Files
ai-kefu-jc/docs/superpowers/plans/2026-10-08-cc-completion.md
T

194 lines
16 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.
# 号码区号补全(IP 推测 + 回显确认 + 首封补全号)实施计划
> **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:** 客户号码缺国家区号时,按 IP + phonenumberslite 判定补全,AI 回显补全号请客户确认,首封线索邮件即带补全号(零延迟、标注透明)。
**Architecture:** 确定性判定树与状态机(新模块 cc_completion + `_cc` 结构化状态,canon 组换号重置)+ extractor LLM 答语分类(cc_reply 双门控)+ 既有墙骨架改造(确认句墙检测 e164 在场、白名单放行注入号)+ 出站先消费后过滤。
**Tech Stack:** phonenumberslite(新增依赖)/ FastAPI / pytest(LLM mock)
**Spec(唯一依据,六轮打磨):** `docs/superpowers/specs/2026-10-08-cc-ip-country-confirm-design.md`
**约定:** 每任务全量 pytest 绿才 commit(当前基线 256);中文一律 Read/Edit 工具写;行号可能漂移,动笔先读代码核实锚点。
---
### Task 1: 基础层——phonenumberslite + ip_service 扩展 + REST XFF
**Files:**
- Modify: `requirements.txt`(+phonenumberslite)、`app/services/ip_service.py`、`app/api/routes/chat_rest.py`(/session 路由)
- Test: `tests/test_ip_service.py`(新建)
- [ ] **Step 1: 依赖** `pip install phonenumberslite` + requirements.txt 加行
- [ ] **Step 2: 失败测试**(新建 tests/test_ip_service.py):
```python
def test_resolve_region_code():
from app.services.ip_service import resolve_region_code
assert resolve_region_code("104.28.41.169") in ("HK", "SG", None) # 公网IP返回ISO或None,绝不返回中文/"本地"
assert resolve_region_code("") is None
assert resolve_region_code("127.0.0.1") is None
def test_country_names_ca_bug_fixed():
from app.services import ip_service
assert ip_service.COUNTRY_NAMES.get("CA") == "加拿大"
assert ip_service.COUNTRY_NAMES.get("AU") == "澳大利亚"
```
- [ ] **Step 3: 跑红** → **Step 4: 实现**:ip_service 加 `resolve_region_code(ip) -> str | None`(iptocc 的 country_code 直透,"本地/未知/--"归 None);修字典 `"AU": "加拿大"` 重复键(CA 缺失)
- [ ] **Step 5: REST XFF**:/session 路由注入 `Request`,XFF 第一段**服务端优先**合并进 client_info(WS 的 chat.py:60 客户端覆盖服务端可伪造——一并改为服务端优先,两处注释说明)
- [ ] **Step 6: 全量绿 + commit** `feat: 基础层——phonenumberslite/resolve_region_code/CA字典bug/REST会话XFF服务端优先`
---
### Task 2: 核心模块 cc_completion(判定树 + 状态机,纯函数,TDD 重头)
**Files:**
- Create: `app/services/cc_completion.py`
- Test: `tests/test_cc_completion.py`(新建)
- [ ] **Step 1: 失败测试矩阵**(spec §3/§4 全转移,先写全再实现):
```python
# 判定树矩阵(spec §3)
def test_classify_tree():
from app.services.cc_completion import classify, HAS_CC, NEED_CC
assert classify("+966501234567", "SA")[0] == HAS_CC # a: +前缀
assert classify("00966501234567", "SA")[0] == HAS_CC # a: 00前缀
r = classify("966501234567", "SA") # b: IP码开头静默规范化
assert r[0] == HAS_CC and r[1]["e164"] == "+966501234567" and r[1]["source"] == "prefix_normalized" and r[1]["confirmed"] is True
r = classify("0501234567", "SA") # c: IP国解析合法
assert r[0] == NEED_CC and r[1]["source"] == "ip_proposed" and r[1]["e164"] == "+966501234567"
r = classify("0501234567", "KE") # d: 均不匹配→naive
assert r[0] == NEED_CC and r[1]["source"] == "naive"
r = classify("0501234567", None) # e: 无IP
assert r[0] == NEED_CC and r[1].get("suggest_ask_full") is True
# 状态机(spec §4): canon重置/attempt递增/affirm/国名重算/耗尽fallback/漏填兜底
def test_transit_full_lifecycle(): ... # 按spec §4逐转移实现
def test_name_to_region(): ... # 中英国名→ISO(覆盖 COUNTRY_NAMES 全集+常见英文)
def test_format_intl(): ... # e164→分组展示 "+254 712 345 678"
```
- [ ] **Step 2: 跑红** → **Step 3: 实现 cc_completion.py**:
- `classify(number, ip_iso) -> (kind, cc_dict)`:四分支判定树(b 分支=剥 IP 国码前缀(`country_code_for_region`)+ 剩余段 `parse(num, region)+is_valid_number` 严格校验;c=`parse` IP 国合法;d=naive 去前导0+IP 码;e=无建议国)
- `NAME_TO_REGION`、`format_intl(e164)`(phonenumbers `format_number(International)`)
- `transit(cc_state, reply, new_canon)`:状态机纯函数(affirm→ip_confirm 落定;country 重算再回显 attempts+1;canon 变化→重置;attempts≥2→source=fallback 落定;无 reply→attempts+1)
- `cc_due(cc_state)`:等待态判定(reminder 消费条件);**None(无 `_cc`,号本就带 +/00)→ 非等待**(邮箱轻补不回归)
- `cc_settled(cc_state)`:落定判定(邮箱轻补解锁条件,spec §4 统一口径);**None → True**
- 测试矩阵点名两条易漏转移(评审补):国名重述不重置 attempts;no_ip 上限 1 轮
- [ ] **Step 4: 跑绿矩阵** → **Step 5: 全量绿 + commit** `feat: cc_completion判定树+状态机纯函数`
---
### Task 3: 管道接入(轮前判定/轮末消费/出口卫生/存量迁移)
**Files:**
- Modify: `app/services/conversation.py`(轮前即时扫描后、轮末合并区、`_contact_completion_note` 改造)、`app/services/ai_service.py`(复核入口过滤)
- Test: `tests/test_lead_email_trigger.py`
- [ ] **Step 1: 轮前钩子**(spec §6-1):即时扫描建 effective_raw 后**同步跑判定树**——裸号且无 `_cc`(或 canon 变化)→ 建 `_cc`(仅 whatsapp 组确认流;phone 组 ip_proposed 静默)→ 与一次性计数写回同事务落库。`_contact_completion_note` 的 cc 分支删除(其 email 分支保留),cc 提醒改由本钩子产出(Task 4 接)
- [ ] **Step 2: 轮末钩子**(spec §6-2):四层合并后、D1 邮件前——消费 `cc_reply`(仅 `cc_due` 等待态,双门控 Python 侧)→ `transit` 状态更新落库;**剥离 cc_reply**(瞬态);**丢弃 extractor 回传的任何 `_` 前缀键**(出口卫生 3)
- [ ] **Step 3: 存量迁移**(spec §4 尾):读到旧 `_cc_asked=True` 且号码裸 → 一次性改写为 `_cc{confirmed:false, source:fallback}`(不追问直通轻补)
- [ ] **Step 3b(评审补)**:计数写回块(:263-283 `if completion_marks:`)条件扩为"计数或 `_cc` 新建任一存在";轮前钩子**在 `_build_context_reminder`(:258)之前**执行(写明顺序)
- [ ] **Step 4: 集成测试**(FakeAI 流程):裸号轮当轮 `_cc` 已建(轮前钩子);cc_reply affirm→落定;canon 换号→重置重走;attempts 耗尽→fallback;出口无 `_` 残留/cc_reply 剥离;旧 `_cc_asked` 会话不重复问;**非等待态收到 cc_reply → Python 侧忽略**(双门控确定性半边);**canon 组归并**(phone/whatsapp 同号确认一次、异号仅 whatsapp 走确认流——管道侧逻辑)
- [ ] **Step 5: 全量绿(既有 CC 相关断言按拆除清单同步迁移)+ commit** `feat: 判定树轮前接入+cc_reply消费+出口卫生+存量迁移`
---
### Task 4: 提醒与墙改造(回显确认模板/确认句墙/白名单)
**Files:**
- Modify: `app/services/conversation.py`(reminder 注入/墙区传参/让位句)、`app/services/boundary_filter.py`(no_contact_value 加 allow_number)
- Test: `tests/test_boundary_filter.py`、`tests/test_lead_email_trigger.py`
- [ ] **Step 1: 失败测试**:白名单(同号放/异号拦/无参照拦/多号同句仅白名单号放);确认句墙(检测词=注入 e164 digits 等值在场,含辅锚 "is that right"/"对吗";不在则整段替换 `_CC_ECHO_*`)
- [ ] **Step 2: 实现**:
- `no_contact_value(sentence, allow_number=None)`:逐 `LONG_NUMBER_RE` 匹配段 digits_only 归一比对,**全部段**命中 allow_number 才放行该句(混入异号照拦)
- 新模板 `_CC_ECHO_EN/_ZH`("…reach you at {intl} — is that right?" 中英,**与 reminder/墙/断言三方同源**——沿用 `_CC_ASK_*` 拼装式结构改造)
- **no_ip 路径专属(评审补)**:IP 无效无可回显——保留一条 ask 形态模板
(`_CC_ASK_*` 原句保留不拆除,服务 no_ip 轮"方便发带区号完整号吗"1 轮为限);
该类轮墙裁决锚 = 问句短语("full number with country code"/"带国家代码")在场,
与 e164 在场锚并列;补一条 no_ip 管道测试(防静默失能)
- **三处门控改线(评审补)**:旧 `completion_marks == ["_cc_asked"]` 触发点
(cc_wall :304 / 让位句 :805-814 / cc 提醒注入 :837)全部改线到 `cc_due`;
conversation :755/:802-824 三处 Python 注入提醒里的"可请客户重发完整号一次"
短语同步收敛为回显语义(防同场竞争降低首过命中率)
- reminder:`cc_due` 时注入回显句(intl 由 Python 算好,不让 GLM 拼号);确认句墙(缓冲)裁决词从 "country code" 改为注入 e164 在场;conversation 墙区调用传 `allow_number=等待态的 e164`
- 让位句改写(4b-3):"回复中绝不写出号码数字——**唯一例外是系统提供的补全号原文(逐字)**"
- [ ] **Step 3: 跑绿 + 全量绿 + commit** `feat: 回显确认模板+确认句墙e164检测+白名单逐段放行`
---
### Task 5: 提示词层(system.md/extractor.md 改写 + 拆除清单 + 断言迁移)
**Files:**
- Modify: `app/prompts/system.md`、`app/prompts/extractor.md`
- Test: `tests/test_system_prompt_dual_contact.py`、`tests/test_extractor_evaluator_prompts.py`
- [ ] **Step 1: 失败断言**(新语义):五处例外句含"回显补全后的完整号码";好差示例含回显组+自编号差例;extractor.md 含 cc_reply 字段说明与双门控声明
- [ ] **Step 2: 改 system.md**(4b-1/2,评审修正——**grep 全量改写而非固定清单**):
grep "重发完整号/号码缺国家代码" 的**全部命中处**(实测 :7/:59/:175/:223/:229
+ 引导策略节 :198-200 + 好差示例 :162-166)改"回显确认"语义;**特别两处高权重锚**:
① :7 语言规则模板句清单成员(旧追问句→回显确认句);② **:56 红线"绝不能在回复里
写出号码"加例外"唯一例外=系统提供的补全号原文(逐字)"**——不改则 GLM 拒写号、
墙轮轮替换(锚点竞争教训);好差示例两改一增(裸号组改回显形态;增"AI 自编号码"差例)。
Step 1 断言按 **grep 命中计数**而非固定五处
- [ ] **Step 3: 改 extractor.md**(4b/§2 双门控):加 `cc_reply` 可选字段(`{"affirm": true}` / `{"affirm": false, "country": "..."}`)+ "仅当客户在回应号码确认时填写"
- [ ] **Step 3b: 语言规则放开(需求方确认 2026-10-08)**:system.md :7 的"模板句
只有中英两套原版、绝不翻译成第三种语言"改为"默认英语;模板句**跟随客户语言
自然表达**——系统提醒提供的逐字话术优先用其语言版本,无则按客户语言自然
翻译(保持意思与礼貌度)";"翻译是违规"示例反向修正;CLAUDE.md 差异表同步。
**风险记录**:REASK 墙仍中英覆盖,放开后"已留资重复索要"的小语种形态靠
reminder 确认式压制——记入观察期(后续按需扩 REASK 句式)。真实验收补
西语客户留资一轮(Task 7 验收⑥)
- [ ] **Step 4: 断言全量迁移**(test_system_prompt_dual_contact 四断言按新语义重写;同源断言锚 `_CC_ECHO_*` 正文)
- [ ] **Step 5: 全量绿 + commit** `feat: 提示词回显确认语义+extractor cc_reply字段+断言迁移`
---
### Task 6: 出站渲染(首封补全号/标注/主题行/推送/面板)
**Files:**
- Modify: `app/services/email_service.py`、`app/services/lead_push_service.py`、`app/services/conversation.py`(ev.contact_info 尾注 + 邮箱轻补条件)
- Test: `tests/test_email_rendering.py`、`tests/test_lead_push_service.py`
- [ ] **Step 1: 失败测试**:首封渲染 e164+`(区号按 IP 推测,未经客户确认)`(confirmed=false 时);prefix_normalized **无**标注;主题行 e164 无标注;no_ip 裸号+`(缺国家区号)`;确认后(号码未变)**不补发**;换号走现有更正;push contact 纯 e164 + profile 含派生标注键;`_` 键过滤前消费;**跨会话 24h 去重(评审补)**——同客户新会话再留同一裸号,canon 对比集须并入 `_cc.e164` 的 canon,回访轻提醒不退化为全量询盘
- [ ] **Step 2: 实现**:email_service 渲染 `_cc`(正文/主题/更正/回头客,签名加参);lead_push `profile_out` 先消费 `_cc`(contact 用 e164、标注进 `号码补全说明` 派生键)再过滤 `_`;`ev.contact_info` 等待态尾注;`_contact_completion_note` 的 email 分支解锁条件改 `cc_settled`;**canon 比对口径钉死(评审补)**:`ev.contact_info` 存裸号原文+尾注、e164 走派生,且同会话更正判定(:635 old_canons)与跨会话去重集(:577-591)**并入 `_cc.e164` 的 canon**——防 e164 替换本体造成"每轮误判换号→更正连发"与"回访退化"
- [ ] **Step 3: 跑绿 + 全量绿 + commit** `feat: 出站补全渲染——首封e164+标注/主题行/推送派生/面板尾注`
---
### Task 7: 全量回归 + 部署 + 真实 LLM 验收
- [ ] **Step 1: 全量 pytest + 既有测试迁移清点**(D1"confirmed 即发"断言 fixture 本地格式号→补全渲染值更新;回显豁免旧断言残留清零;CLAUDE.md 测试基线数更新)
- [ ] **Step 2: 部署**(CLAUDE.md 发版流程;镜像含新依赖自动装)
- [ ] **Step 3: 真实 LLM 验收(⚠️ 全部在本地环境——需求方明令禁止生产测试,防发邮件污染线上数据)**:
本地起 uvicorn + 本地 .env(真实智谱 Key 已有;**SMTP 留占位**→发送失败但触发链路在
本地日志/DB 可查;PUSH_ENABLED=false)。IP 场景:本地 IP 为内网(resolve→None→
no_ip 路径天然覆盖);带 IP 场景用本地 .env 之外临时 client_info 自报公网 IP 模拟
(或仅靠单测矩阵覆盖判定树分支,真实验收聚焦对话行为)。场景清单:
①裸号(`0501234567`)→ **同轮** AI 回显 "+966 50 123 4567 — is that right?"(墙保+白名单放行)→ 客户 "yes" → 确认落定不再追问
②验收 ① 变体:客户 "no, I'm in Egypt" → 重算 egypt 码再回显 → yes → 落定
③客户答非所问(聊产品)→ 2 轮后不再纠缠;首封邮件检查:带 e164+标注
④误判对照:确认等待期客户对无关问题答 yes → **不得**触发确认
⑤邮箱-only 客户零干扰;带 + 号客户零干扰(旁路)
⑥西语客户全流程(语言规则放开验证:AI 用西语回显确认+翻译质量+墙 e164 锚
语言无关不受影响)
验收后清理本地测试会话;**生产部署后仅做只读冒烟**(/health+浏览页面,不对话不留资)
- [ ] **Step 4: 观察期提醒**(breach_rate 号码回显观测已在线)+ commit 收尾文档
---
## 拆除清单(Task 3-5 执行时对照,防漏拆)
| 旧机制(206beae..cbf234d) | 处置 |
|---|---|
| `_CC_ASK_EN/_ZH` 追问句 | **保留不拆**(服务 no_ip 轮);新增 `_CC_ECHO_*` 承接回显场景 |
| `_CC_WALL_*` 检测 "country code" | Task 4 改:回显轮=e164 在场,no_ip 轮=问句短语锚(保留) |
| `_cc_asked` 计数写入 | 停写;存量映射(Task 3 Step 3) |
| `_contact_completion_note` cc 分支 | 删除(判定树接管);email 分支保留改条件 |
| system.md 五处"重发完整号"例外 | Task 5 改回显语义 |
## 回滚
git revert 对应 commit 重发版;判定树/渲染均为独立模块,单任务可回退。