Files
ai-kefu-jc/app/services/cc_completion.py
T
浅念 568c116839 fix: 验收P1P2——轮末新建态免计免消费(回显首轮语义)+P3西语ECHO/ASK模板
P1(换国"报国→再回显→确认"结构性不可达) + P2(extractor 裸号轮误
affirm 跳过确认流)同根因修复: "轮前新建态"的轮语义——回显/追问首次
发出轮,客户本轮还没机会回应确认句,轮末既不消耗 attempts 也不消费
cc_reply。实现: _cc_round_end 增 first_ask 判据(本轮墙确有触发 × 状态
未标记 asked),首问轮只落 asked 标记直通;attempts 从等待回应轮起计。
判据用 asked 标记而非轮前快照比对——统一覆盖轮前新建与轮末补建两条
建态路径的首个墙轮(补建态当轮无墙,首问顺延下轮,否则 P1 结构性残留)。

P3(西语回显轮是英文): 新增 _CC_ECHO_ES/_CC_ASK_ES/_CC_WALL_ES,
_cc_script/_cc_wall_template/_cc_confirm_note 语言分流扩 ES(_cc_lang_of,
与 _lang_pool 同款西语特征粗匹配);no_ip 墙裁决锚补 "código de país"
西语同词(服从句不再被误替换);e164 digits 裁决锚语言无关不受影响。

修复后时序(常规 cap=2): 回显轮(免计0)→等待回应轮起计1→2落定,
落定较修复前多一轮,客户回应机会不变;no_ip cap=1: 首问轮免计,
报国/不理睬一轮后落定。

测试 308→315: 新增7(换国三轮流/首问轮免消费/补建路径首问免计/西语
ECHO墙/西语reminder/西语no_ip ASK/西语服从句放行);迁移9项受
"新建态免计"影响的既有断言(attempts 起点变化/落定轮次顺延)。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-08 20:49:13 +08:00

