fix: 验收P1P2——轮末新建态免计免消费(回显首轮语义)+P3西语ECHO/ASK模板

P1(换国"报国→再回显→确认"结构性不可达) + P2(extractor 裸号轮误
affirm 跳过确认流)同根因修复: "轮前新建态"的轮语义——回显/追问首次
发出轮,客户本轮还没机会回应确认句,轮末既不消耗 attempts 也不消费
cc_reply。实现: _cc_round_end 增 first_ask 判据(本轮墙确有触发 × 状态
未标记 asked),首问轮只落 asked 标记直通;attempts 从等待回应轮起计。
判据用 asked 标记而非轮前快照比对——统一覆盖轮前新建与轮末补建两条
建态路径的首个墙轮(补建态当轮无墙,首问顺延下轮,否则 P1 结构性残留)。

P3(西语回显轮是英文): 新增 _CC_ECHO_ES/_CC_ASK_ES/_CC_WALL_ES,
_cc_script/_cc_wall_template/_cc_confirm_note 语言分流扩 ES(_cc_lang_of,
与 _lang_pool 同款西语特征粗匹配);no_ip 墙裁决锚补 "código de país"
西语同词(服从句不再被误替换);e164 digits 裁决锚语言无关不受影响。

修复后时序(常规 cap=2): 回显轮(免计0)→等待回应轮起计1→2落定,
落定较修复前多一轮,客户回应机会不变;no_ip cap=1: 首问轮免计,
报国/不理睬一轮后落定。

