feat(api): 访谈人格/回复长度策略、口述归一、背景语气与输入净稿全链路
Chat 访谈 - 新增 persona 系统(default / warm_listener / curious_guide)与 background_voice 语气层 - 回复长度由 compute_reply_plan 统一决策(brief / standard / expanded),融合信息密度启发式 - 输入净稿(input_normalize):编排层可选 rules/llm 归一用户口语后再喂模型与记忆检索 - 记忆证据注入:按用户话检索 memory evidence 并注入 prompt Memoir 回忆录 - 口述归一(oral_normalize):segment 原文保留,story 管线取派生净稿作叙事输入 - segment 入队批次门闸:累计字数 + 最长等待秒数,减少零碎提交 - fidelity_check / prompts / narrative_agent 微调 - Alembic 0005:清理跨章节 story 外键 Infra - Dockerfile 加入 ffmpeg - pyproject.toml 新增依赖并同步 uv.lock - .env.example / .env.production 补全新配置项 Tests - 新增 test_background_voice、test_chat_input_normalize、test_experience_regressions - 扩展 test_interview_prompts、test_interview_reply_length、test_story_route_oral_invariant Made-with: Cursor
This commit is contained in:
@@ -1,6 +1,9 @@
|
||||
"""回忆录模块:MemoirOrchestrator、各 Specialist Agent。"""
|
||||
|
||||
from app.agents.memoir.classification_agent import ClassificationAgent
|
||||
from app.agents.memoir.classification_agent import (
|
||||
ChapterClassifyResult,
|
||||
ClassificationAgent,
|
||||
)
|
||||
from app.agents.memoir.extraction_agent import ExtractionAgent, ExtractionResult
|
||||
from app.agents.memoir.fidelity_check_agent import FidelityCheckAgent
|
||||
from app.agents.memoir.narrative_agent import NarrativeAgent
|
||||
@@ -14,6 +17,7 @@ from app.agents.memoir.story_route_agent import (
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"ChapterClassifyResult",
|
||||
"MemoirOrchestrator",
|
||||
"PreparedMemoirBatches",
|
||||
"StoryRouteAgent",
|
||||
|
||||
@@ -10,6 +10,7 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from app.agents.memoir.prompts import (
|
||||
@@ -95,6 +96,14 @@ def _normalize_llm_category(raw: str) -> str:
|
||||
return s
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ChapterClassifyResult:
|
||||
"""章节分类结果;``llm_said_none`` 仅当走 LLM 且解析为 none 时为 True(fragment 启发式不为 True)。"""
|
||||
|
||||
category: str
|
||||
llm_said_none: bool = False
|
||||
|
||||
|
||||
def _parse_category_from_llm_response(raw: str) -> str:
|
||||
"""优先解析 JSON ``{"category": "..."}``,失败则按纯文本 key 处理。"""
|
||||
s = (raw or "").strip()
|
||||
@@ -119,10 +128,11 @@ class ClassificationAgent:
|
||||
llm: Any,
|
||||
*,
|
||||
segment_id: str | None = None,
|
||||
) -> str:
|
||||
) -> ChapterClassifyResult:
|
||||
"""
|
||||
分类到 8 个章节类别之一。
|
||||
LLM 返回 none 或启发式为零散档案时,返回 ``summary``(仍走回忆录流水线)。
|
||||
LLM 返回 none 或启发式为零散档案时,``category`` 为 ``summary``(仍可走回忆录流水线;
|
||||
``llm_said_none`` 仅在 LLM 明确返回 none 时为 True,供空转抑制判断)。
|
||||
llm 需支持 .invoke(prompt) 同步调用。
|
||||
"""
|
||||
if _looks_like_fragment_only(text):
|
||||
@@ -133,7 +143,10 @@ class ClassificationAgent:
|
||||
len(text or ""),
|
||||
_SUMMARY_FALLBACK_CATEGORY,
|
||||
)
|
||||
return _SUMMARY_FALLBACK_CATEGORY
|
||||
return ChapterClassifyResult(
|
||||
category=_SUMMARY_FALLBACK_CATEGORY,
|
||||
llm_said_none=False,
|
||||
)
|
||||
|
||||
if llm:
|
||||
try:
|
||||
@@ -153,14 +166,18 @@ class ClassificationAgent:
|
||||
len(text or ""),
|
||||
_SUMMARY_FALLBACK_CATEGORY,
|
||||
)
|
||||
return _SUMMARY_FALLBACK_CATEGORY
|
||||
return ChapterClassifyResult(
|
||||
category=_SUMMARY_FALLBACK_CATEGORY,
|
||||
llm_said_none=True,
|
||||
)
|
||||
if category in CHAPTER_CATEGORIES:
|
||||
return category
|
||||
return ChapterClassifyResult(category=category, llm_said_none=False)
|
||||
except Exception as e:
|
||||
logger.warning("ClassificationAgent LLM 章节分类失败: {}", e)
|
||||
|
||||
stage = _detect_stage(text, fallback_stage)
|
||||
return _STAGE_TO_DEFAULT_CATEGORY.get(
|
||||
cat = _STAGE_TO_DEFAULT_CATEGORY.get(
|
||||
stage,
|
||||
_STAGE_TO_DEFAULT_CATEGORY.get(fallback_stage, "childhood"),
|
||||
)
|
||||
return ChapterClassifyResult(category=cat, llm_said_none=False)
|
||||
|
||||
@@ -54,21 +54,35 @@ class FidelityCheckAgent:
|
||||
return True
|
||||
existing = (existing_canonical_markdown or "").strip()
|
||||
_log_suspicious_years_not_in_oral(oral, gen)
|
||||
if existing:
|
||||
prompt = f"""你是事实核对员。当前为**续写合并**:模型需要把「已有故事正文」与「本轮口述」合成一篇,生成稿**允许且应当**保留已有正文中的事实(可改写语序、合并段落),并融入本轮口述中的新事实。
|
||||
pass_rules = """## 以下行为是 pass(不算编造)
|
||||
- 口语转书面语(删语气词、调语序、用成语替换口语)
|
||||
- 过渡句与衔接句(「那段日子」「回想起来」等,不引入新实体)
|
||||
- 基于口述已有情感的渲染与书面化(如口述说「难受」,改写为「心里像堵了一团棉花」,但不能新增具体场景细节)
|
||||
- 合并同义重复表述
|
||||
- 纠正明显的语音识别或同音错别字
|
||||
|
||||
【用户本轮口述】(本段亲口补充)
|
||||
## 以下行为是 fail(算编造)
|
||||
- 新增口述中**没有**的具体人名、地名、时间、数字、对话原文
|
||||
- 补全口述未说明的结果或结局(如「最终没考上」)
|
||||
- 把系统摘录/档案里才有的信息写成用户亲口经历
|
||||
- 虚构具体场景细节来「让文章更好看」"""
|
||||
|
||||
if existing:
|
||||
prompt = f"""你是事实核对员。当前为**续写合并**:生成稿应保留「已有故事正文」中的事实并融入「本轮口述」中的新事实。
|
||||
|
||||
【用户本轮口述】
|
||||
{oral[:8000]}
|
||||
|
||||
【已有故事正文】(已落库、允许在生成稿中出现或改写;出现于此处的内容**不算**本轮编造)
|
||||
【已有故事正文】(已落库,出现于此处的内容**不算**编造)
|
||||
{existing[:12000]}
|
||||
|
||||
【模型生成的 JSON 叙事】
|
||||
【模型生成的叙事】
|
||||
{gen[:16000]}
|
||||
|
||||
判断:生成稿是否出现**既明显不在本轮口述、也明显不在已有故事正文**的具体人名、地名、时间、数字、事件经过、对话,或把摘录/档案里才有的信息写成了用户亲口经历?
|
||||
若内容可归因于「已有故事」或「本轮口述」的合理整理,pass=true。
|
||||
若存在无法归因的明显编造或越界,pass=false。
|
||||
{pass_rules}
|
||||
|
||||
判断:生成稿是否出现**既不在本轮口述、也不在已有正文**的具体新实体或虚构细节?
|
||||
若内容可归因于上述两个来源的合理书面化整理,pass=true。
|
||||
|
||||
**JSON 输出**:只输出一个合法 JSON 对象。
|
||||
{{"pass": true, "reason": null}}
|
||||
@@ -77,16 +91,18 @@ class FidelityCheckAgent:
|
||||
|
||||
只输出 JSON,不要其它文字。"""
|
||||
else:
|
||||
prompt = f"""你是事实核对员。比较下面两段文字。
|
||||
prompt = f"""你是事实核对员。比较用户口述与模型生成的叙事。
|
||||
|
||||
【用户口述】(亲历内容)
|
||||
【用户口述】
|
||||
{oral[:8000]}
|
||||
|
||||
【模型生成的 JSON 叙事】(应只含口述中已有事实的整理,不得添油加醋)
|
||||
【模型生成的叙事】
|
||||
{gen[:16000]}
|
||||
|
||||
判断:生成稿是否出现**口述中明显没有**的具体人名、地名、时间、数字、事件经过、对话,或把摘录/档案里才有的信息写成了用户亲口经历?
|
||||
若存在明显编造或越界,pass=false;若仅口语转书面、删赘词、合并指代,pass=true。
|
||||
{pass_rules}
|
||||
|
||||
判断:生成稿是否出现口述中**明显没有**的具体新实体或虚构细节?
|
||||
若仅为口述的书面化整理(含文学性改写、情感渲染、过渡衔接),pass=true。
|
||||
|
||||
**JSON 输出**:只输出一个合法 JSON 对象。
|
||||
{{"pass": true, "reason": null}}
|
||||
|
||||
@@ -67,6 +67,7 @@ class NarrativeAgent:
|
||||
user_profile: str = "",
|
||||
birth_year: Optional[int] = None,
|
||||
llm: Any = None,
|
||||
background_voice: str = "default",
|
||||
) -> str:
|
||||
"""将新对话改写为叙述。若无 LLM 则直接拼接。
|
||||
|
||||
@@ -86,6 +87,7 @@ class NarrativeAgent:
|
||||
existing_content=existing_content,
|
||||
user_profile=user_profile,
|
||||
birth_year=birth_year,
|
||||
background_voice=background_voice,
|
||||
)
|
||||
max_tokens = 8192
|
||||
agent_name = "NarrativeAgent.generate_narrative_merge"
|
||||
@@ -97,6 +99,7 @@ class NarrativeAgent:
|
||||
existing_content=existing_content,
|
||||
user_profile=user_profile,
|
||||
birth_year=birth_year,
|
||||
background_voice=background_voice,
|
||||
)
|
||||
max_tokens = 4096
|
||||
agent_name = "NarrativeAgent.generate_narrative"
|
||||
|
||||
@@ -31,6 +31,8 @@ class PreparedMemoirBatches:
|
||||
|
||||
state: MemoirStateSchema
|
||||
category_to_segments: Dict[str, List[Segment]]
|
||||
#: segment id 在「LLM 判 none 且 extraction slots 为空」时加入;batch 级短路见 memoir_tasks
|
||||
segment_skip_story_ids: Set[str]
|
||||
|
||||
|
||||
class MemoirOrchestrator:
|
||||
@@ -58,6 +60,7 @@ class MemoirOrchestrator:
|
||||
"""
|
||||
state = get_or_create_state()
|
||||
category_to_segments: Dict[str, List[Segment]] = {}
|
||||
segment_skip_story_ids: Set[str] = set()
|
||||
|
||||
for segment in segments:
|
||||
text = segment.user_input_text or ""
|
||||
@@ -87,12 +90,16 @@ class MemoirOrchestrator:
|
||||
"MemoirOrchestrator.ClassificationAgent.classify",
|
||||
segment_id=segment.id,
|
||||
):
|
||||
chapter_category = self.classification_agent.classify(
|
||||
classify_result = self.classification_agent.classify(
|
||||
text=text,
|
||||
fallback_stage=detected_stage,
|
||||
llm=llm,
|
||||
segment_id=segment.id,
|
||||
)
|
||||
chapter_category = classify_result.category
|
||||
if (not result.slots) and classify_result.llm_said_none:
|
||||
segment_skip_story_ids.add(str(segment.id))
|
||||
|
||||
if agent_summary_enabled():
|
||||
logger.info(
|
||||
"MemoirOrchestrator.segment segment_id={} text_len={} "
|
||||
@@ -114,6 +121,7 @@ class MemoirOrchestrator:
|
||||
return PreparedMemoirBatches(
|
||||
state=state,
|
||||
category_to_segments=category_to_segments,
|
||||
segment_skip_story_ids=segment_skip_story_ids,
|
||||
)
|
||||
|
||||
def run(
|
||||
|
||||
@@ -6,6 +6,12 @@ import json
|
||||
import re
|
||||
from typing import Optional
|
||||
|
||||
from app.agents.chat.background_voice import get_background_voice_narrative_block
|
||||
from app.features.memory.evidence_format import (
|
||||
dedupe_evidence_chunk_rows,
|
||||
format_evidence_chunks_for_prompt,
|
||||
)
|
||||
|
||||
CHAPTER_CATEGORIES = {
|
||||
"childhood": "童年与成长背景",
|
||||
"education": "教育经历与青年时期",
|
||||
@@ -134,7 +140,7 @@ def _memoir_fidelity_core_rules() -> str:
|
||||
"""事实边界 1–4 条(与文体第 5 条拆分,供 story 叙事与标题等复用)。"""
|
||||
return """## 事实边界(必须遵守,优先于文采)
|
||||
1. **正文只能展开「本段用户口述」区块中的内容**。若输入中有「相关记忆摘录」等参考区,其中信息**不得**写成本人本轮亲口经历的细节;最多用一两句作主题衔接,且不得引入摘录里才有的具体人名、地点、时间、对话、数字。
|
||||
2. **禁止编造**:不得新增用户未提及的具体人物姓名、对话原文、地点、时间、事件经过、因果、数字;不得推断性心理描写或「典型年代场景」填充。
|
||||
2. **禁止编造**:不得新增用户未提及的具体人物姓名、对话原文、地点、时间、事件经过、因果、数字;不得推断性心理描写或「典型年代场景」填充。**口述未明确结果、结局或对方最终决定时**,不得用常识补全为确定断言(例如未清楚表达落选、未通过、被拒绝等,则不得写「未能被选中」「最终没有录用」等);只写已明确的过程与事实,不确定处宁可略写或使用中性表述。
|
||||
3. **禁止为凑字数扩写**:材料短则输出短;段落数量与长度随材料而定。
|
||||
4. 允许:去除口语赘词与寒暄、调整语序、合并重复指代、把口语改为书面语;**不得**用虚构细节「让文章更好看」。"""
|
||||
|
||||
@@ -167,18 +173,19 @@ def get_memoir_fidelity_facts_only_prompt() -> str:
|
||||
def _memoir_editor_narrative_style_block() -> str:
|
||||
"""与 `get_memoir_editor_system_prompt` 对齐的传记作家改写要点(用于写入 chapter 的 story 正文)。"""
|
||||
return """## 传记作家文体(须同时遵守上文「事实边界」)
|
||||
你是一位专业的传记作家和文字编辑,擅长将口语化的对话内容整理成优雅的书面语回忆录章节。
|
||||
你是一位专业的传记作家和文字编辑,擅长将口语化的对话内容整理成优雅、有温度的书面语回忆录章节。
|
||||
|
||||
### 提炼与筛选
|
||||
对话中往往夹杂噪音,须严格筛选:保留具体事件、人物关系、时地、情感与信念、用户已提及的细节;过滤语气词、寒暄、与 AI 的交互、无关闲聊、重复冗余。
|
||||
|
||||
### 改写原则
|
||||
- 保持用户的真实情感
|
||||
- 使用优雅但不失亲切的书面语,不要直接引用对话原话
|
||||
- 适当添加过渡句,使段落连贯
|
||||
- 保留生动的细节,但将口语表达改写为书面叙述
|
||||
- 保持用户的真实情感,让读者能感受到讲述者的心情
|
||||
- 使用优雅但不失亲切的书面语,不直接引用对话原话
|
||||
- 适当添加过渡句,使段落连贯流畅
|
||||
- 保留生动的细节,将口语表达改写为有画面感的书面叙述
|
||||
- 去除口语中的填充词和无意义重复
|
||||
- 保持时间顺序和逻辑清晰
|
||||
- **文采服务于真实**:可以有文学性的表达与恰当的情感渲染,但不得虚构新的事实来增色
|
||||
|
||||
### 输出格式约束
|
||||
- 使用第一人称
|
||||
@@ -186,11 +193,15 @@ def _memoir_editor_narrative_style_block() -> str:
|
||||
- 如有「衔接上下文」,仅保持语气与时间线连贯,不重复已有段落全文"""
|
||||
|
||||
|
||||
def get_narrative_editor_system_prompt() -> str:
|
||||
def get_narrative_editor_system_prompt(background_voice: str = "default") -> str:
|
||||
"""故事/章节叙事:传记作家式书面语 + 事实边界(chapter 直接展示 story 时使用)。"""
|
||||
return f"""{get_memoir_fidelity_facts_only_prompt()}
|
||||
tail = get_background_voice_narrative_block(background_voice)
|
||||
base = f"""{get_memoir_fidelity_facts_only_prompt()}
|
||||
|
||||
{_memoir_editor_narrative_style_block()}"""
|
||||
if not tail:
|
||||
return base
|
||||
return f"{base}\n\n{tail}"
|
||||
|
||||
|
||||
def _short_classification_edit_prefix() -> str:
|
||||
@@ -336,7 +347,7 @@ def get_creative_title_prompt(
|
||||
profile_section = f"\n用户基本信息:\n{user_profile}" if user_profile else ""
|
||||
time_section = f"\n时间参考:{age_hint}" if age_hint else ""
|
||||
|
||||
return f"""{get_memoir_fidelity_system_prompt()}
|
||||
return f"""{get_memoir_fidelity_facts_only_prompt()}
|
||||
|
||||
请根据下面「阶段、情绪、可用信息」生成 **1 个**回忆录故事标题。
|
||||
|
||||
@@ -346,8 +357,8 @@ def get_creative_title_prompt(
|
||||
|
||||
要求:
|
||||
1. 格式:「时间标注 · 标题正文」(时间标注可用年龄、年代或阶段,须与上列信息一致;勿编造未出现的年份)。
|
||||
2. 标题正文 **12–18 字**,必须概括 **用户口述或 slots 中已出现的主题/事实**;**禁止**文学意象与比喻(如未提巷子/蝉鸣则不得写)。
|
||||
3. **平实**概括,不得引入口述中不存在的人、事、地、物。
|
||||
2. 标题正文 **12–18 字**,须概括用户口述或 slots 中已出现的主题/事实;可以用书面化的概括与凝练表达,但**禁止虚构**口述中不存在的人、事、地、物。
|
||||
3. 语言凝练、有回忆录感,不需要平白直叙也不需要堆砌辞藻。
|
||||
|
||||
只输出标题这一行文字,不要加引号或书名号。
|
||||
"""
|
||||
@@ -384,6 +395,7 @@ def get_narrative_prompt(
|
||||
user_profile: str = "",
|
||||
birth_year: Optional[int] = None,
|
||||
archived_summaries: str = "",
|
||||
background_voice: str = "default",
|
||||
) -> str:
|
||||
"""将新对话改写为叙述(只输出新内容的改写,不重复已有内容)"""
|
||||
context_tail = ""
|
||||
@@ -406,7 +418,7 @@ def get_narrative_prompt(
|
||||
age_hint = _build_age_hint(stage, birth_year)
|
||||
time_section = f"\n时间参考:{age_hint}" if age_hint else ""
|
||||
|
||||
return f"""{get_narrative_editor_system_prompt()}
|
||||
return f"""{get_narrative_editor_system_prompt(background_voice=background_voice)}
|
||||
|
||||
阶段:{stage}
|
||||
可用信息(slots,仅可复述其中已出现事实):{slots}{profile_section}{time_section}
|
||||
@@ -436,6 +448,7 @@ def get_narrative_json_prompt(
|
||||
existing_content: str = "",
|
||||
user_profile: str = "",
|
||||
birth_year: Optional[int] = None,
|
||||
background_voice: str = "default",
|
||||
) -> str:
|
||||
"""将新对话改写为叙述,输出 JSON 格式(paragraphs: [{content, image_description}])"""
|
||||
context_tail = ""
|
||||
@@ -452,7 +465,7 @@ def get_narrative_json_prompt(
|
||||
age_hint = _build_age_hint(stage, birth_year)
|
||||
time_section = f"\n时间参考:{age_hint}" if age_hint else ""
|
||||
|
||||
return f"""{get_narrative_editor_system_prompt()}
|
||||
return f"""{get_narrative_editor_system_prompt(background_voice=background_voice)}
|
||||
|
||||
请将「本段用户口述」改写为第一人称书面叙述,并输出 **纯 JSON**,不要包含任何其他文字或 markdown 代码块。
|
||||
**JSON 输出**:接口已启用 `response_format=json_object`(与 DeepSeek JSON 模式一致),只输出一个合法 JSON 对象。
|
||||
@@ -469,6 +482,7 @@ def get_narrative_json_prompt(
|
||||
2. 过滤语气词、寒暄、与 AI 的交互;不重复已有故事全文;本批只写同一主题/事件链。
|
||||
3. 段落数量与每段长度**随材料而定**,禁止为凑字数编造。
|
||||
4. 使用第一人称、**优雅书面语**(可适当过渡与铺陈,须基于口述事实);不要直接引用原话;不要用 `#`、`##`、表格。
|
||||
5. **不推断结局**:若用户未明确说结果(是否录取、是否被选中等),不要凭常识补全为确定结论;只复述已说清楚的内容。
|
||||
|
||||
## 输出格式(严格 JSON)
|
||||
{{
|
||||
@@ -512,6 +526,7 @@ def get_narrative_merge_json_prompt(
|
||||
existing_content: str,
|
||||
user_profile: str = "",
|
||||
birth_year: Optional[int] = None,
|
||||
background_voice: str = "default",
|
||||
) -> str:
|
||||
"""
|
||||
已有故事追加:将「已有全文(或节选)」与「本段口述」合并为**一篇**第一人称叙述,
|
||||
@@ -527,7 +542,7 @@ def get_narrative_merge_json_prompt(
|
||||
age_hint = _build_age_hint(stage, birth_year)
|
||||
time_section = f"\n时间参考:{age_hint}" if age_hint else ""
|
||||
|
||||
return f"""{get_narrative_editor_system_prompt()}
|
||||
return f"""{get_narrative_editor_system_prompt(background_voice=background_voice)}
|
||||
|
||||
你正在**扩写并重组**一则已有回忆录故事:必须把「已有故事」中的事实全部保留在输出中(可合并重复表述、调整语序),并融入「本段用户口述」中的新事实;按**事件发生的时间顺序**排列段落(早→晚);禁止丢弃未矛盾的旧内容。
|
||||
|
||||
@@ -545,6 +560,7 @@ def get_narrative_merge_json_prompt(
|
||||
2. **禁止编造**:不得新增用户未在「已有」或「本段」中出现的人名、地点、时间、对话、数字。
|
||||
3. 若本段与旧文完全重复或无新信息,可仅输出与旧文等价重组后的正文(不得无故缩短到明显少于旧文)。
|
||||
4. 使用第一人称、**优雅书面语**(与系统说明中的传记作家文体一致);不要用 `#`、`##`、表格。
|
||||
5. **不推断结局**:本段口述未明确结果时,不要用常识补全落选/未通过等确定说法,除非旧文中已有同一事实。
|
||||
|
||||
## 输出格式(严格 JSON)
|
||||
{{
|
||||
@@ -580,6 +596,8 @@ def get_story_route_prompt(
|
||||
|
||||
**new_story_title 与 reason 只能依据口述中已有信息概括,不得编造口述未出现的人、事、地、物。**
|
||||
|
||||
**路由边界(必须遵守)**:仅根据下方「本批口述合并文本」判断 new_story 与 append_story;不得将系统检索摘要、记忆摘录、图谱事实或其它非用户口述材料当作本批口述内容来匹配候选故事。
|
||||
|
||||
当前章节(写作容器):
|
||||
- category: {chapter_category}
|
||||
- title: {chapter_title}
|
||||
@@ -673,96 +691,7 @@ def format_narrative_user_content(oral_text: str, evidence_text: str = "") -> st
|
||||
)
|
||||
|
||||
|
||||
def _normalize_evidence_line(s: str) -> str:
|
||||
return re.sub(r"\s+", " ", (s or "").strip().lower())
|
||||
|
||||
|
||||
def dedupe_evidence_chunk_rows(chunks: list) -> list:
|
||||
"""
|
||||
对 relevant_chunks 做稳定去重:按归一化后长度降序 + 原下标,单遍包含判定;
|
||||
复杂度 O(n log n);输出按原顺序中保留条目的相对顺序稳定。
|
||||
"""
|
||||
extracted: list[tuple[int, str, object]] = []
|
||||
for i, c in enumerate(chunks):
|
||||
content = (
|
||||
c.get("content", "") if isinstance(c, dict) else getattr(c, "content", "")
|
||||
)
|
||||
t = (content or "").strip()
|
||||
if not t:
|
||||
continue
|
||||
extracted.append((i, t, c))
|
||||
if len(extracted) <= 1:
|
||||
return [x[2] for x in extracted]
|
||||
extracted.sort(
|
||||
key=lambda x: (-len(_normalize_evidence_line(x[1])), x[0]),
|
||||
)
|
||||
kept_norms: list[str] = []
|
||||
kept: list[tuple[int, object]] = []
|
||||
for orig_idx, text, c in extracted:
|
||||
n = _normalize_evidence_line(text)
|
||||
dup = False
|
||||
for kn in kept_norms:
|
||||
if len(n) <= len(kn) and n in kn:
|
||||
dup = True
|
||||
break
|
||||
if not dup:
|
||||
kept_norms.append(n)
|
||||
kept.append((orig_idx, c))
|
||||
kept.sort(key=lambda x: x[0])
|
||||
return [x[1] for x in kept]
|
||||
|
||||
|
||||
def format_evidence_chunks_for_prompt(evidence: dict) -> str:
|
||||
"""将 retrieve_evidence / retrieve_evidence_sync 结果格式化为简短文本,供叙事 prompt 使用。
|
||||
|
||||
包含 chunks、摘要(若有)、confirmed facts、timeline、故事摘要(若有)。
|
||||
"""
|
||||
chunks = evidence.get("relevant_chunks") or []
|
||||
chunks = dedupe_evidence_chunk_rows(chunks[:10])
|
||||
summaries = evidence.get("relevant_summaries") or []
|
||||
facts = evidence.get("relevant_facts") or []
|
||||
timeline = evidence.get("timeline_hints") or []
|
||||
stories = evidence.get("relevant_stories") or []
|
||||
parts: list[str] = []
|
||||
for c in chunks:
|
||||
content = (
|
||||
c.get("content", "") if isinstance(c, dict) else getattr(c, "content", "")
|
||||
)
|
||||
if content:
|
||||
parts.append(content.strip())
|
||||
for s in summaries[:3]:
|
||||
if isinstance(s, dict):
|
||||
st = (s.get("content") or "").strip()
|
||||
stype = (s.get("summary_type") or "").strip()
|
||||
if st:
|
||||
label = f"[摘要:{stype}]" if stype else "[摘要]"
|
||||
parts.append(f"{label} {st}")
|
||||
for f in facts[:5]:
|
||||
if isinstance(f, dict):
|
||||
subj = f.get("subject", "")
|
||||
pred = f.get("predicate", "")
|
||||
obj = f.get("object_json", "")
|
||||
if subj or pred:
|
||||
parts.append(f"{subj} {pred} {obj}")
|
||||
else:
|
||||
parts.append(f"{getattr(f, 'subject', '')} {getattr(f, 'predicate', '')}")
|
||||
for t in timeline[:5]:
|
||||
if isinstance(t, dict):
|
||||
title = (t.get("title") or "").strip()
|
||||
year = t.get("event_year")
|
||||
desc = (t.get("description") or "").strip()
|
||||
line = " ".join(
|
||||
x for x in (str(year) if year is not None else "", title, desc) if x
|
||||
)
|
||||
if line:
|
||||
parts.append(line)
|
||||
for st in stories[:3]:
|
||||
if isinstance(st, dict):
|
||||
title = (st.get("title") or "").strip()
|
||||
summ = (st.get("summary") or "").strip()
|
||||
if title or summ:
|
||||
parts.append(" ".join(x for x in (title, summ) if x))
|
||||
return "\n\n".join(parts) if parts else ""
|
||||
# dedupe_evidence_chunk_rows / format_evidence_chunks_for_prompt 见 app.features.memory.evidence_format
|
||||
|
||||
|
||||
# 向后兼容:旧代码中的 get_system_prompt 指「回忆录编辑」系统提示,勿与访谈模块的 get_system_prompt 混淆
|
||||
|
||||
Reference in New Issue
Block a user