Files
life-echo/api/app/agents/chat/interview_turn_plan.py
Kevin 705fe951b3 feat(chat): 低信息短答主动续话;修复本地 dev 环境与迁移链
- interview_turn_plan: 识别低信息短回复,引导 AI 承接后主动追问新话题
- development.sh / docker-compose.dev: Postgres/Redis 端口与 .env 对齐,补充宿主机端口监听检查
- Alembic: 补回 0016 memory pipeline status、0017 segment narrative defer
- app-expo: api/ws URL 去掉末尾斜杠,避免 WS 双斜杠;更新 .env.staging

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-11 12:06:17 +08:00

705 lines
27 KiB
Python
Raw Permalink 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.
"""
访谈轮次编排Option B 定位InterviewTurnPlan 是**本轮行为模式的唯一决策源**)。
约束说明:
- 主 promptprompt_layers只提供跨轮通用的承接-深挖-串联节奏与身份守则;
- 本轮是否「情绪优先」「跟话头」「回忆推进」完全由 `plan_interview_turn()` 决定;
- 对 LLM 的硬指令由 `InterviewTurnPlan.render_system_directive()` 输出,主 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_KEYWORD_WEIGHTS, STAGE_SLOT_KEYS
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"]
@dataclass(frozen=True)
class InterviewTurnPlan:
"""单轮访谈的硬目标(优先级高于主 prompt 的一般性建议)。
作为唯一决策源,对 LLM 的落地由 `render_system_directive()` 输出;其它调用方应通过
`is_emotion_first` / `is_clarify_first` / `is_follow_user_only` / `is_memoir_push` /
`requires_explicit_question` 等语义属性读取决策,避免在主 prompt 里重复立法。
扩展字段承载「主语归属 / 记忆引用策略 / 回复形状」,供防上下文污染与双阶段 planner 合并。
"""
mode: InterviewTurnMode
anchor_slot_key: str | None
anchor_slot_readable: str
anchor_snippet: str
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"
low_information_reply: bool = False
# ---- 语义属性:供 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"
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:
"""优先用户本轮原话,其次极短记忆线索(用于追问挂钩,非事实断言)。
旧记忆不得压过用户当前话头;仅当本轮原话过短时再借 `memory_anchor_source` 挂钩。
`memory_evidence_text` 此处仅为**一条短线索**(非整段 `[M…]` 摘录块)。
"""
um = (user_message or "").strip()
if len(um) >= 10:
return um[:max_chars].strip()
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 rest[:max_chars].strip()
return mem[:max_chars].strip()
if len(um) >= 4:
return um[:max_chars].strip()
return ""
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"
_EMOTION_MARKERS: tuple[str, ...] = (
"",
"难受",
"委屈",
"害怕",
"后悔",
"",
"舍不得",
"崩溃",
"绝望",
"心疼",
"哽咽",
"咽不下",
"睡不着",
"想哭",
"好难",
"太难了",
"挺不住",
"扛不住",
"放不下",
"意难平",
"难堪",
"丢脸",
"怕丢人",
"臊得慌",
)
# 说不清 / 暧昧放慢:不触发 memoir_push 式强推槽位
_AMBIGUITY_MARKERS: tuple[str, ...] = (
"说不清",
"说不明白",
"说不上来",
"说不上",
"说不好",
"不知道怎么说",
"不知道咋说",
"好像",
"也不是",
"不完全是",
"不太确定",
"不确定",
"有点模糊",
"很模糊",
"模模糊糊",
"难说",
"有一点,但我不确定",
)
_SOFT_SLOW_MARKERS: tuple[str, ...] = (
"害羞",
"羞涩",
"不好意思",
"脸红",
"暧昧",
)
_ASSISTANT_IDENTITY_QUESTION_MARKERS: tuple[str, ...] = (
"你是哪里人",
"你哪的人",
"你是哪里长大的",
"你在哪长大",
"你的童年",
"你小时候",
"你家里",
"你爸妈",
"你父母",
"你也有过",
"你也是",
)
_LOW_INFORMATION_REPLY_MAX_CHARS = 8
_LOW_INFORMATION_REPLY_CHARS: frozenset[str] = frozenset(
# 这不是短语白名单,而是一组低信息的应声/确认/语气字符。
# 只要短回复里出现不在此集合中的字,就会被视为有潜在叙事信号。
"嗯唔呃哦噢喔啊呀呢嘛吧哈"
"对是的了好行可允许以"
"没错确实不否无有还太很挺就"
"什么这样那样当当然"
)
_ACK_STRIP_CHARS = " \t\r\n,。!?!?、,.;:~~…"
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 _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
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)
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)
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,
)
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)
low_information_reply = _is_low_information_reply(um)
reply_shape: ReplyShape = "flexible"
if any(
k in um
for k in ("讲讲", "说说", "她的故事", "他的故事", "后来呢", "然后呢")
):
reply_shape = "ack_then_question"
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,
)
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,
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",
)
if stage_switched_this_turn:
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=reply_shape,
primary_focus=_focus_primary_for_mode("follow_user_only"),
focus_source="rule",
)
if not empty_slots:
return InterviewTurnPlan(
mode="follow_user_only",
anchor_slot_key=None,
anchor_slot_readable="(本阶段主要叙述槽已有素材)请 depth-first接续画面或情绪线别重启童年在哪长大式盘点",
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("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",
)
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,
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",
)
def format_interview_turn_directive_block(plan: InterviewTurnPlan) -> str:
"""注入 guided prompt 顶部的硬指令块。"""
snippet_line = (
plan.anchor_snippet
if plan.anchor_snippet
else "(无可用摘录时,必须从用户本轮原话里抽词作挂钩,禁止编造)"
)
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 = ""
ack_block = ""
if plan.low_information_reply:
ack_block = (
"- **低信息短回复处理**:用户本轮只是简短确认、应声或泛泛回应,没有新增叙事素材。"
"不要把这个短答本身当成需要澄清的内容,不要反复追问「你是说……吗」,"
"也不要停在原地等用户继续补充。\n"
"- 先用半句自然接住,再主动从「主追问方向」、上文最近的具体人/事/地方,"
"或过往记忆线索里挑一个**具体、好回答**的新回忆话题;若挂钩线索为空,"
"允许直接借当前阶段未聊方向起问,但禁止编造用户没说过的细节。\n"
)
if plan.mode == "emotion_first":
mode_rules = (
"- **情绪优先**:本轮以承接、并肩与安全感为主,**不要**为推进「叙述槽大纲」而追加信息搜集型追问。\n"
"- 若末尾带问,只能是**贴着用户当前情绪或原词**的极轻一句;禁止切到盘点式下一题。\n"
"- 参考主槽「"
+ plan.anchor_slot_readable
+ "」仅供你心里知道后续方向,**不要**在本轮用问卷口吻硬推该槽。"
)
elif plan.mode == "clarify_first":
mode_rules = (
"- **模糊先澄清**:用户正在表达不确定、说不清,或暧昧/羞涩等未命名感受;"
"以承接、并列为先,**禁止**把模糊感受改写成确定关系、命运、动机或人生结论。\n"
"- 若带问句,最多**一个**,且须邀请对方**用自己的词**继续摸索或澄清(例如更接近哪一种、还是也不完全是),"
"禁止封闭式逼认、禁止替用户命名关系或事件结局。\n"
"- 允许整轮**只承接不问**;参考主槽「"
+ plan.anchor_slot_readable
+ "」仅供你心里知道后续方向,**不要**问卷式硬推该槽。"
)
elif plan.mode == "follow_user_only":
mode_rules = (
"- **跟话头**:本轮禁止问卷式首开、禁止重启式盘点;顺着用户刚展开的画面、人物或情绪自然往下。\n"
"- 若带问句,最多**一个**,且必须**从用户原词或下面摘录**长出来,禁止空泛「还有吗」。"
)
if plan.low_information_reply:
mode_rules = (
"- **跟话头 + 主动续话**:用户本轮只是简短确认,没有新素材;"
"不要围着短答本身澄清,也不要重复上一问等对方补充。\n"
"- 若带问句,最多**一个**,优先从上文最近的具体人/事/地方或下面摘录长出来;"
"若无可用摘录,就从当前阶段已聊内容的纵深处打开一个新回忆话题。"
)
else:
if plan.low_information_reply:
mode_rules = (
"- **回忆推进memoir_push**:用户本轮只是简短确认,"
"对用户可见回复中须有**恰好一个**开放式回忆追问,主动把话头往下递。\n"
" 追问可挂住**上文最近具体细节**、**主追问方向**或**挂钩摘录**"
"不要要求从低信息短答里抽词。\n"
"- 禁止用日常寒暄替代该追问;仍遵守全篇「最多一个问句」「禁止晚会硬切」。"
)
else:
mode_rules = (
"- **回忆推进memoir_push**:对用户可见回复中须有**恰好一个**开放式回忆追问,\n"
" 且意图明显在补足下面「主追问方向」;问句必须挂住**用户本轮原词**或**挂钩摘录**(二者至少其一,且**优先原词**)。\n"
"- **禁止**把用户仍含糊的表达改写成确定事实或关系;禁止用日常寒暄替代该追问;"
"仍遵守全篇「最多一个问句」「禁止晚会硬切」。"
)
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 = "(无可用挂钩时,必须从用户本轮原话里抽词承接,禁止编造)"
return f"""## 本轮编排指令(硬规则,优先于后文一般性建议)
{mode_rules}
{focus_block}{ack_block}{mem_block}{perspective_block}{shape_block}- **主追问方向(叙述槽)**{plan.anchor_slot_readable}
- **挂钩线索**{anchor_label}{snippet_line}
"""
__all__ = [
"FocusPrimary",
"FocusSource",
"InterviewTurnMode",
"InterviewTurnPlan",
"MemoryUsage",
"ReplyShape",
"SubjectOwner",
"AnchorSourceKind",
"apply_safe_mode_override",
"determine_anchor_source_kind",
"extract_anchor_snippet",
"format_interview_turn_directive_block",
"plan_interview_turn",
"primary_empty_slot",
]