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