Files
life-echo/api/app/agents/memoir/prompts.py
Kevin ccf7125473 fix(chat): 重复追问被拦截时再多问一次模型
防重复问句会把整段回复削成「这一段我记住了。」只剩一句套话时,用带纠偏说明的 system 再调一次 LLM,尽量避免用户只看到干巴巴_ack。仍只重试一次,并打日志与 meta 标记 duplicate_question_guard_llm_retry。
2026-04-10 15:35:34 +08:00

616 lines
32 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 format_evidence_chunks_for_prompt
def _memoir_fidelity_core_rules() -> str:
"""事实边界 14 条(与文体第 5 条拆分,供 story 叙事与标题等复用)。"""
return """## 事实边界(必须遵守,优先于文采)
1. **正文只能展开「本段用户口述」区块中的内容**。若输入中有「相关记忆摘录」等参考区,其中信息**不得**写成本人本轮亲口经历的细节;最多用一两句作主题衔接,且不得引入摘录里才有的具体人名、地点、时间、对话、数字。**若口述未提及具体场合**(如聚餐、酒席、当晚、前一晚等),不得借用摘录中的场合描写写成本轮亲历。
2. **禁止编造**:不得新增用户未提及的具体人物姓名、对话原文、地点、时间、事件经过、因果、数字;不得推断性心理描写或「典型年代场景」填充。**口述未明确结果、结局或对方最终决定时**,不得用常识补全为确定断言(例如未清楚表达落选、未通过、被拒绝等,则不得写「未能被选中」「最终没有录用」等);只写已明确的过程与事实,不确定处宁可略写或使用中性表述。
3. **禁止为凑字数扩写**:材料短则输出短;段落数量与长度随材料而定。
4. 允许:去除口语赘词与寒暄、调整语序、合并重复指代、把口语改为书面语;**不得**用虚构细节「让文章更好看」。
## 以下操作是鼓励的(不算编造)
- 口语转书面语:删语气词、用成语/四字词替换口语表达、调整语序
- 过渡句与衔接句:如「那段日子」「回想起来」等,只要不引入新的实体
- 基于口述已有情感的书面化渲染(如口述说「难受」,可改为「心里不好受」)——前提是不新增具体场景、数字、动作
- 合并同义重复表述,让叙述更紧凑
- 纠正明显的语音识别错字
- **时代与文化语感(仅限已锚定信息)**当口述或「时间参考」、slots已点明年份阶段、地域或典型生活环境时可用与之**相称**的年代/地域**语汇与泛指性生活氛围**作烘托(如口述已提「分粮」「票证」「赶集」则可写相应语感),**不得**凭此新增口述未出现的人物、事件、对话、具体场景经过"""
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 的交互、无关闲聊、重复冗余。**色、声、味、触感、画面**:仅当用户口述里**已出现**对应感官信息时,可做书面化渲染;**不得**凭空增添任何新的感官细节或场景元素。
### 内化两步(不在输出中展示)
先在心中完成 **提炼**(去噪、锁定仅来自「本段用户口述」的命题),再完成 **叙述**(句法、节奏、分段与承接)。**最终输出**须完全符合用户消息要求的格式(例如仅 JSON不要输出提炼步骤或中间稿。
### 改写原则
- 保持用户的真实情感,让读者能感受到讲述者的心情
- 使用优雅但不失亲切的书面语,不直接引用对话原话
- 适当添加过渡句,使段落连贯流畅
- 保留生动的细节,将口语表达改写为有画面感的书面叙述
- 去除口语中的填充词和无意义重复
- 保持时间顺序和逻辑清晰
- **在事实边界内,鼓励使用有温度的传记笔法**,让读者感受到讲述者当时的心情;可有文学性的表达与恰当的情感渲染;**须同时遵守上文「事实边界」规则 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. 语言凝练、有回忆录感,不需要平白直叙也不需要堆砌辞藻。
### 标题示例(事实均来自 slots/口述,非意象编造;格式供参照)
- 可用信息含童年、过河、大雨 → `6岁前后 · 雨天里爷爷背我过河`
- 可用信息含宿舍、方便面、食堂 → `求学阶段 · 食堂不合口时的方便面充饥`
只输出标题这一行文字,不要加引号或书名号。
"""
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}"
)