377 lines
17 KiB
Python
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.
"""号码区号补全核心:判定树 + 确认状态机(纯函数模块)。
设计依据:docs/superpowers/specs/2026-10-08-cc-ip-country-confirm-design.md §3/§4。
分工:本模块只做确定性计算(phonenumberslite 全离线),不 import
conversation/DB/LLM——IO、存储与提示词注入归调用方(轮前/轮末钩子)。
状态字典 schema(存 raw_info["_cc"][渠道],spec §4)::
{
"e164": "+966501234567", # 补全号;no_ip 态无此键
"intl": "+966 50 123 4567", # 分组展示(回显/邮件正文用)
"confirmed": false,
"attempts": 0, # 确认轮计数(只被 canon 变化重置)
"source": "ip_proposed", # ip_proposed|naive|ip_confirm|
# prefix_normalized|customer_stated|no_ip|fallback
"canon": "0501234567", # 客户原始号的纯数字形态(换号=RESET 的判据)
"asked": true, # 确认句已发出(conversation 轮末钩子管理,
# 首问轮免计;本模块不读不写,transit 原样携带)
}
"""
from __future__ import annotations
import re
import phonenumbers
from phonenumbers import PhoneNumberFormat
# 判定树两类结局:
# HAS_CC —— 已带国家码,现有流程直走。含两个子形态:显式 +/00 前缀(dict=None,
# 调用方无需建状态);裸写 IP 国码且剩余段合法(prefix_normalized 态,
# confirmed=true,首封无"未经确认"标注)。
# NEED_CC —— 缺国家码,进入确认流(ip_proposed/naive/no_ip 态)。
HAS_CC = "has_cc"
NEED_CC = "need_cc"
# 确认轮上限(spec §4 统一口径):常规路径每渠道 2 轮;
# no_ip(无建议国,只问一次"方便发带区号完整号吗")1 轮。
ATTEMPT_CAP_DEFAULT = 2
ATTEMPT_CAP_NO_IP = 1
# transit 返回 None 即 RESET:canon 变化(换号),调用方对号码重走判定树重建状态。
RESET = None
def _digits(text: str | None) -> str:
"""纯数字提取(canon 口径:客户原始号去一切非数字字符)。"""
return re.sub(r"\D", "", text or "")
def _safe_country_code(iso: str | None) -> str | None:
"""region ISO → 拨号码字符串(如 "SA"→"966");未知 region 返回 None。"""
if not iso:
return None
try:
cc = phonenumbers.country_code_for_region(iso)
except Exception:
return None
# 未知 region phonenumbers 返回 0(不抛),与 None 同义
return str(cc) if cc else None
def _try_parse(text: str, region: str):
"""parse 防御包装:抛错(含空串/垃圾/非法 region)归 None。"""
try:
return phonenumbers.parse(text, region=region)
except Exception:
return None
def format_intl(e164: str) -> str:
"""E.164 → 该国官方分组的国际展示格式(回显/邮件正文用)。
非法/空输入原样返回(防御:naive 拼出的号可能不被 parse 接受)。
"""
if not e164:
return e164 or ""
try:
parsed = phonenumbers.parse(e164)
return phonenumbers.format_number(parsed, PhoneNumberFormat.INTERNATIONAL)
except Exception:
return e164
def _new_state(canon: str, source: str, e164: str | None = None,
confirmed: bool = False) -> dict:
st = {"confirmed": confirmed, "attempts": 0, "source": source, "canon": canon}
if e164:
st["e164"] = e164
st["intl"] = format_intl(e164)
return st
def _complete_with_region(digits: str, iso: str) -> dict | None:
"""按给定 region 把裸号补全为 E.164——判定树 c/d 与状态机重算共用。
先严格解析(c:region=该国 parse 且 is_valid → 高置信),
失败退 naive(d:去前导 0 + 该国拨号码拼接);region 不可用返回 None。
"""
if not digits or not iso:
return None
parsed = _try_parse(digits, iso)
if parsed is not None and phonenumbers.is_valid_number(parsed):
e164 = phonenumbers.format_number(parsed, PhoneNumberFormat.E164)
return {"e164": e164, "intl": format_intl(e164), "strict": True}
cc = _safe_country_code(iso)
if not cc:
return None
national = digits.lstrip("0") or digits
e164 = f"+{cc}{national}"
return {"e164": e164, "intl": format_intl(e164), "strict": False}
def classify(number: str | None, ip_iso: str | None) -> tuple[str, dict | None]:
"""判定树(spec §3):(kind, state)。
a. +/00 开头 → (HAS_CC, None),现有流程直走;
b. 以 IP 国拨号码开头且剩余段对该国 is_valid → (HAS_CC, prefix_normalized
态 confirmed=true)——静默规范化,无对话动作;
c. region=IP 国解析合法 → (NEED_CC, ip_proposed 态)——高置信建议;
d. 均不匹配 → (NEED_CC, naive 态)——去前导 0 + IP 码,回显靠客户纠错;
e. IP 无效/未知(或 region 不被 phonenumbers 认识)→ (NEED_CC, no_ip 态,
无 e164,suggest_ask_full=true,只问一次"方便发带区号完整号吗")。
防御:number 为空/全非数字 → (NEED_CC, None)(调用方契约外场景,不建状态)。
"""
raw = (number or "").strip()
digits = _digits(raw)
# a: 显式国际前缀
if raw.startswith("+") or raw.startswith("00"):
return HAS_CC, None
if not digits:
return NEED_CC, None
if ip_iso:
# b: 裸写 IP 国码 + 剩余段合法(无确认防线,靠 is_valid 严格校验兜底)
cc = _safe_country_code(ip_iso)
if cc and digits.startswith(cc) and len(digits) > len(cc):
rest = digits[len(cc):]
parsed = _try_parse(rest, ip_iso)
if parsed is not None and phonenumbers.is_valid_number(parsed):
e164 = phonenumbers.format_number(parsed, PhoneNumberFormat.E164)
return HAS_CC, _new_state(digits, "prefix_normalized",
e164=e164, confirmed=True)
# c/d: region 严格解析,失败退 naive
comp = _complete_with_region(digits, ip_iso)
if comp:
source = "ip_proposed" if comp["strict"] else "naive"
return NEED_CC, _new_state(digits, source, e164=comp["e164"])
# e: 无建议国
st = _new_state(digits, "no_ip")
st["suggest_ask_full"] = True
return NEED_CC, st
def attempt_cap(source: str | None) -> int:
"""确认轮上限:no_ip 1 轮,其余 2 轮(spec §4 统一口径)。"""
return ATTEMPT_CAP_NO_IP if source == "no_ip" else ATTEMPT_CAP_DEFAULT
def _consume_round(state: dict) -> dict:
"""消耗一轮(无回应/不可解析/报国重算后调用):attempts+1,达上限落定。
落定规则:有 e164 的路径 source 改写 fallback(标注"未经客户确认");
no_ip 无 e164 保持原 source(邮件标"缺国家区号"),仅停止追问。
"""
state["attempts"] = int(state.get("attempts", 0)) + 1
if not state.get("confirmed") and state["attempts"] >= attempt_cap(state.get("source")):
if state.get("e164"):
state["source"] = "fallback"
return state
def transit(state: dict | None, reply: dict | None,
new_canon: str | None = None) -> dict | None:
"""消费一轮 cc_reply 推进状态机(纯函数,不变异入参;spec §4 答语树)。
参数:
state —— classify 产物或上轮 transit 结果;
reply —— None(无回应/extractor 漏填兜底)或 {"affirm": bool,
"country": str|None}(extractor 单轮分类,消费后由调用方剥离);
new_canon —— 本轮最新号码的纯数字形态;与 state["canon"] 不同 → 换号。
返回:
None —— RESET(换号或无态):调用方对号码重走 classify 重建状态
(结构性解决"改号残留":旧补全永不跟随新号);
dict —— 新状态:
- affirm=true → confirmed=true(source=ip_confirm;报国重算后的确认
保持 customer_stated,号源如实标注)→ 落定;
- affirm=false + country → NAME_TO_REGION 转 ISO 重算 e164
(source=customer_stated)→ 再回显,attempts+1;
- affirm=false 无 country / 无回应 / 国名不识别 → attempts+1,
达上限(常规 2 轮)落定 confirmed=false、source=fallback。
"""
if not state:
return RESET
canon = state.get("canon")
if new_canon is not None and canon is not None and new_canon != canon:
return RESET
new = dict(state)
if reply is None:
return _consume_round(new)
affirm = bool(reply.get("affirm"))
country = (reply.get("country") or "").strip() or None
if affirm:
if not new.get("e164"):
# no_ip 态没有可确认对象——肯定答语无意义,按一轮消耗处理
return _consume_round(new)
new["confirmed"] = True
new["source"] = ("customer_stated" if new.get("source") == "customer_stated"
else "ip_confirm")
return new
if country:
iso = name_to_region(country)
recalc = _complete_with_region(canon or "", iso) if iso else None
if recalc:
new["e164"] = recalc["e164"]
new["intl"] = recalc["intl"]
new["source"] = "customer_stated"
# 国名不识别:不重算不撒谎,等同无回应走兜底(spec §9)
return _consume_round(new)
# affirm=false 无 country → 请重发完整号(新号到达=canon 变化自然 RESET)
return _consume_round(new)
def _settled(state: dict) -> bool:
"""确认流是否落定(不再追问):confirmed / attempts 耗尽 /
prefix_normalized 与 fallback 天然终态。"""
if state.get("confirmed"):
return True
if state.get("source") in ("prefix_normalized", "fallback"):
return True
return int(state.get("attempts", 0)) >= attempt_cap(state.get("source"))
def cc_due(state: dict | None) -> bool:
"""是否处于等待 cc_reply 的未落定态——消费答语/注入确认句的门控。
None → False:无状态即无等待(带 + 号客户不进流程)。
"""
if not state:
return False
return not _settled(state)
def cc_settled(state: dict | None) -> bool:
"""确认流是否已落定(或根本无流)。None → True:无态视为天然落定,
邮箱轻补解锁判断不因缺状态而回归(带 + 号客户照走现有轻补路径)。"""
if not state:
return True
return _settled(state)
# 国名 → ISO 3166-1 alpha-2。中文全集与 ip_service.COUNTRY_NAMES 对齐
# (tests/test_cc_completion.py 强制同步:COUNTRY_NAMES 新增国必须同步本表),
# 英文为对应常见英文国名(双语两键同值)+ 高频别称。
NAME_TO_REGION: dict[str, str] = {
# —— 中文(全集对齐 ip_service.COUNTRY_NAMES)——
"中国": "CN", "美国": "US", "尼日利亚": "NG", "加纳": "GH",
"肯尼亚": "KE", "坦桑尼亚": "TZ", "南非": "ZA", "埃及": "EG",
"阿联酋": "AE", "沙特阿拉伯": "SA", "伊拉克": "IQ", "约旦": "JO",
"巴基斯坦": "PK", "孟加拉国": "BD", "印度": "IN", "泰国": "TH",
"越南": "VN", "印尼": "ID", "菲律宾": "PH", "马来西亚": "MY",
"日本": "JP", "韩国": "KR", "英国": "GB", "德国": "DE",
"法国": "FR", "俄罗斯": "RU", "巴西": "BR", "墨西哥": "MX",
"澳大利亚": "AU", "加拿大": "CA", "土耳其": "TR", "伊朗": "IR",
"缅甸": "MM", "柬埔寨": "KH", "老挝": "LA", "斯里兰卡": "LK",
"尼泊尔": "NP", "乌干达": "UG", "埃塞俄比亚": "ET", "塞内加尔": "SN",
"科特迪瓦": "CI", "喀麦隆": "CM", "刚果(金)": "CD", "莫桑比克": "MZ",
# —— 中文高频简称/别称 ——
"沙特": "SA", "迪拜": "AE", "刚果金": "CD",
# —— 英文国名 ——
"China": "CN", "United States": "US", "Nigeria": "NG", "Ghana": "GH",
"Kenya": "KE", "Tanzania": "TZ", "South Africa": "ZA", "Egypt": "EG",
"United Arab Emirates": "AE", "Saudi Arabia": "SA", "Iraq": "IQ",
"Jordan": "JO", "Pakistan": "PK", "Bangladesh": "BD", "India": "IN",
"Thailand": "TH", "Vietnam": "VN", "Indonesia": "ID", "Philippines": "PH",
"Malaysia": "MY", "Japan": "JP", "South Korea": "KR", "United Kingdom": "GB",
"Germany": "DE", "France": "FR", "Russia": "RU", "Brazil": "BR",
"Mexico": "MX", "Australia": "AU", "Canada": "CA", "Turkey": "TR",
"Iran": "IR", "Myanmar": "MM", "Cambodia": "KH", "Laos": "LA",
"Sri Lanka": "LK", "Nepal": "NP", "Uganda": "UG", "Ethiopia": "ET",
"Senegal": "SN", "Ivory Coast": "CI", "Cameroon": "CM", "DR Congo": "CD",
"Mozambique": "MZ",
# —— 英文高频简称/别称 ——
"USA": "US", "UAE": "AE", "KSA": "SA", "UK": "GB", "Korea": "KR",
"Dubai": "AE", "Cote d'Ivoire": "CI", "Democratic Republic of the Congo": "CD",
# —— 超出 COUNTRY_NAMES 的高频市场补充(西语场景为本项目多语言重点)——
"西班牙": "ES", "Spain": "ES",
}
# 大小写容错影子表(中文 casefold 为恒等,不受影响)
_NAME_LOOKUP = {k.casefold(): v for k, v in NAME_TO_REGION.items()}
def name_to_region(name: str | None) -> str | None:
"""国名(extractor 回传原样字符串)→ ISO;未命中返回 None。
容错:去首尾空白、大小写不敏感、弯撇号(U+2019)归一为 ASCII。
"""
if not name:
return None
key = name.strip().replace("’", "'")
if key in NAME_TO_REGION:
return NAME_TO_REGION[key]
return _NAME_LOOKUP.get(key.casefold())
# ---- Task 6 出站渲染(spec §5):首封补全号的展示派生(纯函数,邮件/推送共用) ----
# 标注文案(spec §5 钉死):有 e164 未确认 → 未经确认;无 e164 → 缺区号。
NOTE_UNCONFIRMED = "(区号按 IP 推测,未经客户确认)"
NOTE_MISSING_CC = "(缺国家区号)"
def cc_state_for(cc_map: dict | None, channel: str, value: str) -> dict | None:
"""渠道 → _cc 状态解析(canon 组归并感知,出站渲染入口)。
优先本渠道状态;本渠道无态但 canon 与 whatsapp 组一致时共享组状态
(同号 phone/whatsapp 确认一次,渲染也同标——否则同封邮件一行 e164
一行裸号自相矛盾)。canon 不匹配(换号后残留/防御性垃圾)一律 None,
调用方回退裸号原文——旧补全永不跟随新号。
"""
digits = _digits(value)
if not digits:
return None
for state in ((cc_map or {}).get(channel), (cc_map or {}).get("whatsapp")):
if isinstance(state, dict) and str(state.get("canon") or "") == digits:
return state
return None
def outbound_number(value: str, state: dict | None) -> str:
"""
⚠️ 前提: state 必须来自 cc_state_for 的产物(canon 已匹配);
裸传 state 会失去残留态防御(审查 Issue 2)。邮件正文联系方式行的展示值(spec §5 首封矩阵):
- 有 e164 且 confirmed → 纯 e164(prefix_normalized/ip_confirm/
customer_stated,码是客户自己写的/确认过的,零标注);
- 有 e164 未 confirmed → e164 +(区号按 IP 推测,未经客户确认)
(ip_proposed/naive/fallback 统一口径,spec §4 标注语义);
- 无 e164(no_ip / 迁移分支可产出的 fallback-无-e164)→ 裸号原文 +
(缺国家区号)——拿不出补全号不硬造;
- 无状态(显式 +/00 号码等)→ 原样。
"""
raw = str(value or "")
e164 = str((state or {}).get("e164") or "")
if not e164:
return (raw + NOTE_MISSING_CC) if state is not None else raw
if state.get("confirmed"):
return e164
return e164 + NOTE_UNCONFIRMED
def outbound_e164(value: str, state: dict | None) -> str:
"""主题行/推送 contact 用:有 e164 给纯 e164(无标注——主题拥挤、
对方契约要"带国际区号且有效"的干净号),其余原样。"""
e164 = str((state or {}).get("e164") or "")
return e164 or str(value or "")
def completion_note(value: str, state: dict | None) -> str | None:
"""推送画像派生键「号码补全说明」的单渠道条目;无需披露返回 None。
confirmed 的补全零披露;未确认 → "原文→补全号+标注";缺区号 →
"原文+(缺国家区号)"。出站时由 _cc 计算,不落正常键(零 LLM 泄漏面)。
"""
raw = str(value or "")
if state is None:
return None
e164 = str(state.get("e164") or "")
if not e164:
return raw + NOTE_MISSING_CC
if state.get("confirmed"):
return None
return f"{raw}→{e164}{NOTE_UNCONFIRMED}"