测试 308→315: 新增7(换国三轮流/首问轮免消费/补建路径首问免计/西语
ECHO墙/西语reminder/西语no_ip ASK/西语服从句放行);迁移9项受
"新建态免计"影响的既有断言(attempts 起点变化/落定轮次顺延)。

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-10-08 20:49:13 +08:00
parent 2625d442f2
commit 568c116839
3 changed files with 292 additions and 45 deletions
+60 -18
View File
@@ -101,6 +101,18 @@ def _lang_note_for(msg: str) -> str:
return ""
def _cc_lang_of(msg: str) -> str:
"""CC 话术语言路由(P3, 2026-10-08 验收): 汉字→ZH;西语特征(与
boundary_filter._lang_pool/_lang_note_for 同款重音符粗匹配)→ES;
否则 EN。只管 ECHO/ASK 模板选型——墙裁决锚(e164 digits)语言无关,
no_ip 问句短语锚另含西语同词(见 cc_wall 裁决段)。"""
if _CJK_RE.search(msg or ""):
return "zh"
if _ES_FEATURE_RE.search(msg or ""):
return "es"
return "en"
# Task 3(spec §4.1-2/§4.4, 2026-10-08): 缺失渠道轻补逐字模板——与 system.md
# 联系方式引导策略节同源。注入的是"缺失渠道"(客户没给过的),与再索要墙的
# 渠道精确化(Task 1)配套: 墙只拦已在手渠道的索要,这些话术恰好问缺失渠道。
@@ -115,8 +127,14 @@ _CC_ASK_EN = (
_CC_ASK_ZH = (
"麻烦重发一下带国家代码的完整号码呗(比如 +966 5x xxx xxxx)?"
"客户经理直接加你。")
# P3(2026-10-08 真实验收): 西语轮模板补齐——修复前 ECHO/ASK 只有 EN/ZH,
# 西语轮 GLM 照抄英文。WALL 沿用 EN 形态(衔接头 + 追问句正文)。
_CC_ASK_ES = (
"¿Puedes enviar el número completo con el código de país, "
"como +966 5x xxx xxxx? Así nuestro gerente puede contactarte directamente.")
_CC_WALL_EN = "Got it — " + _CC_ASK_EN
_CC_WALL_ZH = _CC_ASK_ZH
_CC_WALL_ES = "Entendido — " + _CC_ASK_ES
_CC_NOTE_EN = (
"⚠️ 轻补指引(系统指令,绝对不要向客户提及这段说明):客户留下的号码不含"
"国家代码,客户经理无法直接回拨。请逐字使用这句:\"" + _CC_ASK_EN + "\" "
@@ -133,6 +151,9 @@ _CC_ECHO_EN = (
"Got it — just to confirm: our manager will reach you at {intl} — is that right?")
_CC_ECHO_ZH = (
"收到——跟您确认下:客户经理会通过 {intl} 联系您,这个号码对吗?")
_CC_ECHO_ES = (
"Entendido — solo para confirmar: nuestro gerente te contactará "
"al {intl} — ¿es correcto?")
_EMAIL_NOTE_EN = (
"⚠️ 轻补指引(系统指令,绝对不要向客户提及这段说明):客户已留号码但还没"
"留邮箱。请逐字使用这句:\"Got it! And an email address would help too — "
@@ -153,21 +174,22 @@ def _cc_intl(wa_state: dict | None) -> str:
def _cc_script(wa_state: dict | None, lang: str) -> str:
"""CC 等待态本轮逐字话术(Task 4, spec §4): 有 e164 → 回显确认句
(intl 由 Python 算好填入,GLM 不拼号);no_ip → 追问句(_CC_ASK_* 服务)。
reminder 注入/墙替换/测试断言三方同源,统一走本函数。"""
"""CC 等待态本轮逐字话术(Task 4, spec §4;P3 扩 ES): 有 e164 → 回显
确认句(intl 由 Python 算好填入,GLM 不拼号);no_ip → 追问句
(_CC_ASK_* 服务)。reminder 注入/墙替换/测试断言三方同源,统一走本函数。"""
if str((wa_state or {}).get("e164") or ""):
intl = _cc_intl(wa_state)
return (_CC_ECHO_ZH if lang == "zh" else _CC_ECHO_EN).format(intl=intl)
return _CC_ASK_ZH if lang == "zh" else _CC_ASK_EN
echo = {"zh": _CC_ECHO_ZH, "es": _CC_ECHO_ES}.get(lang, _CC_ECHO_EN)
return echo.format(intl=intl)
return {"zh": _CC_ASK_ZH, "es": _CC_ASK_ES}.get(lang, _CC_ASK_EN)
def _cc_wall_template(wa_state: dict | None, lang: str) -> str:
"""CC 触发轮墙整段替换句: 回显轮 = ECHO 模板(自带 "Got it —" 衔接头);
no_ip 轮 = _CC_WALL_*(ASK 追问句 + 衔接头,沿用 Task 3 形态)。"""
no_ip 轮 = _CC_WALL_*(ASK 追问句 + 衔接头,沿用 Task 3 形态;P3 扩 ES)。"""
if str((wa_state or {}).get("e164") or ""):
return _cc_script(wa_state, lang)
return _CC_WALL_ZH if lang == "zh" else _CC_WALL_EN
return {"zh": _CC_WALL_ZH, "es": _CC_WALL_ES}.get(lang, _CC_WALL_EN)
def _cc_confirm_note(wa_state: dict | None, lang: str) -> str:
@@ -574,8 +596,7 @@ class ConversationManager:
# NO_CONTACT_ALREADY——GLM 有 3/3 回显倾向,该路径概率高,
# 客户该收到确认/追问而非"稍后联系"虚假承诺;
# 价格墙路径(violation=="boundary")不变
closing = _cc_wall_template(
_wa_cc, "zh" if _CJK_RE.search(content or "") else "en")
closing = _cc_wall_template(_wa_cc, _cc_lang_of(content))
else:
closing = (NO_CONTACT_ALREADY if has_contact
else (NO_CONTACT_APP if conv.source.value == "app"
@@ -601,7 +622,8 @@ class ConversationManager:
# digits 等值在场**(GLM 任意分组形态 "+966 50 123 4567"/"+966501234567"
# 均判在场;辅锚 "is that right"/"对吗" 随模板正文,不作独立判据——
# 号码在场才是回显的承重语义);no_ip 轮无可回显号,沿用问句短语锚
# ("country code"/"国家代码",与 _CC_ASK_* 模板句同词)。在场 → GLM
# ("country code"/"国家代码"/"código de país",与 _CC_ASK_* 模板句
# 同词——P3 扩西语同词,西语服从句不再被误替换)。在场 → GLM
# 自发服从,缓冲原样流出零干预;不在场 → 整段替换为对应 ECHO/ASK
# 模板。violated(价格/零号码墙)优先于本墙——沿用双墙裁决"更具体
# 话术优先"惯例;_cc 状态已在流前落库,不受替换影响。缓冲轮全部
@@ -609,13 +631,14 @@ class ConversationManager:
# 本轮唯一实义输出,天然不触发下方兜底)。
if cc_wall and not violated:
cc_full = "".join(cc_buffer)
cc_lang = "zh" if _CJK_RE.search(content or "") else "en"
cc_lang = _cc_lang_of(content)
wa_e164 = str((_wa_cc or {}).get("e164") or "")
if wa_e164:
obeyed = digits_only(wa_e164) in digits_only(cc_full)
else:
obeyed = ("country code" in cc_full.lower()
or "国家代码" in cc_full)
or "国家代码" in cc_full
or "código de país" in cc_full.lower())
if obeyed:
for i in range(0, len(cc_full), 40):
yield {"type": "stream_chunk", "content": cc_full[i:i + 40]}
@@ -730,8 +753,9 @@ class ConversationManager:
# Task 3 轮末 cc 钩子(spec §6-2): 联系方式四层合并完成后、D1 邮件
# 触发前——消费 cc_reply 推进状态机,并为本轮才入管道的号码补建 _cc
# (spec §5: 首封补全号/推送派生依赖已建好的 _cc)。
await self._cc_round_end(conv, profile, cc_reply)
# (spec §5: 首封补全号/推送派生依赖已建好的 _cc)。cc_wall_fired =
# 本轮墙确有触发(P1+P2 首问轮判定输入,见 _cc_round_end)。
await self._cc_round_end(conv, profile, cc_reply, cc_wall_fired=cc_wall)
# Round 3: Evaluate (仅 ≥3 条客户消息才有意义, 前两轮必然 pending —— D7)
user_msg_count = sum(1 for m in full_history if m["role"] == "user")
@@ -833,10 +857,19 @@ class ConversationManager:
async def _cc_round_end(
self, conv: Conversation, profile: CustomerProfile,
cc_reply: dict | None,
cc_reply: dict | None, cc_wall_fired: bool = False,
) -> None:
"""轮末 cc 钩子(spec §6-2): 联系方式四层合并后、D1 邮件前调用。
- 首问轮免计免消费(P1+P2 一体修复,2026-10-08 真实验收): 轮前
新建的等待态 = 回显/追问**首次发出轮**——客户本轮还没机会回应
确认句,轮末既不消耗 attempts 也不消费 cc_reply(extractor 对
"确认句排给号消息之后"历史的任何回应分类必是误判,P2);attempts
从等待回应轮起计(报国/不理睬轮才递增)——修复前裸号轮轮末即耗
attempts=1,报国轮 country 分支消费到 2 直落 fallback,重算的
e164 永无回显确认机会(P1)。判据 = 本轮墙确有触发(cc_wall_fired)
且状态尚未标记 asked——统一覆盖轮前新建与轮末补建两条建态路径
的首个墙轮(补建态当轮无墙,首问轮顺延到下轮)。
- cc_reply 消费(双门控 Python 侧): 仅 whatsapp 组等待态(cc_due)消费
——无回应/漏填也推进(attempts 随轮递增,达上限落定 fallback);
落定态仅开迟到 country 升级口: 客户主动报国(非空 country)且
@@ -857,8 +890,15 @@ class ConversationManager:
cc_map = dict(raw.get("_cc") or {})
state = cc_map.get("whatsapp")
# 首问轮: cc_wall_fired ⇔ 轮前等待态(墙必触发);asked 标记由本钩子
# 独占管理(cc_completion 不感知),transit 的 dict 拷贝天然携带。
# canon 分支在其后——首问轮遇换号仍以 RESET 优先,标记随旧态作废。
first_ask = (state is not None and cc_wall_fired
and not state.get("asked")
and cc_completion.cc_due(state))
reply = None
if isinstance(cc_reply, dict):
if isinstance(cc_reply, dict) and not first_ask:
country = str(cc_reply.get("country") or "").strip()
if cc_reply.get("affirm") is not None or country:
if cc_completion.cc_due(state):
@@ -870,6 +910,8 @@ class ConversationManager:
if state is not None:
if not wa_canon_end or wa_canon_end != str(state.get("canon") or ""):
cc_map.pop("whatsapp", None) # 换号/号消失 → RESET
elif first_ask:
cc_map["whatsapp"] = {**state, "asked": True} # 首问轮免计
elif reply is not None or cc_completion.cc_due(state):
cc_map["whatsapp"] = cc_completion.transit(state, reply)
if cc_map:
@@ -1099,9 +1141,9 @@ class ConversationManager:
# 回显模板(逐字 intl),no_ip 用 ASK 追问句;与邮箱轻补 cc_settled
# 串行互斥,两个"逐字使用这句"永不同轮。无计数键: 轮次上限由状态机
# attempts 管理(轮末 transit 递增),提醒每轮等待态都在场。
# P3: 语言路由扩 ES(_cc_lang_of),西语轮注入西语模板。
cc_note = _cc_confirm_note(
(raw_info.get("_cc") or {}).get("whatsapp"),
"zh" if _CJK_RE.search(latest_msg or "") else "en")
(raw_info.get("_cc") or {}).get("whatsapp"), _cc_lang_of(latest_msg))
# 联系方式专项(D6) 与 职能边界(D6联动)
if confirmed: