Co-Authored-By: Claude Code <noreply@anthropic.com>
17 KiB
联系方式双要 + 国家代码完整化 实施计划
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 测试的导入风格)
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 的两条断言必挂——"泛词+仅邮箱"和"精确+泛同句"两个分支被兜底吞掉)。判定顺序固定:①在手精确词→丢;②泛词+有号码渠道→丢;③命中但句中无任何渠道词→保守丢;④其余→放行:
# 渠道精确化(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两个调用点用:
_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
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 相关测试的构造方式)
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 改:
# _ 前缀内部状态(一次性计数等)不进推送画像(spec §4.3——渲染侧
# 两处有过滤,推送端曾整体拷贝泄漏)
profile_out = {k: v for k, v in (raw_info or {}).items()
if not str(k).startswith("_")}
- Step 4: 全量绿 + commit
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:
⚠️前缀系统指令 + 逐字话术 + "仅此一次,客户不给就不再提"指引
- cc 追问英文:
-
计数写回:stream 方法在组装 reminder 前调
_contact_completion_note, reminder 尾部拼接文案;流式开始前把返回的计数键写进profile.raw_info并随本轮事务提交(staticmethod 收到的是内存副本, 写回必须在调用方——spec §4.3 实现约束)。 -
Step 1: 失败单测(追加到 test_lead_email_trigger.py,参考其现有 _build_context_reminder 直调风格)
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:646 确认路径提醒句的硬禁令(plan review Issue 3——防自锁不只 system.md 一处): 该句现为"绝对禁止再索要任何联系方式!",与新轻补 note 同轮注入时 GLM 收到 自相矛盾指令。改为:"绝对禁止再索要客户已提供过的联系方式;缺失渠道 仅可按上述轻补指引提一次。" 同步更新
tests/test_lead_email_trigger.py中 :356/:475/:881 附近的"绝对禁止再索要"类文案断言。 -
Step 6: 集成测试(spec §6-6 计数持久化)——用同文件现有 manager 流程测试风格(CaptureAI 捕获注入 context)构造两轮对话:第一轮正向断言(context 含追问句原文——防"写了计数却没拼进 reminder"的接线失误)+ 第一轮后查 DB
raw_info含_cc_asked、第二轮 reminder 不再含追问句 -
Step 7: 全量绿 + commit
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: 写失败静态断言
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,防自锁报警线):
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
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: 全量测试 + 全局残扫(无意外残留)
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。