# 修复"客户已留资后 AI 仍重复索要联系方式"实施计划 > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** 客户提供 WhatsApp 号码/邮箱后,AI 在任何后续轮次(含留资当轮)都不再索要联系方式——堵住捕获缺口、消除同轮竞态、给三条确定性索要通路加"已留资"门控。 **Architecture:** 三层修复:①数据层——当前消息即时正则扫描(不落库,仅供当轮提醒/路由)+ 正则形态扩充 + key 模糊归一,使"已留资"状态尽早尽全地可见;②提醒/过滤层——App 索要脚本与零号码墙替换句按已留资状态路由;③prompt 层——"假称已留过号"应对改为先查记录。持久化管道(轮末 extractor+scanner+recency)语义零改动。 **Tech Stack:** Python 3.12 / FastAPI / SQLAlchemy async / pytest(纯函数单测 + async_db 集成测),无新依赖、无 schema、无前端、无配置。 **问题分析档案:** 本次会话分析报告(根因 1-6 与因果链),实施时如需背景可读 `docs/2026-08-20-company-contact-handoff-changelog.md`(本计划是其 §6"已知限制"中两残余项的收敛)。 --- ## 文件结构 | 文件 | 动作 | 职责 | |---|---|---| | `app/services/conversation.py` | 修改 | Task 1 同轮即时扫描 / Task 4 App 提醒门控 / Task 5 墙路由 / Task 6 触发词收窄 / Task 8 提醒逐字化 | | `app/services/contact_pipeline.py` | 修改 | Task 2 正则形态扩充 / Task 3 key 模糊归一 | | `app/services/boundary_filter.py` | 修改 | Task 5 新增 `NO_CONTACT_ALREADY` 常量 | | `app/prompts/system.md` | 修改 | Task 7 "假称已留过号"先查记录 | | `tests/test_contact_pipeline.py` | 修改 | Task 2/3 单测 | | `tests/test_lead_email_trigger.py` | 修改 | Task 1/4/6/8 单测与集成测 | | `tests/test_boundary_filter.py` | 修改 | Task 5 单测 | | `docs/2026-09-08-stop-reasking-contact-changelog.md` | 新建 | Task 10 改动文档(仓库惯例) | 不动的文件:`app/services/ai_service.py`、`app/providers/glm.py`、`app/prompts/extractor.md`、`app/prompts/evaluator.md`、前端全部。 --- ### Task 1: 当前消息即时扫描(同轮竞态消除,P0 核心) **原理:** 现在提醒与 `has_contact` 用的是上一轮画像([conversation.py:149-151](app/services/conversation.py#L149-L151)、[conversation.py:165-166](app/services/conversation.py#L165-L166)),客户留资的**当轮**完全不受保护。本任务在构建提醒前对当前 `content` 跑一次纯正则扫描(毫秒级、零 LLM),结果只并入**本轮内存视图**,不落库——持久化仍由轮末管道负责,邮件触发时序零变化。 **Files:** - Modify: `app/services/conversation.py:146-166` - Test: `tests/test_lead_email_trigger.py` - [ ] **Step 1: 写失败测试(同轮提醒含"已提供")** 在 `tests/test_lead_email_trigger.py` 的 `test_reminder_says_no_reasking_when_contact_present` 附近追加: ```python def test_same_turn_scan_feeds_reminder(async_db, monkeypatch): # P0 同轮竞态: 客户当轮才发号码(画像仍空),提醒就必须已含"已提供+禁止再索要" (manager, conv), _ = _setup(async_db, monkeypatch, ai=FakeAI(extract={})) captured = {} class CaptureAI(FakeAI): async def stream_chat(self, messages, context=""): captured["context"] = context async for t in super().stream_chat(messages, context): yield t manager.ai = CaptureAI(extract={}) asyncio.run(_send(manager, conv.session_id, "my whatsapp is +254712345678")) assert "已提供" in captured["context"] assert "禁止" in captured["context"] asyncio.run(manager.db.close()) ``` - [ ] **Step 2: 运行确认失败** Run: `python -m pytest tests/test_lead_email_trigger.py::test_same_turn_scan_feeds_reminder -v` Expected: FAIL(`captured["context"]` 不含"已提供"——画像为空走早退分支) - [ ] **Step 3: 最小实现** `app/services/conversation.py`——`stream_process_message` 中,将(约 149-166 行): ```python profile = await self._get_profile(conv.id) context_reminder = self._build_context_reminder( profile.raw_info or {}, content, history, source=conv.source.value) ``` 改为: ```python profile = await self._get_profile(conv.id) # P0 同轮竞态消除: 当前消息即时正则扫描——仅并入本轮内存视图 # (提醒/话术路由),不落库;持久化仍由轮末 extractor+scanner 负责, # 邮件触发时序不变。 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("联系方式")) context_reminder = self._build_context_reminder( effective_raw, content, history, source=conv.source.value) ``` 同函数 `has_contact`(约 165-166 行)改用同一视图: ```python has_contact = bool(split_validate( normalize_contacts(effective_raw.get("联系方式")))[0]) ``` 注意:`_enrich_short_message(content, profile.raw_info or {}, history)` 保持用原画像(该函数本来就排除联系方式,两者输出恒等;不动它以缩小爆炸半径)。 - [ ] **Step 4: 跑测试确认通过** Run: `python -m pytest tests/test_lead_email_trigger.py::test_same_turn_scan_feeds_reminder -v` Expected: PASS - [ ] **Step 5: 写第二个失败测试(同轮 has_contact 路由价格墙替换句)** ```python def test_same_turn_contact_routes_deflection(async_db, monkeypatch): # 同轮"号码+问价": 价格墙替换句不得再索要(走 *_CONTACT 版) from app.services.boundary_filter import DEFLECTIONS_EN_CONTACT class PriceAI(FakeAI): async def stream_chat(self, messages, context=""): for token in ("it costs ", "$25,000 ok?"): yield token (manager, conv), _ = _setup( async_db, monkeypatch, ai=PriceAI(extract={}), source=ConversationSource.standalone) asyncio.run(_send(manager, conv.session_id, "my whatsapp is +254712345678, how much?")) msgs = asyncio.run(manager._load_history(conv.id)) assistant_tail = [m["content"] for m in msgs if m["role"] == "assistant"][-1] assert assistant_tail in DEFLECTIONS_EN_CONTACT asyncio.run(manager.db.close()) ``` - [ ] **Step 6: 运行(Step 3 已使其通过;若仍 FAIL 则排查)** Run: `python -m pytest tests/test_lead_email_trigger.py::test_same_turn_contact_routes_deflection -v` Expected: PASS(替换句为 "Our manager will send the exact quote to your WhatsApp shortly...") - [ ] **Step 7: 全量回归 + 提交** Run: `python -m pytest tests/ -q` Expected: 全绿(基线记录法见文末"残余风险"第 7 条) ```bash git add app/services/conversation.py tests/test_lead_email_trigger.py git commit -m "fix(contact): 当前消息即时扫描喂提醒/has_contact——同轮留资即受保护" ``` --- ### Task 2: 正则形态扩充(捕获缺口,P0) **原理:** 根因 1 的四种漏网形态。所有新形态沿用既有"邻接绑定"原则(上下文词与数字必须在同一匹配内),且 `latest_stated_contacts` 复用同一批正则,自动获得覆盖权——这与"显式自述可覆盖"语义一致。 **Files:** - Modify: `app/services/contact_pipeline.py:63-77`(正则区) - Test: `tests/test_contact_pipeline.py` - [ ] **Step 1: 写失败测试(新增四个捕获 + 三个误报防护)** 在 `tests/test_contact_pipeline.py` 末尾追加(注意:不要 import 尚未实现的符号,否则整个文件 collection 报错,Step 2 的部分 PASS 预期就没了): ```python # ---- P0 捕获缺口扩充: 号码在前/无is/msg me/wa冒号 ---- # 全部经 scan_user_messages 断言,不直接 import 新正则符号 def test_scan_number_before_whatsapp(): # 倒装: "0712 345 678 is my whatsapp" assert scan_user_messages(m("0712 345 678 is my whatsapp")) == { "whatsapp": "0712 345 678"} def test_scan_number_before_wechat_cn(): # 中文倒装(我的后无空格——正则必须用 \s* 而非 \s+,实证 \s+ 永不命中) assert scan_user_messages(m("13800138000 我的微信")) == { "wechat": "13800138000"} assert scan_user_messages(m("13800138000 这是我的微信")) == { "wechat": "13800138000"} def test_scan_my_number_without_is(): # "my number 0712345678"(无 is/冒号) assert scan_user_messages(m("my number 0712345678")) == { "phone": "0712345678"} def test_scan_text_me_forms(): assert scan_user_messages(m("text me at 07123456789")) == { "phone": "07123456789"} assert scan_user_messages(m("msg me 07123456789")) == { "phone": "07123456789"} def test_scan_wa_colon_local_number(): assert scan_user_messages(m("wa: 07123456789")) == { "whatsapp": "07123456789"} def test_scan_new_forms_do_not_grab_prices(): # 误报防护: 价格/单号即使与 whatsapp 词同现也不得捕获(无 my 指称/无邻接) assert scan_user_messages(m("paid 2500000 via whatsapp transfer")) == {} assert scan_user_messages(m("my budget 25000 number one priority")) == {} assert scan_user_messages(m("order 1234567 is my order number")) == {} # 排位表述(实证: 若倒装正则收 number/phone,"2500000 is my number" 被误捕, # 且 latest_stated_contacts 覆盖权会把真实号码翻新成价格)——倒装只收显式渠道词 assert scan_user_messages(m("2500000 is my number one choice")) == {} # wa 词界守卫: "my watch/way" 的 wa 后无词边界,不得命中 assert scan_user_messages(m("0712345678 my watch")) == {} assert scan_user_messages(m("0712345678 my way home")) == {} ``` - [ ] **Step 2: 运行确认失败** Run: `python -m pytest tests/test_contact_pipeline.py -k "number_before or without_is or text_me or wa_colon or new_forms" -v` Expected: 5 个捕获测试 FAIL(返回 `{}`),防误报测试中仅排位/词界两条 FAIL(现状不捕获所以应 PASS——注意:若误报测试也全 PASS 属正常,`-k` 选中的 FAIL 集合 = 5 捕获 + 可能 0~2 防误报,以最终实现后全绿为准) - [ ] **Step 3: 实现——扩充正则与扫描接线** `app/services/contact_pipeline.py`: WHATSAPP_CTX_RE 的交替组**加**裸 `wa` 冒号形态(仅冒号,防 "in WA state" 误绑)——⚠️ 保留原有 `wa\.?me` 分支,是三元交替而非替换(评审实证:漏掉它会击穿既有 `test_scan_wa_me_link`): ```python WHATSAPP_CTX_RE = re.compile( r"(?:whats\s*app|wa\.?me|(?:^|\b)wa\s*[::])\D{0,12}(\+?\d[\d\s\-().]{5,17}\d)", re.IGNORECASE, ) ``` PHONE_CTX_RE 交替组加两形态(无 is 的紧邻 / msg-text-ping me): ```python PHONE_CTX_RE = re.compile( r"(?:my\s+(?:(?:new|other|another)\s+)?(?:phone\s+)?number\s+is|" r"my\s+(?:(?:new|other|another)\s+)?phone\s+is|" r"my\s+(?:(?:new|other|another)\s+)?number\s+|" r"(?:msg|message|text|ping)\s+me\s+(?:at|on)?\s*|" r"call\s+me\s+(?:at|on)|reach\s+me\s+(?:at|on)|" r"phone\s*[::]|number\s*[::]|号码\s*[::]|手机号?\s*[::]|电话\s*[::])\s*" r"(\+?\d[\d\s\-().]{5,17}\d)", re.IGNORECASE, ) ``` (注意 `my ... number\s+` 分支要求数字紧随其后——"my number one pick" 中 "one" 非数字,天然不匹配。) 新增倒装正则与其在 `scan_user_messages`/`latest_stated_contacts` 的接线: ```python # 倒装自述: "0712 345 678 is my whatsapp / 13800138000 我的微信" # ⚠️ 只收显式渠道词(whatsapp/wa/wechat/微信),不收 number/phone/号码—— # 自查实证: "2500000 is my number one choice" 这类排位表述会把价格误绑成 # 号码,且本正则进入 latest_stated_contacts 即获覆盖权,误捕获会翻新掉 # 已存真实号码(正是 S1 系测试在防的事故类)。倒装电话表述罕见,不值得 # 为其承担该风险;电话主通路由 PHONE_CTX_RE 前缀形态/standalone/INTL 覆盖。 # (?:my|我的)后用 \s* 而非 \s+: 中文"我的微信"连写无空白,\s+ 永不命中(实证)。 NUMBER_FIRST_RE = re.compile( r"(\+?\d[\d\s\-().]{5,17}\d)\s*(?:is|这是)?\s*" r"(?:my|我的)\s*(whats\s*app|wa\b|wechat|微信)", re.IGNORECASE, ) _NUMBER_FIRST_CHANNEL = { "whatsapp": "whatsapp", "whats app": "whatsapp", "wa": "whatsapp", "wechat": "wechat", "微信": "wechat", } ``` `scan_user_messages` 内、whatsapp 分支之后追加: ```python if "whatsapp" not in found and "phone" not in found: hit = NUMBER_FIRST_RE.search(text) if hit: ch = _NUMBER_FIRST_CHANNEL[hit.group(2).strip().lower()] found[ch] = hit.group(1) ``` `latest_stated_contacts` 的循环内追加(与 scan 同位): ```python hit = NUMBER_FIRST_RE.search(text) if hit: ch = _NUMBER_FIRST_CHANNEL[hit.group(2).strip().lower()] latest[ch] = hit.group(1) ``` - [ ] **Step 4: 跑测试确认通过(含全文件旧测试防回归)** Run: `python -m pytest tests/test_contact_pipeline.py -v` Expected: 全 PASS(旧测试无一变化——扩充只增交替分支) - [ ] **Step 5: 提交** ```bash git add app/services/contact_pipeline.py tests/test_contact_pipeline.py git commit -m "fix(contact): 正则补四形态——倒装/无is/my number紧邻/msg-text-ping me/wa冒号" ``` --- ### Task 3: normalize_contacts key 模糊归一(extractor 变体 key 不再静默丢值) **Files:** - Modify: `app/services/contact_pipeline.py:13-34` - Test: `tests/test_contact_pipeline.py` - [ ] **Step 1: 写失败测试** ```python def test_normalize_fuzzy_key_variants(): # extractor 常见即兴 key(精确别名表外)不得静默丢值 assert normalize_contacts({"联系电话": "13800138000"}) == {"phone": "13800138000"} assert normalize_contacts({"手机号": "13800138000"}) == {"phone": "13800138000"} assert normalize_contacts({"WhatsApp账号": "+254712345678"}) == { "whatsapp": "+254712345678"} def test_normalize_unknown_key_still_dropped(): # 完全不含渠道词的 key 仍然丢弃(如 "备注") assert normalize_contacts({"备注": "13800138000"}) == {} def test_normalize_slash_combined_key_deterministic(): # "电话/WhatsApp" 双渠道复合 key: 按 KEY_ALIASES 声明序取第一个命中(phone 先于 whatsapp) assert normalize_contacts({"电话/WhatsApp": "13800138000"}) == { "phone": "13800138000"} ``` - [ ] **Step 2: 运行确认失败** Run: `python -m pytest tests/test_contact_pipeline.py -k fuzzy -v` Expected: FAIL(现精确匹配返回 `{}`) - [ ] **Step 3: 实现——精确优先,contains 兜底** `KEY_ALIASES` 显式补入:`"联系电话": "phone", "手机号": "phone", "手机号码": "phone", "wa": "whatsapp", "wsp": "whatsapp", "whatsapp账号": "whatsapp"`(显式补齐常用形态,减少对 contains 的依赖面)。 `normalize_contacts` 改为: ```python def normalize_contacts(raw: dict | None) -> dict: """key 归一化 + 去空值。精确别名优先;未命中再做 contains 兜底 (extractor 即兴变体 key 不静默丢值)。contains 按 KEY_ALIASES 声明序 取第一个命中——复合 key(电话/WhatsApp)结果确定。""" out: dict[str, str] = {} for k, v in (raw or {}).items(): key = str(k).strip().lower() alias = KEY_ALIASES.get(key) if alias is None: for name, target in KEY_ALIASES.items(): if name in key: alias = target break val = str(v or "").strip() if alias and val: out[alias] = val return out ``` - [ ] **Step 4: 跑测试确认通过 + 全文件回归** Run: `python -m pytest tests/test_contact_pipeline.py -v` Expected: 全 PASS - [ ] **Step 5: 提交** ```bash git add app/services/contact_pipeline.py tests/test_contact_pipeline.py git commit -m "fix(contact): key 归一化精确优先+contains兜底——extractor 变体 key 不再丢值" ``` --- ### Task 4: App 索要提醒按已留资门控(拆确定性索要引擎①) **原理:** [conversation.py:497-508](app/services/conversation.py#L497-L508) 的 App 分支逐字脚本 "Drop your WhatsApp..." 在 `CONTACT_ASK_TRIGGER_RE` 命中时无条件注入,哪怕 confirmed/suspect 已存在——同一条提醒里"禁止再索要"与"逐字 Drop your WhatsApp"互相打架,而 changelog §3.2 已实证 GLM 听逐字不听抽象。改为:已留资时注入"经理会主动联系你"版逐字脚本。web 分支不动。 **Files:** - Modify: `app/services/conversation.py:496-508、524-553` - Test: `tests/test_lead_email_trigger.py` - [ ] **Step 1: 写失败测试** ```python def test_reminder_app_contact_ask_gated_by_existing_contact(): # App 会话已留资 + 再次索要我方联系方式 → 不得注入 "Drop your WhatsApp" 索要脚本 raw = {"联系方式": {"whatsapp": "+254712345678"}} text = ConversationManager._build_context_reminder( raw, "how can I contact you", [], source="app") assert "Drop your WhatsApp" not in text assert "reach out to you at your WhatsApp" in text # 已留资版逐字脚本 def test_reminder_app_contact_ask_without_contact_keeps_script(): # App 会话未留资 → 原反向留资脚本原样保留 text = ConversationManager._build_context_reminder( {}, "how can I contact you", [], source="app") assert "Drop your WhatsApp" in text def test_reminder_web_branch_unchanged(): # web 分支不受影响 text = ConversationManager._build_context_reminder( {}, "how can I contact you", [], source="widget") assert "tap the WhatsApp button" in text.lower() ``` - [ ] **Step 2: 运行确认失败** Run: `python -m pytest tests/test_lead_email_trigger.py -k "app_contact_ask or web_branch_unchanged" -v` Expected: 门控测试 FAIL(现无条件注入 Drop 脚本);后两个 PASS - [ ] **Step 3: 实现——联系方式状态前移,App 分支三态** `_build_context_reminder` 中,把 contact_note 构建移到联系方式判定之后。结构变为: ```python # 联系方式状态前移(供 boundary/contact 提醒共用判定) contacts_all = normalize_contacts(raw_info.get("联系方式")) confirmed, suspect = split_validate(contacts_all) contact_note = "" if CONTACT_ASK_TRIGGER_RE.search(latest_msg or ""): if source == "app": if confirmed or suspect: # 已留资: 索要我方联系方式 → 逐字"经理主动联系"版, # 与"禁止再索要"提醒不再打架 contact_note = ( "⚠️ 客户正在索要我方联系方式,且客户已留过自己的联系方式。" "请逐字使用这句:\"Our manager will reach out to you at your " "WhatsApp shortly with the full quote!\"(中文同理)。" "绝对禁止输出任何号码或邮箱,也绝对禁止再索要联系方式。") else: contact_note = ("⚠️ 客户正在索要我方联系方式(最高意向信号)。当前为 App 会话," "没有页面按钮——禁止使用任何 tap/button 按钮话术!请逐字使用这句:" "\"Drop your WhatsApp and our manager will reach out to you directly " "with the full quote!\"(中文同理引导客户留联系方式)。" "绝对禁止输出任何号码或邮箱。") else: contact_note = ("⚠️ 客户正在索要我方联系方式(最高意向信号)。请逐字使用这句指路:" "\"Just tap the WhatsApp button at the top of this chat — " "our manager replies fast!\"(邮箱同理指 Email 按钮)。" "绝对禁止口头报出任何号码或邮箱。") ``` 下方原 `contacts_all = normalize_contacts(...)` / `confirmed, suspect = split_validate(...)` 两行(约 524-525 行)删除(已前移),其余 confirmed/suspect 渲染逻辑原样保留。早退分支 `if not raw_info:` 在 contact_note 之后、前移的联系方式判定之后——注意早退时 confirmed/suspect 已算好,直接可用(空画像必然双空,行为不变)。 - [ ] **Step 4: 跑测试确认通过 + 提醒全组回归** Run: `python -m pytest tests/test_lead_email_trigger.py -k reminder -v` Expected: 全 PASS(含旧 6 个 reminder 测试) - [ ] **Step 5: 提交** ```bash git add app/services/conversation.py tests/test_lead_email_trigger.py git commit -m "fix(contact): App 索要提醒按已留资门控——已留资注入'经理主动联系'逐字脚本" ``` --- ### Task 5: 零号码墙替换句按 has_contact 路由(拆确定性索要引擎②) **原理:** [conversation.py:216-217](app/services/conversation.py#L216-L217) 的 no_contact 分支不查 has_contact,`NO_CONTACT_APP` 文案本身就是索要——AI 回显号码形态稍有出入触发墙后,替换句在客户刚发完号之后立刻再要一次。与 F6 修价格墙的同款矛盾,用同款修法。 **Files:** - Modify: `app/services/boundary_filter.py:119-122` - Modify: `app/services/conversation.py:209-217` - Test: `tests/test_boundary_filter.py`、`tests/test_lead_email_trigger.py` - [ ] **Step 1: 写失败测试** `tests/test_boundary_filter.py`: ```python def test_no_contact_already_template_clean(): from app.services.boundary_filter import NO_CONTACT_ALREADY assert not no_contact_value(NO_CONTACT_ALREADY) # 模板自身无号码/邮箱 assert "WhatsApp?" not in NO_CONTACT_ALREADY # 不索要 ``` `tests/test_lead_email_trigger.py`: ```python def test_no_contact_wall_already_contact_no_reask(async_db, monkeypatch): # 已留资 + 墙触发(AI 回显形态出入) → 替换句不得再索要 from app.services.boundary_filter import NO_CONTACT_ALREADY class EchoAI(FakeAI): async def stream_chat(self, messages, context=""): # 客户发的是 254712 345678,AI 回显错成 254712 345999(改中间数字, # 不是子串——回显豁免是子串判定,"54712345678" 这种去首位形态仍是 # "254712345678" 的子串,豁免会放行,墙根本不触发,测试变空转) for token in ("got it, ", "254712 345999 call you soon"): yield token (manager, conv), _ = _setup( async_db, monkeypatch, ai=EchoAI(extract={}), source=ConversationSource.standalone) asyncio.run(_send(manager, conv.session_id, "my whatsapp is 254712 345678")) asyncio.run(_send(manager, conv.session_id, "so how much is tang?")) msgs = asyncio.run(manager._load_history(conv.id)) tail = [m["content"] for m in msgs if m["role"] == "assistant"][-1] # 精确断言(评审加固): 负向断言("WhatsApp?" not in)对三种替换句都空转通过, # 必须钉死路由结果本身 assert tail == NO_CONTACT_ALREADY asyncio.run(manager.db.close()) ``` - [ ] **Step 2: 运行确认失败** Run: `python -m pytest tests/test_boundary_filter.py::test_no_contact_already_template_clean tests/test_lead_email_trigger.py::test_no_contact_wall_already_contact_no_reask -v` Expected: FAIL(`NO_CONTACT_ALREADY` 不存在 / 替换句为 `NO_CONTACT_WEB`) - [ ] **Step 3: 实现** `app/services/boundary_filter.py` 常量区加: ```python # F6 同款(15.3 补): 已留资时零号码墙替换句不得索要 NO_CONTACT_ALREADY = ("Got it — our manager will reach out to you at your " "WhatsApp shortly with the full quote!") ``` `app/services/conversation.py` no_contact 分支改为: ```python if violation == "no_contact": # 零号码墙替换句: 已留资→确认话术(不再索要);未留资按 source 分支 closing = (NO_CONTACT_ALREADY if has_contact else (NO_CONTACT_APP if conv.source.value == "app" else NO_CONTACT_WEB)) ``` (import 行补 `NO_CONTACT_ALREADY`。) - [ ] **Step 4: 跑测试确认通过 + 全量回归** Run: `python -m pytest tests/ -q` Expected: 全绿 - [ ] **Step 5: 提交** ```bash git add app/services/boundary_filter.py app/services/conversation.py \ tests/test_boundary_filter.py tests/test_lead_email_trigger.py git commit -m "fix(contact): 零号码墙替换句按 has_contact 路由——已留资不再索要(F6 同款)" ``` --- ### Task 5b: 再索要墙(已留资时输出侧确定性拦截索要句) **原理(生产日志实证补强):** 2026-09 真机日志显示,4 次违规索要**全部**是 GLM 自由生成(逐字复述 system.md 留资话术模板),既非价格墙替换句、亦非 App 脚本——Task 1/4/5 的确定性门控对此零拦截,Task 8 提醒逐字化是唯一防线但属概率层。日志同时证明:捕获链工作正常(整条裸号确定性命中)、GLM 看得见历史仍照问("Got your WhatsApp?"出现在确认收号 19 秒后)。本任务补第三道墙:`has_contact`(confirmed)为真时,输出中的"索要联系方式"疑问/祈使句**逐句静默丢弃**(不掐断流,其余句子照常流出);`has_contact` 为假时闸门全关——首次合法索要不受影响。 **误杀边界(设计即约束):** - 确认句不拦:"Got your number, thanks!" 无问号、动词不在请求表 → 放行 - suspect 礼貌确认不拦:动词表无 confirm/share your number again 等"确认"语义,问句 "Could you confirm your number?" 放行 - 按钮指路不拦:"Just tap the WhatsApp button…" 陈述句 → 放行 - 聊车问句不拦:"What's your budget range?" 无渠道词 → 放行 - 所有模式强制要求**渠道词在场**,防 "Can you send me the details?" 类泛请求误杀 **Files:** - Modify: `app/services/boundary_filter.py`(REASK_PATTERNS + `asks_contact`) - Modify: `app/services/conversation.py`(句子门控循环接线,丢句不掐断) - Test: `tests/test_boundary_filter.py`、`tests/test_lead_email_trigger.py` - [ ] **Step 1: 写失败单测(生产日志原句逐字钉死 + 误杀防护)** `tests/test_boundary_filter.py`: ```python # ---- Task 5b: 再索要墙 ---- from app.services.boundary_filter import asks_contact def test_asks_contact_detects_production_log_phrasings(): # 2026-09 生产日志 4 次违规索要原句 + system.md 全部留资模板同型句,逐字钉死 # (⚠️ 首版模式曾漏 "I'll need your WhatsApp"/"Tell me your WhatsApp" 两句 # system.md 模板原句——GLM 背的就是这些,必须全收;已实证 13/13) assert asks_contact("What's your WhatsApp or email?") assert asks_contact("Could you leave your WhatsApp?") assert asks_contact("Got your WhatsApp so our manager can send the details?") assert asks_contact("Got your WhatsApp?") assert asks_contact("Drop your WhatsApp and our manager will reach out to you directly with the full quote!") assert asks_contact("Want me to get you a proper quote? I'll need your WhatsApp so our manager can reach you.") assert asks_contact("Tell me your WhatsApp and our manager will send you an accurate quote for your budget.") assert asks_contact("leave your contact and our manager will give you an exact schedule") assert asks_contact("Drop yours too and our manager will send you the full quote!") assert asks_contact("留个WhatsApp或者邮箱吧?") assert asks_contact("你WhatsApp多少?") assert asks_contact("你给我个联系方式,我让客户经理给你推几个车型。") def test_asks_contact_clean_phrasings_pass(): assert not asks_contact("Got your number, thanks!") # 确认,非问句 assert not asks_contact("Could you confirm your number?") # suspect 礼貌确认 assert not asks_contact("Just tap the WhatsApp button at the top of this chat — our manager replies fast!") assert not asks_contact("Which color do you prefer?") assert not asks_contact("What's your budget range for this one?") assert not asks_contact("Can you send me more details about the Tang?") # 泛请求无渠道词 assert not asks_contact("Our manager will send you all the details.") assert not asks_contact("I want your advice on the Song L EV.") # want+非渠道词 assert not asks_contact("Could you drop it here once more?") # 核对恢复话术(suspect场景) assert not asks_contact("Our manager needs your config and destination.") # 合法迂回句 ``` - [ ] **Step 2: 运行确认失败** Run: `python -m pytest tests/test_boundary_filter.py -k asks_contact -v` Expected: FAIL(`asks_contact` 未定义) - [ ] **Step 3: 实现 `asks_contact`** `app/services/boundary_filter.py` 零号码墙区之后: ```python # ---- Task 5b: 再索要墙(已留资时输出侧拦截) ---- # 仅当调用方 has_contact=True 才启用(接线在 conversation.py); # 每条模式强制渠道词在场,防泛请求误杀("Can you send me the details?" 放行)。 # 模式集与 system.md 全部留资话术模板逐句对齐并实证(13 命中/12 放行)—— # GLM 背的就是这些模板,模板全集必须全收。 REASK_PATTERNS = [ # what's/which's your <渠道词> re.compile(r"(?:what|which)['′]?s\s+(?:your|the)\s+" r"(?:whatsapp|number|phone|e-?mail|contact|号码|联系方式)", re.I), # got/have your <渠道词> ... ?(问号兜尾——确认句 "Got your number, thanks!" 无问号放行) re.compile(r"(?:got|have)\s+(?:your|the)\s+" r"(?:whatsapp|number|phone|e-?mail|contact)[^!?.]{0,60}\?", re.I), # 陈述式需求: (I'll) need/want your <渠道词>——"I'll need your WhatsApp" 无问号也要拦 re.compile(r"(?:i['′]ll\s+|i\s+)?(?:need|want)\s+(?:your|the)\s+" r"(?:whatsapp|number|phone|e-?mail|contact|号码|联系方式)", re.I), # Tell me your <渠道词> re.compile(r"tell\s+me\s+(?:your\s+)?" r"(?:whatsapp|number|phone|e-?mail|contact|号码|联系方式)", re.I), # could/can you + 给予动词 + ... 渠道词(动词表无 confirm/send——礼貌确认与泛请求放行) re.compile(r"(?:could|can|would|may)\s+you\s+" r"(?:leave|drop|share|give|provide|type|post)[^.!?\n]{0,30}" r"(?:whatsapp|number|phone|e-?mail|contact|号码|联系方式|微信|邮箱)", re.I), # 祈使: leave/drop/share/give (me) your/us a <渠道词> re.compile(r"(?:leave|drop|share|give)\s+(?:me\s+)?(?:your|us\s+your?|a)\s+" r"(?:whatsapp|contact|number|e-?mail|联系方式)", re.I), # 互留后缀 "Drop yours too..."(无渠道词,单列) re.compile(r"(?:drop|leave)\s+yours\b", re.I), # "WhatsApp or email?" 独立短问 re.compile(r"(?:whatsapp|e-?mail|contact)\s+or\s+(?:whatsapp|e-?mail|contact)\s*\?", re.I), # 中文 re.compile(r"留[个下]?\s*(?:你的?)?\s*(?:whatsapp|微信|邮箱|联系方式|号码|电话)|" r"给[我一]?个?\s*(?:联系方式|whatsapp|微信|号码|邮箱|电话)|" r"你的?\s*(?:whatsapp|号码|电话|微信|邮箱|联系方式)[^。!?]{0,10}(?:是多少|多少|\?)", re.I), ] def asks_contact(sentence: str) -> bool: """输出句是否在索要联系方式(仅在客户已留资时用于丢弃)。""" return any(p.search(sentence or "") for p in REASK_PATTERNS) ``` - [ ] **Step 4: 跑单测确认通过 + 提交** Run: `python -m pytest tests/test_boundary_filter.py -k asks_contact -v` Expected: PASS - [ ] **Step 5: 写失败集成测试(丢句不掐断,其余照常流出)** `tests/test_lead_email_trigger.py`: ```python def test_reask_wall_drops_ask_sentence_when_contact(async_db, monkeypatch): # 生产日志复现: 已留资 + GLM 仍在回复中夹索要句 → 该句静默丢弃,前后句照常 class ReAskAI(FakeAI): async def stream_chat(self, messages, context=""): for token in ("Great choice! ", "Got your WhatsApp? ", "Manager will send the details."): yield token (manager, conv), _ = _setup(async_db, monkeypatch, ai=ReAskAI(extract={}), source=ConversationSource.standalone) asyncio.run(_send(manager, conv.session_id, "00963944526529")) # 留资 asyncio.run(_send(manager, conv.session_id, "i want 2model")) msgs = asyncio.run(manager._load_history(conv.id)) tail = [m["content"] for m in msgs if m["role"] == "assistant"][-1] assert "Got your WhatsApp" not in tail # 索要句被丢 assert "Great choice" in tail # 前句保留 assert "send the details" in tail # 后句保留 asyncio.run(manager.db.close()) ``` - [ ] **Step 6: 运行确认失败(索要句未被丢,整段流出)** Run: `python -m pytest tests/test_lead_email_trigger.py::test_reask_wall_drops_ask_sentence_when_contact -v` Expected: FAIL - [ ] **Step 7: 接线——句子门控循环加第三道检查(丢句不掐断)** `app/services/conversation.py` 流式循环内,价格墙与零号码墙检查之后、流出之前: ```python # Task 5b 再索要墙: 已留资时索要句静默丢弃(丢句不掐断, # 其余句子照常流出——软于两墙的掐断+替换) if has_contact and asks_contact(sentence): logger.info("Re-ask wall dropped sentence (conv=%s): %r", conv.id, sentence[:60]) continue ``` 流末尾 `pending` 检查处(`if not violated and pending:` 分支)补同款:命中则不流出、记日志。(import 行补 `asks_contact`。) - [ ] **Step 8: 跑集成测试 + 全量回归 + 提交** Run: `python -m pytest tests/ -q` Expected: 全绿 ```bash git add app/services/boundary_filter.py app/services/conversation.py \ tests/test_boundary_filter.py tests/test_lead_email_trigger.py git commit -m "fix(contact): 再索要墙——已留资时输出侧确定性丢弃索要句(生产日志实证补强)" ``` --- ### Task 6: CONTACT_ASK_TRIGGER_RE 排除将来时误触发 **原理:** "I'll call you later" 命中 `(call)\s+(you)` → App 会话注入索要脚本(已由 Task 4 门控缓解,但未留资客户仍会被莫名要号,且 web 分支也会无谓注入)。用固定宽度负向后顾排除第一人称将来时,不影响真问("how can I contact you" 的 contact 在疑问结构里,前面不是 i'll)。 **Files:** - Modify: `app/services/conversation.py:55-61` - Test: `tests/test_lead_email_trigger.py` - [ ] **Step 1: 写失败测试** ```python from app.services.conversation import CONTACT_ASK_TRIGGER_RE def test_contact_ask_trigger_ignores_future_first_person(): assert not CONTACT_ASK_TRIGGER_RE.search("I'll call you later") assert not CONTACT_ASK_TRIGGER_RE.search("I will email you the specs") assert not CONTACT_ASK_TRIGGER_RE.search("let me call you tonight") def test_contact_ask_trigger_still_fires_on_real_asks(): assert CONTACT_ASK_TRIGGER_RE.search("how can I contact you?") assert CONTACT_ASK_TRIGGER_RE.search("what's your whatsapp number") assert CONTACT_ASK_TRIGGER_RE.search("你们怎么联系") assert CONTACT_ASK_TRIGGER_RE.search("can I call you?") # 疑问结构仍触发 ``` - [ ] **Step 2: 运行确认失败** Run: `python -m pytest tests/test_lead_email_trigger.py -k contact_ask_trigger -v` Expected: 误触发组 FAIL(现命中) - [ ] **Step 3: 实现——负向后顾(定宽,`re` 支持)** ```python CONTACT_ASK_TRIGGER_RE = re.compile( r"(your|our)\s+(whatsapp|number|phone|email|contact)|" r"(?