"""Q&A 反向补全:基于问题答案分析,将树中缺失的事实注入节点。 通过 LLM 分析正确答案需要哪些关键事实,再检查树中是否已有, 对缺失事实执行注入。仅注入客观事实(人名、地点、得分、物体名称), 不注入情感、因果推理、时间推理等主观或高阶信息。 与 TRM4 的关键差异: - 树结构从扁平 dict 变为 TreeIndex(L1Node → L2Node → L3Node)。 - Card 为 frozen dataclass,注入时使用 dataclasses.replace() 创建新实例。 - LLMProvider 为异步接口,返回 LLMResponse(.content 获取文本)。 """ from __future__ import annotations import json from dataclasses import dataclass, replace from typing import TYPE_CHECKING, Any from loguru import logger if TYPE_CHECKING: from app.tree.index import L1Node, L2Node, L3Node, TreeIndex from core.protocols import LLMProvider # --------------------------------------------------------------------------- # 允许注入的类别白名单 # --------------------------------------------------------------------------- _ALLOWED_CATEGORIES = frozenset( { "person_name", "location", "score_number", "object_name", } ) # --------------------------------------------------------------------------- # 类别 → 默认注入字段映射(L2 Card 字段名) # --------------------------------------------------------------------------- _CATEGORY_DEFAULT_FIELD: dict[str, str] = { "person_name": "entities", "location": "entities", "score_number": "entities", "object_name": "entities", } # --------------------------------------------------------------------------- # 统计 # --------------------------------------------------------------------------- @dataclass class SupplementStats: """反向补全统计信息。 属性: questions_analyzed: 分析的问题数量。 facts_injected: 成功注入的事实数量。 facts_skipped: 跳过的事实数量(类别不在白名单中)。 """ questions_analyzed: int = 0 facts_injected: int = 0 facts_skipped: int = 0 # --------------------------------------------------------------------------- # 去重 # --------------------------------------------------------------------------- def deduplicate_field(values: list[str]) -> list[str]: """大小写归一化去重,保留首次出现的原始形式。 参数: values: 待去重字符串列表。 返回: 去重后的列表,保留各值首次出现时的大小写。 空字符串和纯空白字符串会被跳过。 """ seen: set[str] = set() result: list[str] = [] for v in values: key = v.strip().lower() if key and key not in seen: seen.add(key) result.append(v) return result # --------------------------------------------------------------------------- # 节点查找 # --------------------------------------------------------------------------- def _find_node_by_id( index: TreeIndex, node_id: str, ) -> tuple[L1Node | L2Node | L3Node | None, int]: """在 TreeIndex 中按 ID 查找节点,返回节点和所属层级。 参数: index: 树索引。 node_id: 目标节点 ID。 返回: (node, level) 元组。找不到时返回 (None, -1)。 level: 1=L1, 2=L2, 3=L3。 """ for l1 in index.roots: if l1.id == node_id: return l1, 1 for l2 in l1.children: if l2.id == node_id: return l2, 2 for l3 in l2.children: if l3.id == node_id: return l3, 3 return None, -1 # --------------------------------------------------------------------------- # 单值注入(适配 frozen Card) # --------------------------------------------------------------------------- def _inject_into_l2(l2: L2Node, field: str, value: str) -> bool: """向 L2 节点的 Card 指定字段注入一个值。 使用 dataclasses.replace() 创建新的 frozen L2Card。 仅支持 list[str] 类型字段(entities / actions / action_subjects / visible_text) 和 str 类型字段(event_description / spatial_relations / state_changes)。 参数: l2: L2 节点(card 会被替换为新实例)。 field: 目标字段名。 value: 要注入的值。 返回: True 表示实际注入了新内容,False 表示已存在(跳过)。 """ card = l2.card current = getattr(card, field, None) if current is None: # 字段不存在于 Card schema,跳过 logger.debug("L2Card 无字段 {},跳过注入", field) return False if isinstance(current, list): lower_set = {v.strip().lower() for v in current if isinstance(v, str)} if value.strip().lower() in lower_set: return False new_list = deduplicate_field([*current, value]) l2.card = replace(card, **{field: new_list}) return True if isinstance(current, str): if value.strip().lower() in current.lower(): return False new_val = current + "; " + value if current else value l2.card = replace(card, **{field: new_val}) return True return False def _inject_into_l3(l3: L3Node, field: str, value: str) -> bool: """向 L3 节点的 Card 指定字段注入一个值。 使用 dataclasses.replace() 创建新的 frozen L3Card。 参数: l3: L3 节点(card 会被替换为新实例)。 field: 目标字段名。 value: 要注入的值。 返回: True 表示实际注入了新内容,False 表示已存在(跳过)。 """ card = l3.card current = getattr(card, field, None) if current is None: logger.debug("L3Card 无字段 {},跳过注入", field) return False if isinstance(current, list): lower_set = {v.strip().lower() for v in current if isinstance(v, str)} if value.strip().lower() in lower_set: return False new_list = deduplicate_field([*current, value]) l3.card = replace(card, **{field: new_list}) return True if isinstance(current, str): if value.strip().lower() in current.lower(): return False new_val = current + "; " + value if current else value l3.card = replace(card, **{field: new_val}) return True return False def _inject_into_l1(l1: L1Node, field: str, value: str) -> bool: """向 L1 节点的 Card 指定字段注入一个值。 使用 dataclasses.replace() 创建新的 frozen L1Card。 参数: l1: L1 节点(card 会被替换为新实例)。 field: 目标字段名。 value: 要注入的值。 返回: True 表示实际注入了新内容,False 表示已存在(跳过)。 """ card = l1.card current = getattr(card, field, None) if current is None: logger.debug("L1Card 无字段 {},跳过注入", field) return False if isinstance(current, list): lower_set = {v.strip().lower() for v in current if isinstance(v, str)} if value.strip().lower() in lower_set: return False new_list = deduplicate_field([*current, value]) l1.card = replace(card, **{field: new_list}) return True if isinstance(current, str): if value.strip().lower() in current.lower(): return False new_val = current + "; " + value if current else value l1.card = replace(card, **{field: new_val}) return True return False # --------------------------------------------------------------------------- # 批量注入 # --------------------------------------------------------------------------- def apply_injections(index: TreeIndex, injections: list[dict[str, Any]]) -> SupplementStats: """执行一组注入指令,将事实写入树节点 Card。 每条指令格式:: { "category": "person_name" | "location" | "score_number" | "object_name", "inject_value": "...", "targets": [{"node_id": "...", "field": "..."}, ...] } 向后兼容: 若无 targets,读取 target_node_id + target_field 构造单目标。 参数: index: TreeIndex 实例(节点 Card 会被替换为新实例)。 injections: 注入指令列表。 返回: 注入统计信息。 """ stats = SupplementStats() for instr in injections: category = instr.get("category", "") if category not in _ALLOWED_CATEGORIES: logger.debug("拒绝非法类别: {}", category) stats.facts_skipped += 1 continue inject_value = instr.get("inject_value", "") if not inject_value: stats.facts_skipped += 1 continue # 解析目标列表(兼容新旧格式) targets = instr.get("targets") if not targets: node_id = instr.get("target_node_id", "") field = instr.get("target_field", "") if node_id and field: targets = [{"node_id": node_id, "field": field}] else: stats.facts_skipped += 1 continue for target in targets: node_id = target.get("node_id", "") field = target.get("field", "") node, level = _find_node_by_id(index, node_id) if node is None: logger.debug("跳过不存在的节点: {}", node_id) stats.facts_skipped += 1 continue injected = False if level == 1: injected = _inject_into_l1(node, field, inject_value) # type: ignore[arg-type] elif level == 2: injected = _inject_into_l2(node, field, inject_value) # type: ignore[arg-type] elif level == 3: injected = _inject_into_l3(node, field, inject_value) # type: ignore[arg-type] if injected: stats.facts_injected += 1 else: stats.facts_skipped += 1 return stats # --------------------------------------------------------------------------- # LLM Prompt # --------------------------------------------------------------------------- _SUPPLEMENT_SYSTEM_PROMPT = """\ 你是一个视频内容分析专家。你的任务是分析回答某个问题需要哪些关键事实, 并判断这些事实是否已存在于视频树的摘要中。 ## 输出规则 1. 只输出**客观事实**,包括以下四类: - person_name: 人物姓名 - location: 地点名称 - score_number: 比分、数字 - object_name: 关键物体名称 2. **不要**输出以下类型: - 情感、态度、心情 - 因果推理("因为…所以…") - 时间顺序推理("先…后…") - 主观评价 3. 对于 person_name 类别,输出 targets 数组包含两个写入点: - L2 节点的 entities 字段 - L3 节点的 visible_entities 字段 其他类别只写入最相关的单个节点的 entities 字段。 4. 每条 missing fact 必须包含 inject_value(要注入的值)和 targets 数组。 ## 输出格式 (严格 JSON) ```json { "needed_facts": [ {"category": "person_name", "value": "..."} ], "found_in_tree": [ {"category": "person_name", "value": "...", "found_at": "node_id"} ], "missing_facts": [ { "category": "person_name", "inject_value": "...", "targets": [ {"node_id": "...", "field": "entities"}, {"node_id": "...", "field": "visible_entities"} ] } ] } ``` 只输出 JSON,不要输出其他内容。 """ def _build_user_prompt( question: dict[str, Any], index: TreeIndex, srt_text: str, ) -> str: """构建 supplement 分析的 user prompt。 包含: 问题 + 选项 + 正确答案 + 树 L2 摘要 + SRT 字幕(截断至 3000 字符)。 参数: question: 包含 question/options/answer 的字典。 index: TreeIndex 实例。 srt_text: SRT 字幕文本。 返回: 拼装后的 user prompt 字符串。 """ # 问题部分 q_text = question.get("question", "") options = question.get("options", []) answer = question.get("answer", "") options_str = "\n".join(f" {chr(65 + i)}. {opt}" for i, opt in enumerate(options)) # 树 L2 摘要(从 TreeIndex 结构中提取) l2_summaries: list[str] = [] for l1 in index.roots: for l2 in l1.children: description = l2.card.event_description entities_str = ", ".join(l2.card.entities) if l2.card.entities else "" time_str = "" if l2.time_range: time_str = f"{l2.time_range[0]:.1f}-{l2.time_range[1]:.1f}s: " l2_summaries.append( f"[{l2.id}] {time_str}{description}" + (f" | entities: {entities_str}" if entities_str else "") ) l2_block = "\n".join(l2_summaries) if l2_summaries else "(无 L2 摘要)" # SRT 截断 srt_truncated = srt_text[:3000] if srt_text else "(无字幕)" return ( f"## 问题\n{q_text}\n\n" f"## 选项\n{options_str}\n\n" f"## 正确答案\n{answer}\n\n" f"## 视频树 L2 摘要\n{l2_block}\n\n" f"## 字幕 (前 3000 字符)\n{srt_truncated}" ) # --------------------------------------------------------------------------- # LLM 调用 # --------------------------------------------------------------------------- async def analyze_question( llm: LLMProvider, question: dict[str, Any], index: TreeIndex, srt_text: str, ) -> list[dict[str, Any]]: """调用 LLM 分析单个问题,返回需要注入的事实列表。 参数: llm: LLMProvider 实例(异步接口)。 question: 问题字典(含 question/options/answer)。 index: TreeIndex 实例。 srt_text: SRT 字幕文本。 返回: missing_facts 列表,每项含 category / inject_value / targets。 解析失败时返回空列表。 """ user_prompt = _build_user_prompt(question, index, srt_text) messages = [ {"role": "system", "content": _SUPPLEMENT_SYSTEM_PROMPT}, {"role": "user", "content": user_prompt}, ] response = await llm.chat(messages) raw = response.content # 提取 JSON(兼容 markdown 代码块包裹) text = raw.strip() if text.startswith("```"): lines = text.split("\n") lines = [ln for ln in lines if not ln.strip().startswith("```")] text = "\n".join(lines) try: parsed = json.loads(text) except json.JSONDecodeError: logger.warning("supplement LLM 返回非法 JSON,跳过。原始内容: {}", raw[:200]) return [] missing = parsed.get("missing_facts", []) if not isinstance(missing, list): logger.warning("missing_facts 不是列表,跳过") return [] return missing # --------------------------------------------------------------------------- # 主入口 # --------------------------------------------------------------------------- async def supplement_tree( index: TreeIndex, questions: list[dict[str, Any]], llm: LLMProvider, srt_text: str = "", ) -> SupplementStats: """对树索引执行 Q&A 反向补全:遍历问题,分析缺失事实,注入节点。 参数: index: TreeIndex 实例(节点 Card 会被就地替换)。 questions: 问题列表,每项含 question/options/answer。 llm: LLMProvider 实例(异步接口)。 srt_text: SRT 字幕文本(可选,默认空字符串)。 返回: 补全统计信息。 """ all_injections: list[dict[str, Any]] = [] for i, question in enumerate(questions): logger.debug( "supplement: 分析问题 {}/{}", i + 1, len(questions), ) missing = await analyze_question(llm, question, index, srt_text) all_injections.extend(missing) stats = apply_injections(index, all_injections) stats.questions_analyzed = len(questions) logger.info( "supplement_tree 完成: questions={} injections={} injected={} skipped={}", len(questions), len(all_injections), stats.facts_injected, stats.facts_skipped, ) return stats