feat(tree/repair): Q&A 反向补全 — 从 TRM4 supplement 迁移

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-07 02:37:50 -04:00
parent edaa0d8290
commit 18971a794b
2 changed files with 560 additions and 0 deletions
+526
View File
@@ -0,0 +1,526 @@
"""Q&A 反向补全:基于问题答案分析,将树中缺失的事实注入节点。
通过 LLM 分析正确答案需要哪些关键事实,再检查树中是否已有,
对缺失事实执行注入。仅注入客观事实(人名、地点、得分、物体名称),
不注入情感、因果推理、时间推理等主观或高阶信息。
与 TRM4 的关键差异:
- 树结构从扁平 dict 变为 TreeIndexL1Node → 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