feat: 回显确认模板+确认句墙e164检测+白名单逐段放行+no_ip专属

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-10-08 18:43:52 +08:00
parent 4130e2e3f5
commit 807ee0886d
4 changed files with 322 additions and 44 deletions
+163 -14
View File
@@ -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 原样回显 → 拦截整段替换(旧豁免放行此形态——