"""统一联系方式管道:联系方式检测的唯一事实源。 三个动作: - 归一化:中英文 key 别名统一为 phone/whatsapp/email/wechat - 深合并:同 key 新值覆盖旧值(客户改号场景),空值不覆盖 - 校验分级:confirmed(格式通过,可触发邮件/结束)/ suspect(疑似, 仅用于提醒 AI 礼貌确认,防止重复索要——D6/S1) 上游约定:raw_info 中 "_" 前缀 key 为内部状态,禁止进入提示词渲染。 """ import re 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 normalize_contacts(raw: dict | None) -> dict: """key 归一化 + 去空值。精确别名优先;未命中再做 contains 兜底 (extractor 即兴变体 key 不静默丢值)。contains 按 KEY_ALIASES 声明序 取第一个命中——复合 key(电话/WhatsApp)结果确定。""" out: dict[str, str] = {} for k, v in (raw or {}).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 = 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 复用)。""" return re.sub(r"\D", "", s) 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:车架号/订单号/先令价格) WHATSAPP_CTX_RE = re.compile( r"(?:whats\s*app|wa\.?me|(?:^|\b)wa\s*[::])\D{0,12}(\+?\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"] = sn if "phone" not in found: hit = INTL_PHONE_RE.search(text) if hit: found["phone"] = hit.group(0) if "whatsapp" not in found: hit = WHATSAPP_CTX_RE.search(text) if hit: found["whatsapp"] = hit.group(1) if "phone" not in found: hit = PHONE_CTX_RE.search(text) if hit: found["phone"] = hit.group(1) if "phone" not in found: hit = PHONE_ADJACENCY_SCAN_RE.search(text) if hit: found["phone"] = 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] = 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"] = hit.group(1) sn = standalone_number(text) if sn: latest["phone"] = sn hit = PHONE_CTX_RE.search(text) if hit: latest["phone"] = hit.group(1) elif CORRECTION_CTX_RE.search(text): hit = INTL_PHONE_RE.search(text) if hit: latest["phone"] = hit.group(0) hit = NUMBER_FIRST_RE.search(text) if hit: ch = _number_first_channel(hit) latest[ch] = 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