feat: 判定树轮前接入+cc_reply消费+出口卫生+存量迁移
Task 3(cc-completion 计划最重集成项): - 轮前钩子 _cc_pre_turn: effective_raw 建成后、reminder 之前跑判定树, 建/重置/迁移 _cc(同 canon 保留状态,canon 变化即 RESET 重走); 与一次性计数写回同事务落库(条件扩为计数或 _cc 变更任一存在) - 轮末钩子 _cc_round_end: 四层联系方式合并后、D1 邮件前——双门控消费 cc_reply(仅 whatsapp 等待态;落定态仅开迟到 country 升级口,no_ip 耗除外),canon 变化 RESET,末次联系方式补建 pass - 出口卫生三封堵: cc_reply 消费后剥离/extractor 回传 _ 前缀键防御性 丢弃/入口 existing_info 滤 _ 复核 - 存量迁移: 旧 _cc_asked=True+裸 whatsapp 无 _cc → 一次性 fallback 态 - _contact_completion_note cc 分支删除,邮箱轻补解锁条件改 cc_settled(_cc.whatsapp);cc 墙门控改线 cc_due(_wa_cc) - 集成实证修两处数据流 bug: WHATSAPP_CTX_RE 间隙吞 + 号(显式国际 前缀被捕成裸号→误追问);同轮扫描合并方向反转(存量覆盖当轮新号, 轮前 canon 变化检测失效——与轮末最新自述优先同语义对齐) - 新增 10 项集成测试(affirm 确认/换号重走/迟到报国升级与忽略/ 卫生三封堵/存量迁移/confirmed 态忽略/同 canon 组归并/异号 phone 静默/纯 phone 无墙),282 项全绿 Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -921,55 +921,68 @@ def test_e2e_no_reasking_after_contact_provided(async_db, monkeypatch):
|
||||
asyncio.run(manager.db.close())
|
||||
|
||||
|
||||
# ---- Task 3: 缺失渠道一次性轻补提醒(spec §4.2/§4.3/§4.3b) ----
|
||||
def test_contact_completion_note_serial_priority():
|
||||
"""spec §4.3b: 裸号+无邮箱(主场景)只注入国家代码追问;号码闭环后才可能
|
||||
注入邮箱轻补;各计数消耗后不再注入。"""
|
||||
# ---- Task 3: 缺失渠道一次性轻补提醒(spec §4.2/§4.3b;cc 分支已删,判定树接管) ----
|
||||
def test_contact_completion_note_email_gated_by_cc_settled():
|
||||
"""cc 分支删除后 _contact_completion_note 只管邮箱轻补: 解锁条件=
|
||||
cc_settled(_cc.whatsapp)。等待态拦截(串行,防两个逐字句同轮竞争);
|
||||
落定(fallback/attempts 耗尽)解锁;带+号客户无 _cc 状态 →
|
||||
cc_settled(None)=True 不回归;_email_asked 消耗后不再注入。"""
|
||||
from app.services.conversation import _contact_completion_note
|
||||
wa_bare = {"whatsapp": "0501234567"}
|
||||
wa_full = {"whatsapp": "+966501234567"}
|
||||
wa_email = {"whatsapp": "+966501234567", "email": "a@b.com"}
|
||||
note, marks = _contact_completion_note({"联系方式": wa_bare}, wa_bare, {})
|
||||
assert "country code" in note and marks == ["_cc_asked"]
|
||||
due = {"confirmed": False, "attempts": 0, "source": "ip_proposed",
|
||||
"canon": "0501234567", "e164": "+966501234567"}
|
||||
settled_fb = {"confirmed": False, "attempts": 2, "source": "fallback",
|
||||
"canon": "0501234567", "e164": "+966501234567"}
|
||||
# 等待态: 邮箱轻补被拦(旧测试此场景注入 cc 追问——该分支已删,现为空)
|
||||
note, marks = _contact_completion_note(
|
||||
{"联系方式": wa_bare, "_cc": {"whatsapp": due}}, wa_bare, {})
|
||||
assert note == "" and marks == []
|
||||
# 落定(fallback): 邮箱轻补解锁
|
||||
note, marks = _contact_completion_note(
|
||||
{"联系方式": wa_bare, "_cc": {"whatsapp": settled_fb}}, wa_bare, {})
|
||||
assert "email" in note and marks == ["_email_asked"]
|
||||
# 旧 _cc_asked 存量计数不再参与判定(停写停读,迁移由判定树钩子负责)
|
||||
note, marks = _contact_completion_note(
|
||||
{"联系方式": wa_bare, "_cc_asked": True}, wa_bare, {})
|
||||
assert "email" in note and marks == ["_email_asked"]
|
||||
# 带+号: 无 _cc 状态 → cc_settled(None)=True 不回归
|
||||
note, marks = _contact_completion_note({"联系方式": wa_full}, wa_full, {})
|
||||
assert "email" in note and marks == ["_email_asked"]
|
||||
# 已有邮箱 / _email_asked 已耗 / 无号码渠道: 不注入
|
||||
note, marks = _contact_completion_note({"联系方式": wa_email}, wa_email, {})
|
||||
assert note == "" and marks == []
|
||||
note, marks = _contact_completion_note(
|
||||
{"联系方式": wa_full, "_email_asked": True}, wa_full, {})
|
||||
assert note == "" and marks == []
|
||||
note, marks = _contact_completion_note(
|
||||
{"联系方式": {"email": "a@b.com"}}, {"email": "a@b.com"}, {})
|
||||
assert note == "" and marks == []
|
||||
note, marks = _contact_completion_note(
|
||||
{"联系方式": wa_bare, "_cc_asked": True, "_email_asked": True}, wa_bare, {})
|
||||
assert note == "" and marks == []
|
||||
# lang="zh": 注入中文版逐字模板句原文
|
||||
note, marks = _contact_completion_note(
|
||||
{"联系方式": wa_bare}, wa_bare, {}, lang="zh")
|
||||
assert "带国家代码的完整号码" in note and marks == ["_cc_asked"]
|
||||
note, marks = _contact_completion_note(
|
||||
{"联系方式": wa_full}, wa_full, {}, lang="zh")
|
||||
assert "邮箱也留一个呗" in note and marks == ["_email_asked"]
|
||||
|
||||
|
||||
def test_reminder_cc_round_confirmed_block_yields_to_cc_note():
|
||||
"""生产验收场景①修复(三轮终版): CC 追问轮 confirmed 块让位——同轮两个
|
||||
"逐字使用这句"竞争时 GLM 稳定服从更显眼的 NO_CONTACT_ALREADY 确认句,
|
||||
压制国家代码追问;让位段给唯一逐字锚点+禁数字回显(防自创确认句带号)。"""
|
||||
raw = {"联系方式": {"whatsapp": "0501234567"}} # 裸号 → CC 追问轮
|
||||
def test_reminder_cc_due_turn_email_note_blocked():
|
||||
"""cc 等待态轮: 确认句照常在场(旧"让位句"随 cc 轻补分支删除而拆除,
|
||||
回显确认语义由 Task 4 重建);邮箱轻补被 cc_settled 拦截;落定后恢复。"""
|
||||
due = {"confirmed": False, "attempts": 0, "source": "ip_proposed",
|
||||
"canon": "0501234567", "e164": "+966501234567"}
|
||||
settled_fb = {"confirmed": False, "attempts": 2, "source": "fallback",
|
||||
"canon": "0501234567", "e164": "+966501234567"}
|
||||
raw = {"联系方式": {"whatsapp": "0501234567"}, "_cc": {"whatsapp": due}}
|
||||
text = ConversationManager._build_context_reminder(raw, "yes", [])
|
||||
assert "Could you send the full number with the country code" in text # CC 句在场
|
||||
assert "Got it — our manager will reach out" not in text # 确认句让位(逐字建议消失)
|
||||
assert "本轮不要使用确认句" in text # 让位语义钉死
|
||||
assert "绝不写出号码数字" in text # 禁数字回显(三轮回归堵漏)
|
||||
assert "重发完整号一次" in text # confirmed 块禁令已扩例外
|
||||
# 反向钉死边界: 同画像换带前缀号(非 CC 轮) → 确认句回归,无让位段
|
||||
raw2 = {"联系方式": {"whatsapp": "+966501234567"}}
|
||||
assert "Got it — our manager will reach out" in text # 确认句在场(无让位)
|
||||
assert "本轮不要使用确认句" not in text # 旧让位句已拆
|
||||
assert "email address would help too" not in text # 邮箱轻补被 cc_settled 拦
|
||||
assert "重发完整号一次" in text # 禁令例外保留
|
||||
# 落定态: 邮箱轻补恢复注入
|
||||
raw2 = {"联系方式": {"whatsapp": "0501234567"},
|
||||
"_cc": {"whatsapp": settled_fb}}
|
||||
text2 = ConversationManager._build_context_reminder(raw2, "yes", [])
|
||||
assert "Got it — our manager will reach out" in text2
|
||||
assert "本轮不要使用确认句" not in text2
|
||||
assert "重发完整号一次" in text2 # 非CC轮禁令例外同样在场
|
||||
assert "email address would help too" in text2
|
||||
|
||||
|
||||
def test_inline_prohibitions_all_carry_reask_exception():
|
||||
@@ -991,9 +1004,14 @@ def test_inline_prohibitions_all_carry_reask_exception():
|
||||
|
||||
|
||||
def test_contact_completion_two_round_serial_and_persistence(async_db, monkeypatch):
|
||||
"""spec §6-6 计数持久化集成: 裸号两轮——第一轮 cc 追问注入且 _cc_asked 落库;
|
||||
第二轮客户没配合(号码仍裸),按 §4.3b 串行规则转邮箱轻补并落 _email_asked。
|
||||
防"写回失效退化为每轮都问"与"写了计数没拼进 reminder"两类事故。"""
|
||||
"""spec §6-1/§6-2 集成: 裸号轮当轮 _cc 已建且落库(轮前钩子实证——墙
|
||||
触发依赖它);确认流未落定期间邮箱轻补被拦;无回应两轮 attempts 耗尽
|
||||
落定 fallback 后,串行转邮箱轻补并落 _email_asked。"""
|
||||
from app.services import ip_service
|
||||
from app.services.conversation import _CC_WALL_EN
|
||||
|
||||
monkeypatch.setattr(ip_service, "resolve_region_code", lambda ip: "SA")
|
||||
|
||||
class CtxAI(FakeAI):
|
||||
def __init__(self, extract):
|
||||
super().__init__(extract=extract)
|
||||
@@ -1008,30 +1026,289 @@ def test_contact_completion_two_round_serial_and_persistence(async_db, monkeypat
|
||||
(manager, conv), _ = _setup(async_db, monkeypatch, ai=ai,
|
||||
source=ConversationSource.standalone)
|
||||
asyncio.run(_send(manager, conv.session_id, "my whatsapp is 0501234567"))
|
||||
# 第一轮正向断言: cc 追问句原文确实拼进了 reminder(非仅写计数)
|
||||
assert "Could you send the full number with the country code" in ai.contexts[0]
|
||||
assert "email address would help too" not in ai.contexts[0] # 串行: cc 优先
|
||||
assert "Got it — our manager will reach out" not in ai.contexts[0] # 确认句让位(端到端)
|
||||
# 第一轮正向断言(轮前钩子实证): _cc 当轮已建并落库,墙靠它触发替换。
|
||||
# attempts 语义(Task 3 调参钉死): 追问轮自身在轮末即消耗一轮——
|
||||
# 轮前 attempts=0 建态 → 墙问 → 轮末 transit(None) → attempts=1;
|
||||
# 常规路径客户总共恰被问 2 次(第 1/2 轮),第 3 轮起轻补解锁
|
||||
profile = asyncio.run(manager._get_profile(conv.id))
|
||||
assert (profile.raw_info or {}).get("_cc_asked") is True # 计数已落库
|
||||
# 第二轮: 号码仍裸 → cc 不再问,串行规则转邮箱轻补(_cc_asked 已消耗)
|
||||
wa_cc = (profile.raw_info or {}).get("_cc", {}).get("whatsapp")
|
||||
assert wa_cc == {"confirmed": False, "attempts": 1, "source": "ip_proposed",
|
||||
"canon": "0501234567", "e164": "+966501234567",
|
||||
"intl": "+966 50 123 4567"}
|
||||
tails = [m["content"] for m in asyncio.run(manager._load_history(conv.id))
|
||||
if m["role"] == "assistant"]
|
||||
assert tails[-1] == _CC_WALL_EN # cc_due → 墙触发轮
|
||||
assert "email address would help too" not in ai.contexts[0] # 未落定: 轻补被拦
|
||||
assert (profile.raw_info or {}).get("_cc_asked") is None # 旧计数停写
|
||||
# 第二轮无回应: 墙再问一次(1<2 仍 due),轮末 attempts=2 达上限落定 fallback
|
||||
asyncio.run(_send(manager, conv.session_id, "so how much is the pvc pipe?"))
|
||||
assert "Could you send the full number" not in ai.contexts[1]
|
||||
assert "email address would help too" in ai.contexts[1]
|
||||
tails = [m["content"] for m in asyncio.run(manager._load_history(conv.id))
|
||||
if m["role"] == "assistant"]
|
||||
assert tails[-1] == _CC_WALL_EN # 第 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 "email address would help too" not in ai.contexts[1]
|
||||
# 第三轮: 已落定 → 墙关闭、确认句让位、邮箱轻补串行解锁并落计数
|
||||
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] == "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]
|
||||
profile = asyncio.run(manager._get_profile(conv.id))
|
||||
assert (profile.raw_info or {}).get("_email_asked") is True # 计数已落库
|
||||
asyncio.run(manager.db.close())
|
||||
|
||||
|
||||
# ---- Task 3 Step 4: 判定树管道集成测试(spec §6-1/§6-2 全景) ----
|
||||
# 共用捕获桩: 记录每轮 stream context(轻补注入判定)与 extractor 入参
|
||||
# (existing_info 的 `_` 过滤实证)。
|
||||
class _RecAI(FakeAI):
|
||||
def __init__(self, extract):
|
||||
super().__init__(extract=extract)
|
||||
self.contexts = []
|
||||
self.existing_seen = []
|
||||
|
||||
async def stream_chat(self, messages, context=""):
|
||||
self.contexts.append(context)
|
||||
async for t in super().stream_chat(messages, context):
|
||||
yield t
|
||||
|
||||
async def extract_info(self, messages, existing_info):
|
||||
self.existing_seen.append(dict(existing_info))
|
||||
return await super().extract_info(messages, existing_info)
|
||||
|
||||
|
||||
def _sa(monkeypatch):
|
||||
from app.services import ip_service
|
||||
monkeypatch.setattr(ip_service, "resolve_region_code", lambda ip: "SA")
|
||||
|
||||
|
||||
def test_cc_reply_affirm_confirms_and_unlocks_email(async_db, monkeypatch):
|
||||
"""cc_reply affirm → confirmed/ip_confirm 落定;次轮邮箱轻补解锁。"""
|
||||
from app.services.conversation import _CC_WALL_EN
|
||||
_sa(monkeypatch)
|
||||
ai = _RecAI(extract=[{}, {"cc_reply": {"affirm": True, "country": None}}, {}])
|
||||
(manager, conv), _ = _setup(async_db, monkeypatch, ai=ai,
|
||||
source=ConversationSource.standalone)
|
||||
asyncio.run(_send(manager, conv.session_id, "my whatsapp is 0501234567"))
|
||||
asyncio.run(_send(manager, conv.session_id, "yes that's right"))
|
||||
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
|
||||
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] # 落定后解锁
|
||||
profile = asyncio.run(manager._get_profile(conv.id))
|
||||
assert (profile.raw_info or {}).get("_email_asked") is True
|
||||
asyncio.run(manager.db.close())
|
||||
|
||||
|
||||
def test_cc_canon_change_resets_and_rewalks(async_db, monkeypatch):
|
||||
"""换号(canon 变化)→ 旧状态 RESET,新号重走判定树(attempts 归零);
|
||||
extractor 镜像的同 canon 手机号被组归并(不建独立 phone 键)。"""
|
||||
from app.services.cc_completion import classify
|
||||
_sa(monkeypatch)
|
||||
ai = _RecAI(extract=[{},
|
||||
{"联系方式": {"whatsapp": "0512345678",
|
||||
"手机": "0512345678"}}])
|
||||
(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, "my new whatsapp is 0512345678"))
|
||||
profile = asyncio.run(manager._get_profile(conv.id))
|
||||
cc = (profile.raw_info or {}).get("_cc") or {}
|
||||
wa = cc.get("whatsapp")
|
||||
exp = classify("0512345678", "SA")[1]
|
||||
assert wa["canon"] == "0512345678" # 旧 canon 状态已被替换
|
||||
assert wa["source"] == "ip_proposed" and wa["attempts"] == 1 # 重走+本轮已耗1
|
||||
assert wa["e164"] == exp["e164"] # 建议随新号重算
|
||||
assert set(cc) == {"whatsapp"} # 同 canon 手机镜像归并进 whatsapp 组
|
||||
asyncio.run(manager.db.close())
|
||||
|
||||
|
||||
def test_cc_late_country_upgrade_on_fallback(async_db, monkeypatch):
|
||||
"""落定 fallback 后客户主动报国 → 受控升级: e164 重算、信息不丢,
|
||||
但不再回显追问(attempts 已过上限,保持落定)。"""
|
||||
_sa(monkeypatch)
|
||||
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 am based in Egypt"))
|
||||
wa = (asyncio.run(manager._get_profile(conv.id))
|
||||
.raw_info or {}).get("_cc", {}).get("whatsapp")
|
||||
assert wa["e164"] == "+20501234567" # 按报国(EG=+20)重算
|
||||
assert wa["source"] == "fallback" and wa["confirmed"] is False
|
||||
assert wa["attempts"] == 3 # 升级不重置计数,保持落定
|
||||
tails = [m["content"] for m in asyncio.run(manager._load_history(conv.id))
|
||||
if m["role"] == "assistant"]
|
||||
assert tails[-1] == "ok reply" # 不再回显(墙关闭,AI 原文放行)
|
||||
asyncio.run(manager.db.close())
|
||||
|
||||
|
||||
def test_cc_late_country_on_no_ip_exhausted_ignored(async_db, monkeypatch):
|
||||
"""no_ip 态一轮耗尽后收到迟到报国 → 忽略(该口仅对有建议国的
|
||||
落定态开放;no_ip 耗尽是设计明确的静默终态)。"""
|
||||
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, "I live in Egypt actually"))
|
||||
wa = (asyncio.run(manager._get_profile(conv.id))
|
||||
.raw_info or {}).get("_cc", {}).get("whatsapp")
|
||||
assert wa["source"] == "no_ip" and wa["attempts"] == 1
|
||||
assert "e164" not in wa # 迟到报国未触发重算
|
||||
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 回显覆盖不了。"""
|
||||
_sa(monkeypatch)
|
||||
poison = {"联系方式": {"whatsapp": "0501234567"},
|
||||
"cc_reply": {"affirm": False, "country": "Egypt"},
|
||||
"_cc": {"whatsapp": {"hacked": True}}, "_evil": 1}
|
||||
ai = _RecAI(extract=[poison])
|
||||
(manager, conv), _ = _setup(async_db, monkeypatch, ai=ai,
|
||||
source=ConversationSource.standalone)
|
||||
asyncio.run(_send(manager, conv.session_id, "my whatsapp is 0501234567"))
|
||||
# ① 入口侧: 轮前已落库的 _cc 不进 LLM 输入
|
||||
assert ai.existing_seen == [{}]
|
||||
profile = asyncio.run(manager._get_profile(conv.id))
|
||||
raw = profile.raw_info or {}
|
||||
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["e164"] == "+20501234567"
|
||||
asyncio.run(manager.db.close())
|
||||
|
||||
|
||||
def test_legacy_cc_asked_migrates_to_fallback(async_db, monkeypatch):
|
||||
"""存量迁移(spec §4 尾): 旧 `_cc_asked=True` + 裸 whatsapp 无 `_cc` →
|
||||
下一轮一次性落 fallback 态(不再追问、墙不触发、邮箱轻补直通)。"""
|
||||
_sa(monkeypatch)
|
||||
ai = _RecAI(extract=[{}, {}])
|
||||
(manager, conv), _ = _setup(async_db, monkeypatch, ai=ai,
|
||||
source=ConversationSource.standalone)
|
||||
|
||||
async def seed():
|
||||
profile = await manager._get_profile(conv.id)
|
||||
profile.raw_info = {"_cc_asked": True,
|
||||
"联系方式": {"whatsapp": "0501234567"}}
|
||||
await manager.db.commit()
|
||||
asyncio.run(seed())
|
||||
asyncio.run(_send(manager, conv.session_id, "so how about that pvc pipe?"))
|
||||
profile = asyncio.run(manager._get_profile(conv.id))
|
||||
wa = (profile.raw_info or {}).get("_cc", {}).get("whatsapp")
|
||||
assert wa == {"confirmed": False, "attempts": 0, "source": "fallback",
|
||||
"canon": "0501234567", "e164": "+966501234567",
|
||||
"intl": "+966 50 123 4567"} # e164 建议保留(首封可拨)
|
||||
tails = [m["content"] for m in asyncio.run(manager._load_history(conv.id))
|
||||
if m["role"] == "assistant"]
|
||||
assert tails[-1] == "ok reply" # 墙未触发(已问过不再问)
|
||||
assert "email address would help too" in ai.contexts[0] # 轻补直通
|
||||
assert (profile.raw_info or {}).get("_email_asked") is True
|
||||
asyncio.run(manager.db.close())
|
||||
|
||||
|
||||
def test_cc_reply_on_confirmed_state_ignored(async_db, monkeypatch):
|
||||
"""双门控: confirmed 落定态收到 cc_reply(含报国) → 一律忽略——
|
||||
客户改主意请重发完整号走 canon 重置,确认流不逆向回滚。"""
|
||||
_sa(monkeypatch)
|
||||
ai = _RecAI(extract=[{}, {"cc_reply": {"affirm": True, "country": None}},
|
||||
{"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, "yes that's right"))
|
||||
asyncio.run(_send(manager, conv.session_id, "no wait, I am in Egypt"))
|
||||
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 # 原样未动
|
||||
asyncio.run(manager.db.close())
|
||||
|
||||
|
||||
def test_cc_same_canon_group_merges_phone_into_whatsapp(async_db, monkeypatch):
|
||||
"""canon 组归并: phone 与 whatsapp 同号 → 只建 whatsapp 组状态
|
||||
(确认一次,attempts 以组为计数主体),_cc 无独立 phone 键。"""
|
||||
_sa(monkeypatch)
|
||||
ai = _RecAI(extract=[{}])
|
||||
(manager, conv), _ = _setup(async_db, monkeypatch, ai=ai,
|
||||
source=ConversationSource.standalone)
|
||||
asyncio.run(_send(manager, conv.session_id,
|
||||
"my phone is 0501234567 and whatsapp 0501234567"))
|
||||
profile = asyncio.run(manager._get_profile(conv.id))
|
||||
cc = (profile.raw_info or {}).get("_cc") or {}
|
||||
assert set(cc) == {"whatsapp"}
|
||||
assert cc["whatsapp"]["source"] == "ip_proposed"
|
||||
assert cc["whatsapp"]["canon"] == "0501234567"
|
||||
asyncio.run(manager.db.close())
|
||||
|
||||
|
||||
def test_cc_different_number_phone_silent_state(async_db, monkeypatch):
|
||||
"""异号 phone: 只记静默补全态(naive,不进确认流)——墙门控与邮箱
|
||||
轻补串行只看 whatsapp 组;phone 态仅供出站渲染(Task 6)。"""
|
||||
from app.services.conversation import _CC_WALL_EN
|
||||
_sa(monkeypatch)
|
||||
ai = _RecAI(extract=[{}, {}])
|
||||
(manager, conv), _ = _setup(async_db, monkeypatch, ai=ai,
|
||||
source=ConversationSource.standalone)
|
||||
asyncio.run(_send(manager, conv.session_id,
|
||||
"my phone is 13800138000, whatsapp 0501234567"))
|
||||
profile = asyncio.run(manager._get_profile(conv.id))
|
||||
cc = (profile.raw_info or {}).get("_cc") or {}
|
||||
assert set(cc) == {"whatsapp", "phone"}
|
||||
assert cc["whatsapp"]["source"] == "ip_proposed" # whatsapp 走确认流
|
||||
assert cc["phone"]["source"] == "naive" # phone 静默补全
|
||||
assert cc["phone"]["e164"] == "+96613800138000"
|
||||
tails = [m["content"] for m in asyncio.run(manager._load_history(conv.id))
|
||||
if m["role"] == "assistant"]
|
||||
assert tails[-1] == _CC_WALL_EN # 墙看 whatsapp(due)
|
||||
assert "email address would help too" not in ai.contexts[0] # 轻补被拦
|
||||
asyncio.run(manager.db.close())
|
||||
|
||||
|
||||
def test_phone_only_customer_no_wall_email_same_turn(async_db, monkeypatch):
|
||||
"""纯 phone 客户: 无 whatsapp 组 → 墙永不触发,邮箱轻补当轮直通
|
||||
(cc_settled(None)=True——多渠道策略不让 phone 确认流阻塞轻补)。"""
|
||||
_sa(monkeypatch)
|
||||
ai = _RecAI(extract=[{}, {}])
|
||||
(manager, conv), _ = _setup(async_db, monkeypatch, ai=ai,
|
||||
source=ConversationSource.standalone)
|
||||
asyncio.run(_send(manager, conv.session_id, "my phone is 13800138000"))
|
||||
profile = asyncio.run(manager._get_profile(conv.id))
|
||||
cc = (profile.raw_info or {}).get("_cc") or {}
|
||||
assert set(cc) == {"phone"} and cc["phone"]["source"] == "naive"
|
||||
tails = [m["content"] for m in asyncio.run(manager._load_history(conv.id))
|
||||
if m["role"] == "assistant"]
|
||||
assert tails[-1] == "ok reply" # 无墙
|
||||
assert "email address would help too" in ai.contexts[0] # 当轮轻补
|
||||
assert (profile.raw_info or {}).get("_email_asked") is True
|
||||
asyncio.run(manager.db.close())
|
||||
|
||||
|
||||
def test_contact_completion_marks_filtered_from_push(async_db, monkeypatch):
|
||||
"""Task 2 过滤 × Task 3 真实计数键: 流程写入的 _cc_asked 穿过 dispatch 后,
|
||||
"""Task 2 过滤 × Task 3 真实内部键: 流程写入的 _cc 状态机穿过 dispatch 后,
|
||||
推送 payload 画像无任何 _ 前缀键(不泄漏内部状态进生产 CRM)。"""
|
||||
from app.services import lead_push_service as svc
|
||||
|
||||
(manager, conv), _ = _setup(async_db, monkeypatch, ai=FakeAI(extract={}))
|
||||
asyncio.run(_send(manager, conv.session_id, "my whatsapp is 0501234567"))
|
||||
profile = asyncio.run(manager._get_profile(conv.id))
|
||||
assert (profile.raw_info or {}).get("_cc_asked") is True # 前提: 计数确已落库
|
||||
cc_state = (profile.raw_info or {}).get("_cc", {}).get("whatsapp")
|
||||
assert cc_state and cc_state["source"] == "no_ip" # 前提: 判定树确已建状态
|
||||
|
||||
# 流程跑完才开推送开关——避免 fire_pending_pushes 在流程内派生后台 HTTP 任务
|
||||
monkeypatch.setattr(svc.settings, "PUSH_ENABLED", True)
|
||||
@@ -1070,7 +1347,7 @@ _CONFIRM_REPLY = "Sure! Got it — our manager will reach out to you shortly."
|
||||
|
||||
def test_cc_wall_replaces_confirmation_reply(async_db, monkeypatch):
|
||||
"""触发轮 + 纯确认句 → 客户所见/落库均为 CC 模板句正文,原句零残留;
|
||||
_cc_asked 计数照常落库(一次性消耗不受替换影响)。"""
|
||||
_cc 状态照常落库(替换不影响状态机)。"""
|
||||
from app.services.conversation import _CC_WALL_EN
|
||||
(manager, conv), _ = _setup(async_db, monkeypatch, ai=_ConfirmAI(extract={}),
|
||||
source=ConversationSource.standalone)
|
||||
@@ -1090,7 +1367,8 @@ def test_cc_wall_replaces_confirmation_reply(async_db, monkeypatch):
|
||||
tail = [m["content"] for m in msgs if m["role"] == "assistant"][-1]
|
||||
assert tail == _CC_WALL_EN # 落库与流出一致
|
||||
profile = asyncio.run(manager._get_profile(conv.id))
|
||||
assert (profile.raw_info or {}).get("_cc_asked") is True # 替换不吞计数
|
||||
cc_state = (profile.raw_info or {}).get("_cc", {}).get("whatsapp")
|
||||
assert cc_state and cc_state["source"] == "no_ip" # 替换不吞判定树状态
|
||||
asyncio.run(manager.db.close())
|
||||
|
||||
|
||||
@@ -1137,7 +1415,7 @@ def test_cc_wall_passes_through_chinese_asking_reply(async_db, monkeypatch):
|
||||
|
||||
|
||||
def test_cc_wall_inactive_on_non_trigger_turns(async_db, monkeypatch):
|
||||
"""非触发轮(带前缀号/_cc_asked 已耗/双渠道齐)→ 确认句原样流出。"""
|
||||
"""非触发轮(带前缀号/确认流已落定/双渠道齐)→ 确认句原样流出。"""
|
||||
from app.services.conversation import _CC_WALL_EN
|
||||
# (a) 带前缀号: 首轮即留 +966… → 邮箱轻补轮,非 CC 触发
|
||||
(m1, c1), _ = _setup(async_db, monkeypatch, ai=_ConfirmAI(extract={}),
|
||||
@@ -1147,7 +1425,8 @@ 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) _cc_asked 已耗: 首轮裸号(CC 轮被替换),次轮号码仍裸 → 邮箱轻补轮
|
||||
# (b) no_ip 态一轮耗尽落定: 首轮裸号(CC 轮被替换),次轮号码仍裸 →
|
||||
# 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"))
|
||||
@@ -1248,7 +1527,8 @@ def test_cc_wall_no_contact_violation_uses_cc_template(async_db, monkeypatch):
|
||||
assert "0501234567" not in tail # 回显零残留(10 位穿透已修)
|
||||
assert tail == _CC_WALL_EN # closing=CC 追问句,非"稍后联系"
|
||||
profile = asyncio.run(manager._get_profile(conv.id))
|
||||
assert (profile.raw_info or {}).get("_cc_asked") is True # 计数不受影响
|
||||
cc_state = (profile.raw_info or {}).get("_cc", {}).get("whatsapp")
|
||||
assert cc_state and cc_state["source"] == "no_ip" # 状态机不受墙替换影响
|
||||
asyncio.run(manager.db.close())
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user