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:
@@ -14,6 +14,8 @@ conversation/DB/LLM——IO、存储与提示词注入归调用方(轮前/轮
|
||||
"source": "ip_proposed", # ip_proposed|naive|ip_confirm|
|
||||
# prefix_normalized|customer_stated|no_ip|fallback
|
||||
"canon": "0501234567", # 客户原始号的纯数字形态(换号=RESET 的判据)
|
||||
"asked": true, # 确认句已发出(conversation 轮末钩子管理,
|
||||
# 首问轮免计;本模块不读不写,transit 原样携带)
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user