""" 访谈轮次编排(方案 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", ]