"""统一联系方式管道:联系方式检测的唯一事实源。 三个动作: - 归一化:中英文 key 别名统一为 phone/whatsapp/email/wechat - 深合并:同 key 新值覆盖旧值(客户改号场景),空值不覆盖 - 校验分级:confirmed(格式通过,可触发邮件/结束)/ suspect(疑似, 仅用于提醒 AI 礼貌确认,防止重复索要——D6/S1) 上游约定:raw_info 中 "_" 前缀 key 为内部状态,禁止进入提示词渲染。 """ import re # Unicode 十进制数字 → ASCII(阿语٠-٩/波斯语۰-۹), 中东买家号码 CRM 可直接拨。 # 挂在归一化链最前端(normalize_contacts 的值 / digits_only / scan 与 # latest 的捕获赋值): 存库、canon 去重比对全部经此转成 ASCII——阿语消息 # 与 ASCII 提取值比对口径一致,同一号码不会被误判成"改号"触发更正邮件。 _DIGIT_TRANSLATE = str.maketrans( "٠١٢٣٤٥٦٧٨٩۰۱۲۳۴۵۶۷۸۹", "01234567890123456789") def _to_ascii_digits(value: str) -> str: """捕获源头归一: 阿语/波斯语数字转 ASCII(translate 对 ASCII 恒等)。""" return value.translate(_DIGIT_TRANSLATE) KEY_ALIASES = { "phone": "phone", "手机": "phone", "电话": "phone", "电话号码": "phone", "联系电话": "phone", "手机号": "phone", "手机号码": "phone", "tel": "phone", "telephone": "phone", "phone_number": "phone", "phone number": "phone", "mobile": "phone", "mobile_number": "phone", "mobile number": "phone", "whatsapp": "whatsapp", "wa": "whatsapp", "wsp": "whatsapp", "whats app": "whatsapp", "whatsapp账号": "whatsapp", "whatsapp号码": "whatsapp", "whatsapp number": "whatsapp", "email": "email", "邮箱": "email", "e-mail": "email", "mail": "email", "电子邮件": "email", "wechat": "wechat", "微信": "wechat", "wx": "wechat", "weixin": "wechat", } def _coerce_contact_map(raw) -> dict: """真实 GLM extractor 偶发把联系方式压成裸字符串(Task 11 真人验收实测, mock 测不出)——唯一事实源处兜形,绝不让 .items() 崩掉整个消息轮: 裸串按内容归类(含 @ → email,否则 → phone);列表逐项归类; 其余非 dict 形态按空处理。""" if isinstance(raw, dict): return raw if isinstance(raw, str): s = raw.strip() return {"email": s} if "@" in s else ({"phone": s} if s else {}) if isinstance(raw, (list, tuple)): out: dict = {} for item in raw: out.update(_coerce_contact_map(item)) return out return {} def normalize_contacts(raw: dict | None) -> dict: """key 归一化 + 去空值。精确别名优先;未命中再做 contains 兜底 (extractor 即兴变体 key 不静默丢值)。contains 按 KEY_ALIASES 声明序 取第一个命中——复合 key(电话/WhatsApp)结果确定。""" out: dict[str, str] = {} for k, v in _coerce_contact_map(raw).items(): key = str(k).strip().lower() alias = KEY_ALIASES.get(key) if alias is None: for name, target in KEY_ALIASES.items(): if name in key: alias = target break val = _to_ascii_digits(str(v or "").strip()) if alias and val: out[alias] = val return out def merge_contacts(old: dict | None, new: dict | None) -> dict: """同 key 新值覆盖旧值;空/缺省不覆盖(堵浅合并抹数据 bug)。""" return {**normalize_contacts(old), **normalize_contacts(new)} def digits_only(s: str) -> str: """提取纯数字(split_validate 位数判断与后续 canon 复用)。 阿语/波斯语数字先转 ASCII——canon 比对两侧都走本函数,天然保证同形 比较(Unicode 正则的数字类本就匹配阿语数字,位数判定不变;此处只统一 "比对与存储的形态"。boundary_filter 回显豁免移除后不再依赖本函数)。""" return re.sub(r"\D", "", s.translate(_DIGIT_TRANSLATE)) def split_validate(contacts: dict) -> tuple[dict, dict]: """返回 (confirmed, suspect)。电话/WhatsApp 取数字 7-15 位为合格;微信取非空 ≥4 字符。""" confirmed: dict[str, str] = {} suspect: dict[str, str] = {} for k, v in normalize_contacts(contacts).items(): if k == "email": ok = re.fullmatch(r"[^@\s]+@[^@\s]+\.[^@\s]+", v) is not None elif k == "wechat": ok = len(v.strip()) >= 4 # 微信号无数字格式,非空短串即可 else: ok = 7 <= len(digits_only(v)) <= 15 (confirmed if ok else suspect)[k] = v return confirmed, suspect EMAIL_RE = re.compile(r"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}") INTL_PHONE_RE = re.compile(r"\+\d{6,15}") # 邻接绑定:上下文词与数字必须在同一匹配内(仅空白衔接)——禁止"消息任意位置 # 有上下文词 + 任意位置有 7 位数字"的消息级误绑(S1:单号/订单号/先令价格) # 间隙字符类排除 "+"(2026-10-08 cc 判定树 Task 3 集成实证): 原 \D{0,12} 会把 # 客户显式写的 "+" 吞进间隙("whatsapp is +966…" 捕成裸 "966…"),下游 # cc_completion.classify 误判 NEED_CC 对已给全号的客户追问区号——"+" 是 # classify a 分支(显式国际前缀→HAS_CC)的唯一判据,必须在捕获里存活。 WHATSAPP_CTX_RE = re.compile( r"(?:whats\s*app|wa\.?me|(?:^|\b)wa\s*[::])[^+\d]{0,12}" r"(\+?\d[\d\s\-().]{5,17}\d)", re.IGNORECASE, ) PHONE_CTX_RE = re.compile( r"(?:my\s+(?:(?:new|other|another)\s+)?(?:phone\s+)?number\s+is|" r"my\s+(?:(?:new|other|another)\s+)?phone\s+is|" r"(?:msg|message|text|ping)\s+me\s+(?:at|on)?\s*|" r"call\s+me\s+(?:at|on)|reach\s+me\s+(?:at|on)|" r"phone\s*[::]|number\s*[::]|号码\s*[::]|手机号?\s*[::]|电话\s*[::])\s*" r"(\+?\d[\d\s\-().]{5,17}\d)", re.IGNORECASE, ) # 仅 scan_user_messages 使用的裸邻接形态: "my number 0712345678"(无 is/冒号)。 # ⚠️ 不得进 latest_stated_contacts——裸邻接无自述确定性,授覆盖权会让首轮旧值 # 长期压制 extractor 后续改号(实证: test_correction_up_to_three 更正邮件全灭)。 # 已知覆盖边界: "my phone 0712345678"(phone 变体,无 is)不在本形态内。 PHONE_ADJACENCY_SCAN_RE = re.compile( r"my\s+(?:(?:new|other|another)\s+)?number\s+(\+?\d[\d\s\-().]{5,17}\d)", re.IGNORECASE, ) # 倒装自述: "0712 345 678 is my whatsapp / 13800138000 我的微信" # ⚠️ 只收显式渠道词(whatsapp/wa/wechat/微信),不收 number/phone/号码—— # 自查实证: "2500000 is my number one choice" 这类排位表述会把价格误绑成 # 号码,且本正则进入 latest_stated_contacts 即获覆盖权,误捕获会翻新掉 # 已存真实号码(正是 S1 系测试在防的事故类)。倒装电话表述罕见,不值得 # 为其承担该风险;电话主通路由 PHONE_CTX_RE 前缀形态/standalone/INTL 覆盖。 # (?:my|我的)后用 \s* 而非 \s+: 中文"我的微信"连写无空白,\s+ 永不命中(实证)。 NUMBER_FIRST_RE = re.compile( r"(\+?\d[\d\s\-().]{5,17}\d)\s*(?:is|这是)?\s*" r"(?:my|我的)\s*(whats\s*app|wa\b|wechat|微信)", re.IGNORECASE, ) _NUMBER_FIRST_CHANNEL = { "whatsapp": "whatsapp", "whats app": "whatsapp", "wa": "whatsapp", "wechat": "wechat", "微信": "wechat", } def _number_first_channel(hit: re.Match) -> str: # whats\s*app 可捕到任意空白变体(双空格/NBSP/换行),查表前先归一成 # 单空格——否则 KeyError 会沿着 scan 的每消息热路径直接断流整轮对话。 return _NUMBER_FIRST_CHANNEL[" ".join(hit.group(2).lower().split())] # 更正语境(与裸国际号同消息出现时才授予覆盖权) CORRECTION_CTX_RE = re.compile( r"wrong\s+(?:number|phone)|typo|打错|写错|输错|错了|换成了?|改为|" r"use\s+(?:this|new|the\s+new)|instead|correct\s+(?:number|phone|it)", re.IGNORECASE, ) def scan_user_messages(messages: list[dict]) -> dict: """S4: 扫描全部客户消息,返回高置信捕获(email / +国际号 / whatsapp·wa.me / 显式电话上下文邻接数字)。裸数字(价格/年份/数量/单号)一律不收。""" found: dict[str, str] = {} for msg in messages: if msg.get("role") != "user": continue text = msg.get("content", "") if "email" not in found: hit = EMAIL_RE.search(text) if hit: found["email"] = hit.group(0) if "phone" not in found: sn = standalone_number(text) if sn: found["phone"] = _to_ascii_digits(sn) if "phone" not in found: hit = INTL_PHONE_RE.search(text) if hit: found["phone"] = _to_ascii_digits(hit.group(0)) if "whatsapp" not in found: hit = WHATSAPP_CTX_RE.search(text) if hit: found["whatsapp"] = _to_ascii_digits(hit.group(1)) if "phone" not in found: hit = PHONE_CTX_RE.search(text) if hit: found["phone"] = _to_ascii_digits(hit.group(1)) if "phone" not in found: hit = PHONE_ADJACENCY_SCAN_RE.search(text) if hit: found["phone"] = _to_ascii_digits(hit.group(1)) # 倒装属最低置信层: whatsapp/phone 任一已被更高置信源捕获即跳过, # 不与之竞争(跨 key 门控故含 phone)。 if "whatsapp" not in found and "phone" not in found: hit = NUMBER_FIRST_RE.search(text) if hit: ch = _number_first_channel(hit) found[ch] = _to_ascii_digits(hit.group(1)) return found def canon(value: str) -> str: """去重比对用的规范形:邮箱小写,其余取纯数字(S3/R3 best-effort)。""" value = str(value or "").strip() if "@" in value: return value.lower() return digits_only(value) def standalone_number(text: str) -> str | None: """整条消息仅为一个号码(Task 12d): 客户"直接甩号"的最显式表达,授予覆盖权。 ≥9 位下限把年份(4)/美元价(5)/先令价(5-7)全部排除在外; 允许 +/-/空格/横线/点/括号包裹,其余任何字符(字母/逗号)即非独立号码条。""" t = (text or "").strip() if not re.fullmatch(r"\+?[\d\s\-().·]{9,25}", t): return None return t if 9 <= len(digits_only(t)) <= 15 else None def latest_stated_contacts(messages: list[dict]) -> dict: """最新显式自述(唯一可覆盖 extractor 回传的来源,Task 12): 同 key 取全历史最后一次显式自述。覆盖权仅授予: - 上下文门控捕获(whatsapp/wa.me/wa:、my (new/other) number is、msg/text/ping me、colon 形式、email) - 倒装渠道自述(NUMBER_FIRST_RE: "0712 345 678 is my whatsapp"、 "13800138000 我的微信"——只认显式渠道词) - "更正语境 ∧ 同消息裸国际号"(wrong number, use +86...) 纯裸数字无覆盖权(S1): 防价格/年份/订单号误翻新。 例外(Task 12d): 整条消息仅为一个号码(≥9位) = 最显式自述,授予覆盖权。 通则(权威契约): 正则接进本循环即授覆盖权——新增低精度形态时只进 扫描辅源 scan_user_messages,不进这里(先例: PHONE_ADJACENCY_SCAN_RE, 裸 my number 邻接曾致更正邮件全灭)。 """ latest: dict[str, str] = {} for msg in messages: if msg.get("role") != "user": continue text = msg.get("content") or "" hit = EMAIL_RE.search(text) if hit: latest["email"] = hit.group(0) hit = WHATSAPP_CTX_RE.search(text) if hit: latest["whatsapp"] = _to_ascii_digits(hit.group(1)) sn = standalone_number(text) if sn: latest["phone"] = _to_ascii_digits(sn) hit = PHONE_CTX_RE.search(text) if hit: latest["phone"] = _to_ascii_digits(hit.group(1)) elif CORRECTION_CTX_RE.search(text): hit = INTL_PHONE_RE.search(text) if hit: latest["phone"] = _to_ascii_digits(hit.group(0)) hit = NUMBER_FIRST_RE.search(text) if hit: ch = _number_first_channel(hit) latest[ch] = _to_ascii_digits(hit.group(1)) return latest def resolve_by_recency(before: dict, after: dict, messages: list[dict]) -> dict: """最近提及裁决(Task 12c): before 中同 canon 的非 email key 构成一个镜像身份组。 组内分歧时,取"出现在最晚客户消息里"的 canon 代表值—— - 杀掉 extractor 在历史号码间的振荡(旧号总在更早的消息里,天然落败) - 压制幻觉值(从未出现在任何消息里,last_idx=-1) - 客户最新消息里的新号(extractor 抓到的裸号)天然胜出 组内一致或不同组(本就不同的号)时原样返回。""" out = dict(after) groups: dict[str, set[str]] = {} for k, v in (before or {}).items(): if k != "email" and v: groups.setdefault(canon(str(v)), set()).add(k) user_digits = [ digits_only(m.get("content") or "") for m in messages if m.get("role") == "user" ] def last_idx(c: str) -> int: for i in range(len(user_digits) - 1, -1, -1): if c and c in user_digits[i]: return i return -1 for keys in groups.values(): vals = {k: out[k] for k in keys if out.get(k)} distinct = {canon(str(v)) for v in vals.values()} if len(distinct) > 1: best = max(distinct, key=last_idx) if last_idx(best) >= 0: rep = next(v for v in vals.values() if canon(str(v)) == best) for k in keys: out[k] = rep return out