diff --git a/app/services/cc_completion.py b/app/services/cc_completion.py index 2f846cd..db70829 100644 --- a/app/services/cc_completion.py +++ b/app/services/cc_completion.py @@ -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 原样携带) } """ diff --git a/app/services/conversation.py b/app/services/conversation.py index 1d62795..3bf6564 100644 --- a/app/services/conversation.py +++ b/app/services/conversation.py @@ -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: diff --git a/tests/test_lead_email_trigger.py b/tests/test_lead_email_trigger.py index e003f4c..f027f3a 100644 --- a/tests/test_lead_email_trigger.py +++ b/tests/test_lead_email_trigger.py @@ -808,7 +808,12 @@ def test_no_contact_wall_already_contact_no_reask(async_db, monkeypatch): async_db, monkeypatch, ai=EchoAI(extract={}), source=ConversationSource.standalone) asyncio.run(_send(manager, conv.session_id, "my whatsapp is 254712 345678")) + # 首问轮免计(P1+P2 修复): 次轮 no_ip 仍未落定(0<1)→ CC 墙照常介入, + # 回显被零号码墙拦下时 closing = CC 追问句(Important-2 路由);本轮 + # 轮末 transit 才落定(attempts=1=cap) asyncio.run(_send(manager, conv.session_id, "so how much is pvc?")) + # 第三轮: 确认流已落定 → CC 墙关闭,纯零号码墙路径 + asyncio.run(_send(manager, conv.session_id, "and the fittings?")) msgs = asyncio.run(manager._load_history(conv.id)) tail = [m["content"] for m in msgs if m["role"] == "assistant"][-1] # 精确断言(评审加固): 负向断言("WhatsApp?" not in)对三种替换句都空转通过, @@ -1020,9 +1025,11 @@ def test_inline_prohibitions_all_carry_reask_exception(): def test_contact_completion_two_round_serial_and_persistence(async_db, monkeypatch): """spec §6-1/§6-2 集成: 裸号轮当轮 _cc 已建且落库(轮前钩子实证——墙 - 触发依赖它);确认流未落定期间邮箱轻补被拦;无回应两轮 attempts 耗尽 - 落定 fallback 后,串行转邮箱轻补并落 _email_asked。 - Task 4: 有建议国(SA→e164)轮墙替换句 = 回显确认模板(ECHO,含 intl)。""" + 触发依赖它);确认流未落定期间邮箱轻补被拦;无回应轮 attempts 递增, + 耗尽落定 fallback 后串行转邮箱轻补并落 _email_asked。 + Task 4: 有建议国(SA→e164)轮墙替换句 = 回显确认模板(ECHO,含 intl)。 + P1+P2 修复后 attempts 语义: 首问轮(回显首次发出)免计 0→等待回应轮 + 起计 1→2 落定——较修复前多一轮,客户回应机会不变。""" from app.services import ip_service from app.services.conversation import _CC_ECHO_EN @@ -1043,37 +1050,46 @@ def test_contact_completion_two_round_serial_and_persistence(async_db, monkeypat source=ConversationSource.standalone) asyncio.run(_send(manager, conv.session_id, "my whatsapp is 0501234567")) # 第一轮正向断言(轮前钩子实证): _cc 当轮已建并落库,墙靠它触发替换。 - # attempts 语义(Task 3 调参钉死): 追问轮自身在轮末即消耗一轮—— - # 轮前 attempts=0 建态 → 墙问 → 轮末 transit(None) → attempts=1; - # 常规路径客户总共恰被问 2 次(第 1/2 轮),第 3 轮起轻补解锁 + # attempts 语义(P1+P2 修复): 首问轮(回显首次发出)免计——客户本轮 + # 还没机会回应,轮末不消耗不消费,只落 asked 标记;attempts 从等待 + # 回应轮(次轮)起计 profile = asyncio.run(manager._get_profile(conv.id)) wa_cc = (profile.raw_info or {}).get("_cc", {}).get("whatsapp") - assert wa_cc == {"confirmed": False, "attempts": 1, "source": "ip_proposed", + assert wa_cc == {"confirmed": False, "attempts": 0, "source": "ip_proposed", "canon": "0501234567", "e164": "+966501234567", - "intl": "+966 50 123 4567"} + "intl": "+966 50 123 4567", "asked": True} tails = [m["content"] for m in asyncio.run(manager._load_history(conv.id)) if m["role"] == "assistant"] assert tails[-1] == _CC_ECHO_EN.format(intl="+966 50 123 4567") # 回显模板 assert "email address would help too" not in ai.contexts[0] # 未落定: 轻补被拦 assert (profile.raw_info or {}).get("_cc_asked") is None # 旧计数停写 - # 第二轮无回应: 墙再问一次(1<2 仍 due),轮末 attempts=2 达上限落定 fallback + # 第二轮无回应: 墙再问一次(0<2 仍 due),轮末 attempts=1(等待回应轮起计) asyncio.run(_send(manager, conv.session_id, "so how much is the pvc pipe?")) tails = [m["content"] for m in asyncio.run(manager._load_history(conv.id)) if m["role"] == "assistant"] assert tails[-1] == _CC_ECHO_EN.format(intl="+966 50 123 4567") # 第 2 次(上限内) wa_cc = (asyncio.run(manager._get_profile(conv.id)) .raw_info or {}).get("_cc", {}).get("whatsapp") - assert wa_cc["attempts"] == 2 and wa_cc["source"] == "fallback" + assert wa_cc["attempts"] == 1 and wa_cc["source"] == "ip_proposed" assert "email address would help too" not in ai.contexts[1] - # 第三轮: 已落定 → 墙关闭、确认句让位、邮箱轻补串行解锁并落计数 + # 第三轮无回应: 墙问第 3 次(1<2 仍 due),轮末 attempts=2 达上限落定 fallback asyncio.run(_send(manager, conv.session_id, "and the fittings?")) + tails = [m["content"] for m in asyncio.run(manager._load_history(conv.id)) + if m["role"] == "assistant"] + assert tails[-1] == _CC_ECHO_EN.format(intl="+966 50 123 4567") # 第 3 次(上限内) + wa_cc = (asyncio.run(manager._get_profile(conv.id)) + .raw_info or {}).get("_cc", {}).get("whatsapp") + assert wa_cc["attempts"] == 2 and wa_cc["source"] == "fallback" + assert "email address would help too" not in ai.contexts[2] + # 第四轮: 已落定 → 墙关闭、确认句让位、邮箱轻补串行解锁并落计数 + asyncio.run(_send(manager, conv.session_id, "what about the price list?")) tails = [m["content"] for m in asyncio.run(manager._load_history(conv.id)) if m["role"] == "assistant"] assert tails[-1] == "ok reply" # 墙关闭,AI 原文放行 wa_cc = (asyncio.run(manager._get_profile(conv.id)) .raw_info or {}).get("_cc", {}).get("whatsapp") assert wa_cc["attempts"] == 2 and wa_cc["source"] == "fallback" - assert "email address would help too" in ai.contexts[2] + assert "email address would help too" in ai.contexts[3] profile = asyncio.run(manager._get_profile(conv.id)) assert (profile.raw_info or {}).get("_email_asked") is True # 计数已落库 asyncio.run(manager.db.close()) @@ -1115,7 +1131,8 @@ def test_cc_reply_affirm_confirms_and_unlocks_email(async_db, monkeypatch): wa = (asyncio.run(manager._get_profile(conv.id)) .raw_info or {}).get("_cc", {}).get("whatsapp") assert wa["confirmed"] is True and wa["source"] == "ip_confirm" - assert wa["e164"] == "+966501234567" and wa["attempts"] == 1 + # P1+P2: 首问轮免计(attempts=0),affirm 确认不消耗——保持 0 + assert wa["e164"] == "+966501234567" and wa["attempts"] == 0 assert "email address would help too" not in ai.contexts[1] # 确认轮仍串行 asyncio.run(_send(manager, conv.session_id, "and the fittings?")) assert "email address would help too" in ai.contexts[2] # 落定后解锁 @@ -1141,7 +1158,8 @@ def test_cc_canon_change_resets_and_rewalks(async_db, monkeypatch): wa = cc.get("whatsapp") exp = classify("0512345678", "SA")[1] assert wa["canon"] == "0512345678" # 旧 canon 状态已被替换 - assert wa["source"] == "ip_proposed" and wa["attempts"] == 1 # 重走+本轮已耗1 + # P1+P2: 重走态本轮即回显(首问轮)→ 免计 attempts=0(旧口径为 1) + assert wa["source"] == "ip_proposed" and wa["attempts"] == 0 assert wa["e164"] == exp["e164"] # 建议随新号重算 assert set(cc) == {"whatsapp"} # 同 canon 手机镜像归并进 whatsapp 组 asyncio.run(manager.db.close()) @@ -1149,14 +1167,16 @@ def test_cc_canon_change_resets_and_rewalks(async_db, monkeypatch): def test_cc_late_country_upgrade_on_fallback(async_db, monkeypatch): """落定 fallback 后客户主动报国 → 受控升级: e164 重算、信息不丢, - 但不再回显追问(attempts 已过上限,保持落定)。""" + 但不再回显追问(attempts 已过上限,保持落定)。P1+P2 后 attempts 从 + 等待回应轮起计,需两轮不理睬(1→2)才落定 fallback,报国轮顺延。""" _sa(monkeypatch) - ai = _RecAI(extract=[{}, {}, + ai = _RecAI(extract=[{}, {}, {}, {"cc_reply": {"affirm": False, "country": "Egypt"}}]) (manager, conv), _ = _setup(async_db, monkeypatch, ai=ai, source=ConversationSource.standalone) asyncio.run(_send(manager, conv.session_id, "my whatsapp is 0501234567")) asyncio.run(_send(manager, conv.session_id, "so how much is the pvc pipe?")) + asyncio.run(_send(manager, conv.session_id, "and the fittings?")) asyncio.run(_send(manager, conv.session_id, "I am based in Egypt")) wa = (asyncio.run(manager._get_profile(conv.id)) .raw_info or {}).get("_cc", {}).get("whatsapp") @@ -1171,12 +1191,14 @@ def test_cc_late_country_upgrade_on_fallback(async_db, monkeypatch): def test_cc_late_country_on_no_ip_exhausted_ignored(async_db, monkeypatch): """no_ip 态一轮耗尽后收到迟到报国 → 忽略(该口仅对有建议国的 - 落定态开放;no_ip 耗尽是设计明确的静默终态)。""" - ai = _RecAI(extract=[{}, + 落定态开放;no_ip 耗尽是设计明确的静默终态)。P1+P2 后首问轮免计, + 耗尽落定顺延到次轮轮末,报国轮再顺延一轮。""" + ai = _RecAI(extract=[{}, {}, {"cc_reply": {"affirm": False, "country": "Egypt"}}]) (manager, conv), _ = _setup(async_db, monkeypatch, ai=ai, source=ConversationSource.standalone) asyncio.run(_send(manager, conv.session_id, "my whatsapp is 0501234567")) + asyncio.run(_send(manager, conv.session_id, "so how much is the pvc pipe?")) asyncio.run(_send(manager, conv.session_id, "I live in Egypt actually")) wa = (asyncio.run(manager._get_profile(conv.id)) .raw_info or {}).get("_cc", {}).get("whatsapp") @@ -1185,16 +1207,102 @@ def test_cc_late_country_on_no_ip_exhausted_ignored(async_db, monkeypatch): asyncio.run(manager.db.close()) +def test_cc_country_report_recalc_reechoes_and_confirms(async_db, monkeypatch): + """P1 回归(2026-10-08 真实验收): 换国"报国→再回显→确认"三轮可达—— + 裸号轮首问免计(attempts=0)→报国轮 attempts=1 且 e164 按报国重算 + (次轮回显新号)→yes 轮 confirmed(source=customer_stated)。 + 修复前: 裸号轮轮末 transit(None) 即耗 attempts=1("回显发出轮"被当 + "等待回应轮"计),报国轮 country 分支消费到 2 直落 fallback——重算的 + e164 永无回显确认机会。""" + from app.services.cc_completion import format_intl + from app.services.conversation import _CC_ECHO_EN + _sa(monkeypatch) + ai = _RecAI(extract=[{}, + {"cc_reply": {"affirm": False, "country": "Egypt"}}, + {"cc_reply": {"affirm": True, "country": None}}]) + (manager, conv), _ = _setup(async_db, monkeypatch, ai=ai, + source=ConversationSource.standalone) + asyncio.run(_send(manager, conv.session_id, "my whatsapp is 0501234567")) + wa = (asyncio.run(manager._get_profile(conv.id)) + .raw_info or {}).get("_cc", {}).get("whatsapp") + assert wa["attempts"] == 0 # 首问轮(回显首次发出)免计 + asyncio.run(_send(manager, conv.session_id, "no, I am in Egypt")) + wa = (asyncio.run(manager._get_profile(conv.id)) + .raw_info or {}).get("_cc", {}).get("whatsapp") + assert wa["attempts"] == 1 # 等待回应轮起计 + assert wa["source"] == "customer_stated" and wa["e164"] == "+20501234567" + echo_sa = _CC_ECHO_EN.format(intl="+966 50 123 4567") + tails = [m["content"] for m in asyncio.run(manager._load_history(conv.id)) + if m["role"] == "assistant"] + assert tails[0] == echo_sa # 首问轮回显(IP 推测号) + assert tails[1] == echo_sa # 报国轮时滞: 仍回显旧号 + asyncio.run(_send(manager, conv.session_id, "yes that's right")) + wa = (asyncio.run(manager._get_profile(conv.id)) + .raw_info or {}).get("_cc", {}).get("whatsapp") + assert wa["confirmed"] is True and wa["source"] == "customer_stated" + assert wa["e164"] == "+20501234567" + tails = [m["content"] for m in asyncio.run(manager._load_history(conv.id)) + if m["role"] == "assistant"] + assert tails[2] == _CC_ECHO_EN.format(intl=format_intl("+20501234567")) + asyncio.run(manager.db.close()) # 重算号再回显(确认轮时滞) + + +def test_cc_fresh_round_ignores_extractor_affirm(async_db, monkeypatch): + """P2 回归(2026-10-08 真实验收): 裸号轮 extractor 误把给号消息分类 + affirm=true(同轮回显使确认句排给号消息之后,GLM 误判)→ 首问轮轮末 + 不消费 cc_reply: attempts=0、confirmed=False——确认流不被跳过, + 错号不可能被误标 confirmed。""" + _sa(monkeypatch) + ai = _RecAI(extract=[{"cc_reply": {"affirm": True, "country": None}}]) + (manager, conv), _ = _setup(async_db, monkeypatch, ai=ai, + source=ConversationSource.standalone) + asyncio.run(_send(manager, conv.session_id, "my whatsapp is 0501234567")) + wa = (asyncio.run(manager._get_profile(conv.id)) + .raw_info or {}).get("_cc", {}).get("whatsapp") + assert wa["confirmed"] is False # 误判 affirm 未被消费 + assert wa["attempts"] == 0 and wa["source"] == "ip_proposed" + assert wa.get("asked") is True # 首问轮已标记(下轮起计) + asyncio.run(manager.db.close()) + + +def test_cc_extractor_sourced_number_first_wall_round_free(async_db, monkeypatch): + """补建路径的 P1 同构修复: 号码仅 extractor 可见(正则扫描失明)→ 当轮 + 轮末补建态(无墙无回显,attempts=0);首个墙轮(次轮回显首次发出)免计 + ——判据"墙确有触发且未标记 asked"而非"轮前快照无同 canon 态", + 否则补建态会在首问轮即耗 attempts=1(P1 结构性残留)。""" + from app.services.conversation import _CC_ECHO_EN + _sa(monkeypatch) + ai = _RecAI(extract=[{"联系方式": {"whatsapp": "0501234567"}}, {}]) + (manager, conv), _ = _setup(async_db, monkeypatch, ai=ai, + source=ConversationSource.standalone) + asyncio.run(_send(manager, conv.session_id, "let me share my contact")) + wa = (asyncio.run(manager._get_profile(conv.id)) + .raw_info or {}).get("_cc", {}).get("whatsapp") + assert wa and wa["attempts"] == 0 # 补建态已建(轮末 pass) + assert wa.get("asked") is None # 当轮无墙: 未标记 + asyncio.run(_send(manager, conv.session_id, "so how much is the pvc pipe?")) + wa = (asyncio.run(manager._get_profile(conv.id)) + .raw_info or {}).get("_cc", {}).get("whatsapp") + assert wa["attempts"] == 0 and wa.get("asked") is True # 首个墙轮免计 + tails = [m["content"] for m in asyncio.run(manager._load_history(conv.id)) + if m["role"] == "assistant"] + assert tails[1] == _CC_ECHO_EN.format(intl="+966 50 123 4567") # 首问回显 + asyncio.run(manager.db.close()) + + def test_extractor_hygiene_strips_cc_reply_and_underscore_keys( async_db, monkeypatch): """出口卫生三封堵: ① extractor 入参 existing_info 无 `_` 键; ② cc_reply 提取后即剥离(不进 raw_info);③ extractor 回传的 `_cc`/ - `_evil` 防御性丢弃——_cc 由 Python 判定树独占管理,LLM 回显覆盖不了。""" + `_evil` 防御性丢弃——_cc 由 Python 判定树独占管理,LLM 回显覆盖不了。 + P2: 首问轮(裸号轮)cc_reply 免消费——同轮回显使确认句排给号消息 + 之后,extractor 分类必是误判;次轮报国 cc_reply 正常消费。""" _sa(monkeypatch) poison = {"联系方式": {"whatsapp": "0501234567"}, "cc_reply": {"affirm": False, "country": "Egypt"}, "_cc": {"whatsapp": {"hacked": True}}, "_evil": 1} - ai = _RecAI(extract=[poison]) + ai = _RecAI(extract=[poison, {"cc_reply": {"affirm": False, + "country": "Egypt"}}]) (manager, conv), _ = _setup(async_db, monkeypatch, ai=ai, source=ConversationSource.standalone) asyncio.run(_send(manager, conv.session_id, "my whatsapp is 0501234567")) @@ -1205,7 +1313,13 @@ def test_extractor_hygiene_strips_cc_reply_and_underscore_keys( assert "cc_reply" not in raw and "_evil" not in raw # ②③ 出口侧 wa = (raw.get("_cc") or {}).get("whatsapp") assert wa and wa.get("canon") == "0501234567" # Python 管理,非 hacked - assert wa["source"] == "customer_stated" # cc_reply 正常消费 + assert wa["source"] == "ip_proposed" # P2: 首问轮免消费 + assert wa["e164"] == "+966501234567" + # 次轮(等待回应轮): cc_reply 正常消费——报国重算 + asyncio.run(_send(manager, conv.session_id, "I am in Egypt")) + wa = (asyncio.run(manager._get_profile(conv.id)) + .raw_info or {}).get("_cc", {}).get("whatsapp") + assert wa["source"] == "customer_stated" assert wa["e164"] == "+20501234567" asyncio.run(manager.db.close()) @@ -1252,7 +1366,8 @@ def test_cc_reply_on_confirmed_state_ignored(async_db, monkeypatch): wa = (asyncio.run(manager._get_profile(conv.id)) .raw_info or {}).get("_cc", {}).get("whatsapp") assert wa["confirmed"] is True and wa["source"] == "ip_confirm" - assert wa["e164"] == "+966501234567" and wa["attempts"] == 1 # 原样未动 + # P1+P2: 首问轮免计,affirm 确认不消耗 → attempts 保持 0(原样未动) + assert wa["e164"] == "+966501234567" and wa["attempts"] == 0 asyncio.run(manager.db.close()) @@ -1442,16 +1557,19 @@ def test_cc_wall_inactive_on_non_trigger_turns(async_db, monkeypatch): if m["role"] == "assistant"][-1] assert t1 == _CONFIRM_REPLY asyncio.run(m1.db.close()) - # (b) no_ip 态一轮耗尽落定: 首轮裸号(CC 轮被替换),次轮号码仍裸 → - # cc_due=False 墙关闭(邮箱轻补轮),确认句原样 + # (b) no_ip 态耗尽落定: 首轮裸号(CC 轮被替换,首问免计),次轮墙仍 + # 在场(0<1 due,时滞语义)再问一次、轮末 attempts=1=cap 落定, + # 第三轮 cc_due=False 墙关闭(邮箱轻补轮),确认句原样 (m2, c2), _ = _setup(async_db, monkeypatch, ai=_ConfirmAI(extract={}), source=ConversationSource.standalone) asyncio.run(_send(m2, c2.session_id, "my whatsapp is 0501234567")) asyncio.run(_send(m2, c2.session_id, "so how about that pvc pipe?")) + asyncio.run(_send(m2, c2.session_id, "and the fittings?")) tails = [m["content"] for m in asyncio.run(m2._load_history(c2.id)) if m["role"] == "assistant"] assert tails[0] == _CC_WALL_EN # 前提: 第一轮确是 CC 触发轮(被墙替换) - assert tails[1] == _CONFIRM_REPLY # 第二轮墙不再触发 → 确认句原样 + assert tails[1] == _CC_WALL_EN # 首问免计 → 第二轮仍 due,再问一次 + assert tails[2] == _CONFIRM_REPLY # 第三轮墙不再触发 → 确认句原样 asyncio.run(m2.db.close()) # (c) 双渠道齐: 号码带前缀 + 邮箱 → 无任何轻补,墙关闭 (m3, c3), _ = _setup(async_db, monkeypatch, ai=_ConfirmAI(extract={}), @@ -1549,6 +1667,91 @@ def test_cc_echo_wall_chinese_template(async_db, monkeypatch): asyncio.run(manager.db.close()) +def test_cc_echo_wall_spanish_template(async_db, monkeypatch): + """P3(2026-10-08 真实验收): 西语等待态轮 + 英文纯确认句 → 西语回显 + 模板替换(intl + ¿es correcto?)——修复前 ECHO 模板只有 EN/ZH, + 西语轮 GLM 照抄英文。""" + from app.services.conversation import _CC_ECHO_ES + _sa(monkeypatch) + (manager, conv), _ = _setup(async_db, monkeypatch, ai=_ConfirmAI(extract={}), + source=ConversationSource.standalone) + asyncio.run(_send(manager, conv.session_id, + "mi WhatsApp es 0501234567, ¿me confirman?")) + msgs = asyncio.run(manager._load_history(conv.id)) + tail = [m["content"] for m in msgs if m["role"] == "assistant"][-1] + assert tail == _CC_ECHO_ES.format(intl="+966 50 123 4567") + assert "¿es correcto?" in tail + asyncio.run(manager.db.close()) + + +def test_cc_echo_reminder_spanish_script(async_db, monkeypatch): + """P3: lang=es 等待态轮 reminder 注入西语回显句(逐字 intl + 零号码 + 例外外壳)——_cc_confirm_note 语言分流扩 ES。""" + from app.services.conversation import _CC_ECHO_ES + _sa(monkeypatch) + ai = _RecAI(extract=[{}]) + (manager, conv), _ = _setup(async_db, monkeypatch, ai=ai, + source=ConversationSource.standalone) + asyncio.run(_send(manager, conv.session_id, + "mi WhatsApp es 0501234567, ¿vale?")) + ctx = ai.contexts[0] + assert _CC_ECHO_ES.format(intl="+966 50 123 4567") in ctx # 逐字西语回显句 + assert "email address would help too" not in ctx # 轻补仍串行 + asyncio.run(manager.db.close()) + + +def test_cc_no_ip_spanish_ask_template_and_note(async_db, monkeypatch): + """P3: 西语 no_ip 轮 → 墙替换句/reminder 注入均为西语 ASK 追问句 + (_CC_ASK_ES/_CC_WALL_ES),与 EN/ZH 路径同构、不串台。""" + from app.services.conversation import _CC_ASK_ES, _CC_ECHO_ES, _CC_WALL_ES + captured = {} + + class CtxConfirmAI(FakeAI): + async def stream_chat(self, messages, context=""): + captured["ctx"] = context + for token in ("Sure! ", "Got it — our manager will reach out to you " + "shortly."): + yield token + + (manager, conv), _ = _setup(async_db, monkeypatch, + ai=CtxConfirmAI(extract={}), + source=ConversationSource.standalone) + asyncio.run(_send(manager, conv.session_id, + "mi WhatsApp es 0501234567, ¿vale?")) + msgs = asyncio.run(manager._load_history(conv.id)) + tail = [m["content"] for m in msgs if m["role"] == "assistant"][-1] + assert tail == _CC_WALL_ES # 替换句 = 西语 ASK + assert _CC_ASK_ES in captured["ctx"] # reminder 注入西语追问 + assert _CC_ECHO_ES.format(intl="") not in captured["ctx"] # 无回显模板注入 + asyncio.run(manager.db.close()) + + +def test_cc_no_ip_spanish_asking_reply_passes(async_db, monkeypatch): + """P3: 西语 no_ip 轮 GLM 回复已含 "código de país"(与 _CC_ASK_ES 模板 + 句同词)→ 判服从零干预——裁决锚扩西语同词,与 EN "country code"/ + ZH "国家代码" 同构。""" + reply = ("¿Puedes enviar el número completo con el código de país, " + "como +966 5x xxx xxxx? Así nuestro gerente puede contactarte " + "directamente.") + + class ObedientEsAI(FakeAI): + async def stream_chat(self, messages, context=""): + for token in ("¿Puedes enviar el número completo con el código " + "de país, como +966 5x xxx xxxx? ", + "Así nuestro gerente puede contactarte directamente."): + yield token + + (manager, conv), _ = _setup(async_db, monkeypatch, + ai=ObedientEsAI(extract={}), + source=ConversationSource.standalone) + asyncio.run(_send(manager, conv.session_id, + "mi WhatsApp es 0501234567, ¿vale?")) + msgs = asyncio.run(manager._load_history(conv.id)) + tail = [m["content"] for m in msgs if m["role"] == "assistant"][-1] + assert tail == reply # 原样放行 + asyncio.run(manager.db.close()) + + def test_cc_no_ip_turn_uses_ask_template_and_note(async_db, monkeypatch): """no_ip 轮(无 e164 态)专属: reminder 注入 ASK 追问句("full number with country code"),墙替换句 = _CC_WALL_*(ASK 正文)——回显语义缺席, @@ -1740,8 +1943,8 @@ def test_cc_first_email_panel_tail_and_no_resend_on_confirm(async_db, monkeypatc asyncio.run(_send(manager, conv.session_id, "my whatsapp is 0501234567")) assert [s[0] for s in sent] == ["lead"] ev = asyncio.run(get_ev()) - # 面板尾注: 首封时确认流未落定(attempts=1 < 2),快照值带等待态标注; - # 尾注全非数字,不影响后续 canon 比对 + # 面板尾注: 首封时确认流未落定(P1+P2 后首问轮免计 attempts=0 < 2), + # 快照值带等待态标注;尾注全非数字,不影响后续 canon 比对 assert ev.contact_info["whatsapp"] == "0501234567(待确认补全号)" asyncio.run(_send(manager, conv.session_id, "yes that's right"))