488 lines
18 KiB
Python
488 lines
18 KiB
Python
"""节点内容摘要模块 — 两轮 LLM 调用生成 question-conditioned 摘要。
|
||
|
||
提取轮:带防幻觉 system prompt,提取与问题相关的信息。
|
||
验证轮:带核实 system prompt,逐条核实并给置信度。
|
||
与 TRM4 core/tree/summarizer.py 保真迁移:
|
||
同步 → async、_call_llm → await llm.chat()、ThreadPoolExecutor → asyncio.gather。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import asyncio
|
||
import re
|
||
from typing import TYPE_CHECKING, Any
|
||
|
||
from loguru import logger
|
||
|
||
if TYPE_CHECKING:
|
||
from collections.abc import Callable
|
||
from pathlib import Path
|
||
|
||
from core.protocols import LLMProvider
|
||
|
||
# ── 正则常量 ──────────────────────────────────────────────────────────
|
||
|
||
# 行号引注组:括号包裹的 s/c 行号列表,如 (s1) / (c2,s5) / (c70-c73,s196-s200)
|
||
# (兼容全角括号与逗号;单元允许范围语法 s3-s5 / s3-5,60-span 实测模型常用)
|
||
_ANCHOR_GROUP = re.compile(
|
||
r"[((]\s*([sc]\d+(?:-[sc]?\d+)?(?:\s*[,,]\s*[sc]\d+(?:-[sc]?\d+)?)*)\s*[))]"
|
||
)
|
||
_ANCHOR_RANGE = re.compile(r"([sc])(\d+)-([sc]?)(\d+)")
|
||
_RELEVANT_SECTION = re.compile(r"\[相关信息\](.*?)(?=\n\[|\Z)", re.DOTALL)
|
||
# 无相关信息声明句:60-span 实测全为"该节点未包含与问题直接相关的信息"类变体
|
||
_NO_INFO_STATEMENT = re.compile(r"未包含.*相关.*信息")
|
||
|
||
# 范围展开条数上限:防 (s1-s9999) 这类爆炸展开
|
||
_RANGE_MAX_IDS = 50
|
||
|
||
# 双封顶参数:上轮 A/B 证明无上限引用膨胀至 8.4 条/span 挤占提取预算(hall +51%)
|
||
_EXPAND_MAX_ITEMS = 5
|
||
_EXPAND_MAX_CHARS = 800
|
||
_EXPAND_LINE_CAP = 200
|
||
|
||
|
||
# ── Prompt 加载 ──────────────────────────────────────────────────────
|
||
|
||
|
||
def _load_prompt(prompts_dir: Path, filename: str) -> str:
|
||
"""从 prompts 目录加载 system prompt 文件。
|
||
|
||
参数:
|
||
prompts_dir: prompt 文件所在目录。
|
||
filename: prompt 文件名。
|
||
|
||
返回:
|
||
文件内容字符串。
|
||
"""
|
||
return (prompts_dir / filename).read_text(encoding="utf-8")
|
||
|
||
|
||
# ── Anchor 工具函数 ──────────────────────────────────────────────────
|
||
|
||
|
||
def _expand_anchor_ids(group_text: str) -> list[str]:
|
||
"""把引注组文本展开为逐 id 列表(支持范围语法)。
|
||
|
||
参数:
|
||
group_text: _ANCHOR_GROUP 捕获的组内文本,如 "s3-s5, c1"。
|
||
|
||
返回:
|
||
逐 id 列表。合法范围(同前缀、起点<=终点、展开条数<=50)展开为
|
||
逐 id("s3-s5"/"s3-5" -> s3,s4,s5);非法范围(跨前缀如 c3-s5、
|
||
起点>终点、展开条数超限防爆炸)保留原 token——后续查表必然失配,
|
||
整段按 1 个非法锚计罚剔除。
|
||
"""
|
||
ids: list[str] = []
|
||
for token in re.split(r"[,,]\s*", group_text):
|
||
token = token.strip()
|
||
m = _ANCHOR_RANGE.fullmatch(token)
|
||
if m is None:
|
||
ids.append(token)
|
||
continue
|
||
prefix, start = m.group(1), int(m.group(2))
|
||
end_prefix, end = m.group(3), int(m.group(4))
|
||
legal_range = (
|
||
(not end_prefix or end_prefix == prefix)
|
||
and start <= end
|
||
and end - start + 1 <= _RANGE_MAX_IDS
|
||
)
|
||
if not legal_range:
|
||
ids.append(token)
|
||
continue
|
||
ids.extend(f"{prefix}{i}" for i in range(start, end + 1))
|
||
return ids
|
||
|
||
|
||
def check_anchors(summary: str, anchor_map: dict[str, str]) -> tuple[str, dict[str, int]]:
|
||
"""校验行号引注:非法行号删锚不删断言。
|
||
|
||
参数:
|
||
summary: 提取轮输出(含行号引注)。
|
||
anchor_map: {锚: 原文行} 查表。
|
||
|
||
返回:
|
||
(清理后文本, {"n_assertions", "n_anchored", "n_illegal"})。
|
||
|
||
关键实现细节:
|
||
清洗全文、统计限段:非法锚无论出现在哪一段都删除并计入 n_illegal
|
||
(避免未校验段落的编造锚流入装配展开);断言统计
|
||
(n_assertions/n_anchored)仅数 [相关信息] 段内非空内容行。
|
||
引注组先经 _expand_anchor_ids 把范围语法展开为逐 id 再逐 id 校验
|
||
(合法子集重写为逐 id 列表如 (s3,s4,s5)),组内全非法则整组删除;
|
||
组外文本一律不动(删锚不删断言)。分母口径:匹配"未包含...相关...
|
||
信息"词面的声明句不计入 n_assertions——它们天然无锚,计入会虚压
|
||
遵从率。
|
||
"""
|
||
stats: dict[str, int] = {"n_assertions": 0, "n_anchored": 0, "n_illegal": 0}
|
||
|
||
def _clean_group(gm: re.Match) -> str:
|
||
ids = _expand_anchor_ids(gm.group(1))
|
||
legal = [i for i in ids if i in anchor_map]
|
||
stats["n_illegal"] += len(ids) - len(legal)
|
||
return f"({','.join(legal)})" if legal else ""
|
||
|
||
cleaned = _ANCHOR_GROUP.sub(_clean_group, summary)
|
||
m = _RELEVANT_SECTION.search(cleaned)
|
||
if m is None:
|
||
return cleaned, stats
|
||
for line in m.group(1).splitlines():
|
||
line = line.strip().lstrip("-•*").strip()
|
||
if not line:
|
||
continue
|
||
if _NO_INFO_STATEMENT.search(line):
|
||
continue
|
||
stats["n_assertions"] += 1
|
||
if _ANCHOR_GROUP.search(line):
|
||
stats["n_anchored"] += 1
|
||
return cleaned, stats
|
||
|
||
|
||
def _cited_anchor_ids(summary: str, anchor_map: dict[str, str]) -> list[str]:
|
||
"""按引注首次出现顺序收集合法锚 id(去重)。
|
||
|
||
参数:
|
||
summary: 含行号引注的文本。
|
||
anchor_map: {锚: 原文行} 查表。
|
||
|
||
返回:
|
||
去重后的合法锚 id 列表(保持首次出现顺序)。
|
||
|
||
关键实现细节:
|
||
从 assemble_anchored_output 提取以满足圈复杂度门槛;范围语法经
|
||
_expand_anchor_ids 展开后逐 id 收集;只收合法锚(非法锚已由
|
||
check_anchors 清除,此处过滤是防御性双保险)。
|
||
"""
|
||
ordered: list[str] = []
|
||
for gm in _ANCHOR_GROUP.finditer(summary):
|
||
for aid in _expand_anchor_ids(gm.group(1)):
|
||
if aid in anchor_map and aid not in ordered:
|
||
ordered.append(aid)
|
||
return ordered
|
||
|
||
|
||
def assemble_anchored_output(
|
||
summary: str, anchor_map: dict[str, str], mode: str
|
||
) -> tuple[str, dict[str, int]]:
|
||
"""按装配形态生成最终输出:展开引文并施加双封顶。
|
||
|
||
参数:
|
||
summary: check_anchors 清理后的文本。
|
||
anchor_map: {锚: 原文行}。
|
||
mode: "ids"(裸行号)| "ids_expand"(行号+展开)| "expand_only"(展开剥行号)。
|
||
|
||
返回:
|
||
(最终文本, {"n_expanded", "n_trunc"})。
|
||
|
||
关键实现细节:
|
||
展开按引注首次出现顺序取前 5 条;总额帽按 [引文] 条目完整长度
|
||
(含前缀与引号)记账,<=800 字符;单行原文超 200 字符先截断。
|
||
n_expanded/n_trunc 仅计实际输出的条目。expand_only 先对正文剥除
|
||
全部引注 token、再拼接 [引文] 段(judge 探针判定 id token 被计罚
|
||
时的回退形态)——引文行不经过剥离,原文行中的括号文本得以保留。
|
||
"""
|
||
assert mode in ("ids", "ids_expand", "expand_only"), f"未知装配形态: {mode}"
|
||
stats: dict[str, int] = {"n_expanded": 0, "n_trunc": 0}
|
||
if mode != "ids":
|
||
ordered = _cited_anchor_ids(summary, anchor_map)
|
||
expansions: list[str] = []
|
||
total = 0
|
||
for aid in ordered[:_EXPAND_MAX_ITEMS]:
|
||
line = anchor_map[aid]
|
||
truncated = len(line) > _EXPAND_LINE_CAP
|
||
if truncated:
|
||
line = line[:_EXPAND_LINE_CAP] + "…"
|
||
entry = f' ▸ {aid}: "{line}"'
|
||
if total + len(entry) > _EXPAND_MAX_CHARS:
|
||
break
|
||
total += len(entry)
|
||
expansions.append(entry)
|
||
stats["n_expanded"] += 1
|
||
if truncated:
|
||
stats["n_trunc"] += 1
|
||
if mode == "expand_only":
|
||
summary = _ANCHOR_GROUP.sub("", summary)
|
||
if expansions:
|
||
summary = summary + "\n[引文]\n" + "\n".join(expansions)
|
||
return summary, stats
|
||
|
||
|
||
# ── LLM 调用辅助 ─────────────────────────────────────────────────────
|
||
|
||
|
||
async def _call_llm(
|
||
llm: LLMProvider,
|
||
system_prompt: str,
|
||
user_text: str,
|
||
*,
|
||
session_id: str | None = None,
|
||
parent_call_id: str | None = None,
|
||
) -> str:
|
||
"""调用 LLM 并返回响应文本。
|
||
|
||
参数:
|
||
llm: LLMProvider 端口实例。
|
||
system_prompt: 系统提示词。
|
||
user_text: 用户消息文本。
|
||
session_id: 会话 ID(透传遥测)。
|
||
parent_call_id: 父调用 ID(透传遥测)。
|
||
|
||
返回:
|
||
模型回答文本。
|
||
"""
|
||
messages = [
|
||
{"role": "system", "content": system_prompt},
|
||
{"role": "user", "content": user_text},
|
||
]
|
||
response = await llm.chat(messages, session_id=session_id, parent_call_id=parent_call_id)
|
||
return response.content
|
||
|
||
|
||
# ── 摘要函数 ─────────────────────────────────────────────────────────
|
||
|
||
|
||
async def summarize_node(
|
||
llm: LLMProvider,
|
||
raw_text: str,
|
||
question: str,
|
||
prompts_dir: Path,
|
||
*,
|
||
anchor_map: dict[str, str] | None,
|
||
assemble_mode: str,
|
||
stats_sink: Callable[[dict[str, Any]], None] | None = None,
|
||
session_id: str | None = None,
|
||
parent_call_id: str | None = None,
|
||
) -> str:
|
||
"""对单个节点做 question-conditioned 两轮摘要(可选行号锚模式)。
|
||
|
||
参数:
|
||
llm: LLMProvider 端口实例。
|
||
raw_text: 节点文本(锚模式下为带 [c1]/[s1] 行号的素材)。
|
||
question: Agent 当前关注的具体问题。
|
||
prompts_dir: prompt 文件目录。
|
||
anchor_map: {锚: 原文行};None 表示 v1 行为(无校验无装配无统计)。
|
||
assemble_mode: 装配形态("ids"/"ids_expand"/"expand_only"),
|
||
anchor_map 为 None 时忽略。
|
||
stats_sink: 统计回调(None 不收集);统计严禁写入输出文本。
|
||
session_id: 会话 ID(透传遥测)。
|
||
parent_call_id: 父调用 ID(透传遥测)。
|
||
|
||
返回:
|
||
"[内容摘要] {结果}\\n[核实] {验证结果}" 或错误信息。
|
||
|
||
关键实现细节:
|
||
锚模式流程:提取 -> check_anchors 清洗 -> 核实轮(见清洗后未装配文本)
|
||
-> assemble_anchored_output 装配 -> sink 上报。sink dict 完整键名:
|
||
n_assertions/n_anchored/n_illegal(check_anchors)、
|
||
n_expanded/n_trunc(装配)、output_chars(最终输出字符数)、
|
||
pre_assembly(清洗后未装配文本快照)、anchor_map(原样透传)。
|
||
"""
|
||
extract_input = f"问题: {question}\n\n以下是视频片段的描述和字幕:\n{raw_text}"
|
||
try:
|
||
raw_summary = await _call_llm(
|
||
llm,
|
||
_load_prompt(prompts_dir, "view_node_extract.md"),
|
||
extract_input,
|
||
session_id=session_id,
|
||
parent_call_id=parent_call_id,
|
||
)
|
||
except Exception as e:
|
||
return f"[摘要错误] {e}"
|
||
|
||
anchor_stats: dict[str, int] = {}
|
||
if anchor_map is not None:
|
||
raw_summary, anchor_stats = check_anchors(raw_summary, anchor_map)
|
||
pre_assembly = raw_summary
|
||
|
||
verify_input = (
|
||
f"问题: {question}\n\n"
|
||
f"原始内容:\n{raw_text}\n\n"
|
||
f"以下是另一个模型基于上述内容生成的摘要,请核实:\n{raw_summary}"
|
||
)
|
||
try:
|
||
verify_result = await _call_llm(
|
||
llm,
|
||
_load_prompt(prompts_dir, "view_node_verify.md"),
|
||
verify_input,
|
||
session_id=session_id,
|
||
parent_call_id=parent_call_id,
|
||
)
|
||
except Exception as e:
|
||
logger.warning("验证轮调用失败,跳过: {}", e)
|
||
verify_result = "跳过(调用失败)"
|
||
|
||
if anchor_map is not None:
|
||
raw_summary, asm_stats = assemble_anchored_output(raw_summary, anchor_map, assemble_mode)
|
||
anchor_stats.update(asm_stats)
|
||
|
||
result = f"[内容摘要] {raw_summary}\n[核实] {verify_result}"
|
||
if anchor_map is not None and stats_sink is not None:
|
||
stats_sink(
|
||
{
|
||
**anchor_stats,
|
||
"output_chars": len(result),
|
||
"pre_assembly": pre_assembly,
|
||
"anchor_map": anchor_map,
|
||
}
|
||
)
|
||
return result
|
||
|
||
|
||
async def summarize_children(
|
||
llm: LLMProvider,
|
||
children_info: list[dict[str, Any]],
|
||
question: str,
|
||
prompts_dir: Path,
|
||
*,
|
||
session_id: str | None = None,
|
||
parent_call_id: str | None = None,
|
||
) -> str:
|
||
"""对子节点列表做 question-conditioned 相关性标注(两轮)。
|
||
|
||
参数:
|
||
llm: LLMProvider 端口实例。
|
||
children_info: 子节点信息列表,每项含 id, time_range, summary。
|
||
question: Agent 当前关注的具体问题。
|
||
prompts_dir: prompt 文件目录。
|
||
session_id: 会话 ID(透传遥测)。
|
||
parent_call_id: 父调用 ID(透传遥测)。
|
||
|
||
返回:
|
||
带相关性标注的子节点概览文本。失败时降级返回原始列表。
|
||
"""
|
||
lines = []
|
||
for child in children_info:
|
||
t_start, t_end = child["time_range"]
|
||
lines.append(f"- {child['id']} ({t_start:.0f}-{t_end:.0f}s): {child['summary']}")
|
||
children_text = "\n".join(lines)
|
||
|
||
extract_input = f"问题: {question}\n\n{children_text}"
|
||
try:
|
||
raw_ranking = await _call_llm(
|
||
llm,
|
||
_load_prompt(prompts_dir, "view_node_children_extract.md"),
|
||
extract_input,
|
||
session_id=session_id,
|
||
parent_call_id=parent_call_id,
|
||
)
|
||
except Exception as e:
|
||
logger.warning("子节点标注失败,回退原始列表: {}", e)
|
||
return children_text
|
||
|
||
verify_input = (
|
||
f"问题: {question}\n\n"
|
||
f"原始子节点列表:\n{children_text}\n\n"
|
||
f"以下是另一个模型基于上述信息生成的相关性标注,请核实:\n{raw_ranking}"
|
||
)
|
||
try:
|
||
verify_result = await _call_llm(
|
||
llm,
|
||
_load_prompt(prompts_dir, "view_node_children_verify.md"),
|
||
verify_input,
|
||
session_id=session_id,
|
||
parent_call_id=parent_call_id,
|
||
)
|
||
return f"{raw_ranking}\n[核实] {verify_result}"
|
||
except Exception as e:
|
||
logger.warning("子节点标注验证轮失败,跳过: {}", e)
|
||
return raw_ranking
|
||
|
||
|
||
async def _summarize_search_result(
|
||
llm: LLMProvider,
|
||
raw_text: str,
|
||
question: str,
|
||
prompts_dir: Path,
|
||
*,
|
||
session_id: str | None = None,
|
||
parent_call_id: str | None = None,
|
||
) -> str:
|
||
"""对搜索结果做两轮摘要(search_similar 专用)。
|
||
|
||
参数:
|
||
llm: LLMProvider 端口实例。
|
||
raw_text: 节点原始文本。
|
||
question: Agent 当前关注的具体问题。
|
||
prompts_dir: prompt 文件目录。
|
||
session_id: 会话 ID(透传遥测)。
|
||
parent_call_id: 父调用 ID(透传遥测)。
|
||
|
||
返回:
|
||
"[内容摘要] {提取结果}\\n[核实] {验证结果}" 或错误信息。
|
||
"""
|
||
extract_input = f"问题: {question}\n\n以下是语义搜索命中的视频节点描述和字幕:\n{raw_text}"
|
||
try:
|
||
raw_summary = await _call_llm(
|
||
llm,
|
||
_load_prompt(prompts_dir, "search_similar_extract.md"),
|
||
extract_input,
|
||
session_id=session_id,
|
||
parent_call_id=parent_call_id,
|
||
)
|
||
except Exception as e:
|
||
return f"[摘要错误] {e}"
|
||
|
||
verify_input = (
|
||
f"问题: {question}\n\n"
|
||
f"原始内容:\n{raw_text}\n\n"
|
||
f"以下是另一个模型基于上述内容生成的摘要,请核实:\n{raw_summary}"
|
||
)
|
||
try:
|
||
verify_result = await _call_llm(
|
||
llm,
|
||
_load_prompt(prompts_dir, "search_similar_verify.md"),
|
||
verify_input,
|
||
session_id=session_id,
|
||
parent_call_id=parent_call_id,
|
||
)
|
||
return f"[内容摘要] {raw_summary}\n[核实] {verify_result}"
|
||
except Exception as e:
|
||
logger.warning("搜索结果验证轮失败,跳过: {}", e)
|
||
return f"[内容摘要] {raw_summary}\n[核实] 跳过(调用失败)"
|
||
|
||
|
||
async def summarize_nodes_batch(
|
||
llm: LLMProvider,
|
||
items: list[tuple[str, str, str]],
|
||
question: str,
|
||
prompts_dir: Path,
|
||
*,
|
||
session_id: str | None = None,
|
||
parent_call_id: str | None = None,
|
||
) -> list[tuple[str, str]]:
|
||
"""并发对多个搜索结果做两轮摘要。
|
||
|
||
参数:
|
||
llm: LLMProvider 端口实例。
|
||
items: [(node_id, raw_text, extra_info), ...] 列表。
|
||
question: Agent 当前关注的具体问题。
|
||
prompts_dir: prompt 文件目录。
|
||
session_id: 会话 ID(透传遥测)。
|
||
parent_call_id: 父调用 ID(透传遥测)。
|
||
|
||
返回:
|
||
[(node_id, summary_text), ...] 列表,顺序与输入一致。
|
||
"""
|
||
if not items:
|
||
return []
|
||
|
||
async def _worker(idx: int, node_id: str, raw_text: str) -> tuple[int, str, str]:
|
||
"""单个节点的摘要工作协程。"""
|
||
summary = await _summarize_search_result(
|
||
llm,
|
||
raw_text,
|
||
question,
|
||
prompts_dir,
|
||
session_id=session_id,
|
||
parent_call_id=parent_call_id,
|
||
)
|
||
return idx, node_id, summary
|
||
|
||
tasks = [_worker(i, nid, text) for i, (nid, text, _) in enumerate(items)]
|
||
results_raw = await asyncio.gather(*tasks)
|
||
|
||
results: dict[int, tuple[str, str]] = {}
|
||
for idx, node_id, summary in results_raw:
|
||
results[idx] = (node_id, summary)
|
||
|
||
return [results[i] for i in range(len(items))]
|