Files
life-echo/api/app/agents/chat/interview_turn_plan.py
yangshilin 17b9fa3466 fix:
1. 修复登录界面文字被遮挡问题
2. 大字模式关闭后显示异常问题
3. 重新调整大字模式是否开启时的字体显示效果
2026-04-10 20:35:57 +08:00

203 lines
6.5 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
访谈轮次编排(方案 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",
]