Files
ai-kefu-jc/docs/superpowers/plans/2026-10-08-dual-contact-and-country-code.md
T

334 lines
17 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 联系方式双要 + 国家代码完整化 实施计划
> **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:** AI 索要联系方式升级为"WhatsApp(带国家代码)+ 邮箱分步双要",同时再索要墙精确化为按渠道放行缺失渠道的补充索要。
**Architecture:** 三层闭环——system.md 话术教行为;conversation.py 状态感知提醒(一次性计数 `_cc_asked`/`_email_asked`,号码闭环优先于邮箱);boundary_filter.asks_contact 渠道精确化。推送端补 `_` 前缀过滤防内部状态泄漏。
**Tech Stack:** 既有栈(FastAPI/pytest,LLM 全 mock)+ 生产真实验收
**设计文档:** `docs/superpowers/specs/2026-10-08-dual-contact-and-country-code-design.md`(本计划的唯一依据,含全部决策)
**约定:** 每任务 pytest 全绿才 commit;中文进文件用 Read/Edit 工具(环境坑见 CLAUDE.md)。
---
### Task 1: 再索要墙渠道精确化(TDD)
**Files:**
- Modify: `app/services/boundary_filter.py:276-278`(asks_contact)、`app/services/conversation.py:210`(拆 confirmed)、`:239/:258`(两处调用传参)
- Test: `tests/test_boundary_filter.py`
- [ ] **Step 1: 写失败测试**(追加到 test_boundary_filter.py,先看现有 REASK 测试的导入风格)
```python
def test_reask_wall_channel_precision():
"""渠道精确化(2026-10-08 spec §4.4): 索要已在手的渠道才丢,
索要缺失渠道放行;泛渠道词只要手上有任何号码渠道即丢(宁丢勿放)。"""
wa = {"whatsapp"} # 已有 WhatsApp
em = {"email"} # 只有邮箱
# 精确渠道: 已在手的丢
assert asks_contact("What's your WhatsApp number?", wa) is True
assert asks_contact("留个邮箱吧", em) is True
# 精确渠道: 缺失的放行(双要核心场景)
assert asks_contact("And an email address would help too", wa) is False
assert asks_contact("你WhatsApp多少?", em) is False
# 泛渠道词: 手上有任何号码渠道(whatsapp/phone/wechat)即丢
assert asks_contact("What's your number?", wa) is True
assert asks_contact("留个电话呗", wa) is True
# 泛渠道词: 只有邮箱时放行
assert asks_contact("What's your number?", em) is False
# 精确+泛同句共存: 任一命中判丢
assert asks_contact("leave your number or email", wa) is True
# 无参调用: 原行为(命中即丢)不变
assert asks_contact("What's your WhatsApp number?") is True
assert asks_contact("hello there") is False
```
- [ ] **Step 2: 跑红** `python -m pytest tests/test_boundary_filter.py::test_reask_wall_channel_precision -v` → TypeError(asks_contact 不收第二参)
- [ ] **Step 3: 实现**。boundary_filter.py 把 asks_contact 替换为(保持 REASK_PATTERNS 不动,新增渠道映射层)。
⚠️ 分支顺序是评审修正版(plan review Issue 1:初版顺序会让 Step 1 的两条断言必挂——"泛词+仅邮箱"和"精确+泛同句"两个分支被兜底吞掉)。判定顺序固定:①在手精确词→丢;②泛词+有号码渠道→丢;③命中但句中无任何渠道词→保守丢;④其余→放行:
```python
# 渠道精确化(2026-10-08 spec §4.4): REASK 模式命中后按渠道词分辨——
# 索要已在手的渠道才丢,索要缺失渠道放行(双要补邮箱场景);泛渠道词
# 只要手上有任何号码渠道即丢(宁丢勿放);精确+泛同句共存时按①②自然
# 实现任一命中判丢。中文泛词不用 \b(CJK 无词边界,"电话"前 \b 永不成立)。
_PRECISE_CHANNEL_RE = re.compile(r"whatsapp|e-?mail|邮箱|微信|wechat", re.I)
_GENERIC_CONTACT_RE = re.compile(r"\b(?:number|phone|contact)\b|电话|联系方式|号码", re.I)
def _precise_channels_in(s: str) -> set:
out = set()
for m in _PRECISE_CHANNEL_RE.finditer(s):
w = m.group(0).lower()
out.add("email" if w in ("e-mail", "email", "邮箱")
else "wechat" if w in ("微信", "wechat") else "whatsapp")
return out
def asks_contact(sentence: str, confirmed_channels: set | None = None) -> bool:
"""输出句是否在索要联系方式(已留资时用于丢弃)。
confirmed_channels=None → 原行为(命中即丢,向后兼容);
传入已确认渠道集合 → 渠道精确化(①②③④判定,见上注释)。"""
s = sentence or ""
hit = any(p.search(s) for p in REASK_PATTERNS)
if confirmed_channels is None or not hit:
return hit
has_number = bool(confirmed_channels & {"whatsapp", "phone", "wechat"})
precise = _precise_channels_in(s)
has_generic = bool(_GENERIC_CONTACT_RE.search(s))
if precise & confirmed_channels: # ① 句中有在手精确渠道词
return True
if has_number and has_generic: # ② 泛词 + 手上有号码渠道
return True
if not precise and not has_generic: # ③ 命中但无渠道词 → 保守丢
return True
return False # ④ 精确词全缺失/泛词但无号码 → 放行
```
- [ ] **Step 4: 跑绿**(该测试 + 现有全部 REASK 测试不回归)
- [ ] **Step 5: conversation.py 接线**。`:210` 处把 `has_contact = bool(split_validate(...))` 的结果拆出 confirmed 渠道集合(先读该处上下文确认变量形态),供 `:239`、`:258` 两个调用点用:
```python
_confirmed_contacts, _ = split_validate(...) # 复用既有调用,不再只取 bool
has_contact = bool(_confirmed_contacts)
_confirmed_channels = set(_confirmed_contacts) # {"whatsapp","phone","email","wechat"}
# 两处调用改为:
if has_contact and asks_contact(sentence, _confirmed_channels):
...
elif has_contact and asks_contact(pending, _confirmed_channels):
```
- [ ] **Step 5b: 更新两处被新语义打破的既有测试**(plan review Issue 2):
`tests/test_lead_email_trigger.py` 的 `test_reask_wall_drops_ask_sentence_when_contact` 与
`test_reask_wall_all_dropped_falls_back_to_closing`(场景=客户留 phone、AI 问
"Got your WhatsApp?"、期望丢句)——新语义下 WhatsApp 是缺失渠道 → 放行。
改法:测试场景改为 AI 问**已在手的渠道**(如 "Got your phone number?")保持
丢句语义;同时各补一条"有 phone 在手问 WhatsApp → 放行并流出"的断言
(钉死 spec 未明说的语义决定:号码渠道间互视为可补全)。
- [ ] **Step 6: 全量绿 + commit**
```bash
python -m pytest -q && git add -A && git commit -m "feat: 再索要墙渠道精确化——索要缺失渠道放行(双要前置)
Co-Authored-By: Claude Code <noreply@anthropic.com>"
```
---
### Task 2: 推送 `_` 前缀过滤(TDD)
**Files:**
- Modify: `app/services/lead_push_service.py:89`
- Test: `tests/test_lead_push_service.py`
- [ ] **Step 1: 失败测试**(追加;参考同文件现有 _build_payload 相关测试的构造方式)
```python
def test_build_payload_filters_internal_underscore_keys():
"""内部状态键(_cc_asked/_email_asked)不得泄进推送画像(spec §4.3)。"""
# 构造含 _ 前缀键的 profile,走 _build_payload,断言产物无 _ 键
# (具体 fixture 构造参考同文件现有 payload 测试;核心断言:)
# assert all(not str(k).startswith("_") for k in payload["profile"])
```
(实现者按现有测试风格补全 fixture;断言目标即注释所示。)
- [ ] **Step 2: 跑红**
- [ ] **Step 3: 实现**。lead_push_service.py:89 改:
```python
# _ 前缀内部状态(一次性计数等)不进推送画像(spec §4.3——渲染侧
# 两处有过滤,推送端曾整体拷贝泄漏)
profile_out = {k: v for k, v in (raw_info or {}).items()
if not str(k).startswith("_")}
```
- [ ] **Step 4: 全量绿 + commit**
```bash
python -m pytest -q && git add -A && git commit -m "fix: 推送画像过滤_前缀内部键(防CRM泄漏)
Co-Authored-By: Claude Code <noreply@anthropic.com>"
```
---
### Task 3: 状态提醒注入 + 一次性计数(TDD,核心任务)
**Files:**
- Modify: `app/services/conversation.py`(_build_context_reminder 注入区 + stream 方法写回计数)
- Test: `tests/test_lead_email_trigger.py`(复用其 manager 流程设施做集成测试)
**设计要点(实现者必读):**
- 新增模块级函数 `_contact_completion_note(raw_info, confirmed, suspect) -> tuple[str, list[str]]`
—— 返回 (注入文案, 需落库的计数键列表);纯函数便于单测。
- 串行优先级(spec §4.3b):**号码闭环优先**——confirmed 号码均不以 `+`/`00`
开头且 `_cc_asked` 未标记 → 注入国家代码追问;仅当号码已带前缀**或**
`_cc_asked` 已标记,且 confirmed 含号码渠道不含 email 且 `_email_asked`
未标记 → 注入邮箱轻补。一次最多返回一条。
- 注入文案(逐字模板,中英双语随 latest_msg 语言选择——参考 `_lang_note_for`
的实现形态):
- cc 追问英文:`"Could you send the full number with the country code, like +966 5x xxx xxxx? Just so our manager can reach you directly."`(中文版:`"麻烦重发一下带国家代码的完整号码呗(比如 +966 5x xxx xxxx)?客户经理直接加你。"`)
- email 轻补英文:`"Got it! And an email address would help too — our manager will send the formal quotation PDF there."`(中文版:`"收到!邮箱也留一个呗?客户经理把正式报价单 PDF 发你邮箱。"`)
- 注入格式仿既有 contact_note:`⚠️` 前缀系统指令 + 逐字话术 + "仅此一次,客户不给就不再提"指引
- 计数写回:stream 方法在组装 reminder 前调 `_contact_completion_note`,
reminder 尾部拼接文案;**流式开始前**把返回的计数键写进
`profile.raw_info` 并随本轮事务提交(staticmethod 收到的是内存副本,
写回必须在调用方——spec §4.3 实现约束)。
- [ ] **Step 1: 失败单测**(追加到 test_lead_email_trigger.py,参考其现有 _build_context_reminder 直调风格)
```python
def test_contact_completion_note_serial_priority():
"""spec §4.3b: 裸号+无邮箱(主场景)只注入国家代码追问;号码闭环后才可能
注入邮箱轻补;各计数消耗后不再注入。"""
from app.services.conversation import _contact_completion_note
wa_bare = {"whatsapp": "0501234567"} # 裸号(无+/00前缀)
wa_full = {"whatsapp": "+966501234567"}
wa_email = {"whatsapp": "+966501234567", "email": "a@b.com"}
# 1) 裸号+无邮箱 → cc追问,计数["_cc_asked"]
note, marks = _contact_completion_note({"联系方式": wa_bare}, wa_bare, {})
assert "country code" in note and marks == ["_cc_asked"]
# 2) _cc_asked已消耗+仍裸号无邮箱 → 转邮箱轻补
note, marks = _contact_completion_note(
{"联系方式": wa_bare, "_cc_asked": True}, wa_bare, {})
assert "email" in note and marks == ["_email_asked"]
# 3) 完整号+无邮箱 → 直接邮箱轻补
note, marks = _contact_completion_note({"联系方式": wa_full}, wa_full, {})
assert "email" in note and marks == ["_email_asked"]
# 4) 双渠道齐 → 不注入
note, marks = _contact_completion_note({"联系方式": wa_email}, wa_email, {})
assert note == "" and marks == []
# 5) email-only → 不注入(不硬要WhatsApp)
note, marks = _contact_completion_note(
{"联系方式": {"email": "a@b.com"}}, {"email": "a@b.com"}, {})
assert note == "" and marks == []
# 6) 计数均已消耗 → 不再注入(防死缠)
note, marks = _contact_completion_note(
{"联系方式": wa_bare, "_cc_asked": True, "_email_asked": True},
wa_bare, {})
assert note == "" and marks == []
```
- [ ] **Step 2: 跑红**
- [ ] **Step 3: 实现 `_contact_completion_note`**(纯函数:按上述串行优先级;文案含中英两版,语言随参数选——若直调测试传不了 latest_msg,函数签名加 `lang="en"` 参数,中文由调用方传)
- [ ] **Step 4: 跑绿单测**
- [ ] **Step 5: 接线 _build_context_reminder**(有画像路径 parts 拼接处追加 note;空画像早退路径不注入——没联系方式谈不上补全)+ stream 方法流式前写回计数键
- [ ] **Step 5b: 改写 conversation.py 的两处硬禁令提醒句**(plan review Issue 3——防自锁不只 system.md 一处;复审补正:是两句不是一句):
- `:646`(索要我方联系方式确认路径)"绝对禁止再索要任何联系方式!" → 改为
"绝对禁止再索要客户**已提供过**的联系方式;缺失渠道仅可按轻补指引提一次。"
- `:651-652`(confirmed + boundary_note 分支,**"已留资+当轮问价"最常见组合
就走这里**)"…禁止再索要联系方式。" → 尾部补"(缺失渠道可按轻补指引提一次)"。
⚠️ 断言因果(复审确认):test_lead_email_trigger.py `:475` 断言的
"禁止再索要联系方式"子串实际来自 `:651`——只改 `:646` 时三处断言都不会挂
(空转),改了 `:651` 后 `:475/:881` 才需要同步更新(届时按现有清单执行)。
- [ ] **Step 6: 集成测试**(spec §6-6 计数持久化)——用同文件现有 manager 流程测试风格(CaptureAI 捕获注入 context)构造两轮对话:**第一轮正向断言**(context 含追问句原文——防"写了计数却没拼进 reminder"的接线失误)+ 第一轮后查 DB `raw_info` 含 `_cc_asked`、第二轮 reminder 不再含追问句
- [ ] **Step 7: 全量绿 + commit**
```bash
python -m pytest -q && git add -A && git commit -m "feat: 国家代码/邮箱双要状态提醒+一次性计数(串行优先级)
Co-Authored-By: Claude Code <noreply@anthropic.com>"
```
---
### Task 4: system.md 话术升级(7 处,静态断言先行)
**Files:**
- Modify: `app/prompts/system.md`(先通读全文再动笔)
- Test: `tests/test_extractor_evaluator_prompts.py` 或新建(静态断言风格)
- [ ] **Step 1: 写失败静态断言**
```python
def test_system_md_dual_contact_wording():
src = open("app/prompts/system.md", encoding="utf-8").read()
# 国家代码引导(spec §4.1-1)
assert "with the country code" in src
# 分步双要+不坚持(§4.1-2/3)
assert "formal quotation PDF" in src and "轻补" in src
# 追问话术(§4.1-4)
assert "send the full number with the country code" in src
# 好差示例新增2组(§4.1-5)
assert src.count("国家代码") >= 2
# 全局禁令改写(§4.1-6——防自锁)
assert "已在手的渠道" in src and "缺失渠道" in src
# 模板句枚举新增(§4.1-7)
assert "国家代码追问句" in src and "邮箱轻补句" in src
```
- [ ] **Step 2: 跑红** → **Step 3: 改 system.md 七处**(话术全文见 spec §4.1;⚠️ 第 6 处"已留资时不用任何索要句"改写是防自锁关键,逐字改为"已留资时不再索要**已在手的渠道**(改确认式);**缺失渠道**可按【联系方式引导策略】轻补一次")。
⚠️ **plan review Issue 3 补充——防自锁是四处不是一处**:除 L7 外,system.md
还有三处同款硬禁令必须同步改写(否则更显眼的老禁令抑制新行为):
- 红线节(约 :59)"绝不再索要联系方式"→ 补"(缺失渠道可轻补一次)"
- 系统提醒配合节(约 :201)同款表述同步
- 对话流程节(约 :166)"拿到联系方式后…别再提联系方式"→ 补"(缺失渠道轻补一次除外)"
- [ ] **Step 4: 跑绿 + 全量绿**(现有 system.md 文案断言若冲突同步更新)
- [ ] **Step 5: 模板句过墙断言**(spec §6-4,防自锁报警线):
```python
def test_new_templates_pass_reask_wall():
"""新模板句逐字原文在所有渠道状态下均不命中 asks_contact(spec §6-4):
墙只拦意译索要,不得拦系统注入话术——若扩REASK模式或改措辞致命中,此断言报警。"""
from app.services.boundary_filter import asks_contact
cc_en = ("Could you send the full number with the country code, "
"like +966 5x xxx xxxx? Just so our manager can reach you directly.")
em_en = ("Got it! And an email address would help too — our manager "
"will send the formal quotation PDF there.")
for channels in (None, set(), {"whatsapp"}, {"email"},
{"whatsapp", "phone"}, {"whatsapp", "email"}):
for s in (cc_en, em_en):
assert not asks_contact(s, channels), (s, channels)
```
- [ ] **Step 6: 全量绿 + commit**
```bash
python -m pytest -q && git add -A && git commit -m "feat: system.md双要话术+国家代码引导+全局禁令改写(防自锁)
Co-Authored-By: Claude Code <noreply@anthropic.com>"
```
---
### Task 5: 全量回归 + 生产部署 + 真实 LLM 验收
- [ ] **Step 1: 全量测试 + 全局残扫**(无意外残留)
```bash
python -m pytest -q
```
- [ ] **Step 2: 部署**(照 CLAUDE.md 发版流程:push → 服务器 pull + build + up → health 验证)
- [ ] **Step 3: 生产三场景验收**(spec §6-8,用测试号):
① 给裸号 `0501234567` → AI 礼貌请其重发完整号(**仅一次**);
② 留 WhatsApp → AI 轻补邮箱一次;
③ 只留邮箱 → 不被纠缠要 WhatsApp
(验收后 admin 删除测试会话;检查推送日志无 `_` 前缀键泄漏)
- [ ] **Step 4: commit 验收记录**(如有文档更新)
---
## 回滚
任一任务出问题:`git revert <commit>` 重发版(发版流程同上);话术类改动可单独回滚 Task 4 的 commit。