Files
ai-kefu-jc/app/services/contact_pipeline.py
浅念 0b5c219090 feat: 判定树轮前接入+cc_reply消费+出口卫生+存量迁移
Task 3(cc-completion 计划最重集成项):
- 轮前钩子 _cc_pre_turn: effective_raw 建成后、reminder 之前跑判定树,
  建/重置/迁移 _cc(同 canon 保留状态,canon 变化即 RESET 重走);
  与一次性计数写回同事务落库(条件扩为计数或 _cc 变更任一存在)
- 轮末钩子 _cc_round_end: 四层联系方式合并后、D1 邮件前——双门控消费
  cc_reply(仅 whatsapp 等待态;落定态仅开迟到 country 升级口,no_ip
  耗除外),canon 变化 RESET,末次联系方式补建 pass
- 出口卫生三封堵: cc_reply 消费后剥离/extractor 回传 _ 前缀键防御性
  丢弃/入口 existing_info 滤 _ 复核
- 存量迁移: 旧 _cc_asked=True+裸 whatsapp 无 _cc → 一次性 fallback 态
- _contact_completion_note cc 分支删除,邮箱轻补解锁条件改
  cc_settled(_cc.whatsapp);cc 墙门控改线 cc_due(_wa_cc)
- 集成实证修两处数据流 bug: WHATSAPP_CTX_RE 间隙吞 + 号(显式国际
  前缀被捕成裸号→误追问);同轮扫描合并方向反转(存量覆盖当轮新号,
  轮前 canon 变化检测失效——与轮末最新自述优先同语义对齐)
- 新增 10 项集成测试(affirm 确认/换号重走/迟到报国升级与忽略/
  卫生三封堵/存量迁移/confirmed 态忽略/同 canon 组归并/异号 phone
  静默/纯 phone 无墙),282 项全绿

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-08 18:06:39 +08:00

303 lines
14 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""统一联系方式管道:联系方式检测的唯一事实源。
三个动作:
- 归一化:中英文 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