Files
life-echo/api/app/agents/chat/interview_turn_plan.py

705 lines
27 KiB
Python
Raw Normal View History

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