fix: 验收P1P2——轮末新建态免计免消费(回显首轮语义)+P3西语ECHO/ASK模板

P1(换国"报国→再回显→确认"结构性不可达) + P2(extractor 裸号轮误
affirm 跳过确认流)同根因修复: "轮前新建态"的轮语义——回显/追问首次
发出轮,客户本轮还没机会回应确认句,轮末既不消耗 attempts 也不消费
cc_reply。实现: _cc_round_end 增 first_ask 判据(本轮墙确有触发 × 状态
未标记 asked),首问轮只落 asked 标记直通;attempts 从等待回应轮起计。
判据用 asked 标记而非轮前快照比对——统一覆盖轮前新建与轮末补建两条
建态路径的首个墙轮(补建态当轮无墙,首问顺延下轮,否则 P1 结构性残留)。

P3(西语回显轮是英文): 新增 _CC_ECHO_ES/_CC_ASK_ES/_CC_WALL_ES,
_cc_script/_cc_wall_template/_cc_confirm_note 语言分流扩 ES(_cc_lang_of,
与 _lang_pool 同款西语特征粗匹配);no_ip 墙裁决锚补 "código de país"
西语同词(服从句不再被误替换);e164 digits 裁决锚语言无关不受影响。

修复后时序(常规 cap=2): 回显轮(免计0)→等待回应轮起计1→2落定,
落定较修复前多一轮,客户回应机会不变;no_ip cap=1: 首问轮免计,
报国/不理睬一轮后落定。

测试 308→315: 新增7(换国三轮流/首问轮免消费/补建路径首问免计/西语
ECHO墙/西语reminder/西语no_ip ASK/西语服从句放行);迁移9项受
"新建态免计"影响的既有断言(attempts 起点变化/落定轮次顺延)。

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-10-08 20:49:13 +08:00
parent 2625d442f2
commit 568c116839
3 changed files with 292 additions and 45 deletions
+230 -27
View File
@@ -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"))