Compare commits
3 Commits
2e08ab22b6
...
99f51e7703
| Author | SHA1 | Date | |
|---|---|---|---|
| 99f51e7703 | |||
| 41b2ce081f | |||
| 5f124e691b |
@@ -14,8 +14,6 @@
|
||||
保修期限是编造的公司政策承诺,一并墙级拦截,见 PRICE_PATTERNS 时间承诺组)。"""
|
||||
import re
|
||||
|
||||
from app.services.contact_pipeline import digits_only
|
||||
|
||||
PRICE_PATTERNS = [
|
||||
re.compile(r"\$\s?\d"),
|
||||
re.compile(r"\d[\d,.]*\s*(usd\b|美元|块钱|万块?(?!\s*(公里|km)))", re.I),
|
||||
@@ -201,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"
|
||||
@@ -215,22 +217,16 @@ 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, user_history: list[dict] | None = None) -> bool:
|
||||
def no_contact_value(sentence: str) -> bool:
|
||||
"""零号码墙(v2): AI 输出不得含任何 ≥10 位数字串或邮箱。
|
||||
唯一豁免: 该串出现在近期客户消息中(客户自己号码的礼貌回显)。
|
||||
无值比对——年份/规格编号天然安全(已验证)。"""
|
||||
for m in LONG_NUMBER_RE.finditer(sentence or ""):
|
||||
c = digits_only(m.group(0))
|
||||
if user_history and any(c in digits_only(msg.get("content") or "")
|
||||
for msg in user_history if msg.get("role") == "user"):
|
||||
continue # 客户回显豁免
|
||||
回显豁免已移除(2026-10-08 增补设计 §3): 红线规定客户自己的号码/邮箱
|
||||
也不回显(确认收到即可),旧豁免与红线矛盾且被 GLM 稳定利用回显号码;
|
||||
AI 输出中的客户号码只可能来自回显,拦截无误杀面。阿语-印度数字由
|
||||
Unicode \\d 原生匹配(转 ASCII 后同为 ≥10 位)。无值比对——年份/规格
|
||||
编号天然安全(DN110/5000m 等已验证)。"""
|
||||
if LONG_NUMBER_RE.search(sentence or ""):
|
||||
return True
|
||||
for m in ANY_EMAIL_RE.finditer(sentence or ""):
|
||||
if user_history and any(m.group(0).lower() in (msg.get("content") or "").lower()
|
||||
for msg in user_history if msg.get("role") == "user"):
|
||||
continue
|
||||
return True
|
||||
return False
|
||||
return bool(ANY_EMAIL_RE.search(sentence or ""))
|
||||
|
||||
|
||||
# ---- Task 5b: 再索要墙(已留资时输出侧拦截) ----
|
||||
|
||||
@@ -12,9 +12,8 @@ import re
|
||||
|
||||
# Unicode 十进制数字 → ASCII(阿语٠-٩/波斯语۰-۹), 中东买家号码 CRM 可直接拨。
|
||||
# 挂在归一化链最前端(normalize_contacts 的值 / digits_only / scan 与
|
||||
# latest 的捕获赋值): 存库、canon 去重比对、boundary_filter 客户回显豁免
|
||||
# 两侧全部经此转成 ASCII——阿语消息与 ASCII 提取值比对口径一致,
|
||||
# 同一号码不会被误判成"改号"触发更正邮件。
|
||||
# latest 的捕获赋值): 存库、canon 去重比对全部经此转成 ASCII——阿语消息
|
||||
# 与 ASCII 提取值比对口径一致,同一号码不会被误判成"改号"触发更正邮件。
|
||||
_DIGIT_TRANSLATE = str.maketrans(
|
||||
"٠١٢٣٤٥٦٧٨٩۰۱۲۳۴۵۶۷۸۹", "01234567890123456789")
|
||||
|
||||
@@ -82,9 +81,9 @@ def merge_contacts(old: dict | None, new: dict | None) -> dict:
|
||||
|
||||
def digits_only(s: str) -> str:
|
||||
"""提取纯数字(split_validate 位数判断与后续 canon 复用)。
|
||||
阿语/波斯语数字先转 ASCII——canon 与 boundary_filter 回显豁免两侧
|
||||
都走本函数,天然保证同形比较(Unicode 正则的数字类本就匹配阿语数字,
|
||||
位数判定不变;此处只统一"比对与存储的形态")。"""
|
||||
阿语/波斯语数字先转 ASCII——canon 比对两侧都走本函数,天然保证同形
|
||||
比较(Unicode 正则的数字类本就匹配阿语数字,位数判定不变;此处只统一
|
||||
"比对与存储的形态"。boundary_filter 回显豁免移除后不再依赖本函数)。"""
|
||||
return re.sub(r"\D", "", s.translate(_DIGIT_TRANSLATE))
|
||||
|
||||
|
||||
|
||||
@@ -98,15 +98,24 @@ def _lang_note_for(msg: str) -> str:
|
||||
# Task 3(spec §4.1-2/§4.4, 2026-10-08): 缺失渠道轻补逐字模板——与 system.md
|
||||
# 联系方式引导策略节同源。注入的是"缺失渠道"(客户没给过的),与再索要墙的
|
||||
# 渠道精确化(Task 1)配套: 墙只拦已在手渠道的索要,这些话术恰好问缺失渠道。
|
||||
# CC 强制墙(2026-10-08 增补设计 §2)复用同源正文: _CC_ASK_* 是注入指引里
|
||||
# "请逐字使用这句"的正文;_CC_WALL_* 是墙的整段替换句(EN 加 "Got it — "
|
||||
# 衔接头,设计 §2 定稿文案;ZH 与正文同句)——两处逐字锚点不漂移。
|
||||
_CC_ASK_EN = (
|
||||
"Could you send the full number with the country code, "
|
||||
"like +966 5x xxx xxxx? Just so our manager can reach you directly.")
|
||||
_CC_ASK_ZH = (
|
||||
"麻烦重发一下带国家代码的完整号码呗(比如 +966 5x xxx xxxx)?"
|
||||
"客户经理直接加你。")
|
||||
_CC_WALL_EN = "Got it — " + _CC_ASK_EN
|
||||
_CC_WALL_ZH = _CC_ASK_ZH
|
||||
_CC_NOTE_EN = (
|
||||
"⚠️ 轻补指引(系统指令,绝对不要向客户提及这段说明):客户留下的号码不含"
|
||||
"国家代码,客户经理无法直接回拨。请逐字使用这句:\"Could you send the "
|
||||
"full number with the country code, like +966 5x xxx xxxx? Just so our "
|
||||
"manager can reach you directly.\" 仅此一次,客户不给就不再提。")
|
||||
"国家代码,客户经理无法直接回拨。请逐字使用这句:\"" + _CC_ASK_EN + "\" "
|
||||
"仅此一次,客户不给就不再提。")
|
||||
_CC_NOTE_ZH = (
|
||||
"⚠️ 轻补指引(系统指令,绝对不要向客户提及这段说明):客户留下的号码不含"
|
||||
"国家代码,客户经理无法直接回拨。请逐字使用这句:\"麻烦重发一下带国家代码"
|
||||
"的完整号码呗(比如 +966 5x xxx xxxx)?客户经理直接加你。\" "
|
||||
"国家代码,客户经理无法直接回拨。请逐字使用这句:\"" + _CC_ASK_ZH + "\" "
|
||||
"仅此一次,客户不给就不再提。")
|
||||
_EMAIL_NOTE_EN = (
|
||||
"⚠️ 轻补指引(系统指令,绝对不要向客户提及这段说明):客户已留号码但还没"
|
||||
@@ -285,6 +294,11 @@ class ConversationManager:
|
||||
# (confirmed/suspect 已在 Task 3 写回处算好,此处复用)
|
||||
has_contact = bool(_confirmed_now)
|
||||
_confirmed_channels = set(_confirmed_now)
|
||||
# CC 强制墙(2026-10-08 增补设计 §2): 本轮注入的是国家代码追问时,
|
||||
# 输出侧确定性强制。流式无法撤回 → 触发轮不逐句流出,整段缓冲到
|
||||
# 流末统一判 "country code" 在场与否(缓冲期间价格/零号码/再索要
|
||||
# 三墙照常逐句检查,优先级不变)。提示词层全部保留,墙只是兜底。
|
||||
cc_wall = completion_marks == ["_cc_asked"]
|
||||
|
||||
yield {"type": "stream_start"}
|
||||
emitted = ""
|
||||
@@ -292,6 +306,7 @@ class ConversationManager:
|
||||
violated = False
|
||||
dropped_ask = False # Task 5b: 本轮是否有索要句被再索要墙丢弃
|
||||
dirty = ""
|
||||
cc_buffer: list[str] = [] # CC 触发轮: 已过墙句子的延迟流出缓冲
|
||||
# 15.3: 双墙裁决——价格墙检查在前: 同句双违规时价格路径话术优先
|
||||
# (职能边界话术更具体,且自带留资引导);仅纯号码/邮箱违规时
|
||||
# violation=="no_contact",用零号码墙替换句
|
||||
@@ -305,7 +320,7 @@ class ConversationManager:
|
||||
dirty = sentence
|
||||
violation = "boundary"
|
||||
break
|
||||
if no_contact_value(sentence, history_for_ai):
|
||||
if no_contact_value(sentence):
|
||||
violated = True
|
||||
dirty = sentence
|
||||
violation = "no_contact"
|
||||
@@ -318,6 +333,9 @@ class ConversationManager:
|
||||
conv.id, sentence[:60])
|
||||
dropped_ask = True
|
||||
continue
|
||||
if cc_wall:
|
||||
cc_buffer.append(sentence) # 墙照检,流出延后到流末
|
||||
continue
|
||||
for i in range(0, len(sentence), 40):
|
||||
yield {"type": "stream_chunk", "content": sentence[i:i + 40]}
|
||||
emitted += sentence
|
||||
@@ -328,7 +346,7 @@ class ConversationManager:
|
||||
violated = True
|
||||
dirty = pending
|
||||
violation = "boundary"
|
||||
elif no_contact_value(pending, history_for_ai):
|
||||
elif no_contact_value(pending):
|
||||
violated = True
|
||||
dirty = pending
|
||||
violation = "no_contact"
|
||||
@@ -337,6 +355,8 @@ class ConversationManager:
|
||||
logger.warning("Re-ask wall dropped tail (conv=%s): %r",
|
||||
conv.id, pending[:60])
|
||||
dropped_ask = True
|
||||
elif cc_wall:
|
||||
cc_buffer.append(pending)
|
||||
else:
|
||||
for i in range(0, len(pending), 40):
|
||||
yield {"type": "stream_chunk", "content": pending[i:i + 40]}
|
||||
@@ -348,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",
|
||||
@@ -358,9 +386,38 @@ 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
|
||||
# CC 强制墙裁决(2026-10-08 增补设计 §2): 触发轮缓冲全文在此判定——
|
||||
# 含 "country code"(中文轮认 "国家代码",与 _CC_NOTE_ZH 注入的逐字
|
||||
# 追问句同词)→ GLM 自发服从,缓冲原样流出,零干预;不含 → 整段替换
|
||||
# 为 CC 模板句正文。violated(价格/零号码墙)优先于本墙——沿用双墙
|
||||
# 裁决"更具体话术优先"惯例;_cc_asked 已在流前落库,一次性消耗不受
|
||||
# 替换影响。缓冲轮全部实义句被再索要墙丢弃时 cc_buffer 为空 → 同样
|
||||
# 走替换(模板句即本轮唯一实义输出,天然不触发下方兜底)。
|
||||
if cc_wall and not violated:
|
||||
cc_full = "".join(cc_buffer)
|
||||
if "country code" in cc_full.lower() or "国家代码" in cc_full:
|
||||
for i in range(0, len(cc_full), 40):
|
||||
yield {"type": "stream_chunk", "content": cc_full[i:i + 40]}
|
||||
emitted = cc_full
|
||||
else:
|
||||
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)
|
||||
for i in range(0, len(cc_template), 40):
|
||||
yield {"type": "stream_chunk",
|
||||
"content": cc_template[i:i + 40]}
|
||||
emitted = cc_template
|
||||
# Task 5b 评审修正: 整条回复只有索要句(全被丢)→ 兜底流出"经理主动
|
||||
# 联系"句,绝不空回复。与上方墙收尾互斥——violated 路径已自带收尾,
|
||||
# 此处仅在无任何实义内容流出时接管;已流出的纯空白前缀一并作废,
|
||||
|
||||
@@ -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` 已消耗 → 墙不再触发(一次性)
|
||||
|
||||
@@ -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]
|
||||
|
||||
|
||||
@@ -117,9 +117,11 @@ def test_no_contact_wall_blocks_any_number_or_email():
|
||||
assert no_contact_value("our email is sales@example.com")
|
||||
|
||||
|
||||
def test_no_contact_wall_customer_echo_exempt():
|
||||
history = [{"role": "user", "content": "my number is 07012345678"}]
|
||||
assert not no_contact_value("Got it, 07012345678!", history)
|
||||
def test_no_contact_wall_echo_no_longer_exempt():
|
||||
# 回显豁免收窄(2026-10-08 增补设计 §3): 客户自己的号码被 AI 回显也拦——
|
||||
# 红线早已规定"确认收到即可,不回显",旧豁免与红线矛盾且被 GLM 稳定利用
|
||||
# (回显 3/3)。user_history 参数已随豁免一并移除。
|
||||
assert no_contact_value("Got it, 07012345678!")
|
||||
|
||||
|
||||
def test_no_contact_wall_whitelist_safe():
|
||||
@@ -382,14 +384,42 @@ def test_no_auto_wording_left_in_any_pool():
|
||||
assert "什么车" not in t, f"{name}: {t}"
|
||||
|
||||
|
||||
def test_arabic_echo_exemption_consistent():
|
||||
def test_no_contact_wall_blocks_echo_in_all_digit_scripts():
|
||||
# 回显豁免收窄(2026-10-08): 阿语数字形态与 ASCII 形态一律拦——
|
||||
# Unicode \d 本就匹配阿语-印度数字,转 ASCII 后同为 ≥10 位。
|
||||
# 旧豁免(Task 6)曾按"客户消息出现过"放行两形态,现全部拦截。
|
||||
# 阿语数字用码位构造,避免源文件编码事故。
|
||||
ar_num = "".join(chr(c) for c in
|
||||
(0x0669, 0x0666, 0x0666, 0x0665, 0x0660, 0x0661,
|
||||
0x0662, 0x0663, 0x0664, 0x0665, 0x0666, 0x0667)) # 966501234567
|
||||
# 阿语回显豁免一致性锁定(Task 6 质量审查): 客户用阿语数字留号,AI 无论
|
||||
# 回显阿语还是 ASCII 形态都豁免——两侧均经 digits_only(内含 translate)。
|
||||
# 阿语数字用码位构造,避免源文件编码事故。
|
||||
hist = [{"role": "user", "content": "my whatsapp is " + ar_num}]
|
||||
assert not no_contact_value("Got it — messaging you at " + ar_num, hist)
|
||||
assert not no_contact_value("Got it — messaging you at +966501234567", hist)
|
||||
assert no_contact_value("Reach me at 11987654321", hist) # 不同号(11位)仍拦
|
||||
assert no_contact_value("Got it — messaging you at " + ar_num)
|
||||
assert no_contact_value("Got it — messaging you at +966501234567")
|
||||
assert no_contact_value("Reach me at 11987654321") # 11位裸串
|
||||
|
||||
|
||||
def test_no_contact_wall_email_echo_blocked():
|
||||
# 邮箱回显同规则收窄: 确认句只需"收到",不需复述地址
|
||||
assert no_contact_value("Got it — a@b.com, manager will write you")
|
||||
|
||||
|
||||
def test_no_contact_wall_spec_forms_still_pass():
|
||||
# 误杀防护(增补设计 §3): 建材规格数字形态不构成 ≥10 位连续数字
|
||||
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")
|
||||
|
||||
@@ -1051,3 +1051,232 @@ def test_contact_completion_marks_filtered_from_push(async_db, monkeypatch):
|
||||
profile_out = log.request_payload["profile"]
|
||||
assert not [k for k in profile_out if str(k).startswith("_")] # 内部键零泄漏
|
||||
asyncio.run(manager.db.close())
|
||||
|
||||
|
||||
# ---- CC 强制墙(2026-10-08 增补设计 §2: 场景①输出侧确定性强制) ----
|
||||
# 前情: 三轮提示词修复后真实 GLM 验收 0/8——GLM 对"反惯性"的 CC 重发追问
|
||||
# 结构性无视。墙层兜底: 触发轮整段缓冲,流末判 "country code" 在场与否。
|
||||
class _ConfirmAI(FakeAI):
|
||||
"""CC 触发轮 GLM 抗命典型形态: 纯确认句,零追问(生产验收复刻)。"""
|
||||
|
||||
async def stream_chat(self, messages, context=""):
|
||||
for token in ("Sure! ", "Got it — our manager will reach out to you "
|
||||
"shortly."):
|
||||
yield token
|
||||
|
||||
|
||||
_CONFIRM_REPLY = "Sure! Got it — our manager will reach out to you shortly."
|
||||
|
||||
|
||||
def test_cc_wall_replaces_confirmation_reply(async_db, monkeypatch):
|
||||
"""触发轮 + 纯确认句 → 客户所见/落库均为 CC 模板句正文,原句零残留;
|
||||
_cc_asked 计数照常落库(一次性消耗不受替换影响)。"""
|
||||
from app.services.conversation import _CC_WALL_EN
|
||||
(manager, conv), _ = _setup(async_db, monkeypatch, ai=_ConfirmAI(extract={}),
|
||||
source=ConversationSource.standalone)
|
||||
events = []
|
||||
|
||||
async def run():
|
||||
async for e in manager.stream_process_message(
|
||||
conv.session_id, "my whatsapp is 0501234567"):
|
||||
events.append(e)
|
||||
asyncio.run(run())
|
||||
streamed = "".join(e.get("content", "") for e in events
|
||||
if e["type"] == "stream_chunk")
|
||||
assert streamed == _CC_WALL_EN # 客户所见 = 模板句
|
||||
assert "country code" in streamed # 追问语义在场
|
||||
assert "reach out to you shortly" not in streamed # 原确认句零残留
|
||||
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 # 落库与流出一致
|
||||
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_cc_wall_passes_through_asking_reply(async_db, monkeypatch):
|
||||
"""触发轮 + 回复已含 "country code" → GLM 自发服从,零干预逐字节原样。"""
|
||||
class ObedientAI(FakeAI):
|
||||
async def stream_chat(self, messages, context=""):
|
||||
for token in ("Could you send the full number with the country "
|
||||
"code, like +966 5x xxx xxxx? ",
|
||||
"Just so our manager can reach you directly."):
|
||||
yield token
|
||||
|
||||
reply = ("Could you send the full number with the country code, "
|
||||
"like +966 5x xxx xxxx? Just so our manager can reach you "
|
||||
"directly.")
|
||||
(manager, conv), _ = _setup(async_db, monkeypatch, ai=ObedientAI(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 # 原样(缓冲后整段流出,不增不减一字)
|
||||
asyncio.run(manager.db.close())
|
||||
|
||||
|
||||
def test_cc_wall_passes_through_chinese_asking_reply(async_db, monkeypatch):
|
||||
"""中文触发轮 + 回复含 "国家代码" → 同样视为服从,零干预
|
||||
(检测词表含中文形态——ZH 轮 GLM 逐字复述的是中文追问句)。"""
|
||||
class ObedientZhAI(FakeAI):
|
||||
async def stream_chat(self, messages, context=""):
|
||||
for token in ("麻烦重发一下带国家代码的完整号码呗(比如 "
|
||||
"+966 5x xxx xxxx)?", "客户经理直接加你。"):
|
||||
yield token
|
||||
|
||||
reply = ("麻烦重发一下带国家代码的完整号码呗(比如 +966 5x xxx xxxx)?"
|
||||
"客户经理直接加你。")
|
||||
(manager, conv), _ = _setup(async_db, monkeypatch,
|
||||
ai=ObedientZhAI(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 == reply
|
||||
asyncio.run(manager.db.close())
|
||||
|
||||
|
||||
def test_cc_wall_inactive_on_non_trigger_turns(async_db, monkeypatch):
|
||||
"""非触发轮(带前缀号/_cc_asked 已耗/双渠道齐)→ 确认句原样流出。"""
|
||||
from app.services.conversation import _CC_WALL_EN
|
||||
# (a) 带前缀号: 首轮即留 +966… → 邮箱轻补轮,非 CC 触发
|
||||
(m1, c1), _ = _setup(async_db, monkeypatch, ai=_ConfirmAI(extract={}),
|
||||
source=ConversationSource.standalone)
|
||||
asyncio.run(_send(m1, c1.session_id, "my whatsapp is +966501234567"))
|
||||
t1 = [m["content"] for m in asyncio.run(m1._load_history(c1.id))
|
||||
if m["role"] == "assistant"][-1]
|
||||
assert t1 == _CONFIRM_REPLY
|
||||
asyncio.run(m1.db.close())
|
||||
# (b) _cc_asked 已耗: 首轮裸号(CC 轮被替换),次轮号码仍裸 → 邮箱轻补轮
|
||||
(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?"))
|
||||
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 # 第二轮墙不再触发 → 确认句原样
|
||||
asyncio.run(m2.db.close())
|
||||
# (c) 双渠道齐: 号码带前缀 + 邮箱 → 无任何轻补,墙关闭
|
||||
(m3, c3), _ = _setup(async_db, monkeypatch, ai=_ConfirmAI(extract={}),
|
||||
source=ConversationSource.standalone)
|
||||
asyncio.run(_send(m3, c3.session_id,
|
||||
"reach me on whatsapp +966501234567 or a@b.com"))
|
||||
t3 = [m["content"] for m in asyncio.run(m3._load_history(c3.id))
|
||||
if m["role"] == "assistant"][-1]
|
||||
assert t3 == _CONFIRM_REPLY
|
||||
asyncio.run(m3.db.close())
|
||||
|
||||
|
||||
def test_cc_wall_chinese_template(async_db, monkeypatch):
|
||||
"""中文触发轮 + 英文纯确认句 → 中文模板句正文替换(语言随本轮判定)。"""
|
||||
from app.services.conversation import _CC_WALL_ZH
|
||||
(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_WALL_ZH
|
||||
assert "国家代码" in tail
|
||||
asyncio.run(manager.db.close())
|
||||
|
||||
|
||||
# ---- 回显豁免收窄(2026-10-08 增补设计 §3: 零号码墙输出侧一律拦) ----
|
||||
def test_no_contact_wall_blocks_exact_customer_number_echo(async_db, monkeypatch):
|
||||
"""客户自己的号码被 AI 原样回显 → 拦截整段替换(旧豁免放行此形态——
|
||||
GLM 稳定利用它回显号码,与"不回显"红线矛盾)。带前缀号构造,避开 CC
|
||||
触发轮,隔离验证本规则。"""
|
||||
from app.services.boundary_filter import NO_CONTACT_ALREADY
|
||||
|
||||
class ExactEchoAI(FakeAI):
|
||||
async def stream_chat(self, messages, context=""):
|
||||
for token in ("Got it — +966501234567! ",
|
||||
"Our manager will contact you soon."):
|
||||
yield token
|
||||
|
||||
(manager, conv), _ = _setup(async_db, monkeypatch,
|
||||
ai=ExactEchoAI(extract={}),
|
||||
source=ConversationSource.standalone)
|
||||
asyncio.run(_send(manager, conv.session_id, "my whatsapp is +966501234567"))
|
||||
msgs = asyncio.run(manager._load_history(conv.id))
|
||||
tail = [m["content"] for m in msgs if m["role"] == "assistant"][-1]
|
||||
assert "+966501234567" not in tail # 回显零残留
|
||||
assert tail == NO_CONTACT_ALREADY # 已留资 → 确认话术替换
|
||||
asyncio.run(manager.db.close())
|
||||
|
||||
|
||||
def test_no_contact_wall_blocks_customer_email_echo(async_db, monkeypatch):
|
||||
"""客户邮箱被 AI 回显 → 同规则拦截(确认收到即可,不复述地址)。"""
|
||||
from app.services.boundary_filter import NO_CONTACT_ALREADY
|
||||
|
||||
class EmailEchoAI(FakeAI):
|
||||
async def stream_chat(self, messages, context=""):
|
||||
for token in ("Got it, a@b.com — ", "quote goes there soon!"):
|
||||
yield token
|
||||
|
||||
(manager, conv), _ = _setup(async_db, monkeypatch,
|
||||
ai=EmailEchoAI(extract={}),
|
||||
source=ConversationSource.standalone)
|
||||
asyncio.run(_send(manager, conv.session_id, "my email is a@b.com"))
|
||||
msgs = asyncio.run(manager._load_history(conv.id))
|
||||
tail = [m["content"] for m in msgs if m["role"] == "assistant"][-1]
|
||||
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())
|
||||
|
||||
Reference in New Issue
Block a user