303 lines
14 KiB
Python
303 lines
14 KiB
Python
|
|
"""统一联系方式管道:联系方式检测的唯一事实源。
|
|||
|
|
|
|||
|
|
三个动作:
|
|||
|
|
- 归一化:中英文 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
|