diff --git a/app/services/boundary_filter.py b/app/services/boundary_filter.py index cc342fc..2b1fdb8 100644 --- a/app/services/boundary_filter.py +++ b/app/services/boundary_filter.py @@ -217,16 +217,31 @@ NO_CONTACT_ALREADY = ("Got it — our manager will reach out to you at your " "WhatsApp shortly with the full quote!") -def no_contact_value(sentence: str) -> bool: +def no_contact_value(sentence: str, allow_number: str | None = None) -> bool: """零号码墙(v2): AI 输出不得含任何 ≥10 位数字串或邮箱。 回显豁免已移除(2026-10-08 增补设计 §3): 红线规定客户自己的号码/邮箱 也不回显(确认收到即可),旧豁免与红线矛盾且被 GLM 稳定利用回显号码; AI 输出中的客户号码只可能来自回显,拦截无误杀面。阿语-印度数字由 Unicode \\d 原生匹配(转 ASCII 后同为 ≥10 位)。无值比对——年份/规格 - 编号天然安全(DN110/5000m 等已验证)。""" - if LONG_NUMBER_RE.search(sentence or ""): + 编号天然安全(DN110/5000m 等已验证)。 + + allow_number(2026-10-08 判定树设计 Task 4, spec §4 回显确认轮白名单): + 等待态系统注入的补全号(e164)。LONG_NUMBER_RE 逐匹配段提取数字与 + allow_number 数字归一比对,**全部段命中才放行**(混入任何异号整句照拦, + 4b-6 防自锁报警线);邮箱不受白名单影响(回显确认不需要邮箱)。无 + allow_number → 原行为(任何 ≥10 位数字串即拦)。""" + s = sentence or "" + if allow_number is None: + return bool(LONG_NUMBER_RE.search(s) or ANY_EMAIL_RE.search(s)) + if ANY_EMAIL_RE.search(s): return True - return bool(ANY_EMAIL_RE.search(sentence or "")) + allowed = re.sub(r"\D", "", allow_number) + if not allowed: + return bool(LONG_NUMBER_RE.search(s)) + for m in LONG_NUMBER_RE.finditer(s): + if re.sub(r"\D", "", m.group(0)) != allowed: + return True # 任一段不命中(混入异号) → 整句拦 + return False # ---- Task 5b: 再索要墙(已留资时输出侧拦截) ---- diff --git a/app/services/conversation.py b/app/services/conversation.py index cae72eb..242b299 100644 --- a/app/services/conversation.py +++ b/app/services/conversation.py @@ -121,6 +121,14 @@ _CC_NOTE_ZH = ( "⚠️ 轻补指引(系统指令,绝对不要向客户提及这段说明):客户留下的号码不含" "国家代码,客户经理无法直接回拨。请逐字使用这句:\"" + _CC_ASK_ZH + "\" " "仅此一次,客户不给就不再提。") +# Task 4(spec §4 回显确认轮/§4b): 判定树给出 e164 建议的等待态轮用回显确认 +# 模板——{intl} 由 Python format_intl 算好填入(reminder/墙替换/测试断言三方 +# 同源引用本常量),GLM 只逐字复述不拼号。no_ip 轮(无可回显号)仍走 _CC_ASK_* +# (上方保留不拆),两条路径按 _cc.whatsapp 的 e164 在场分流。 +_CC_ECHO_EN = ( + "Got it — just to confirm: our manager will reach you at {intl} — is that right?") +_CC_ECHO_ZH = ( + "收到——跟您确认下:客户经理会通过 {intl} 联系您,这个号码对吗?") _EMAIL_NOTE_EN = ( "⚠️ 轻补指引(系统指令,绝对不要向客户提及这段说明):客户已留号码但还没" "留邮箱。请逐字使用这句:\"Got it! And an email address would help too — " @@ -132,6 +140,55 @@ _EMAIL_NOTE_ZH = ( "发你邮箱。\" 仅此一次,客户不给就不再提。") +def _cc_intl(wa_state: dict | None) -> str: + """等待态的分组展示号: intl 键缺失时按 e164 防御重算(存量/迁移数据)。""" + e164 = str((wa_state or {}).get("e164") or "") + if not e164: + return "" + return str((wa_state or {}).get("intl") or cc_completion.format_intl(e164)) + + +def _cc_script(wa_state: dict | None, lang: str) -> str: + """CC 等待态本轮逐字话术(Task 4, spec §4): 有 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 + + +def _cc_wall_template(wa_state: dict | None, lang: str) -> str: + """CC 触发轮墙整段替换句: 回显轮 = ECHO 模板(自带 "Got it —" 衔接头); + no_ip 轮 = _CC_WALL_*(ASK 追问句 + 衔接头,沿用 Task 3 形态)。""" + 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 + + +def _cc_confirm_note(wa_state: dict | None, lang: str) -> str: + """cc 等待态确认句注入(Task 4, spec §4/§4b): cc_due 才注入,与邮箱轻补 + cc_settled 串行互斥(两个"逐字使用这句"不同轮竞争)。外壳仿轻补指引: + ⚠️ 系统指令声明 + 逐字话术 + 客户答复后致谢不追问。回显分支带零号码 + 唯一例外(4b-3 让位语义重建: 仅系统提供的补全号原文可写出)。""" + if not wa_state or not cc_completion.cc_due(wa_state): + return "" + if str(wa_state.get("e164") or ""): + intl = _cc_intl(wa_state) + return ( + "⚠️ 号码确认指引(系统指令,绝对不要向客户提及这段说明):客户留下" + "的号码不含国家代码,系统已按 IP 推测补全为 " + intl + + "。请逐字使用这句:\"" + _cc_script(wa_state, lang) + "\" " + "本轮回复绝不写出任何号码数字——唯一例外是上面系统提供的补全号" + "原文(逐字照抄,禁止自编或改动数字)。客户答复确认或纠正后," + "致谢即可,不再追问号码。") + return ( + "⚠️ 号码确认指引(系统指令,绝对不要向客户提及这段说明):客户留下" + "的号码不含国家代码,且系统无法按 IP 推测补全。请逐字使用这句:\"" + + _cc_script(wa_state, lang) + "\" 仅此一次,客户不给就不再提。" + "客户答复后致谢即可,不再追问号码。") + + def _contact_completion_note( raw_info: dict | None, confirmed: dict | None, suspect: dict | None, lang: str = "en", @@ -387,11 +444,14 @@ class ConversationManager: # CC 强制墙(2026-10-08 cc 设计): 门控改线判定树——cc_due(_cc.whatsapp) # 等待态即触发(旧 `completion_marks == ["_cc_asked"]` 门控随 cc 轻补 # 分支删除而消亡,_cc_asked 停写)。流式无法撤回 → 触发轮不逐句流出, - # 整段缓冲到流末统一判 "country code" 在场与否(缓冲期间价格/零号码/ - # 再索要三墙照常逐句检查,优先级不变)。no_ip 态(无可回显号)沿用 - # 本套 ASK 模板与检测词;有建议国态的回显确认模板/e164 检测词由 - # Task 4 接管。 + # 整段缓冲到流末统一裁决(缓冲期间价格/零号码/再索要三墙照常逐句 + # 检查,优先级不变)。Task 4(spec §4/§4b): 回显轮(等待态有 e164)裁决 + # 锚 = 注入 e164 的 digits 等值在场,替换句 = ECHO 模板;no_ip 轮 + # (无可回显号)沿用 ASK 模板与 "country code" 问句短语锚。 cc_wall = cc_completion.cc_due(_wa_cc) + # Task 4 白名单传参(spec §4): 零号码墙逐段放行系统注入的补全号—— + # 仅回显轮(等待态有 e164)传,其余轮 None 保持原行为(全拦) + cc_allow_number = ((_wa_cc or {}).get("e164") if cc_wall else None) yield {"type": "stream_start"} emitted = "" @@ -413,7 +473,7 @@ class ConversationManager: dirty = sentence violation = "boundary" break - if no_contact_value(sentence): + if no_contact_value(sentence, allow_number=cc_allow_number): violated = True dirty = sentence violation = "no_contact" @@ -439,7 +499,7 @@ class ConversationManager: violated = True dirty = pending violation = "boundary" - elif no_contact_value(pending): + elif no_contact_value(pending, allow_number=cc_allow_number): violated = True dirty = pending violation = "no_contact" @@ -462,12 +522,13 @@ class ConversationManager: "No-contact wall replaced sentence (conv=%s): %r", conv.id, dirty[:60]) if cc_wall: - # Important-2(审查修复): CC 触发轮回显被拦 → closing 改用 - # CC 模板句而非 NO_CONTACT_ALREADY——GLM 有 3/3 回显倾向, - # 该路径概率高,客户该收到追问而非"稍后联系"虚假承诺; + # Important-2(审查修复) + Task 4: CC 触发轮回显被拦 → + # closing 改用本轮 CC 模板(回显轮 ECHO/no_ip 轮 ASK)而非 + # NO_CONTACT_ALREADY——GLM 有 3/3 回显倾向,该路径概率高, + # 客户该收到确认/追问而非"稍后联系"虚假承诺; # 价格墙路径(violation=="boundary")不变 - closing = (_CC_WALL_ZH if _CJK_RE.search(content or "") - else _CC_WALL_EN) + closing = _cc_wall_template( + _wa_cc, "zh" if _CJK_RE.search(content or "") else "en") else: closing = (NO_CONTACT_ALREADY if has_contact else (NO_CONTACT_APP if conv.source.value == "app" @@ -488,16 +549,27 @@ class ConversationManager: for i in range(0, len(closing), 40): yield {"type": "stream_chunk", "content": closing[i:i + 40]} emitted += closing - # CC 强制墙裁决(2026-10-08 cc 设计): 触发轮缓冲全文在此判定—— - # 含 "country code"(中文轮认 "国家代码",与 ASK 模板句同词)→ GLM - # 自发服从,缓冲原样流出,零干预;不含 → 整段替换为 CC 模板句正文。 - # violated(价格/零号码墙)优先于本墙——沿用双墙裁决"更具体话术优先" - # 惯例;_cc 状态已在流前落库,不受替换影响。缓冲轮全部实义句被再索要 - # 墙丢弃时 cc_buffer 为空 → 同样走替换(模板句即本轮唯一实义输出, - # 天然不触发下方兜底)。 + # CC 强制墙裁决(2026-10-08 cc 设计;Task 4 改造 spec §4b-4): 触发轮 + # 缓冲全文在此判定——回显轮(等待态有 e164)裁决锚 = **注入 e164 的 + # digits 等值在场**(GLM 任意分组形态 "+966 50 123 4567"/"+966501234567" + # 均判在场;辅锚 "is that right"/"对吗" 随模板正文,不作独立判据—— + # 号码在场才是回显的承重语义);no_ip 轮无可回显号,沿用问句短语锚 + # ("country code"/"国家代码",与 _CC_ASK_* 模板句同词)。在场 → GLM + # 自发服从,缓冲原样流出零干预;不在场 → 整段替换为对应 ECHO/ASK + # 模板。violated(价格/零号码墙)优先于本墙——沿用双墙裁决"更具体 + # 话术优先"惯例;_cc 状态已在流前落库,不受替换影响。缓冲轮全部 + # 实义句被再索要墙丢弃时 cc_buffer 为空 → 同样走替换(模板句即 + # 本轮唯一实义输出,天然不触发下方兜底)。 if cc_wall and not violated: cc_full = "".join(cc_buffer) - if "country code" in cc_full.lower() or "国家代码" in cc_full: + cc_lang = "zh" if _CJK_RE.search(content or "") else "en" + 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) + if obeyed: for i in range(0, len(cc_full), 40): yield {"type": "stream_chunk", "content": cc_full[i:i + 40]} emitted = cc_full @@ -505,8 +577,7 @@ class ConversationManager: logger.warning( "CC enforcement wall replaced reply (conv=%s): %r", conv.id, cc_full[:60]) - cc_template = (_CC_WALL_ZH if _CJK_RE.search(content or "") - else _CC_WALL_EN) + cc_template = _cc_wall_template(_wa_cc, cc_lang) for i in range(0, len(cc_template), 40): yield {"type": "stream_chunk", "content": cc_template[i:i + 40]} @@ -914,7 +985,8 @@ class ConversationManager: "WhatsApp shortly with the full quote!\"(中文同理)。" "绝对禁止输出任何号码或邮箱,也绝对禁止再索要客户已提供过" "的联系方式(缺失渠道可按轻补指引提一次;号码缺国家代码时" - "可请客户重发完整号一次)。") + "按系统提醒回显补全后的完整号码请客户确认——仅系统提供的" + "号码,禁止自编)。") else: contact_note = ("⚠️ 客户正在索要我方联系方式(最高意向信号)。当前为 App 会话," "没有页面按钮——禁止使用任何 tap/button 按钮话术!请逐字使用这句:" @@ -952,6 +1024,14 @@ class ConversationManager: raw_info, confirmed, suspect, lang="zh" if _CJK_RE.search(latest_msg or "") else "en") + # Task 4(spec §4 回显确认轮): cc 等待态注入确认句指引——有 e164 用 + # 回显模板(逐字 intl),no_ip 用 ASK 追问句;与邮箱轻补 cc_settled + # 串行互斥,两个"逐字使用这句"永不同轮。无计数键: 轮次上限由状态机 + # attempts 管理(轮末 transit 递增),提醒每轮等待态都在场。 + cc_note = _cc_confirm_note( + (raw_info.get("_cc") or {}).get("whatsapp"), + "zh" if _CJK_RE.search(latest_msg or "") else "en") + # 联系方式专项(D6) 与 职能边界(D6联动) if confirmed: shown = ", ".join( @@ -961,8 +1041,9 @@ class ConversationManager: # Task 3 防自锁改写(spec 4.1-6): 原"绝对禁止再索要任何联系方式!" # 会抑制缺失渠道轻补——只禁已提供过的渠道,缺失渠道放行一次 parts.append(f"⚠️ 客户已提供联系方式({shown})。绝对禁止再索要客户已提供过" - f"的联系方式;缺失渠道仅可按轻补指引提一次;号码缺国家代码" - f"时可请客户重发完整号一次。") + f"的联系方式;缺失渠道仅可按轻补指引提一次;号码缺国家代码时" + f"按系统提醒回显补全后的完整号码请客户确认(仅系统提供的" + f"号码,禁止自编)。") parts.append(f"需要回应时可直接逐字使用:\"{NO_CONTACT_ALREADY}\"(中文同理)。" f"确认收到后继续解答客户问题即可,不必急着结束对话。") if boundary_note: @@ -970,8 +1051,9 @@ class ConversationManager: # Task 3 防自锁改写: 尾部补缺失渠道豁免(与上方改写同因) parts.append("客户已留过联系方式——边界请求直接告知'客户经理会把准确报价/" "安排发到你 WhatsApp'即可,禁止再索要联系方式" - "(缺失渠道可按轻补指引提一次;号码缺国家代码时可请客户" - "重发完整号一次)。") + "(缺失渠道可按轻补指引提一次;号码缺国家代码时按系统" + "提醒回显补全后的完整号码请客户确认——仅系统提供的号码," + "禁止自编)。") elif suspect: shown = ", ".join( f"{k}: {' '.join(str(v).split())[:60]}" for k, v in suspect.items()) @@ -983,6 +1065,10 @@ class ConversationManager: if boundary_note and not confirmed and not suspect: parts.append(boundary_note) # 未留资:边界提醒引导留资(职能边界话术自带) + # Task 4 cc 确认指引注入(与下方邮箱轻补互斥——cc_due vs cc_settled) + if cc_note: + parts.append(cc_note) + # Task 3 轻补注入(计算已前移到 confirmed 块之前;cc_settled 未落定时 # note 为空,天然不注入) if completion_note: diff --git a/tests/test_boundary_filter.py b/tests/test_boundary_filter.py index 0e2bfd9..2b9b1d4 100644 --- a/tests/test_boundary_filter.py +++ b/tests/test_boundary_filter.py @@ -423,3 +423,31 @@ def test_no_contact_wall_ten_digit_number_blocked(): # 规格形态复测(修复后回归锚): 逗号千分位+短数字段仍全放行 assert not no_contact_value("DN110 PE100, 5000m per batch, 20ft container") assert not no_contact_value("45,000 km, VIN LFV3A23C8K3123456") + + +# ---- Task 4: 白名单逐段放行(spec §4 回显确认轮) ---- +def test_no_contact_wall_allow_number_whitelist(): + """白名单逐段放行(spec §4): 只放行系统注入的精确号码,逐匹配段digits比对, + 混入异号整句拦,无参照照拦(4b-6 防自锁报警线)。""" + from app.services.boundary_filter import no_contact_value + ok = "reach you at +254 712 345 678 — is that right?" + assert no_contact_value(ok, allow_number="+254712345678") is False # 白名单号放行 + assert no_contact_value(ok) is True # 无参照拦 + bad = "reach you at +254 712 345 678 or call 11987654321" # 混入异号 + assert no_contact_value(bad, allow_number="+254712345678") is True # 整句拦 + assert no_contact_value("my mail a@b.com", allow_number="+254712345678") is True # 邮箱照拦 + + +def test_no_contact_wall_allow_number_reformatted_echo(): + """digits 等值口径: GLM 重排分组的回显形态(无分隔/横线分隔)同样放行—— + 白名单比对的归一化与墙裁决的 digits 等值同口径,分组形态不误拦。""" + assert no_contact_value( + "confirm +254712345678 please", allow_number="+254712345678") is False + assert no_contact_value( + "confirm +254-712-345-678 please", allow_number="+254712345678") is False + # 客户裸号原文(canon)不是 e164 参照——回显裸号照拦(canon≠补全号) + assert no_contact_value( + "got it, 0712345678!", allow_number="+254712345678") is True + # 无数字句 + 白名单在场: 不构成长号,照常放行(白名单只作用于长号段) + assert no_contact_value( + "plain sentence", allow_number="+254712345678") is False diff --git a/tests/test_lead_email_trigger.py b/tests/test_lead_email_trigger.py index 1d35ea5..eecdda4 100644 --- a/tests/test_lead_email_trigger.py +++ b/tests/test_lead_email_trigger.py @@ -967,7 +967,7 @@ def test_contact_completion_note_email_gated_by_cc_settled(): def test_reminder_cc_due_turn_email_note_blocked(): """cc 等待态轮: 确认句照常在场(旧"让位句"随 cc 轻补分支删除而拆除, - 回显确认语义由 Task 4 重建);邮箱轻补被 cc_settled 拦截;落定后恢复。""" + Task 4 重建为回显确认指引注入);邮箱轻补被 cc_settled 拦截;落定后恢复。""" due = {"confirmed": False, "attempts": 0, "source": "ip_proposed", "canon": "0501234567", "e164": "+966501234567"} settled_fb = {"confirmed": False, "attempts": 2, "source": "fallback", @@ -977,21 +977,27 @@ def test_reminder_cc_due_turn_email_note_blocked(): assert "Got it — our manager will reach out" in text # 确认句在场(无让位) assert "本轮不要使用确认句" not in text # 旧让位句已拆 assert "email address would help too" not in text # 邮箱轻补被 cc_settled 拦 - assert "重发完整号一次" in text # 禁令例外保留 - # 落定态: 邮箱轻补恢复注入 + assert "回显补全后的完整号码" in text # 禁令例外(Task 4 回显语义) + # Task 4: cc_due 轮注入回显确认指引(逐字 intl + is that right + 致谢不追问) + assert "+966 50 123 4567" in text and "is that right" in text + assert "不再追问号码" in text + # 落定态: 邮箱轻补恢复注入,确认指引退场 raw2 = {"联系方式": {"whatsapp": "0501234567"}, "_cc": {"whatsapp": settled_fb}} text2 = ConversationManager._build_context_reminder(raw2, "yes", []) assert "email address would help too" in text2 + assert "is that right" not in text2 def test_inline_prohibitions_all_carry_reask_exception(): """三轮修复防第四次漏: conversation.py 三处内联禁令运行时文本(app分支/ - confirmed块/边界分支)都必须带"重发完整号一次"例外——CC 追问是对已在手 - 渠道的重发请求,只豁免缺失渠道的禁令会压制它(生产验收两轮复发的根因)。""" + confirmed块/边界分支)都必须带回显确认例外——号码确认是对"缺国家代码"的 + 补全动作,只豁免缺失渠道的禁令会压制它(生产验收两轮复发的根因); + Task 4(spec §4b-3)例外从"可请客户重发完整号一次"收敛为回显语义 + (防同场竞争降低首过命中率)。""" with open("app/services/conversation.py", encoding="utf-8") as f: src = f.read() - assert src.count("重发完整号一次") >= 3 + assert src.count("回显补全后的完整号码") >= 3 anchors = [ "也绝对禁止再索要客户已提供过", # contact_note app+已留资分支 "。绝对禁止再索要客户已提供过", # confirmed 块(句号起,不与app分支互串) @@ -1000,15 +1006,16 @@ def test_inline_prohibitions_all_carry_reask_exception(): for anchor in anchors: assert anchor in src, anchor i = src.index(anchor) - assert "重发完整号一次" in src[i:i + 260], anchor # 例外必须就近在场 + assert "回显补全后的完整号码" in src[i:i + 260], anchor # 例外必须就近在场 def test_contact_completion_two_round_serial_and_persistence(async_db, monkeypatch): """spec §6-1/§6-2 集成: 裸号轮当轮 _cc 已建且落库(轮前钩子实证——墙 触发依赖它);确认流未落定期间邮箱轻补被拦;无回应两轮 attempts 耗尽 - 落定 fallback 后,串行转邮箱轻补并落 _email_asked。""" + 落定 fallback 后,串行转邮箱轻补并落 _email_asked。 + Task 4: 有建议国(SA→e164)轮墙替换句 = 回显确认模板(ECHO,含 intl)。""" from app.services import ip_service - from app.services.conversation import _CC_WALL_EN + from app.services.conversation import _CC_ECHO_EN monkeypatch.setattr(ip_service, "resolve_region_code", lambda ip: "SA") @@ -1037,14 +1044,14 @@ def test_contact_completion_two_round_serial_and_persistence(async_db, monkeypat "intl": "+966 50 123 4567"} tails = [m["content"] for m in asyncio.run(manager._load_history(conv.id)) if m["role"] == "assistant"] - assert tails[-1] == _CC_WALL_EN # cc_due → 墙触发轮 + 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 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_WALL_EN # 第 2 次追问(上限内) + 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" @@ -1259,8 +1266,9 @@ def test_cc_same_canon_group_merges_phone_into_whatsapp(async_db, monkeypatch): def test_cc_different_number_phone_silent_state(async_db, monkeypatch): """异号 phone: 只记静默补全态(naive,不进确认流)——墙门控与邮箱 - 轻补串行只看 whatsapp 组;phone 态仅供出站渲染(Task 6)。""" - from app.services.conversation import _CC_WALL_EN + 轻补串行只看 whatsapp 组;phone 态仅供出站渲染(Task 6)。 + Task 4: whatsapp 有 e164 建议(SA) → 墙替换句 = 回显模板。""" + from app.services.conversation import _CC_ECHO_EN _sa(monkeypatch) ai = _RecAI(extract=[{}, {}]) (manager, conv), _ = _setup(async_db, monkeypatch, ai=ai, @@ -1275,7 +1283,7 @@ def test_cc_different_number_phone_silent_state(async_db, monkeypatch): assert cc["phone"]["e164"] == "+96613800138000" tails = [m["content"] for m in asyncio.run(manager._load_history(conv.id)) if m["role"] == "assistant"] - assert tails[-1] == _CC_WALL_EN # 墙看 whatsapp(due) + assert tails[-1] == _CC_ECHO_EN.format(intl="+966 50 123 4567") # 墙看 whatsapp assert "email address would help too" not in ai.contexts[0] # 轻补被拦 asyncio.run(manager.db.close()) @@ -1460,6 +1468,147 @@ def test_cc_wall_chinese_template(async_db, monkeypatch): asyncio.run(manager.db.close()) +# ---- Task 4: 回显确认模板/确认句墙 e164 检测/白名单/no_ip 专属(spec §4/§4b) ---- +# 等待态(判定树已给 e164 建议,如 _sa 的 SA→+966501234567)轮走 ECHO 路径; +# no_ip 轮(无 IP 无建议)走 ASK 路径(_CC_ASK_* 保留服务,问完整号一次)。 +def test_cc_echo_wall_replaces_noncompliant_reply(async_db, monkeypatch): + """等待态轮 GLM 输出自创确认句(无 e164)→ 整段替换为回显模板: + 含 intl 分组号 + "is that right",自创句零残留。""" + from app.services.conversation import _CC_ECHO_EN + _sa(monkeypatch) + (manager, conv), _ = _setup(async_db, monkeypatch, ai=_ConfirmAI(extract={}), + source=ConversationSource.standalone) + asyncio.run(_send(manager, conv.session_id, "my whatsapp is 0501234567")) + msgs = asyncio.run(manager._load_history(conv.id)) + tail = [m["content"] for m in msgs if m["role"] == "assistant"][-1] + assert tail == _CC_ECHO_EN.format(intl="+966 50 123 4567") # 模板+intl + assert "is that right" in tail # 辅锚随模板在场 + assert "reach out to you shortly" not in tail # 自创确认句零残留 + asyncio.run(manager.db.close()) + + +def test_cc_echo_wall_passes_through_e164_reply(async_db, monkeypatch): + """等待态轮 GLM 输出已含注入 e164 → 零干预逐字节原样(墙白名单放行 + + 裁决 digits 等值在场);重排分组形态(无分隔/横线)同判在场。""" + reply = ("Got it — just to confirm: our manager will reach you at " + "+966 50 123 4567 — is that right?") + + class EchoObedientAI(FakeAI): + async def stream_chat(self, messages, context=""): + for token in ("Got it — just to confirm: our manager will reach " + "you at ", "+966 50 123 4567", " — is that right?"): + yield token + + _sa(monkeypatch) + (manager, conv), _ = _setup(async_db, monkeypatch, + ai=EchoObedientAI(extract={}), + source=ConversationSource.standalone) + asyncio.run(_send(manager, conv.session_id, "my whatsapp is 0501234567")) + msgs = asyncio.run(manager._load_history(conv.id)) + tail = [m["content"] for m in msgs if m["role"] == "assistant"][-1] + assert tail == reply # 原样(缓冲后整段流出,不增不减一字) + + # digits 等值变体: 无分隔/横线分隔的同一号码同样判在场,原样放行 + flat_reply = "Confirm: +966501234567 — is that right?" + + class FlatEchoAI(FakeAI): + async def stream_chat(self, messages, context=""): + for token in ("Confirm: +966501234567", " — is that right?"): + yield token + + (m2, c2), _ = _setup(async_db, monkeypatch, ai=FlatEchoAI(extract={}), + source=ConversationSource.standalone) + asyncio.run(_send(m2, c2.session_id, "my whatsapp is 0501234567")) + msgs2 = asyncio.run(m2._load_history(c2.id)) + tail2 = [m["content"] for m in msgs2 if m["role"] == "assistant"][-1] + assert tail2 == flat_reply + asyncio.run(manager.db.close()) + asyncio.run(m2.db.close()) + + +def test_cc_echo_wall_chinese_template(async_db, monkeypatch): + """中文等待态轮 + 英文纯确认句 → 中文回显模板替换(含 intl + "对吗")。""" + from app.services.conversation import _CC_ECHO_ZH + _sa(monkeypatch) + (manager, conv), _ = _setup(async_db, monkeypatch, ai=_ConfirmAI(extract={}), + source=ConversationSource.standalone) + asyncio.run(_send(manager, conv.session_id, "我的WhatsApp是0501234567")) + msgs = asyncio.run(manager._load_history(conv.id)) + tail = [m["content"] for m in msgs if m["role"] == "assistant"][-1] + assert tail == _CC_ECHO_ZH.format(intl="+966 50 123 4567") + assert "对吗" in tail + 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 正文)——回显语义缺席, + 两路径不串台。""" + from app.services.conversation import _CC_ASK_EN, _CC_ECHO_EN, _CC_WALL_EN + 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, "my whatsapp is 0501234567")) + msgs = asyncio.run(manager._load_history(conv.id)) + tail = [m["content"] for m in msgs if m["role"] == "assistant"][-1] + assert tail == _CC_WALL_EN # 替换句 = ASK 追问 + assert _CC_ASK_EN in captured["ctx"] # reminder 注入 ASK 逐字句 + assert _CC_ECHO_EN.format(intl="") not in captured["ctx"] # 无回显模板注入 + assert "is that right" not in captured["ctx"] # 回显辅锚缺席(no_ip) + asyncio.run(manager.db.close()) + + +def test_cc_echo_reminder_injects_verbatim_script(async_db, monkeypatch): + """reminder 注入回显确认句: 逐字 intl + "is that right" + 零号码唯一 + 例外(系统提供的补全号原文)——4b-3 让位语义在确认指引内重建。""" + from app.services.conversation import _CC_ECHO_EN + _sa(monkeypatch) + ai = _RecAI(extract=[{}]) + (manager, conv), _ = _setup(async_db, monkeypatch, ai=ai, + source=ConversationSource.standalone) + asyncio.run(_send(manager, conv.session_id, "my whatsapp is 0501234567")) + ctx = ai.contexts[0] + assert _CC_ECHO_EN.format(intl="+966 50 123 4567") in ctx # 逐字回显句在场 + assert "唯一例外" in ctx and "禁止自编" in ctx # 零号码例外改写 + assert "email address would help too" not in ctx # 轻补仍串行 + asyncio.run(manager.db.close()) + + +def test_cc_answer_turn_wall_lag_pinned(async_db, monkeypatch): + """已知时滞(Task 4 接受,记录观察期): 客户答 yes 当轮 cc_due 仍 True + (轮末才消费)→ 墙仍按等待态替换,客户当轮再收到一次回显确认;轮末消费 + 落定后下轮恢复正常输出。""" + from app.services.conversation import _CC_ECHO_EN + _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")) + asyncio.run(_send(manager, conv.session_id, "yes that's right")) + tails = [m["content"] for m in asyncio.run(manager._load_history(conv.id)) + if m["role"] == "assistant"] + echo = _CC_ECHO_EN.format(intl="+966 50 123 4567") + assert tails[0] == echo # 等待态轮: 回显替换 + assert tails[1] == echo # 答语轮时滞: 墙仍替换(轮末才消费 cc_reply) + 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[2] == "ok reply" # 落定后墙关闭,原文放行 + 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" + asyncio.run(manager.db.close()) + + # ---- 回显豁免收窄(2026-10-08 增补设计 §3: 零号码墙输出侧一律拦) ---- def test_no_contact_wall_blocks_exact_customer_number_echo(async_db, monkeypatch): """客户自己的号码被 AI 原样回显 → 拦截整段替换(旧豁免放行此形态——