Files
life-echo/api/app/agents/memoir/fidelity_check_agent.py
Kevin 07c6478742 feat(api): 访谈路径轻量门控、Memoir Phase1 批处理与叙事/记忆管线加固
- 新增 utterance_substance:短时/应答/元话语可跳过记忆检索、阶段 LLM 与资料抽取 LLM;可配置
- 输入归一化:LLM 模式默认仅语音/ASR;配置项写入 .env.example
- Memoir Phase1:可选 batch LLM 一次性抽取+分类(失败回退逐段);Extraction 空槽位时阶段与 current_stage 对齐,prompt 约束收紧
- 叙事与忠实度:narrative_safety、证据重叠/场合锚点、标题 slots 与履历短语 grounded;fidelity 解析失败 fail-open 可配置
- 章节管线:锁 TTL 上调、锁竞争 Celery 重试、Phase2 immediate singleflight 等;story_pipeline_sync / chapter_compose / memoir_tasks 联动
- Memory:compaction / repo / summarizer / evidence 小修;事实 FTS 未命中是否回退最近事实可配置
- 新增 memoir_pipeline_trace;补充 memoir_reliability 文档与多项回归/门控测试
2026-04-03 10:12:59 +08:00

137 lines
5.1 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
FidelityCheckAgent比较「用户口述」与叙事 JSON 输出,判定是否存在明显编造或越界。
续写合并append时传入 `existing_canonical_markdown`,将已有故事正文一并视为允许来源。
失败时由流水线回退(见 story_pipeline_sync续写为「已有 + 口述」,新建为口述原文。
"""
from __future__ import annotations
import json
import re
from typing import Any
from app.core.config import settings
from app.core.langchain_llm import invoke_json_object
from app.core.logging import get_logger
from app.core.json_utils import extract_json_payload
logger = get_logger(__name__)
# 生成稿中出现的四位年份,若口述中未出现同串,仅打日志(不误杀)
_YEAR_4_RE = re.compile(r"(?<!\d)(19|20)\d{2}(?!\d)")
def _log_suspicious_years_not_in_oral(oral_text: str, narrative_json: str) -> None:
oral = oral_text or ""
gen = narrative_json or ""
for m in _YEAR_4_RE.finditer(gen):
y = m.group(0)
if y not in oral:
logger.debug(
"event=fidelity_heuristic_year_not_in_oral year={} oral_len={} gen_len={}",
y,
len(oral),
len(gen),
)
class FidelityCheckAgent:
"""叙事忠实度检查json_object失败时上层应回退为口述原文。"""
def passes(
self,
*,
oral_text: str,
narrative_json: str,
llm: Any,
existing_canonical_markdown: str | None = None,
is_append: bool = False,
) -> bool:
if not llm or not settings.memoir_fidelity_check_enabled:
return True
oral = (oral_text or "").strip()
gen = (narrative_json or "").strip()
if not oral or not gen:
return True
existing = (existing_canonical_markdown or "").strip()
_log_suspicious_years_not_in_oral(oral, gen)
pass_rules = """## 以下行为是 pass不算编造
- 口语转书面语(删语气词、调语序、用成语替换口语)
- 过渡句与衔接句(「那段日子」「回想起来」等,不引入新实体)
- 基于口述已有情感的渲染与书面化(如口述说「难受」,改写为「心里像堵了一团棉花」,但不能新增具体场景细节)
- 合并同义重复表述
- 纠正明显的语音识别或同音错别字
## 以下行为是 fail算编造
- 新增口述中**没有**的具体人名、地名、时间、数字、对话原文
- 补全口述未说明的结果或结局(如「最终没考上」)
- 把系统摘录/档案里才有的信息写成用户亲口经历
- 虚构具体场景细节来「让文章更好看」
- 叙述中新增**具体场合/场景锚点**而口述没有同类表述(如写入「聚餐」「酒席」「那晚」「前一晚」等聚会或时间场合,但口述仅有话题内容而未提及该场合;把摘录里才有的场合写成亲历)"""
if existing:
prompt = f"""你是事实核对员。当前为**续写合并**:生成稿应保留「已有故事正文」中的事实并融入「本轮口述」中的新事实。
【用户本轮口述】
{oral[:8000]}
【已有故事正文】(已落库,出现于此处的内容**不算**编造)
{existing[:12000]}
【模型生成的叙事】
{gen[:16000]}
{pass_rules}
判断:生成稿是否出现**既不在本轮口述、也不在已有正文**的具体新实体或虚构细节?
若内容可归因于上述两个来源的合理书面化整理pass=true。
**JSON 输出**:只输出一个合法 JSON 对象。
{{"pass": true, "reason": null}}
{{"pass": false, "reason": "一句话说明"}}
只输出 JSON不要其它文字。"""
else:
prompt = f"""你是事实核对员。比较用户口述与模型生成的叙事。
【用户口述】
{oral[:8000]}
【模型生成的叙事】
{gen[:16000]}
{pass_rules}
判断:生成稿是否出现口述中**明显没有**的具体新实体或虚构细节?
若仅为口述的书面化整理含文学性改写、情感渲染、过渡衔接pass=true。
**JSON 输出**:只输出一个合法 JSON 对象。
{{"pass": true, "reason": null}}
{{"pass": false, "reason": "一句话说明"}}
只输出 JSON不要其它文字。"""
try:
raw = invoke_json_object(
llm,
prompt,
max_tokens=settings.memoir_fidelity_check_max_tokens,
agent="FidelityCheckAgent.passes",
)
data = json.loads(extract_json_payload(raw))
ok = bool(data.get("pass", True))
if not ok:
logger.warning(
"event=fidelity_check_fail reason={}",
(data.get("reason") or "")[:200],
)
return ok
except Exception as e:
logger.warning("FidelityCheckAgent 解析失败: {}", e)
if is_append or settings.memoir_fidelity_fail_open_on_parse_error:
logger.info("event=fidelity_parse_fail_open is_append={}", is_append)
return True
logger.warning("event=fidelity_parse_fail_closed")
return False