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:
@@ -14,6 +14,8 @@ conversation/DB/LLM——IO、存储与提示词注入归调用方(轮前/轮
|
||||
"source": "ip_proposed", # ip_proposed|naive|ip_confirm|
|
||||
# prefix_normalized|customer_stated|no_ip|fallback
|
||||
"canon": "0501234567", # 客户原始号的纯数字形态(换号=RESET 的判据)
|
||||
"asked": true, # 确认句已发出(conversation 轮末钩子管理,
|
||||
# 首问轮免计;本模块不读不写,transit 原样携带)
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
@@ -101,6 +101,18 @@ def _lang_note_for(msg: str) -> str:
|
||||
return ""
|
||||
|
||||
|
||||
def _cc_lang_of(msg: str) -> str:
|
||||
"""CC 话术语言路由(P3, 2026-10-08 验收): 汉字→ZH;西语特征(与
|
||||
boundary_filter._lang_pool/_lang_note_for 同款重音符粗匹配)→ES;
|
||||
否则 EN。只管 ECHO/ASK 模板选型——墙裁决锚(e164 digits)语言无关,
|
||||
no_ip 问句短语锚另含西语同词(见 cc_wall 裁决段)。"""
|
||||
if _CJK_RE.search(msg or ""):
|
||||
return "zh"
|
||||
if _ES_FEATURE_RE.search(msg or ""):
|
||||
return "es"
|
||||
return "en"
|
||||
|
||||
|
||||
# Task 3(spec §4.1-2/§4.4, 2026-10-08): 缺失渠道轻补逐字模板——与 system.md
|
||||
# 联系方式引导策略节同源。注入的是"缺失渠道"(客户没给过的),与再索要墙的
|
||||
# 渠道精确化(Task 1)配套: 墙只拦已在手渠道的索要,这些话术恰好问缺失渠道。
|
||||
@@ -115,8 +127,14 @@ _CC_ASK_EN = (
|
||||
_CC_ASK_ZH = (
|
||||
"麻烦重发一下带国家代码的完整号码呗(比如 +966 5x xxx xxxx)?"
|
||||
"客户经理直接加你。")
|
||||
# P3(2026-10-08 真实验收): 西语轮模板补齐——修复前 ECHO/ASK 只有 EN/ZH,
|
||||
# 西语轮 GLM 照抄英文。WALL 沿用 EN 形态(衔接头 + 追问句正文)。
|
||||
_CC_ASK_ES = (
|
||||
"¿Puedes enviar el número completo con el código de país, "
|
||||
"como +966 5x xxx xxxx? Así nuestro gerente puede contactarte directamente.")
|
||||
_CC_WALL_EN = "Got it — " + _CC_ASK_EN
|
||||
_CC_WALL_ZH = _CC_ASK_ZH
|
||||
_CC_WALL_ES = "Entendido — " + _CC_ASK_ES
|
||||
_CC_NOTE_EN = (
|
||||
"⚠️ 轻补指引(系统指令,绝对不要向客户提及这段说明):客户留下的号码不含"
|
||||
"国家代码,客户经理无法直接回拨。请逐字使用这句:\"" + _CC_ASK_EN + "\" "
|
||||
@@ -133,6 +151,9 @@ _CC_ECHO_EN = (
|
||||
"Got it — just to confirm: our manager will reach you at {intl} — is that right?")
|
||||
_CC_ECHO_ZH = (
|
||||
"收到——跟您确认下:客户经理会通过 {intl} 联系您,这个号码对吗?")
|
||||
_CC_ECHO_ES = (
|
||||
"Entendido — solo para confirmar: nuestro gerente te contactará "
|
||||
"al {intl} — ¿es correcto?")
|
||||
_EMAIL_NOTE_EN = (
|
||||
"⚠️ 轻补指引(系统指令,绝对不要向客户提及这段说明):客户已留号码但还没"
|
||||
"留邮箱。请逐字使用这句:\"Got it! And an email address would help too — "
|
||||
@@ -153,21 +174,22 @@ def _cc_intl(wa_state: dict | None) -> str:
|
||||
|
||||
|
||||
def _cc_script(wa_state: dict | None, lang: str) -> str:
|
||||
"""CC 等待态本轮逐字话术(Task 4, spec §4): 有 e164 → 回显确认句
|
||||
(intl 由 Python 算好填入,GLM 不拼号);no_ip → 追问句(_CC_ASK_* 服务)。
|
||||
reminder 注入/墙替换/测试断言三方同源,统一走本函数。"""
|
||||
"""CC 等待态本轮逐字话术(Task 4, spec §4;P3 扩 ES): 有 e164 → 回显
|
||||
确认句(intl 由 Python 算好填入,GLM 不拼号);no_ip → 追问句
|
||||
(_CC_ASK_* 服务)。reminder 注入/墙替换/测试断言三方同源,统一走本函数。"""
|
||||
if str((wa_state or {}).get("e164") or ""):
|
||||
intl = _cc_intl(wa_state)
|
||||
return (_CC_ECHO_ZH if lang == "zh" else _CC_ECHO_EN).format(intl=intl)
|
||||
return _CC_ASK_ZH if lang == "zh" else _CC_ASK_EN
|
||||
echo = {"zh": _CC_ECHO_ZH, "es": _CC_ECHO_ES}.get(lang, _CC_ECHO_EN)
|
||||
return echo.format(intl=intl)
|
||||
return {"zh": _CC_ASK_ZH, "es": _CC_ASK_ES}.get(lang, _CC_ASK_EN)
|
||||
|
||||
|
||||
def _cc_wall_template(wa_state: dict | None, lang: str) -> str:
|
||||
"""CC 触发轮墙整段替换句: 回显轮 = ECHO 模板(自带 "Got it —" 衔接头);
|
||||
no_ip 轮 = _CC_WALL_*(ASK 追问句 + 衔接头,沿用 Task 3 形态)。"""
|
||||
no_ip 轮 = _CC_WALL_*(ASK 追问句 + 衔接头,沿用 Task 3 形态;P3 扩 ES)。"""
|
||||
if str((wa_state or {}).get("e164") or ""):
|
||||
return _cc_script(wa_state, lang)
|
||||
return _CC_WALL_ZH if lang == "zh" else _CC_WALL_EN
|
||||
return {"zh": _CC_WALL_ZH, "es": _CC_WALL_ES}.get(lang, _CC_WALL_EN)
|
||||
|
||||
|
||||
def _cc_confirm_note(wa_state: dict | None, lang: str) -> str:
|
||||
@@ -574,8 +596,7 @@ class ConversationManager:
|
||||
# NO_CONTACT_ALREADY——GLM 有 3/3 回显倾向,该路径概率高,
|
||||
# 客户该收到确认/追问而非"稍后联系"虚假承诺;
|
||||
# 价格墙路径(violation=="boundary")不变
|
||||
closing = _cc_wall_template(
|
||||
_wa_cc, "zh" if _CJK_RE.search(content or "") else "en")
|
||||
closing = _cc_wall_template(_wa_cc, _cc_lang_of(content))
|
||||
else:
|
||||
closing = (NO_CONTACT_ALREADY if has_contact
|
||||
else (NO_CONTACT_APP if conv.source.value == "app"
|
||||
@@ -601,7 +622,8 @@ class ConversationManager:
|
||||
# digits 等值在场**(GLM 任意分组形态 "+966 50 123 4567"/"+966501234567"
|
||||
# 均判在场;辅锚 "is that right"/"对吗" 随模板正文,不作独立判据——
|
||||
# 号码在场才是回显的承重语义);no_ip 轮无可回显号,沿用问句短语锚
|
||||
# ("country code"/"国家代码",与 _CC_ASK_* 模板句同词)。在场 → GLM
|
||||
# ("country code"/"国家代码"/"código de país",与 _CC_ASK_* 模板句
|
||||
# 同词——P3 扩西语同词,西语服从句不再被误替换)。在场 → GLM
|
||||
# 自发服从,缓冲原样流出零干预;不在场 → 整段替换为对应 ECHO/ASK
|
||||
# 模板。violated(价格/零号码墙)优先于本墙——沿用双墙裁决"更具体
|
||||
# 话术优先"惯例;_cc 状态已在流前落库,不受替换影响。缓冲轮全部
|
||||
@@ -609,13 +631,14 @@ class ConversationManager:
|
||||
# 本轮唯一实义输出,天然不触发下方兜底)。
|
||||
if cc_wall and not violated:
|
||||
cc_full = "".join(cc_buffer)
|
||||
cc_lang = "zh" if _CJK_RE.search(content or "") else "en"
|
||||
cc_lang = _cc_lang_of(content)
|
||||
wa_e164 = str((_wa_cc or {}).get("e164") or "")
|
||||
if wa_e164:
|
||||
obeyed = digits_only(wa_e164) in digits_only(cc_full)
|
||||
else:
|
||||
obeyed = ("country code" in cc_full.lower()
|
||||
or "国家代码" in cc_full)
|
||||
or "国家代码" in cc_full
|
||||
or "código de país" in cc_full.lower())
|
||||
if obeyed:
|
||||
for i in range(0, len(cc_full), 40):
|
||||
yield {"type": "stream_chunk", "content": cc_full[i:i + 40]}
|
||||
@@ -730,8 +753,9 @@ class ConversationManager:
|
||||
|
||||
# Task 3 轮末 cc 钩子(spec §6-2): 联系方式四层合并完成后、D1 邮件
|
||||
# 触发前——消费 cc_reply 推进状态机,并为本轮才入管道的号码补建 _cc
|
||||
# (spec §5: 首封补全号/推送派生依赖已建好的 _cc)。
|
||||
await self._cc_round_end(conv, profile, cc_reply)
|
||||
# (spec §5: 首封补全号/推送派生依赖已建好的 _cc)。cc_wall_fired =
|
||||
# 本轮墙确有触发(P1+P2 首问轮判定输入,见 _cc_round_end)。
|
||||
await self._cc_round_end(conv, profile, cc_reply, cc_wall_fired=cc_wall)
|
||||
|
||||
# Round 3: Evaluate (仅 ≥3 条客户消息才有意义, 前两轮必然 pending —— D7)
|
||||
user_msg_count = sum(1 for m in full_history if m["role"] == "user")
|
||||
@@ -833,10 +857,19 @@ class ConversationManager:
|
||||
|
||||
async def _cc_round_end(
|
||||
self, conv: Conversation, profile: CustomerProfile,
|
||||
cc_reply: dict | None,
|
||||
cc_reply: dict | None, cc_wall_fired: bool = False,
|
||||
) -> None:
|
||||
"""轮末 cc 钩子(spec §6-2): 联系方式四层合并后、D1 邮件前调用。
|
||||
|
||||
- 首问轮免计免消费(P1+P2 一体修复,2026-10-08 真实验收): 轮前
|
||||
新建的等待态 = 回显/追问**首次发出轮**——客户本轮还没机会回应
|
||||
确认句,轮末既不消耗 attempts 也不消费 cc_reply(extractor 对
|
||||
"确认句排给号消息之后"历史的任何回应分类必是误判,P2);attempts
|
||||
从等待回应轮起计(报国/不理睬轮才递增)——修复前裸号轮轮末即耗
|
||||
attempts=1,报国轮 country 分支消费到 2 直落 fallback,重算的
|
||||
e164 永无回显确认机会(P1)。判据 = 本轮墙确有触发(cc_wall_fired)
|
||||
且状态尚未标记 asked——统一覆盖轮前新建与轮末补建两条建态路径
|
||||
的首个墙轮(补建态当轮无墙,首问轮顺延到下轮)。
|
||||
- cc_reply 消费(双门控 Python 侧): 仅 whatsapp 组等待态(cc_due)消费
|
||||
——无回应/漏填也推进(attempts 随轮递增,达上限落定 fallback);
|
||||
落定态仅开迟到 country 升级口: 客户主动报国(非空 country)且
|
||||
@@ -857,8 +890,15 @@ class ConversationManager:
|
||||
cc_map = dict(raw.get("_cc") or {})
|
||||
state = cc_map.get("whatsapp")
|
||||
|
||||
# 首问轮: cc_wall_fired ⇔ 轮前等待态(墙必触发);asked 标记由本钩子
|
||||
# 独占管理(cc_completion 不感知),transit 的 dict 拷贝天然携带。
|
||||
# canon 分支在其后——首问轮遇换号仍以 RESET 优先,标记随旧态作废。
|
||||
first_ask = (state is not None and cc_wall_fired
|
||||
and not state.get("asked")
|
||||
and cc_completion.cc_due(state))
|
||||
|
||||
reply = None
|
||||
if isinstance(cc_reply, dict):
|
||||
if isinstance(cc_reply, dict) and not first_ask:
|
||||
country = str(cc_reply.get("country") or "").strip()
|
||||
if cc_reply.get("affirm") is not None or country:
|
||||
if cc_completion.cc_due(state):
|
||||
@@ -870,6 +910,8 @@ class ConversationManager:
|
||||
if state is not None:
|
||||
if not wa_canon_end or wa_canon_end != str(state.get("canon") or ""):
|
||||
cc_map.pop("whatsapp", None) # 换号/号消失 → RESET
|
||||
elif first_ask:
|
||||
cc_map["whatsapp"] = {**state, "asked": True} # 首问轮免计
|
||||
elif reply is not None or cc_completion.cc_due(state):
|
||||
cc_map["whatsapp"] = cc_completion.transit(state, reply)
|
||||
if cc_map:
|
||||
@@ -1099,9 +1141,9 @@ class ConversationManager:
|
||||
# 回显模板(逐字 intl),no_ip 用 ASK 追问句;与邮箱轻补 cc_settled
|
||||
# 串行互斥,两个"逐字使用这句"永不同轮。无计数键: 轮次上限由状态机
|
||||
# attempts 管理(轮末 transit 递增),提醒每轮等待态都在场。
|
||||
# P3: 语言路由扩 ES(_cc_lang_of),西语轮注入西语模板。
|
||||
cc_note = _cc_confirm_note(
|
||||
(raw_info.get("_cc") or {}).get("whatsapp"),
|
||||
"zh" if _CJK_RE.search(latest_msg or "") else "en")
|
||||
(raw_info.get("_cc") or {}).get("whatsapp"), _cc_lang_of(latest_msg))
|
||||
|
||||
# 联系方式专项(D6) 与 职能边界(D6联动)
|
||||
if confirmed:
|
||||
|
||||
@@ -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