feat: jc 引擎主线搬入 app/engine(315 测试基线全绿:320 去掉 admin 5 用例)

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-10-12 16:25:32 +08:00
parent 0ae5952002
commit 603c03eb4a
55 changed files with 10912 additions and 0 deletions
View File
+98
View File
@@ -0,0 +1,98 @@
import json
import logging
from collections.abc import AsyncGenerator
from pathlib import Path
from app.engine.providers.base import AIProvider
from app.engine.providers.glm import GLMProvider
from app.engine.config import settings
logger = logging.getLogger(__name__)
PROMPTS_DIR = Path(__file__).resolve().parent.parent / "prompts"
def _load_prompt(filename: str) -> str:
return (PROMPTS_DIR / filename).read_text(encoding="utf-8")
def create_provider() -> AIProvider:
if settings.AI_PROVIDER == "glm":
return GLMProvider(
api_key=settings.ZHIPU_API_KEY,
model=settings.AI_MODEL,
base_url=settings.ZHIPU_BASE_URL,
)
raise ValueError(f"Unknown AI provider: {settings.AI_PROVIDER}")
SYSTEM_PROMPT = _load_prompt("system.md")
COMPANY_FACTS = _load_prompt("company_facts.md") # 公司事实卡(知识与行为分离)
EXTRACTOR_PROMPT = _load_prompt("extractor.md")
EVALUATOR_PROMPT = _load_prompt("evaluator.md")
# 对话基座: 行为 prompt 在前(红线显著性),事实卡居中(参考资料),
# 当轮 context 提醒最后拼接(在 chat/stream_chat 内,保持最新鲜)
_BASE_CHAT_PROMPT = SYSTEM_PROMPT + "\n\n" + COMPANY_FACTS
class AIService:
def __init__(self, provider: AIProvider | None = None):
self.provider = provider or create_provider()
async def chat(self, messages: list[dict], context: str = "") -> str:
"""Main conversation: get AI reply to user message."""
prompt = _BASE_CHAT_PROMPT + ("\n\n" + context if context else "")
return await self.provider.chat(messages, prompt)
async def stream_chat(self, messages: list[dict], context: str = "") -> AsyncGenerator[str, None]:
"""Stream AI reply token by token."""
prompt = _BASE_CHAT_PROMPT + ("\n\n" + context if context else "")
async for token in self.provider.stream_chat(messages, prompt):
yield token
async def extract_info(
self, messages: list[dict], existing_info: dict
) -> dict:
"""Extract structured info from conversation history."""
prompt = EXTRACTOR_PROMPT.replace(
"$existing_info", json.dumps(existing_info or {}, ensure_ascii=False)
).replace("$messages", self._format_messages(messages))
_, extracted = await self.provider.chat_with_extraction(
messages=[], system_prompt=prompt
)
return extracted
async def evaluate(
self, profile: dict, recent_messages: list[dict]
) -> dict:
"""Evaluate customer intent level."""
prompt = EVALUATOR_PROMPT.replace(
"$profile", json.dumps(profile, ensure_ascii=False)
).replace("$recent_messages", self._format_messages(recent_messages[-6:]))
_, result = await self.provider.chat_with_extraction(
messages=[], system_prompt=prompt
)
return self._normalize_evaluation(result)
@staticmethod
def _format_messages(messages: list[dict]) -> str:
lines = []
for m in messages:
role = m.get("role", "unknown")
content = m.get("content", "")
label = {"user": "客户", "assistant": "顾问"}.get(role, role)
lines.append(f"{label}: {content}")
return "\n".join(lines)
@staticmethod
def _normalize_evaluation(result: dict) -> dict:
intent = result.get("intent_level", "pending")
if intent not in ("interested", "not_interested", "pending"):
intent = "pending"
return {
"intent_level": intent,
"reasoning": result.get("reasoning", ""),
"contact_info": result.get("contact_info", {}),
"info_sufficient": result.get("info_sufficient", False),
}
+329
View File
@@ -0,0 +1,329 @@
"""职能边界输出过滤(方案B): AI 回复价格形态的确定性拦截。
流式回复无法撤回,故在流出前全量缓冲过滤;违规整体替换为标准迂回话术。
模式以 scripts/breach_rate.py 为基础并收严(预算复述/金融收严;公里等误报除外项)。
评审修正(13.6 复查):
- 货币组尾部 \\b 对中文备选(万/块钱/美元)永远无法命中——中文无词边界,
"2万左右"的 万与左 之间没有 \\b(实测漏检预算复述 6/6 的原形态)。
\\b 只保留在拉丁备选 usd 之后(防 "usdroid" 类前缀误命中)。
- 公里/km 排除必须直接挂在 万块? 之后: 挂在末尾裸 万 备选上会被
万块? 的空块匹配绕过("3万公里"仍命中,实测)。
- 预算复述(裸 budget+数字/中文数字万)、金融裸数(首付/月供)收严;
k 形式的 km/miles 除外项保留(建材语境已无里程语义,兜底无害)。质保期
不再豁免(政策变更 2026-09-10:
保修期限是编造的公司政策承诺,一并墙级拦截,见 PRICE_PATTERNS 时间承诺组)。"""
import re
PRICE_PATTERNS = [
re.compile(r"\$\s?\d"),
re.compile(r"\d[\d,.]*\s*(usd\b|美元|块钱|万块?(?!\s*(公里|km)))", re.I),
re.compile(r"\d[\d,.]*\s?k\b(?!\s*(km|kilometers?|kilometres?|miles?|mi\b|公里))", re.I),
re.compile(r"(price|cost|quote)[^.\d]{0,20}(?<![a-z])(?!(?:19|20)\d\d(?!\d))\d", re.I),
# 时间承诺: 去掉 warranty 豁免(政策变更 2026-09-10——保修期限是编造的
# 公司政策承诺,不再白名单)
re.compile(r"\d+\s*[- ]?(day|week|month)s?\b", re.I),
# 年限保修定向(双向): year 不能进时间模式(使用年限 "under 5 years old" 会误杀),
# 只收 数字+年 与 warranty/质保/保修 的组合(含倒装"质保3年"/"warranty of 8 years")
re.compile(r"\d+\s*years?\b[^.!?]{0,15}warranty", re.I),
re.compile(r"warranty\s*(?:of|for|:)?\s*\d+\s*years?\b", re.I),
re.compile(r"\d+\s*年[^。!?]{0,8}(?:质保|保修)"),
re.compile(r"(?:质保|保修)[^。!?]{0,8}\d+\s*年"),
# 中文时限承诺: 天/周/个月(年不收——裸 "2024年" 是年份/项目日期,
# 带质保/保修的年限由上方定向组收)
re.compile(r"\d+\s*(?:天|周|个月)"),
# 裸预算复述(F3,方案B旗舰形态——冒烟 6/6 破防即此形,原模式完全漏检)
re.compile(r"(budget|预算)[^\d\n]{0,12}\d", re.I),
re.compile(r"(两|三|四|五|六|七|八|九|十)\s*万(?!\s*(公里|km))"),
# 金融裸数(F4)
re.compile(r"(down\s+payment|monthly\s+payment|首付|月供)[^\d\n]{0,10}\d", re.I),
# ---- 多语言墙(建材市场: 欧/英/南美/俄语区/中东) ----
# 货币符号: €/£(参考项目只有 $)
re.compile(r"[€£]\s?\d"),
# 价格名词前置(西/葡/俄/阿): 词后 0-20 非数字字符内出现数字
# ⚠️ 拉丁词必须加 \b 词边界——custo 是英文 customs 的子串,外贸高频,
# "customs paperwork for 20ft" 会被误杀(Task 3 质量审查发现)
# ⚠️ 阿语定冠词 ال 与词连写(السعر): 词首 \b 落在 ل|س 之间(两者皆 \w)
# 不成立,备选须收 (?:ال)? 形态——裸 \bسعر\b 实测漏检 "السعر 500 دولار"
# ⚠️ (?<![a-z]) 字母后顾(与英文组 (?<![a-z]) 同款): 建材规格数字永远
# 跟在拉丁字母后(DN800/SDR11/PN16/SCH40),后顾天然放行——实测
# "El precio del DN800 PE100..." 旧形态误杀,加后顾全放行且 HITS 全保
# ⚠️ 复数/变格(spec 审查加固): precio?s?/costo?s?/custo?s?/preço?s?
# (西葡 +s 复数;preço 的 ç 是词干,只放开 s——避免 "preç" 类
# 错拼形态);俄语 цен[аы]?(цена/цены/цен);阿语复数 أسعار。
# \b 包裹不受影响——"customs" 中 costo?s? 无子串匹配(c 后是 u),
# 词边界防线依旧成立
# ⚠️ 无英文组的年份排除 (?!(?:19|20)\d\d): "precios 2025" 会命中
# ——有意为之(价格词在前的宁拦勿放,与英文组从严方向一致但更保守)
re.compile(r"\b(?:precio?s?|costo?s?|custo?s?|preço?s?|цен[аы]?|"
r"стоимость|(?:ال)?سعر|(?:ال)?أسعار|(?:ال)?ثمن)\b"
r"[^\d\n]{0,20}(?<![a-z])\d", re.I),
# 货币名词后置(数字+货币词): dólares/euros/рублей/долларов
# ⚠️ 西里尔 долларов 变格必须单列(质量审查发现): 前置组只有名词
# стоимость,动词句 "стоит 500 долларов"/"будет стоить 800
# долларов" 前无价格名词,靠前置组兜不住
# ⚠️ euros 排除 pallets 邻接——"50 euro pallets"(欧标托盘)是建材
# 物流高频词,非价格
re.compile(r"\d[\d,.]*\s*(?:d[óo]lares?|euros?\b(?!\s*pallets?)|"
r"рублей|руб\.?|доллар(?:ов|а|ы)?)", re.I),
# 多语言时间承诺(西/葡 dias|semanas,俄 дн/недел,阿 أيام;与英文
# days/weeks 组同构)
# ⚠️ 不收 horas/hours/小时——system.md 的 "reply within 12 hours"
# 依赖此豁免(响应时效承诺非交期承诺)
# ⚠️ 俄语变格只收 дн(ей|я)/недел[ьи] 主形态,其余变格(дню/неделю
# 等)不收——宁可少而准
# ⚠️ re.I 必须有——大写形态 '15 Días'/'30 SEMANAS' 实测要拦(质量审查),
# 与英文时间组 re.I 对齐
re.compile(r"\d+\s*[- ]?(?:d[ií]as?|semanas?|дн(?:ей|я)|недел[ьи]|أيام)\b", re.I),
]
DEFLECTIONS_EN = [
"Prices vary a lot by config and shipping — our manager will prepare an exact quote for you. What's your WhatsApp or email?",
"For an exact price our manager needs your config and destination — drop your WhatsApp and you'll have the quote today.",
"I'd rather give you an exact number than a rough guess — leave your WhatsApp and our manager will send it over.",
]
DEFLECTIONS_ZH = [
"价格要看配置和目的地,客户经理会给你准确报价——留个WhatsApp或邮箱?",
"我让客户经理按你的情况出准确报价,留个联系方式,今天就能发你。",
]
# F6(D6×过滤器矛盾): 客户已留联系方式时,迂回话术不得再索要——否则与
# 注入的"禁止再索要联系方式"提醒直接打架
DEFLECTIONS_EN_CONTACT = [
"Our manager will send the exact quote to your WhatsApp shortly. Anything else about our pipes?",
]
DEFLECTIONS_ZH_CONTACT = [
"客户经理稍后会把准确报价发到你的 WhatsApp。还想了解什么管道产品?",
]
# ---- Task 5: ES 话术池(南美第一大流量) ----
# 与 EN 池语义对齐的西语替换句(注意: 是"替换句",绝不含价格数字,
# 且不得触发本墙自身——见 test_spanish_pools_are_wall_clean)
DEFLECTIONS_ES = [
"El precio exacto depende de la especificación y el flete — nuestro gerente te lo prepara. ¿Me dejas tu WhatsApp o correo?",
"Para un precio exacto, nuestro gerente necesita tu especificación y destino — déjame tu WhatsApp y lo tienes hoy.",
"Prefiero darte una cifra exacta y no un estimado — déjanos tu WhatsApp y nuestro gerente te lo envía.",
]
DEFLECTIONS_ES_CONTACT = [
"Nuestro gerente te enviará el precio exacto a tu WhatsApp en breve. ¿Algo más sobre nuestras tuberías?",
]
def violates_boundary(reply: str) -> bool:
return any(p.search(reply or "") for p in PRICE_PATTERNS)
# ---- 13.7: 句子粒度门控 ----
# 完整句切分: 英文句尾标点后需跟空白才切(小数 "2.5" 的句点后是数字,天然不切);
# 中文标点无需后续空白即可切。finditer 实现为无损——句间空白保留为下一片段的
# 前导空白(方案稿的 re.split 会吃掉空白,实测把 "A. B" 粘成 "A.B",
# 干净回复逐句流出后无法逐字还原)。
_SENT_BOUNDARY = re.compile(r"(?<=[.!?;;])\s+|(?<=[。!?…])")
def split_sentences(text: str) -> tuple[list[str], str]:
"""切出完整句子,返回 (完整句子列表, 不完整尾巴)。
无损保证: "".join(完整) + 尾巴 == 原文。完整句不带尾随空白。"""
if not text:
return [], ""
parts: list[str] = []
prev = 0
for m in _SENT_BOUNDARY.finditer(text):
parts.append(text[prev:m.start()])
prev = m.start() # 边界(句间空白)留在下一片段开头
parts.append(text[prev:])
parts = [p for p in parts if p]
if not parts:
return [], text
return parts[:-1], parts[-1]
# 过渡话术(pivot): 句子门控拦截时已流出干净前缀,接一句过渡收尾。
# 与开场版迂回话术不同——读起来像回复中段的转向,故单条不轮换。
PIVOTS_EN = [
"Actually, for the exact price — what's your WhatsApp? Our manager will send it over.",
]
PIVOTS_ZH = [
"具体的报价,客户经理会发给你——留个WhatsApp或邮箱?",
]
PIVOTS_EN_CONTACT = [
"Our manager will send the exact price to your WhatsApp shortly.",
]
PIVOTS_ZH_CONTACT = [
"客户经理稍后会把准确报价发到你的 WhatsApp。",
]
PIVOTS_ES = [
"Para el precio exacto — ¿cuál es tu WhatsApp? Nuestro gerente te lo envía.",
]
PIVOTS_ES_CONTACT = [
"Nuestro gerente te enviará el precio exacto a tu WhatsApp en breve.",
]
# Task 5: 语言路由三分支——汉字→ZH;西语特征(重音符/ñ/ç/ã/¿¡ 粗匹配,与
# 汉字检测同构)→ES;否则 EN。葡语借道 ES 池(拉美沟通惯例相近;ç/ã 为
# 葡语专属特征,"qual o preço?" 类纯 ç 短句也归 ES,spec 审查加固)
def _lang_pool(user_msg: str) -> str:
# 方法论局限(有意取舍): 特征类是"重音字符粗匹配"——法语/意大利用户
# (ça 的 ç / perché 的 é)同样命中 ES 池,收到西语替换句。罗曼语族互
# 半可懂,属可接受的优雅降级;葡语借道 ES 池(拉美惯例)同理。
# 无变音符打字形态("preco 500"/"qual o preco?")墙与路由双双放行,
# 降级 EN——宁可少而准,观察期 breach_rate 暴露后再扩。
if re.search(r"[一-鿿]", user_msg or ""):
return "zh"
if re.search(r"[áéíóúñãç¿¡]", user_msg or ""):
return "es"
return "en"
def pivot_for(user_msg: str, has_contact: bool = False) -> str:
lang = _lang_pool(user_msg)
if has_contact:
pool = {"zh": PIVOTS_ZH_CONTACT, "es": PIVOTS_ES_CONTACT,
"en": PIVOTS_EN_CONTACT}[lang]
else:
pool = {"zh": PIVOTS_ZH, "es": PIVOTS_ES, "en": PIVOTS_EN}[lang]
return pool[0]
def deflection_for(user_msg: str, rotate: int = 0,
has_contact: bool = False) -> str:
lang = _lang_pool(user_msg)
if has_contact:
pool = {"zh": DEFLECTIONS_ZH_CONTACT, "es": DEFLECTIONS_ES_CONTACT,
"en": DEFLECTIONS_EN_CONTACT}[lang]
else:
pool = {"zh": DEFLECTIONS_ZH, "es": DEFLECTIONS_ES,
"en": DEFLECTIONS_EN}[lang]
return pool[rotate % len(pool)]
# ---- 15.3: 零号码墙(doctrine v2) ----
# AI 绝不输出任何号码/邮箱: 与价格墙并列的第二道确定性红线。
# Important-1(审查修复): 量词 {8,17} 最小匹配 10 字符——纯 10 位号
# (沙特裸号 0501234567,CC 模板范例市场本体)必须命中。',' 不在字符类:
# "50,000,000" 千分位形态放行(与 "45,000 km" 放行同机制),空格/横线
# 分隔的 ≥10 位数字串命中;规格形态(DN110/5000m)经实测仍全放行。
LONG_NUMBER_RE = re.compile(r"\+?\d[\d\s\-().]{8,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 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 补): 已留资时零号码墙替换句不得索要
NO_CONTACT_ALREADY = ("Got it — our manager will reach out to you at your "
"WhatsApp shortly with the full quote!")
def no_contact_value(sentence: str, allow_number: str | None = None) -> bool:
"""零号码墙(v2): AI 输出不得含任何 ≥10 位数字串或邮箱。
回显豁免已移除(2026-10-08 增补设计 §3): 红线规定客户自己的号码/邮箱
也不回显(确认收到即可),旧豁免与红线矛盾且被 GLM 稳定利用回显号码;
AI 输出中的客户号码只可能来自回显,拦截无误杀面。阿语-印度数字由
Unicode \\d 原生匹配(转 ASCII 后同为 ≥10 位)。无值比对——年份/规格
编号天然安全(DN110/5000m 等已验证)。
allow_number(2026-10-08 判定树设计 Task 4, spec §4 回显确认轮白名单):
等待态系统注入的补全号(e164)。LONG_NUMBER_RE 逐匹配段提取数字与
allow_number 数字归一比对,**全部段命中才放行**(混入任何异号整句照拦,
4b-6 防自锁报警线);邮箱不受白名单影响(回显确认不需要邮箱)。无
allow_number → 原行为(任何 ≥10 位数字串即拦)。"""
s = sentence or ""
if allow_number is None:
return bool(LONG_NUMBER_RE.search(s) or ANY_EMAIL_RE.search(s))
if ANY_EMAIL_RE.search(s):
return True
allowed = re.sub(r"\D", "", allow_number)
if not allowed:
return bool(LONG_NUMBER_RE.search(s))
for m in LONG_NUMBER_RE.finditer(s):
if re.sub(r"\D", "", m.group(0)) != allowed:
return True # 任一段不命中(混入异号) → 整句拦
return False
# ---- Task 5b: 再索要墙(已留资时输出侧拦截) ----
# 仅当调用方 has_contact=True 才启用(接线在 conversation.py);
# 每条模式强制渠道词在场,防泛请求误杀("Can you send me the details?" 放行)。
# 模式集与 system.md 全部留资话术模板逐句对齐并实证(13 命中/12 放行)——
# GLM 背的就是这些模板,模板全集必须全收。
REASK_PATTERNS = [
# what's/which's your <渠道词>
re.compile(r"(?:what|which)['′]?s\s+(?:your|the)\s+"
r"(?:whatsapp|number|phone|e-?mail|contact|号码|联系方式)", re.I),
# got/have your <渠道词> ... ?(问号兜尾——确认句 "Got your number, thanks!" 无问号放行)
re.compile(r"(?:got|have)\s+(?:your|the)\s+"
r"(?:whatsapp|number|phone|e-?mail|contact)[^!?.]{0,60}\?", re.I),
# 陈述式需求: (I'll) need/want your <渠道词>——"I'll need your WhatsApp" 无问号也要拦
re.compile(r"(?:i['′]ll\s+|i\s+)?(?:need|want)\s+(?:your|the)\s+"
r"(?:whatsapp|number|phone|e-?mail|contact|号码|联系方式)", re.I),
# Tell me your <渠道词>
re.compile(r"tell\s+me\s+(?:your\s+)?"
r"(?:whatsapp|number|phone|e-?mail|contact|号码|联系方式)", re.I),
# could/can you + 给予动词 + ... 渠道词(动词表无 confirm/send——礼貌确认与泛请求放行)
re.compile(r"(?:could|can|would|may)\s+you\s+"
r"(?:leave|drop|share|give|provide|type|post)[^.!?\n]{0,30}"
r"(?:whatsapp|number|phone|e-?mail|contact|号码|联系方式|微信|邮箱)", re.I),
# 祈使: leave/drop/share/give (me) your/us a <渠道词>
re.compile(r"(?:leave|drop|share|give)\s+(?:me\s+)?(?:your|us\s+your?|a)\s+"
r"(?:whatsapp|contact|number|e-?mail|联系方式)", re.I),
# 互留后缀 "Drop yours too..."(无渠道词,单列)
re.compile(r"(?:drop|leave)\s+yours\b", re.I),
# "WhatsApp or email?" 独立短问
re.compile(r"(?:whatsapp|e-?mail|contact)\s+or\s+(?:whatsapp|e-?mail|contact)\s*\?", re.I),
# 裸碎片问句: "Your number, please?"/"Your WhatsApp?"(Ghana 案例穿透补)
# 后顾排除跟进确认句("reach/got/sent/did your X?" 是问进度不是要号)
re.compile(r"(?<!reach )(?<!reached )(?<!got )(?<!sent )(?<!did )(?<!to )"
r"your\s+(?:whatsapp|number|phone|e-?mail|contact)\b[^.!?]{0,20}[??]", re.I),
# 中文
re.compile(r"留[个下]?\s*(?:你的?)?\s*(?:whatsapp|微信|邮箱|联系方式|号码|电话)|"
r"给[我一]?个?\s*(?:联系方式|whatsapp|微信|号码|邮箱|电话)|"
r"你的?\s*(?:whatsapp|号码|电话|微信|邮箱|联系方式)[^。!?]{0,10}(?:是多少|多少|\?)", re.I),
]
# 渠道精确化(2026-10-08 spec §4.4): REASK 模式命中后按渠道词分辨——
# 索要已在手的渠道才丢,索要缺失渠道放行(双要补邮箱场景);泛渠道词
# 只要手上有任何号码渠道即丢(宁丢勿放);精确+泛同句共存时按①②自然
# 实现任一命中判丢。中文泛词不用 \b(CJK 无词边界,"电话"前 \b 在
# 主流 CJK 连写形态下不成立)。
_PRECISE_CHANNEL_RE = re.compile(r"whatsapp|e-?mail|邮箱|微信|wechat", re.I)
# 匹配形态→渠道键查表(对齐 contact_pipeline._NUMBER_FIRST_CHANNEL 先例:
# 未来扩 _PRECISE_CHANNEL_RE 加新渠道词而漏配此表 → KeyError 显性失败,
# 而非链式三元静默兜到 whatsapp)
_PRECISE_CHANNEL_MAP = {
"whatsapp": "whatsapp",
"e-mail": "email", "email": "email", "邮箱": "email",
"wechat": "wechat", "微信": "wechat",
}
_GENERIC_CONTACT_RE = re.compile(r"\b(?:number|phone|contact)\b|电话|联系方式|号码", re.I)
def _precise_channels_in(s: str) -> set:
out = set()
for m in _PRECISE_CHANNEL_RE.finditer(s):
out.add(_PRECISE_CHANNEL_MAP[m.group(0).lower()])
return out
def asks_contact(sentence: str, confirmed_channels: set | None = None) -> bool:
"""输出句是否在索要联系方式(已留资时用于丢弃)。
confirmed_channels=None → 原行为(命中即丢,向后兼容);
传入已确认渠道集合 → 渠道精确化(①②③④判定,见上注释)。"""
s = sentence or ""
hit = any(p.search(s) for p in REASK_PATTERNS)
if confirmed_channels is None or not hit:
return hit
has_number = bool(confirmed_channels & {"whatsapp", "phone", "wechat"})
precise = _precise_channels_in(s)
has_generic = bool(_GENERIC_CONTACT_RE.search(s))
if precise & confirmed_channels: # ① 句中有在手精确渠道词
return True
if has_number and has_generic: # ② 泛词 + 手上有号码渠道
return True
if not precise and not has_generic: # ③ 命中但无渠道词 → 保守丢
return True
return False # ④ 精确词全缺失/泛词但无号码 → 放行
+376
View File
@@ -0,0 +1,376 @@
"""号码区号补全核心:判定树 + 确认状态机(纯函数模块)。
设计依据:docs/superpowers/specs/2026-10-08-cc-ip-country-confirm-design.md §3/§4。
分工:本模块只做确定性计算(phonenumberslite 全离线),不 import
conversation/DB/LLM——IO、存储与提示词注入归调用方(轮前/轮末钩子)。
状态字典 schema(存 raw_info["_cc"][渠道],spec §4)::
{
"e164": "+966501234567", # 补全号;no_ip 态无此键
"intl": "+966 50 123 4567", # 分组展示(回显/邮件正文用)
"confirmed": false,
"attempts": 0, # 确认轮计数(只被 canon 变化重置)
"source": "ip_proposed", # ip_proposed|naive|ip_confirm|
# prefix_normalized|customer_stated|no_ip|fallback
"canon": "0501234567", # 客户原始号的纯数字形态(换号=RESET 的判据)
"asked": true, # 确认句已发出(conversation 轮末钩子管理,
# 首问轮免计;本模块不读不写,transit 原样携带)
}
"""
from __future__ import annotations
import re
import phonenumbers
from phonenumbers import PhoneNumberFormat
# 判定树两类结局:
# HAS_CC —— 已带国家码,现有流程直走。含两个子形态:显式 +/00 前缀(dict=None,
# 调用方无需建状态);裸写 IP 国码且剩余段合法(prefix_normalized 态,
# confirmed=true,首封无"未经确认"标注)。
# NEED_CC —— 缺国家码,进入确认流(ip_proposed/naive/no_ip 态)。
HAS_CC = "has_cc"
NEED_CC = "need_cc"
# 确认轮上限(spec §4 统一口径):常规路径每渠道 2 轮;
# no_ip(无建议国,只问一次"方便发带区号完整号吗")1 轮。
ATTEMPT_CAP_DEFAULT = 2
ATTEMPT_CAP_NO_IP = 1
# transit 返回 None 即 RESET:canon 变化(换号),调用方对号码重走判定树重建状态。
RESET = None
def _digits(text: str | None) -> str:
"""纯数字提取(canon 口径:客户原始号去一切非数字字符)。"""
return re.sub(r"\D", "", text or "")
def _safe_country_code(iso: str | None) -> str | None:
"""region ISO → 拨号码字符串(如 "SA"→"966");未知 region 返回 None。"""
if not iso:
return None
try:
cc = phonenumbers.country_code_for_region(iso)
except Exception:
return None
# 未知 region phonenumbers 返回 0(不抛),与 None 同义
return str(cc) if cc else None
def _try_parse(text: str, region: str):
"""parse 防御包装:抛错(含空串/垃圾/非法 region)归 None。"""
try:
return phonenumbers.parse(text, region=region)
except Exception:
return None
def format_intl(e164: str) -> str:
"""E.164 → 该国官方分组的国际展示格式(回显/邮件正文用)。
非法/空输入原样返回(防御:naive 拼出的号可能不被 parse 接受)。
"""
if not e164:
return e164 or ""
try:
parsed = phonenumbers.parse(e164)
return phonenumbers.format_number(parsed, PhoneNumberFormat.INTERNATIONAL)
except Exception:
return e164
def _new_state(canon: str, source: str, e164: str | None = None,
confirmed: bool = False) -> dict:
st = {"confirmed": confirmed, "attempts": 0, "source": source, "canon": canon}
if e164:
st["e164"] = e164
st["intl"] = format_intl(e164)
return st
def _complete_with_region(digits: str, iso: str) -> dict | None:
"""按给定 region 把裸号补全为 E.164——判定树 c/d 与状态机重算共用。
先严格解析(c:region=该国 parse 且 is_valid → 高置信),
失败退 naive(d:去前导 0 + 该国拨号码拼接);region 不可用返回 None。
"""
if not digits or not iso:
return None
parsed = _try_parse(digits, iso)
if parsed is not None and phonenumbers.is_valid_number(parsed):
e164 = phonenumbers.format_number(parsed, PhoneNumberFormat.E164)
return {"e164": e164, "intl": format_intl(e164), "strict": True}
cc = _safe_country_code(iso)
if not cc:
return None
national = digits.lstrip("0") or digits
e164 = f"+{cc}{national}"
return {"e164": e164, "intl": format_intl(e164), "strict": False}
def classify(number: str | None, ip_iso: str | None) -> tuple[str, dict | None]:
"""判定树(spec §3):(kind, state)。
a. +/00 开头 → (HAS_CC, None),现有流程直走;
b. 以 IP 国拨号码开头且剩余段对该国 is_valid → (HAS_CC, prefix_normalized
态 confirmed=true)——静默规范化,无对话动作;
c. region=IP 国解析合法 → (NEED_CC, ip_proposed 态)——高置信建议;
d. 均不匹配 → (NEED_CC, naive 态)——去前导 0 + IP 码,回显靠客户纠错;
e. IP 无效/未知(或 region 不被 phonenumbers 认识)→ (NEED_CC, no_ip 态,
无 e164,suggest_ask_full=true,只问一次"方便发带区号完整号吗")。
防御:number 为空/全非数字 → (NEED_CC, None)(调用方契约外场景,不建状态)。
"""
raw = (number or "").strip()
digits = _digits(raw)
# a: 显式国际前缀
if raw.startswith("+") or raw.startswith("00"):
return HAS_CC, None
if not digits:
return NEED_CC, None
if ip_iso:
# b: 裸写 IP 国码 + 剩余段合法(无确认防线,靠 is_valid 严格校验兜底)
cc = _safe_country_code(ip_iso)
if cc and digits.startswith(cc) and len(digits) > len(cc):
rest = digits[len(cc):]
parsed = _try_parse(rest, ip_iso)
if parsed is not None and phonenumbers.is_valid_number(parsed):
e164 = phonenumbers.format_number(parsed, PhoneNumberFormat.E164)
return HAS_CC, _new_state(digits, "prefix_normalized",
e164=e164, confirmed=True)
# c/d: region 严格解析,失败退 naive
comp = _complete_with_region(digits, ip_iso)
if comp:
source = "ip_proposed" if comp["strict"] else "naive"
return NEED_CC, _new_state(digits, source, e164=comp["e164"])
# e: 无建议国
st = _new_state(digits, "no_ip")
st["suggest_ask_full"] = True
return NEED_CC, st
def attempt_cap(source: str | None) -> int:
"""确认轮上限:no_ip 1 轮,其余 2 轮(spec §4 统一口径)。"""
return ATTEMPT_CAP_NO_IP if source == "no_ip" else ATTEMPT_CAP_DEFAULT
def _consume_round(state: dict) -> dict:
"""消耗一轮(无回应/不可解析/报国重算后调用):attempts+1,达上限落定。
落定规则:有 e164 的路径 source 改写 fallback(标注"未经客户确认");
no_ip 无 e164 保持原 source(邮件标"缺国家区号"),仅停止追问。
"""
state["attempts"] = int(state.get("attempts", 0)) + 1
if not state.get("confirmed") and state["attempts"] >= attempt_cap(state.get("source")):
if state.get("e164"):
state["source"] = "fallback"
return state
def transit(state: dict | None, reply: dict | None,
new_canon: str | None = None) -> dict | None:
"""消费一轮 cc_reply 推进状态机(纯函数,不变异入参;spec §4 答语树)。
参数:
state —— classify 产物或上轮 transit 结果;
reply —— None(无回应/extractor 漏填兜底)或 {"affirm": bool,
"country": str|None}(extractor 单轮分类,消费后由调用方剥离);
new_canon —— 本轮最新号码的纯数字形态;与 state["canon"] 不同 → 换号。
返回:
None —— RESET(换号或无态):调用方对号码重走 classify 重建状态
(结构性解决"改号残留":旧补全永不跟随新号);
dict —— 新状态:
- affirm=true → confirmed=true(source=ip_confirm;报国重算后的确认
保持 customer_stated,号源如实标注)→ 落定;
- affirm=false + country → NAME_TO_REGION 转 ISO 重算 e164
(source=customer_stated)→ 再回显,attempts+1;
- affirm=false 无 country / 无回应 / 国名不识别 → attempts+1,
达上限(常规 2 轮)落定 confirmed=false、source=fallback。
"""
if not state:
return RESET
canon = state.get("canon")
if new_canon is not None and canon is not None and new_canon != canon:
return RESET
new = dict(state)
if reply is None:
return _consume_round(new)
affirm = bool(reply.get("affirm"))
country = (reply.get("country") or "").strip() or None
if affirm:
if not new.get("e164"):
# no_ip 态没有可确认对象——肯定答语无意义,按一轮消耗处理
return _consume_round(new)
new["confirmed"] = True
new["source"] = ("customer_stated" if new.get("source") == "customer_stated"
else "ip_confirm")
return new
if country:
iso = name_to_region(country)
recalc = _complete_with_region(canon or "", iso) if iso else None
if recalc:
new["e164"] = recalc["e164"]
new["intl"] = recalc["intl"]
new["source"] = "customer_stated"
# 国名不识别:不重算不撒谎,等同无回应走兜底(spec §9)
return _consume_round(new)
# affirm=false 无 country → 请重发完整号(新号到达=canon 变化自然 RESET)
return _consume_round(new)
def _settled(state: dict) -> bool:
"""确认流是否落定(不再追问):confirmed / attempts 耗尽 /
prefix_normalized 与 fallback 天然终态。"""
if state.get("confirmed"):
return True
if state.get("source") in ("prefix_normalized", "fallback"):
return True
return int(state.get("attempts", 0)) >= attempt_cap(state.get("source"))
def cc_due(state: dict | None) -> bool:
"""是否处于等待 cc_reply 的未落定态——消费答语/注入确认句的门控。
None → False:无状态即无等待(带 + 号客户不进流程)。
"""
if not state:
return False
return not _settled(state)
def cc_settled(state: dict | None) -> bool:
"""确认流是否已落定(或根本无流)。None → True:无态视为天然落定,
邮箱轻补解锁判断不因缺状态而回归(带 + 号客户照走现有轻补路径)。"""
if not state:
return True
return _settled(state)
# 国名 → ISO 3166-1 alpha-2。中文全集与 ip_service.COUNTRY_NAMES 对齐
# (tests/test_cc_completion.py 强制同步:COUNTRY_NAMES 新增国必须同步本表),
# 英文为对应常见英文国名(双语两键同值)+ 高频别称。
NAME_TO_REGION: dict[str, str] = {
# —— 中文(全集对齐 ip_service.COUNTRY_NAMES)——
"中国": "CN", "美国": "US", "尼日利亚": "NG", "加纳": "GH",
"肯尼亚": "KE", "坦桑尼亚": "TZ", "南非": "ZA", "埃及": "EG",
"阿联酋": "AE", "沙特阿拉伯": "SA", "伊拉克": "IQ", "约旦": "JO",
"巴基斯坦": "PK", "孟加拉国": "BD", "印度": "IN", "泰国": "TH",
"越南": "VN", "印尼": "ID", "菲律宾": "PH", "马来西亚": "MY",
"日本": "JP", "韩国": "KR", "英国": "GB", "德国": "DE",
"法国": "FR", "俄罗斯": "RU", "巴西": "BR", "墨西哥": "MX",
"澳大利亚": "AU", "加拿大": "CA", "土耳其": "TR", "伊朗": "IR",
"缅甸": "MM", "柬埔寨": "KH", "老挝": "LA", "斯里兰卡": "LK",
"尼泊尔": "NP", "乌干达": "UG", "埃塞俄比亚": "ET", "塞内加尔": "SN",
"科特迪瓦": "CI", "喀麦隆": "CM", "刚果(金)": "CD", "莫桑比克": "MZ",
# —— 中文高频简称/别称 ——
"沙特": "SA", "迪拜": "AE", "刚果金": "CD",
# —— 英文国名 ——
"China": "CN", "United States": "US", "Nigeria": "NG", "Ghana": "GH",
"Kenya": "KE", "Tanzania": "TZ", "South Africa": "ZA", "Egypt": "EG",
"United Arab Emirates": "AE", "Saudi Arabia": "SA", "Iraq": "IQ",
"Jordan": "JO", "Pakistan": "PK", "Bangladesh": "BD", "India": "IN",
"Thailand": "TH", "Vietnam": "VN", "Indonesia": "ID", "Philippines": "PH",
"Malaysia": "MY", "Japan": "JP", "South Korea": "KR", "United Kingdom": "GB",
"Germany": "DE", "France": "FR", "Russia": "RU", "Brazil": "BR",
"Mexico": "MX", "Australia": "AU", "Canada": "CA", "Turkey": "TR",
"Iran": "IR", "Myanmar": "MM", "Cambodia": "KH", "Laos": "LA",
"Sri Lanka": "LK", "Nepal": "NP", "Uganda": "UG", "Ethiopia": "ET",
"Senegal": "SN", "Ivory Coast": "CI", "Cameroon": "CM", "DR Congo": "CD",
"Mozambique": "MZ",
# —— 英文高频简称/别称 ——
"USA": "US", "UAE": "AE", "KSA": "SA", "UK": "GB", "Korea": "KR",
"Dubai": "AE", "Cote d'Ivoire": "CI", "Democratic Republic of the Congo": "CD",
# —— 超出 COUNTRY_NAMES 的高频市场补充(西语场景为本项目多语言重点)——
"西班牙": "ES", "Spain": "ES",
}
# 大小写容错影子表(中文 casefold 为恒等,不受影响)
_NAME_LOOKUP = {k.casefold(): v for k, v in NAME_TO_REGION.items()}
def name_to_region(name: str | None) -> str | None:
"""国名(extractor 回传原样字符串)→ ISO;未命中返回 None。
容错:去首尾空白、大小写不敏感、弯撇号(U+2019)归一为 ASCII。
"""
if not name:
return None
key = name.strip().replace("’", "'")
if key in NAME_TO_REGION:
return NAME_TO_REGION[key]
return _NAME_LOOKUP.get(key.casefold())
# ---- Task 6 出站渲染(spec §5):首封补全号的展示派生(纯函数,邮件/推送共用) ----
# 标注文案(spec §5 钉死):有 e164 未确认 → 未经确认;无 e164 → 缺区号。
NOTE_UNCONFIRMED = "(区号按 IP 推测,未经客户确认)"
NOTE_MISSING_CC = "(缺国家区号)"
def cc_state_for(cc_map: dict | None, channel: str, value: str) -> dict | None:
"""渠道 → _cc 状态解析(canon 组归并感知,出站渲染入口)。
优先本渠道状态;本渠道无态但 canon 与 whatsapp 组一致时共享组状态
(同号 phone/whatsapp 确认一次,渲染也同标——否则同封邮件一行 e164
一行裸号自相矛盾)。canon 不匹配(换号后残留/防御性垃圾)一律 None,
调用方回退裸号原文——旧补全永不跟随新号。
"""
digits = _digits(value)
if not digits:
return None
for state in ((cc_map or {}).get(channel), (cc_map or {}).get("whatsapp")):
if isinstance(state, dict) and str(state.get("canon") or "") == digits:
return state
return None
def outbound_number(value: str, state: dict | None) -> str:
"""
⚠️ 前提: state 必须来自 cc_state_for 的产物(canon 已匹配);
裸传 state 会失去残留态防御(审查 Issue 2)。邮件正文联系方式行的展示值(spec §5 首封矩阵):
- 有 e164 且 confirmed → 纯 e164(prefix_normalized/ip_confirm/
customer_stated,码是客户自己写的/确认过的,零标注);
- 有 e164 未 confirmed → e164 +(区号按 IP 推测,未经客户确认)
(ip_proposed/naive/fallback 统一口径,spec §4 标注语义);
- 无 e164(no_ip / 迁移分支可产出的 fallback-无-e164)→ 裸号原文 +
(缺国家区号)——拿不出补全号不硬造;
- 无状态(显式 +/00 号码等)→ 原样。
"""
raw = str(value or "")
e164 = str((state or {}).get("e164") or "")
if not e164:
return (raw + NOTE_MISSING_CC) if state is not None else raw
if state.get("confirmed"):
return e164
return e164 + NOTE_UNCONFIRMED
def outbound_e164(value: str, state: dict | None) -> str:
"""主题行/推送 contact 用:有 e164 给纯 e164(无标注——主题拥挤、
对方契约要"带国际区号且有效"的干净号),其余原样。"""
e164 = str((state or {}).get("e164") or "")
return e164 or str(value or "")
def completion_note(value: str, state: dict | None) -> str | None:
"""推送画像派生键「号码补全说明」的单渠道条目;无需披露返回 None。
confirmed 的补全零披露;未确认 → "原文→补全号+标注";缺区号 →
"原文+(缺国家区号)"。出站时由 _cc 计算,不落正常键(零 LLM 泄漏面)。
"""
raw = str(value or "")
if state is None:
return None
e164 = str(state.get("e164") or "")
if not e164:
return raw + NOTE_MISSING_CC
if state.get("confirmed"):
return None
return f"{raw}→{e164}{NOTE_UNCONFIRMED}"
+302
View File
@@ -0,0 +1,302 @@
"""统一联系方式管道:联系方式检测的唯一事实源。
三个动作:
- 归一化:中英文 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
File diff suppressed because it is too large Load Diff
+163
View File
@@ -0,0 +1,163 @@
import html
import logging
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
import aiosmtplib
from app.engine.config import settings
from app.engine.services.cc_completion import cc_state_for, outbound_e164, outbound_number
logger = logging.getLogger(__name__)
TAG_LABELS = {
"interested": "意向强", "pending": "待确认",
"not_interested": "无意向", "": "未评估",
}
CONTACT_LABELS = {"phone": "手机", "whatsapp": "WhatsApp", "email": "邮箱", "wechat": "微信"}
def _esc(v) -> str:
return html.escape(str(v)) if v else ""
def _flat(v) -> str:
"""主题行用:折叠空白(堵换行注入头部的通道)+ 限长。"""
return " ".join(str(v).split())[:60]
def _contact_rows(contacts: dict) -> str:
return "".join(
f"<tr><td style='padding:6px 12px;font-weight:bold;'>{html.escape(CONTACT_LABELS.get(k, k))}</td>"
f"<td style='padding:6px 12px;'>{_esc(v)}</td></tr>"
for k, v in (contacts or {}).items() if v
)
def _cc_views(contacts: dict, cc_info: dict | None) -> tuple[dict, dict]:
"""Task 6(spec §5)出站双视图:_cc 感知的正文/主题两套联系方式值。
正文视图(body):phone/whatsapp 有态 → e164 + 按 confirmed 标注;
无 e164 → 裸号 +(缺国家区号);canon 不匹配的残留态在 cc_state_for
内判 None 回退原文。主题视图(subj):同状态一律纯 e164 无标注
(主题拥挤)。其余渠道(email/wechat)原样。
"""
body: dict = {}
subj: dict = {}
for k, v in (contacts or {}).items():
if not v:
continue
if k in ("phone", "whatsapp"):
state = cc_state_for(cc_info, k, str(v))
body[k] = outbound_number(str(v), state)
subj[k] = outbound_e164(str(v), state)
else:
body[k] = str(v)
subj[k] = str(v)
return body, subj
async def _send(msg: MIMEMultipart) -> bool:
if not all([settings.SMTP_HOST, settings.SMTP_USER, settings.NOTIFY_EMAIL]):
logger.warning("SMTP not configured, skipping email")
return False
try:
await aiosmtplib.send(
msg, hostname=settings.SMTP_HOST, port=settings.SMTP_PORT,
username=settings.SMTP_USER, password=settings.SMTP_PASS,
use_tls=True,
)
logger.info("Lead email sent to %s", settings.NOTIFY_EMAIL)
return True
except Exception:
logger.exception("Failed to send lead email")
return False
async def send_lead_email(
customer_profile: dict,
contact_info: dict,
reasoning: str,
conversation_messages: list[dict],
intent_level: str = "",
cc_info: dict | None = None,
) -> bool:
"""cc_info = raw_info["_cc"](Task 6, spec §5):正文联系方式行渲染
补全号+标注、主题行号码用 e164 无标注;缺省(无 _cc/显式 + 号)行为不变。"""
tag = TAG_LABELS.get(intent_level, "未评估")
contacts, subj_contacts = _cc_views(contact_info, cc_info)
country = (customer_profile or {}).get("目标国家", "")
if not country:
country = (customer_profile or {}).get("target_country", "未知")
msg = MIMEMultipart("alternative")
contact_parts = [
f"{CONTACT_LABELS.get(k, k)}: {_flat(v)}" for k, v in subj_contacts.items()
]
subject = f"[新询盘·{tag}] {_flat(country)}"
if contact_parts:
subject += " | " + ", ".join(contact_parts)
msg["Subject"] = subject
msg["From"] = f"【HESUPIPE】 <{settings.SMTP_USER}>"
msg["To"] = settings.NOTIFY_EMAIL
info_rows = "".join(
f"<tr><td style='padding:6px 12px;font-weight:bold;'>{_esc(k)}</td>"
f"<td style='padding:6px 12px;'>{_esc(v)}</td></tr>"
for k, v in (customer_profile or {}).items() if not str(k).startswith("_")
)
chat_lines = "".join(
f"<div style='margin:4px 0;'><b>{'客户' if m.get('role') == 'user' else 'AI'}:</b> {_esc(m.get('content', ''))}</div>"
for m in conversation_messages or []
)
html_body = f"""
<html><body style="font-family:sans-serif;color:#333;">
<h2>🎉 新询盘({html.escape(tag)})</h2>
<h3>联系方式</h3>
<table style="border-collapse:collapse;border:1px solid #ddd;">{_contact_rows(contacts)}</table>
<h3>客户画像</h3>
<table style="border-collapse:collapse;border:1px solid #ddd;">{info_rows}</table>
<h3>评估理由</h3><p>{_esc(reasoning)}</p>
<h3>完整对话记录</h3>
<div style="background:#f5f5f5;padding:12px;border-radius:6px;">{chat_lines}</div>
<p style="color:#999;font-size:12px;">注:邮件为触发时快照,对话可能仍在进行,最新记录请以后台为准。</p>
</body></html>
"""
msg.attach(MIMEText(html_body, "html", "utf-8"))
return await _send(msg)
async def send_returning_lead_email(
contact_info: dict, cc_info: dict | None = None,
) -> bool:
"""D5: 24h 内同联系方式再访,只发一行轻提醒。主题行号码走 e164(Task 6)。"""
_body, subj_contacts = _cc_views(contact_info, cc_info)
contact_parts = [
f"{CONTACT_LABELS.get(k, k)}: {_flat(v)}" for k, v in subj_contacts.items()
]
msg = MIMEMultipart("alternative")
msg["Subject"] = f"[线索回访] {' | '.join(contact_parts) or '已知客户'}再次来访"
msg["From"] = f"【HESUPIPE】 <{settings.SMTP_USER}>"
msg["To"] = settings.NOTIFY_EMAIL
msg.attach(MIMEText(
f"<html><body><p>该联系方式 24h 内已发过完整询盘,客户又开启了新对话,"
f"详情请查后台。</p></body></html>", "html", "utf-8"))
return await _send(msg)
async def send_correction_email(
contact_info: dict, count: int = 1, cc_info: dict | None = None,
) -> bool:
"""D4: 联系方式变化(改号/新增/无提示换值)后的轻量更正邮件,每会话上限3封。
正文联系方式行渲染补全号+标注(Task 6, spec §5 更正路径)。"""
body_contacts, _subj = _cc_views(contact_info, cc_info)
msg = MIMEMultipart("alternative")
msg["Subject"] = f"[询盘·更正{count}] 客户联系方式已更新(以本封为准)"
msg["From"] = f"【HESUPIPE】 <{settings.SMTP_USER}>"
msg["To"] = settings.NOTIFY_EMAIL
msg.attach(MIMEText(
f"<html><body><p>这是本会话第 {count} 次更正(上限 3 次),请以本封为准:</p>"
f"<table>{_contact_rows(body_contacts)}</table>"
f"</body></html>", "html", "utf-8"))
return await _send(msg)
+46
View File
@@ -0,0 +1,46 @@
from iptocc import country_code
# ISO 3166-1 alpha-2 to country name (common ones)
COUNTRY_NAMES = {
"CN": "中国", "US": "美国", "NG": "尼日利亚", "GH": "加纳",
"KE": "肯尼亚", "TZ": "坦桑尼亚", "ZA": "南非", "EG": "埃及",
"AE": "阿联酋", "SA": "沙特阿拉伯", "IQ": "伊拉克", "JO": "约旦",
"PK": "巴基斯坦", "BD": "孟加拉国", "IN": "印度", "TH": "泰国",
"VN": "越南", "ID": "印尼", "PH": "菲律宾", "MY": "马来西亚",
"JP": "日本", "KR": "韩国", "GB": "英国", "DE": "德国",
"FR": "法国", "RU": "俄罗斯", "BR": "巴西", "MX": "墨西哥",
"AU": "澳大利亚", "CA": "加拿大", "TR": "土耳其", "IR": "伊朗",
"MM": "缅甸", "KH": "柬埔寨", "LA": "老挝", "LK": "斯里兰卡",
"NP": "尼泊尔", "UG": "乌干达", "ET": "埃塞俄比亚", "SN": "塞内加尔",
"CI": "科特迪瓦", "CM": "喀麦隆", "CD": "刚果(金)", "MZ": "莫桑比克",
}
def resolve_country(ip: str) -> str:
if not ip or ip in ("127.0.0.1", "::1", "localhost"):
return "本地"
try:
code = country_code(ip)
if code and code != "--":
return COUNTRY_NAMES.get(code, code)
return "未知"
except Exception:
return "未知"
def resolve_region_code(ip: str) -> str | None:
"""解析 ip 为 ISO 3166-1 alpha-2 国家码(如 "US"),查不到返回 None。
与 resolve_country 的分工:resolve_country 返回中文名(含"本地"/"未知"
兜底占位,仅供展示/记录);本函数返回 ISO alpha-2 或 None,供电话区号
推断等逻辑消费——绝无中文或占位值,调用方无需再过滤。
"""
if not ip or ip in ("127.0.0.1", "::1", "localhost"):
return None
try:
code = country_code(ip)
if code and code != "--":
return code
return None
except Exception:
return None
+321
View File
@@ -0,0 +1,321 @@
"""询盘推送 UniService 服务(设计 2026-09-28-lead-push-to-uniservice)。
三个入口:
- dispatch_lead_push: 触发点调用(发信分支进入处),写 pending/skipped 日志。
红线: 整体吞异常,绝不影响邮件主流程。
- fire_pending_pushes: 主事务 commit 之后调用,create_task 派生后台任务。
- sweep_stale_pending: lifespan 启动清扫(崩溃遗留的 pending → failed)。
后台任务红线(设计 8.1): 不读库只写结果,payload 内存传递;
结果写入用独立 session(from app.main import async_session,照 chat_rest 模式)。
"""
import asyncio
import logging
from datetime import datetime, timedelta, timezone
import httpx
from sqlalchemy import func, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.engine.config import settings
from app.main import async_session
from app.engine.models import (
Conversation, CustomerProfile, Evaluation, IntentLevel, LeadPushLog,
Message, MessageRole,
)
from app.engine.services.cc_completion import cc_state_for, completion_note, outbound_e164
from app.engine.services.ip_service import resolve_country
logger = logging.getLogger(__name__)
# 对方契约(docs/AI_INQUIRY_PUSH_API.md): phone/whatsapp/email 至少一个有效
_TRIO = ("phone", "whatsapp", "email")
_CST = timezone(timedelta(hours=8))
_INTENT_MAP = {
"interested": "high",
"pending": "medium",
"not_interested": "low",
}
def _fmt_cst(dt: datetime) -> str:
"""UTC(-aware 或 naive)→ +08:00 ISO 串。对方明确拒收 UTC(契约 §2)。"""
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
dt = dt.replace(microsecond=0) # 归一整秒: 契约示例为整秒,亚秒精度无益且冒严格解析器拒收风险
return dt.astimezone(_CST).isoformat()
def map_intent(level: str | None, evaluated: bool) -> str:
"""我方 IntentLevel → 对方 high/medium/low/unknown。
evaluated=False(<3 条客户消息,D2)→ unknown: 未评估不是"待确认",
与邮件标签 [未评估]/[待确认] 的区分同源。
"""
if not evaluated or not level:
return "unknown"
return _INTENT_MAP.get(level, "unknown")
_sleep = asyncio.sleep # 模块级绑定,测试 monkeypatch 记录退避序列用
async def _build_payload(
db: AsyncSession, ev: Evaluation, conv: Conversation,
profile: CustomerProfile, confirmed: dict, push_type: str,
evaluation: dict, evaluated: bool,
) -> dict:
"""组装契约报文(设计 7.2 映射表)。仅读当前事务,不产生副作用。"""
# 7.1 event 规则: 仅更正可能 update,且必须同会话此前推送成功过
# (不依赖对方"update 未知键"的未定义行为)
event = "new"
if push_type == "correction":
prior = await db.execute(
select(func.count()).select_from(LeadPushLog).where(
LeadPushLog.conversation_id == conv.id,
LeadPushLog.status == "success",
))
if (prior.scalar() or 0) > 0:
event = "update"
result = await db.execute(
select(Message)
.where(Message.conversation_id == conv.id)
.order_by(Message.created_at)
)
msgs = result.scalars().all()
raw_info = profile.raw_info or {}
client_info = conv.client_info or {}
# Task 6(spec §5 先消费后过滤,顺序钉死): 先读 raw_info 的 _cc 派生——
# contact 的 phone/whatsapp 换成对应渠道 e164(首封即补全号,确认与否都给;
# 无 e164 态用原文),标注进"号码补全说明"派生键;之后才做 _ 前缀过滤,
# _cc 本体绝不进 payload,派生键(无 _ 前缀)存活。canon 不匹配的残留态
# 在 cc_state_for 内判 None——旧补全永不跟随新号。
cc_map = raw_info.get("_cc") or {}
contact_out: dict = {}
cc_notes: list[str] = []
for k, v in (confirmed or {}).items():
if k in ("phone", "whatsapp") and v:
state = cc_state_for(cc_map, k, str(v))
contact_out[k] = outbound_e164(str(v), state)
note = completion_note(str(v), state)
if note:
cc_notes.append(note)
else:
contact_out[k] = v
# _ 前缀内部状态(一次性计数等)不进推送画像(spec §4.3——渲染侧
# 三处有过滤(enrich/reminder/email_service),推送端曾整体拷贝泄漏)
profile_out = {k: v for k, v in raw_info.items()
if not str(k).startswith("_")}
profile_out["country"] = resolve_country(client_info.get("ip", ""))
profile_out["source"] = conv.source.value
if client_info.get("lang"):
profile_out["语言"] = client_info["lang"]
if cc_notes:
profile_out["号码补全说明"] = ";".join(cc_notes)
reasoning = (evaluation.get("reasoning") or "") if evaluated else ""
return {
"event": event,
"external_key": f"{settings.PUSH_EXTERNAL_KEY_PREFIX}-{conv.id}",
"occurred_at": _fmt_cst(datetime.now(timezone.utc)),
"contact": {
"name": None, # 我方 contact_info 快照无称呼字段,契约可空
"phone": contact_out.get("phone"),
"whatsapp": contact_out.get("whatsapp"),
"email": contact_out.get("email"),
"wechat": contact_out.get("wechat"),
"skype": None,
},
"intent_level": map_intent(evaluation.get("intent_level"), evaluated),
"intent_reason": reasoning or None,
"profile": profile_out,
"conversation": [
{
"role": "customer" if m.role == MessageRole.user else "ai",
"text": m.content,
"at": _fmt_cst(m.created_at),
}
for m in msgs if m.role in (MessageRole.user, MessageRole.assistant)
],
}
async def dispatch_lead_push(
db: AsyncSession, ev: Evaluation, conv: Conversation,
profile: CustomerProfile, confirmed: dict, push_type: str,
evaluation: dict, evaluated: bool,
) -> LeadPushLog | None:
"""触发点唯一入口: 返回待派发的 pending 行(加入当前事务,随主事务提交)。
红线(设计 6.1): 整体吞异常——任何失败只记日志,绝不影响邮件主流程;
触发条件 = 调用方进入发信分支,与 SMTP 成败解耦。
DB 层失败用 SAVEPOINT 隔离: 只回滚推送写入,不毒化调用方事务
(否则 flush 失败 → PendingRollbackError → email_sent 落不了库 → 重复发邮件)。
"""
if not settings.PUSH_ENABLED:
return None
try:
async with db.begin_nested():
# 三选一预检(设计 §4 skipped 语义): 对方必拒,不发 HTTP 只留审计痕迹
if not any(confirmed.get(k) for k in _TRIO):
db.add(LeadPushLog(
evaluation_id=ev.id, conversation_id=conv.id,
push_type=push_type, status="skipped", attempts=0,
error_message="no valid contact (phone/whatsapp/email)",
))
return None
payload = await _build_payload(
db, ev, conv, profile, confirmed, push_type, evaluation, evaluated)
log = LeadPushLog(
evaluation_id=ev.id, conversation_id=conv.id,
push_type=push_type, status="pending", attempts=0,
request_payload=payload,
)
db.add(log)
await db.flush() # 取 id 供后台任务写结果
return log
except Exception:
logger.exception("dispatch_lead_push failed (type=%s, conv=%s)",
push_type, conv.id)
return None
async def _do_post(payload: dict) -> tuple[httpx.Response | None, str | None]:
"""实际 HTTP 调用(独立函数便于测试 monkeypatch)。
返回 (resp, transport_error): 传输层异常不抛出,转成错误串由重试矩阵消化。
"""
try:
async with httpx.AsyncClient(
timeout=settings.PUSH_TIMEOUT_SECONDS,
follow_redirects=True) as client: # 307/308 不落终态失败
resp = await client.post(
settings.PUSH_API_URL,
json={"messages": [payload]}, # 契约 §2: 批量结构,我方单条
headers={"X-Api-Key": settings.PUSH_API_KEY},
)
return resp, None
except httpx.HTTPError as exc:
return None, repr(exc)
def _retryable(resp: httpx.Response | None) -> bool:
"""契约 §3: 5xx/超时可重试;401/400/其他 4xx 重试无益。200 不在此判定。"""
return resp is None or resp.status_code >= 500
async def _update_log(push_log_id: str, **fields) -> None:
"""后台任务写结果——独立 session + 小事务(设计 6.3)。"""
async with async_session() as session:
await session.execute(
update(LeadPushLog)
.where(LeadPushLog.id == push_log_id)
.values(**fields)
)
await session.commit()
async def _run_push(push_log_id: str, payload: dict) -> None:
"""后台任务体: 分类重试 + 200 逐条结果解析(设计 6.3/§10)。
红线: 全套 try/except,异常只记日志绝不逃逸(fire-and-forget)。
"""
try:
attempts = 0
while True:
attempts += 1
resp, transport_error = await _do_post(payload)
if _retryable(resp) and attempts < settings.PUSH_MAX_ATTEMPTS:
await _update_log(
push_log_id, attempts=attempts,
error_message=transport_error
or (f"http {resp.status_code}" if resp is not None else "unknown"),
)
await _sleep(
settings.PUSH_RETRY_BACKOFF_BASE * 2 ** (attempts - 1))
continue
break
if resp is not None and resp.status_code == 200:
try:
raw = resp.json()
except ValueError:
# 非 JSON 的 200(如网关重定向到 HTML 登录页)——
# 无法确认对方已接受,诚实落 failed(幂等键保证补推安全)
await _update_log(push_log_id, attempts=attempts, status="failed",
response_status=200,
error_message="non-JSON 200 body")
return
data = raw.get("data") if isinstance(raw, dict) else None
data = data if isinstance(data, dict) else {}
failed_mine = [
f for f in (data.get("failed") or [])
if f.get("external_key") == payload["external_key"]
]
if failed_mine:
await _update_log(
push_log_id, attempts=attempts, status="failed",
response_status=200,
error_message=str(failed_mine[0].get("reason") or "rejected"),
)
else:
# 单条推送: accepted/duplicated 计数即本条结论
outcome = ("duplicated"
if (data.get("duplicated") or 0) > 0 else "accepted")
await _update_log(
push_log_id, attempts=attempts, status="success",
response_status=200, outcome=outcome,
)
return
# 终态失败: 重试耗尽(5xx/传输)或不可重试(401/400/其他)
if resp is not None and resp.status_code in (400, 401):
logger.error("lead push rejected (log_id=%s, http %s) — "
"密钥/报文问题需人工介入,不重试",
push_log_id, resp.status_code)
await _update_log(
push_log_id, attempts=attempts, status="failed",
response_status=resp.status_code if resp is not None else None,
error_message=transport_error
or (f"http {resp.status_code}" if resp is not None else "unknown"),
)
except Exception:
logger.exception("lead push task crashed (log_id=%s)", push_log_id)
# 后台任务引用集: 防 GC(asyncio 官方要求),done 即移除
_tasks: set[asyncio.Task] = set()
def fire_pending_pushes(pushes: list[LeadPushLog]) -> None:
"""主事务 commit 之后调用(设计 8.1): payload 内存传递,任务不读库。
同步函数、纳秒级返回——这就是"不阻塞"的派发点。
"""
for log in pushes:
if log.status != "pending":
continue
task = asyncio.create_task(_run_push(log.id, log.request_payload))
_tasks.add(task)
task.add_done_callback(_tasks.discard)
async def sweep_stale_pending(stale_minutes: int = 30) -> int:
"""启动清扫(设计 8.2): 崩溃遗留的 pending → failed。
已知限制(设计 §14): 推送成功但结果写回失败也会被标 failed——
幂等键保证将来补推安全(对方返回 duplicated),概率极低,接受。
"""
cutoff = datetime.now(timezone.utc) - timedelta(minutes=stale_minutes)
async with async_session() as session:
result = await session.execute(
update(LeadPushLog).where(
LeadPushLog.status == "pending",
LeadPushLog.updated_at < cutoff,
).values(status="failed", error_message="interrupted by restart")
)
await session.commit()
return result.rowcount or 0
+39
View File
@@ -0,0 +1,39 @@
"""Minimal in-memory rate limiter (per (ip, device_id), rolling 1h window).
Kept aligned with the reference implementation's rate_limit.py shape. Only the chat message route uses this;
session/history endpoints are cheap and not throttled. State is process-local
and resets on restart, which is acceptable for a single-worker ai-kefu deploy.
Caveats:
- Single-process, in-memory. Under `uvicorn --workers >1` each worker keeps
its own `_buckets`, so the effective cap is `max_requests * workers`.
- Keyed on (ip, device_id); a client rotating the `X-Device-Id` header per
request bypasses it. This is a light safeguard against accidental GLM-cost
burn, NOT abuse-proof.
"""
import time
from collections import defaultdict
_buckets: dict[tuple[str, str], list[float]] = defaultdict(list)
WINDOW = 3600
DEFAULT_MAX = 30
def check(
ip: str | None,
device_id: str | None,
*,
max_requests: int = DEFAULT_MAX,
) -> bool:
"""Return True if the request is under the cap (and record it), else False."""
key = (ip or "", device_id or "")
now = time.time()
_buckets[key] = [t for t in _buckets[key] if t > now - WINDOW]
if len(_buckets[key]) >= max_requests:
return False
_buckets[key].append(now)
return True
def _reset_for_test() -> None:
_buckets.clear()