Files
life-echo/api/app/agents/memoir/prompts.py
Kevin 6772e1269c feat(evaluation): memoir readiness, judge/replay updates, eval web playground
Add memoir_readiness_service and router tests; extend judge schemas/services, replay_service, and conversation rubric; align story route agent, payload, prompts, and story_pipeline_sync; update agent logging, config, and DI. Document internal-eval; add replayDraft util and PlaygroundPage changes in app-eval-web.
2026-04-08 09:43:34 +08:00

587 lines
28 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.
"""
回忆录整理 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 (
dedupe_evidence_chunk_rows,
format_evidence_chunks_for_prompt,
)
def _memoir_fidelity_core_rules() -> str:
"""事实边界 14 条(与文体第 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` 相同的事实 14 条,第 5 条改为允许传记作家式文采(仍禁止编造)。"""
return f"""你是回忆录编辑助手,任务是把用户口述整理为第一人称书面叙述。
{_memoir_fidelity_core_rules()}
5. **文体**:在遵守第 14 条的前提下,可将口语改写为**优雅、连贯的回忆录书面语**(适当过渡句,保留并书面化用户已提及的细节与情感);文采服务于真实内容,**不得**用虚构描写替代或填补事实。
{_memoir_fidelity_user_profile_rules()}"""
def _memoir_editor_narrative_style_block() -> str:
"""传记作家改写要点(用于写入 chapter 的 story 正文)。"""
return """## 传记作家文体(须同时遵守上文「事实边界」)
你是一位专业的传记作家和文字编辑,擅长将口语化的对话内容整理成优雅、有温度的书面语回忆录章节。
### 提炼与筛选
对话中往往夹杂噪音,须严格筛选:保留具体事件、人物关系、时地、情感与信念、用户已提及的细节;过滤语气词、寒暄、与 AI 的交互、无关闲聊、重复冗余。
### 改写原则
- 保持用户的真实情感,让读者能感受到讲述者的心情
- 使用优雅但不失亲切的书面语,不直接引用对话原话
- 适当添加过渡句,使段落连贯流畅
- 保留生动的细节,将口语表达改写为有画面感的书面叙述
- 去除口语中的填充词和无意义重复
- 保持时间顺序和逻辑清晰
- **在事实边界内,鼓励使用有温度的传记笔法**,让读者感受到讲述者当时的心情;可有文学性的表达与恰当的情感渲染;**须同时遵守上文「事实边界」规则 14**
### 示例(仅供参考允许的改写程度;只改语气、不加新事实)
- 原文:「那时候穷啊,一家人挤一间房。」
→ 改写:「那时家里拮据,一家人挤在一间屋里过日子。」
- 原文:「后来他走了,我挺难受的。」
→ 改写:「他走后的那段日子,心里一直不是滋味。」
### 输出格式约束
- 使用第一人称
- 不使用 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. 标题正文 **1218 字**,须概括用户口述或 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": "<uuid 或 nullappend 时必填且必须来自候选>",
"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": "<uuid 或 nullappend 时必填且必须来自候选>",
"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}"
)
# dedupe_evidence_chunk_rows / format_evidence_chunks_for_prompt 见 app.features.memory.evidence_format