diff --git a/app/services/boundary_filter.py b/app/services/boundary_filter.py index ff72553..cc342fc 100644 --- a/app/services/boundary_filter.py +++ b/app/services/boundary_filter.py @@ -199,7 +199,11 @@ def deflection_for(user_msg: str, rotate: int = 0, # ---- 15.3: 零号码墙(doctrine v2) ---- # AI 绝不输出任何号码/邮箱: 与价格墙并列的第二道确定性红线。 -LONG_NUMBER_RE = re.compile(r"\+?\d[\d\s\-().]{9,17}\d") # ≥10位连续数字(允许分隔形态) +# Important-1(审查修复): 量词 {8,17} 最小匹配 10 字符——纯 10 位号 +# (沙特裸号 0501234567,CC 模板范例市场本体)必须命中。',' 不在字符类: +# "50,000,000" 千分位形态放行(与 "45,000 km" 放行同机制),空格/横线 +# 分隔的 ≥10 位数字串命中;规格形态(DN110/5000m)经实测仍全放行。 +LONG_NUMBER_RE = re.compile(r"\+?\d[\d\s\-().]{8,17}\d") # ≥10位连续数字(允许分隔形态) ANY_EMAIL_RE = re.compile(r"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}") # 12 小时口径与 system.md 对齐(Task 3 质量审查 Minor 4: 原句 "replies fast" diff --git a/app/services/conversation.py b/app/services/conversation.py index 0d76d9c..a4c3cca 100644 --- a/app/services/conversation.py +++ b/app/services/conversation.py @@ -368,9 +368,17 @@ class ConversationManager: logger.warning( "No-contact wall replaced sentence (conv=%s): %r", conv.id, dirty[:60]) - closing = (NO_CONTACT_ALREADY if has_contact - else (NO_CONTACT_APP if conv.source.value == "app" - else NO_CONTACT_WEB)) + if cc_wall: + # Important-2(审查修复): CC 触发轮回显被拦 → closing 改用 + # CC 模板句而非 NO_CONTACT_ALREADY——GLM 有 3/3 回显倾向, + # 该路径概率高,客户该收到追问而非"稍后联系"虚假承诺; + # 价格墙路径(violation=="boundary")不变 + closing = (_CC_WALL_ZH if _CJK_RE.search(content or "") + else _CC_WALL_EN) + else: + closing = (NO_CONTACT_ALREADY if has_contact + else (NO_CONTACT_APP if conv.source.value == "app" + else NO_CONTACT_WEB)) else: logger.warning( "Boundary filter intercepted sentence (conv=%s): %r | tail=%r", @@ -378,6 +386,12 @@ class ConversationManager: closing = (pivot_for(content, has_contact) if emitted else deflection_for(content, _boundary_rotate(), has_contact=has_contact)) + # Minor-5(审查修复): 已流出前缀与替换句之间补一个空格分隔 + # ("...xxxx? Got it —"——英文衔接无空格会粘连);CC/触发轮缓冲 + # 未流出,emitted 恒空,天然不受影响。空格随 closing 一起流出, + # 客户所见与落库保持一致 + if emitted and not emitted.endswith((" ", "\n")): + closing = " " + closing for i in range(0, len(closing), 40): yield {"type": "stream_chunk", "content": closing[i:i + 40]} emitted += closing diff --git a/docs/superpowers/specs/2026-10-08-cc-enforcement-wall-design.md b/docs/superpowers/specs/2026-10-08-cc-enforcement-wall-design.md index e4b47c9..8e202b6 100644 --- a/docs/superpowers/specs/2026-10-08-cc-enforcement-wall-design.md +++ b/docs/superpowers/specs/2026-10-08-cc-enforcement-wall-design.md @@ -24,6 +24,10 @@ GLM 对**顺对话惯性**的指令全部服从、对**反惯性**的 CC 重发 full number with the country code, like +966 5x xxx xxxx? Just so our manager can reach you directly.",中文同 `_CC_NOTE_ZH` 正文),走既有 violated 替换 机制(logger.warning 留痕) +- **回显冲突裁决(2026-10-08 审查修复补记)**:触发轮若零号码墙先拦下回显句 + (violation=="no_contact"),替换句改用 CC 模板句而非 NO_CONTACT_ALREADY—— + GLM 有 3/3 回显倾向,该路径概率高,客户该收到追问而非"稍后联系"虚假承诺; + 价格墙路径(violation=="boundary")维持原替换话术不变 - **计数**:`_cc_asked` 照常写回(一次性语义不变——无论 GLM 是否服从,本轮消耗) - 客户配合重发完整号后:下轮号码带前缀 → CC 墙/note 均不再触发,确认句自然回归 - 客户不理睬:下轮 `_cc_asked` 已消耗 → 墙不再触发(一次性) diff --git a/scripts/breach_rate.py b/scripts/breach_rate.py index 21ad9ad..48cb760 100644 --- a/scripts/breach_rate.py +++ b/scripts/breach_rate.py @@ -20,6 +20,10 @@ PATTERNS = [ (r"\d[\d,.]*\s*k\b(?!\w)", "25k形式"), (r"\d+\s*[- ]?(day|week|month|d[ií]as?|semanas?|дн(?:ей|я)|недел[ьи]|" r"أيام)s?\b", "交期/时间承诺"), + # Minor-4(审查修复): 号码回显漏放观测——与零号码墙 LONG_NUMBER_RE 同形 + # ({8,17} 修复后口径),AI 回复中 ≥10 位数字串(含分隔/阿语数字形态) + (r"\+?\d[\d\s\-().]{8,17}\d", "号码回显(≥10位)"), + (r"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}", "邮箱回显"), ] COMPILED = [(re.compile(p, re.I), name) for p, name in PATTERNS] diff --git a/tests/test_boundary_filter.py b/tests/test_boundary_filter.py index ef97642..0e2bfd9 100644 --- a/tests/test_boundary_filter.py +++ b/tests/test_boundary_filter.py @@ -407,3 +407,19 @@ def test_no_contact_wall_spec_forms_still_pass(): assert not no_contact_value("DN110 PE100 pipes, 5000m total, 20ft container") assert not no_contact_value("PN16 SDR11 to ISO4427, batches of 5000") assert not no_contact_value("DN800双壁波纹管,5000米一批") + + +def test_no_contact_wall_ten_digit_number_blocked(): + # Important-1(审查修复): 旧 {9,17} 最小匹配 11 字符,纯 10 位号穿透—— + # 沙特裸号 0501234567 恰是 CC 模板范例市场本体。收窄 {8,17} 后命中。 + assert no_contact_value("Got it — 0501234567!") + assert no_contact_value("my whatsapp is 0501234567") + # 千分位行为钉死(审查预估与实际正则的差异): ',' 不在 LONG_NUMBER_RE + # 字符类 [\d\s\-().] 内,"50,000,000" 逗号形态天然放行——与 "45,000 km" + # 放行同机制(既有误杀防线,不动);空格/横线分隔的 ≥10 位数字串命中。 + # 若需求方要拦千分位大数量(MOQ 复述),显式把 ',' 加进 class 再扩测试。 + assert not no_contact_value("MOQ is 50,000,000 for this grade") + assert no_contact_value("MOQ is 50 000 000 for this grade") + # 规格形态复测(修复后回归锚): 逗号千分位+短数字段仍全放行 + assert not no_contact_value("DN110 PE100, 5000m per batch, 20ft container") + assert not no_contact_value("45,000 km, VIN LFV3A23C8K3123456") diff --git a/tests/test_lead_email_trigger.py b/tests/test_lead_email_trigger.py index f8048fb..b29f7d5 100644 --- a/tests/test_lead_email_trigger.py +++ b/tests/test_lead_email_trigger.py @@ -1223,3 +1223,60 @@ def test_no_contact_wall_blocks_customer_email_echo(async_db, monkeypatch): assert "a@b.com" not in tail assert tail == NO_CONTACT_ALREADY asyncio.run(manager.db.close()) + + +# ---- 墙层审查修复(2026-10-08): CC轮回显closing/10位穿透/拼接分隔 ---- +def test_cc_wall_no_contact_violation_uses_cc_template(async_db, monkeypatch): + """Important-2: CC 触发轮回显被零号码墙拦下时,closing 改用 CC 模板句 + 而非 NO_CONTACT_ALREADY——GLM 有 3/3 回显倾向,该路径概率高,客户该 + 收到追问而非"稍后联系"虚假承诺。同时钉死 Important-1: 10 位裸号 + 回显(旧 {9,17} 穿透形态)确实被拦。""" + from app.services.conversation import _CC_WALL_EN + + class EchoConfirmAI(FakeAI): + async def stream_chat(self, messages, context=""): + for token in ("Got it — 0501234567! ", + "Manager will contact you soon."): + yield token + + (manager, conv), _ = _setup(async_db, monkeypatch, + ai=EchoConfirmAI(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 "0501234567" not in tail # 回显零残留(10 位穿透已修) + assert tail == _CC_WALL_EN # closing=CC 追问句,非"稍后联系" + profile = asyncio.run(manager._get_profile(conv.id)) + assert (profile.raw_info or {}).get("_cc_asked") is True # 计数不受影响 + asyncio.run(manager.db.close()) + + +def test_violated_closing_separated_by_space(async_db, monkeypatch): + """Minor-5: 中途句违规掐断时,已流出前缀与替换句之间以空格分隔 + ("...xxxx? Got it —"),客户所见与落库一致。""" + from app.services.boundary_filter import NO_CONTACT_WEB + + class MidNumberAI(FakeAI): + async def stream_chat(self, messages, context=""): + for token in ("Great question! ", "Call us at +86 138 0013 8000 ", + "anytime."): + yield token + + (manager, conv), _ = _setup(async_db, monkeypatch, + ai=MidNumberAI(extract={}), + source=ConversationSource.standalone) + events = [] + + async def run(): + async for e in manager.stream_process_message( + conv.session_id, "how can I contact you?"): + events.append(e) + asyncio.run(run()) + streamed = "".join(e.get("content", "") for e in events + if e["type"] == "stream_chunk") + assert streamed == "Great question! " + NO_CONTACT_WEB # 空格分隔钉死 + msgs = asyncio.run(manager._load_history(conv.id)) + tail = [m["content"] for m in msgs if m["role"] == "assistant"][-1] + assert tail == streamed # 落库=流出 + asyncio.run(manager.db.close())