script_understanding_agent.py 39 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076
  1. #!/usr/bin/env python
  2. # -*- coding: utf-8 -*-
  3. """
  4. 脚本理解Agent
  5. 功能: 脚本理解与分析(创作者视角)
  6. - step1: Section划分 - 分析已有帖子的分段结构,理解创作者是如何组织内容的(树状结构输出)
  7. - step2: 元素列表生成 - 从已有帖子内容中提取元素列表(树状结构输出)
  8. """
  9. from typing import List
  10. from src.components.agents.base import BaseLLMAgent
  11. from src.utils.logger import get_logger
  12. from src.utils.llm_invoker import LLMInvoker
  13. logger = get_logger(__name__)
  14. class ScriptUnderstandingAgent(BaseLLMAgent):
  15. """脚本理解Agent - 分析已有帖子的脚本结构
  16. """
  17. def __init__(
  18. self,
  19. name: str = "script_understanding_agent",
  20. description: str = "脚本理解Agent - 分析已有帖子的脚本结构(创作者视角)",
  21. model_provider: str = "google_genai",
  22. temperature: float = 0.1,
  23. max_tokens: int = 40960
  24. ):
  25. """初始化脚本理解Agent
  26. """
  27. system_prompt = self._build_system_prompt()
  28. super().__init__(
  29. name=name,
  30. description=description,
  31. model_provider=model_provider,
  32. system_prompt=system_prompt,
  33. temperature=temperature,
  34. max_tokens=max_tokens
  35. )
  36. def _build_system_prompt(self) -> str:
  37. """构建系统提示词"""
  38. return """你是脚本结构分析专家,擅长从创作者视角理解内容的分段结构。
  39. # 核心能力
  40. 1. 结构识别:分析已有帖子内容,识别创作者的分段逻辑
  41. 2. 树状输出:通过children字段表示层级关系,不使用parent_id
  42. # 工作原则
  43. - step1(Section划分):识别创作者如何分段组织内容(树状结构)
  44. - step2(元素提取):提取具体元素列表(树状结构)"""
  45. def step1(self, text_data: dict, images: List[str], topic_description: dict, content_weight: dict = None) -> dict:
  46. """第一步:Section划分
  47. 分析已有帖子的内容,识别创作者是如何分段组织内容的
  48. """
  49. if not self.is_initialized:
  50. self.initialize()
  51. logger.info("【Step1】Section划分 - 分析帖子的分段结构")
  52. # 提取权重信息
  53. weight_info = ""
  54. strategy_guidance = ""
  55. if content_weight:
  56. image_weight = content_weight.get("图片权重", 5)
  57. text_weight = content_weight.get("文字权重", 5)
  58. primary_source = content_weight.get("主要信息源", "both")
  59. content_relationship = content_weight.get("图文关系", "相关")
  60. weight_info = f"""
  61. # 图文权重信息
  62. - 图片权重: {image_weight}/10
  63. - 文字权重: {text_weight}/10
  64. - 主要信息源: {primary_source}
  65. - 图文关系: {content_relationship}
  66. """
  67. # 根据权重和关系确定策略指导
  68. weight_diff = image_weight - text_weight
  69. # 确定策略类型和验证要求
  70. if weight_diff >= 3:
  71. strategy_type = "以图为主"
  72. strategy_guidance = """
  73. ## 图文策略: 以图为主
  74. **核心原则**: 用图划分段落,图与图之间有顺序,文可以乱序对应到段落
  75. **Section切分要点**:
  76. - 以图片序列为核心主线进行划分
  77. - **图片编号必须保持严格连续性**
  78. - 文字可以灵活对应到图片
  79. - 关注图片之间的关系、图片内容的语义分段、叙事逻辑
  80. """
  81. validation_guidance = """
  82. **验证内容**:
  83. 1. ✅ **图片连续性验证**(核心验证)
  84. - 检查每个Section内的图片编号是否严格连续
  85. - 方法:逐个检查图片编号,确保无跳跃
  86. 2. ✅ **图片语义关系验证**
  87. - 同一Section内的图片是否有语义关联
  88. - 图片之间的过渡是否自然合理
  89. 3. ❌ **不验证文字顺序**
  90. - 文字可以灵活对应到图片
  91. **禁止行为**:
  92. - ❌ 跨段落跳跃式引用图片
  93. - ❌ 打乱图片的原始顺序
  94. **验证流程**:
  95. - Step1: 提取每个Section的图片编号列表
  96. - Step2: 检查图片编号是否连续
  97. - Step3: 如发现跳跃,重新调整Section划分
  98. """
  99. elif weight_diff <= -3:
  100. strategy_type = "以文为主"
  101. strategy_guidance = """
  102. ## 图文策略: 以文为主
  103. **核心原则**: 用文划分段落,文与文之间有顺序,图可以乱序对应到段落
  104. **Section切分要点**:
  105. - 以文字段落为核心主线进行划分
  106. - **文字必须保持原始顺序**
  107. - 图片可以灵活对应到文字
  108. - 关注文字的话题转换、逻辑推进、情绪变化
  109. """
  110. validation_guidance = """
  111. **验证内容**:
  112. 1. ✅ **文字顺序验证**(核心验证)
  113. - 检查每个Section内的文字是否保持原始顺序
  114. - 段落之间的文字流转是否自然连贯
  115. 2. ✅ **文字逻辑性验证**
  116. - 同一Section内的文字是否有逻辑关联
  117. - 话题转换是否合理
  118. 3. ❌ **不验证图片顺序**
  119. - 图片可以灵活对应到文字
  120. **禁止行为**:
  121. - ❌ 打乱文字的原始顺序
  122. - ❌ 跨段落错乱文字位置
  123. **验证流程**:
  124. - Step1: 提取每个Section的文字内容
  125. - Step2: 按原文顺序检查文字是否连贯
  126. - Step3: 如发现乱序,重新调整Section划分
  127. """
  128. else:
  129. # 策略3/4: 图文同权
  130. if content_relationship == "相关":
  131. strategy_type = "图文同权-图文相关"
  132. strategy_guidance = """
  133. ## 图文策略: 图文同权-图文相关
  134. **核心原则**: 根据消费者吸引力,判断图/文为主,另一个为辅
  135. **Section切分要点**:
  136. - 综合图文信息进行Section划分
  137. - 根据图片排版质量、文字表达清晰度等判断主导维度
  138. - 保持图文的对应关系,相互解释
  139. - 关注图文相互补充的部分、哪个维度的表达更完整
  140. """
  141. validation_guidance = """
  142. **前置步骤**:
  143. - 先判断主导维度(图为主 or 文为主)
  144. - 明确记录判断依据
  145. **验证内容**:
  146. 1. ✅ **主导维度顺序验证**(核心验证)
  147. - 如果图为主:验证图片序列的严格连续性
  148. - 如果文为主:验证文字段落的逻辑性
  149. 2. ✅ **图文对应关系验证**
  150. - 检查图文之间的对应关系是否合理
  151. - 图文是否相互解释、相互补充
  152. 3. ✅ **辅助维度合理性验证**
  153. - 辅助维度是否支撑主导维度
  154. **禁止行为**:
  155. - ❌ 主导维度出现顺序跳跃或错乱
  156. - ❌ 图文对应关系混乱
  157. **验证流程**:
  158. - Step1: 判断并记录主导维度
  159. - Step2: 针对主导维度执行严格顺序验证
  160. - Step3: 验证图文对应关系
  161. - Step4: 如发现问题,重新调整Section划分
  162. """
  163. else:
  164. strategy_type = "图文同权-图文不相关"
  165. strategy_guidance = """
  166. ## 图文策略: 图文同权-图文不相关
  167. **核心原则**: 根据创作者目的,判断只关注图还是只关注文
  168. **Section切分要点**:
  169. - 明确选择关注图或关注文,不能同时关注
  170. - 分析创作者的主要表达意图
  171. - 选定维度后,完全基于该维度进行Section划分
  172. - 关注创作者想重点传达什么、解构的目标是什么
  173. """
  174. validation_guidance = """
  175. **前置步骤**:
  176. - 先判断选择关注的维度(图 or 文)
  177. - 明确记录选择依据
  178. **验证内容**:
  179. 1. ✅ **选定维度顺序验证**(核心验证)
  180. - 如果关注图:验证图片序列的严格连续性
  181. - 如果关注文:验证文字段落的逻辑性
  182. 2. ✅ **单一维度一致性验证**
  183. - 确认所有Section都基于选定的单一维度划分
  184. 3. ❌ **不验证未选择的维度**
  185. - 未选择的维度可以完全忽略
  186. **禁止行为**:
  187. - ❌ 选定维度出现顺序跳跃或错乱
  188. - ❌ 同时关注图和文进行Section划分
  189. **验证流程**:
  190. - Step1: 判断并记录关注的维度
  191. - Step2: 针对选定维度执行严格顺序验证
  192. - Step3: 确认未使用另一维度进行划分
  193. - Step4: 如发现问题,重新调整Section划分
  194. """
  195. logger.info(f"使用图文策略: {strategy_type} - 图片:{image_weight}, 文字:{text_weight}, 关系:{content_relationship}")
  196. else:
  197. strategy_type = "未指定"
  198. validation_guidance = ""
  199. logger.info("未提供图文权重信息")
  200. # 构建帖子内容
  201. post_content_parts = []
  202. if text_data.get("title"):
  203. post_content_parts.append(f"标题: {text_data['title']}")
  204. if text_data.get("body"):
  205. post_content_parts.append(f"正文: {text_data['body']}")
  206. post_content = "\n".join(post_content_parts) if post_content_parts else "无文本信息"
  207. # 构建选题描述文本
  208. topic_parts = []
  209. if topic_description.get("主题"):
  210. topic_parts.append(f"主题: {topic_description['主题']}")
  211. if topic_description.get("描述"):
  212. topic_parts.append(f"描述: {topic_description['描述']}")
  213. topic_text = "\n".join(topic_parts) if topic_parts else "无选题描述"
  214. # 构建prompt
  215. prompt = f"""{weight_info}
  216. {strategy_guidance}
  217. # 帖子内容
  218. {post_content}
  219. # 选题描述
  220. {topic_text}
  221. # 任务
  222. 从**创作者视角**分析这个已有帖子是如何组织内容的。
  223. ## Section切分流程
  224. **第一步:应用图文策略**
  225. 根据上述图文策略指导,确定Section切分的主导维度和具体方法。
  226. **第二步:识别主题显著变化位置**
  227. 扫描全部内容,识别**主题发生显著变化**的位置:
  228. - **判断标准**:
  229. * 语义跃迁: 讨论对象发生根本性改变
  230. * 逻辑转换: 从"是什么"转向"为什么"或"怎么办"
  231. * 功能变化: 从"问题陈述"转向"解决方案"
  232. - **划分原则**:
  233. * 避免过度细分(每个小变化都成为顶层段落)
  234. * 避免过度粗放(将所有内容合并为1个顶层段落)
  235. * 以"主题板块"而非"内容单元"为划分粒度
  236. **第三步:初步划分**
  237. - 按照策略要点确定核心驱动
  238. - 基于主题显著变化位置进行划分
  239. - 支持主Section和子Section的层级结构
  240. **第四步:顺序验证与反思**
  241. {validation_guidance}
  242. ## 层级要求
  243. **段落必须至少保留2层结构**:
  244. 1. **第1层(抽象层)**:从具象中聚合出的共性维度
  245. 2. **第2层(具象层)**:具体的内容细节
  246. **层级关系说明**:
  247. - 抽象层是对多个具象内容的归纳和提炼
  248. - 具象层是抽象层的具体展开
  249. - 每个抽象层下必须有至少1个具象层子项
  250. ## Section字段
  251. - 描述: 段落描述(共性维度名称;具体内容概括)
  252. - 内容范围: **列表格式,包含具体内容**
  253. - 格式:["图X","正文-XXXX"]
  254. - 要求:必须包含具体的图片编号或文字原文片段
  255. - 推理依据: 为什么这样划分
  256. - 子项: 子Section列表(树状结构)
  257. # 输出(JSON)- 树状结构
  258. {{
  259. "内容品类": "内容品类",
  260. "段落列表": [
  261. {{
  262. "描述": "共性维度名称",
  263. "内容范围": ["图1", "图2", "正文——具体文字内容片段"],
  264. "推理依据": "为什么这样划分这个抽象层",
  265. "子项": [
  266. {{
  267. "描述": "具体内容概括",
  268. "内容范围": ["图1", "正文——具体文字内容片段"],
  269. "推理依据": "这个具象内容如何支撑上层抽象",
  270. "子项": []
  271. }}
  272. ]
  273. }}
  274. ]
  275. }}"""
  276. # 构建多模态消息
  277. message_content = [{"type": "text", "text": prompt}]
  278. # 添加图片
  279. if images:
  280. for idx, image_url in enumerate(images, 1):
  281. message_content.append({"type": "text", "text": f"[图片{idx}]"})
  282. message_content.append({
  283. "type": "image_url",
  284. "image_url": {"url": image_url}
  285. })
  286. messages = [
  287. {"role": "system", "content": self.system_prompt},
  288. {"role": "user", "content": message_content}
  289. ]
  290. # 调用LLM
  291. result = LLMInvoker.safe_invoke(
  292. self,
  293. "Section划分",
  294. messages,
  295. fallback={"内容品类": "未知品类", "段落列表": []}
  296. )
  297. # 只打印JSON结构
  298. import json
  299. logger.info(f"Step1结果:\n{json.dumps(result, ensure_ascii=False, indent=2)}")
  300. return result
  301. def process(self, state: dict) -> dict:
  302. """处理state,执行完整的脚本理解流程(step1 + step2)
  303. 从state中提取所需数据,执行step1和step2,并返回结果
  304. Args:
  305. state: 工作流状态,包含:
  306. - text: 文本数据 {title, body}
  307. - images: 图片URL列表
  308. - topic_selection_understanding: 选题理解结果
  309. - content_weight: 图文权重信息
  310. Returns:
  311. dict: 包含script_understanding结果
  312. """
  313. logger.info("=== 开始脚本理解处理 ===")
  314. # 从state中提取数据
  315. text_data = state.get("text", {})
  316. images = state.get("images", [])
  317. # 提取选题描述(作为字典)
  318. topic_understanding = state.get("topic_selection_understanding", {})
  319. topic_description = {
  320. "主题": topic_understanding.get("主题", ""),
  321. "描述": topic_understanding.get("描述", "")
  322. }
  323. # 提取图文权重
  324. content_weight = state.get("content_weight", {})
  325. # 执行 step1 - Section划分
  326. logger.info("执行 Step1 - Section划分")
  327. sections_result = self.step1(
  328. text_data=text_data,
  329. images=images,
  330. topic_description=topic_description,
  331. content_weight=content_weight
  332. )
  333. sections = sections_result.get("段落列表", [])
  334. # 执行 step2 - 元素列表生成
  335. logger.info("执行 Step2 - 元素列表生成")
  336. elements_result = self.step2(
  337. text_data=text_data,
  338. images=images,
  339. topic_description=topic_description,
  340. content_weight=content_weight
  341. )
  342. # 组装最终结果
  343. script_understanding = {
  344. "内容品类": sections_result.get("内容品类", "未知品类"),
  345. "段落列表": sections,
  346. "元素列表": elements_result.get("元素列表", []),
  347. "图片列表": images # 添加原始图片URL列表
  348. }
  349. # 只打印JSON结构
  350. import json
  351. logger.info(f"脚本理解最终结果:\n{json.dumps(script_understanding, ensure_ascii=False, indent=2)}")
  352. return {"脚本理解": script_understanding}
  353. def step2_1_identify_candidates(self, text_data: dict, images: List[str], topic_description: dict, content_weight: dict = None) -> dict:
  354. """Step2.1: 候选元素识别
  355. 基于选题主题,识别与主题相关的候选元素(包括实质性和概念性元素)
  356. """
  357. if not self.is_initialized:
  358. self.initialize()
  359. logger.info("=" * 80)
  360. logger.info("【Step2.1】候选元素识别 - 基于选题主题识别候选元素(实质性+概念性)")
  361. logger.info("=" * 80)
  362. # 构建权重信息
  363. weight_info = self._build_weight_info(content_weight)
  364. # 构建内容
  365. post_content = self._build_post_content(text_data)
  366. topic_text = self._build_topic_text(topic_description)
  367. # 构建prompt
  368. prompt = f"""{weight_info}
  369. # 帖子内容
  370. {post_content}
  371. # 选题描述
  372. {topic_text}
  373. # 任务
  374. **基于选题主题作为关注锚点**,识别与主题相关或支撑主题的候选元素。
  375. ## 选题的作用
  376. 选题主题是识别的**关注锚点**:
  377. - ✅ **直接相关**:与选题主题直接相关的核心元素
  378. - ✅ **支撑主题**:对主题有支撑作用的辅助元素(即使不是核心,但能帮助理解主题)
  379. - ✅ **间接相关**:与选题主题间接相关的背景元素
  380. - ❌ **完全无关**:与选题主题没有任何关联的元素
  381. ## 识别原则
  382. - **全面识别**:宁可多识别,不要漏掉与主题相关或支撑主题的元素
  383. - **关系多样**:包括直接相关、支撑性、补充性、背景性等各种关系
  384. - **宽松标准**:此阶段标准宽松,后续会进一步筛选
  385. - **倾向保留**:不确定是否相关时,倾向于保留
  386. ## 识别标准:实质性元素 + 概念性元素
  387. ### 实质性元素(Substantial Elements)
  388. **定义**:看得见、摸得着的具体物体
  389. - ✅ 必须是名词形式
  390. - ✅ 必须是可观察的具体事物
  391. - ✅ 必须是**原子的名词**,关注的是类/ID
  392. - ✅ 包括所有出现的物体,无论大小或重要性
  393. - ❌ 不包括动作或状态
  394. - ❌ 不包括情绪或氛围
  395. - ❌ 不包括带修饰语的复合名词(修饰语应在后续分析中体现)
  396. ### 概念性元素(Conceptual Elements)
  397. **定义**:虽然不是客观实体,但是对理解帖子至关重要的抽象概念
  398. - ✅ 必须是名词形式的概念
  399. - ✅ 虽然没有直接提及,但图片或文字隐含了这个概念
  400. - ✅ 出现频率高或重要性强的描述性概念
  401. - ✅ 对理解主题有显著帮助的抽象维度
  402. **概念性元素的判断标准**:
  403. - ✅ 这个概念是否帮助理解帖子的核心价值?
  404. - ✅ 这个概念是否贯穿多个段落或图片?
  405. - ✅ 这个概念是否隐含在视觉或文字表达中?
  406. - ❌ 不是单纯的形容词或副词,必须能转化为名词概念
  407. ## 识别要求
  408. - **全面浏览**:不要遗漏任何出现的元素(实质性+概念性)
  409. - **原始记录**:此阶段不进行筛选,全部记录
  410. - **原子名词**:使用最基础的名词形式,去除修饰语
  411. - **显性+隐性**:既要识别显性出现的实体,也要识别隐性的概念
  412. ## 元素类型标注
  413. 每个元素需要标注类型:
  414. - **实质性**:具体的客观物体
  415. - **概念性**:抽象的概念维度
  416. # 输出(JSON)
  417. {{
  418. "候选元素列表": [
  419. {{
  420. "元素名称": "元素名称(原子名词)",
  421. "元素类型": "实质性/概念性",
  422. "出现位置": "该元素出现在哪些位置",
  423. "识别依据": "为什么识别这个元素(特别是概念性元素,需要说明隐含的依据)"
  424. }}
  425. ]
  426. }}"""
  427. # 构建多模态消息
  428. message_content = [{"type": "text", "text": prompt}]
  429. if images:
  430. for idx, image_url in enumerate(images, 1):
  431. message_content.append({"type": "text", "text": f"[图片{idx}]"})
  432. message_content.append({
  433. "type": "image_url",
  434. "image_url": {"url": image_url}
  435. })
  436. messages = [
  437. {"role": "system", "content": self.system_prompt},
  438. {"role": "user", "content": message_content}
  439. ]
  440. # 调用LLM
  441. result = LLMInvoker.safe_invoke(
  442. self,
  443. "候选元素识别",
  444. messages,
  445. fallback={"候选元素列表": []}
  446. )
  447. # 只打印JSON结构
  448. import json
  449. logger.info(f"Step2.1结果:\n{json.dumps(result, ensure_ascii=False, indent=2)}")
  450. return result
  451. def step2_2_score_dimensions(self, candidates_result: dict, text_data: dict, images: List[str], topic_description: dict, content_weight: dict = None) -> dict:
  452. """Step2.2: 双维度评分(分视角分析)
  453. 对每个候选元素进行双维度评分(每维度0-10分)
  454. - 先分视角:判断概念性维度 vs 实质性维度哪个更重
  455. - 优先处理权重更大的维度
  456. - 维度1: 主题支撑性 - 评估元素对选题展开和目的达成的支撑程度
  457. - 维度2: 多段落共性 - 评估元素的贯穿性和共性
  458. """
  459. if not self.is_initialized:
  460. self.initialize()
  461. logger.info("\n" + "=" * 80)
  462. logger.info("【Step2.2】双维度评分(分视角分析)")
  463. logger.info("=" * 80)
  464. candidates = candidates_result.get("候选元素列表", [])
  465. if not candidates:
  466. logger.warning("⚠️ 没有候选元素可供评分")
  467. return {"评分结果": []}
  468. # 统计概念性和实质性元素数量
  469. conceptual_count = sum(1 for c in candidates if c.get("元素类型") == "概念性")
  470. substantial_count = sum(1 for c in candidates if c.get("元素类型") == "实质性")
  471. logger.info(f"输入: {len(candidates)} 个候选元素(概念性: {conceptual_count}, 实质性: {substantial_count})")
  472. # 构建权重信息
  473. weight_info = self._build_weight_info(content_weight)
  474. # 构建内容
  475. post_content = self._build_post_content(text_data)
  476. topic_text = self._build_topic_text(topic_description)
  477. # 构建候选元素列表文本
  478. candidates_text = ""
  479. for c in candidates:
  480. candidates_text += f"\n- {c.get('元素名称', 'N/A')} ({c.get('元素类型', 'N/A')})"
  481. candidates_text += f"\n 出现位置: {c.get('出现位置', 'N/A')}"
  482. if c.get('识别依据'):
  483. candidates_text += f"\n 识别依据: {c.get('识别依据', 'N/A')}"
  484. # 构建prompt
  485. prompt = f"""{weight_info}
  486. # 帖子内容
  487. {post_content}
  488. # 选题描述
  489. {topic_text}
  490. # 候选元素列表
  491. {candidates_text}
  492. # 任务
  493. 对每个候选元素进行**双维度评分**(每维度0-10分)。
  494. ## 分视角分析流程
  495. **第一步:判断维度权重**
  496. 根据候选元素列表,判断概念性元素和实质性元素哪个更重:
  497. - 如果概念性元素数量多或对主题更核心 → 概念性维度权重更大
  498. - 如果实质性元素数量多或对主题更核心 → 实质性维度权重更大
  499. - 如果两者相当 → 两个维度权重相同
  500. **第二步:优先处理权重更大的维度**
  501. - 先对权重更大的维度进行详细评分和分析
  502. - 确保权重更大维度的元素得到充分评估
  503. - 再处理另一个维度作为补充
  504. **第三步:综合评分**
  505. - 对所有元素进行双维度评分
  506. - 权重更大的维度应有更多高分元素
  507. ## 两大评分维度
  508. ### 1. 主题支撑性(0-10分)
  509. 评估元素对选题展开和目的达成的支撑程度:
  510. - **核心支撑(8-10分)**:元素是选题展开的核心支撑,直接承载创作目的
  511. - **重要支撑(5-7分)**:元素为选题提供重要支撑,对目的达成有明显作用
  512. - **辅助支撑(3-4分)**:元素为选题提供辅助性支撑
  513. - **弱支撑(0-2分)**:元素与选题关联度低,对目的达成作用微弱
  514. ### 2. 多段落共性(0-10分)
  515. 评估元素在多个段落中的贯穿性和共性:
  516. - **跨段落出现**:元素在多个段落/Section中出现
  517. - **持续性存在**:元素在内容中的连续性呈现
  518. - **代表性**:元素的典型性和普遍性,能否代表一类事物
  519. **贯穿段落字段说明**:
  520. - 格式:列表格式,包含该元素出现的具体内容
  521. - 要求:必须包含具体的图片编号或文字原文片段
  522. ## 评分要求
  523. - 客观评分:基于实际内容,不要主观臆测
  524. - 严格打分:真正优秀的元素才能得高分
  525. - 详细说明:每个维度都要给出评分依据
  526. - 分视角优先:优先处理权重更大的维度
  527. # 输出(JSON)
  528. {{
  529. "维度判断": {{
  530. "概念性元素重要性": "高/中/低",
  531. "实质性元素重要性": "高/中/低",
  532. "优先维度": "概念性/实质性/均衡",
  533. "判断依据": "为什么这个维度更重要"
  534. }},
  535. "评分结果": [
  536. {{
  537. "元素名称": "元素名称",
  538. "元素类型": "概念性/实质性",
  539. "主题支撑性得分": 8,
  540. "主题支撑性依据": "评分依据说明(如何支撑选题展开和目的达成)",
  541. "多段落共性得分": 7,
  542. "多段落共性依据": "评分依据说明",
  543. "贯穿段落": ["图1", "图2", "正文——具体文字内容片段"]
  544. }}
  545. ]
  546. }}"""
  547. # 构建多模态消息
  548. message_content = [{"type": "text", "text": prompt}]
  549. if images:
  550. for idx, image_url in enumerate(images, 1):
  551. message_content.append({"type": "text", "text": f"[图片{idx}]"})
  552. message_content.append({
  553. "type": "image_url",
  554. "image_url": {"url": image_url}
  555. })
  556. messages = [
  557. {"role": "system", "content": self.system_prompt},
  558. {"role": "user", "content": message_content}
  559. ]
  560. # 调用LLM
  561. result = LLMInvoker.safe_invoke(
  562. self,
  563. "双维度评分",
  564. messages,
  565. fallback={"评分结果": []}
  566. )
  567. # 只打印JSON结构
  568. import json
  569. logger.info(f"Step2.2结果:\n{json.dumps(result, ensure_ascii=False, indent=2)}")
  570. return result
  571. def step2_3_strict_filter(self, scored_result: dict, single_threshold: float = 6.0, weighted_threshold: float = 5.0) -> dict:
  572. """Step2.3: 严格筛选
  573. 根据评分结果进行严格筛选,满足以下条件之一即可通过:
  574. 1. 主题支撑性 > single_threshold
  575. 2. 多段落共性 > single_threshold
  576. 3. 加权得分 > weighted_threshold(加权:主题支撑性 * 0.5 + 多段落共性 * 0.5)
  577. Args:
  578. scored_result: 评分结果
  579. single_threshold: 单维度筛选阈值,默认为6.0(降低阈值,返回更多支撑主题的元素)
  580. weighted_threshold: 加权得分筛选阈值,默认为5.0(整体合格即可)
  581. """
  582. if not self.is_initialized:
  583. self.initialize()
  584. logger.info("\n" + "=" * 80)
  585. logger.info("【Step2.3】严格筛选")
  586. logger.info("=" * 80)
  587. logger.info("筛选规则(满足任一条件即通过):")
  588. logger.info(f" 1. 主题支撑性 > {single_threshold} (单维度良好)")
  589. logger.info(f" 2. 多段落共性 > {single_threshold} (单维度良好)")
  590. logger.info(f" 3. 加权得分 > {weighted_threshold} (整体合格,权重:主题支撑性=0.5, 多段落共性=0.5)")
  591. logger.info(f" 注:阈值已从7降低到6,以返回更多支撑主题的元素")
  592. scored_elements = scored_result.get("评分结果", [])
  593. if not scored_elements:
  594. logger.warning("⚠️ 没有评分结果可供筛选")
  595. return {"筛选结果": []}
  596. logger.info(f"\n输入: {len(scored_elements)} 个评分元素")
  597. logger.info("\n筛选过程:")
  598. filtered_elements = []
  599. for element in scored_elements:
  600. element_name = element.get("元素名称", "未知")
  601. # 提取分数
  602. theme_score = element.get("主题支撑性得分", 0)
  603. multi_para_score = element.get("多段落共性得分", 0)
  604. # 计算加权得分(主题支撑性和多段落共性各占50%)
  605. weighted_score = theme_score * 0.5 + multi_para_score * 0.5
  606. # 判断是否满足任一条件
  607. conditions_met = []
  608. if theme_score > single_threshold:
  609. conditions_met.append(f"主题支撑性={theme_score}")
  610. if multi_para_score > single_threshold:
  611. conditions_met.append(f"多段落共性={multi_para_score}")
  612. if weighted_score > weighted_threshold:
  613. conditions_met.append(f"加权得分={weighted_score:.1f}")
  614. # 如果满足任一条件,则通过筛选
  615. if conditions_met:
  616. logger.info(f" ✓ {element_name}: 通过筛选 ({', '.join(conditions_met)})")
  617. filtered_elements.append(element)
  618. else:
  619. logger.info(f" ✗ {element_name}: 未通过筛选 (主题支撑性={theme_score}, 多段={multi_para_score}, 加权={weighted_score:.1f})")
  620. # 只打印JSON结构
  621. import json
  622. logger.info(f"Step2.3结果:\n{json.dumps({'筛选结果': filtered_elements}, ensure_ascii=False, indent=2)}")
  623. return {"筛选结果": filtered_elements}
  624. def step2_4_deduplicate(self, filtered_result: dict, text_data: dict, images: List[str], topic_description: dict) -> dict:
  625. """Step2.4: 去重与整合
  626. 合并同类元素,确保元素之间正交独立,生成树状结构
  627. - 非叶子节点:分类(抽象维度)
  628. - 叶子节点:具体元素
  629. """
  630. if not self.is_initialized:
  631. self.initialize()
  632. logger.info("\n" + "=" * 80)
  633. logger.info("【Step2.4】去重与整合")
  634. logger.info("=" * 80)
  635. filtered_elements = filtered_result.get("筛选结果", [])
  636. if not filtered_elements:
  637. logger.warning("⚠️ 没有筛选结果可供去重")
  638. return {"元素列表": []}
  639. logger.info(f"输入: {len(filtered_elements)} 个筛选后的元素")
  640. # 构建内容
  641. post_content = self._build_post_content(text_data)
  642. topic_text = self._build_topic_text(topic_description)
  643. # 构建筛选元素文本
  644. elements_text = ""
  645. for elem in filtered_elements:
  646. elements_text += f"\n- {elem.get('元素名称', 'N/A')}"
  647. elements_text += f"\n 主题支撑性: {elem.get('主题支撑性得分', 0)}, 多段落共性: {elem.get('多段落共性得分', 0)}"
  648. elements_text += f"\n 贯穿段落: {elem.get('贯穿段落', 'N/A')}"
  649. # 构建prompt
  650. prompt = f"""# 帖子内容
  651. {post_content}
  652. # 选题描述
  653. {topic_text}
  654. # 筛选后的元素列表
  655. {elements_text}
  656. # 任务
  657. 对筛选后的元素进行**去重与整合**,生成最终的树状结构元素列表。
  658. ## 核心原则
  659. **树状结构:非叶子节点为分类,叶子节点为具体元素**
  660. - 非叶子节点:分类(抽象维度),可以包含子分类或具体元素
  661. - 叶子节点:具体元素,是实际的元素
  662. - 层级深度:根据实际内容灵活确定,不限定层数
  663. - 分类节点通过"子项"字段包含下一层的分类或元素
  664. ## 去重规则
  665. 1. **合并同类**:将具有相同上位概念的元素归类到同一分类下
  666. 2. **正交独立**:同级节点之间必须互不重叠、相互独立
  667. ## 分类规则(使用元素本身 What 的维度)
  668. **核心原则:分类基于元素本身的 What 维度(本质属性)**
  669. - **What 维度**:回答"这是什么类型的东西"
  670. - **分类来源**:从元素本身的本质属性中抽象出分类,基于"它们是什么"来分类
  671. - **分类层级**:分类可以嵌套(分类下可以有子分类)
  672. - **同级正交**:同一分类下的节点应该具有相同的 What 维度(上位概念)
  673. **MECE分类视角框架(选择最适合的一个维度)**:
  674. - **物理维度**:基于物理属性(形态、结构、状态等)
  675. - **化学维度**:基于化学成分和组成
  676. - **生物维度**:基于生物分类(动物、植物、微生物等)
  677. - **功能维度**:基于客观功能分类(注意:是物体本身的功能属性,不是对其他事物的影响)
  678. **分类禁止项**:
  679. - ❌ 禁止从"对其他事物的影响/效果"角度分类
  680. - ❌ 禁止从"目的/价值"角度分类
  681. - ❌ 禁止使用抽象概念作为分类
  682. **判断标准**:
  683. 分类名称应该能回答"XX是一种什么?",而不是"XX对YY有什么作用?"或"XX导致什么结果?"
  684. ## 节点字段说明
  685. ### 分类节点(非叶子节点)
  686. - **元素名称**:分类名称(基于 What 维度的抽象分类)
  687. - **描述**:对这个分类的定义说明(回答"这是什么类型的东西")
  688. - **子项**:该分类下的子分类或具体元素列表
  689. ### 元素节点(叶子节点)
  690. - **元素名称**:使用原子名词(最基础的名词形式,去除修饰语)
  691. - **描述**:该元素在帖子中的定义,包含具体表现
  692. - **上游支撑**:说明该元素如何支撑选题的展开和目的达成
  693. - **贯穿段落**:该元素贯穿的内容范围,列表格式(如:["图1", "图2", "正文——具体文字内容片段"])
  694. - **主题支撑性得分**:0-10分
  695. - **多段落共性得分**:0-10分
  696. - **推理依据**:为什么这是核心元素
  697. - **子项**:[](叶子节点,必须为空列表)
  698. ## 输出要求
  699. - 元素数量:宁少勿多,只保留真正核心的元素
  700. - 同级正交:同级节点之间必须相互独立
  701. - 描述清晰:每个节点都要有清晰的定义
  702. - 支撑明确:每个元素都要说明如何支撑选题
  703. - 层级灵活:根据实际内容确定树的深度
  704. # 输出(JSON)- 树状结构
  705. {{
  706. "元素列表": [
  707. {{
  708. "元素名称": "一级分类名称",
  709. "描述": "对这个分类的定义说明",
  710. "子项": [
  711. {{
  712. "元素名称": "二级分类名称(可选)",
  713. "描述": "对这个子分类的定义说明",
  714. "子项": [
  715. {{
  716. "元素名称": "具体元素名称(原子名词)",
  717. "描述": "该元素在帖子中的定义,包含具体表现",
  718. "上游支撑": "该元素如何支撑选题的展开和目的达成",
  719. "贯穿段落": ["图1", "图2", "正文——具体文字内容片段"],
  720. "主题支撑性得分": 8,
  721. "多段落共性得分": 9,
  722. "推理依据": "为什么这是核心元素(如何满足双维度要求)",
  723. "子项": []
  724. }}
  725. ]
  726. }},
  727. {{
  728. "元素名称": "具体元素名称(原子名词)",
  729. "描述": "该元素在帖子中的定义,包含具体表现",
  730. "上游支撑": "该元素如何支撑选题的展开和目的达成",
  731. "贯穿段落": ["图3", "正文——具体文字内容片段"],
  732. "主题支撑性得分": 7,
  733. "多段落共性得分": 8,
  734. "推理依据": "为什么这是核心元素",
  735. "子项": []
  736. }}
  737. ]
  738. }}
  739. ]
  740. }}
  741. **注意**:
  742. - 如果分类下直接是具体元素,则元素是该分类的直接子项
  743. - 如果需要更细的分类,可以在分类下再创建子分类
  744. - 具体元素(叶子节点)必须包含完整的元素字段(描述、上游支撑、评分等)
  745. - 分类节点(非叶子)只需要包含名称、描述和子项"""
  746. # 构建多模态消息
  747. message_content = [{"type": "text", "text": prompt}]
  748. if images:
  749. for idx, image_url in enumerate(images, 1):
  750. message_content.append({"type": "text", "text": f"[图片{idx}]"})
  751. message_content.append({
  752. "type": "image_url",
  753. "image_url": {"url": image_url}
  754. })
  755. messages = [
  756. {"role": "system", "content": self.system_prompt},
  757. {"role": "user", "content": message_content}
  758. ]
  759. # 调用LLM
  760. result = LLMInvoker.safe_invoke(
  761. self,
  762. "去重与整合",
  763. messages,
  764. fallback={"元素列表": []}
  765. )
  766. # 只打印JSON结构
  767. import json
  768. logger.info(f"Step2.4结果:\n{json.dumps(result, ensure_ascii=False, indent=2)}")
  769. return result
  770. def step2(self, text_data: dict, images: List[str], topic_description: dict, content_weight: dict = None) -> dict:
  771. """第二步:元素 - 提取与主题相关的核心元素
  772. 执行完整的4步筛选流程:
  773. 1. 候选元素识别
  774. 2. 双维度评分(主题支撑性 + 多段落共性)
  775. 3. 严格筛选(满足任一条件即可)
  776. 4. 去重与整合
  777. """
  778. if not self.is_initialized:
  779. self.initialize()
  780. logger.info("\n" + "█" * 80)
  781. logger.info("█" + " " * 78 + "█")
  782. logger.info("█" + " " * 25 + "【Step2】元素提取 - 四步筛选流程" + " " * 23 + "█")
  783. logger.info("█" + " " * 78 + "█")
  784. logger.info("█" * 80)
  785. # Step 2.1: 候选元素识别
  786. logger.info("\n▶ 开始执行 Step 2.1: 候选元素识别")
  787. candidates_result = self.step2_1_identify_candidates(
  788. text_data, images, topic_description, content_weight
  789. )
  790. # Step 2.2: 双维度评分
  791. logger.info("\n▶ 开始执行 Step 2.2: 双维度评分(主题支撑性 + 多段落共性)")
  792. scored_result = self.step2_2_score_dimensions(
  793. candidates_result, text_data, images, topic_description, content_weight
  794. )
  795. # Step 2.3: 严格筛选
  796. logger.info("\n▶ 开始执行 Step 2.3: 严格筛选(满足任一条件即可)")
  797. filtered_result = self.step2_3_strict_filter(scored_result)
  798. # Step 2.4: 去重与整合
  799. logger.info("\n▶ 开始执行 Step 2.4: 去重与整合")
  800. final_result = self.step2_4_deduplicate(
  801. filtered_result, text_data, images, topic_description
  802. )
  803. # 只打印JSON结构
  804. import json
  805. logger.info(f"Step2最终结果:\n{json.dumps(final_result, ensure_ascii=False, indent=2)}")
  806. return final_result
  807. def _build_weight_info(self, content_weight: dict = None) -> str:
  808. """构建权重信息文本"""
  809. if not content_weight:
  810. return ""
  811. image_weight = content_weight.get("图片权重", 5)
  812. text_weight = content_weight.get("文字权重", 5)
  813. primary_source = content_weight.get("主要信息源", "both")
  814. return f"""# 图文权重信息
  815. - 图片权重: {image_weight}/10
  816. - 文字权重: {text_weight}/10
  817. - 主要信息源: {primary_source}
  818. **元素提取时需要考虑权重**:
  819. - 如果图片权重高,元素提取应更多关注图片中的视觉元素
  820. - 如果文字权重高,元素提取应更多关注文字描述的概念和对象
  821. - 如果权重相近,需要综合图文信息提取元素
  822. """
  823. def _build_post_content(self, text_data: dict) -> str:
  824. """构建帖子内容文本"""
  825. post_content_parts = []
  826. if text_data.get("title"):
  827. post_content_parts.append(f"标题: {text_data['title']}")
  828. if text_data.get("body"):
  829. post_content_parts.append(f"正文: {text_data['body']}")
  830. return "\n".join(post_content_parts) if post_content_parts else "无文本信息"
  831. def _build_topic_text(self, topic_description: dict) -> str:
  832. """构建选题描述文本"""
  833. topic_parts = []
  834. if topic_description.get("主题"):
  835. topic_parts.append(f"主题: {topic_description['主题']}")
  836. if topic_description.get("描述"):
  837. topic_parts.append(f"描述: {topic_description['描述']}")
  838. return "\n".join(topic_parts) if topic_parts else "无选题描述"
  839. def _build_messages(self, state: dict) -> List[dict]:
  840. """构建消息 - ScriptUnderstandingAgent 不使用此方法
  841. 本 Agent 使用 step1 和 step2 方法直接构建消息
  842. """
  843. return []
  844. def _update_state(self, state: dict, response) -> dict:
  845. """更新状态 - ScriptUnderstandingAgent 不使用此方法
  846. 本 Agent 使用 step1 和 step2 方法直接返回结果
  847. """
  848. return state