commit code
All checks were successful
Gitea Actions Demo / deploy (push) Successful in 11s

This commit is contained in:
2025-11-24 21:45:12 +08:00
parent 3d62df27cd
commit f796a3833b
4 changed files with 463 additions and 2 deletions

194
llm/generate_podcast.py Normal file
View File

@ -0,0 +1,194 @@
import json
from datetime import datetime, timedelta, timezone
import re
from typing import Any, Dict, List, Optional
from openai import OpenAI
from config.settings import settings
from llm import prompt as prompts
from utils.logger import logger
BASE_URL = "https://dashscope.aliyuncs.com/compatible-mode/v1"
MODEL = "deepseek-v3.2-exp"
def _make_client() -> OpenAI:
return OpenAI(api_key=settings.DASHSCOPE_API_KEY, base_url=BASE_URL)
def _call_model(system_prompt: Optional[str], user_prompt: str, stream: bool = False) -> Any:
client = _make_client()
messages = []
if system_prompt:
messages.append({"role": "system", "content": system_prompt})
messages.append({"role": "user", "content": user_prompt})
# Non-streaming call for simplicity
resp = client.chat.completions.create(model=MODEL, messages=messages, stream=stream)
# When stream=False the SDK typically returns a full object; content location may vary.
# We'll try common access patterns.
try:
# OpenAI-compatible: resp.choices[0].message.content
return resp.choices[0].message.content
except Exception:
try:
# fallback: resp.choices[0].text
return resp.choices[0].text
except Exception:
# As last resort, return raw resp
return resp
def _extract_json(text: str) -> str:
"""Attempt to extract the first JSON object/array from text."""
if not isinstance(text, str):
raise ValueError("Expected text to be str")
# Find first '[' or '{'
start_idx = None
for i, ch in enumerate(text):
if ch in "[{":
start_idx = i
break
if start_idx is None:
raise ValueError("No JSON object/array found in text")
# Try to find a matching closing bracket by scanning and counting
stack = []
for j in range(start_idx, len(text)):
ch = text[j]
if ch in "{[":
stack.append(ch)
elif ch in "]}":
if not stack:
continue
opening = stack.pop()
if (opening == "{" and ch != "}") or (opening == "[" and ch != "]"):
# mismatched, continue
continue
if not stack:
return text[start_idx : j + 1]
# Fallback: try regex to capture last '}' or ']' occurrence
m = re.search(r"(\{.*\}|\[.*\])", text, re.S)
if m:
return m.group(1)
raise ValueError("Could not extract JSON from model output")
def _parse_json_safe(text: str) -> Any:
try:
return json.loads(text)
except Exception:
# try to extract JSON substring
jtext = _extract_json(text)
return json.loads(jtext)
def generate_topics(start_time: Optional[str] = None, end_time: Optional[str] = None) -> List[Dict[str, Any]]:
"""Call prompt_a to get a list of candidate meme topics.
If start_time/end_time are provided (YYYY-MM-DD), they will be injected into the prompt
to limit the timeframe the model should scan.
If start_time/end_time are not provided, default to the last 7 days:
end_time = today (UTC, YYYY-MM-DD)
start_time = end_time - 7 days
Both parameters should be strings in YYYY-MM-DD format when provided.
"""
# compute defaults (UTC)
if end_time is None:
end_date = datetime.now(timezone.utc).date()
end_time = end_date.isoformat()
if start_time is None:
start_date = end_date - timedelta(days=7)
start_time = start_date.isoformat()
user_prompt = prompts.prompt_a
# If the prompt contains the literal placeholder, replace it; otherwise append a time line.
if "start_time ~ end_time" in user_prompt:
if start_time is None:
start_time = ""
if end_time is None:
end_time = ""
user_prompt = user_prompt.replace("start_time ~ end_time", f"{start_time} ~ {end_time}")
logger.debug(f"prompt for generate_topics:\n{user_prompt}")
content = _call_model(system_prompt=None, user_prompt=user_prompt)
logger.debug(f"raw output from generate_topics:\n{content}")
if isinstance(content, (dict, list)):
return content
text = content if isinstance(content, str) else str(content)
data = _parse_json_safe(text)
if not isinstance(data, list):
raise ValueError("prompt_a did not return a JSON array")
logger.debug(f"result for generate_topics:\n{data}")
return data
def generate_bits(meme_name: str, research_text: str, prompt_bit: str = prompts.prompt_b) -> Dict[str, Any]:
user_prompt = prompt_bit + f"\n\nmeme_name: {meme_name}\nresearch:\n{research_text}\n"
content = _call_model(system_prompt=None, user_prompt=user_prompt)
text = content if isinstance(content, str) else str(content)
data = _parse_json_safe(text)
return data
def generate_bit(meme_name: str, research_text: str, prompt_bit: str) -> Dict[str, Any]:
user_prompt = prompt_bit + f"\n\nmeme_name: {meme_name}\nresearch:\n{research_text}\n"
content = _call_model(system_prompt=None, user_prompt=user_prompt)
text = content if isinstance(content, str) else str(content)
data = _parse_json_safe(text)
return data
def generate_script(meme_name: str, materials_text: str) -> Dict[str, Any]:
user_prompt = prompts.prompt_c + f"\n\nmeme_name: {meme_name}\nmaterials:\n{materials_text}\n"
content = _call_model(system_prompt=None, user_prompt=user_prompt)
text = content if isinstance(content, str) else str(content)
data = _parse_json_safe(text)
return data
def orchestrate_for_first_topic() -> Dict[str, Any]:
"""High-level orchestration: pick first topic, synthesize research, create bits and final script."""
topics = generate_topics()
if not topics:
raise RuntimeError("No topics returned")
top = topics[0]
meme = top.get("title") or top.get("name") or "未知梗"
# Build a concise research text from topic fields
parts = []
if "summary" in top:
parts.append(f"简介:{top['summary']}")
if "origin" in top:
parts.append(f"可能起源:{top['origin']}")
if "reach_estimate" in top:
parts.append(f"传播估计:{top['reach_estimate']}")
if "angles" in top:
parts.append("角度:" + "; ".join(top.get("angles", [])))
research_text = "\n".join(parts)
bits = generate_bits(meme, research_text)
# Combine materials: human-crafted research + selected bits
materials = research_text + "\n\n" + json.dumps(bits, ensure_ascii=False, indent=2)
script = generate_script(meme, materials)
return {"topic": top, "bits": bits, "script": script}
if __name__ == "__main__":
# quick sanity check when run as script (will call API if keys present)
try:
out = orchestrate_for_first_topic()
print(json.dumps(out, ensure_ascii=False, indent=2))
except Exception as e:
print(f"Error during orchestration: {e}")

