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

17 KiB
Raw Blame History

联系方式双要 + 国家代码完整化 实施计划

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:⚠️ 前缀系统指令 + 逐字话术 + "仅此一次,客户不给就不再提"指引
  • 计数写回: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。