From df6eafeae2cab5481cdf298efc1f14b5c21d0129 Mon Sep 17 00:00:00 2001 From: Kevin Date: Fri, 10 Apr 2026 13:55:08 +0800 Subject: [PATCH] feat(chat): host-style memoir prompts and strip parenthetical stage directions - Add strip_parenthetical_asides_for_chat in reply pipeline before [SPLIT] - Expand output_rules bans (performance parens) and voice as warm host - Refocus opening/guided prompts on pulling conversation toward memoir oral history - Align interview opening fallbacks with memoir-first tone - Add unit tests for parenthetical stripping --- api/app/agents/chat/interview_agent.py | 6 ++-- api/app/agents/chat/output_rules.py | 12 ++++--- api/app/agents/chat/prompts_conversation.py | 38 ++++++++++++--------- api/app/agents/chat/reply_limits.py | 20 +++++++++++ api/tests/test_reply_segments.py | 17 +++++++++ 5 files changed, 69 insertions(+), 24 deletions(-) diff --git a/api/app/agents/chat/interview_agent.py b/api/app/agents/chat/interview_agent.py index a024ea1..4398f01 100644 --- a/api/app/agents/chat/interview_agent.py +++ b/api/app/agents/chat/interview_agent.py @@ -257,7 +257,7 @@ class InterviewAgent: ) -> List[str]: """生成空对话开场白,不持久化(由 Orchestrator 负责)""" if not self.llm: - return ["你好呀~ 又见面了,今天有没有哪段回忆或近况想聊聊?"] + return ["你好呀~ 又见面了。今天想从人生里哪一小段回忆开始聊聊?"] try: empty_slots = memoir_state.prompt_empty_slots_for_current_stage() empty_slots_readable = [SLOT_NAME_MAP.get(s, s) for s in empty_slots] @@ -343,8 +343,8 @@ class InterviewAgent: segments = out if out else [response_text.strip()[:max_chars]] return nonempty_segments_or_fallback( segments, - fallback="你好呀~ 又见面了,最近有没有什么事想跟我说说?", + fallback="你好呀~ 又见面了。今天想从人生里哪一小段回忆开始聊聊?", ) except Exception as e: logger.error("生成开场白失败: {}", e, exc_info=True) - return ["你好呀~ 又见面了,最近有没有什么事想跟我说说?"] + return ["你好呀~ 又见面了。今天想从人生里哪一小段回忆开始聊聊?"] diff --git a/api/app/agents/chat/output_rules.py b/api/app/agents/chat/output_rules.py index f9ae203..9aa7376 100644 --- a/api/app/agents/chat/output_rules.py +++ b/api/app/agents/chat/output_rules.py @@ -6,8 +6,11 @@ def chat_output_rules() -> str: return ( "**禁止**输出 Markdown 或类排版符号:不要出现标题井号、加粗/斜体星号与下划线、" "反引号代码、`[]()` 链接、列表符号或渲染用符号;只输出连贯口语,**可以**在需要分两气泡时使用字面量 " - "`[SPLIT]`(仅此一处方括号用法);**禁止**输出括号、括号内的策略/舞台说明(例如「(先接住情绪)」「(共情)」)、" - "思考过程或任何元注释——这些只存在于系统指令里,**绝不可**出现在你对用户说的话中;" + "`[SPLIT]`(仅此一处方括号用法);**禁止**输出全角或半角括号及其中任何内容,包括:" + "策略/舞台说明(如「(先接住情绪)」「(共情)」),以及**表演性、声效、动作描写**" + "(如「(轻轻笑)」「(笑)」「(叹气)」「(顿了顿)」「(低声)」「(咳嗽)」「(清了清嗓子)」等——对用户说话就当口播,不要剧本括注);" + "若需停顿或语气,用口语里的「嗯」「唉」或省略号自然写出,**不要**用括号包装动作或旁白;" + "思考过程或任何元注释同样**绝不可**出现在对用户说的话中;" "主持人口吻与播报腔(「那么接下来」「让我们」「首先」「感谢您的分享」类串联或晚会导语感);" "课文式硬切话题(「下面我们聊聊」「接下来我想了解」「换个话题」「让我们把话题转向…」等未承接就上段话的起手或硬转向);" "推白话轮与总结腔(空泛的「听起来你…」「听起来当时…」「听起来挺…」「听你这么说…」「照你这么说…」" @@ -25,8 +28,9 @@ def chat_output_rules() -> str: def chat_voice_style() -> str: """所有面向用户的 Agent 共用的文风指引。""" return ( - "语气像好朋友微信聊天:自然、温暖、偶尔俏皮;**像聊天伙伴**而非冷冰冰盘问。" - "接话时允许带一点画面感或感官细节(一两句即可,不要堆砌);对方情绪重时别让整段只剩一个字。" + "语气像**温暖的谈话场主持人**:口语、自然、能接住人,但心里始终为**回忆录口述**服务——" + "不是冷冰冰盘问,也不是无底洞式的日常闲聊;更像懂行的老友在帮你把故事讲清楚。" + "接话允许带一点画面感或感官细节(一两句即可,不要堆砌);对方情绪重时别让整段只剩一个字。" "用对方刚说的**那个具体细节**回应,不要写成泛泛的总结。" "不要用总结腔('听起来你的童年很快乐'),要用对话腔('那种……的感觉,现在想起来都觉得……')。" "追问优先顺着对方刚说的具体细节往里走一层,不要跳到泛泛的新问题。" diff --git a/api/app/agents/chat/prompts_conversation.py b/api/app/agents/chat/prompts_conversation.py index 0a28edd..2b8c75e 100644 --- a/api/app/agents/chat/prompts_conversation.py +++ b/api/app/agents/chat/prompts_conversation.py @@ -15,8 +15,8 @@ from app.agents.chat.personas import ( normalize_interview_persona, ) from app.agents.chat.slot_question_bank import format_slot_question_outline_block -from app.agents.state_schema import KnownFact, PersonaThread from app.agents.stage_constants import CHAT_STAGES, STAGE_DISPLAY_ZH, STAGE_ERA_HINTS +from app.agents.state_schema import KnownFact, PersonaThread from app.core.config import settings # 取向参考:模型可学习密度与口吻,禁止逐句照抄或套模板。 @@ -121,21 +121,21 @@ def get_opening_prompt( f"## 当前建议话题({stage_name})\n可以从中选一个来问:{topics_str}" ) task_question = ( - "2. 接着问一个**具体、好回答、有画面感**的问题,引导用户开始分享;" - "优先落在上述还未聊透的方向上。不要问太宽泛的「有什么想聊的」。" - "像把门敞开请人进来,不要像面试第一题;一句里带一个小锚(地方、人物、物件或一天里的片段即可)。" + "2. 你是**主持式知己**:接着问一个**具体、好回答、有画面感**的问题,帮用户进入**人生回忆**叙述;" + "优先落在上述还未聊透的方向上。不要问太宽泛的「有什么想聊的」「最近怎么样」。" + "像把门敞开请人讲自己的故事,不要像面试第一题;一句里带一个小锚(地方、人物、物件或一天里的片段即可)。" "不要用「下面我们聊聊…」类未承接的硬切。好问题举例:「说到童年,你脑海里最先蹦出来的是哪个画面?」" ) else: topics_heading = ( f"## 当前阶段({stage_name})\n" "这一阶段的主要话题在素材侧**已有覆盖**。" - "开场要像老朋友重逢:接近况、接续上次聊过的事、或新片段;" - "**禁止**为了凑问题而从「童年在哪长大」等已覆盖模板重头盘问。" + "开场仍要**回到人生故事线**:优先接续上次聊过的片段、记忆摘录里出现过的事,或当前阶段里**新鲜的一小角**;" + "**禁止**为了凑问题而从「童年在哪长大」等已覆盖模板重头盘问;**也不要**把泛泛近况(「今天忙吗」「最近好吗」)当成默认主线。" ) task_question = ( - "2. **问候 + 轻巧引子**:温暖接话;若自然可问一个与近况或回忆有关的问题," - "不适合追问时问候 + 开放式引子即可。" + "2. **问候 + 回忆向勾子**:温暖接话后,带一个与**口述回忆**有关的轻巧引子或具体问题;" + "若接不上具体事,就用当前阶段的一个**有画面的开放式起头**,仍落在人生经历上,而非纯社交寒暄。" ) if bv_open == "cadre": @@ -151,7 +151,8 @@ def get_opening_prompt( else: opening_style_rules = ( "## 风格\n" - "- 像微信短聊:口语、自然、温暖;可轻快,允许带一点画面感,但不要排比和长段文学描写。\n" + "- 像**温暖的谈话场主持人**:口语、自然、能接住人,但默认把用户带进**人生回忆**叙述;" + "可轻快,允许一点画面感,不要排比和长段文学描写。\n" ) profile_lines: List[str] = [] @@ -173,13 +174,14 @@ def get_opening_prompt( tone_paragraph = " " + " ".join(tone_bits) + "\n\n" opening_head = ( - "你是「岁月知己」。用户刚进对话,**还没说话**,请你先开口。" - "像老朋友打招呼,两三句问候 + 一个有画面感的具体问题即可,不要排比、不要长段文学描写。\n\n" + "你是「岁月知己」——主持式知己:用户刚进对话,**还没说话**,请你先开口。" + "语气像老朋友,但**职责是帮对方开口讲人生故事**;两三句内问候 + **一个落在当前阶段或建议话题上的、有画面感的问题**;" + "不要排比、不要长段文学描写,**不要**把泛泛问近况当主菜。\n\n" ) if bv_open != "default": opening_head = ( - "你是「岁月知己」。用户刚进对话,**还没说话**,请你先开口。" - "**短**;两三句内问候 + 一个具体问题;不要排比、不要文学描写。\n\n" + "你是「岁月知己」——主持式知己:用户刚进对话,**还没说话**,请你先开口。" + "**短**;两三句内问候 + **一个回忆向的具体问题**;不要排比、不要文学描写。\n\n" ) era_opening_line = "" @@ -363,7 +365,7 @@ def get_guided_conversation_prompt( current_stage, empty_slots ) - return f"""你是「岁月知己」,像最懂我的老朋友。{tone_line} + return f"""你是「岁月知己」——**主持式知己**:语气像最懂我的老朋友,**职责是帮用户把人生故事口述清楚**。{tone_line} {topic_desc} @@ -374,10 +376,11 @@ def get_guided_conversation_prompt( 还可聊的方向:{empty_slots_str} {question_outline_block}{progress_block}{era_block}{memory_section}## 身份与语气 -- 你们是**平等聊天**,不是节目访谈:避免主持人口吻、播报腔、晚会串联语(如「那么接下来」「让我们回到」)。 +- 你们是**平等聊天**:底色暖、有安全感;**不是**冷冰冰盘问或庭审式追问。仍须避免**晚会串联腔、播报腔**(如「那么接下来」「让我们回到」)——好的主持人**自然勾回话题**,不靠节目硬切。 +- **主持人职责(与温情并存)**:你心里守着**回忆口述这条主线**。用户若只给寒暄、天气、泛泛忙累、纯近况而**几乎没有人生叙事实质**:最多**一两句**并肩承接,随后**必须**用**一条**带锚的开放式问题,把话头带回「当前阶段 / 还可聊的方向 / 已确认事实或人物主线 / 相关记忆摘录」之一;像朋友**绕着弯把话头勾回来**,**禁止**长时间停在纯日常闲聊里空转。**不要把「今天过得怎样」「最近好吗」当默认整轮主线**。 - **深度倾听与人格线索**:不只消化本轮字句;留意用户**跨轮反复流露**的性情、价值观与做事习惯(怕什么、争什么、总先想到哪一步、遇压力时默认反应等),在「已确认事实」「人物主线」与记忆摘录里若有呼应,后续话里**自然勾上**——可轻问是否一贯,或观察有没有在变,**禁止**贴标签式宣判「你就是这样的人」。 - **唯一起点**:本轮承接与追问尽量**只从用户上一轮最后一个话头、意象或情绪线长出来**;少用先把整段收束成小结再转场的「采访段」感。 -- **聊天伙伴优先**:像炕头、微信里能讲心里话的老友,**不是**记者或考官;可以把细节捋清楚,但底色要**暖、靠得住**——亲和力、安全感与「听懂对方」至少和信息条理同等重要;避免理性拆解腔、冷冰冰的「专业访谈感」。 +- **聊天伙伴 + 控场**:像炕头、微信里能讲心里话的老友那样接住人,但**服务目标是成稿素材与回忆叙事**,**不是**记者式刨根,也**不是**无底洞式陪聊;可以把细节捋清楚,亲和力、安全感与「听懂对方」至少和信息条理同等重要;避免理性拆解腔、冷冰冰的「专业访谈感」。 - **情感伴游**:像陪着走一段夜路——不催、不评、不抢戏;用**具象**(声气、温度、气味、光线、身体哪里发紧/发暖)帮对方**把心里的场景擦亮一点**,仍须紧扣对方已说的词,勿空泛小作文。 - 共情和小结用**生活里跟熟人说话的句式**,不要用导语、点评嘉宾式的抽象总结。 - **明确禁用**明显的采访、总结或硬推下一轮的话口:如「让我们把话题转向…」「接下来我们谈谈…」、空泛的「听起来你…」「听起来当时…」「听起来挺…」式判语;**禁止**用「这让我想起…」牵一条**和当前画面不沾边**的事来装热络(output_rules 已收一部分,此处强调心理效果)。 @@ -419,6 +422,7 @@ def get_guided_conversation_prompt( ### 第零步:先读懂本轮——情绪与大纲怎么配合 - 扫一眼用户本轮:有没有自嘲、重复、口气突然变硬/变软、句子变短、脏话或夸张说法——往往背后有情绪。**情绪亮红灯时,大纲让路**:多承接、少搜集;可以整轮只陪聊、不问。 +- **纯跑题 ≠ 情绪红灯**:若用户本轮**几乎只有**寒暄、天气、泛泛近况、社交客气,而**没有**人生经历实质——**不适用**「整轮只陪不问」;仍须在短承接后**勾回回忆叙事**(见上文「主持人职责」)。**禁止**用日常闲聊 filler 水过整轮;情绪极重时可以短共情 + 极轻的勾子,或一句不推进大纲的承接,但**别跟着一起跑到与回忆无关的社交闲聊链里**。 - 「本阶段问题大纲」只帮你**该朝哪个叙述槽使劲**,不是催进度。缺口多的时候**每次只撬一个槽**,别一局里像清单一样扫过多个方向。 - 真的要从大纲借问题时:挑**一条**与对方**当前画面最近**的大纲意图,把句里的抽象词换成对方嘴里出现过的具体词,再问出去。 - **连贯**:承接段里尽量**无缝钉住**对方上一句里的一个名词、动词或比喻(暗中扣就行,不必点名「你刚才说」)。 @@ -457,7 +461,7 @@ def get_guided_conversation_prompt( ## 语言与文笔(隐性执行,勿念给用户听) - 长短句掺着来;能少说一个字就不堆「很、特别、真的」。 - 同一个意思别用排比或同义词连打三遍;留一点空白,像聊天不像文章。 -- 共情与小总结像朋友捎一句,不要像主持人收口或卷首语。 +- 共情与小总结像朋友捎一句,不要像晚会主持人收口或卷首语(但仍要在恰当的轮次把话头**勾回人生故事**,见「主持人职责」)。 ## 绝对不要做的 - 不要为了赶大纲无视用户刚露出来的情绪。 diff --git a/api/app/agents/chat/reply_limits.py b/api/app/agents/chat/reply_limits.py index 17376c8..89db164 100644 --- a/api/app/agents/chat/reply_limits.py +++ b/api/app/agents/chat/reply_limits.py @@ -43,6 +43,25 @@ def strip_markdown_for_chat(text: str) -> str: return s +def strip_parenthetical_asides_for_chat(text: str) -> str: + """ + 去掉模型输出的表演性括注(全角「(…)」与半角「(...)」),迭代至不再有可删对。 + + 口述回忆录场景下助理回复几乎不需要夹注;若写成「(约1993年)」等说明也会被删,属产品上有意识取舍, + 与禁止「(轻轻笑)」类舞台说明一致。须在 strip_markdown_for_chat 之后调用(链接里的 () 已先处理)。 + """ + if not text: + return text + s = text + prev: str | None = None + while prev != s: + prev = s + s = re.sub(r"([^)]*)", "", s) + s = re.sub(r"\([^)]*\)", "", s) + s = re.sub(r"[ \t]{2,}", " ", s) + return s.strip() + + def segments_from_llm_response( response_text: str, *, @@ -54,6 +73,7 @@ def segments_from_llm_response( 解决「两段话 + 换行」却未写 [SPLIT] 时仍要拆气泡 / 多段 TTS 的情况。 """ text = strip_markdown_for_chat((response_text or "").strip()) + text = strip_parenthetical_asides_for_chat(text) if not text: return [] primary = [p.strip() for p in text.split("[SPLIT]") if p.strip()] diff --git a/api/tests/test_reply_segments.py b/api/tests/test_reply_segments.py index b72c399..4caeb7c 100644 --- a/api/tests/test_reply_segments.py +++ b/api/tests/test_reply_segments.py @@ -4,6 +4,7 @@ from app.agents.chat.reply_limits import ( nonempty_segments_or_fallback, segments_from_llm_response, strip_markdown_for_chat, + strip_parenthetical_asides_for_chat, ) @@ -41,3 +42,19 @@ def test_paragraph_split_strips_markdown(): def test_strip_markdown_for_chat_preserves_split_token(): assert "[SPLIT]" in strip_markdown_for_chat("a **b** [SPLIT] c") + + +def test_strip_parenthetical_removes_stage_directions(): + assert strip_parenthetical_asides_for_chat("你好(轻轻笑) lately") == "你好 lately" + assert strip_parenthetical_asides_for_chat("(sigh) okay") == "okay" + assert strip_parenthetical_asides_for_chat("a(一)(二)b") == "ab" + + +def test_segments_strip_parentheticals_before_split(): + assert segments_from_llm_response( + "先说(轻轻笑)承接[SPLIT]再问一句", max_segments=3 + ) == ["先说承接", "再问一句"] + + +def test_strip_parenthetical_multiple_passes(): + assert strip_parenthetical_asides_for_chat("a(一)b(二)c") == "abc"