113
llm/prompt.py Normal file
View File

@ -0,0 +1,113 @@
prompt_a = """
你是网络文化研究员。请扫描近一周(start_time ~ end_time)中文互联网的热点挑选并输出5个适合做播客主题的“梗”。
输出要求(严格返回 JSON 数组,仅输出 JSON不要额外解释
[
{
"title": "梗名称不超过6字",
"summary": "一句话简述≤30字",
"origin": "可能起源平台或事件1-2项",
"reach_estimate": "传播广度估计(简短量化或描述,如“百万级阅读”/“小范围社群内”)",
"angles": ["值得深挖的文化/社会角度1-3项"],
"debut_time": "首次出现时间精确到日格式YYYY-MM-DD"
},
...
]
每项尽量简明扼要,避免长段落。字段内容中文优先,数值或量级请尽量提供简短量化表述。
"""
prompt_b = """
你是脱口秀编剧。输入两个变量:
- meme_name要写段子的梗名称字符串
- research关于该梗的深度研究文本字符串
根据以上输入创作3篇风格不同的脱口秀段子要求如下并严格返回 JSON 对象(仅输出 JSON
{
"meme": "梗名称",
"bits": [
{"style": "观察生活", "text": "…(口语化,适合朗读,含‘铺垫->笑点结构1000-1200字"},
{"style": "夸张讽刺", "text": "…(夸张视角,含‘铺垫->笑点结构1000-1200字"},
{"style": "角色扮演", "text": "…(以第一人称表演,含‘铺垫->笑点结构1000-1200字"}
]
}
要求:语言口语化、节奏感强,避免书面化长句;每段保留清晰的‘铺垫-笑点’节奏。不要添加额外说明或元信息。
"""
prompt_b1 = """
你是脱口秀编剧。输入两个变量:
- meme_name要写段子的梗名称字符串
- research关于该梗的深度研究文本字符串
根据以上输入创作3篇风格不同的脱口秀段子要求如下并严格返回 JSON 对象(仅输出 JSON
{
"meme": "梗名称",
"style": "观察生活",
"text": "…(口语化,适合朗读,含‘铺垫->笑点结构1000-1200字"
}
要求:语言口语化、节奏感强,避免书面化长句;每段保留清晰的‘铺垫-笑点’节奏。不要添加额外说明或元信息。
"""
prompt_b2 = """
你是脱口秀编剧。输入两个变量:
- meme_name要写段子的梗名称字符串
- research关于该梗的深度研究文本字符串
根据以上输入创作3篇风格不同的脱口秀段子要求如下并严格返回 JSON 对象(仅输出 JSON
{
"meme": "梗名称",
"style": "夸张讽刺",
"text": "…(夸张视角,含‘铺垫->笑点结构1000-1200字"
}
要求:语言口语化、节奏感强,避免书面化长句;每段保留清晰的‘铺垫-笑点’节奏。不要添加额外说明或元信息。
"""
prompt_b3 = """
你是脱口秀编剧。输入两个变量:
- meme_name要写段子的梗名称字符串
- research关于该梗的深度研究文本字符串
根据以上输入创作3篇风格不同的脱口秀段子要求如下并严格返回 JSON 对象(仅输出 JSON
{
"meme": "梗名称",
"style": "角色扮演",
"text": "…(以第一人称表演,含‘铺垫->笑点结构1000-1200字"
}
要求:语言口语化、节奏感强,避免书面化长句;每段保留清晰的‘铺垫-笑点’节奏。不要添加额外说明或元信息。
"""
prompt_c = """
你是播客编剧。输入两个变量:
- meme_name梗名称字符串
- materials包含“深度研究”与若干脱口秀段子的文本字符串已由人工筛选
任务:把 materials 整合成一篇完整的播客文稿,结构严格按照:开场白 -> 梗介绍 -> 起源考据 -> 传播路径 -> 影响分析 -> 脱口秀环节插入2-3个段子 -> 结束语
输出格式(严格 JSON对话按顺序列出角色限定为 host/guest
{
"title": "节目标题建议不超12字",
"script": [
{"role": "host", "text": "开场白口语化20-60字"},
{"role": "host", "text": "梗介绍简明30-80字"},
{"role": "guest", "text": "起源考据40-120字"},
{"role": "host", "text": "传播路径30-80字"},
{"role": "guest", "text": "影响分析40-120字"},
{"role": "host", "text": "转入脱口秀环节的台词15-40字"},
{"role": "guest", "text": "段子A来自 materials1000-1200字"},
{"role": "guest", "text": "段子B来自 materials1000-1200字"},
{"role": "guest", "text": "段子C来自 materials1000-1200字"},
{"role": "host", "text": "结束语15-40字"}
]
}
要求:
- 语言口语化避免书面语角色语气分别为host理性、引导、guest幽默、即兴
- 在 script 中只保留最终可直接朗读的台词,不要加入编剧说明或括注。每段尽量简洁,便于主播读出。
- 严格输出 JSON不要额外解释或多余文本。
"""

