step3_generate_inspirations.py 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573
  1. """
  2. Step3: 基于匹配节点生成灵感点
  3. 基于 Step1 的 Top1 匹配结果,以匹配到的人设要素作为锚点,
  4. 让 Agent 分析可以产生哪些灵感点
  5. """
  6. import os
  7. import sys
  8. import json
  9. import asyncio
  10. from pathlib import Path
  11. from agents import Agent, Runner, trace
  12. from agents.tracing.create import custom_span
  13. from lib.my_trace import set_trace_smith as set_trace
  14. from lib.client import get_model
  15. from lib.data_loader import load_persona_data, load_inspiration_list, select_inspiration
  16. # 模型配置
  17. MODEL_NAME = "google/gemini-2.5-pro"
  18. # ========== System Prompt ==========
  19. GENERATE_INSPIRATIONS_PROMPT = """
  20. # 任务
  21. 你是一个内容创作者,现在要从一个锚点分类出发,通过思维路径推导出可能触发创作冲动的客观刺激源(灵感点)。
  22. ## 核心概念
  23. **分类**(维度):创作者接收外界信息刺激的角度或通道
  24. - 格式:2-4个字,简洁直观
  25. **灵感点**:创作前遇到的、触发创作冲动的客观刺激源
  26. - 本质:作者被动接收的信息(看到的、听说的、发现的、观察到的、感知到的)
  27. - 格式:不超过15个字,使用自然、通俗、口语化的表达
  28. - 表达要求:
  29. * 使用日常生活语言,避免学术化、抽象化词汇堆砌
  30. * 优先使用"的"字短语(如"夏日的热闹景象")或动宾短语(如"观察到的自然互动")
  31. * 禁止使用多个抽象名词连用(如"具象化动态互动自然拟人")
  32. * 让普通人一看就懂
  33. **描述**:对刺激源本身是什么的详细说明
  34. - 描述刺激源的具体特征、形态、场景、内容等客观信息
  35. - 注意区分:刺激源内容本身 vs 呈现方式/表现形式
  36. **推理路径**:展示从锚点分类到灵感点的推导过程
  37. - 格式:`[锚点分类] → [思维方向] → [联想节点] → [灵感点]`
  38. - 思维方向:从锚点出发的联想角度(如:具体场景、情感延伸、时间维度、反差对比等)
  39. - 联想节点:人设体系中的相关节点,或具体的联想内容
  40. ## 严格禁止
  41. - 不描述创作者如何运用/展现/表达刺激,不使用推理性词汇
  42. - 不能是创作形式、表现手法、表达方式、呈现方式、风格、格式等
  43. - 必须是被动接收的刺激,不能是主动创造的内容
  44. - 不解释创作者为什么被触发、如何使用
  45. - 不进行主观推理和价值判断
  46. - 禁止词汇堆砌
  47. ## 输入说明
  48. - **<人设体系></人设体系>**: 完整的人设系统,包含所有可用节点
  49. - **<锚点分类></锚点分类>**: 作为起点的分类维度(接收刺激的角度)
  50. - **<分类定义></分类定义>**: 该分类的完整定义
  51. - **<分类上下文></分类上下文>**: 该分类的上下文信息
  52. ## 推导方法
  53. 从锚点分类出发,通过思维路径推导灵感点:
  54. 1. **确定锚点**:锚点分类是什么?
  55. 2. **选择思维方向**:从这个分类可以往哪个方向联想?
  56. 3. **找到联想节点**:结合人设体系,这个方向上有哪些相关节点或内容?
  57. 4. **得出灵感点**:这些联想最终指向什么具体的客观刺激?
  58. ## 输出格式(严格JSON)
  59. **重要:必须输出严格的 JSON 格式,注意以下几点:**
  60. - 使用英文双引号 `"` 而非中文引号 `""`
  61. - 字段值中如果包含引号,必须转义 `\"`
  62. - 不要在最后一个元素后添加逗号
  63. - 确保所有括号正确闭合
  64. - 描述内容不要换行,保持在一行内
  65. ```json
  66. {
  67. "灵感点列表": [
  68. {
  69. "推理路径": "[锚点分类] → [思维方向] → [联想节点] → [灵感点]",
  70. "灵感点": "具体的客观刺激源描述(不超过15字,口语化)",
  71. "描述": "对这个刺激源本身是什么的详细说明,描述其具体特征、形态、场景、内容等客观信息(不换行,一句话)"
  72. }
  73. ]
  74. }
  75. ```
  76. **要求**:
  77. 1. 生成 8-15 个灵感点
  78. 2. 每个灵感点必须是客观刺激源,不能是创作手法
  79. 3. "推理路径"字段:清晰展示推导过程
  80. 4. "灵感点"字段:简洁口语化,不超过15字
  81. 5. "描述"字段:客观描述刺激源本身,不涉及如何运用,不换行
  82. 6. 字段值避免使用特殊字符(如未转义的引号、换行符等)
  83. 7. 必须输出完整有效的 JSON,可以直接被解析器读取
  84. """.strip()
  85. def create_agent(model_name: str, prompt: str, name: str) -> Agent:
  86. """创建 Agent
  87. Args:
  88. model_name: 模型名称
  89. prompt: System prompt
  90. name: Agent 名称
  91. Returns:
  92. Agent 实例
  93. """
  94. agent = Agent(
  95. name=name,
  96. instructions=prompt,
  97. model=get_model(model_name),
  98. tools=[],
  99. )
  100. return agent
  101. def parse_json_response(response_content: str, default_value: dict = None) -> dict:
  102. """解析 JSON 响应
  103. Args:
  104. response_content: Agent 返回的响应内容
  105. default_value: 解析失败时的默认返回值
  106. Returns:
  107. 解析后的字典
  108. """
  109. import re
  110. # 提取 JSON 文本
  111. def extract_json_text(content):
  112. if "```json" in content:
  113. json_start = content.index("```json") + 7
  114. # 查找下一个 ``` 或 ``` 后的内容结束
  115. try:
  116. json_end = content.index("```", json_start)
  117. except ValueError:
  118. # 如果找不到结束标记,取到末尾
  119. json_end = len(content)
  120. return content[json_start:json_end].strip()
  121. elif "```" in content:
  122. json_start = content.index("```") + 3
  123. try:
  124. json_end = content.index("```", json_start)
  125. except ValueError:
  126. json_end = len(content)
  127. return content[json_start:json_end].strip()
  128. else:
  129. return content.strip()
  130. json_text = extract_json_text(response_content)
  131. # 尝试1: 直接解析
  132. try:
  133. return json.loads(json_text)
  134. except json.JSONDecodeError as e:
  135. print(f"\n⚠️ JSON 解析失败(尝试1),开始修复...")
  136. print(f" 错误: {e}\n")
  137. # 尝试2: 修复常见问题
  138. try:
  139. # 修复1: 去除尾部逗号
  140. fixed = re.sub(r',(\s*[}\]])', r'\1', json_text)
  141. # 修复2: 处理未完成的JSON(截断问题)
  142. # 如果JSON被截断了,尝试补全
  143. if fixed.count('{') > fixed.count('}'):
  144. # 补充缺失的闭合括号
  145. diff = fixed.count('{') - fixed.count('}')
  146. fixed += '\n' + ' }'*diff
  147. if fixed.count('[') > fixed.count(']'):
  148. diff = fixed.count('[') - fixed.count(']')
  149. fixed += '\n' + ' ]'*diff
  150. # 修复3: 去除未完成的最后一项
  151. # 如果最后一项没有闭合,移除它
  152. lines = fixed.split('\n')
  153. # 倒序查找最后一个完整的对象
  154. bracket_count = 0
  155. last_complete_idx = len(lines)
  156. for i in range(len(lines) - 1, -1, -1):
  157. line = lines[i]
  158. bracket_count += line.count('}') - line.count('{')
  159. bracket_count += line.count(']') - line.count('[')
  160. if bracket_count == 0 and ('}' in line or ']' in line):
  161. last_complete_idx = i + 1
  162. break
  163. if last_complete_idx < len(lines):
  164. print(f" 检测到未完成的内容,截断到第 {last_complete_idx} 行")
  165. fixed = '\n'.join(lines[:last_complete_idx])
  166. result = json.loads(fixed)
  167. print(f"✓ JSON 修复成功\n")
  168. return result
  169. except Exception as fix_error:
  170. print(f" 修复失败: {fix_error}\n")
  171. # 最终失败,返回默认值
  172. print(f"\n{'!' * 80}")
  173. print(f"⚠️ 所有尝试均失败,返回空结果")
  174. print(f"{'!' * 80}")
  175. print(f"\n原始响应内容:\n")
  176. print(response_content[:3000])
  177. print(f"\n{'!' * 80}\n")
  178. return default_value if default_value else {}
  179. def format_persona_system(persona_data: dict) -> str:
  180. """格式化完整人设系统为文本
  181. Args:
  182. persona_data: 人设数据
  183. Returns:
  184. 格式化的人设系统文本
  185. """
  186. lines = ["# 人设系统"]
  187. # 处理三个部分:灵感点列表、目的点、关键点列表
  188. for section_key, section_title in [
  189. ("灵感点列表", "【灵感点】灵感的来源和性质"),
  190. ("目的点", "【目的点】创作的目的和价值导向"),
  191. ("关键点列表", "【关键点】内容的核心主体和表达方式")
  192. ]:
  193. section_data = persona_data.get(section_key, [])
  194. if not section_data:
  195. continue
  196. lines.append(f"\n## {section_title}\n")
  197. for perspective in section_data:
  198. perspective_name = perspective.get("视角名称", "")
  199. lines.append(f"\n### 视角:{perspective_name}")
  200. for pattern in perspective.get("模式列表", []):
  201. pattern_name = pattern.get("分类名称", "")
  202. pattern_def = pattern.get("核心定义", "")
  203. lines.append(f"\n 【一级】{pattern_name}")
  204. if pattern_def:
  205. lines.append(f" 定义:{pattern_def}")
  206. # 二级细分
  207. for sub in pattern.get("二级细分", []):
  208. sub_name = sub.get("分类名称", "")
  209. sub_def = sub.get("分类定义", "")
  210. lines.append(f" 【二级】{sub_name}:{sub_def}")
  211. return "\n".join(lines)
  212. def find_element_definition(persona_data: dict, element_name: str) -> str:
  213. """从人设数据中查找要素的定义
  214. Args:
  215. persona_data: 人设数据
  216. element_name: 要素名称
  217. Returns:
  218. 要素定义文本,如果未找到则返回空字符串
  219. """
  220. # 在灵感点列表中查找
  221. for section_key in ["灵感点列表", "目的点", "关键点列表"]:
  222. section_data = persona_data.get(section_key, [])
  223. for perspective in section_data:
  224. for pattern in perspective.get("模式列表", []):
  225. # 检查一级分类
  226. if pattern.get("分类名称", "") == element_name:
  227. definition = pattern.get("核心定义", "")
  228. if definition:
  229. return definition
  230. # 检查二级分类
  231. for sub in pattern.get("二级细分", []):
  232. if sub.get("分类名称", "") == element_name:
  233. return sub.get("分类定义", "")
  234. return ""
  235. def find_step1_file(persona_dir: str, inspiration: str, model_name: str) -> str:
  236. """查找 step1 输出文件
  237. Args:
  238. persona_dir: 人设目录
  239. inspiration: 灵感点名称
  240. model_name: 模型名称
  241. Returns:
  242. step1 文件路径
  243. Raises:
  244. SystemExit: 找不到文件时退出
  245. """
  246. step1_dir = os.path.join(persona_dir, "how", "灵感点", inspiration)
  247. model_name_short = model_name.replace("google/", "").replace("/", "_")
  248. step1_file_pattern = f"*_step1_*_{model_name_short}.json"
  249. step1_files = list(Path(step1_dir).glob(step1_file_pattern))
  250. if not step1_files:
  251. print(f"❌ 找不到 step1 输出文件")
  252. print(f"查找路径: {step1_dir}/{step1_file_pattern}")
  253. sys.exit(1)
  254. return str(step1_files[0])
  255. async def generate_inspirations_with_paths(
  256. persona_system_text: str,
  257. anchor_category: str,
  258. category_definition: str,
  259. category_context: str
  260. ) -> list:
  261. """从锚点分类推导灵感点列表
  262. Args:
  263. persona_system_text: 完整人设系统文本
  264. anchor_category: 锚点分类(维度)
  265. category_definition: 分类定义
  266. category_context: 分类上下文
  267. Returns:
  268. 灵感点列表 [{"分类": "...", "灵感点": "...", "描述": "...", "推理": "..."}, ...]
  269. """
  270. task_description = f"""## 本次任务
  271. <人设体系>
  272. {persona_system_text}
  273. </人设体系>
  274. <锚点分类>
  275. {anchor_category}
  276. </锚点分类>
  277. <分类定义>
  278. {category_definition if category_definition else '无'}
  279. </分类定义>
  280. <分类上下文>
  281. {category_context}
  282. </分类上下文>
  283. 请从锚点分类出发,推导出可能触发创作冲动的客观刺激源(灵感点),严格按照 JSON 格式输出。"""
  284. messages = [{
  285. "role": "user",
  286. "content": [{"type": "input_text", "text": task_description}]
  287. }]
  288. agent = create_agent(MODEL_NAME, GENERATE_INSPIRATIONS_PROMPT, "Inspiration Path Generator")
  289. result = await Runner.run(agent, input=messages)
  290. parsed = parse_json_response(result.final_output, {"灵感点列表": []})
  291. return parsed.get("灵感点列表", [])
  292. async def process_step3_generate_inspirations(
  293. step1_top1: dict,
  294. persona_data: dict,
  295. current_time: str = None,
  296. log_url: str = None
  297. ) -> dict:
  298. """执行灵感生成分析(核心业务逻辑 - 从锚点分类推导灵感点)
  299. Args:
  300. step1_top1: step1 的 top1 匹配结果
  301. persona_data: 完整的人设数据
  302. current_time: 当前时间戳
  303. log_url: trace URL
  304. Returns:
  305. 生成结果字典
  306. """
  307. # 从 step1 结果中提取信息
  308. business_info = step1_top1.get("业务信息", {})
  309. input_info = step1_top1.get("输入信息", {})
  310. anchor_category = business_info.get("匹配要素名称", "")
  311. category_context = input_info.get("A_Context", "")
  312. # 格式化人设系统
  313. persona_system_text = format_persona_system(persona_data)
  314. # 查找分类定义
  315. category_definition = find_element_definition(persona_data, anchor_category)
  316. print(f"\n{'=' * 80}")
  317. print(f"Step3: 从锚点分类推导灵感点")
  318. print(f"{'=' * 80}")
  319. print(f"锚点分类: {anchor_category}")
  320. print(f"分类定义: {category_definition if category_definition else '(未找到定义)'}")
  321. print(f"模型: {MODEL_NAME}\n")
  322. # 生成灵感点
  323. with custom_span(name="从锚点分类推导灵感点", data={"锚点分类": anchor_category}):
  324. inspirations = await generate_inspirations_with_paths(
  325. persona_system_text, anchor_category, category_definition, category_context
  326. )
  327. print(f"\n{'=' * 80}")
  328. print(f"完成!共生成 {len(inspirations)} 个灵感点")
  329. print(f"{'=' * 80}\n")
  330. # 预览前3个
  331. if inspirations:
  332. print("预览前3个灵感点:")
  333. for i, item in enumerate(inspirations[:3], 1):
  334. print(f" {i}. 推理路径: {item.get('推理路径', '')}")
  335. print(f" 灵感点: {item.get('灵感点', '')} ({len(item.get('灵感点', ''))}字)")
  336. print(f" 描述: {item.get('描述', '')[:60]}...")
  337. print()
  338. # 构建输出
  339. return {
  340. "元数据": {
  341. "current_time": current_time,
  342. "log_url": log_url,
  343. "model": MODEL_NAME,
  344. "步骤": "Step3: 从锚点分类推导灵感点"
  345. },
  346. "锚点信息": {
  347. "锚点分类": anchor_category,
  348. "分类定义": category_definition if category_definition else "无",
  349. "分类上下文": category_context
  350. },
  351. "step1_结果": step1_top1,
  352. "灵感点列表": inspirations
  353. }
  354. async def main(current_time: str, log_url: str, force: bool = False):
  355. """主函数
  356. Args:
  357. current_time: 当前时间戳
  358. log_url: 日志链接
  359. force: 是否强制重新执行(跳过已存在文件检查)
  360. """
  361. # 解析命令行参数
  362. persona_dir = sys.argv[1] if len(sys.argv) > 1 else "data/阿里多多酱/out/人设_1110"
  363. inspiration_arg = sys.argv[2] if len(sys.argv) > 2 else "0"
  364. # 第三个参数:force(如果从命令行调用且有该参数,则覆盖函数参数)
  365. if len(sys.argv) > 3 and sys.argv[3] == "force":
  366. force = True
  367. print(f"{'=' * 80}")
  368. print(f"Step3: 从锚点分类推导灵感点")
  369. print(f"{'=' * 80}")
  370. print(f"人设目录: {persona_dir}")
  371. print(f"灵感参数: {inspiration_arg}")
  372. # 加载数据
  373. persona_data = load_persona_data(persona_dir)
  374. inspiration_list = load_inspiration_list(persona_dir)
  375. # 选择灵感
  376. try:
  377. inspiration_index = int(inspiration_arg)
  378. if 0 <= inspiration_index < len(inspiration_list):
  379. test_inspiration = inspiration_list[inspiration_index]
  380. print(f"使用灵感[{inspiration_index}]: {test_inspiration}")
  381. else:
  382. print(f"❌ 灵感索引超出范围: {inspiration_index}")
  383. sys.exit(1)
  384. except ValueError:
  385. if inspiration_arg in inspiration_list:
  386. test_inspiration = inspiration_arg
  387. print(f"使用灵感: {test_inspiration}")
  388. else:
  389. print(f"❌ 找不到灵感: {inspiration_arg}")
  390. sys.exit(1)
  391. # 查找并加载 step1 结果
  392. step1_file = find_step1_file(persona_dir, test_inspiration, MODEL_NAME)
  393. step1_filename = os.path.basename(step1_file)
  394. step1_basename = os.path.splitext(step1_filename)[0]
  395. print(f"Step1 输入文件: {step1_file}")
  396. # 构建输出文件路径
  397. output_dir = os.path.join(persona_dir, "how", "灵感点", test_inspiration)
  398. model_name_short = MODEL_NAME.replace("google/", "").replace("/", "_")
  399. scope_prefix = step1_basename.split("_")[0]
  400. result_index = 0
  401. output_filename = f"{scope_prefix}_step3_top{result_index + 1}_生成灵感_{model_name_short}.json"
  402. output_file = os.path.join(output_dir, output_filename)
  403. # 检查文件是否已存在
  404. if not force and os.path.exists(output_file):
  405. print(f"\n✓ 输出文件已存在,跳过执行: {output_file}")
  406. print(f"提示: 如需重新执行,请添加 'force' 参数\n")
  407. return
  408. with open(step1_file, 'r', encoding='utf-8') as f:
  409. step1_data = json.load(f)
  410. actual_inspiration = step1_data.get("灵感", "")
  411. step1_results = step1_data.get("匹配结果列表", [])
  412. if not step1_results:
  413. print("❌ step1 结果为空")
  414. sys.exit(1)
  415. print(f"灵感: {actual_inspiration}")
  416. # 默认处理 top1
  417. selected_result = step1_results[result_index]
  418. print(f"处理第 {result_index + 1} 个匹配结果(Top{result_index + 1})\n")
  419. # 执行核心业务逻辑
  420. output = await process_step3_generate_inspirations(
  421. step1_top1=selected_result,
  422. persona_data=persona_data,
  423. current_time=current_time,
  424. log_url=log_url
  425. )
  426. # 在元数据中添加 step1 匹配索引
  427. output["元数据"]["step1_匹配索引"] = result_index + 1
  428. # 保存结果
  429. os.makedirs(output_dir, exist_ok=True)
  430. with open(output_file, 'w', encoding='utf-8') as f:
  431. json.dump(output, f, ensure_ascii=False, indent=2)
  432. # 输出统计信息
  433. inspirations_list = output.get("灵感点列表", [])
  434. print(f"\n{'=' * 80}")
  435. print(f"统计信息:")
  436. print(f" 生成灵感点数量: {len(inspirations_list)}")
  437. # 统计字段完整性
  438. complete_count = sum(
  439. 1 for item in inspirations_list
  440. if all(key in item and item[key] for key in ["推理路径", "灵感点", "描述"])
  441. )
  442. print(f" 字段完整的灵感点: {complete_count}/{len(inspirations_list)}")
  443. # 统计灵感点字数
  444. lengths = [len(item.get("灵感点", "")) for item in inspirations_list if item.get("灵感点")]
  445. if lengths:
  446. avg_length = sum(lengths) / len(lengths)
  447. max_length = max(lengths)
  448. over_15 = sum(1 for l in lengths if l > 15)
  449. print(f" 灵感点字数: 平均 {avg_length:.1f}字, 最长 {max_length}字")
  450. if over_15 > 0:
  451. print(f" ⚠️ 超过15字的灵感点: {over_15}个")
  452. print(f"{'=' * 80}")
  453. print(f"\n完成!结果已保存到: {output_file}")
  454. if log_url:
  455. print(f"Trace: {log_url}\n")
  456. if __name__ == "__main__":
  457. # 设置 trace
  458. current_time, log_url = set_trace()
  459. # 使用 trace 上下文包裹整个执行流程
  460. with trace("Step3: 生成灵感点"):
  461. asyncio.run(main(current_time, log_url))