feat: 会话编排建材化+阿语号码转ASCII+12小时话术跨层同步
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -204,8 +204,10 @@ def deflection_for(user_msg: str, rotate: int = 0,
|
||||
LONG_NUMBER_RE = re.compile(r"\+?\d[\d\s\-().]{9,17}\d") # ≥10位连续数字(允许分隔形态)
|
||||
ANY_EMAIL_RE = re.compile(r"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}")
|
||||
|
||||
# 12 小时口径与 system.md 对齐(Task 3 质量审查 Minor 4: 原句 "replies fast"
|
||||
# 与 system.md 的 "reply within 12 hours" 承诺不一致)——hours 不触时间承诺墙
|
||||
NO_CONTACT_WEB = ("Just tap the WhatsApp button at the top of this chat — "
|
||||
"our manager replies fast!")
|
||||
"our manager will reply within 12 hours!")
|
||||
NO_CONTACT_APP = ("Drop your WhatsApp and our manager will reach out to you directly "
|
||||
"with the full quote!")
|
||||
# F6 同款(15.3 补): 已留资时零号码墙替换句不得索要
|
||||
|
||||
@@ -10,6 +10,19 @@
|
||||
"""
|
||||
import re
|
||||
|
||||
# Unicode 十进制数字 → ASCII(阿语٠-٩/波斯语۰-۹), 中东买家号码 CRM 可直接拨。
|
||||
# 挂在归一化链最前端(normalize_contacts 的值 / digits_only / scan 与
|
||||
# latest 的捕获赋值): 存库、canon 去重比对、boundary_filter 客户回显豁免
|
||||
# 两侧全部经此转成 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",
|
||||
@@ -38,7 +51,7 @@ def normalize_contacts(raw: dict | None) -> dict:
|
||||
if name in key:
|
||||
alias = target
|
||||
break
|
||||
val = str(v or "").strip()
|
||||
val = _to_ascii_digits(str(v or "").strip())
|
||||
if alias and val:
|
||||
out[alias] = val
|
||||
return out
|
||||
@@ -50,8 +63,11 @@ def merge_contacts(old: dict | None, new: dict | None) -> dict:
|
||||
|
||||
|
||||
def digits_only(s: str) -> str:
|
||||
"""提取纯数字(split_validate 位数判断与后续 canon 复用)。"""
|
||||
return re.sub(r"\D", "", s)
|
||||
"""提取纯数字(split_validate 位数判断与后续 canon 复用)。
|
||||
阿语/波斯语数字先转 ASCII——canon 与 boundary_filter 回显豁免两侧
|
||||
都走本函数,天然保证同形比较(Unicode 正则的数字类本就匹配阿语数字,
|
||||
位数判定不变;此处只统一"比对与存储的形态")。"""
|
||||
return re.sub(r"\D", "", s.translate(_DIGIT_TRANSLATE))
|
||||
|
||||
|
||||
def split_validate(contacts: dict) -> tuple[dict, dict]:
|
||||
@@ -72,7 +88,7 @@ def split_validate(contacts: dict) -> tuple[dict, dict]:
|
||||
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:车架号/订单号/先令价格)
|
||||
# 有上下文词 + 任意位置有 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,
|
||||
@@ -129,7 +145,7 @@ CORRECTION_CTX_RE = re.compile(
|
||||
|
||||
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":
|
||||
@@ -142,30 +158,30 @@ def scan_user_messages(messages: list[dict]) -> dict:
|
||||
if "phone" not in found:
|
||||
sn = standalone_number(text)
|
||||
if sn:
|
||||
found["phone"] = sn
|
||||
found["phone"] = _to_ascii_digits(sn)
|
||||
if "phone" not in found:
|
||||
hit = INTL_PHONE_RE.search(text)
|
||||
if hit:
|
||||
found["phone"] = hit.group(0)
|
||||
found["phone"] = _to_ascii_digits(hit.group(0))
|
||||
if "whatsapp" not in found:
|
||||
hit = WHATSAPP_CTX_RE.search(text)
|
||||
if hit:
|
||||
found["whatsapp"] = hit.group(1)
|
||||
found["whatsapp"] = _to_ascii_digits(hit.group(1))
|
||||
if "phone" not in found:
|
||||
hit = PHONE_CTX_RE.search(text)
|
||||
if hit:
|
||||
found["phone"] = hit.group(1)
|
||||
found["phone"] = _to_ascii_digits(hit.group(1))
|
||||
if "phone" not in found:
|
||||
hit = PHONE_ADJACENCY_SCAN_RE.search(text)
|
||||
if hit:
|
||||
found["phone"] = hit.group(1)
|
||||
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] = hit.group(1)
|
||||
found[ch] = _to_ascii_digits(hit.group(1))
|
||||
return found
|
||||
|
||||
|
||||
@@ -211,21 +227,21 @@ def latest_stated_contacts(messages: list[dict]) -> dict:
|
||||
latest["email"] = hit.group(0)
|
||||
hit = WHATSAPP_CTX_RE.search(text)
|
||||
if hit:
|
||||
latest["whatsapp"] = hit.group(1)
|
||||
latest["whatsapp"] = _to_ascii_digits(hit.group(1))
|
||||
sn = standalone_number(text)
|
||||
if sn:
|
||||
latest["phone"] = sn
|
||||
latest["phone"] = _to_ascii_digits(sn)
|
||||
hit = PHONE_CTX_RE.search(text)
|
||||
if hit:
|
||||
latest["phone"] = hit.group(1)
|
||||
latest["phone"] = _to_ascii_digits(hit.group(1))
|
||||
elif CORRECTION_CTX_RE.search(text):
|
||||
hit = INTL_PHONE_RE.search(text)
|
||||
if hit:
|
||||
latest["phone"] = hit.group(0)
|
||||
latest["phone"] = _to_ascii_digits(hit.group(0))
|
||||
hit = NUMBER_FIRST_RE.search(text)
|
||||
if hit:
|
||||
ch = _number_first_channel(hit)
|
||||
latest[ch] = hit.group(1)
|
||||
latest[ch] = _to_ascii_digits(hit.group(1))
|
||||
return latest
|
||||
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ from app.services.contact_pipeline import (
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 职能边界触发词(Task 13.2, 评审#1/#4 修订): 命中即注入红线提醒。精修说明:
|
||||
# - 裸 "how long"/"多久" 不收(续航类问题常用),英文要求 take/arrive/deliver/ship 邻接,
|
||||
# - 裸 "how long"/"多久" 不收(规格参数类问题常用),英文要求 take/arrive/deliver/ship 邻接,
|
||||
# 中文只收 到货/多久能到/交货 等明确交付语境
|
||||
# - 【评审#1】施压话术本身也触发: 磨价轮("just roughly"/"说个大概")没有价格词,
|
||||
# 却是最易破防的时刻——压力词单列一组
|
||||
@@ -38,13 +38,15 @@ BOUNDARY_TRIGGER_RE = re.compile(
|
||||
r"price|pricing|how\s+much|\bcost\b|discount|cheaper|expensive|\bfee\b|"
|
||||
r"quote|quotation|shipping\s+(fee|cost|time)|freight|tariff|customs\s+fee|"
|
||||
r"best\s+offer|any\s+offer|free\s+shipping|"
|
||||
r"test\s+drive|book(ing)?\s|reserv|arrange|"
|
||||
r"sample|certificat|\bmoq\b|container\s+loading|"
|
||||
r"book(ing)?\s|reserv|arrange|"
|
||||
r"delivery|lead\s+time|arriv|"
|
||||
r"how\s+long\s+(does|will|would|to).*(take|arriv|deliver|ship)|"
|
||||
r"roughly|ballpark|just\s+tell|come\s+on|at\s+least\s+(a|some|give)|"
|
||||
r"budget|预算|价格|价钱|多少钱|报价|优惠|折扣|便宜|贵|划算|"
|
||||
r"总价|落地|运费|关税|免费|"
|
||||
r"到货|多久能到|交货|时效|发货|试驾|预约|下单|定金|提车|安排|"
|
||||
r"到货|多久能到|交货|时效|发货|样品|证书|装柜|起订|"
|
||||
r"预约|下单|定金|安排|"
|
||||
r"大概|估个|估一|随便说|说个|求你|求了",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
@@ -101,14 +103,14 @@ class ConversationManager:
|
||||
await self.db.refresh(conv)
|
||||
|
||||
# Conditional whitelist seed of app context into raw_info (red line):
|
||||
# only when `context` is provided, and only vehicle_name/vehicle_id
|
||||
# only when `context` is provided, and only product_name/product_id
|
||||
# mapped to app_* keys. WS path passes no context -> raw_info stays {}.
|
||||
raw_info: dict = {}
|
||||
if context:
|
||||
if context.get("vehicle_name"):
|
||||
raw_info["app_vehicle_name"] = context["vehicle_name"]
|
||||
if context.get("vehicle_id"):
|
||||
raw_info["app_vehicle_id"] = context["vehicle_id"]
|
||||
if context.get("product_name"):
|
||||
raw_info["app_product_name"] = context["product_name"]
|
||||
if context.get("product_id"):
|
||||
raw_info["app_product_id"] = context["product_id"]
|
||||
profile = CustomerProfile(conversation_id=conv.id, raw_info=raw_info)
|
||||
self.db.add(profile)
|
||||
await self.db.commit()
|
||||
@@ -554,8 +556,8 @@ class ConversationManager:
|
||||
if BUDGET_TRIGGER_RE.search(latest_msg or ""):
|
||||
# 评审#9: 预算求推荐 → 预算路径(推荐+反问+引导),不一刀切要号
|
||||
boundary_note = (
|
||||
"⚠️ 职能边界提醒:客户报预算求推荐。推荐车型但绝不复述预算数字、"
|
||||
"不报车价 → 反问意向车型 → 顺势引导留联系方式。")
|
||||
"⚠️ 职能边界提醒:客户报预算求推荐。推荐产品方案但绝不复述预算数字、"
|
||||
"不报价格 → 反问意向产品与需求规格 → 顺势引导留联系方式。")
|
||||
else:
|
||||
boundary_note = (
|
||||
"⚠️ 职能边界提醒:客户正在询问价格/优惠/交付时效/实质安排。绝对禁止"
|
||||
@@ -590,7 +592,7 @@ class ConversationManager:
|
||||
else:
|
||||
contact_note = ("⚠️ 客户正在索要我方联系方式(最高意向信号)。请逐字使用这句指路:"
|
||||
"\"Just tap the WhatsApp button at the top of this chat — "
|
||||
"our manager replies fast!\"(邮箱同理指 Email 按钮)。"
|
||||
"our manager will reply within 12 hours!\"(邮箱同理指 Email 按钮)。"
|
||||
"绝对禁止口头报出任何号码或邮箱。")
|
||||
|
||||
if not raw_info:
|
||||
|
||||
Reference in New Issue
Block a user