View File

@ -9,3 +9,4 @@ apscheduler
fastapi
uvicorn
gunicorn
openai

View File

@ -1,6 +1,159 @@
import json
from llm import prompt
from utils.logger import logger
import datetime
from llm.generate_podcast import generate_topics
from models.script import Script
from config.database import SessionLocal
def job_heartbeat():
logger.info(f"[heartbeat] {datetime.datetime.now()}")
def job_generate_topics():
"""定时任务:搜索上一周的热门梗并保存至数据库。"""
# 1. 调用 LLM 生成热门梗列表
topics = generate_topics()
content = {"topics": topics}
if not topics:
logger.warning("No topics generated.")
return
# 2. 构建 Script 实例
# subject 以当前日期为准,格式 YYYY-MM-DD
today_str = datetime.datetime.now().strftime("%Y-%m-%d")
db = SessionLocal()
try:
# 查询是否已存在 project+subject 唯一记录
script = db.query(Script).filter_by(project="梗文化研究所", subject=today_str).first()
if script:
# 存在则更新内容
script.content = json.dumps(content, ensure_ascii=False, indent=2)
db.commit()
logger.info(f"Updated script for {today_str} with {len(topics)} topics.")
else:
# 不存在则新建
script = Script(
project="梗文化研究所",
subject=today_str,
content=json.dumps(content, ensure_ascii=False, indent=2)
)
db.add(script)
db.commit()
logger.info(f"Saved script for {today_str} with {len(topics)} topics.")
except Exception as e:
db.rollback()
logger.error(f"Failed to save/update script for {today_str}: {e}")
def job_generate_bits():
"""定时任务:为最新梗生成脱口秀段子并保存至数据库。"""
db = SessionLocal()
try:
# 获取最新的 Script 记录
script = db.query(Script).filter_by(project="梗文化研究所").order_by(Script.create_time.desc()).first()
if not script or not script.content:
logger.warning("No script found for generating bits.")
return
data = json.loads(script.content)
topics = data.get("topics", [])
if not topics:
logger.warning("No topics in the latest script.")
return
# 仅处理第一个梗
top = topics[0]
meme_name = top.get("title") or top.get("name") or "未知梗"
# 构建研究文本
parts = []
if "summary" in top:
parts.append(f"简介:{top['summary']}")
if "origin" in top:
parts.append(f"可能起源:{top['origin']}")
if "reach_estimate" in top:
parts.append(f"传播估计:{top['reach_estimate']}")
if "angles" in top:
parts.append("角度:" + "; ".join(top.get("angles", [])))
research_text = "\n".join(parts)
bits = []
# 调用 LLM 生成段子
from llm.generate_podcast import generate_bit
bit = generate_bit(meme_name, research_text, prompt.prompt_b1)
logger.debug(f"Generated bits for meme '{meme_name}': {bit}")
bits.append(bit)
bit = generate_bit(meme_name, research_text, prompt.prompt_b2)
logger.debug(f"Generated bits for meme '{meme_name}': {bit}")
bits.append(bit)
bit = generate_bit(meme_name, research_text, prompt.prompt_b3)
logger.debug(f"Generated bits for meme '{meme_name}': {bit}")
bits.append(bit)
content = {"topics": topics, "bits": bits}
script.content = json.dumps(content, ensure_ascii=False, indent=2)
db.commit()
logger.info(f"Saved bits for meme '{meme_name}' with {len(bits)} segments.")
except Exception as e:
db.rollback()
logger.error(f"Failed to generate/save bits: {e}")
def job_generate_script():
"""定时任务:为最新梗生成完整脱口秀脚本并保存至数据库。"""
logger.debug("Starting job_generate_script")
db = SessionLocal()
try:
# 获取最新的 Script 记录
script = db.query(Script).filter_by(project="梗文化研究所").order_by(Script.create_time.desc()).first()
if not script or not script.content:
logger.warning("No script found for generating full script.")
return
data = json.loads(script.content)
topics = data.get("topics", [])
bits = data.get("bits", [])
if not topics:
logger.warning("No topics in the latest script.")
return
if not bits:
logger.warning("No bits in the latest script.")
return
# 仅处理第一个梗
top = topics[0]
meme_name = top.get("title") or top.get("name") or "未知梗"
logger.debug(f"Generating full script for meme '{meme_name}'")
# 构建材料文本
parts = []
if "summary" in top:
parts.append(f"简介:{top['summary']}")
if "origin" in top:
parts.append(f"可能起源:{top['origin']}")
if "reach_estimate" in top:
parts.append(f"传播估计:{top['reach_estimate']}")
if "angles" in top:
parts.append("角度:" + "; ".join(top.get("angles", [])))
research_text = "\n".join(parts)
materials_text = research_text + "\n\n" + json.dumps(bits, ensure_ascii=False, indent=2)
# 调用 LLM 生成完整脚本
from llm.generate_podcast import generate_script
full_script = generate_script(meme_name, materials_text)
content = {"topics": topics, "bits": bits, "script": full_script}
script.content = json.dumps(content, ensure_ascii=False, indent=2)
db.commit()
logger.info(f"Saved full script for meme '{meme_name}'.")
except Exception as e:
db.rollback()
logger.error(f"Failed to generate/save full script: {e}")
# For manual testing
if __name__ == "__main__":
# job_generate_topics()
# job_generate_bits()
job_generate_script()