2026-04-10 13:56:44 +08:00
|
|
|
|
"""
|
2026-04-22 16:56:28 +08:00
|
|
|
|
访谈轮次编排(Option B 定位:InterviewTurnPlan 是**本轮行为模式的唯一决策源**)。
|
|
|
|
|
|
|
|
|
|
|
|
约束说明:
|
|
|
|
|
|
- 主 prompt(prompt_layers)只提供跨轮通用的承接-深挖-串联节奏与身份守则;
|
|
|
|
|
|
- 本轮是否「情绪优先」「跟话头」「回忆推进」完全由 `plan_interview_turn()` 决定;
|
|
|
|
|
|
- 对 LLM 的硬指令由 `InterviewTurnPlan.render_system_directive()` 输出,主 prompt **不得**再
|
|
|
|
|
|
针对具体模式另立软约束。
|
2026-04-10 13:56:44 +08:00
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
|
|
from dataclasses import dataclass
|
|
|
|
|
|
from typing import Literal
|
|
|
|
|
|
|
|
|
|
|
|
from app.agents.chat.prompts_conversation import SLOT_NAME_MAP
|
2026-05-11 12:06:17 +08:00
|
|
|
|
from app.agents.stage_constants import STAGE_KEYWORD_WEIGHTS, STAGE_SLOT_KEYS
|
2026-04-10 13:56:44 +08:00
|
|
|
|
|
2026-04-22 16:56:28 +08:00
|
|
|
|
InterviewTurnMode = Literal[
|
|
|
|
|
|
"emotion_first",
|
|
|
|
|
|
"clarify_first",
|
|
|
|
|
|
"memoir_push",
|
|
|
|
|
|
"follow_user_only",
|
|
|
|
|
|
]
|
|
|
|
|
|
SubjectOwner = Literal["user_only"]
|
|
|
|
|
|
MemoryUsage = Literal["none", "allowed_with_attribution"]
|
|
|
|
|
|
ReplyShape = Literal["flexible", "ack_only", "ack_then_question"]
|
|
|
|
|
|
AnchorSourceKind = Literal["user_message", "memory", "none"]
|
|
|
|
|
|
|
|
|
|
|
|
# 本轮承接焦点(规则基线或 focus planner LLM 输出;供 directive 与日志)
|
|
|
|
|
|
FocusPrimary = Literal[
|
|
|
|
|
|
"emotion",
|
|
|
|
|
|
"relationship",
|
|
|
|
|
|
"identity",
|
|
|
|
|
|
"scene",
|
|
|
|
|
|
"memoir_gap",
|
|
|
|
|
|
"follow_user",
|
|
|
|
|
|
]
|
|
|
|
|
|
FocusSource = Literal["rule", "llm", "fallback"]
|
2026-04-10 13:56:44 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
|
|
|
|
class InterviewTurnPlan:
|
2026-04-22 16:56:28 +08:00
|
|
|
|
"""单轮访谈的硬目标(优先级高于主 prompt 的一般性建议)。
|
|
|
|
|
|
|
|
|
|
|
|
作为唯一决策源,对 LLM 的落地由 `render_system_directive()` 输出;其它调用方应通过
|
|
|
|
|
|
`is_emotion_first` / `is_clarify_first` / `is_follow_user_only` / `is_memoir_push` /
|
|
|
|
|
|
`requires_explicit_question` 等语义属性读取决策,避免在主 prompt 里重复立法。
|
|
|
|
|
|
|
|
|
|
|
|
扩展字段承载「主语归属 / 记忆引用策略 / 回复形状」,供防上下文污染与双阶段 planner 合并。
|
|
|
|
|
|
"""
|
2026-04-10 13:56:44 +08:00
|
|
|
|
|
|
|
|
|
|
mode: InterviewTurnMode
|
|
|
|
|
|
anchor_slot_key: str | None
|
|
|
|
|
|
anchor_slot_readable: str
|
|
|
|
|
|
anchor_snippet: str
|
2026-04-22 16:56:28 +08:00
|
|
|
|
anchor_source_kind: AnchorSourceKind = "none"
|
|
|
|
|
|
assistant_identity_question: bool = False
|
|
|
|
|
|
subject_owner: SubjectOwner = "user_only"
|
|
|
|
|
|
memory_usage: MemoryUsage = "none"
|
|
|
|
|
|
memory_reference_style: str = "你之前提过"
|
|
|
|
|
|
forbid_first_person_experience: bool = True
|
|
|
|
|
|
reply_shape: ReplyShape = "flexible"
|
|
|
|
|
|
# 本轮承接重点(单一事实源的一部分;与 mode 配合,由 directive 落地)
|
|
|
|
|
|
primary_focus: FocusPrimary = "memoir_gap"
|
|
|
|
|
|
secondary_focus: FocusPrimary | None = None
|
|
|
|
|
|
focus_summary: str = ""
|
|
|
|
|
|
focus_source: FocusSource = "rule"
|
2026-05-11 12:06:17 +08:00
|
|
|
|
low_information_reply: bool = False
|
2026-04-22 16:56:28 +08:00
|
|
|
|
|
|
|
|
|
|
# ---- 语义属性:供 prompt_layers / interview_agent 等调用方消费,禁止重复立法 ----
|
|
|
|
|
|
|
|
|
|
|
|
@property
|
|
|
|
|
|
def is_emotion_first(self) -> bool:
|
|
|
|
|
|
return self.mode == "emotion_first"
|
|
|
|
|
|
|
|
|
|
|
|
@property
|
|
|
|
|
|
def is_clarify_first(self) -> bool:
|
|
|
|
|
|
return self.mode == "clarify_first"
|
|
|
|
|
|
|
|
|
|
|
|
@property
|
|
|
|
|
|
def is_follow_user_only(self) -> bool:
|
|
|
|
|
|
return self.mode == "follow_user_only"
|
|
|
|
|
|
|
|
|
|
|
|
@property
|
|
|
|
|
|
def is_memoir_push(self) -> bool:
|
|
|
|
|
|
return self.mode == "memoir_push"
|
|
|
|
|
|
|
|
|
|
|
|
@property
|
|
|
|
|
|
def requires_explicit_question(self) -> bool:
|
|
|
|
|
|
"""memoir_push 要求恰好一个开放式追问;其它模式允许不问或尽量不问。"""
|
|
|
|
|
|
return self.mode == "memoir_push"
|
|
|
|
|
|
|
|
|
|
|
|
@property
|
|
|
|
|
|
def allows_zero_question(self) -> bool:
|
|
|
|
|
|
"""emotion_first / clarify_first 整轮允许不问;follow_user_only 仅允许最多一个轻问。"""
|
|
|
|
|
|
return self.mode in ("emotion_first", "clarify_first", "follow_user_only")
|
|
|
|
|
|
|
|
|
|
|
|
def render_system_directive(self) -> str:
|
|
|
|
|
|
"""对 LLM 的硬指令(供注入 system prompt 顶部)。"""
|
|
|
|
|
|
return format_interview_turn_directive_block(self)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _focus_primary_for_mode(mode: InterviewTurnMode) -> FocusPrimary:
|
|
|
|
|
|
if mode == "emotion_first":
|
|
|
|
|
|
return "emotion"
|
|
|
|
|
|
if mode == "clarify_first":
|
|
|
|
|
|
return "follow_user"
|
|
|
|
|
|
if mode == "follow_user_only":
|
|
|
|
|
|
return "follow_user"
|
|
|
|
|
|
return "memoir_gap"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def apply_safe_mode_override(
|
|
|
|
|
|
baseline: InterviewTurnMode,
|
|
|
|
|
|
override: str | None,
|
|
|
|
|
|
*,
|
|
|
|
|
|
primary_focus: str | None,
|
|
|
|
|
|
) -> InterviewTurnMode | None:
|
|
|
|
|
|
"""若 planner 建议覆盖 mode,在此做安全收敛;返回 None 表示保持 baseline。"""
|
|
|
|
|
|
if not override or override == baseline:
|
|
|
|
|
|
return None
|
|
|
|
|
|
if override not in (
|
|
|
|
|
|
"emotion_first",
|
|
|
|
|
|
"clarify_first",
|
|
|
|
|
|
"memoir_push",
|
|
|
|
|
|
"follow_user_only",
|
|
|
|
|
|
):
|
|
|
|
|
|
return None
|
|
|
|
|
|
# 情绪优先一旦成立,不因 planner 改回强推素材
|
|
|
|
|
|
if baseline == "emotion_first" and override != "emotion_first":
|
|
|
|
|
|
return None
|
|
|
|
|
|
# 跟话头 / 模糊先澄清:不允许改回问卷式 memoir_push
|
|
|
|
|
|
if baseline in ("follow_user_only", "clarify_first") and override == "memoir_push":
|
|
|
|
|
|
return None
|
|
|
|
|
|
# memoir_push -> emotion_first:仅当焦点偏情绪/关系/身份(或空,由模型判断)
|
|
|
|
|
|
if baseline == "memoir_push" and override == "emotion_first":
|
|
|
|
|
|
pf = (primary_focus or "").strip()
|
|
|
|
|
|
if pf in ("", "emotion", "relationship", "identity"):
|
|
|
|
|
|
return "emotion_first"
|
|
|
|
|
|
return None
|
|
|
|
|
|
if baseline == "memoir_push" and override == "follow_user_only":
|
|
|
|
|
|
return "follow_user_only"
|
|
|
|
|
|
if baseline == "follow_user_only" and override == "emotion_first":
|
|
|
|
|
|
return "emotion_first"
|
|
|
|
|
|
if baseline == "follow_user_only" and override == "clarify_first":
|
|
|
|
|
|
return "clarify_first"
|
|
|
|
|
|
if baseline == "memoir_push" and override == "clarify_first":
|
|
|
|
|
|
pf = (primary_focus or "").strip()
|
|
|
|
|
|
if pf in ("", "emotion", "relationship", "identity", "follow_user"):
|
|
|
|
|
|
return "clarify_first"
|
|
|
|
|
|
return None
|
|
|
|
|
|
if baseline == "clarify_first" and override == "emotion_first":
|
|
|
|
|
|
return "emotion_first"
|
|
|
|
|
|
if baseline == "clarify_first" and override == "follow_user_only":
|
|
|
|
|
|
return "follow_user_only"
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _focus_directive_lines(plan: InterviewTurnPlan) -> str:
|
|
|
|
|
|
"""本轮承接重点:focus_summary 仅作追问角度,不支配正文措辞。"""
|
|
|
|
|
|
if (plan.focus_summary or "").strip():
|
|
|
|
|
|
summary = (plan.focus_summary or "").strip()
|
|
|
|
|
|
if len(summary) > 200:
|
|
|
|
|
|
summary = summary[:199].rstrip() + "…"
|
|
|
|
|
|
return (
|
|
|
|
|
|
"- **本轮追问/承接角度(非正文提纲;主回复仍须从用户本轮原话长出来)**:"
|
|
|
|
|
|
+ summary
|
|
|
|
|
|
+ "\n"
|
|
|
|
|
|
)
|
|
|
|
|
|
labels: dict[FocusPrimary, str] = {
|
|
|
|
|
|
"emotion": "情绪与感受",
|
|
|
|
|
|
"relationship": "关系与他人(谁在场、谁在看、和谁较劲)",
|
|
|
|
|
|
"identity": "身份与面子(自我形象、怕丢脸、要强)",
|
|
|
|
|
|
"scene": "现场与感官(画面、光线、身体感受)",
|
|
|
|
|
|
"memoir_gap": "叙述槽与成稿素材(当前主追问方向)",
|
|
|
|
|
|
"follow_user": "顺着用户话头自然展开",
|
|
|
|
|
|
}
|
|
|
|
|
|
pri = labels.get(plan.primary_focus, labels["memoir_gap"])
|
|
|
|
|
|
if plan.secondary_focus and plan.secondary_focus != plan.primary_focus:
|
|
|
|
|
|
sec = labels.get(plan.secondary_focus, "")
|
|
|
|
|
|
if sec:
|
|
|
|
|
|
return (
|
|
|
|
|
|
f"- **本轮承接重点**:先锚定「{pri}」,必要时再轻点「{sec}」作辅助。\n"
|
|
|
|
|
|
)
|
|
|
|
|
|
return f"- **本轮承接重点**:优先锚定「{pri}」。\n"
|
2026-04-10 13:56:44 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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:
|
2026-04-22 16:56:28 +08:00
|
|
|
|
"""优先用户本轮原话,其次极短记忆线索(用于追问挂钩,非事实断言)。
|
|
|
|
|
|
|
|
|
|
|
|
旧记忆不得压过用户当前话头;仅当本轮原话过短时再借 `memory_anchor_source` 挂钩。
|
|
|
|
|
|
`memory_evidence_text` 此处仅为**一条短线索**(非整段 `[M…]` 摘录块)。
|
|
|
|
|
|
"""
|
|
|
|
|
|
um = (user_message or "").strip()
|
|
|
|
|
|
if len(um) >= 10:
|
|
|
|
|
|
return um[:max_chars].strip()
|
2026-04-10 13:56:44 +08:00
|
|
|
|
mem = _strip_scene_hint(memory_evidence_text)
|
|
|
|
|
|
if mem and len(mem) >= 4:
|
2026-04-22 16:56:28 +08:00
|
|
|
|
for line in mem.splitlines():
|
|
|
|
|
|
s = line.strip()
|
|
|
|
|
|
if s.startswith("[M") and "]" in s:
|
|
|
|
|
|
rest = s.split("]", 1)[1].strip()
|
|
|
|
|
|
if rest and len(rest) >= 4:
|
|
|
|
|
|
return rest[:max_chars].strip()
|
2026-04-10 13:56:44 +08:00
|
|
|
|
return mem[:max_chars].strip()
|
2026-04-22 16:56:28 +08:00
|
|
|
|
if len(um) >= 4:
|
2026-04-10 13:56:44 +08:00
|
|
|
|
return um[:max_chars].strip()
|
|
|
|
|
|
return ""
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-04-22 16:56:28 +08:00
|
|
|
|
def determine_anchor_source_kind(
|
|
|
|
|
|
*,
|
|
|
|
|
|
memory_evidence_text: str,
|
|
|
|
|
|
user_message: str,
|
|
|
|
|
|
) -> AnchorSourceKind:
|
|
|
|
|
|
"""标记挂钩线索来源,避免把用户本轮原话误标成旧记忆。"""
|
|
|
|
|
|
um = (user_message or "").strip()
|
|
|
|
|
|
if len(um) >= 10:
|
|
|
|
|
|
return "user_message"
|
|
|
|
|
|
mem = _strip_scene_hint(memory_evidence_text)
|
|
|
|
|
|
if mem and len(mem) >= 4:
|
|
|
|
|
|
for line in mem.splitlines():
|
|
|
|
|
|
s = line.strip()
|
|
|
|
|
|
if s.startswith("[M") and "]" in s:
|
|
|
|
|
|
rest = s.split("]", 1)[1].strip()
|
|
|
|
|
|
if rest and len(rest) >= 4:
|
|
|
|
|
|
return "memory"
|
|
|
|
|
|
return "memory"
|
|
|
|
|
|
if len(um) >= 4:
|
|
|
|
|
|
return "user_message"
|
|
|
|
|
|
return "none"
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-04-10 13:56:44 +08:00
|
|
|
|
_EMOTION_MARKERS: tuple[str, ...] = (
|
|
|
|
|
|
"哭",
|
|
|
|
|
|
"难受",
|
|
|
|
|
|
"委屈",
|
|
|
|
|
|
"害怕",
|
|
|
|
|
|
"后悔",
|
|
|
|
|
|
"恨",
|
|
|
|
|
|
"舍不得",
|
|
|
|
|
|
"崩溃",
|
|
|
|
|
|
"绝望",
|
|
|
|
|
|
"心疼",
|
|
|
|
|
|
"哽咽",
|
|
|
|
|
|
"咽不下",
|
|
|
|
|
|
"睡不着",
|
|
|
|
|
|
"想哭",
|
|
|
|
|
|
"好难",
|
|
|
|
|
|
"太难了",
|
|
|
|
|
|
"挺不住",
|
|
|
|
|
|
"扛不住",
|
|
|
|
|
|
"放不下",
|
|
|
|
|
|
"意难平",
|
2026-04-22 16:56:28 +08:00
|
|
|
|
"难堪",
|
|
|
|
|
|
"丢脸",
|
|
|
|
|
|
"怕丢人",
|
|
|
|
|
|
"臊得慌",
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
# 说不清 / 暧昧放慢:不触发 memoir_push 式强推槽位
|
|
|
|
|
|
_AMBIGUITY_MARKERS: tuple[str, ...] = (
|
|
|
|
|
|
"说不清",
|
|
|
|
|
|
"说不明白",
|
|
|
|
|
|
"说不上来",
|
|
|
|
|
|
"说不上",
|
|
|
|
|
|
"说不好",
|
|
|
|
|
|
"不知道怎么说",
|
|
|
|
|
|
"不知道咋说",
|
|
|
|
|
|
"好像",
|
|
|
|
|
|
"也不是",
|
|
|
|
|
|
"不完全是",
|
|
|
|
|
|
"不太确定",
|
|
|
|
|
|
"不确定",
|
|
|
|
|
|
"有点模糊",
|
|
|
|
|
|
"很模糊",
|
|
|
|
|
|
"模模糊糊",
|
|
|
|
|
|
"难说",
|
|
|
|
|
|
"有一点,但我不确定",
|
|
|
|
|
|
)
|
|
|
|
|
|
_SOFT_SLOW_MARKERS: tuple[str, ...] = (
|
|
|
|
|
|
"害羞",
|
|
|
|
|
|
"羞涩",
|
|
|
|
|
|
"不好意思",
|
|
|
|
|
|
"脸红",
|
|
|
|
|
|
"暧昧",
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
_ASSISTANT_IDENTITY_QUESTION_MARKERS: tuple[str, ...] = (
|
|
|
|
|
|
"你是哪里人",
|
|
|
|
|
|
"你哪的人",
|
|
|
|
|
|
"你是哪里长大的",
|
|
|
|
|
|
"你在哪长大",
|
|
|
|
|
|
"你的童年",
|
|
|
|
|
|
"你小时候",
|
|
|
|
|
|
"你家里",
|
|
|
|
|
|
"你爸妈",
|
|
|
|
|
|
"你父母",
|
|
|
|
|
|
"你也有过",
|
|
|
|
|
|
"你也是",
|
2026-04-10 13:56:44 +08:00
|
|
|
|
)
|
|
|
|
|
|
|
2026-05-11 12:06:17 +08:00
|
|
|
|
_LOW_INFORMATION_REPLY_MAX_CHARS = 8
|
|
|
|
|
|
_LOW_INFORMATION_REPLY_CHARS: frozenset[str] = frozenset(
|
|
|
|
|
|
# 这不是短语白名单,而是一组低信息的应声/确认/语气字符。
|
|
|
|
|
|
# 只要短回复里出现不在此集合中的字,就会被视为有潜在叙事信号。
|
|
|
|
|
|
"嗯唔呃哦噢喔啊呀呢嘛吧哈"
|
|
|
|
|
|
"对是的了好行可允许以"
|
|
|
|
|
|
"没错确实不否无有还太很挺就"
|
|
|
|
|
|
"什么这样那样当当然"
|
|
|
|
|
|
)
|
|
|
|
|
|
_ACK_STRIP_CHARS = " \t\r\n,。!?!?、,.;;::~~…"
|
|
|
|
|
|
|
2026-04-10 13:56:44 +08:00
|
|
|
|
|
|
|
|
|
|
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
|
2026-04-10 20:35:57 +08:00
|
|
|
|
if (
|
|
|
|
|
|
len(t) >= 40
|
|
|
|
|
|
and ("!" in t or "!" in t)
|
|
|
|
|
|
and (".." in t or "…" in t or "唉" in t)
|
|
|
|
|
|
):
|
2026-04-10 13:56:44 +08:00
|
|
|
|
return True
|
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-04-22 16:56:28 +08:00
|
|
|
|
def _is_ambiguous_or_needs_slow_pace(text: str) -> bool:
|
|
|
|
|
|
"""模糊表达、暧昧/羞涩等:先澄清或放慢,不强推叙述槽。"""
|
|
|
|
|
|
t = (text or "").strip()
|
|
|
|
|
|
if not t:
|
|
|
|
|
|
return True
|
|
|
|
|
|
if any(m in t for m in _AMBIGUITY_MARKERS):
|
|
|
|
|
|
return True
|
|
|
|
|
|
if any(m in t for m in _SOFT_SLOW_MARKERS):
|
|
|
|
|
|
return True
|
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-05-11 12:06:17 +08:00
|
|
|
|
def _normalized_short_reply(text: str) -> str:
|
|
|
|
|
|
return "".join(ch for ch in (text or "").strip() if ch not in _ACK_STRIP_CHARS)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _has_substantive_short_reply_signal(compact: str) -> bool:
|
|
|
|
|
|
"""短回复里的叙事实质信号:地点/人事词、阶段关键词、年份数字等。"""
|
|
|
|
|
|
if any(ch.isdigit() for ch in compact):
|
|
|
|
|
|
return True
|
|
|
|
|
|
if any(("a" <= ch.lower() <= "z") for ch in compact):
|
|
|
|
|
|
return True
|
|
|
|
|
|
for weighted_keywords in STAGE_KEYWORD_WEIGHTS.values():
|
|
|
|
|
|
if any(keyword and keyword in compact for keyword, _ in weighted_keywords):
|
|
|
|
|
|
return True
|
|
|
|
|
|
return any(ch not in _LOW_INFORMATION_REPLY_CHARS for ch in compact)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _is_low_information_reply(text: str) -> bool:
|
|
|
|
|
|
"""识别短、低信息、没有新增叙事素材的确认/应声回复。"""
|
|
|
|
|
|
compact = _normalized_short_reply(text)
|
|
|
|
|
|
if not compact:
|
|
|
|
|
|
return False
|
|
|
|
|
|
if len(compact) > _LOW_INFORMATION_REPLY_MAX_CHARS:
|
|
|
|
|
|
return False
|
|
|
|
|
|
return not _has_substantive_short_reply_signal(compact)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-04-22 16:56:28 +08:00
|
|
|
|
def _is_too_vague_for_memoir_push(text: str) -> bool:
|
|
|
|
|
|
"""过短或仍含糊时,不进入 memoir_push。"""
|
|
|
|
|
|
t = (text or "").strip()
|
|
|
|
|
|
if len(t) < 12:
|
|
|
|
|
|
return True
|
|
|
|
|
|
return _is_ambiguous_or_needs_slow_pace(t)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _is_asking_assistant_identity_or_life(text: str) -> bool:
|
|
|
|
|
|
t = (text or "").strip()
|
|
|
|
|
|
if not t:
|
|
|
|
|
|
return False
|
|
|
|
|
|
return any(marker in t for marker in _ASSISTANT_IDENTITY_QUESTION_MARKERS)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-04-10 13:56:44 +08:00
|
|
|
|
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:
|
|
|
|
|
|
"""
|
|
|
|
|
|
粗规则(可迭代):
|
|
|
|
|
|
- 情绪浓:先共情,不强推叙述槽搜集问。
|
2026-04-22 16:56:28 +08:00
|
|
|
|
- 说不清/暧昧羞涩等:模糊先澄清,禁止替用户下结论或强推槽位。
|
2026-04-10 13:56:44 +08:00
|
|
|
|
- 刚切换人生阶段:跟着用户节奏,不做「新阶段问卷首开」。
|
|
|
|
|
|
- 当前阶段无空槽:深度跟进,不重启盘点。
|
2026-04-22 16:56:28 +08:00
|
|
|
|
- 默认:memoir_push(仅当本轮话头足够具体、非含糊短答时)。
|
2026-04-10 13:56:44 +08:00
|
|
|
|
"""
|
|
|
|
|
|
snippet = extract_anchor_snippet(
|
|
|
|
|
|
memory_evidence_text=memory_evidence_text,
|
|
|
|
|
|
user_message=normalized_user_message,
|
|
|
|
|
|
)
|
2026-04-22 16:56:28 +08:00
|
|
|
|
anchor_source_kind = determine_anchor_source_kind(
|
|
|
|
|
|
memory_evidence_text=memory_evidence_text,
|
|
|
|
|
|
user_message=normalized_user_message,
|
|
|
|
|
|
)
|
|
|
|
|
|
mem_trim = _strip_scene_hint(memory_evidence_text).strip()
|
|
|
|
|
|
mem_use: MemoryUsage = (
|
|
|
|
|
|
"allowed_with_attribution" if mem_trim else "none"
|
|
|
|
|
|
)
|
|
|
|
|
|
um = (normalized_user_message or "").strip()
|
|
|
|
|
|
asks_assistant_identity = _is_asking_assistant_identity_or_life(um)
|
2026-05-11 12:06:17 +08:00
|
|
|
|
low_information_reply = _is_low_information_reply(um)
|
2026-04-22 16:56:28 +08:00
|
|
|
|
reply_shape: ReplyShape = "flexible"
|
|
|
|
|
|
if any(
|
|
|
|
|
|
k in um
|
|
|
|
|
|
for k in ("讲讲", "说说", "她的故事", "他的故事", "后来呢", "然后呢")
|
|
|
|
|
|
):
|
|
|
|
|
|
reply_shape = "ack_then_question"
|
2026-04-10 13:56:44 +08:00
|
|
|
|
|
2026-05-11 12:06:17 +08:00
|
|
|
|
if low_information_reply:
|
|
|
|
|
|
slot = primary_empty_slot(current_stage, empty_slots)
|
|
|
|
|
|
if slot:
|
|
|
|
|
|
readable = SLOT_NAME_MAP.get(slot, slot)
|
|
|
|
|
|
return InterviewTurnPlan(
|
|
|
|
|
|
mode="memoir_push",
|
|
|
|
|
|
anchor_slot_key=slot,
|
|
|
|
|
|
anchor_slot_readable=readable,
|
|
|
|
|
|
anchor_snippet=snippet,
|
|
|
|
|
|
anchor_source_kind=anchor_source_kind,
|
|
|
|
|
|
assistant_identity_question=asks_assistant_identity,
|
|
|
|
|
|
memory_usage=mem_use,
|
|
|
|
|
|
reply_shape="ack_then_question",
|
|
|
|
|
|
primary_focus=_focus_primary_for_mode("memoir_push"),
|
|
|
|
|
|
focus_summary=(
|
|
|
|
|
|
f"用户只做了简短确认;短接一句后,不澄清“{um}”,"
|
|
|
|
|
|
f"主动从「{readable}」打开一个具体、好回答的新回忆话题"
|
|
|
|
|
|
),
|
|
|
|
|
|
focus_source="rule",
|
|
|
|
|
|
low_information_reply=True,
|
|
|
|
|
|
)
|
|
|
|
|
|
return InterviewTurnPlan(
|
|
|
|
|
|
mode="follow_user_only",
|
|
|
|
|
|
anchor_slot_key=None,
|
|
|
|
|
|
anchor_slot_readable="(本阶段主要叙述槽已有素材)请回到上文最近的人/事/地方或情绪线,主动打开一个纵深问题",
|
|
|
|
|
|
anchor_snippet=snippet,
|
|
|
|
|
|
anchor_source_kind=anchor_source_kind,
|
|
|
|
|
|
assistant_identity_question=asks_assistant_identity,
|
|
|
|
|
|
memory_usage=mem_use,
|
|
|
|
|
|
reply_shape="ack_then_question",
|
|
|
|
|
|
primary_focus=_focus_primary_for_mode("follow_user_only"),
|
|
|
|
|
|
focus_summary=(
|
|
|
|
|
|
f"用户只做了简短确认;短接一句后,不澄清“{um}”,"
|
|
|
|
|
|
"回到上文最近的具体线索,主动递一个新的回忆追问"
|
|
|
|
|
|
),
|
|
|
|
|
|
focus_source="rule",
|
|
|
|
|
|
low_information_reply=True,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-04-10 13:56:44 +08:00
|
|
|
|
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,
|
2026-04-22 16:56:28 +08:00
|
|
|
|
anchor_source_kind=anchor_source_kind,
|
|
|
|
|
|
assistant_identity_question=asks_assistant_identity,
|
|
|
|
|
|
memory_usage=mem_use,
|
|
|
|
|
|
reply_shape=reply_shape,
|
|
|
|
|
|
primary_focus=_focus_primary_for_mode("emotion_first"),
|
|
|
|
|
|
focus_source="rule",
|
2026-04-10 13:56:44 +08:00
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
if stage_switched_this_turn:
|
|
|
|
|
|
return InterviewTurnPlan(
|
|
|
|
|
|
mode="follow_user_only",
|
|
|
|
|
|
anchor_slot_key=None,
|
|
|
|
|
|
anchor_slot_readable="(刚自然谈到本阶段,先顺着对方语势,勿问卷式首开)",
|
|
|
|
|
|
anchor_snippet=snippet,
|
2026-04-22 16:56:28 +08:00
|
|
|
|
anchor_source_kind=anchor_source_kind,
|
|
|
|
|
|
assistant_identity_question=asks_assistant_identity,
|
|
|
|
|
|
memory_usage=mem_use,
|
|
|
|
|
|
reply_shape=reply_shape,
|
|
|
|
|
|
primary_focus=_focus_primary_for_mode("follow_user_only"),
|
|
|
|
|
|
focus_source="rule",
|
2026-04-10 13:56:44 +08:00
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
if not empty_slots:
|
|
|
|
|
|
return InterviewTurnPlan(
|
|
|
|
|
|
mode="follow_user_only",
|
|
|
|
|
|
anchor_slot_key=None,
|
|
|
|
|
|
anchor_slot_readable="(本阶段主要叙述槽已有素材)请 depth-first:接续画面或情绪线,别重启童年在哪长大式盘点",
|
|
|
|
|
|
anchor_snippet=snippet,
|
2026-04-22 16:56:28 +08:00
|
|
|
|
anchor_source_kind=anchor_source_kind,
|
|
|
|
|
|
assistant_identity_question=asks_assistant_identity,
|
|
|
|
|
|
memory_usage=mem_use,
|
|
|
|
|
|
reply_shape=reply_shape,
|
|
|
|
|
|
primary_focus=_focus_primary_for_mode("follow_user_only"),
|
|
|
|
|
|
focus_source="rule",
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
if _is_too_vague_for_memoir_push(um):
|
|
|
|
|
|
slot = primary_empty_slot(current_stage, empty_slots)
|
|
|
|
|
|
readable = (
|
|
|
|
|
|
SLOT_NAME_MAP.get(slot, slot or "")
|
|
|
|
|
|
if slot
|
|
|
|
|
|
else "(先澄清感受时可暂不强绑某一槽位)"
|
|
|
|
|
|
)
|
|
|
|
|
|
return InterviewTurnPlan(
|
|
|
|
|
|
mode="clarify_first",
|
|
|
|
|
|
anchor_slot_key=slot,
|
|
|
|
|
|
anchor_slot_readable=readable,
|
|
|
|
|
|
anchor_snippet=snippet,
|
|
|
|
|
|
anchor_source_kind=anchor_source_kind,
|
|
|
|
|
|
assistant_identity_question=asks_assistant_identity,
|
|
|
|
|
|
memory_usage=mem_use,
|
|
|
|
|
|
reply_shape=reply_shape,
|
|
|
|
|
|
primary_focus=_focus_primary_for_mode("clarify_first"),
|
|
|
|
|
|
focus_source="rule",
|
2026-04-10 13:56:44 +08:00
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
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,
|
2026-04-22 16:56:28 +08:00
|
|
|
|
anchor_source_kind=anchor_source_kind,
|
|
|
|
|
|
assistant_identity_question=asks_assistant_identity,
|
|
|
|
|
|
memory_usage=mem_use,
|
|
|
|
|
|
reply_shape=reply_shape,
|
|
|
|
|
|
primary_focus=_focus_primary_for_mode("memoir_push"),
|
|
|
|
|
|
focus_source="rule",
|
2026-04-10 13:56:44 +08:00
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def format_interview_turn_directive_block(plan: InterviewTurnPlan) -> str:
|
|
|
|
|
|
"""注入 guided prompt 顶部的硬指令块。"""
|
|
|
|
|
|
snippet_line = (
|
|
|
|
|
|
plan.anchor_snippet
|
|
|
|
|
|
if plan.anchor_snippet
|
|
|
|
|
|
else "(无可用摘录时,必须从用户本轮原话里抽词作挂钩,禁止编造)"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-04-22 16:56:28 +08:00
|
|
|
|
if plan.memory_usage == "allowed_with_attribution":
|
|
|
|
|
|
mem_block = (
|
|
|
|
|
|
"- **过往记忆线索**:主 Context 里若有一条极短线索,**只**用于帮你选追问角度;"
|
|
|
|
|
|
"**不是**正文提纲,**不是**长素材。\n"
|
|
|
|
|
|
"- **禁止**用「"
|
|
|
|
|
|
+ plan.memory_reference_style
|
|
|
|
|
|
+ "…」开场或整段复述旧记忆;若轻勾连,**最多半句**,且须让本轮原话占主位。\n"
|
|
|
|
|
|
"- **禁止**把线索写成助手亲历;**禁止**把线索当「咱俩共同回忆」。\n"
|
|
|
|
|
|
)
|
|
|
|
|
|
else:
|
|
|
|
|
|
mem_block = (
|
|
|
|
|
|
"- **过往记忆线索**:本轮无可用线索(或已策略性不注入);"
|
|
|
|
|
|
"**不要**编造「你说过…」式假归因。\n"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
perspective_block = (
|
|
|
|
|
|
"- **主语与视角**:用户可见回复主线必须是**用户**的经历与感受;"
|
|
|
|
|
|
"助手**没有**真实人生传记。\n"
|
|
|
|
|
|
)
|
|
|
|
|
|
if plan.forbid_first_person_experience:
|
|
|
|
|
|
perspective_block += (
|
|
|
|
|
|
"- **禁止助手自传式措辞**:不得使用「我小时候」「我演过/我扮演」「我当时暗恋」"
|
|
|
|
|
|
"「我爸妈」等自称亲历表述。\n"
|
|
|
|
|
|
)
|
|
|
|
|
|
if plan.assistant_identity_question:
|
|
|
|
|
|
perspective_block += (
|
|
|
|
|
|
"- **本轮用户在问助手本人**:必须明确守住边界,不能假装自己有籍贯、童年、家庭或恋爱经历;"
|
|
|
|
|
|
"若要借记忆继续聊,只能明确归因到用户,例如「你刚提到上海」「你前面说过……」。\n"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
if plan.reply_shape == "ack_only":
|
|
|
|
|
|
shape_block = "- **本轮回复形状**:以短承接为主;若无必要可整轮不问。\n"
|
|
|
|
|
|
elif plan.reply_shape == "ack_then_question":
|
|
|
|
|
|
shape_block = (
|
|
|
|
|
|
"- **本轮回复形状**:先短承接,再尽量带**一条**贴用户话头的开放式问(仍守全篇最多一个问句)。\n"
|
|
|
|
|
|
)
|
|
|
|
|
|
else:
|
|
|
|
|
|
shape_block = ""
|
|
|
|
|
|
|
2026-05-11 12:06:17 +08:00
|
|
|
|
ack_block = ""
|
|
|
|
|
|
if plan.low_information_reply:
|
|
|
|
|
|
ack_block = (
|
|
|
|
|
|
"- **低信息短回复处理**:用户本轮只是简短确认、应声或泛泛回应,没有新增叙事素材。"
|
|
|
|
|
|
"不要把这个短答本身当成需要澄清的内容,不要反复追问「你是说……吗」,"
|
|
|
|
|
|
"也不要停在原地等用户继续补充。\n"
|
|
|
|
|
|
"- 先用半句自然接住,再主动从「主追问方向」、上文最近的具体人/事/地方,"
|
|
|
|
|
|
"或过往记忆线索里挑一个**具体、好回答**的新回忆话题;若挂钩线索为空,"
|
|
|
|
|
|
"允许直接借当前阶段未聊方向起问,但禁止编造用户没说过的细节。\n"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-04-10 13:56:44 +08:00
|
|
|
|
if plan.mode == "emotion_first":
|
|
|
|
|
|
mode_rules = (
|
|
|
|
|
|
"- **情绪优先**:本轮以承接、并肩与安全感为主,**不要**为推进「叙述槽大纲」而追加信息搜集型追问。\n"
|
|
|
|
|
|
"- 若末尾带问,只能是**贴着用户当前情绪或原词**的极轻一句;禁止切到盘点式下一题。\n"
|
|
|
|
|
|
"- 参考主槽「"
|
|
|
|
|
|
+ plan.anchor_slot_readable
|
|
|
|
|
|
+ "」仅供你心里知道后续方向,**不要**在本轮用问卷口吻硬推该槽。"
|
|
|
|
|
|
)
|
2026-04-22 16:56:28 +08:00
|
|
|
|
elif plan.mode == "clarify_first":
|
|
|
|
|
|
mode_rules = (
|
|
|
|
|
|
"- **模糊先澄清**:用户正在表达不确定、说不清,或暧昧/羞涩等未命名感受;"
|
|
|
|
|
|
"以承接、并列为先,**禁止**把模糊感受改写成确定关系、命运、动机或人生结论。\n"
|
|
|
|
|
|
"- 若带问句,最多**一个**,且须邀请对方**用自己的词**继续摸索或澄清(例如更接近哪一种、还是也不完全是),"
|
|
|
|
|
|
"禁止封闭式逼认、禁止替用户命名关系或事件结局。\n"
|
|
|
|
|
|
"- 允许整轮**只承接不问**;参考主槽「"
|
|
|
|
|
|
+ plan.anchor_slot_readable
|
|
|
|
|
|
+ "」仅供你心里知道后续方向,**不要**问卷式硬推该槽。"
|
|
|
|
|
|
)
|
2026-04-10 13:56:44 +08:00
|
|
|
|
elif plan.mode == "follow_user_only":
|
|
|
|
|
|
mode_rules = (
|
|
|
|
|
|
"- **跟话头**:本轮禁止问卷式首开、禁止重启式盘点;顺着用户刚展开的画面、人物或情绪自然往下。\n"
|
|
|
|
|
|
"- 若带问句,最多**一个**,且必须**从用户原词或下面摘录**长出来,禁止空泛「还有吗」。"
|
|
|
|
|
|
)
|
2026-05-11 12:06:17 +08:00
|
|
|
|
if plan.low_information_reply:
|
|
|
|
|
|
mode_rules = (
|
|
|
|
|
|
"- **跟话头 + 主动续话**:用户本轮只是简短确认,没有新素材;"
|
|
|
|
|
|
"不要围着短答本身澄清,也不要重复上一问等对方补充。\n"
|
|
|
|
|
|
"- 若带问句,最多**一个**,优先从上文最近的具体人/事/地方或下面摘录长出来;"
|
|
|
|
|
|
"若无可用摘录,就从当前阶段已聊内容的纵深处打开一个新回忆话题。"
|
|
|
|
|
|
)
|
2026-04-10 13:56:44 +08:00
|
|
|
|
else:
|
2026-05-11 12:06:17 +08:00
|
|
|
|
if plan.low_information_reply:
|
|
|
|
|
|
mode_rules = (
|
|
|
|
|
|
"- **回忆推进(memoir_push)**:用户本轮只是简短确认,"
|
|
|
|
|
|
"对用户可见回复中须有**恰好一个**开放式回忆追问,主动把话头往下递。\n"
|
|
|
|
|
|
" 追问可挂住**上文最近具体细节**、**主追问方向**或**挂钩摘录**;"
|
|
|
|
|
|
"不要要求从低信息短答里抽词。\n"
|
|
|
|
|
|
"- 禁止用日常寒暄替代该追问;仍遵守全篇「最多一个问句」「禁止晚会硬切」。"
|
|
|
|
|
|
)
|
|
|
|
|
|
else:
|
|
|
|
|
|
mode_rules = (
|
|
|
|
|
|
"- **回忆推进(memoir_push)**:对用户可见回复中须有**恰好一个**开放式回忆追问,\n"
|
|
|
|
|
|
" 且意图明显在补足下面「主追问方向」;问句必须挂住**用户本轮原词**或**挂钩摘录**(二者至少其一,且**优先原词**)。\n"
|
|
|
|
|
|
"- **禁止**把用户仍含糊的表达改写成确定事实或关系;禁止用日常寒暄替代该追问;"
|
|
|
|
|
|
"仍遵守全篇「最多一个问句」「禁止晚会硬切」。"
|
|
|
|
|
|
)
|
2026-04-10 13:56:44 +08:00
|
|
|
|
|
2026-04-22 16:56:28 +08:00
|
|
|
|
focus_block = _focus_directive_lines(plan)
|
|
|
|
|
|
|
|
|
|
|
|
if plan.anchor_source_kind == "user_message":
|
|
|
|
|
|
anchor_label = "(仅作追问挂钩,来自用户本轮原话摘录;优先把这句里的原词接住)"
|
|
|
|
|
|
elif plan.anchor_source_kind == "memory":
|
|
|
|
|
|
anchor_label = "(仅作追问挂钩,来自检索到的用户过往口述/摘要;不是用户本轮新说的内容)"
|
|
|
|
|
|
else:
|
|
|
|
|
|
anchor_label = "(无可用挂钩时,必须从用户本轮原话里抽词承接,禁止编造)"
|
|
|
|
|
|
|
2026-04-10 13:56:44 +08:00
|
|
|
|
return f"""## 本轮编排指令(硬规则,优先于后文一般性建议)
|
|
|
|
|
|
{mode_rules}
|
2026-05-11 12:06:17 +08:00
|
|
|
|
{focus_block}{ack_block}{mem_block}{perspective_block}{shape_block}- **主追问方向(叙述槽)**:{plan.anchor_slot_readable}
|
2026-04-22 16:56:28 +08:00
|
|
|
|
- **挂钩线索**{anchor_label}:{snippet_line}
|
2026-04-10 13:56:44 +08:00
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
__all__ = [
|
2026-04-22 16:56:28 +08:00
|
|
|
|
"FocusPrimary",
|
|
|
|
|
|
"FocusSource",
|
2026-04-10 13:56:44 +08:00
|
|
|
|
"InterviewTurnMode",
|
|
|
|
|
|
"InterviewTurnPlan",
|
2026-04-22 16:56:28 +08:00
|
|
|
|
"MemoryUsage",
|
|
|
|
|
|
"ReplyShape",
|
|
|
|
|
|
"SubjectOwner",
|
|
|
|
|
|
"AnchorSourceKind",
|
|
|
|
|
|
"apply_safe_mode_override",
|
|
|
|
|
|
"determine_anchor_source_kind",
|
2026-04-10 13:56:44 +08:00
|
|
|
|
"extract_anchor_snippet",
|
|
|
|
|
|
"format_interview_turn_directive_block",
|
|
|
|
|
|
"plan_interview_turn",
|
|
|
|
|
|
"primary_empty_slot",
|
|
|
|
|
|
]
|