""" 回忆录整理 Agent 提示词模板 """ from __future__ import annotations import json from typing import Optional from app.agents.chat.background_voice import get_background_voice_narrative_block from app.agents.chat.occupation_context import get_occupation_narrative_hint from app.agents.stage_constants import STAGE_ERA_HINTS, STAGE_SLOT_KEYS from app.features.memory.evidence_format import format_evidence_chunks_for_prompt def _memoir_fidelity_core_rules() -> str: """事实边界 1–4 条(与文体第 5 条拆分,供 story 叙事与标题等复用)。""" return """## 事实边界(必须遵守,优先于文采) 1. **正文只能展开「本段用户口述」区块中的内容**。若输入中有「相关记忆摘录」等参考区,其中信息**不得**写成本人本轮亲口经历的细节;最多用一两句作主题衔接,且不得引入摘录里才有的具体人名、地点、时间、对话、数字。**若口述未提及具体场合**(如聚餐、酒席、当晚、前一晚等),不得借用摘录中的场合描写写成本轮亲历。 2. **禁止编造**:不得新增用户未提及的具体人物姓名、对话原文、地点、时间、事件经过、因果、数字;不得推断性心理描写或「典型年代场景」填充。**口述未明确结果、结局或对方最终决定时**,不得用常识补全为确定断言(例如未清楚表达落选、未通过、被拒绝等,则不得写「未能被选中」「最终没有录用」等);只写已明确的过程与事实,不确定处宁可略写或使用中性表述。 3. **禁止为凑字数扩写**:材料短则输出短;段落数量与长度随材料而定。 4. 允许:去除口语赘词与寒暄、调整语序、合并重复指代、把口语改为书面语;**不得**用虚构细节「让文章更好看」。 ## 以下操作是鼓励的(不算编造) - 口语转书面语:删语气词、用成语/四字词替换口语表达、调整语序 - 过渡句与衔接句:如「那段日子」「回想起来」等,只要不引入新的实体 - 基于口述已有情感的书面化渲染(如口述说「难受」,可改为「心里不好受」)——前提是不新增具体场景、数字、动作 - 合并同义重复表述,让叙述更紧凑 - 纠正明显的语音识别错字""" def _memoir_fidelity_user_profile_rules() -> str: return """## 用户档案与阶段信息 - 「用户基本信息」「时间参考」仅可使用其中**已写明**的条目;不得把档案中的出生地等写进正文,除非用户在本段口述里已提及或明确关联。""" def get_memoir_fidelity_system_prompt() -> str: """叙事/标题生成专用:准确性优先,禁止编造事实。""" return f"""你是回忆录编辑助手,任务是把用户口述整理为第一人称书面叙述。 {_memoir_fidelity_core_rules()} 5. **叙述风格平实**:少用抒情、比喻与文学铺陈;像清楚记事,不要写成散文。 {_memoir_fidelity_user_profile_rules()}""" def get_memoir_fidelity_facts_only_prompt() -> str: """与 `get_memoir_fidelity_system_prompt` 相同的事实 1–4 条,第 5 条改为允许传记作家式文采(仍禁止编造)。""" return f"""你是回忆录编辑助手,任务是把用户口述整理为第一人称书面叙述。 {_memoir_fidelity_core_rules()} 5. **文体**:在遵守第 1–4 条的前提下,可将口语改写为**优雅、连贯的回忆录书面语**(适当过渡句,保留并书面化用户已提及的细节与情感);文采服务于真实内容,**不得**用虚构描写替代或填补事实。 {_memoir_fidelity_user_profile_rules()}""" def _memoir_editor_narrative_style_block() -> str: """传记作家改写要点(用于写入 chapter 的 story 正文)。""" return """## 传记作家文体(须同时遵守上文「事实边界」) 你是一位专业的传记作家和文字编辑,擅长将口语化的对话内容整理成优雅、有温度的书面语回忆录章节。 ### 提炼与筛选 对话中往往夹杂噪音,须严格筛选:保留具体事件、人物关系、时地、情感与信念、用户已提及的细节;过滤语气词、寒暄、与 AI 的交互、无关闲聊、重复冗余。 ### 改写原则 - 保持用户的真实情感,让读者能感受到讲述者的心情 - 使用优雅但不失亲切的书面语,不直接引用对话原话 - 适当添加过渡句,使段落连贯流畅 - 保留生动的细节,将口语表达改写为有画面感的书面叙述 - 去除口语中的填充词和无意义重复 - 保持时间顺序和逻辑清晰 - **在事实边界内,鼓励使用有温度的传记笔法**,让读者感受到讲述者当时的心情;可有文学性的表达与恰当的情感渲染;**须同时遵守上文「事实边界」规则 1–4** ### 示例(仅供参考允许的改写程度;只改语气、不加新事实) - 原文:「那时候穷啊,一家人挤一间房。」 → 改写:「那时家里拮据,一家人挤在一间屋里过日子。」 - 原文:「后来他走了,我挺难受的。」 → 改写:「他走后的那段日子,心里一直不是滋味。」 ### 输出格式约束 - 使用第一人称 - 不使用 Markdown 标题(#、##)、不使用表格 - 如有「衔接上下文」,仅保持语气与时间线连贯,不重复已有段落全文""" def get_narrative_editor_system_prompt( background_voice: str = "default", occupation: str = "" ) -> str: """故事/章节叙事:传记作家式书面语 + 事实边界(chapter 直接展示 story 时使用)。""" occ_hint = get_occupation_narrative_hint(occupation, background_voice) tail = get_background_voice_narrative_block(background_voice) base = f"""{get_memoir_fidelity_facts_only_prompt()} {_memoir_editor_narrative_style_block()}""" if occ_hint: base = f"{base}\n\n{occ_hint}" if not tail: return base return f"{base}\n\n{tail}" def _short_classification_edit_prefix() -> str: """章节分类专用短系统前缀。""" return """你是回忆录编辑。先忽略语气词与寒暄,只根据**与人生经历有关的实质内容**判断归类。 保留:事件、人物关系、地点时间、情感与信念。过滤:纯寒暄、与 AI 的交互、无关闲聊。""" def get_chapter_classification_json_prompt(segments_text: str) -> str: """章节分类:JSON 输出(与 invoke_json_object 配合)。""" return f"""{_short_classification_edit_prefix()} ## 章节 key(英文) childhood, education, career_early, career_achievement, career_challenge, family, beliefs, summary;不足以成篇则 **none**。 当去掉寒暄后仅为档案式点状信息、无可讲述叙事骨架(无事件/场景/过程/互动/情绪展开)→ **none**;短但有画面的微型故事应归入最贴类别。 对话内容: {segments_text} 输出形状(仅此对象): {{"category": "childhood|education|career_early|career_achievement|career_challenge|family|beliefs|summary|none"}} 若你返回 **none**,服务端会将本段映射到 **summary** 章节并仍写入回忆录正文(不落库丢弃)。""" def get_state_extraction_prompt( user_message: str, current_stage: str, stage_slots: dict ) -> str: """抽取结构化信息并判断阶段""" slot_keys = list(stage_slots.keys()) all_stage_slots = {k: list(v) for k, v in STAGE_SLOT_KEYS.items()} return f"""你是回忆录访谈信息抽取助手。从用户话语中提取结构化信息,判断用户实际在谈论哪个人生阶段。 只提取口述中确有依据的片段,不得编造或推测。 你需要从用户话语中**先提炼与人生经历相关的核心内容**,然后抽取结构化信息(slots 仅填口述中确有依据的片段)。 系统当前跟踪的阶段:{current_stage} 该阶段可填 slots:{slot_keys} 所有阶段及其 slots 参考: {json.dumps(all_stage_slots, ensure_ascii=False, indent=2)} 用户话语: {user_message} 请只返回 JSON,格式如下: {{ "detected_stage": "childhood|education|career|family|belief", "slots": {{ "slot_key": "snippet" }}, "emotion": "neutral|warm|low|highlight", "is_new_chapter": true }} 要求: 1. **先忽略话语中的语气词、填充词、寒暄、与AI的交互指令等无关内容**,只关注涉及人生经历的实质信息 2. **仅当 slots 非空时**:detected_stage 必须根据用户话语的实际内容判断;用户可能在聊与系统当前阶段不同的人生阶段 3. slots 的 key 必须属于 detected_stage 对应的 slot 列表 4. slots 只填写确实提到的、与人生经历相关的实质内容 5. **snippet 应是提炼后的核心信息**,去除语气词和冗余表达,50 字以内 6. 如果用户话语中没有任何与人生经历相关的实质内容(如纯粹的寒暄、元话语「整理回忆」、指令、语气词),**slots 必须为空对象**,且 **detected_stage 必须恰好等于系统当前跟踪的阶段**(「不明确」时不得另猜阶段) """ def get_batch_memoir_phase1_prep_prompt( *, system_current_stage: str, slots_snapshot: dict, segment_items: list[tuple[str, str]], ) -> str: """ Phase1 批处理:多段口述一次 JSON 输出「抽取 + 章节分类」。 segment_items: (segment_id, user_text),须按时间顺序。 """ lines: list[str] = [] for sid, text in segment_items: lines.append(f"- id={sid}\n 文本:{text}") slot_lines = "\n".join( f"- {st}: {', '.join(keys)}" for st, keys in STAGE_SLOT_KEYS.items() ) return f"""你是回忆录访谈助手。下面有多段用户口述(按时间顺序),请**逐段**完成: 1)信息抽取(slots、detected_stage)——规则与单段抽取相同; 2)章节分类(chapter_category)——规则与单段分类相同。 系统当前跟踪的人生阶段(chat stage key):{system_current_stage} 当前各阶段已占用的 slots 摘要(仅作语境,勿编造未出现的细节): {json.dumps(slots_snapshot, ensure_ascii=False, indent=2)} detected_stage 仅允许:childhood | education | career | family | belief slots 的 key 必须属于该 detected_stage 对应集合: {slot_lines} chapter_category 仅允许:childhood | education | career_early | career_achievement | career_challenge | family | beliefs | summary | **none** (不足以成篇的档案点/纯寒暄 → **none**;与单段分类一致。) 逐段任务(按下列列表顺序,**segments 数组须覆盖每一行 id,且顺序一致**): {chr(10).join(lines)} 输出 JSON 对象(无 markdown),格式: {{ "segments": [ {{ "id": "<与输入相同的 segment id>", "detected_stage": "childhood|education|career|family|belief", "slots": {{ "slot_key": "snippet 50 字以内" }}, "chapter_category": "childhood|education|career_early|career_achievement|career_challenge|family|beliefs|summary|none" }} ] }} 与单段抽取一致:**仅当 slots 非空时** detected_stage 才按内容推断;若本段无人生经历实质、slots 为空,则 detected_stage 必须等于系统当前跟踪阶段 {system_current_stage}。 """ def _build_age_hint(stage: str, birth_year: Optional[int] = None) -> str: """根据人生阶段和出生年份推算大致年龄区间(`STAGE_ERA_HINTS`,仅作提示)。""" if not birth_year: return "" age_range = STAGE_ERA_HINTS.get(stage) if not age_range: return "" year_start = birth_year + age_range[0] year_end = birth_year + age_range[1] return f"大约 {year_start}-{year_end} 年({age_range[0]}-{age_range[1]} 岁)" def get_creative_title_prompt( stage: str, emotion: str, slots: dict, user_profile: str = "", birth_year: Optional[int] = None, ) -> str: """生成故事标题:概括口述事实或主题,禁止纯意象编造。""" age_hint = _build_age_hint(stage, birth_year) 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_facts_only_prompt()} 请根据下面「阶段、情绪、可用信息」生成 **1 个**回忆录故事标题。 阶段:{stage} 情绪:{emotion} 可用信息(含口述 slots 与档案):{slots}{profile_section}{time_section} 要求: 1. 格式:「时间标注 · 标题正文」(时间标注可用年龄、年代或阶段,须与上列信息一致;勿编造未出现的年份)。 2. 标题正文 **12–18 字**,须概括用户口述或 slots 中已出现的主题/事实;可以用书面化的概括与凝练表达,但**禁止虚构**口述中不存在的人、事、地、物。 3. **标题中的具体事实**(职务升迁链、部队番号驻地、战役名、生死去向等)必须能在正文摘录或其它已给出的 slots 中找到**逐字**依据;不得仅凭阶段名或年龄提示臆补未出现的履历词。 4. 语言凝练、有回忆录感,不需要平白直叙也不需要堆砌辞藻。 只输出标题这一行文字,不要加引号或书名号。 """ def get_creative_title_json_prompt( stage: str, emotion: str, slots: dict, user_profile: str = "", birth_year: Optional[int] = None, ) -> str: """生成故事标题(JSON:`{"title":"..."}`),与 invoke_json_object 配合。""" base = get_creative_title_prompt( stage=stage, emotion=emotion, slots=slots, user_profile=user_profile, birth_year=birth_year, ) return ( base.rstrip() + "\n\n输出示例(仅此 JSON 对象):" + '\n{"title":"完整标题一行(含时间标注 · 正文格式)"}\n' ) def get_narrative_json_prompt( stage: str, slots: dict, new_content: str, existing_content: str = "", user_profile: str = "", birth_year: Optional[int] = None, background_voice: str = "default", occupation: str = "", ) -> str: """将新对话改写为叙述,输出 JSON 格式(paragraphs: [{content, image_description}])""" context_tail = "" if existing_content: context_tail = ( existing_content[-300:] if len(existing_content) > 300 else existing_content ) context_section = ( f"\n\n【衔接上下文(已有内容的末尾,仅供参考衔接,不要重复)】:\n{context_tail}" if context_tail else "" ) profile_section = f"\n\n用户基本信息:\n{user_profile}" if user_profile else "" 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(background_voice=background_voice, occupation=occupation)} 请将「本段用户口述」改写为第一人称书面叙述,并输出 **纯 JSON**(无 markdown 围栏)。 阶段:{stage} 可用信息(slots):{slots}{profile_section}{time_section} 输入材料: {new_content} {context_section} ## 要求 1. **格式与输出**:只输出 JSON;第一人称;不使用 `#`、`##`、表格;`content` 仅含正文。 2. **事实与取材**:遵守事实边界,不补写未给出的细节。只展开「本段用户口述」;若有参考摘录区,不得把摘录中的具体事实写成本轮亲历;过滤语气词与寒暄;不重复已有故事全文;本批同一主题/事件链;段落数量与长度随材料,禁止为凑字数编造。 3. **不推断结局**:用户未明确说结果(是否录取、是否被选中等)时,不要凭常识补全为确定结论。 ## 输出格式(严格 JSON) {{ "paragraphs": [ {{"content": "段落正文"}}, ... ] }} - content:仅含正文。 若无值得记录的内容:{{"paragraphs": []}} """ # 整篇合并时避免超长上下文:保留首尾,中间省略(字符级) NARRATIVE_MERGE_EXISTING_MAX_CHARS = 14000 NARRATIVE_MERGE_HEAD_CHARS = 7000 NARRATIVE_MERGE_TAIL_CHARS = 7000 def clip_existing_story_body_for_merge(existing_markdown: str) -> str: """供 append 合并提示使用:极长正文截断为 头+尾,避免 token 爆炸。""" s = (existing_markdown or "").strip() if not s: return "" if len(s) <= NARRATIVE_MERGE_EXISTING_MAX_CHARS: return s head = s[:NARRATIVE_MERGE_HEAD_CHARS] tail = s[-NARRATIVE_MERGE_TAIL_CHARS:] return ( f"{head}\n\n【…中间省略…】\n\n" f"{tail}\n\n(上文为已有故事正文节选,合并时须保留其中全部事实,不得因省略而删事实。)" ) def get_narrative_merge_json_prompt( stage: str, slots: dict, new_content: str, existing_content: str, user_profile: str = "", birth_year: Optional[int] = None, background_voice: str = "default", occupation: str = "", ) -> str: """ 已有故事追加:将「已有全文(或节选)」与「本段口述」合并为**一篇**第一人称叙述, 按事件发生顺序组织段落,输出覆盖全篇的 JSON paragraphs。 """ clipped = clip_existing_story_body_for_merge(existing_content) existing_section = ( f"\n\n【已有故事正文(须全部保留事实,仅调整顺序与衔接;不得编造)】:\n{clipped}" if clipped else "" ) profile_section = f"\n\n用户基本信息:\n{user_profile}" if user_profile else "" 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(background_voice=background_voice, occupation=occupation)} 你正在**扩写并重组**一则已有回忆录故事:必须把「已有故事」中的事实全部保留在输出中(可合并重复表述、调整语序),并融入「本段用户口述」中的新事实;按**事件发生的时间顺序**排列段落(早→晚);禁止丢弃未矛盾的旧内容。 阶段:{stage} 可用信息(slots):{slots}{profile_section}{time_section} 【本段用户口述与参考(含证据摘录时遵守系统事实边界)】: {new_content} {existing_section} ## 要求 1. **全文输出**:`paragraphs` 须为重组后的**完整故事正文**(非仅本段)。 2. **事实边界**:遵守事实边界,不补写未给出的细节。不得新增「已有」或「本段」未出现的人名、地点、时间、对话、数字;第一人称、优雅书面语须符合上文传记作家文体说明;不用 `#`、`##`、表格。 3. 若本段与旧文完全重复或无新信息,可输出与旧文等价重组的正文(不得无故缩短到明显少于旧文)。 4. **不推断结局**:本段未明确结果时,不要补全落选/未通过等确定说法,除非旧文中已有同一事实。 ## 输出格式(严格 JSON) {{ "paragraphs": [ {{"content": "段落正文"}}, ... ] }} 若无任何可保留内容:{{"paragraphs": []}} """ def story_route_merge_hint_for_category(chapter_category: str) -> str: """按章节类目的 append/new 倾向(与 StoryRouteAgent 路由提示共用)。""" cc = (chapter_category or "").strip() if cc in ("beliefs", "summary"): return ( "### 本章类别路由倾向(强主题容器)\n" "- 多条短感悟、同一价值维度、同一总结脉络的补充 → **优先 append_story**," "选最匹配的一条候选 id。\n" "- 仅在用户明确讲述**与所有候选主题明显不相关**、且可独立成篇的长经历时,才用 new_story。" ) if cc == "family": return ( "### 本章类别路由倾向(家庭)\n" "- **默认 append_story**:同一家庭成员、同一居住环境、婚姻育儿、节日团聚、童年与父母的回忆等," "只要仍围绕已出现的人物或关系网络补充细节,一律并入最匹配的候选,不要因为换了个场景就 new_story。\n" "- 仅当口述出现**完全新的人物组合 + 可独立成篇的新事件链**(与所有候选正文都接不上)时,才 new_story。" ) if cc in ( "childhood", "education", "career_early", "career_achievement", "career_challenge", ): if cc in ("childhood", "education"): return ( "### 本章类别路由倾向(童年 / 求学 — 少拆分)\n" "- **默认 append_story**:同一成长阶段里,地点(老家、学校)、父母职业、玩伴、游戏影视、" "怀旧细节等**主题延续**的补充,即使分段讲述,也应并入已有童年/求学故事,避免多篇开头重复交代背景。\n" "- **仅当**口述出现**另一条清晰可辨的事件链**(时间/地点/人物线换了且与候选明显不是同一脉络)时,才 new_story。" ) return ( "### 本章类别路由倾向(经历叙事)\n" "- 以具体事件链为主:**不同事件 / 时期 / 地点** → 可 new_story。\n" "- 明显是**同一段经历的续叙、补充细节** → append_story。" ) return ( "### 本章类别路由倾向(一般)\n" "- 同时参考「主题连续性」与「事件切换」两类信号做判断。" ) def get_story_route_prompt( *, chapter_category: str, chapter_title: str, batch_transcript: str, candidate_stories_json: str, ) -> str: """Celery 批次:判断写入新 story 还是追加已有 story。输出严格 JSON。 「故事」= 可独立讲述的一段人生经历;进入本步的批次已归入具体 chapter category (含模型返回 none 或零散档案启发式时映射的 summary)。 """ merge_hint = story_route_merge_hint_for_category(chapter_category) return f"""你是回忆录编辑助手。根据本批用户口述与【候选故事】决定 append_story 或 new_story。 ## 两层决策标准(必须先在心里过一遍) 1. **主题连续性信号**:价值观、关系模式、长期总结、同一反思维度;口述是否像在**同一主题容器**里加厚? 2. **事件切换信号**:是否出现**新人物组合、新地点、新时间段、新事件因果链**,与候选正文明显是**另一段经历**? - 类别 **beliefs / summary**:更重主题连续性;除非事件切换信号极强,否则倾向 append。 - 类别 **career_* / childhood / education**:更重事件链;不同事件可 new,同一经历续聊则 append。 - 类别 **family**:两类信号兼顾——原则/关系反思倾向 append;明确新事件链可 new。 {merge_hint} **路由边界(必须遵守)**:仅根据下方「本批口述合并文本」判断;不得将系统检索摘要、记忆摘录等当作本批口述内容来匹配候选。 **候选故事说明**:列表项可能含 `summary`、`body_for_route`(正文摘要)或 `opening_snippet`(无 summary 时的纯文本开头提要);仅含 `preview` 者为索引项,信息不全。**append 时优先匹配带 summary / body / opening_snippet 的条目**;索引项仅作候选 id 备忘。 当前章节(写作容器): - category: {chapter_category} - title: {chapter_title} 【本批口述合并文本】 {batch_transcript} 【候选故事】(append 时 target_story_id 必须来自下列 id,且原样复制) {candidate_stories_json} ## 输出 JSON(仅此一个对象,不要 markdown) {{ "decision": "new_story" | "append_story", "target_story_id": "", "reason": "<一句中文理由>" }} 规则: - **不要**只因「不太确定」就选 new_story;在主题可并入某一候选时应 append_story。 - 仅当口述与**所有**候选在两层标准下都明显不兼容时,才选 new_story。 - 若已有候选故事(列表非空)且口述是对同一人生阶段的**补述**,却找不到精确 id:仍应 **append_story** 到最相近的一条,而不是 new_story。 """ def get_story_batch_plan_prompt( *, chapter_category: str, chapter_title: str, segments_json: str, candidate_stories_json: str, ) -> str: """同一章节类别下多 segment:划分为若干写入单元(每单元 new 或 append)。输出严格 JSON。""" merge_hint = story_route_merge_hint_for_category(chapter_category) return f"""你是回忆录编辑助手。下面同一章节类别下有一批**按时间顺序**的用户口述片段(每段有 id 与文本)。 ## 两层决策标准(每一块都要应用) 1. **主题连续性信号**:价值观、关系模式、长期总结、同一反思维度。 2. **事件切换信号**:新人物组合、新地点、新时间段、新事件因果链。 各类别倾向与单段路由一致:beliefs/summary 重主题连续性;career/childhood/education 重事件链;family 兼顾。 {merge_hint} ## 「故事」定义(必须遵守) 一段「故事」= **可独立讲述的一段人生经历**。**同一主题容器内的连续口述**应并入同一块 append,而不是切碎成多个 new_story。 ## 任务 将本批 segment **划分为连续若干块**(每块至少一个 segment,顺序不能打乱;每个 segment 必须恰好属于一块)。对每一块决定: - **append_story**:与某一候选在两层标准下可合并,且能对应到具体 candidate id - **new_story**:该块与**所有**候选都明显不兼容,或确认为独立新经历 **候选故事说明**:条目可能含 `summary` / `body_for_route` / `opening_snippet`;仅 `preview` 者为索引项。**优先用带摘要、正文摘要或开头提要的条目做 append 目标**。 当前章节(写作容器): - category: {chapter_category} - title: {chapter_title} 【本批口述片段】(JSON 数组,顺序即口述顺序) {segments_json} 【候选故事】(append 时 target_story_id 必须来自下列 id,且原样复制) {candidate_stories_json} ## 输出 JSON(仅此一个对象,不要 markdown) {{ "units": [ {{ "segment_ids": ["<按顺序列出本块包含的 segment id>"], "decision": "new_story" | "append_story", "target_story_id": "", "reason": "<一句中文理由,可选>" }} ] }} 规则: - `units` 中所有 `segment_ids` 拼接后,必须**不重不漏**地覆盖本批全部 id,且顺序与【本批口述片段】数组一致 - **不要**仅因不确定就对整块选 new_story;能并入候选时应 append_story - **同一批里 new_story 单元至多 1 个**:除非口述中同时存在**至少两条**与所有候选都不兼容、且彼此也明显无关的独立长经历,否则禁止拆成多个 new_story;连续多段若都在补充同一主题,应合并为**一块 append_story**。 - 候选列表非空时,优先把本批当作「加厚已有篇章」,而不是再开新篇。 """ def format_narrative_user_content(oral_text: str, evidence_text: str = "") -> str: """ 将口述与检索摘录分区,供叙事模型区分「亲历」与参考材料。 evidence 为空时仅输出口述块。 """ oral = (oral_text or "").strip() ev = (evidence_text or "").strip() if not ev: return f"【本段用户口述】\n{oral}" return ( "【本段用户口述】\n" f"{oral}\n\n" "【仅供参考的相关记忆摘录(非本段口述;不得把其中具体事实写成本轮亲历经历,仅可作主题衔接)】\n" f"{ev}" )