41b2ce081f
增补设计 §3(需求方决策): 旧"礼貌回显"豁免与"不回显"红线矛盾,且被 GLM 稳定利用回显号码 3/3——AI 输出中的客户号码只可能来自回显,拦截 无误杀面。 - boundary_filter.no_contact_value: 移除 user_history 参数与豁免分支, ≥10 位数字串(含阿语数字形态,Unicode \d 原生匹配)/邮箱一律拦; digits_only 导入随之清理 - conversation.py 两处调用点同步去参(句子循环 + pending 尾巴) - contact_pipeline.py 两处注释里指向已移除豁免的表述同步修正 - 测试: 旧豁免测试(echo_exempt/arabic_echo_exemption_consistent)按新 语义改写为一律拦;新增邮箱回显拦截/规格形态(DN110/5000m/中文)放行; 流式集成 2 项——客户完整号/邮箱被原样回显 → 整段替换 NO_CONTACT_ALREADY Co-Authored-By: Claude Code <noreply@anthropic.com>
298 lines
13 KiB
Python
298 lines
13 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:单号/订单号/先令价格)
|
||
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"] = _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
|