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.
587 lines
28 KiB
Python
587 lines
28 KiB
Python
"""
|
||
回忆录整理 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:
|
||
"""事实边界 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": "<uuid 或 null;append 时必填且必须来自候选>",
|
||
"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 或 null;append 时必填且必须来自候选>",
|
||
"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
|