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|
|
"source": "ip_proposed", # ip_proposed|naive|ip_confirm|
|
||||||
# prefix_normalized|customer_stated|no_ip|fallback
|
# prefix_normalized|customer_stated|no_ip|fallback
|
||||||
"canon": "0501234567", # 客户原始号的纯数字形态(换号=RESET 的判据)
|
"canon": "0501234567", # 客户原始号的纯数字形态(换号=RESET 的判据)
|
||||||
|
"asked": true, # 确认句已发出(conversation 轮末钩子管理,
|
||||||
|
# 首问轮免计;本模块不读不写,transit 原样携带)
|
||||||
}
|
}
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|||||||
@@ -101,6 +101,18 @@ def _lang_note_for(msg: str) -> str:
|
|||||||
return ""
|
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 3(spec §4.1-2/§4.4, 2026-10-08): 缺失渠道轻补逐字模板——与 system.md
|
||||||
# 联系方式引导策略节同源。注入的是"缺失渠道"(客户没给过的),与再索要墙的
|
# 联系方式引导策略节同源。注入的是"缺失渠道"(客户没给过的),与再索要墙的
|
||||||
# 渠道精确化(Task 1)配套: 墙只拦已在手渠道的索要,这些话术恰好问缺失渠道。
|
# 渠道精确化(Task 1)配套: 墙只拦已在手渠道的索要,这些话术恰好问缺失渠道。
|
||||||
@@ -115,8 +127,14 @@ _CC_ASK_EN = (
|
|||||||
_CC_ASK_ZH = (
|
_CC_ASK_ZH = (
|
||||||
"麻烦重发一下带国家代码的完整号码呗(比如 +966 5x xxx xxxx)?"
|
"麻烦重发一下带国家代码的完整号码呗(比如 +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_EN = "Got it — " + _CC_ASK_EN
|
||||||
_CC_WALL_ZH = _CC_ASK_ZH
|
_CC_WALL_ZH = _CC_ASK_ZH
|
||||||
|
_CC_WALL_ES = "Entendido — " + _CC_ASK_ES
|
||||||
_CC_NOTE_EN = (
|
_CC_NOTE_EN = (
|
||||||
"⚠️ 轻补指引(系统指令,绝对不要向客户提及这段说明):客户留下的号码不含"
|
"⚠️ 轻补指引(系统指令,绝对不要向客户提及这段说明):客户留下的号码不含"
|
||||||
"国家代码,客户经理无法直接回拨。请逐字使用这句:\"" + _CC_ASK_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?")
|
"Got it — just to confirm: our manager will reach you at {intl} — is that right?")
|
||||||
_CC_ECHO_ZH = (
|
_CC_ECHO_ZH = (
|
||||||
"收到——跟您确认下:客户经理会通过 {intl} 联系您,这个号码对吗?")
|
"收到——跟您确认下:客户经理会通过 {intl} 联系您,这个号码对吗?")
|
||||||
|
_CC_ECHO_ES = (
|
||||||
|
"Entendido — solo para confirmar: nuestro gerente te contactará "
|
||||||
|
"al {intl} — ¿es correcto?")
|
||||||
_EMAIL_NOTE_EN = (
|
_EMAIL_NOTE_EN = (
|
||||||
"⚠️ 轻补指引(系统指令,绝对不要向客户提及这段说明):客户已留号码但还没"
|
"⚠️ 轻补指引(系统指令,绝对不要向客户提及这段说明):客户已留号码但还没"
|
||||||
"留邮箱。请逐字使用这句:\"Got it! And an email address would help too — "
|
"留邮箱。请逐字使用这句:\"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:
|
def _cc_script(wa_state: dict | None, lang: str) -> str:
|
||||||
"""CC 等待态本轮逐字话术(Task 4, spec §4): 有 e164 → 回显确认句
|
"""CC 等待态本轮逐字话术(Task 4, spec §4;P3 扩 ES): 有 e164 → 回显
|
||||||
(intl 由 Python 算好填入,GLM 不拼号);no_ip → 追问句(_CC_ASK_* 服务)。
|
确认句(intl 由 Python 算好填入,GLM 不拼号);no_ip → 追问句
|
||||||
reminder 注入/墙替换/测试断言三方同源,统一走本函数。"""
|
(_CC_ASK_* 服务)。reminder 注入/墙替换/测试断言三方同源,统一走本函数。"""
|
||||||
if str((wa_state or {}).get("e164") or ""):
|
if str((wa_state or {}).get("e164") or ""):
|
||||||
intl = _cc_intl(wa_state)
|
intl = _cc_intl(wa_state)
|
||||||
return (_CC_ECHO_ZH if lang == "zh" else _CC_ECHO_EN).format(intl=intl)
|
echo = {"zh": _CC_ECHO_ZH, "es": _CC_ECHO_ES}.get(lang, _CC_ECHO_EN)
|
||||||
return _CC_ASK_ZH if lang == "zh" else _CC_ASK_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:
|
def _cc_wall_template(wa_state: dict | None, lang: str) -> str:
|
||||||
"""CC 触发轮墙整段替换句: 回显轮 = ECHO 模板(自带 "Got it —" 衔接头);
|
"""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 ""):
|
if str((wa_state or {}).get("e164") or ""):
|
||||||
return _cc_script(wa_state, lang)
|
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:
|
def _cc_confirm_note(wa_state: dict | None, lang: str) -> str:
|
||||||
@@ -574,8 +596,7 @@ class ConversationManager:
|
|||||||
# NO_CONTACT_ALREADY——GLM 有 3/3 回显倾向,该路径概率高,
|
# NO_CONTACT_ALREADY——GLM 有 3/3 回显倾向,该路径概率高,
|
||||||
# 客户该收到确认/追问而非"稍后联系"虚假承诺;
|
# 客户该收到确认/追问而非"稍后联系"虚假承诺;
|
||||||
# 价格墙路径(violation=="boundary")不变
|
# 价格墙路径(violation=="boundary")不变
|
||||||
closing = _cc_wall_template(
|
closing = _cc_wall_template(_wa_cc, _cc_lang_of(content))
|
||||||
_wa_cc, "zh" if _CJK_RE.search(content or "") else "en")
|
|
||||||
else:
|
else:
|
||||||
closing = (NO_CONTACT_ALREADY if has_contact
|
closing = (NO_CONTACT_ALREADY if has_contact
|
||||||
else (NO_CONTACT_APP if conv.source.value == "app"
|
else (NO_CONTACT_APP if conv.source.value == "app"
|
||||||
@@ -601,7 +622,8 @@ class ConversationManager:
|
|||||||
# digits 等值在场**(GLM 任意分组形态 "+966 50 123 4567"/"+966501234567"
|
# digits 等值在场**(GLM 任意分组形态 "+966 50 123 4567"/"+966501234567"
|
||||||
# 均判在场;辅锚 "is that right"/"对吗" 随模板正文,不作独立判据——
|
# 均判在场;辅锚 "is that right"/"对吗" 随模板正文,不作独立判据——
|
||||||
# 号码在场才是回显的承重语义);no_ip 轮无可回显号,沿用问句短语锚
|
# 号码在场才是回显的承重语义);no_ip 轮无可回显号,沿用问句短语锚
|
||||||
# ("country code"/"国家代码",与 _CC_ASK_* 模板句同词)。在场 → GLM
|
# ("country code"/"国家代码"/"código de país",与 _CC_ASK_* 模板句
|
||||||
|
# 同词——P3 扩西语同词,西语服从句不再被误替换)。在场 → GLM
|
||||||
# 自发服从,缓冲原样流出零干预;不在场 → 整段替换为对应 ECHO/ASK
|
# 自发服从,缓冲原样流出零干预;不在场 → 整段替换为对应 ECHO/ASK
|
||||||
# 模板。violated(价格/零号码墙)优先于本墙——沿用双墙裁决"更具体
|
# 模板。violated(价格/零号码墙)优先于本墙——沿用双墙裁决"更具体
|
||||||
# 话术优先"惯例;_cc 状态已在流前落库,不受替换影响。缓冲轮全部
|
# 话术优先"惯例;_cc 状态已在流前落库,不受替换影响。缓冲轮全部
|
||||||
@@ -609,13 +631,14 @@ class ConversationManager:
|
|||||||
# 本轮唯一实义输出,天然不触发下方兜底)。
|
# 本轮唯一实义输出,天然不触发下方兜底)。
|
||||||
if cc_wall and not violated:
|
if cc_wall and not violated:
|
||||||
cc_full = "".join(cc_buffer)
|
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 "")
|
wa_e164 = str((_wa_cc or {}).get("e164") or "")
|
||||||
if wa_e164:
|
if wa_e164:
|
||||||
obeyed = digits_only(wa_e164) in digits_only(cc_full)
|
obeyed = digits_only(wa_e164) in digits_only(cc_full)
|
||||||
else:
|
else:
|
||||||
obeyed = ("country code" in cc_full.lower()
|
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:
|
if obeyed:
|
||||||
for i in range(0, len(cc_full), 40):
|
for i in range(0, len(cc_full), 40):
|
||||||
yield {"type": "stream_chunk", "content": cc_full[i:i + 40]}
|
yield {"type": "stream_chunk", "content": cc_full[i:i + 40]}
|
||||||
@@ -730,8 +753,9 @@ class ConversationManager:
|
|||||||
|
|
||||||
# Task 3 轮末 cc 钩子(spec §6-2): 联系方式四层合并完成后、D1 邮件
|
# Task 3 轮末 cc 钩子(spec §6-2): 联系方式四层合并完成后、D1 邮件
|
||||||
# 触发前——消费 cc_reply 推进状态机,并为本轮才入管道的号码补建 _cc
|
# 触发前——消费 cc_reply 推进状态机,并为本轮才入管道的号码补建 _cc
|
||||||
# (spec §5: 首封补全号/推送派生依赖已建好的 _cc)。
|
# (spec §5: 首封补全号/推送派生依赖已建好的 _cc)。cc_wall_fired =
|
||||||
await self._cc_round_end(conv, profile, cc_reply)
|
# 本轮墙确有触发(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)
|
# Round 3: Evaluate (仅 ≥3 条客户消息才有意义, 前两轮必然 pending —— D7)
|
||||||
user_msg_count = sum(1 for m in full_history if m["role"] == "user")
|
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(
|
async def _cc_round_end(
|
||||||
self, conv: Conversation, profile: CustomerProfile,
|
self, conv: Conversation, profile: CustomerProfile,
|
||||||
cc_reply: dict | None,
|
cc_reply: dict | None, cc_wall_fired: bool = False,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""轮末 cc 钩子(spec §6-2): 联系方式四层合并后、D1 邮件前调用。
|
"""轮末 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)消费
|
- cc_reply 消费(双门控 Python 侧): 仅 whatsapp 组等待态(cc_due)消费
|
||||||
——无回应/漏填也推进(attempts 随轮递增,达上限落定 fallback);
|
——无回应/漏填也推进(attempts 随轮递增,达上限落定 fallback);
|
||||||
落定态仅开迟到 country 升级口: 客户主动报国(非空 country)且
|
落定态仅开迟到 country 升级口: 客户主动报国(非空 country)且
|
||||||
@@ -857,8 +890,15 @@ class ConversationManager:
|
|||||||
cc_map = dict(raw.get("_cc") or {})
|
cc_map = dict(raw.get("_cc") or {})
|
||||||
state = cc_map.get("whatsapp")
|
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
|
reply = None
|
||||||
if isinstance(cc_reply, dict):
|
if isinstance(cc_reply, dict) and not first_ask:
|
||||||
country = str(cc_reply.get("country") or "").strip()
|
country = str(cc_reply.get("country") or "").strip()
|
||||||
if cc_reply.get("affirm") is not None or country:
|
if cc_reply.get("affirm") is not None or country:
|
||||||
if cc_completion.cc_due(state):
|
if cc_completion.cc_due(state):
|
||||||
@@ -870,6 +910,8 @@ class ConversationManager:
|
|||||||
if state is not None:
|
if state is not None:
|
||||||
if not wa_canon_end or wa_canon_end != str(state.get("canon") or ""):
|
if not wa_canon_end or wa_canon_end != str(state.get("canon") or ""):
|
||||||
cc_map.pop("whatsapp", None) # 换号/号消失 → RESET
|
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):
|
elif reply is not None or cc_completion.cc_due(state):
|
||||||
cc_map["whatsapp"] = cc_completion.transit(state, reply)
|
cc_map["whatsapp"] = cc_completion.transit(state, reply)
|
||||||
if cc_map:
|
if cc_map:
|
||||||
@@ -1099,9 +1141,9 @@ class ConversationManager:
|
|||||||
# 回显模板(逐字 intl),no_ip 用 ASK 追问句;与邮箱轻补 cc_settled
|
# 回显模板(逐字 intl),no_ip 用 ASK 追问句;与邮箱轻补 cc_settled
|
||||||
# 串行互斥,两个"逐字使用这句"永不同轮。无计数键: 轮次上限由状态机
|
# 串行互斥,两个"逐字使用这句"永不同轮。无计数键: 轮次上限由状态机
|
||||||
# attempts 管理(轮末 transit 递增),提醒每轮等待态都在场。
|
# attempts 管理(轮末 transit 递增),提醒每轮等待态都在场。
|
||||||
|
# P3: 语言路由扩 ES(_cc_lang_of),西语轮注入西语模板。
|
||||||
cc_note = _cc_confirm_note(
|
cc_note = _cc_confirm_note(
|
||||||
(raw_info.get("_cc") or {}).get("whatsapp"),
|
(raw_info.get("_cc") or {}).get("whatsapp"), _cc_lang_of(latest_msg))
|
||||||
"zh" if _CJK_RE.search(latest_msg or "") else "en")
|
|
||||||
|
|
||||||
# 联系方式专项(D6) 与 职能边界(D6联动)
|
# 联系方式专项(D6) 与 职能边界(D6联动)
|
||||||
if confirmed:
|
if confirmed:
|
||||||
|
|||||||
@@ -808,7 +808,12 @@ def test_no_contact_wall_already_contact_no_reask(async_db, monkeypatch):
|
|||||||
async_db, monkeypatch,
|
async_db, monkeypatch,
|
||||||
ai=EchoAI(extract={}), source=ConversationSource.standalone)
|
ai=EchoAI(extract={}), source=ConversationSource.standalone)
|
||||||
asyncio.run(_send(manager, conv.session_id, "my whatsapp is 254712 345678"))
|
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?"))
|
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))
|
msgs = asyncio.run(manager._load_history(conv.id))
|
||||||
tail = [m["content"] for m in msgs if m["role"] == "assistant"][-1]
|
tail = [m["content"] for m in msgs if m["role"] == "assistant"][-1]
|
||||||
# 精确断言(评审加固): 负向断言("WhatsApp?" not in)对三种替换句都空转通过,
|
# 精确断言(评审加固): 负向断言("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):
|
def test_contact_completion_two_round_serial_and_persistence(async_db, monkeypatch):
|
||||||
"""spec §6-1/§6-2 集成: 裸号轮当轮 _cc 已建且落库(轮前钩子实证——墙
|
"""spec §6-1/§6-2 集成: 裸号轮当轮 _cc 已建且落库(轮前钩子实证——墙
|
||||||
触发依赖它);确认流未落定期间邮箱轻补被拦;无回应两轮 attempts 耗尽
|
触发依赖它);确认流未落定期间邮箱轻补被拦;无回应轮 attempts 递增,
|
||||||
落定 fallback 后,串行转邮箱轻补并落 _email_asked。
|
耗尽落定 fallback 后串行转邮箱轻补并落 _email_asked。
|
||||||
Task 4: 有建议国(SA→e164)轮墙替换句 = 回显确认模板(ECHO,含 intl)。"""
|
Task 4: 有建议国(SA→e164)轮墙替换句 = 回显确认模板(ECHO,含 intl)。
|
||||||
|
P1+P2 修复后 attempts 语义: 首问轮(回显首次发出)免计 0→等待回应轮
|
||||||
|
起计 1→2 落定——较修复前多一轮,客户回应机会不变。"""
|
||||||
from app.services import ip_service
|
from app.services import ip_service
|
||||||
from app.services.conversation import _CC_ECHO_EN
|
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)
|
source=ConversationSource.standalone)
|
||||||
asyncio.run(_send(manager, conv.session_id, "my whatsapp is 0501234567"))
|
asyncio.run(_send(manager, conv.session_id, "my whatsapp is 0501234567"))
|
||||||
# 第一轮正向断言(轮前钩子实证): _cc 当轮已建并落库,墙靠它触发替换。
|
# 第一轮正向断言(轮前钩子实证): _cc 当轮已建并落库,墙靠它触发替换。
|
||||||
# attempts 语义(Task 3 调参钉死): 追问轮自身在轮末即消耗一轮——
|
# attempts 语义(P1+P2 修复): 首问轮(回显首次发出)免计——客户本轮
|
||||||
# 轮前 attempts=0 建态 → 墙问 → 轮末 transit(None) → attempts=1;
|
# 还没机会回应,轮末不消耗不消费,只落 asked 标记;attempts 从等待
|
||||||
# 常规路径客户总共恰被问 2 次(第 1/2 轮),第 3 轮起轻补解锁
|
# 回应轮(次轮)起计
|
||||||
profile = asyncio.run(manager._get_profile(conv.id))
|
profile = asyncio.run(manager._get_profile(conv.id))
|
||||||
wa_cc = (profile.raw_info or {}).get("_cc", {}).get("whatsapp")
|
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",
|
"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))
|
tails = [m["content"] for m in asyncio.run(manager._load_history(conv.id))
|
||||||
if m["role"] == "assistant"]
|
if m["role"] == "assistant"]
|
||||||
assert tails[-1] == _CC_ECHO_EN.format(intl="+966 50 123 4567") # 回显模板
|
assert tails[-1] == _CC_ECHO_EN.format(intl="+966 50 123 4567") # 回显模板
|
||||||
assert "email address would help too" not in ai.contexts[0] # 未落定: 轻补被拦
|
assert "email address would help too" not in ai.contexts[0] # 未落定: 轻补被拦
|
||||||
assert (profile.raw_info or {}).get("_cc_asked") is None # 旧计数停写
|
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?"))
|
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))
|
tails = [m["content"] for m in asyncio.run(manager._load_history(conv.id))
|
||||||
if m["role"] == "assistant"]
|
if m["role"] == "assistant"]
|
||||||
assert tails[-1] == _CC_ECHO_EN.format(intl="+966 50 123 4567") # 第 2 次(上限内)
|
assert tails[-1] == _CC_ECHO_EN.format(intl="+966 50 123 4567") # 第 2 次(上限内)
|
||||||
wa_cc = (asyncio.run(manager._get_profile(conv.id))
|
wa_cc = (asyncio.run(manager._get_profile(conv.id))
|
||||||
.raw_info or {}).get("_cc", {}).get("whatsapp")
|
.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]
|
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?"))
|
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))
|
tails = [m["content"] for m in asyncio.run(manager._load_history(conv.id))
|
||||||
if m["role"] == "assistant"]
|
if m["role"] == "assistant"]
|
||||||
assert tails[-1] == "ok reply" # 墙关闭,AI 原文放行
|
assert tails[-1] == "ok reply" # 墙关闭,AI 原文放行
|
||||||
wa_cc = (asyncio.run(manager._get_profile(conv.id))
|
wa_cc = (asyncio.run(manager._get_profile(conv.id))
|
||||||
.raw_info or {}).get("_cc", {}).get("whatsapp")
|
.raw_info or {}).get("_cc", {}).get("whatsapp")
|
||||||
assert wa_cc["attempts"] == 2 and wa_cc["source"] == "fallback"
|
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))
|
profile = asyncio.run(manager._get_profile(conv.id))
|
||||||
assert (profile.raw_info or {}).get("_email_asked") is True # 计数已落库
|
assert (profile.raw_info or {}).get("_email_asked") is True # 计数已落库
|
||||||
asyncio.run(manager.db.close())
|
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))
|
wa = (asyncio.run(manager._get_profile(conv.id))
|
||||||
.raw_info or {}).get("_cc", {}).get("whatsapp")
|
.raw_info or {}).get("_cc", {}).get("whatsapp")
|
||||||
assert wa["confirmed"] is True and wa["source"] == "ip_confirm"
|
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] # 确认轮仍串行
|
assert "email address would help too" not in ai.contexts[1] # 确认轮仍串行
|
||||||
asyncio.run(_send(manager, conv.session_id, "and the fittings?"))
|
asyncio.run(_send(manager, conv.session_id, "and the fittings?"))
|
||||||
assert "email address would help too" in ai.contexts[2] # 落定后解锁
|
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")
|
wa = cc.get("whatsapp")
|
||||||
exp = classify("0512345678", "SA")[1]
|
exp = classify("0512345678", "SA")[1]
|
||||||
assert wa["canon"] == "0512345678" # 旧 canon 状态已被替换
|
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 wa["e164"] == exp["e164"] # 建议随新号重算
|
||||||
assert set(cc) == {"whatsapp"} # 同 canon 手机镜像归并进 whatsapp 组
|
assert set(cc) == {"whatsapp"} # 同 canon 手机镜像归并进 whatsapp 组
|
||||||
asyncio.run(manager.db.close())
|
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):
|
def test_cc_late_country_upgrade_on_fallback(async_db, monkeypatch):
|
||||||
"""落定 fallback 后客户主动报国 → 受控升级: e164 重算、信息不丢,
|
"""落定 fallback 后客户主动报国 → 受控升级: e164 重算、信息不丢,
|
||||||
但不再回显追问(attempts 已过上限,保持落定)。"""
|
但不再回显追问(attempts 已过上限,保持落定)。P1+P2 后 attempts 从
|
||||||
|
等待回应轮起计,需两轮不理睬(1→2)才落定 fallback,报国轮顺延。"""
|
||||||
_sa(monkeypatch)
|
_sa(monkeypatch)
|
||||||
ai = _RecAI(extract=[{}, {},
|
ai = _RecAI(extract=[{}, {}, {},
|
||||||
{"cc_reply": {"affirm": False, "country": "Egypt"}}])
|
{"cc_reply": {"affirm": False, "country": "Egypt"}}])
|
||||||
(manager, conv), _ = _setup(async_db, monkeypatch, ai=ai,
|
(manager, conv), _ = _setup(async_db, monkeypatch, ai=ai,
|
||||||
source=ConversationSource.standalone)
|
source=ConversationSource.standalone)
|
||||||
asyncio.run(_send(manager, conv.session_id, "my whatsapp is 0501234567"))
|
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, "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"))
|
asyncio.run(_send(manager, conv.session_id, "I am based in Egypt"))
|
||||||
wa = (asyncio.run(manager._get_profile(conv.id))
|
wa = (asyncio.run(manager._get_profile(conv.id))
|
||||||
.raw_info or {}).get("_cc", {}).get("whatsapp")
|
.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):
|
def test_cc_late_country_on_no_ip_exhausted_ignored(async_db, monkeypatch):
|
||||||
"""no_ip 态一轮耗尽后收到迟到报国 → 忽略(该口仅对有建议国的
|
"""no_ip 态一轮耗尽后收到迟到报国 → 忽略(该口仅对有建议国的
|
||||||
落定态开放;no_ip 耗尽是设计明确的静默终态)。"""
|
落定态开放;no_ip 耗尽是设计明确的静默终态)。P1+P2 后首问轮免计,
|
||||||
ai = _RecAI(extract=[{},
|
耗尽落定顺延到次轮轮末,报国轮再顺延一轮。"""
|
||||||
|
ai = _RecAI(extract=[{}, {},
|
||||||
{"cc_reply": {"affirm": False, "country": "Egypt"}}])
|
{"cc_reply": {"affirm": False, "country": "Egypt"}}])
|
||||||
(manager, conv), _ = _setup(async_db, monkeypatch, ai=ai,
|
(manager, conv), _ = _setup(async_db, monkeypatch, ai=ai,
|
||||||
source=ConversationSource.standalone)
|
source=ConversationSource.standalone)
|
||||||
asyncio.run(_send(manager, conv.session_id, "my whatsapp is 0501234567"))
|
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"))
|
asyncio.run(_send(manager, conv.session_id, "I live in Egypt actually"))
|
||||||
wa = (asyncio.run(manager._get_profile(conv.id))
|
wa = (asyncio.run(manager._get_profile(conv.id))
|
||||||
.raw_info or {}).get("_cc", {}).get("whatsapp")
|
.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())
|
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(
|
def test_extractor_hygiene_strips_cc_reply_and_underscore_keys(
|
||||||
async_db, monkeypatch):
|
async_db, monkeypatch):
|
||||||
"""出口卫生三封堵: ① extractor 入参 existing_info 无 `_` 键;
|
"""出口卫生三封堵: ① extractor 入参 existing_info 无 `_` 键;
|
||||||
② cc_reply 提取后即剥离(不进 raw_info);③ extractor 回传的 `_cc`/
|
② cc_reply 提取后即剥离(不进 raw_info);③ extractor 回传的 `_cc`/
|
||||||
`_evil` 防御性丢弃——_cc 由 Python 判定树独占管理,LLM 回显覆盖不了。"""
|
`_evil` 防御性丢弃——_cc 由 Python 判定树独占管理,LLM 回显覆盖不了。
|
||||||
|
P2: 首问轮(裸号轮)cc_reply 免消费——同轮回显使确认句排给号消息
|
||||||
|
之后,extractor 分类必是误判;次轮报国 cc_reply 正常消费。"""
|
||||||
_sa(monkeypatch)
|
_sa(monkeypatch)
|
||||||
poison = {"联系方式": {"whatsapp": "0501234567"},
|
poison = {"联系方式": {"whatsapp": "0501234567"},
|
||||||
"cc_reply": {"affirm": False, "country": "Egypt"},
|
"cc_reply": {"affirm": False, "country": "Egypt"},
|
||||||
"_cc": {"whatsapp": {"hacked": True}}, "_evil": 1}
|
"_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,
|
(manager, conv), _ = _setup(async_db, monkeypatch, ai=ai,
|
||||||
source=ConversationSource.standalone)
|
source=ConversationSource.standalone)
|
||||||
asyncio.run(_send(manager, conv.session_id, "my whatsapp is 0501234567"))
|
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 # ②③ 出口侧
|
assert "cc_reply" not in raw and "_evil" not in raw # ②③ 出口侧
|
||||||
wa = (raw.get("_cc") or {}).get("whatsapp")
|
wa = (raw.get("_cc") or {}).get("whatsapp")
|
||||||
assert wa and wa.get("canon") == "0501234567" # Python 管理,非 hacked
|
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"
|
assert wa["e164"] == "+20501234567"
|
||||||
asyncio.run(manager.db.close())
|
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))
|
wa = (asyncio.run(manager._get_profile(conv.id))
|
||||||
.raw_info or {}).get("_cc", {}).get("whatsapp")
|
.raw_info or {}).get("_cc", {}).get("whatsapp")
|
||||||
assert wa["confirmed"] is True and wa["source"] == "ip_confirm"
|
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())
|
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]
|
if m["role"] == "assistant"][-1]
|
||||||
assert t1 == _CONFIRM_REPLY
|
assert t1 == _CONFIRM_REPLY
|
||||||
asyncio.run(m1.db.close())
|
asyncio.run(m1.db.close())
|
||||||
# (b) no_ip 态一轮耗尽落定: 首轮裸号(CC 轮被替换),次轮号码仍裸 →
|
# (b) no_ip 态耗尽落定: 首轮裸号(CC 轮被替换,首问免计),次轮墙仍
|
||||||
# cc_due=False 墙关闭(邮箱轻补轮),确认句原样
|
# 在场(0<1 due,时滞语义)再问一次、轮末 attempts=1=cap 落定,
|
||||||
|
# 第三轮 cc_due=False 墙关闭(邮箱轻补轮),确认句原样
|
||||||
(m2, c2), _ = _setup(async_db, monkeypatch, ai=_ConfirmAI(extract={}),
|
(m2, c2), _ = _setup(async_db, monkeypatch, ai=_ConfirmAI(extract={}),
|
||||||
source=ConversationSource.standalone)
|
source=ConversationSource.standalone)
|
||||||
asyncio.run(_send(m2, c2.session_id, "my whatsapp is 0501234567"))
|
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, "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))
|
tails = [m["content"] for m in asyncio.run(m2._load_history(c2.id))
|
||||||
if m["role"] == "assistant"]
|
if m["role"] == "assistant"]
|
||||||
assert tails[0] == _CC_WALL_EN # 前提: 第一轮确是 CC 触发轮(被墙替换)
|
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())
|
asyncio.run(m2.db.close())
|
||||||
# (c) 双渠道齐: 号码带前缀 + 邮箱 → 无任何轻补,墙关闭
|
# (c) 双渠道齐: 号码带前缀 + 邮箱 → 无任何轻补,墙关闭
|
||||||
(m3, c3), _ = _setup(async_db, monkeypatch, ai=_ConfirmAI(extract={}),
|
(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())
|
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):
|
def test_cc_no_ip_turn_uses_ask_template_and_note(async_db, monkeypatch):
|
||||||
"""no_ip 轮(无 e164 态)专属: reminder 注入 ASK 追问句("full number
|
"""no_ip 轮(无 e164 态)专属: reminder 注入 ASK 追问句("full number
|
||||||
with country code"),墙替换句 = _CC_WALL_*(ASK 正文)——回显语义缺席,
|
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"))
|
asyncio.run(_send(manager, conv.session_id, "my whatsapp is 0501234567"))
|
||||||
assert [s[0] for s in sent] == ["lead"]
|
assert [s[0] for s in sent] == ["lead"]
|
||||||
ev = asyncio.run(get_ev())
|
ev = asyncio.run(get_ev())
|
||||||
# 面板尾注: 首封时确认流未落定(attempts=1 < 2),快照值带等待态标注;
|
# 面板尾注: 首封时确认流未落定(P1+P2 后首问轮免计 attempts=0 < 2),
|
||||||
# 尾注全非数字,不影响后续 canon 比对
|
# 快照值带等待态标注;尾注全非数字,不影响后续 canon 比对
|
||||||
assert ev.contact_info["whatsapp"] == "0501234567(待确认补全号)"
|
assert ev.contact_info["whatsapp"] == "0501234567(待确认补全号)"
|
||||||
|
|
||||||
asyncio.run(_send(manager, conv.session_id, "yes that's right"))
|
asyncio.run(_send(manager, conv.session_id, "yes that's right"))
|
||||||
|
|||||||
Reference in New Issue
Block a user