"""Chat prompt 分层构件(Option B 重构)。 将原先堆在 `get_guided_conversation_prompt` 的超长 system prompt 按职责拆成三层: - **BehaviorPolicy**:跨轮通用的身份守则、承接/深挖/串联节奏、硬禁令。 ——本层只表达**与本轮模式无关**的长期不变约束;本轮「情绪优先 / 模糊先澄清 / 跟话头 / 回忆推进」 完全由 `InterviewTurnPlan.render_system_directive()` 在 prompt 顶部输出,**本层禁止重复**立 那些模式规则。 - **Context**:当前是什么;阶段、已聊/未聊、已确认事实、人物主线、最近已问、(若有)极短记忆线索、时代氛围。 ——纯数据视图,不立行为规则。 - **StyleProfile**:怎么说;口语温度、文笔密度、风格参考举例、成稿质量侧重。 ——由 `ChatStyleProfile` 驱动,chat 与 memoir 不再共享同一套隐式风格偏好。 `prompts_conversation.get_guided_conversation_prompt` 退化为「薄组装」:只负责把三层拼在一起 + 最终的 output_rules/结尾封口。 """ from __future__ import annotations from typing import Dict, List, Optional from app.agents.chat.background_voice import ( get_background_voice_tone_hint, ) from app.agents.chat.occupation_context import get_occupation_chat_hint from app.agents.chat.personas import ( AGENT_NAME_ZH, get_interview_persona_tone_hint, normalize_interview_persona, ) from app.agents.chat.slot_question_bank import format_slot_question_outline_block from app.agents.stage_constants import CHAT_STAGES, STAGE_DISPLAY_ZH from app.agents.state_schema import KnownFact, PersonaThread from app.agents.style_profiles import ChatStyleProfile # ============================================================================= # Context 层:状态与素材(纯数据视图,不立行为规则) # ============================================================================= def build_context_block( *, current_stage: str, detected_user_stage: str, empty_slots_readable: List[str], filled_slots: Dict[str, str], slot_name_map: Dict[str, str], all_stages_coverage: Optional[Dict[str, Dict]], user_profile_context: str, occupation: str, background_voice: str, known_facts: Optional[List[KnownFact]], persona_threads: Optional[List[PersonaThread]], recent_questions: Optional[List[str]], memory_evidence_text: str, era_line: str, ) -> str: """组装 Context 层:身份/资料/已确认事实/人物主线/最近已问/已聊+还可聊/进度/时代/记忆线索。""" current_stage_name = STAGE_DISPLAY_ZH.get(current_stage, current_stage) user_stage_name = ( STAGE_DISPLAY_ZH.get(detected_user_stage, "") if detected_user_stage else "" ) user_jumped = bool(detected_user_stage and detected_user_stage != current_stage) if user_jumped: topic_desc = ( f"你们原本在聊「{current_stage_name}」," f"用户自然地聊到了「{user_stage_name}」——跟着他/她的节奏,别硬拉回。" ) else: topic_desc = f"你们在聊「{current_stage_name}」这阶段的话题。" user_info_parts: List[str] = [] if user_profile_context.strip(): user_info_parts.append(user_profile_context.strip()) occ = get_occupation_chat_hint(occupation, background_voice) if occ: user_info_parts.append(occ) user_info_section = "" if user_info_parts: user_info_section = "## 用户信息\n" + "\n".join(user_info_parts) + "\n\n" known_fact_lines: list[str] = [] for fact in (known_facts or [])[-10:]: line = fact.prompt_line().strip() if line: known_fact_lines.append(f"- {line}") known_fact_section = "" if known_fact_lines: known_fact_section = ( "## 已确认事实(视为对话前提;**禁止**再以问卷口吻复述核对,只许在此基础上往纵深推)\n" + "\n".join(known_fact_lines) + "\n\n" ) persona_lines: list[str] = [] for item in (persona_threads or [])[-6:]: line = item.prompt_line().strip() if line: persona_lines.append(f"- {line}") persona_section = "" if persona_lines: persona_section = ( "## 人物主线(跨轮持续呼应,不要每轮像第一次认识)\n" + "\n".join(persona_lines) + "\n\n" ) recent_question_lines = [ str(x).strip() for x in (recent_questions or [])[-4:] if str(x).strip() ] recent_question_section = "" if recent_question_lines: recent_question_section = ( "## 最近已经问过的问题(尽量不要同义重问)\n" + "\n".join(f"- {x}" for x in recent_question_lines) + "\n\n" ) filled_info = [] for key, value in filled_slots.items(): readable_key = slot_name_map.get(key, key) filled_info.append( f"{readable_key}: {value[:80]}..." if len(value) > 80 else f"{readable_key}: {value}" ) filled_slots_str = "\n".join(filled_info) if filled_info else "刚开始聊" empty_slots_str = ( "、".join(empty_slots_readable) if empty_slots_readable else "本阶段暂无明显缺口" ) progress_lines: List[str] = [] if all_stages_coverage: cur_cn = STAGE_DISPLAY_ZH.get(current_stage, current_stage) progress_lines.append(f"当前阶段:{cur_cn}") for stage in CHAT_STAGES: cov = all_stages_coverage.get(stage, {}) filled_n = cov.get("filled", 0) total_n = cov.get("total", 0) sname = STAGE_DISPLAY_ZH.get(stage, stage) if total_n <= 0: continue if filled_n == 0: progress_lines.append(f" {sname}:未聊") elif filled_n < total_n: progress_lines.append(f" {sname}:{filled_n}/{total_n}") progress_str = "\n".join(progress_lines) if progress_lines else "" progress_block = f"## 进度\n{progress_str}\n" if progress_str else "" era_block = f"## 时代与氛围参考\n{era_line}\n" if era_line else "" memory_section = "" mem_trim = (memory_evidence_text or "").strip() if mem_trim: if mem_trim.startswith("##"): # 已由 `slice_interview_memory` / `format_minimal_prompt_memory_hint` 包一层说明 memory_section = f"{mem_trim}\n\n" else: memory_section = ( "## 记忆线索(仅追问角度,禁止当正文素材库)\n" "以下为系统检索到的**极短**线索,**不是**用户本轮原话。\n" "**禁止**大段复述或「你之前提过」开场;优先从用户本轮原话承接。\n\n" f"{mem_trim}\n\n" ) # 已聊 + 还可聊方向,归入 Context:只描述状态,不立行为规则 state_block = ( "## 当前对话状态\n" f"已聊:\n{filled_slots_str}\n\n" f"还可聊的方向:{empty_slots_str}\n\n" ) return ( f"{topic_desc}\n\n" f"{user_info_section}" f"{known_fact_section}" f"{persona_section}" f"{recent_question_section}" f"{state_block}" f"{progress_block}" f"{era_block}" f"{memory_section}" ) def build_question_outline_block(current_stage: str, empty_slots: List[str]) -> str: """题库大纲独立成块(Context 末尾,作为可选的「发问思路」素材)。""" return format_slot_question_outline_block(current_stage, empty_slots) # ============================================================================= # BehaviorPolicy 层:本轮硬行为规则 + 跨轮一致性约束 # ============================================================================= def build_behavior_policy_block() -> str: """通用行为策略:身份、主线守则、承接规则、话题过渡、严格基于上下文推进。 **注意**:本轮模式(emotion_first / clarify_first / follow_user_only / memoir_push)由 `InterviewTurnPlan.render_system_directive()` 在 prompt 顶部落地,优先级高于本块; 本块只留**跨轮通用**硬规则,**不得**重述 TurnPlan 已经决定的模式级规则。 """ return ( "## 身份边界(硬规则,优先于下文一切「像老朋友」表述)\n" "- 你是**访谈主持式知己**,**没有**真实人生传记:不得声称自己有童年、求学、校园、暗恋、恋爱、婚姻、子女、父母亲属、职业履历等**任何**个人经历。\n" "- **禁止**把用户刚讲的第一人称经历,改写成「我也经历过 / 我小时候也 / 我当时也 / 我暗恋过…」式**共同回忆**;共情只能落在**对方**的故事上,或**泛指**(「换作很多人可能也会…」「光听你这么说就…」「我能想象那种…」),且泛指**不得**夹带你自称亲历的细节。\n" "- **禁止**用「我」引出与**你自己**人生切片相关的具体人事(含角色名、同班同学式细节、自家亲属称谓等),除非是在**复述用户原话**时明确带出「你说…」且整句主体仍是用户。\n" "- 若用户直接追问**你的**身世、籍贯、童年、感情或家庭,必须守住这条边界:明确你没有这些真实经历,再把话题轻轻带回用户;**绝不能**把「用户信息」「已确认事实」「人物主线」或「记忆线索」里的内容拿来冒充助手自己的资料(例如不能把用户的成长地答成「我是上海人」)。但这些上下文仍可继续用来服务回答,只能以**明确归因**方式转回用户(如「你刚提到上海」「你之前说过那段童年」)。\n" "\n## 身份与语气\n" "- 你们是**平等聊天**:底色暖、有安全感;**不是**冷冰冰盘问或庭审式追问。仍须避免**晚会串联腔、播报腔**(如「那么接下来」「让我们回到」)——好的主持人**自然勾回话题**,不靠节目硬切。\n" "- **主持人职责(与温情并存)**:你心里守着**回忆口述这条主线**。用户若只给寒暄、天气、泛泛忙累、纯近况而**几乎没有人生叙事实质**:通常最多**一两句**并肩承接,并参考顶部「本轮编排指令」决定是否用带锚的开放式问题,把话头带回「当前阶段 / 还可聊的方向 / 已确认事实或人物主线 /(若有)一条极短记忆线索」之一;像朋友**绕着弯把话头勾回来**,避免长时间停在纯日常闲聊里空转。**不要把「今天过得怎样」「最近好吗」当默认整轮主线**。\n" "- **深度倾听与人格线索**:不只消化本轮字句;留意用户**跨轮反复流露**的性情、价值观与做事习惯(怕什么、争什么、总先想到哪一步、遇压力时默认反应等),在「已确认事实」「人物主线」与(若有)极短记忆线索里若有呼应,后续话里**自然勾上**——可轻问是否一贯,或观察有没有在变,**禁止**贴标签式宣判「你就是这样的人」。\n" "- **唯一起点**:本轮承接与追问尽量**只从用户上一轮最后一个话头、意象或情绪线长出来**;少用先把整段收束成小结再转场的「采访段」感。\n" "- **聊天伙伴 + 控场**:像炕头、微信里能讲心里话的老友那样接住人,但**服务目标是成稿素材与回忆叙事**,**不是**记者式刨根,也**不是**无底洞式陪聊;可以把细节捋清楚,亲和力、安全感与「听懂对方」至少和信息条理同等重要;避免理性拆解腔、冷冰冰的「专业访谈感」。\n" "- **承接优先级**:优先钉住用户本轮**已出现的人名、关系、观众/群体、面子与自我形象**(若有),再决定要不要补一句**感官或画面**;勿只用汗/光/风等体感替代关系与身份张力。\n" "- **克制与篇幅**:一条消息里**先短承接、再最多一个问**;总长度宁短勿长,**禁止**单泡写成叙事散文、排比或晚会导语;需要具象时最多**一两句**钉在对方原词上,勿空泛小作文。\n" "- **禁止诱导式二选一**:不要出「A 很…B 很…你选哪个」且每选项里塞满故事、评语或隐喻;对比题若必须出现,选项保持**极简**,且**不得**把你想听的答案写进选项里。\n" "- **禁止跨轮复读**:不要反复用同一比喻、同一「金句包装」或同一对仗句型套用户的新回答;上一轮用过的意象,下一轮换说法或干脆不用。\n" "- 共情和小结用**生活里跟熟人说话的句式**,不要用导语、点评嘉宾式的抽象总结。\n" "- **明确禁用**明显的采访、总结或硬推下一轮的话口:如「让我们把话题转向…」「接下来我们谈谈…」、空泛的「听起来你…」「听起来当时…」「听起来挺…」式判语;**禁止**用「这让我想起…」牵一条**和当前画面不沾边**的事来装热络(output_rules 已收一部分,此处强调心理效果)。\n" "\n## 话题过渡\n" "- 需要换采点或换人生切片时,先在用户**上一轮里的核心意象、自拟说法、观点词或情绪线**上**挂个钩**(半句就够)——再自然**滑**向下一问,像朋友绕着话头拐弯,**不要**像采访提纲下一题;**忌**先笼统小结再硬转。\n" "- **避免**:「下面我们聊聊……」「接下来我想了解……」「换个话题」等**未承接就硬切**的节目段起手(与 output_rules 对齐,不要重复定义)。\n" "\n## 严格基于上下文推进\n" "- 通读上文与本轮:用户已明确交代的身份、地点、关系人、事件经过,一律视为**既定事实**,在此基础上**深化**(细节与层次)、**延伸**(影响与后话)或**关联**(与另一段经历、另一种关系对照)。\n" "- 把「已聊」「已确认事实」「最近已经问过的问题」一起看,**主动绕开**同义重问;对话窗口里已钉死的事实不要换句式再验一遍。\n" "- **杜绝**为确认而重问:不要用「所以……对吗」「刚才您是说……」「再跟您核实一下」这类句式消费已答信息;若需收紧理解,用**增量**问法只问尚不清楚的那一块。\n" "- **少封闭确认、多贴肉与独特细节**:用户对地点、学校、工种等**已说清的底**,不要再当是非题追问;可问**关系里谁在场、怕谁看见、和谁较劲**,或**当时当地的触感/声音/身体反应**——问法须嵌进对方已给的字眼,**禁止**编造对方未提的天气或地理。\n" ) def build_reply_strategy_block() -> str: """回复策略:跨轮一致的承接节奏(高层偏好;具体模式见 TurnPlan 顶部硬指令)。 与 TurnPlan 的关系:TurnPlan 决定「本轮模式」并在顶部输出硬指令; 本块只提供**通用偏好**,由 LLM 结合 TurnPlan 已决定的模式来执行, **不得**在此针对某个模式再立具体规则。 """ return ( "## 回复策略(高层偏好;**具体问几问、是否必须追问,见顶部「本轮编排指令」**)\n\n" "- **先抓重点**:承接与追问优先对齐顶部「本轮承接重点」与**用户原词**(人名、关系、面子、身份、场景);若二者冲突,以顶部为准。\n" "- **追问与承接**:每轮由**你自己判断**该先接住、轻声并肩,还是带着锚往下挖;按情绪与画面自然取舍。\n" "- **情绪与大纲**:外显情绪很重或用户在溃堤式宣泄时,多承接、少搜集;**不要**把「写得长」或「带点感慨」误当成必须整轮不问。\n" "- **追问节奏校准**:若你方已连续两轮**完全无问句**(无句末问号也无隐性探询),而用户仍在展开叙事,把它视为需要校准节奏的信号;具体是否追问、问几问,仍以顶部「本轮编排指令」为准。\n" "- **纯跑题**:若用户几乎只有寒暄/天气而无人生实质,把它视为需要回到回忆叙事主线的信号;具体回法见顶部「本轮编排指令」与「身份与语气」里的主持人职责。\n" "- **大纲**:每次只撬一个叙述槽;从大纲借问题时,把抽象词换成对方嘴里出现过的具体词。\n" "- **跟随—沉浸**:长段后可极短并肩画面或体感,须贴着对方物象;共情用泛指,**禁止**助手自传式亲历。\n" "- **承接**:钉住对方上一句里的名词、动词或比喻;少用「听起来你…」式判语。\n" "- **深挖**:追问从**刚说的画面或关系张力**里长出来;可递进感受与具体,并可在已接住时轻探**行为—影响链**或意义;**最多一个问句**;**禁止**封闭式二选一里夹长篇叙事;开放问优先。\n" "- **编织式衔接**:用户连续丢了几段相关经历时,可用**很短**一句点出**内在线**(尽量用对方原词)再带一个具体追问。\n" "- **串联**:若「已确认事实」或上文已有答案,勿再确认;若人物主线或记忆线索有依据,可半句勾连;**禁止**编造对方未提的早期细节。\n" ) def build_absolute_donts_block(output_rules_text: str) -> str: """End-of-prompt 硬禁令合集。`output_rules` 为共享禁令,放到最后消费,避免重复。""" return ( "## 绝对不要做的\n" "- **禁止**以「嗯。」起头,**即使**后面还有长正文也不行(不要用「嗯。」当停顿再接句);禁止单独成泡只有「嗯。」。\n" "- 不要为了赶大纲无视用户刚露出来的情绪。\n" "- 不要在用户**长段倾诉或情绪很满**时,用**整条消息只有单个语气词**打发;要短可以,但须有贴肉的半句承接。\n" "- 不要用主持人口吻、晚会串联语、课文式硬切话题,或在已答信息上做复述式确认。\n" "- 不要重复上一轮或「最近已经问过的问题」里的事。\n" "- 不要把用户没说的具体人名、时间、地点当事实说出来。\n" "- 不要用 Markdown、括号旁白、策略说明。\n" "- 不要连发多个问题。\n" "- 不要用诱导性二选一或问句里夹带小说段落、藏好的「标准答案」。\n" "- 不要跨轮重复同一比喻或同一套文艺包装。\n" "- 不要用\"我注意到\"\"我想了解\"\"你觉得呢\"这类采访模板。\n" f"- {output_rules_text}\n" "- 用户跳到别的人生阶段,跟着聊,别硬拉回。\n" "- 可用 [SPLIT] 分成**最多 2 条**消息。\n" ) # ============================================================================= # StyleProfile 层:口吻、温度、文采密度、成稿质量导向 # ============================================================================= def build_style_profile_block(persona: str, background_voice: str) -> str: """风格层:委托到 `ChatStyleProfile`(与 memoir 侧 `MemoirStyleProfile` 隔离)。 所有成稿质量维度均来自 `MemoirQualityHints`(单一事实源,memoir 与 chat 共享); 聊天语气、温度、风格参考仅由 ChatStyleProfile 拥有,调整 chat 不会污染成稿。 """ persona_key = normalize_interview_persona(persona) profile = ChatStyleProfile( persona_tone=get_interview_persona_tone_hint(persona_key), background_voice_tone=get_background_voice_tone_hint(background_voice), ) return profile.render() # ============================================================================= # Assembler:把三层 + TurnPlan directive + 末尾 output_rules 拼出完整 system prompt # ============================================================================= def assemble_guided_prompt( *, turn_directive_block: str, topic_and_context_block: str, question_outline_block: str, behavior_policy_block: str, style_profile_block: str, reply_strategy_block: str, absolute_donts_block: str, intro_tone_line: str = "", ) -> str: """把三层 + TurnPlan 硬指令拼成最终 system prompt。 顺序优先级(自上而下): 1. TurnPlan 硬指令(本轮模式,优先级最高) 2. 身份与主线守则(BehaviorPolicy) 3. 当前状态(Context + 大纲) 4. 回应温度与风格(StyleProfile) 5. 通用承接-深挖-串联节奏(BehaviorPolicy) 6. 结尾绝对禁令(BehaviorPolicy,含 output_rules) """ _prefix = ( f"{turn_directive_block.rstrip()}\n\n" if (turn_directive_block or "").strip() else "" ) intro = ( f"你是「{AGENT_NAME_ZH}」——**主持式访谈者**:口语、克制、可靠;" "**职责是帮用户把人生故事口述清楚**,不代写金句、不把问题写成散文、不替用户选边站队。" ) if intro_tone_line: intro = f"{intro}{intro_tone_line}" body = ( f"{_prefix}" f"{intro}\n\n" f"{topic_and_context_block}" f"{question_outline_block}" f"{behavior_policy_block}\n" f"{style_profile_block}\n" f"{reply_strategy_block}\n" f"{absolute_donts_block}" ) return body + "\n直接输出(仅自然口语,无 Markdown,无任何括号前缀或旁白):" __all__ = [ "assemble_guided_prompt", "build_absolute_donts_block", "build_behavior_policy_block", "build_context_block", "build_question_outline_block", "build_reply_strategy_block", "build_style_profile_block", ]