From 0b5c21909010900cb0bb05e627e94e8298168171 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B5=85=E5=BF=B5?= Date: Thu, 8 Oct 2026 18:06:39 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=88=A4=E5=AE=9A=E6=A0=91=E8=BD=AE?= =?UTF-8?q?=E5=89=8D=E6=8E=A5=E5=85=A5+cc=5Freply=E6=B6=88=E8=B4=B9+?= =?UTF-8?q?=E5=87=BA=E5=8F=A3=E5=8D=AB=E7=94=9F+=E5=AD=98=E9=87=8F?= =?UTF-8?q?=E8=BF=81=E7=A7=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- app/services/contact_pipeline.py | 7 +- app/services/conversation.py | 285 ++++++++++++++++++------ tests/test_lead_email_trigger.py | 370 +++++++++++++++++++++++++++---- 3 files changed, 549 insertions(+), 113 deletions(-) diff --git a/app/services/contact_pipeline.py b/app/services/contact_pipeline.py index 3bd6ddc..4945467 100644 --- a/app/services/contact_pipeline.py +++ b/app/services/contact_pipeline.py @@ -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( diff --git a/app/services/conversation.py b/app/services/conversation.py index 6daacad..cae72eb 100644 --- a/app/services/conversation.py +++ b/app/services/conversation.py @@ -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) diff --git a/tests/test_lead_email_trigger.py b/tests/test_lead_email_trigger.py index b29f7d5..1d35ea5 100644 --- a/tests/test_lead_email_trigger.py +++ b/tests/test_lead_email_trigger.py @@ -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())