Files
life-echo/api/app/agents/memoir/fidelity_check_agent.py

128 lines
4.9 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 re
from typing import Any
from app.agents.memoir.schemas import FidelityOutput
from app.core.config import settings
from app.core.llm_call import LLMCallError, llm_json_call
from app.core.logging import get_logger
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。
输出形状示例:
{{"pass": true, "reason": null}}{{"pass": false, "reason": "一句话说明"}}"""
else:
prompt = f"""你是事实核对员。比较用户口述与模型生成的叙事。
【用户口述】
{oral[:8000]}
【模型生成的叙事】
{gen[:16000]}
{pass_rules}
判断:生成稿是否出现口述中**明显没有**的具体新实体或虚构细节?
若仅为口述的书面化整理含文学性改写、情感渲染、过渡衔接pass=true。
输出形状示例:
{{"pass": true, "reason": null}}{{"pass": false, "reason": "一句话说明"}}"""
try:
out = llm_json_call(
llm,
prompt,
FidelityOutput,
max_tokens=settings.memoir_fidelity_check_max_tokens,
agent="FidelityCheckAgent.passes",
)
ok = bool(out.pass_)
if not ok:
logger.warning(
"event=fidelity_check_fail reason={}",
(out.reason or "")[:200],
)
return ok
except LLMCallError 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