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:
@@ -106,8 +106,13 @@ EMAIL_RE = re.compile(r"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}")
|
||||
INTL_PHONE_RE = re.compile(r"\+\d{6,15}")
|
||||
# 邻接绑定:上下文词与数字必须在同一匹配内(仅空白衔接)——禁止"消息任意位置
|
||||
# 有上下文词 + 任意位置有 7 位数字"的消息级误绑(S1:单号/订单号/先令价格)
|
||||
# 间隙字符类排除 "+"(2026-10-08 cc 判定树 Task 3 集成实证): 原 \D{0,12} 会把
|
||||
# 客户显式写的 "+" 吞进间隙("whatsapp is +966…" 捕成裸 "966…"),下游
|
||||
# cc_completion.classify 误判 NEED_CC 对已给全号的客户追问区号——"+" 是
|
||||
# classify a 分支(显式国际前缀→HAS_CC)的唯一判据,必须在捕获里存活。
|
||||
WHATSAPP_CTX_RE = re.compile(
|
||||
r"(?:whats\s*app|wa\.?me|(?:^|\b)wa\s*[::])\D{0,12}(\+?\d[\d\s\-().]{5,17}\d)",
|
||||
r"(?:whats\s*app|wa\.?me|(?:^|\b)wa\s*[::])[^+\d]{0,12}"
|
||||
r"(\+?\d[\d\s\-().]{5,17}\d)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
PHONE_CTX_RE = re.compile(
|
||||
|
||||
+218
-67
@@ -12,7 +12,9 @@ from app.models.database import (
|
||||
Message, MessageRole, CustomerProfile, Evaluation, IntentLevel,
|
||||
LeadPushLog,
|
||||
)
|
||||
from app.services import email_service, lead_push_service
|
||||
from app.services import (
|
||||
cc_completion, email_service, ip_service, lead_push_service,
|
||||
)
|
||||
from app.services.ai_service import AIService
|
||||
from app.services.boundary_filter import (
|
||||
NO_CONTACT_ALREADY, NO_CONTACT_APP, NO_CONTACT_WEB, asks_contact,
|
||||
@@ -20,8 +22,8 @@ from app.services.boundary_filter import (
|
||||
violates_boundary,
|
||||
)
|
||||
from app.services.contact_pipeline import (
|
||||
canon, latest_stated_contacts, merge_contacts, normalize_contacts,
|
||||
resolve_by_recency, scan_user_messages, split_validate,
|
||||
canon, digits_only, latest_stated_contacts, merge_contacts,
|
||||
normalize_contacts, resolve_by_recency, scan_user_messages, split_validate,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -98,9 +100,11 @@ def _lang_note_for(msg: str) -> str:
|
||||
# Task 3(spec §4.1-2/§4.4, 2026-10-08): 缺失渠道轻补逐字模板——与 system.md
|
||||
# 联系方式引导策略节同源。注入的是"缺失渠道"(客户没给过的),与再索要墙的
|
||||
# 渠道精确化(Task 1)配套: 墙只拦已在手渠道的索要,这些话术恰好问缺失渠道。
|
||||
# CC 强制墙(2026-10-08 增补设计 §2)复用同源正文: _CC_ASK_* 是注入指引里
|
||||
# "请逐字使用这句"的正文;_CC_WALL_* 是墙的整段替换句(EN 加 "Got it — "
|
||||
# 衔接头,设计 §2 定稿文案;ZH 与正文同句)——两处逐字锚点不漂移。
|
||||
# CC 强制墙(2026-10-08 增补设计 §2)复用同源正文: _CC_ASK_* 是追问句正文;
|
||||
# _CC_WALL_* 是墙的整段替换句(EN 加 "Got it — " 衔接头;ZH 与正文同句)。
|
||||
# 2026-10-08 判定树设计 Task 3: cc 轻补注入分支已删(判定树接管),_CC_NOTE_*
|
||||
# 不再运行时注入——常量保留作 no_ip 路径/Task 4 回显模板的正文基准
|
||||
# (test_boundary_filter / test_system_prompt_dual_contact 仍锚定其正文)。
|
||||
_CC_ASK_EN = (
|
||||
"Could you send the full number with the country code, "
|
||||
"like +966 5x xxx xxxx? Just so our manager can reach you directly.")
|
||||
@@ -132,15 +136,14 @@ def _contact_completion_note(
|
||||
raw_info: dict | None, confirmed: dict | None, suspect: dict | None,
|
||||
lang: str = "en",
|
||||
) -> tuple[str, list[str]]:
|
||||
"""一次性缺失渠道轻补提醒(spec §4.2/§4.3/§4.3b)。纯函数,返回(注入文案,计数键)。
|
||||
"""邮箱轻补提醒(spec §4.2/§4.3b,2026-10-08 判定树设计收编)。
|
||||
纯函数,返回(注入文案,计数键)。
|
||||
|
||||
串行优先级(§4.3b——两提醒同轮并发时一次只注入一个,顺序固定):
|
||||
1. cc 优先: confirmed 号码渠道(whatsapp/phone)均有值且均不以 +/00 开头、
|
||||
_cc_asked 未标记 → 国家代码追问(引导重发完整号,走改号覆盖机制,
|
||||
管道零改动)
|
||||
2. email 轻补: (任一号码已带前缀 或 _cc_asked 已消耗)且 confirmed 含号码
|
||||
渠道、不含 email、_email_asked 未标记 → 邮箱轻补
|
||||
3. 其余(双渠道齐/仅 email/两计数均耗尽/confirmed 无号码渠道) → ("", [])
|
||||
cc 追问分支已删除——号码区号补全由轮前判定树钩子接管(_cc 状态机,
|
||||
_cc_pre_turn),确认提醒由 cc_due 驱动(Task 4 接)。本函数只管邮箱轻补,
|
||||
且串行解锁条件改为 cc_settled(_cc.whatsapp): 确认流未落定时注入邮箱
|
||||
轻补会让两个"逐字使用这句"同轮竞争(旧 §4.3b 场景①教训);落定即解锁。
|
||||
带 + 号客户无 _cc 状态 → cc_settled(None)=True,旧轻补行为不回归。
|
||||
suspect 仅为与调用处画像判定同签名,规则只看 confirmed(spec §4.2)。
|
||||
"""
|
||||
info = raw_info or {}
|
||||
@@ -148,14 +151,74 @@ def _contact_completion_note(
|
||||
if k in ("whatsapp", "phone") and v]
|
||||
if not numbers:
|
||||
return "", []
|
||||
all_bare = all(not v.startswith(("+", "00")) for v in numbers)
|
||||
if all_bare and not info.get("_cc_asked"):
|
||||
return (_CC_NOTE_ZH if lang == "zh" else _CC_NOTE_EN), ["_cc_asked"]
|
||||
if (not all_bare or info.get("_cc_asked")) \
|
||||
and "email" not in (confirmed or {}) \
|
||||
and not info.get("_email_asked"):
|
||||
return (_EMAIL_NOTE_ZH if lang == "zh" else _EMAIL_NOTE_EN), ["_email_asked"]
|
||||
return "", []
|
||||
if "email" in (confirmed or {}):
|
||||
return "", []
|
||||
if info.get("_email_asked"):
|
||||
return "", []
|
||||
if not cc_completion.cc_settled((info.get("_cc") or {}).get("whatsapp")):
|
||||
return "", [] # whatsapp 确认流未落定: 邮箱轻补串行等待
|
||||
return (_EMAIL_NOTE_ZH if lang == "zh" else _EMAIL_NOTE_EN), ["_email_asked"]
|
||||
|
||||
|
||||
def _cc_pre_turn(raw_info: dict, confirmed: dict, ip_iso: str | None) -> bool:
|
||||
"""轮前判定树钩子(spec §6-1/§3/§4): 对 confirmed 号码建/重置/迁移 _cc。
|
||||
|
||||
- 多渠道策略: 仅 whatsapp 组走确认流;phone 与 whatsapp 同 canon 时共享
|
||||
whatsapp 组状态(不建独立键),异号时 phone 组只记静默补全态
|
||||
(ip_proposed/naive/no_ip,不进确认流)——确认流的消费与门控
|
||||
(cc_due/cc_settled/轮末 transit)一律只看 _cc.whatsapp,phone 态
|
||||
仅供出站渲染(Task 6)。
|
||||
- canon 变化即重置重走判定树(旧补全永不跟随新号);同 canon 保留原状态
|
||||
(attempts 只被 canon 变化重置,spec §4)。
|
||||
- 显式 +/00 号码不建状态(无态=天然落定);prefix_normalized 静默规范化
|
||||
态(confirmed=true)照写,供出站渲染无标注输出。
|
||||
- 存量迁移(spec §4 尾): 旧 _cc_asked=True 且 whatsapp 裸号尚无 _cc 组 →
|
||||
一次性落 fallback 态(已问过不再追问,直通邮箱轻补);判定树算出的
|
||||
e164 建议保留(首封可拨)。_cc 组一旦存在即不再触发。
|
||||
就地更新 raw_info["_cc"],返回是否有变更(调用方与计数写回同事务落库)。
|
||||
"""
|
||||
before = raw_info.get("_cc")
|
||||
cc = dict(before or {})
|
||||
migrate = bool(raw_info.get("_cc_asked"))
|
||||
wa = str(confirmed.get("whatsapp") or "").strip()
|
||||
ph = str(confirmed.get("phone") or "").strip()
|
||||
|
||||
def _refresh(channel: str, value: str) -> None:
|
||||
existing = cc.get(channel)
|
||||
if existing is not None \
|
||||
and str(existing.get("canon") or "") == digits_only(value):
|
||||
return # 同 canon: 保留原状态(attempts 不重置)
|
||||
kind, state = cc_completion.classify(value, ip_iso)
|
||||
if state is None:
|
||||
cc.pop(channel, None) # 显式 +/00(或防御性垃圾输入): 无态即落定
|
||||
return
|
||||
st = dict(state)
|
||||
if (migrate and channel == "whatsapp" and existing is None
|
||||
and kind == cc_completion.NEED_CC):
|
||||
st["confirmed"] = False
|
||||
st["source"] = "fallback" # 存量迁移: 已问过,不再追问
|
||||
st.pop("suggest_ask_full", None)
|
||||
cc[channel] = st
|
||||
|
||||
if wa:
|
||||
_refresh("whatsapp", wa)
|
||||
else:
|
||||
cc.pop("whatsapp", None) # 号码不再在手: 状态清,防残留跟随
|
||||
if ph:
|
||||
wa_state = cc.get("whatsapp")
|
||||
if wa_state is not None \
|
||||
and str(wa_state.get("canon") or "") == digits_only(ph):
|
||||
cc.pop("phone", None) # 同 canon 组归并: phone 共享 whatsapp 状态
|
||||
else:
|
||||
_refresh("phone", ph)
|
||||
else:
|
||||
cc.pop("phone", None)
|
||||
|
||||
if cc:
|
||||
raw_info["_cc"] = cc
|
||||
else:
|
||||
raw_info.pop("_cc", None)
|
||||
return raw_info.get("_cc") != before
|
||||
|
||||
|
||||
# 方案B(13.6): 迂回话术轮换序号——连续破防时不至于复读同一句
|
||||
@@ -247,40 +310,64 @@ class ConversationManager:
|
||||
profile = await self._get_profile(conv.id)
|
||||
|
||||
# P0 同轮竞态消除: 当前消息即时正则扫描——仅并入本轮内存视图
|
||||
# (提醒/话术路由),不落库;持久化仍由轮末 extractor+scanner 负责,
|
||||
# 邮件触发时序不变。
|
||||
# (提醒/话术路由/轮前 cc 判定树),不落库;持久化仍由轮末
|
||||
# extractor+scanner 负责,邮件触发时序不变。
|
||||
# 合并方向(Task 3 修正): 当前消息捕获覆盖存量——客户当轮改号时,
|
||||
# 同轮视图必须看到新号(轮前判定树 canon 变化即重置依赖此方向;
|
||||
# 与轮末 Task 12 "最新显式自述优先"同语义)。存量只在当前消息
|
||||
# 未提及的渠道上补位。
|
||||
current_scan = scan_user_messages([{"role": "user", "content": content}])
|
||||
effective_raw = dict(profile.raw_info or {})
|
||||
if current_scan:
|
||||
effective_raw["联系方式"] = merge_contacts(
|
||||
current_scan, effective_raw.get("联系方式"))
|
||||
effective_raw.get("联系方式"), current_scan)
|
||||
|
||||
# 联系方式视图(轮前钩子/计数写回/再索要墙区共用;原在 reminder 之后
|
||||
# 计算,前移只为喂轮前钩子,输入相同取值不变)
|
||||
_contacts_now = normalize_contacts(effective_raw.get("联系方式"))
|
||||
_confirmed_now, _suspect_now = split_validate(_contacts_now)
|
||||
|
||||
# Task 3 轮前判定树钩子(spec §6-1;评审 Step 3b 钉死顺序: 必须在
|
||||
# _build_context_reminder 之前)——即时扫描并入裸号后同步跑判定树
|
||||
# (纯计算毫秒级)建/重置/迁移 _cc,当轮即可被墙门控/轮末消费/出站
|
||||
# 渲染读到;落库与下方一次性计数写回同事务。
|
||||
_ip_iso = ip_service.resolve_region_code(
|
||||
(conv.client_info or {}).get("ip"))
|
||||
_cc_changed = _cc_pre_turn(effective_raw, _confirmed_now, _ip_iso)
|
||||
_wa_cc = (effective_raw.get("_cc") or {}).get("whatsapp")
|
||||
|
||||
context_reminder = self._build_context_reminder(
|
||||
effective_raw, content, history, source=conv.source.value)
|
||||
if CONTACT_ASK_TRIGGER_RE.search(content or ""):
|
||||
logger.info("Contact-ask reminder fired (source=%s)", conv.source.value)
|
||||
|
||||
# Task 3(spec §4.3): 一次性计数写回——_build_context_reminder 是
|
||||
# staticmethod 收内存副本,计数键须由调用方在流式开始前显式落库;
|
||||
# 轮末 extractor 合并起点是 dict(profile.raw_info or {}),提前提交可存活。
|
||||
# 与 reminder 内部同参调用(上方纯函数),保证写回与注入永不漂移。
|
||||
_contacts_now = normalize_contacts(effective_raw.get("联系方式"))
|
||||
_confirmed_now, _suspect_now = split_validate(_contacts_now)
|
||||
# Task 3(spec §4.3/§6-1): 一次性计数写回 + _cc 状态同事务落库——
|
||||
# _build_context_reminder 是 staticmethod 收内存副本,计数键/状态须由
|
||||
# 调用方在流式开始前显式落库;轮末 extractor 合并起点是
|
||||
# dict(profile.raw_info or {}),提前提交可存活。与 reminder 内部同参
|
||||
# 调用(上方纯函数),保证写回与注入永不漂移;条件扩为"计数或 _cc
|
||||
# 变更任一存在"(评审 Step 3b)。
|
||||
_, completion_marks = _contact_completion_note(
|
||||
effective_raw, _confirmed_now, _suspect_now,
|
||||
"zh" if _CJK_RE.search(content or "") else "en")
|
||||
if completion_marks:
|
||||
if completion_marks or _cc_changed:
|
||||
raw = dict(profile.raw_info or {})
|
||||
for key in completion_marks:
|
||||
raw[key] = True
|
||||
if _cc_changed:
|
||||
if effective_raw.get("_cc"):
|
||||
raw["_cc"] = effective_raw["_cc"]
|
||||
else:
|
||||
raw.pop("_cc", None)
|
||||
profile.raw_info = raw
|
||||
self.db.add(profile)
|
||||
await self.db.commit()
|
||||
# warning 级(2026-10-08 第四轮验收): 生产曾现 2/5 裸号轮墙未介入的
|
||||
# 非确定性异常(同输入容器内链路复现正常)——本行生产可见,异常会话
|
||||
# 可立刻分辨"计数/注入是否发生",供观察期定位
|
||||
logger.warning("Contact completion note injected (conv=%s): %s",
|
||||
conv.id, completion_marks)
|
||||
# 可立刻分辨"计数/状态/注入是否发生",供观察期定位
|
||||
logger.warning("Contact completion state persisted (conv=%s): "
|
||||
"marks=%s cc_changed=%s",
|
||||
conv.id, completion_marks, _cc_changed)
|
||||
|
||||
# Enrich short user messages with context so AI doesn't lose track
|
||||
history_for_ai = list(history)
|
||||
@@ -297,11 +384,14 @@ class ConversationManager:
|
||||
# (confirmed/suspect 已在 Task 3 写回处算好,此处复用)
|
||||
has_contact = bool(_confirmed_now)
|
||||
_confirmed_channels = set(_confirmed_now)
|
||||
# CC 强制墙(2026-10-08 增补设计 §2): 本轮注入的是国家代码追问时,
|
||||
# 输出侧确定性强制。流式无法撤回 → 触发轮不逐句流出,整段缓冲到
|
||||
# 流末统一判 "country code" 在场与否(缓冲期间价格/零号码/再索要
|
||||
# 三墙照常逐句检查,优先级不变)。提示词层全部保留,墙只是兜底。
|
||||
cc_wall = completion_marks == ["_cc_asked"]
|
||||
# CC 强制墙(2026-10-08 cc 设计): 门控改线判定树——cc_due(_cc.whatsapp)
|
||||
# 等待态即触发(旧 `completion_marks == ["_cc_asked"]` 门控随 cc 轻补
|
||||
# 分支删除而消亡,_cc_asked 停写)。流式无法撤回 → 触发轮不逐句流出,
|
||||
# 整段缓冲到流末统一判 "country code" 在场与否(缓冲期间价格/零号码/
|
||||
# 再索要三墙照常逐句检查,优先级不变)。no_ip 态(无可回显号)沿用
|
||||
# 本套 ASK 模板与检测词;有建议国态的回显确认模板/e164 检测词由
|
||||
# Task 4 接管。
|
||||
cc_wall = cc_completion.cc_due(_wa_cc)
|
||||
|
||||
yield {"type": "stream_start"}
|
||||
emitted = ""
|
||||
@@ -398,13 +488,13 @@ class ConversationManager:
|
||||
for i in range(0, len(closing), 40):
|
||||
yield {"type": "stream_chunk", "content": closing[i:i + 40]}
|
||||
emitted += closing
|
||||
# CC 强制墙裁决(2026-10-08 增补设计 §2): 触发轮缓冲全文在此判定——
|
||||
# 含 "country code"(中文轮认 "国家代码",与 _CC_NOTE_ZH 注入的逐字
|
||||
# 追问句同词)→ GLM 自发服从,缓冲原样流出,零干预;不含 → 整段替换
|
||||
# 为 CC 模板句正文。violated(价格/零号码墙)优先于本墙——沿用双墙
|
||||
# 裁决"更具体话术优先"惯例;_cc_asked 已在流前落库,一次性消耗不受
|
||||
# 替换影响。缓冲轮全部实义句被再索要墙丢弃时 cc_buffer 为空 → 同样
|
||||
# 走替换(模板句即本轮唯一实义输出,天然不触发下方兜底)。
|
||||
# CC 强制墙裁决(2026-10-08 cc 设计): 触发轮缓冲全文在此判定——
|
||||
# 含 "country code"(中文轮认 "国家代码",与 ASK 模板句同词)→ GLM
|
||||
# 自发服从,缓冲原样流出,零干预;不含 → 整段替换为 CC 模板句正文。
|
||||
# violated(价格/零号码墙)优先于本墙——沿用双墙裁决"更具体话术优先"
|
||||
# 惯例;_cc 状态已在流前落库,不受替换影响。缓冲轮全部实义句被再索要
|
||||
# 墙丢弃时 cc_buffer 为空 → 同样走替换(模板句即本轮唯一实义输出,
|
||||
# 天然不触发下方兜底)。
|
||||
if cc_wall and not violated:
|
||||
cc_full = "".join(cc_buffer)
|
||||
if "country code" in cc_full.lower() or "国家代码" in cc_full:
|
||||
@@ -450,7 +540,7 @@ class ConversationManager:
|
||||
# Task 12b: 轮前联系方式快照(extractor 深合并 + 覆盖 + 同号同步的比对基线)
|
||||
before_contacts = normalize_contacts((profile.raw_info or {}).get("联系方式"))
|
||||
|
||||
# _ 前缀内部键(_cc_asked 等一次性计数)不进 LLM 输入(Task 3 审查
|
||||
# _ 前缀内部键(_cc 状态机/_email_asked 计数)不进 LLM 输入(Task 3 审查
|
||||
# Minor 3 防御纵深——渲染/推送侧已过滤,此处防提示词侧意外回显)
|
||||
new_info = await self.ai.extract_info(
|
||||
full_history,
|
||||
@@ -458,6 +548,21 @@ class ConversationManager:
|
||||
if not str(k).startswith("_")},
|
||||
)
|
||||
logger.info("Extractor result: %s", new_info)
|
||||
# 出口卫生(spec §4 三封堵之②/③): cc_reply 是 extractor 的单轮瞬态
|
||||
# 分类,提取后即剥离——绝不进 raw_info 持久层(消费点在轮末钩子
|
||||
# _cc_round_end);同时防御性丢弃 extractor 回传的任何 `_` 前缀键,
|
||||
# 防 LLM 回显 _cc 覆盖 Python 管理的状态机。(入口侧封堵①: 上方
|
||||
# existing_info 已滤 `_`,复核通过)
|
||||
cc_reply = None
|
||||
if isinstance(new_info, dict):
|
||||
cc_reply = new_info.pop("cc_reply", None)
|
||||
stripped = {k: v for k, v in new_info.items()
|
||||
if not str(k).startswith("_")}
|
||||
if len(stripped) != len(new_info):
|
||||
logger.warning(
|
||||
"Dropped underscore keys from extractor output (conv=%s): %s",
|
||||
conv.id, [k for k in new_info if str(k).startswith("_")])
|
||||
new_info = stripped
|
||||
if new_info:
|
||||
raw = dict(profile.raw_info or {})
|
||||
for k, v in new_info.items():
|
||||
@@ -505,6 +610,11 @@ class ConversationManager:
|
||||
profile.raw_info = raw
|
||||
self.db.add(profile)
|
||||
|
||||
# Task 3 轮末 cc 钩子(spec §6-2): 联系方式四层合并完成后、D1 邮件
|
||||
# 触发前——消费 cc_reply 推进状态机,并为本轮才入管道的号码补建 _cc
|
||||
# (spec §5: 首封补全号/推送派生依赖已建好的 _cc)。
|
||||
await self._cc_round_end(conv, profile, cc_reply)
|
||||
|
||||
# Round 3: Evaluate (仅 ≥3 条客户消息才有意义, 前两轮必然 pending —— D7)
|
||||
user_msg_count = sum(1 for m in full_history if m["role"] == "user")
|
||||
evaluation_ran = user_msg_count >= 3
|
||||
@@ -590,6 +700,56 @@ class ConversationManager:
|
||||
seen.add(canon(str(v)))
|
||||
return seen
|
||||
|
||||
async def _cc_round_end(
|
||||
self, conv: Conversation, profile: CustomerProfile,
|
||||
cc_reply: dict | None,
|
||||
) -> None:
|
||||
"""轮末 cc 钩子(spec §6-2): 联系方式四层合并后、D1 邮件前调用。
|
||||
|
||||
- cc_reply 消费(双门控 Python 侧): 仅 whatsapp 组等待态(cc_due)消费
|
||||
——无回应/漏填也推进(attempts 随轮递增,达上限落定 fallback);
|
||||
落定态仅开迟到 country 升级口: 客户主动报国(非空 country)且
|
||||
source 非 no_ip 耗尽 → transit 重算 e164(客户主动给的信息不
|
||||
丢弃);迟到 affirm 不升级(对已标"未经确认"旧号的确认语义模糊,
|
||||
confirmed 态更不重算——客户改主意请重发完整号,走 canon 重置)。
|
||||
- canon 变化(换号/号消失)→ RESET 删组状态。
|
||||
- 补建 pass: 对最终联系方式重跑判定树(_cc_pre_turn)——本轮才经
|
||||
extractor 入管道的裸号/换号后的新号当场建状态,不下轮补课。
|
||||
"""
|
||||
original_cc = (profile.raw_info or {}).get("_cc")
|
||||
ip_iso = ip_service.resolve_region_code(
|
||||
(conv.client_info or {}).get("ip"))
|
||||
raw = dict(profile.raw_info or {})
|
||||
confirmed_end, _ = split_validate(normalize_contacts(raw.get("联系方式")))
|
||||
wa_canon_end = digits_only(str(confirmed_end.get("whatsapp") or ""))
|
||||
|
||||
cc_map = dict(raw.get("_cc") or {})
|
||||
state = cc_map.get("whatsapp")
|
||||
|
||||
reply = None
|
||||
if isinstance(cc_reply, dict):
|
||||
country = str(cc_reply.get("country") or "").strip()
|
||||
if cc_reply.get("affirm") is not None or country:
|
||||
if cc_completion.cc_due(state):
|
||||
reply = cc_reply
|
||||
elif (state is not None and not state.get("confirmed")
|
||||
and country and state.get("source") != "no_ip"):
|
||||
reply = cc_reply # 迟到 country 升级口
|
||||
|
||||
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 reply is not None or cc_completion.cc_due(state):
|
||||
cc_map["whatsapp"] = cc_completion.transit(state, reply)
|
||||
if cc_map:
|
||||
raw["_cc"] = cc_map
|
||||
else:
|
||||
raw.pop("_cc", None)
|
||||
_cc_pre_turn(raw, confirmed_end, ip_iso)
|
||||
if raw.get("_cc") != original_cc:
|
||||
profile.raw_info = raw
|
||||
self.db.add(profile)
|
||||
|
||||
async def _maybe_send_lead_email(
|
||||
self, conv: Conversation, profile: CustomerProfile, evaluation: dict,
|
||||
evaluated: bool = True,
|
||||
@@ -783,11 +943,12 @@ class ConversationManager:
|
||||
if val:
|
||||
parts.append(f"{key}: {val}")
|
||||
|
||||
# Task 3(spec §4.2/§4.3b): 缺失渠道一次性轻补——cc 优先、邮箱次之,串行
|
||||
# 注入。计数键(_marks)此处弃用: 本函数是 staticmethod 收内存副本,写回由
|
||||
# stream 调用方以同参调用纯函数取回并落库(纯函数保证两处判定不漂移)。
|
||||
# 计算前移到 confirmed 块之前: CC 追问轮 confirmed 块的确认句要让位。
|
||||
completion_note, completion_marks = _contact_completion_note(
|
||||
# Task 3(spec §4.2/§4.3b): 邮箱轻补注入(cc 分支已删,判定树接管号码
|
||||
# 区号补全——确认提醒由 cc_due 驱动,Task 4 接)。计数键(_marks)此处
|
||||
# 弃用: 本函数是 staticmethod 收内存副本,写回由 stream 调用方以同参
|
||||
# 调用纯函数取回并落库(纯函数保证两处判定不漂移)。计算前移到
|
||||
# confirmed 块之前: 未落定轮邮箱轻补不注入(cc_settled 串行)。
|
||||
completion_note, _marks = _contact_completion_note(
|
||||
raw_info, confirmed, suspect,
|
||||
lang="zh" if _CJK_RE.search(latest_msg or "") else "en")
|
||||
|
||||
@@ -802,19 +963,8 @@ class ConversationManager:
|
||||
parts.append(f"⚠️ 客户已提供联系方式({shown})。绝对禁止再索要客户已提供过"
|
||||
f"的联系方式;缺失渠道仅可按轻补指引提一次;号码缺国家代码"
|
||||
f"时可请客户重发完整号一次。")
|
||||
if completion_marks == ["_cc_asked"]:
|
||||
# 生产验收场景①修复(2026-10,三轮): CC 追问是对已在手渠道的重发
|
||||
# 请求,不在"缺失渠道"例外内——同轮两个"逐字使用这句"竞争时 GLM
|
||||
# 稳定服从更显眼的确认句,压制追问。本轮 confirmed 块让位: 唯一
|
||||
# 逐字锚点=下方轻补指引的追问句;禁数字回显堵住"让位后 GLM 自创
|
||||
# 确认句带出真实号码"的回归(墙的客户回显豁免会放行)
|
||||
parts.append("本轮不要使用确认句,请逐字使用下面这条国家代码追问句请客户"
|
||||
"重发带国家代码的完整号码(仅此一次,客户重发或明确不给后"
|
||||
"下一轮恢复正常)。回复中绝不写出号码数字——提到号码一律用 "
|
||||
"your WhatsApp 指代。")
|
||||
else:
|
||||
parts.append(f"需要回应时可直接逐字使用:\"{NO_CONTACT_ALREADY}\"(中文同理)。"
|
||||
f"确认收到后继续解答客户问题即可,不必急着结束对话。")
|
||||
parts.append(f"需要回应时可直接逐字使用:\"{NO_CONTACT_ALREADY}\"(中文同理)。"
|
||||
f"确认收到后继续解答客户问题即可,不必急着结束对话。")
|
||||
if boundary_note:
|
||||
parts.append(boundary_note)
|
||||
# Task 3 防自锁改写: 尾部补缺失渠道豁免(与上方改写同因)
|
||||
@@ -833,7 +983,8 @@ class ConversationManager:
|
||||
if boundary_note and not confirmed and not suspect:
|
||||
parts.append(boundary_note) # 未留资:边界提醒引导留资(职能边界话术自带)
|
||||
|
||||
# Task 3 轻补注入(计算已前移到 confirmed 块之前——CC 轮让位判定需要)
|
||||
# Task 3 轻补注入(计算已前移到 confirmed 块之前;cc_settled 未落定时
|
||||
# note 为空,天然不注入)
|
||||
if completion_note:
|
||||
parts.append(completion_note)
|
||||
|
||||
|
||||
@@ -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