955 lines
45 KiB
Markdown
955 lines
45 KiB
Markdown
|
|
# 修复"客户已留资后 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`)回流补模式,与既有"漏词案例回流补词"同机制。
|