"""号码区号补全核心:判定树 + 确认状态机(纯函数模块)。 设计依据: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}"