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>
This commit is contained in:
@@ -14,7 +14,7 @@ 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
|
||||
from app.agents.stage_constants import STAGE_KEYWORD_WEIGHTS, STAGE_SLOT_KEYS
|
||||
|
||||
InterviewTurnMode = Literal[
|
||||
"emotion_first",
|
||||
@@ -66,6 +66,7 @@ class InterviewTurnPlan:
|
||||
secondary_focus: FocusPrimary | None = None
|
||||
focus_summary: str = ""
|
||||
focus_source: FocusSource = "rule"
|
||||
low_information_reply: bool = False
|
||||
|
||||
# ---- 语义属性:供 prompt_layers / interview_agent 等调用方消费,禁止重复立法 ----
|
||||
|
||||
@@ -323,6 +324,17 @@ _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()
|
||||
@@ -351,6 +363,32 @@ def _is_ambiguous_or_needs_slow_pace(text: str) -> bool:
|
||||
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()
|
||||
@@ -396,6 +434,7 @@ def plan_interview_turn(
|
||||
)
|
||||
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
|
||||
@@ -403,6 +442,45 @@ def plan_interview_turn(
|
||||
):
|
||||
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 = (
|
||||
@@ -534,6 +612,17 @@ def format_interview_turn_directive_block(plan: InterviewTurnPlan) -> str:
|
||||
else:
|
||||
shape_block = ""
|
||||
|
||||
ack_block = ""
|
||||
if plan.low_information_reply:
|
||||
ack_block = (
|
||||
"- **低信息短回复处理**:用户本轮只是简短确认、应声或泛泛回应,没有新增叙事素材。"
|
||||
"不要把这个短答本身当成需要澄清的内容,不要反复追问「你是说……吗」,"
|
||||
"也不要停在原地等用户继续补充。\n"
|
||||
"- 先用半句自然接住,再主动从「主追问方向」、上文最近的具体人/事/地方,"
|
||||
"或过往记忆线索里挑一个**具体、好回答**的新回忆话题;若挂钩线索为空,"
|
||||
"允许直接借当前阶段未聊方向起问,但禁止编造用户没说过的细节。\n"
|
||||
)
|
||||
|
||||
if plan.mode == "emotion_first":
|
||||
mode_rules = (
|
||||
"- **情绪优先**:本轮以承接、并肩与安全感为主,**不要**为推进「叙述槽大纲」而追加信息搜集型追问。\n"
|
||||
@@ -557,13 +646,29 @@ def format_interview_turn_directive_block(plan: InterviewTurnPlan) -> str:
|
||||
"- **跟话头**:本轮禁止问卷式首开、禁止重启式盘点;顺着用户刚展开的画面、人物或情绪自然往下。\n"
|
||||
"- 若带问句,最多**一个**,且必须**从用户原词或下面摘录**长出来,禁止空泛「还有吗」。"
|
||||
)
|
||||
if plan.low_information_reply:
|
||||
mode_rules = (
|
||||
"- **跟话头 + 主动续话**:用户本轮只是简短确认,没有新素材;"
|
||||
"不要围着短答本身澄清,也不要重复上一问等对方补充。\n"
|
||||
"- 若带问句,最多**一个**,优先从上文最近的具体人/事/地方或下面摘录长出来;"
|
||||
"若无可用摘录,就从当前阶段已聊内容的纵深处打开一个新回忆话题。"
|
||||
)
|
||||
else:
|
||||
mode_rules = (
|
||||
"- **回忆推进(memoir_push)**:对用户可见回复中须有**恰好一个**开放式回忆追问,\n"
|
||||
" 且意图明显在补足下面「主追问方向」;问句必须挂住**用户本轮原词**或**挂钩摘录**(二者至少其一,且**优先原词**)。\n"
|
||||
"- **禁止**把用户仍含糊的表达改写成确定事实或关系;禁止用日常寒暄替代该追问;"
|
||||
"仍遵守全篇「最多一个问句」「禁止晚会硬切」。"
|
||||
)
|
||||
if plan.low_information_reply:
|
||||
mode_rules = (
|
||||
"- **回忆推进(memoir_push)**:用户本轮只是简短确认,"
|
||||
"对用户可见回复中须有**恰好一个**开放式回忆追问,主动把话头往下递。\n"
|
||||
" 追问可挂住**上文最近具体细节**、**主追问方向**或**挂钩摘录**;"
|
||||
"不要要求从低信息短答里抽词。\n"
|
||||
"- 禁止用日常寒暄替代该追问;仍遵守全篇「最多一个问句」「禁止晚会硬切」。"
|
||||
)
|
||||
else:
|
||||
mode_rules = (
|
||||
"- **回忆推进(memoir_push)**:对用户可见回复中须有**恰好一个**开放式回忆追问,\n"
|
||||
" 且意图明显在补足下面「主追问方向」;问句必须挂住**用户本轮原词**或**挂钩摘录**(二者至少其一,且**优先原词**)。\n"
|
||||
"- **禁止**把用户仍含糊的表达改写成确定事实或关系;禁止用日常寒暄替代该追问;"
|
||||
"仍遵守全篇「最多一个问句」「禁止晚会硬切」。"
|
||||
)
|
||||
|
||||
focus_block = _focus_directive_lines(plan)
|
||||
|
||||
@@ -576,7 +681,7 @@ def format_interview_turn_directive_block(plan: InterviewTurnPlan) -> str:
|
||||
|
||||
return f"""## 本轮编排指令(硬规则,优先于后文一般性建议)
|
||||
{mode_rules}
|
||||
{focus_block}{mem_block}{perspective_block}{shape_block}- **主追问方向(叙述槽)**:{plan.anchor_slot_readable}
|
||||
{focus_block}{ack_block}{mem_block}{perspective_block}{shape_block}- **主追问方向(叙述槽)**:{plan.anchor_slot_readable}
|
||||
- **挂钩线索**{anchor_label}:{snippet_line}
|
||||
"""
|
||||
|
||||
|
||||
Reference in New Issue
Block a user