diff --git a/docs/superpowers/plans/2026-10-08-dual-contact-and-country-code.md b/docs/superpowers/plans/2026-10-08-dual-contact-and-country-code.md new file mode 100644 index 0000000..27c4858 --- /dev/null +++ b/docs/superpowers/plans/2026-10-08-dual-contact-and-country-code.md @@ -0,0 +1,303 @@ +# 联系方式双要 + 国家代码完整化 实施计划 + +> **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 不动,新增渠道映射层): + +```python +# 渠道精确化(2026-10-08 spec §4.4): REASK 模式命中后按渠道词分辨—— +# 索要已在手的渠道才丢,索要缺失渠道放行(双要补邮箱场景);泛渠道词 +# (number/phone/contact/电话/联系方式/号码)只要手上有任何号码渠道即丢, +# 宁丢勿放防重复索要;精确+泛同句共存时任一命中判丢。 +_PRECISE_CHANNEL_RE = re.compile( + r"whatsapp|e-?mail|邮箱|微信|wechat", re.I) +_GENERIC_CONTACT_RE = re.compile( + r"(?:^|\b)(?:number|phone|contact|电话|联系方式|号码)", re.I) + + +def asks_contact(sentence: str, confirmed_channels: set | None = None) -> bool: + """输出句是否在索要联系方式(已留资时用于丢弃)。 + + confirmed_channels=None → 原行为(命中即丢,向后兼容); + 传入已确认渠道集合(如 {"whatsapp","phone"}) → 渠道精确化。""" + s = sentence or "" + if confirmed_channels is None: + return any(p.search(s) for p in REASK_PATTERNS) + if not any(p.search(s) for p in REASK_PATTERNS): + return False + has_number_channel = bool( + confirmed_channels & {"whatsapp", "phone", "wechat"}) + for m in _PRECISE_CHANNEL_RE.finditer(s): + w = m.group(0).lower() + ch = ("email" if w in ("e-mail", "email", "邮箱") + else "wechat" if w in ("微信", "wechat") else "whatsapp") + if ch not in confirmed_channels: + return False # 索要缺失的精确渠道 → 放行 + if has_number_channel and _GENERIC_CONTACT_RE.search(s): + return True # 泛词 + 手上有号码 → 丢 + return True # 命中但无精确渠道词(纯泛词/其他形态) → 保守丢 +``` + +- [ ] **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 6: 全量绿 + commit** + +```bash +python -m pytest -q && git add -A && git commit -m "feat: 再索要墙渠道精确化——索要缺失渠道放行(双要前置) + +Co-Authored-By: Claude Code " +``` + +--- + +### 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 " +``` + +--- + +### 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 6: 集成测试**(spec §6-6 计数持久化)——用同文件现有 manager 流程测试风格构造两轮对话:第一轮后查 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 " +``` + +--- + +### 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 处"已留资时不用任何索要句"改写是防自锁关键,逐字改为"已留资时不再索要**已在手的渠道**(改确认式);**缺失渠道**可按【联系方式引导策略】轻补一次")→ **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 " +``` + +--- + +### 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 ` 重发版(发版流程同上);话术类改动可单独回滚 Task 4 的 commit。