Files
ai-kefu-jc/docs/superpowers/plans/2026-09-08-stop-reasking-contact.md
T

955 lines
45 KiB
Markdown
Raw Normal View History

# 修复"客户已留资后 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"(?<!i'll )(?<!i’ll )(?<!i will )(?<!let me )"
r"(contact|reach|call|email)\s+(you|us)|"
r"你(们)?的?\s?(whatsapp|号码|电话|微信|邮箱|联系方式)|"
r"(怎么|如何)联系|给我.{0,4}(号码|电话|联系方式)",
re.IGNORECASE,
)
```
(⚠️ 评审实证:不能用负向前顾 `(?!let me call you)` 排除——前顾在 `(contact|call|...)` 起始位置求值,那里只有 "call you...",永远看不到 "let me",拦不住。四个负向后顾均为定宽(`i'll `/`i’ll ` 各 5 字符、`i will ` 7、`let me ` 7),Python `re` 合法。`i’ll ` 是弯引号变体,手机输入法常见。"can I call you" 前文是 "an I " 不拦——客户问我们能否打电话属真意向,保持触发。)
- [ ] **Step 4: 跑测试确认通过 + 提交**
Run: `python -m pytest tests/test_lead_email_trigger.py -k contact_ask_trigger -v`
Expected: PASS
```bash
git add app/services/conversation.py tests/test_lead_email_trigger.py
git commit -m "fix(contact): 索要触发词排除第一人称将来时——I'll call you later 不再误触"
```
---
### Task 7: system.md"假称已留过号"改为先查记录(prompt 层)
**Files:**
- Modify: `app/prompts/system.md:62`
- [ ] **Step 1: 修改话术**
原:
```
- 假称已留过号("I gave you my number already")→ 温和核对:"Sorry, I may have missed it — could you drop it here once more?"
```
改为:
```
- 客户说"我已经给过号码了"("I gave you my number already")→ **先回看聊天记录**:
记录里确有号码 → 确认收到("Got it — our manager will reach out to you at your WhatsApp shortly!"),绝不要求重发;
记录里确实没有 → 才温和核对:"Sorry, I may have missed it — could you drop it here once more?"
```
- [ ] **Step 2: 既有提醒测试回归(prompt 改动无单测,靠提醒/墙测试守行为)+ 提交**
Run: `python -m pytest tests/test_lead_email_trigger.py tests/test_boundary_filter.py -q`
Expected: 全绿(prompt 文件改动不影响这些测试,跑一遍防意外)
```bash
git add app/prompts/system.md
git commit -m "fix(prompt): 假称已留过号先查记录——确有号码时确认收到,不要求重发"
```
---
### Task 8: 提醒"已提供"段附逐字话术(GLM 依从性加固)
**原理:** changelog §3.2 实证:GLM 无视抽象指令、执行逐字话术。"绝对禁止再索要"是抽象禁令,附一句可直接照抄的话术把依从性从"理解"降为"复制"。
**Files:**
- Modify: `app/services/conversation.py:526-530`(Task 4 后行号会移,定位 confirmed 渲染段)
- Test: `tests/test_lead_email_trigger.py`
- [ ] **Step 1: 写失败测试**
```python
def test_reminder_confirmed_includes_verbatim_script():
raw = {"联系方式": {"whatsapp": "+254712345678"}}
text = ConversationManager._build_context_reminder(raw, "yes", [])
assert "绝对禁止再索要" in text
assert "Got it — our manager will reach out" in text # 逐字话术在场
```
- [ ] **Step 2: 运行确认失败**
Run: `python -m pytest tests/test_lead_email_trigger.py::test_reminder_confirmed_includes_verbatim_script -v`
Expected: FAIL
- [ ] **Step 3: 实现——confirmed 渲染段加一句**
```python
if confirmed:
shown = ", ".join(
f"{k}: {' '.join(str(v).split())[:60]}" for k, v in confirmed.items())
# 逐字话术直接引用 NO_CONTACT_ALREADY 常量(Task 5 已 import)——
# 与零号码墙替换句同源,三处"经理主动联系"话术不漂移
parts.append(f"⚠️ 客户已提供联系方式({shown})。绝对禁止再索要任何联系方式!"
f"需要回应时可直接逐字使用:\"{NO_CONTACT_ALREADY}\"(中文同理)。"
f"确认收到后继续解答客户问题即可,不必急着结束对话。")
```
- [ ] **Step 4: 跑测试确认通过 + 提交**
Run: `python -m pytest tests/test_lead_email_trigger.py -k reminder -v`
Expected: 全 PASS
```bash
git add app/services/conversation.py tests/test_lead_email_trigger.py
git commit -m "fix(contact): 已留资提醒附逐字话术——GLM 依从性加固(听逐字不听抽象)"
```
---
### Task 9: 端到端回归(用户报告场景)
- [ ] **Step 1: 写端到端测试——客户发号→继续聊,全程零再索要**
```python
def test_e2e_no_reasking_after_contact_provided(async_db, monkeypatch):
# 用户报告场景复现: 发号(倒装形态)→继续问价→再闲聊,三轮 AI 回复零索要
class StrictAI(FakeAI):
def __init__(self, extract):
super().__init__(extract=extract)
self.contexts = []
async def stream_chat(self, messages, context=""):
self.contexts.append(context)
for token in ("sure thing", ):
yield token
ai = StrictAI(extract={}) # extractor 全程失明,只靠正则管道
(manager, conv), _ = _setup(async_db, monkeypatch, ai=ai,
source=ConversationSource.standalone)
asyncio.run(_send(manager, conv.session_id, "hi there"))
asyncio.run(_send(manager, conv.session_id,
"0712 345 678 is my whatsapp btw"))
asyncio.run(_send(manager, conv.session_id, "so how much is the Tang?"))
asyncio.run(_send(manager, conv.session_id, "and shipping to Kenya?"))
# 第2轮起每轮提醒都必须含"已提供+禁止再索要"
for ctx in ai.contexts[1:]:
assert "已提供" in ctx and "禁止" in ctx, ctx[:200]
# AI 回复里没有索要话术(StrictAI 不索要;此断言守住提醒确实在场,
# 索要与否由提醒+墙两层守,墙的确定性已由前序任务覆盖)
asyncio.run(manager.db.close())
```
- [ ] **Step 2: 运行(应直接通过——这是验收性测试,若 FAIL 说明前序任务有漏)**
Run: `python -m pytest tests/test_lead_email_trigger.py::test_e2e_no_reasking_after_contact_provided -v`
Expected: PASS
- [ ] **Step 3: 全量测试 + 提交**
Run: `python -m pytest tests/ -q`
Expected: 全绿
```bash
git add tests/test_lead_email_trigger.py
git commit -m "test(contact): 端到端回归——发号后三轮零再索要(extractor失明场景)"
```
---
### Task 10: 改动文档 + 真机冒烟(仓库惯例)
- [ ] **Step 1: 新建 `docs/2026-09-08-stop-reasking-contact-changelog.md`**
按 `docs/2026-08-20-company-contact-handoff-changelog.md` 的体例:背景(根因 1-6 一句话各) / 三层实现对照表 / 文件清单 / 已知限制(见计划"残余风险"节,原样转录) / 部署与回滚(重启生效,逐 commit 可回滚,无迁移)。
- [ ] **Step 2: 真机 GLM 冒烟(prompt 层与提醒依从性的唯一验证手段,单测覆盖不到)**
至少覆盖以下场景,逐条记录结果进 changelog(参照 2026-08-20 文档 §5 的表格式):
| 场景 | 期望 |
|---|---|
| 发号后继续问价(同轮:"my whatsapp is +86..., how much?") | 确认收到 + 不再要号,报价转经理话术 |
| 倒装发号("0712 345 678 is my whatsapp")后闲聊 2 轮 | 全程零索要 |
| App 会话发号后再问"how can I contact you" | "经理会主动联系你"版话术,无 Drop your WhatsApp |
| "I'll call you later" | 不触发索要脚本,正常道别 |
| 发号后说 "I gave you my number already" | 先查记录确认收到,不要求重发 |
无 GLM key 时标注"未冒烟"并保留该表为部署前置项——**prompt 依从性(Task 7/8)只有这条验证路径,跳过即盲发**。
- [ ] **Step 3: 提交**
```bash
git add docs/2026-09-08-stop-reasking-contact-changelog.md
git commit -m "docs: 已留资不再索要——改动文档"
```
---
## 残余风险(实现时须知,文档 Task 10 需转录)
1. **同轮扫描仍是正则**——Task 2 扩充后仍存在未覆盖表述(如纯语音转写噪声、极端缩写);当轮失守时下一轮由轮末管道兜底。正则永远不是全集,这是防御纵深的第一层而非全部。
2. **prompt 层(Task 7)不可单测**——GLM 依从性靠真机冒烟;确定性防线(Task 1/4/5)才是主修,prompt 是 belt。
3. **`NO_CONTACT_ALREADY` 英文单语**——与既有 `NO_CONTACT_WEB/APP` 一致(既有设计如此,中文会话也会收到英文替换句);如需中英路由属独立增强,不在本计划。
4. **墙路由用 confirmed-only**——suspect(疑似)时墙仍可能问一次;这是有意的(suspect 本来就该礼貌确认一次)。
5. **contains 兜底可能误绑怪 key**——如 extractor 输出 `"无email"` 作 key 会映射 email;概率极低(渠道名作 key 是常态语义),且 value 仍过 split_validate 校验,风险受控。
6. **新正则进入 `latest_stated_contacts` 即获得覆盖权**——倒装/msg-me 形态语义上就是显式自述,授予权正确。倒装形态**只收显式渠道词**(whatsapp/wa/wechat/微信):自查实证 "2500000 is my number one choice" 这类排位表述会把价格误绑成号码并经覆盖权翻新真实号码,故 number/phone/号码 不进倒装正则;已知误报形态已用防误报测试钉住。
7. **测试基线**——开工前先 `python -m pytest tests/ -q` 记录基线数(应为 117+),收尾时对齐;任何既有测试变红即停。
8. **同轮改号场景的视图显示旧值**——Task 1 的同轮视图沿用 `merge_contacts(current_scan, 已存值)` 方向(已存优先):客户先留过号、当轮发新号时,当轮提醒显示的仍是旧号。这不影响"禁止再索要"(任一联系方式在场即触发禁令),下一轮轮末管道即校正;反向合并(新值优先)会引入"当轮误捕获顶掉好值进提醒"的新面,两害取轻,维持现方向。
9. **触发词的 we/they 将来时不排除**——"We will call you tomorrow" 仍触发索要提醒(Task 6 只排 i'll/i will/let me)。已留资时被 Task 4 门控兜住(注入的是"经理主动联系"版),未留资时多注入一次反向留资脚本——语义无害,不加更多后顾以维持正则可读。
10. **再索要墙是模式匹配,不是语义理解**——Task 5b 已把 system.md 全部留资模板与生产日志原句钉进测试(13 命中/12 放行,全部实证),但 GLM 改写措辞可能绕过模式(漏拦)或撞上边缘句式(误拦)。护栏:仅 has_contact=True 时启用、每条模式强制渠道词、丢句不掐断(误拦只损失一句,对话不中断)。上线后拦截日志(`Re-ask wall dropped sentence`)回流补模式,与既有"漏词案例回流补词"同机制。