Co-Authored-By: Claude Code <noreply@anthropic.com>
45 KiB
修复"客户已留资后 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、conversation.py:165-166),客户留资的当轮完全不受保护。本任务在构建提醒前对当前 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 附近追加:
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 行):
profile = await self._get_profile(conv.id)
context_reminder = self._build_context_reminder(
profile.raw_info or {}, content, history, source=conv.source.value)
改为:
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 行)改用同一视图:
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 路由价格墙替换句)
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 条)
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 预期就没了):
# ---- 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):
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):
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 的接线:
# 倒装自述: "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 分支之后追加:
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 同位):
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: 提交
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: 写失败测试
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 改为:
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: 提交
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 分支逐字脚本 "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: 写失败测试
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 构建移到联系方式判定之后。结构变为:
# 联系方式状态前移(供 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: 提交
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 的 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:
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:
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 常量区加:
# 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 分支改为:
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: 提交
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:
# ---- 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 零号码墙区之后:
# ---- 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:
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 流式循环内,价格墙与零号码墙检查之后、流出之前:
# 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: 全绿
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: 写失败测试
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支持)
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
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 文件改动不影响这些测试,跑一遍防意外)
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: 写失败测试
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 渲染段加一句
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
git add app/services/conversation.py tests/test_lead_email_trigger.py
git commit -m "fix(contact): 已留资提醒附逐字话术——GLM 依从性加固(听逐字不听抽象)"
Task 9: 端到端回归(用户报告场景)
- Step 1: 写端到端测试——客户发号→继续聊,全程零再索要
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: 全绿
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: 提交
git add docs/2026-09-08-stop-reasking-contact-changelog.md
git commit -m "docs: 已留资不再索要——改动文档"
残余风险(实现时须知,文档 Task 10 需转录)
- 同轮扫描仍是正则——Task 2 扩充后仍存在未覆盖表述(如纯语音转写噪声、极端缩写);当轮失守时下一轮由轮末管道兜底。正则永远不是全集,这是防御纵深的第一层而非全部。
- prompt 层(Task 7)不可单测——GLM 依从性靠真机冒烟;确定性防线(Task 1/4/5)才是主修,prompt 是 belt。
NO_CONTACT_ALREADY英文单语——与既有NO_CONTACT_WEB/APP一致(既有设计如此,中文会话也会收到英文替换句);如需中英路由属独立增强,不在本计划。- 墙路由用 confirmed-only——suspect(疑似)时墙仍可能问一次;这是有意的(suspect 本来就该礼貌确认一次)。
- contains 兜底可能误绑怪 key——如 extractor 输出
"无email"作 key 会映射 email;概率极低(渠道名作 key 是常态语义),且 value 仍过 split_validate 校验,风险受控。 - 新正则进入
latest_stated_contacts即获得覆盖权——倒装/msg-me 形态语义上就是显式自述,授予权正确。倒装形态只收显式渠道词(whatsapp/wa/wechat/微信):自查实证 "2500000 is my number one choice" 这类排位表述会把价格误绑成号码并经覆盖权翻新真实号码,故 number/phone/号码 不进倒装正则;已知误报形态已用防误报测试钉住。 - 测试基线——开工前先
python -m pytest tests/ -q记录基线数(应为 117+),收尾时对齐;任何既有测试变红即停。 - 同轮改号场景的视图显示旧值——Task 1 的同轮视图沿用
merge_contacts(current_scan, 已存值)方向(已存优先):客户先留过号、当轮发新号时,当轮提醒显示的仍是旧号。这不影响"禁止再索要"(任一联系方式在场即触发禁令),下一轮轮末管道即校正;反向合并(新值优先)会引入"当轮误捕获顶掉好值进提醒"的新面,两害取轻,维持现方向。 - 触发词的 we/they 将来时不排除——"We will call you tomorrow" 仍触发索要提醒(Task 6 只排 i'll/i will/let me)。已留资时被 Task 4 门控兜住(注入的是"经理主动联系"版),未留资时多注入一次反向留资脚本——语义无害,不加更多后顾以维持正则可读。
- 再索要墙是模式匹配,不是语义理解——Task 5b 已把 system.md 全部留资模板与生产日志原句钉进测试(13 命中/12 放行,全部实证),但 GLM 改写措辞可能绕过模式(漏拦)或撞上边缘句式(误拦)。护栏:仅 has_contact=True 时启用、每条模式强制渠道词、丢句不掐断(误拦只损失一句,对话不中断)。上线后拦截日志(
Re-ask wall dropped sentence)回流补模式,与既有"漏词案例回流补词"同机制。