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:
@@ -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"))
|
||||
|
||||
Reference in New Issue
Block a user