feat: jc 引擎主线搬入 app/engine(315 测试基线全绿:320 去掉 admin 5 用例)
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -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),
|
||||
}
|
||||
@@ -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 # ④ 精确词全缺失/泛词但无号码 → 放行
|
||||
@@ -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}"
|
||||
@@ -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
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user