From 5ff495729ef06c623acde6ae16c41f8754d1bb7f Mon Sep 17 00:00:00 2001 From: Kevin Date: Fri, 10 Apr 2026 13:56:44 +0800 Subject: [PATCH] feat(chat): server-side interview turn plan (mode, anchor slot, snippet) - Add plan_interview_turn: emotion_first / memoir_push / follow_user_only - Inject hard directive block at top of guided system prompt - Pass stage_switched_this_turn from ChatOrchestrator after stage detection - Log interview_turn_plan for observability; add unit tests --- api/app/agents/chat/interview_agent.py | 17 ++ api/app/agents/chat/interview_turn_plan.py | 198 ++++++++++++++++++++ api/app/agents/chat/orchestrator.py | 7 +- api/app/agents/chat/prompt_context.py | 11 ++ api/app/agents/chat/prompts_conversation.py | 5 +- api/tests/test_interview_turn_plan.py | 65 +++++++ 6 files changed, 301 insertions(+), 2 deletions(-) create mode 100644 api/app/agents/chat/interview_turn_plan.py create mode 100644 api/tests/test_interview_turn_plan.py diff --git a/api/app/agents/chat/interview_agent.py b/api/app/agents/chat/interview_agent.py index 4398f01..840fa13 100644 --- a/api/app/agents/chat/interview_agent.py +++ b/api/app/agents/chat/interview_agent.py @@ -15,6 +15,7 @@ from app.agents.chat.interview_state_hints import ( extract_recent_questions, update_recent_questions, ) +from app.agents.chat.interview_turn_plan import plan_interview_turn from app.agents.chat.personas import normalize_interview_persona from app.agents.chat.prompt_context import ChatPromptContext from app.agents.chat.prompts_conversation import ( @@ -99,6 +100,7 @@ class InterviewAgent: occupation: str = "", profile_birth_year: int | None = None, profile_era_place: str = "", + stage_switched_this_turn: bool = False, ) -> AgentChatTurn: """生成状态感知的访谈回复,不持久化(由 Orchestrator 负责)""" if not self.llm: @@ -133,6 +135,20 @@ class InterviewAgent: max_tokens = int(settings.chat_interview_max_tokens) max_chars = int(settings.chat_interview_max_chars_per_segment) + turn_plan = plan_interview_turn( + current_stage=memoir_state.current_stage, + empty_slots=empty_slots, + normalized_user_message=text_for_model, + memory_evidence_text=memory_evidence_text, + stage_switched_this_turn=stage_switched_this_turn, + ) + logger.info( + "event=interview_turn_plan mode={} anchor_slot={} snippet_len={}", + turn_plan.mode, + turn_plan.anchor_slot_key or "-", + len(turn_plan.anchor_snippet or ""), + ) + ctx = ChatPromptContext( current_stage=memoir_state.current_stage, empty_slots=empty_slots, @@ -149,6 +165,7 @@ class InterviewAgent: known_facts=memoir_state.known_facts, persona_threads=memoir_state.persona_threads, recent_questions=recent_questions or memoir_state.recent_questions, + turn_plan=turn_plan, ) system_prompt = ctx.guided_system_prompt() messages: List[Any] = [SystemMessage(content=system_prompt)] diff --git a/api/app/agents/chat/interview_turn_plan.py b/api/app/agents/chat/interview_turn_plan.py new file mode 100644 index 0000000..8fa8efe --- /dev/null +++ b/api/app/agents/chat/interview_turn_plan.py @@ -0,0 +1,198 @@ +""" +访谈轮次编排(方案 A):由服务端显式给出 turn_mode / 主槽 / 挂钩摘录, +减少仅靠长 prompt 软约束时模型「随便问、不往回忆录引」的漂移。 +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Literal + +from app.agents.chat.prompts_conversation import SLOT_NAME_MAP +from app.agents.stage_constants import STAGE_SLOT_KEYS + +InterviewTurnMode = Literal["emotion_first", "memoir_push", "follow_user_only"] + + +@dataclass(frozen=True) +class InterviewTurnPlan: + """单轮访谈的硬目标(供注入 system prompt 顶部,优先级高于一般性建议)。""" + + mode: InterviewTurnMode + anchor_slot_key: str | None + anchor_slot_readable: str + anchor_snippet: str + + +def primary_empty_slot(stage: str, empty_slots: list[str]) -> str | None: + """按 STAGE_SLOT_KEYS 顺序取第一个仍空的槽。""" + if not empty_slots: + return None + order = STAGE_SLOT_KEYS.get(stage, ()) + for key in order: + if key in empty_slots: + return key + return empty_slots[0] + + +def _strip_scene_hint(memory_evidence_text: str) -> str: + raw = (memory_evidence_text or "").strip() + if "[场景氛围提示" in raw: + raw = raw.split("[场景氛围提示", 1)[0].strip() + return raw + + +def extract_anchor_snippet( + *, + memory_evidence_text: str, + user_message: str, + max_chars: int = 180, +) -> str: + """优先记忆摘录,其次用户原话(用于追问挂钩,非事实断言)。""" + mem = _strip_scene_hint(memory_evidence_text) + if mem and len(mem) >= 4: + return mem[:max_chars].strip() + um = (user_message or "").strip() + if len(um) >= 10: + return um[:max_chars].strip() + return "" + + +_EMOTION_MARKERS: tuple[str, ...] = ( + "哭", + "难受", + "委屈", + "害怕", + "后悔", + "恨", + "舍不得", + "崩溃", + "绝望", + "心疼", + "哽咽", + "咽不下", + "睡不着", + "想哭", + "好难", + "太难了", + "挺不住", + "扛不住", + "放不下", + "意难平", +) + + +def _is_emotion_heavy(text: str) -> bool: + t = (text or "").strip() + if not t: + return False + if any(m in t for m in _EMOTION_MARKERS): + return True + if len(t) >= 40 and ("!" in t or "!" in t) and (".." in t or "…" in t or "唉" in t): + return True + return False + + +def plan_interview_turn( + *, + current_stage: str, + empty_slots: list[str], + normalized_user_message: str, + memory_evidence_text: str, + stage_switched_this_turn: bool, +) -> InterviewTurnPlan: + """ + 粗规则(可迭代): + - 情绪浓:先共情,不强推叙述槽搜集问。 + - 刚切换人生阶段:跟着用户节奏,不做「新阶段问卷首开」。 + - 当前阶段无空槽:深度跟进,不重启盘点。 + - 默认:memoir_push,锁一个主槽 + 挂钩摘录。 + """ + snippet = extract_anchor_snippet( + memory_evidence_text=memory_evidence_text, + user_message=normalized_user_message, + ) + + if _is_emotion_heavy(normalized_user_message): + slot = primary_empty_slot(current_stage, empty_slots) + readable = ( + SLOT_NAME_MAP.get(slot, slot or "") + if slot + else "(情绪优先时可暂不强绑某一槽位)" + ) + return InterviewTurnPlan( + mode="emotion_first", + anchor_slot_key=slot, + anchor_slot_readable=readable, + anchor_snippet=snippet, + ) + + if stage_switched_this_turn: + return InterviewTurnPlan( + mode="follow_user_only", + anchor_slot_key=None, + anchor_slot_readable="(刚自然谈到本阶段,先顺着对方语势,勿问卷式首开)", + anchor_snippet=snippet, + ) + + if not empty_slots: + return InterviewTurnPlan( + mode="follow_user_only", + anchor_slot_key=None, + anchor_slot_readable="(本阶段主要叙述槽已有素材)请 depth-first:接续画面或情绪线,别重启童年在哪长大式盘点", + anchor_snippet=snippet, + ) + + slot = primary_empty_slot(current_stage, empty_slots) + assert slot is not None + return InterviewTurnPlan( + mode="memoir_push", + anchor_slot_key=slot, + anchor_slot_readable=SLOT_NAME_MAP.get(slot, slot), + anchor_snippet=snippet, + ) + + +def format_interview_turn_directive_block(plan: InterviewTurnPlan) -> str: + """注入 guided prompt 顶部的硬指令块。""" + snippet_line = ( + plan.anchor_snippet + if plan.anchor_snippet + else "(无可用摘录时,必须从用户本轮原话里抽词作挂钩,禁止编造)" + ) + + if plan.mode == "emotion_first": + mode_rules = ( + "- **情绪优先**:本轮以承接、并肩与安全感为主,**不要**为推进「叙述槽大纲」而追加信息搜集型追问。\n" + "- 若末尾带问,只能是**贴着用户当前情绪或原词**的极轻一句;禁止切到盘点式下一题。\n" + "- 参考主槽「" + + plan.anchor_slot_readable + + "」仅供你心里知道后续方向,**不要**在本轮用问卷口吻硬推该槽。" + ) + elif plan.mode == "follow_user_only": + mode_rules = ( + "- **跟话头**:本轮禁止问卷式首开、禁止重启式盘点;顺着用户刚展开的画面、人物或情绪自然往下。\n" + "- 若带问句,最多**一个**,且必须**从用户原词或下面摘录**长出来,禁止空泛「还有吗」。" + ) + else: + mode_rules = ( + "- **回忆推进(memoir_push)**:对用户可见回复中须有**恰好一个**开放式回忆追问,\n" + " 且意图明显在补足下面「主追问方向」;问句必须挂住**挂钩摘录**或**用户本轮原词**(二者至少其一)。\n" + "- 禁止用日常寒暄替代该追问;仍遵守全篇「最多一个问句」「禁止晚会硬切」。" + ) + + return f"""## 本轮编排指令(硬规则,优先于后文一般性建议) +{mode_rules} +- **主追问方向(叙述槽)**:{plan.anchor_slot_readable} +- **挂钩摘录**(仅作衔接线索,**不是**用户本轮新说的内容;禁止写成就等于用户刚讲的原话):{snippet_line} +""" + + +__all__ = [ + "InterviewTurnMode", + "InterviewTurnPlan", + "extract_anchor_snippet", + "format_interview_turn_directive_block", + "plan_interview_turn", + "primary_empty_slot", +] diff --git a/api/app/agents/chat/orchestrator.py b/api/app/agents/chat/orchestrator.py index da86ab6..52deff5 100644 --- a/api/app/agents/chat/orchestrator.py +++ b/api/app/agents/chat/orchestrator.py @@ -254,12 +254,14 @@ class ChatOrchestrator: is_from_voice=is_from_voice, ) state = await get_or_create_state(user_id, db) + stage_before = state.current_stage detected = await detect_primary_life_stage( normalized_user_message, state.current_stage, self.interview_agent.llm, ) - if detected != state.current_stage: + stage_switched_this_turn = detected != stage_before + if stage_switched_this_turn: state = await switch_stage(user_id, detected, db) if conversation and conversation.conversation_stage != state.current_stage: @@ -317,6 +319,7 @@ class ChatOrchestrator: occupation=occupation, profile_birth_year=profile_birth_year, profile_era_place=profile_era_place, + stage_switched_this_turn=stage_switched_this_turn, ) recent_questions = prompt_state.recent_questions if turn.interview_state_meta and isinstance(turn.interview_state_meta, dict): @@ -413,6 +416,7 @@ class ChatOrchestrator: occupation: str = "", profile_birth_year: int | None = None, profile_era_place: str = "", + stage_switched_this_turn: bool = False, ) -> AgentChatTurn: """委托 InterviewAgent 生成访谈回复(持久化由调用方负责)。""" return await self.interview_agent.generate_response_with_state( @@ -427,6 +431,7 @@ class ChatOrchestrator: occupation=occupation, profile_birth_year=profile_birth_year, profile_era_place=profile_era_place, + stage_switched_this_turn=stage_switched_this_turn, ) def detect_user_stage(self, user_message: str) -> str: diff --git a/api/app/agents/chat/prompt_context.py b/api/app/agents/chat/prompt_context.py index 35b63b5..6d86f55 100644 --- a/api/app/agents/chat/prompt_context.py +++ b/api/app/agents/chat/prompt_context.py @@ -5,6 +5,7 @@ from __future__ import annotations from dataclasses import dataclass from typing import Dict, List, Optional +from app.agents.chat.interview_turn_plan import InterviewTurnPlan from app.agents.state_schema import KnownFact, PersonaThread @@ -27,11 +28,20 @@ class ChatPromptContext: known_facts: List[KnownFact] | None = None persona_threads: List[PersonaThread] | None = None recent_questions: List[str] | None = None + turn_plan: InterviewTurnPlan | None = None def guided_system_prompt(self) -> str: """用户原话仅以对话历史 + HumanMessage 注入模型。""" + from app.agents.chat.interview_turn_plan import ( + format_interview_turn_directive_block, + ) from app.agents.chat.prompts_conversation import get_guided_conversation_prompt + directive = ( + format_interview_turn_directive_block(self.turn_plan) + if self.turn_plan is not None + else "" + ) return get_guided_conversation_prompt( current_stage=self.current_stage, empty_slots=self.empty_slots, @@ -48,4 +58,5 @@ class ChatPromptContext: known_facts=self.known_facts or [], persona_threads=self.persona_threads or [], recent_questions=self.recent_questions or [], + turn_directive_block=directive, ) diff --git a/api/app/agents/chat/prompts_conversation.py b/api/app/agents/chat/prompts_conversation.py index 2b8c75e..5606508 100644 --- a/api/app/agents/chat/prompts_conversation.py +++ b/api/app/agents/chat/prompts_conversation.py @@ -229,6 +229,7 @@ def get_guided_conversation_prompt( known_facts: list[KnownFact] | None = None, persona_threads: list[PersonaThread] | None = None, recent_questions: list[str] | None = None, + turn_directive_block: str = "", ) -> str: """生成状态感知的对话提示词;用户原话仅以 HumanMessage 传入,不写入本 system 文本。""" persona_key = normalize_interview_persona(persona) @@ -365,7 +366,9 @@ def get_guided_conversation_prompt( current_stage, empty_slots ) - return f"""你是「岁月知己」——**主持式知己**:语气像最懂我的老朋友,**职责是帮用户把人生故事口述清楚**。{tone_line} + _prefix = f"{turn_directive_block.rstrip()}\n\n" if (turn_directive_block or "").strip() else "" + + return f"""{_prefix}你是「岁月知己」——**主持式知己**:语气像最懂我的老朋友,**职责是帮用户把人生故事口述清楚**。{tone_line} {topic_desc} diff --git a/api/tests/test_interview_turn_plan.py b/api/tests/test_interview_turn_plan.py new file mode 100644 index 0000000..953dd3f --- /dev/null +++ b/api/tests/test_interview_turn_plan.py @@ -0,0 +1,65 @@ +"""interview_turn_plan:轮次模式与主槽选择(服务端硬编排)。""" + +from app.agents.chat.interview_turn_plan import ( + extract_anchor_snippet, + plan_interview_turn, + primary_empty_slot, +) + + +def test_primary_empty_slot_order(): + assert primary_empty_slot("childhood", ["emotion", "place"]) == "place" + assert primary_empty_slot("childhood", ["emotion"]) == "emotion" + + +def test_extract_anchor_snippet_prefers_memory(): + mem = "摘录的一段记忆\n\n[场景氛围提示" + assert "摘录的一段记忆" in extract_anchor_snippet( + memory_evidence_text=mem, user_message="用户说很长一句" * 3 + ) + + +def test_plan_memoir_push(): + p = plan_interview_turn( + current_stage="childhood", + empty_slots=["place", "people"], + normalized_user_message="我小时候住在河边,夏天常去玩水。", + memory_evidence_text="", + stage_switched_this_turn=False, + ) + assert p.mode == "memoir_push" + assert p.anchor_slot_key == "place" + assert p.anchor_snippet + + +def test_plan_emotion_first(): + p = plan_interview_turn( + current_stage="childhood", + empty_slots=["place"], + normalized_user_message="想起来还是很难受,忍不住想哭。", + memory_evidence_text="", + stage_switched_this_turn=False, + ) + assert p.mode == "emotion_first" + + +def test_plan_follow_on_stage_switch(): + p = plan_interview_turn( + current_stage="education", + empty_slots=["school", "city"], + normalized_user_message="后来我去省城读中学了。", + memory_evidence_text="", + stage_switched_this_turn=True, + ) + assert p.mode == "follow_user_only" + + +def test_plan_follow_when_no_empty_slots(): + p = plan_interview_turn( + current_stage="childhood", + empty_slots=[], + normalized_user_message="嗯。", + memory_evidence_text="", + stage_switched_this_turn=False, + ) + assert p.mode == "follow_user_only"