chore: 从汽车AI客服项目拷贝骨架并清理(blog页/redis)
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.providers.base import AIProvider
|
||||
from app.providers.glm import GLMProvider
|
||||
from app.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,199 @@
|
||||
"""职能边界输出过滤(方案B): AI 回复价格形态的确定性拦截。
|
||||
流式回复无法撤回,故在流出前全量缓冲过滤;违规整体替换为标准迂回话术。
|
||||
模式以 scripts/breach_rate.py 为基础并收严(预算复述/金融收严;公里等误报除外项)。
|
||||
|
||||
评审修正(13.6 复查):
|
||||
- 货币组尾部 \\b 对中文备选(万/块钱/美元)永远无法命中——中文无词边界,
|
||||
"2万左右"的 万与左 之间没有 \\b(实测漏检预算复述 6/6 的原形态)。
|
||||
\\b 只保留在拉丁备选 usd 之后(防 "usdroid" 类前缀误命中)。
|
||||
- 公里/km 排除必须直接挂在 万块? 之后: 挂在末尾裸 万 备选上会被
|
||||
万块? 的空块匹配绕过("3万公里"仍命中,实测)。
|
||||
- 预算复述(裸 budget+数字/中文数字万)、金融裸数(首付/月供)收严;
|
||||
里程 k 形式(30k km/45k miles)除外。质保期不再豁免(政策变更 2026-09-10:
|
||||
保修期限是编造的公司政策承诺,一并墙级拦截,见 PRICE_PATTERNS 时间承诺组)。"""
|
||||
import re
|
||||
|
||||
from app.services.contact_pipeline import digits_only
|
||||
|
||||
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),
|
||||
]
|
||||
|
||||
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 the cars?",
|
||||
]
|
||||
DEFLECTIONS_ZH_CONTACT = [
|
||||
"客户经理稍后会把准确报价发到你的 WhatsApp。还想了解什么车?",
|
||||
]
|
||||
|
||||
|
||||
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。",
|
||||
]
|
||||
|
||||
|
||||
def pivot_for(user_msg: str, has_contact: bool = False) -> str:
|
||||
zh = bool(re.search(r"[一-鿿]", user_msg or ""))
|
||||
if has_contact:
|
||||
pool = PIVOTS_ZH_CONTACT if zh else PIVOTS_EN_CONTACT
|
||||
else:
|
||||
pool = PIVOTS_ZH if zh else PIVOTS_EN
|
||||
return pool[0]
|
||||
|
||||
|
||||
def deflection_for(user_msg: str, rotate: int = 0,
|
||||
has_contact: bool = False) -> str:
|
||||
zh = bool(re.search(r"[一-鿿]", user_msg or ""))
|
||||
if has_contact:
|
||||
pool = DEFLECTIONS_ZH_CONTACT if zh else DEFLECTIONS_EN_CONTACT
|
||||
else:
|
||||
pool = DEFLECTIONS_ZH if zh else DEFLECTIONS_EN
|
||||
return pool[rotate % len(pool)]
|
||||
|
||||
|
||||
# ---- 15.3: 零号码墙(doctrine v2) ----
|
||||
# AI 绝不输出任何号码/邮箱: 与价格墙并列的第二道确定性红线。
|
||||
LONG_NUMBER_RE = re.compile(r"\+?\d[\d\s\-().]{9,17}\d") # ≥10位连续数字(允许分隔形态)
|
||||
ANY_EMAIL_RE = re.compile(r"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}")
|
||||
|
||||
NO_CONTACT_WEB = ("Just tap the WhatsApp button at the top of this chat — "
|
||||
"our manager replies fast!")
|
||||
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, user_history: list[dict] | None = None) -> bool:
|
||||
"""零号码墙(v2): AI 输出不得含任何 ≥10 位数字串或邮箱。
|
||||
唯一豁免: 该串出现在近期客户消息中(客户自己号码的礼貌回显)。
|
||||
无值比对——年份/里程/VIN/续航天然安全(已验证)。"""
|
||||
for m in LONG_NUMBER_RE.finditer(sentence or ""):
|
||||
c = digits_only(m.group(0))
|
||||
if user_history and any(c in digits_only(msg.get("content") or "")
|
||||
for msg in user_history if msg.get("role") == "user"):
|
||||
continue # 客户回显豁免
|
||||
return True
|
||||
for m in ANY_EMAIL_RE.finditer(sentence or ""):
|
||||
if user_history and any(m.group(0).lower() in (msg.get("content") or "").lower()
|
||||
for msg in user_history if msg.get("role") == "user"):
|
||||
continue
|
||||
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),
|
||||
]
|
||||
|
||||
|
||||
def asks_contact(sentence: str) -> bool:
|
||||
"""输出句是否在索要联系方式(仅在客户已留资时用于丢弃)。"""
|
||||
return any(p.search(sentence or "") for p in REASK_PATTERNS)
|
||||
@@ -0,0 +1,264 @@
|
||||
"""统一联系方式管道:联系方式检测的唯一事实源。
|
||||
|
||||
三个动作:
|
||||
- 归一化:中英文 key 别名统一为 phone/whatsapp/email/wechat
|
||||
- 深合并:同 key 新值覆盖旧值(客户改号场景),空值不覆盖
|
||||
- 校验分级:confirmed(格式通过,可触发邮件/结束)/ suspect(疑似,
|
||||
仅用于提醒 AI 礼貌确认,防止重复索要——D6/S1)
|
||||
|
||||
上游约定:raw_info 中 "_" 前缀 key 为内部状态,禁止进入提示词渲染。
|
||||
"""
|
||||
import re
|
||||
|
||||
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 normalize_contacts(raw: dict | None) -> dict:
|
||||
"""key 归一化 + 去空值。精确别名优先;未命中再做 contains 兜底
|
||||
(extractor 即兴变体 key 不静默丢值)。contains 按 KEY_ALIASES 声明序
|
||||
取第一个命中——复合 key(电话/WhatsApp)结果确定。"""
|
||||
out: dict[str, str] = {}
|
||||
for k, v in (raw or {}).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 = 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 复用)。"""
|
||||
return re.sub(r"\D", "", s)
|
||||
|
||||
|
||||
def split_validate(contacts: dict) -> tuple[dict, dict]:
|
||||
"""返回 (confirmed, suspect)。电话/WhatsApp 取数字 7-15 位为合格;微信取非空 ≥4 字符。"""
|
||||
confirmed: dict[str, str] = {}
|
||||
suspect: dict[str, str] = {}
|
||||
for k, v in normalize_contacts(contacts).items():
|
||||
if k == "email":
|
||||
ok = re.fullmatch(r"[^@\s]+@[^@\s]+\.[^@\s]+", v) is not None
|
||||
elif k == "wechat":
|
||||
ok = len(v.strip()) >= 4 # 微信号无数字格式,非空短串即可
|
||||
else:
|
||||
ok = 7 <= len(digits_only(v)) <= 15
|
||||
(confirmed if ok else suspect)[k] = v
|
||||
return confirmed, suspect
|
||||
|
||||
|
||||
EMAIL_RE = re.compile(r"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}")
|
||||
INTL_PHONE_RE = re.compile(r"\+\d{6,15}")
|
||||
# 邻接绑定:上下文词与数字必须在同一匹配内(仅空白衔接)——禁止"消息任意位置
|
||||
# 有上下文词 + 任意位置有 7 位数字"的消息级误绑(S1:车架号/订单号/先令价格)
|
||||
WHATSAPP_CTX_RE = re.compile(
|
||||
r"(?:whats\s*app|wa\.?me|(?:^|\b)wa\s*[::])\D{0,12}(\+?\d[\d\s\-().]{5,17}\d)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
PHONE_CTX_RE = re.compile(
|
||||
r"(?:my\s+(?:(?:new|other|another)\s+)?(?:phone\s+)?number\s+is|"
|
||||
r"my\s+(?:(?:new|other|another)\s+)?phone\s+is|"
|
||||
r"(?:msg|message|text|ping)\s+me\s+(?:at|on)?\s*|"
|
||||
r"call\s+me\s+(?:at|on)|reach\s+me\s+(?:at|on)|"
|
||||
r"phone\s*[::]|number\s*[::]|号码\s*[::]|手机号?\s*[::]|电话\s*[::])\s*"
|
||||
r"(\+?\d[\d\s\-().]{5,17}\d)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
# 仅 scan_user_messages 使用的裸邻接形态: "my number 0712345678"(无 is/冒号)。
|
||||
# ⚠️ 不得进 latest_stated_contacts——裸邻接无自述确定性,授覆盖权会让首轮旧值
|
||||
# 长期压制 extractor 后续改号(实证: test_correction_up_to_three 更正邮件全灭)。
|
||||
# 已知覆盖边界: "my phone 0712345678"(phone 变体,无 is)不在本形态内。
|
||||
PHONE_ADJACENCY_SCAN_RE = re.compile(
|
||||
r"my\s+(?:(?:new|other|another)\s+)?number\s+(\+?\d[\d\s\-().]{5,17}\d)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
# 倒装自述: "0712 345 678 is my whatsapp / 13800138000 我的微信"
|
||||
# ⚠️ 只收显式渠道词(whatsapp/wa/wechat/微信),不收 number/phone/号码——
|
||||
# 自查实证: "2500000 is my number one choice" 这类排位表述会把价格误绑成
|
||||
# 号码,且本正则进入 latest_stated_contacts 即获覆盖权,误捕获会翻新掉
|
||||
# 已存真实号码(正是 S1 系测试在防的事故类)。倒装电话表述罕见,不值得
|
||||
# 为其承担该风险;电话主通路由 PHONE_CTX_RE 前缀形态/standalone/INTL 覆盖。
|
||||
# (?:my|我的)后用 \s* 而非 \s+: 中文"我的微信"连写无空白,\s+ 永不命中(实证)。
|
||||
NUMBER_FIRST_RE = re.compile(
|
||||
r"(\+?\d[\d\s\-().]{5,17}\d)\s*(?:is|这是)?\s*"
|
||||
r"(?:my|我的)\s*(whats\s*app|wa\b|wechat|微信)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
_NUMBER_FIRST_CHANNEL = {
|
||||
"whatsapp": "whatsapp", "whats app": "whatsapp", "wa": "whatsapp",
|
||||
"wechat": "wechat", "微信": "wechat",
|
||||
}
|
||||
|
||||
|
||||
def _number_first_channel(hit: re.Match) -> str:
|
||||
# whats\s*app 可捕到任意空白变体(双空格/NBSP/换行),查表前先归一成
|
||||
# 单空格——否则 KeyError 会沿着 scan 的每消息热路径直接断流整轮对话。
|
||||
return _NUMBER_FIRST_CHANNEL[" ".join(hit.group(2).lower().split())]
|
||||
|
||||
|
||||
# 更正语境(与裸国际号同消息出现时才授予覆盖权)
|
||||
CORRECTION_CTX_RE = re.compile(
|
||||
r"wrong\s+(?:number|phone)|typo|打错|写错|输错|错了|换成了?|改为|"
|
||||
r"use\s+(?:this|new|the\s+new)|instead|correct\s+(?:number|phone|it)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def scan_user_messages(messages: list[dict]) -> dict:
|
||||
"""S4: 扫描全部客户消息,返回高置信捕获(email / +国际号 / whatsapp·wa.me /
|
||||
显式电话上下文邻接数字)。裸数字(价格/年份/里程/车架号)一律不收。"""
|
||||
found: dict[str, str] = {}
|
||||
for msg in messages:
|
||||
if msg.get("role") != "user":
|
||||
continue
|
||||
text = msg.get("content", "")
|
||||
if "email" not in found:
|
||||
hit = EMAIL_RE.search(text)
|
||||
if hit:
|
||||
found["email"] = hit.group(0)
|
||||
if "phone" not in found:
|
||||
sn = standalone_number(text)
|
||||
if sn:
|
||||
found["phone"] = sn
|
||||
if "phone" not in found:
|
||||
hit = INTL_PHONE_RE.search(text)
|
||||
if hit:
|
||||
found["phone"] = hit.group(0)
|
||||
if "whatsapp" not in found:
|
||||
hit = WHATSAPP_CTX_RE.search(text)
|
||||
if hit:
|
||||
found["whatsapp"] = hit.group(1)
|
||||
if "phone" not in found:
|
||||
hit = PHONE_CTX_RE.search(text)
|
||||
if hit:
|
||||
found["phone"] = hit.group(1)
|
||||
if "phone" not in found:
|
||||
hit = PHONE_ADJACENCY_SCAN_RE.search(text)
|
||||
if hit:
|
||||
found["phone"] = hit.group(1)
|
||||
# 倒装属最低置信层: whatsapp/phone 任一已被更高置信源捕获即跳过,
|
||||
# 不与之竞争(跨 key 门控故含 phone)。
|
||||
if "whatsapp" not in found and "phone" not in found:
|
||||
hit = NUMBER_FIRST_RE.search(text)
|
||||
if hit:
|
||||
ch = _number_first_channel(hit)
|
||||
found[ch] = hit.group(1)
|
||||
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"] = hit.group(1)
|
||||
sn = standalone_number(text)
|
||||
if sn:
|
||||
latest["phone"] = sn
|
||||
hit = PHONE_CTX_RE.search(text)
|
||||
if hit:
|
||||
latest["phone"] = hit.group(1)
|
||||
elif CORRECTION_CTX_RE.search(text):
|
||||
hit = INTL_PHONE_RE.search(text)
|
||||
if hit:
|
||||
latest["phone"] = hit.group(0)
|
||||
hit = NUMBER_FIRST_RE.search(text)
|
||||
if hit:
|
||||
ch = _number_first_channel(hit)
|
||||
latest[ch] = 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
|
||||
@@ -0,0 +1,645 @@
|
||||
import logging
|
||||
import re
|
||||
import uuid
|
||||
from collections.abc import AsyncGenerator
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.database import (
|
||||
Conversation, ConversationStatus, ConversationSource,
|
||||
Message, MessageRole, CustomerProfile, Evaluation, IntentLevel,
|
||||
LeadPushLog,
|
||||
)
|
||||
from app.services import email_service, lead_push_service
|
||||
from app.services.ai_service import AIService
|
||||
from app.services.boundary_filter import (
|
||||
NO_CONTACT_ALREADY, NO_CONTACT_APP, NO_CONTACT_WEB, asks_contact,
|
||||
deflection_for, no_contact_value, pivot_for, split_sentences,
|
||||
violates_boundary,
|
||||
)
|
||||
from app.services.contact_pipeline import (
|
||||
canon, latest_stated_contacts, merge_contacts, normalize_contacts,
|
||||
resolve_by_recency, scan_user_messages, split_validate,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 职能边界触发词(Task 13.2, 评审#1/#4 修订): 命中即注入红线提醒。精修说明:
|
||||
# - 裸 "how long"/"多久" 不收(续航类问题常用),英文要求 take/arrive/deliver/ship 邻接,
|
||||
# 中文只收 到货/多久能到/交货 等明确交付语境
|
||||
# - 【评审#1】施压话术本身也触发: 磨价轮("just roughly"/"说个大概")没有价格词,
|
||||
# 却是最易破防的时刻——压力词单列一组
|
||||
# - 【评审#4】v1 补齐漏词: expensive/fee/贵/价钱/总价/落地/划算/免费(短语式)等;
|
||||
# cost/fee 用 \b 边界,costa rica 不再误命中("costa"含cost但无词边界)
|
||||
# - 原则: 误触发低害(多一条提醒),漏触发高危(红线失守),词表偏宽;上线后漏词案例回流补词
|
||||
BOUNDARY_TRIGGER_RE = re.compile(
|
||||
r"price|pricing|how\s+much|\bcost\b|discount|cheaper|expensive|\bfee\b|"
|
||||
r"quote|quotation|shipping\s+(fee|cost|time)|freight|tariff|customs\s+fee|"
|
||||
r"best\s+offer|any\s+offer|free\s+shipping|"
|
||||
r"test\s+drive|book(ing)?\s|reserv|arrange|"
|
||||
r"delivery|lead\s+time|arriv|"
|
||||
r"how\s+long\s+(does|will|would|to).*(take|arriv|deliver|ship)|"
|
||||
r"roughly|ballpark|just\s+tell|come\s+on|at\s+least\s+(a|some|give)|"
|
||||
r"budget|预算|价格|价钱|多少钱|报价|优惠|折扣|便宜|贵|划算|"
|
||||
r"总价|落地|运费|关税|免费|"
|
||||
r"到货|多久能到|交货|时效|发货|试驾|预约|下单|定金|提车|安排|"
|
||||
r"大概|估个|估一|随便说|说个|求你|求了",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
# 预算路径路由(评审#9): 命中 BOUNDARY 触发词且含预算词 → 走预算 playbook
|
||||
BUDGET_TRIGGER_RE = re.compile(r"budget|预算", re.IGNORECASE)
|
||||
|
||||
# 索要我方联系方式触发(15.2, doctrine v2): 客户问"你们怎么联系/你的微信多少"
|
||||
# = 最高意向信号。提醒层按 source 分支: web 指按钮, App 无按钮走反向留资
|
||||
# - 排除第一人称将来时: "I'll call you later" 是日常告别语,不是索要联系方式。
|
||||
# 必须用定宽 negative lookbehind——lookahead 在动词位置求值,永远看不到
|
||||
# 前面的 let me / i'll,不生效。四个 lookbehind 全部定宽合法: i'll(5)、
|
||||
# i’ll(5, 弯撇 U+2019 手机常见)、i will(7)、let me(7);IGNORECASE 兼容大写
|
||||
CONTACT_ASK_TRIGGER_RE = re.compile(
|
||||
r"(your|our)\s+(whatsapp|number|phone|email|contact)|"
|
||||
r"(?<!i'll )(?<!i’ll )(?<!i will )(?<!let me )"
|
||||
r"(contact|reach|call|email)\s+(you|us)|"
|
||||
r"你(们)?的?\s?(whatsapp|号码|电话|微信|邮箱|联系方式)|"
|
||||
r"(怎么|如何)联系|给我.{0,4}(号码|电话|联系方式)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
# 方案B(13.6): 迂回话术轮换序号——连续破防时不至于复读同一句
|
||||
_boundary_seq = 0
|
||||
|
||||
|
||||
def _boundary_rotate() -> int:
|
||||
global _boundary_seq
|
||||
_boundary_seq += 1
|
||||
return _boundary_seq
|
||||
|
||||
|
||||
class ConversationManager:
|
||||
def __init__(self, db: AsyncSession, ai_service: AIService | None = None):
|
||||
self.db = db
|
||||
self.ai = ai_service or AIService()
|
||||
|
||||
async def create_conversation(
|
||||
self,
|
||||
source: ConversationSource = ConversationSource.standalone,
|
||||
client_info: dict | None = None,
|
||||
page_token: str | None = None,
|
||||
context: dict | None = None,
|
||||
) -> Conversation:
|
||||
session_id = uuid.uuid4().hex
|
||||
conv = Conversation(
|
||||
session_id=session_id,
|
||||
source=source,
|
||||
client_info=client_info,
|
||||
page_token=page_token,
|
||||
)
|
||||
self.db.add(conv)
|
||||
await self.db.commit()
|
||||
await self.db.refresh(conv)
|
||||
|
||||
# Conditional whitelist seed of app context into raw_info (red line):
|
||||
# only when `context` is provided, and only vehicle_name/vehicle_id
|
||||
# mapped to app_* keys. WS path passes no context -> raw_info stays {}.
|
||||
raw_info: dict = {}
|
||||
if context:
|
||||
if context.get("vehicle_name"):
|
||||
raw_info["app_vehicle_name"] = context["vehicle_name"]
|
||||
if context.get("vehicle_id"):
|
||||
raw_info["app_vehicle_id"] = context["vehicle_id"]
|
||||
profile = CustomerProfile(conversation_id=conv.id, raw_info=raw_info)
|
||||
self.db.add(profile)
|
||||
await self.db.commit()
|
||||
|
||||
return conv
|
||||
|
||||
async def get_by_session(self, session_id: str) -> Conversation | None:
|
||||
result = await self.db.execute(
|
||||
select(Conversation).where(Conversation.session_id == session_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def get_by_page_token(self, page_token: str) -> Conversation | None:
|
||||
result = await self.db.execute(
|
||||
select(Conversation).where(
|
||||
Conversation.page_token == page_token,
|
||||
Conversation.status == ConversationStatus.active,
|
||||
)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def stream_process_message(
|
||||
self, session_id: str, content: str
|
||||
) -> AsyncGenerator[dict, None]:
|
||||
"""Stream AI reply token by token, then do extract/evaluate."""
|
||||
conv = await self.get_by_session(session_id)
|
||||
if not conv:
|
||||
yield {"type": "error", "message": "session not found"}
|
||||
return
|
||||
|
||||
if conv.status != ConversationStatus.active:
|
||||
yield {"type": "error", "message": "conversation ended"}
|
||||
return
|
||||
|
||||
# Save user message
|
||||
user_msg = Message(
|
||||
conversation_id=conv.id, role=MessageRole.user, content=content
|
||||
)
|
||||
self.db.add(user_msg)
|
||||
await self.db.commit()
|
||||
|
||||
history = await self._load_history(conv.id)
|
||||
|
||||
# Build context reminder (merged into system prompt)
|
||||
profile = await self._get_profile(conv.id)
|
||||
|
||||
# P0 同轮竞态消除: 当前消息即时正则扫描——仅并入本轮内存视图
|
||||
# (提醒/话术路由),不落库;持久化仍由轮末 extractor+scanner 负责,
|
||||
# 邮件触发时序不变。
|
||||
current_scan = scan_user_messages([{"role": "user", "content": content}])
|
||||
effective_raw = dict(profile.raw_info or {})
|
||||
if current_scan:
|
||||
effective_raw["联系方式"] = merge_contacts(
|
||||
current_scan, effective_raw.get("联系方式"))
|
||||
|
||||
context_reminder = self._build_context_reminder(
|
||||
effective_raw, content, history, source=conv.source.value)
|
||||
if CONTACT_ASK_TRIGGER_RE.search(content or ""):
|
||||
logger.info("Contact-ask reminder fired (source=%s)", conv.source.value)
|
||||
|
||||
# Enrich short user messages with context so AI doesn't lose track
|
||||
history_for_ai = list(history)
|
||||
enriched = self._enrich_short_message(content, profile.raw_info or {}, history)
|
||||
if history_for_ai and history_for_ai[-1]["role"] == "user" and enriched != content:
|
||||
history_for_ai[-1] = {**history_for_ai[-1], "content": enriched}
|
||||
logger.info("Enriched short message: %r -> %r", content[:50], enriched[:100])
|
||||
|
||||
# 方案B'(13.7): 句子粒度门控——干净句子逐句流出(恢复打字机),
|
||||
# 违规句永不流出:已流出内容全部干净,违规时接过渡话术收尾并掐断生成。
|
||||
# F6(D6联动): 客户已留联系方式时,收尾话术不得再索要——路由 contact 版
|
||||
has_contact = bool(split_validate(
|
||||
normalize_contacts(effective_raw.get("联系方式")))[0])
|
||||
|
||||
yield {"type": "stream_start"}
|
||||
emitted = ""
|
||||
pending = ""
|
||||
violated = False
|
||||
dropped_ask = False # Task 5b: 本轮是否有索要句被再索要墙丢弃
|
||||
dirty = ""
|
||||
# 15.3: 双墙裁决——价格墙检查在前: 同句双违规时价格路径话术优先
|
||||
# (职能边界话术更具体,且自带留资引导);仅纯号码/邮箱违规时
|
||||
# violation=="no_contact",用零号码墙替换句
|
||||
violation = ""
|
||||
async for token in self.ai.stream_chat(history_for_ai, context=context_reminder):
|
||||
pending += token
|
||||
complete, pending = split_sentences(pending)
|
||||
for sentence in complete:
|
||||
if violates_boundary(sentence):
|
||||
violated = True
|
||||
dirty = sentence
|
||||
violation = "boundary"
|
||||
break
|
||||
if no_contact_value(sentence, history_for_ai):
|
||||
violated = True
|
||||
dirty = sentence
|
||||
violation = "no_contact"
|
||||
break
|
||||
# Task 5b 再索要墙: 已留资时索要句静默丢弃(丢句不掐断,
|
||||
# 其余句子照常流出——软于两墙的掐断+替换)
|
||||
if has_contact and asks_contact(sentence):
|
||||
logger.warning("Re-ask wall dropped sentence (conv=%s): %r",
|
||||
conv.id, sentence[:60])
|
||||
dropped_ask = True
|
||||
continue
|
||||
for i in range(0, len(sentence), 40):
|
||||
yield {"type": "stream_chunk", "content": sentence[i:i + 40]}
|
||||
emitted += sentence
|
||||
if violated:
|
||||
break # 提前掐断生成,省 token
|
||||
if not violated and pending:
|
||||
if violates_boundary(pending):
|
||||
violated = True
|
||||
dirty = pending
|
||||
violation = "boundary"
|
||||
elif no_contact_value(pending, history_for_ai):
|
||||
violated = True
|
||||
dirty = pending
|
||||
violation = "no_contact"
|
||||
elif has_contact and asks_contact(pending):
|
||||
# Task 5b 再索要墙: 尾巴是索要句 → 静默丢弃(不流出不掐断)
|
||||
logger.warning("Re-ask wall dropped tail (conv=%s): %r",
|
||||
conv.id, pending[:60])
|
||||
dropped_ask = True
|
||||
else:
|
||||
for i in range(0, len(pending), 40):
|
||||
yield {"type": "stream_chunk", "content": pending[i:i + 40]}
|
||||
emitted += pending
|
||||
if violated:
|
||||
if violation == "no_contact":
|
||||
# 零号码墙替换句: 已留资→确认话术(不再索要);未留资按 source 分支
|
||||
# (App 无按钮 → 反向留资; web/widget/standalone → 指顶部按钮)
|
||||
logger.warning(
|
||||
"No-contact wall replaced sentence (conv=%s): %r",
|
||||
conv.id, dirty[:60])
|
||||
closing = (NO_CONTACT_ALREADY if has_contact
|
||||
else (NO_CONTACT_APP if conv.source.value == "app"
|
||||
else NO_CONTACT_WEB))
|
||||
else:
|
||||
logger.warning(
|
||||
"Boundary filter intercepted sentence (conv=%s): %r | tail=%r",
|
||||
conv.id, dirty[:80], (pending or "")[:40])
|
||||
closing = (pivot_for(content, has_contact) if emitted
|
||||
else deflection_for(content, _boundary_rotate(),
|
||||
has_contact=has_contact))
|
||||
for i in range(0, len(closing), 40):
|
||||
yield {"type": "stream_chunk", "content": closing[i:i + 40]}
|
||||
emitted += closing
|
||||
# Task 5b 评审修正: 整条回复只有索要句(全被丢)→ 兜底流出"经理主动
|
||||
# 联系"句,绝不空回复。与上方墙收尾互斥——violated 路径已自带收尾,
|
||||
# 此处仅在无任何实义内容流出时接管;已流出的纯空白前缀一并作废,
|
||||
# 落库恒为完整兜底句(客户看到的比一个空格更糟的是: 什么都没有)。
|
||||
if not violated and dropped_ask and not emitted.strip():
|
||||
logger.warning("Re-ask wall dropped entire reply (conv=%s), "
|
||||
"falling back to closing", conv.id)
|
||||
for i in range(0, len(NO_CONTACT_ALREADY), 40):
|
||||
yield {"type": "stream_chunk",
|
||||
"content": NO_CONTACT_ALREADY[i:i + 40]}
|
||||
emitted = NO_CONTACT_ALREADY
|
||||
yield {"type": "stream_end"}
|
||||
ai_reply = emitted # 落库与流出统一为已流出文本(下方持久化)
|
||||
|
||||
# Save assistant message (skip if empty)
|
||||
if ai_reply.strip():
|
||||
assistant_msg = Message(
|
||||
conversation_id=conv.id, role=MessageRole.assistant, content=ai_reply
|
||||
)
|
||||
self.db.add(assistant_msg)
|
||||
await self.db.commit()
|
||||
|
||||
# Round 2: Extract info (background)
|
||||
full_history = history + [{"role": "assistant", "content": ai_reply}]
|
||||
profile = await self._get_profile(conv.id)
|
||||
|
||||
# Task 12b: 轮前联系方式快照(extractor 深合并 + 覆盖 + 同号同步的比对基线)
|
||||
before_contacts = normalize_contacts((profile.raw_info or {}).get("联系方式"))
|
||||
|
||||
new_info = await self.ai.extract_info(
|
||||
full_history, existing_info=profile.raw_info or {}
|
||||
)
|
||||
logger.info("Extractor result: %s", new_info)
|
||||
if new_info:
|
||||
raw = dict(profile.raw_info or {})
|
||||
for k, v in new_info.items():
|
||||
# 联系方式深合并(同key新值覆盖),其余顶层覆盖 —— 堵浅合并抹数据
|
||||
if k == "联系方式":
|
||||
raw[k] = merge_contacts(raw.get(k), v)
|
||||
else:
|
||||
raw[k] = v
|
||||
profile.raw_info = raw
|
||||
self.db.add(profile)
|
||||
|
||||
# Round 2.5: 联系方式正则辅源(S4: 扫全量客户消息历史)
|
||||
# 🔴 合并方向: 扫描器只填空,extractor 已有值优先——否则扫描器的
|
||||
# 首个历史值会压掉 extractor 后续捕获的改号, D4 更正邮件失效
|
||||
scanned = scan_user_messages(full_history)
|
||||
if scanned:
|
||||
raw = dict(profile.raw_info or {})
|
||||
raw["联系方式"] = merge_contacts(scanned, raw.get("联系方式"))
|
||||
profile.raw_info = raw
|
||||
self.db.add(profile)
|
||||
|
||||
# Task 12: 最新显式自述优先 —— 唯一允许文本压过 extractor 回传的位置
|
||||
# (extractor 受"保留已有信息"倾向会滞留旧值,实测 wrong number 场景命中)
|
||||
latest_stated = latest_stated_contacts(full_history)
|
||||
if latest_stated:
|
||||
current = normalize_contacts((profile.raw_info or {}).get("联系方式"))
|
||||
changed = False
|
||||
for k, v in latest_stated.items():
|
||||
if v and canon(str(v)) != canon(str(current.get(k, ""))):
|
||||
current[k] = v
|
||||
changed = True
|
||||
if changed:
|
||||
raw = dict(profile.raw_info or {})
|
||||
raw["联系方式"] = current
|
||||
profile.raw_info = raw
|
||||
self.db.add(profile)
|
||||
|
||||
# Task 12c 最近提及裁决: 镜像组分歧时以客户最晚提及的号码为准
|
||||
# (杀 extractor 历史振荡 + 压制幻觉;Task 12b 同号同步场景被涵盖)
|
||||
after_contacts = normalize_contacts((profile.raw_info or {}).get("联系方式"))
|
||||
resolved = resolve_by_recency(before_contacts, after_contacts, full_history)
|
||||
if resolved != after_contacts:
|
||||
raw = dict(profile.raw_info or {})
|
||||
raw["联系方式"] = resolved
|
||||
profile.raw_info = raw
|
||||
self.db.add(profile)
|
||||
|
||||
# Round 3: Evaluate (仅 ≥3 条客户消息才有意义, 前两轮必然 pending —— D7)
|
||||
user_msg_count = sum(1 for m in full_history if m["role"] == "user")
|
||||
evaluation_ran = user_msg_count >= 3
|
||||
if evaluation_ran:
|
||||
evaluation = await self.ai.evaluate(
|
||||
profile=profile.raw_info or {},
|
||||
recent_messages=full_history,
|
||||
)
|
||||
else:
|
||||
# 客户端 evaluated 事件语义保持 pending;邮件标签由 evaluation_ran 决定(D2)
|
||||
evaluation = {"intent_level": "pending", "reasoning": "",
|
||||
"contact_info": {}, "info_sufficient": False}
|
||||
logger.info("Evaluation (stream): profile=%s | eval=%s",
|
||||
profile.raw_info, evaluation)
|
||||
|
||||
# 邮件触发(D1): 与会话结束彻底解耦,联系方式到手即发
|
||||
pending_pushes: list[LeadPushLog] = []
|
||||
await self._maybe_send_lead_email(
|
||||
conv, profile, evaluation, evaluated=evaluation_ran,
|
||||
pending_pushes=pending_pushes,
|
||||
)
|
||||
|
||||
# 联系方式判定(供 D8 评估落库): 有意向但没联系方式 → 卡单供 admin 人工兜底
|
||||
confirmed_now, _ = split_validate(
|
||||
normalize_contacts((profile.raw_info or {}).get("联系方式")))
|
||||
|
||||
# 评估落库(D8 泛化): 每轮评估后刷新意向;email_sent/correction_count
|
||||
# 永不触碰。有意向无联系方式 = 卡单(admin 可筛);已发过邮件的
|
||||
# contact_info 由触发器更正分支管理,这里不覆盖。
|
||||
if user_msg_count >= 3:
|
||||
ev = await self._get_or_init_evaluation(conv.id)
|
||||
ev.intent_level = IntentLevel(evaluation.get("intent_level", "pending"))
|
||||
ev.reasoning = evaluation.get("reasoning", "")
|
||||
if not confirmed_now:
|
||||
ev.contact_info = {}
|
||||
elif not ev.email_sent:
|
||||
ev.contact_info = confirmed_now
|
||||
self.db.add(ev)
|
||||
|
||||
# 决策A(2026-08-18): 自动结束移除——会话永不因评估结果结束;
|
||||
# evaluated 事件保留但 ended 恒为 False(客户端协议不变)。
|
||||
await self.db.commit()
|
||||
|
||||
# 询盘推送(设计 8.1): 主事务提交后才派生后台任务——payload 内存
|
||||
# 传递,任务不读库只写结果;fire 为同步函数,纳秒级返回不阻塞
|
||||
lead_push_service.fire_pending_pushes(pending_pushes)
|
||||
|
||||
yield {
|
||||
"type": "evaluated",
|
||||
"ended": False,
|
||||
"intent_level": evaluation.get("intent_level", "pending"),
|
||||
}
|
||||
|
||||
async def _get_or_init_evaluation(self, conv_id: str) -> Evaluation:
|
||||
result = await self.db.execute(
|
||||
select(Evaluation).where(Evaluation.conversation_id == conv_id)
|
||||
)
|
||||
ev = result.scalar_one_or_none()
|
||||
if not ev:
|
||||
ev = Evaluation(
|
||||
conversation_id=conv_id,
|
||||
intent_level=IntentLevel.pending,
|
||||
contact_info={},
|
||||
)
|
||||
self.db.add(ev)
|
||||
await self.db.flush()
|
||||
return ev
|
||||
|
||||
async def _recently_emailed_canons(self) -> set[str]:
|
||||
cutoff = datetime.now(timezone.utc) - timedelta(hours=24)
|
||||
# 只取需要的列;🔴 禁止加 LIMIT——完整 24h 窗口才正确
|
||||
result = await self.db.execute(
|
||||
select(Evaluation.contact_info).where(
|
||||
Evaluation.email_sent.is_(True),
|
||||
Evaluation.evaluated_at >= cutoff,
|
||||
)
|
||||
)
|
||||
seen: set[str] = set()
|
||||
for (contact_info,) in result:
|
||||
for v in (contact_info or {}).values():
|
||||
if v:
|
||||
seen.add(canon(str(v)))
|
||||
return seen
|
||||
|
||||
async def _maybe_send_lead_email(
|
||||
self, conv: Conversation, profile: CustomerProfile, evaluation: dict,
|
||||
evaluated: bool = True,
|
||||
pending_pushes: list[LeadPushLog] | None = None,
|
||||
) -> None:
|
||||
"""D1: 联系方式是发邮件的唯一条件。confirmed 才触发;suspect 只进提醒。
|
||||
|
||||
evaluated=False 表示本轮跳过了 GLM 评估(<3条客户消息)——此时邮件
|
||||
intent_level 必须传空串(→[未评估]),不得把客户端事件用的 "pending"
|
||||
透传成 [待确认](D2 两者语义不同)。
|
||||
"""
|
||||
contacts_all = normalize_contacts((profile.raw_info or {}).get("联系方式"))
|
||||
confirmed, _suspect = split_validate(contacts_all)
|
||||
if not confirmed:
|
||||
return
|
||||
ev = await self._get_or_init_evaluation(conv.id)
|
||||
|
||||
if not ev.email_sent:
|
||||
# S3: 任一值已见于24h内其他会话 → 轻提醒;否则全量
|
||||
seen = await self._recently_emailed_canons()
|
||||
if any(canon(str(v)) in seen for v in confirmed.values()):
|
||||
await self._collect_push(
|
||||
pending_pushes, ev, conv, profile, confirmed,
|
||||
"returning", evaluation, evaluated)
|
||||
sent = await email_service.send_returning_lead_email(confirmed)
|
||||
else:
|
||||
await self._collect_push(
|
||||
pending_pushes, ev, conv, profile, confirmed,
|
||||
"lead", evaluation, evaluated)
|
||||
messages = await self._load_history(conv.id)
|
||||
sent = await email_service.send_lead_email(
|
||||
customer_profile=profile.raw_info or {},
|
||||
contact_info=confirmed,
|
||||
reasoning=evaluation.get("reasoning", ""),
|
||||
conversation_messages=messages,
|
||||
intent_level=evaluation.get("intent_level", "") if evaluated else "",
|
||||
)
|
||||
if sent:
|
||||
ev.email_sent = True
|
||||
ev.contact_info = confirmed
|
||||
self.db.add(ev)
|
||||
else:
|
||||
old_canons = {canon(str(v)) for v in (ev.contact_info or {}).values()}
|
||||
if any(canon(str(v)) not in old_canons for v in confirmed.values()):
|
||||
if ev.correction_count < 3:
|
||||
count = ev.correction_count + 1
|
||||
await self._collect_push(
|
||||
pending_pushes, ev, conv, profile, confirmed,
|
||||
"correction", evaluation, evaluated)
|
||||
if await email_service.send_correction_email(confirmed, count=count):
|
||||
ev.correction_count = count
|
||||
ev.contact_info = confirmed
|
||||
self.db.add(ev)
|
||||
else:
|
||||
# D4'(2026-08-18): 锁邮件不锁快照 —— 上限已到,
|
||||
# admin 仍要看到客户最新号码
|
||||
ev.contact_info = confirmed
|
||||
self.db.add(ev)
|
||||
|
||||
async def _collect_push(
|
||||
self, pending, ev, conv, profile, confirmed,
|
||||
push_type, evaluation, evaluated,
|
||||
) -> None:
|
||||
"""询盘推送派发(设计 §8): 触发条件=进入发信分支,与 SMTP 成败解耦。
|
||||
|
||||
dispatch 内部吞异常(失败只记日志),这里无需再兜。
|
||||
"""
|
||||
log = await lead_push_service.dispatch_lead_push(
|
||||
self.db, ev, conv, profile, confirmed, push_type,
|
||||
evaluation, evaluated,
|
||||
)
|
||||
if log is not None and pending is not None:
|
||||
pending.append(log)
|
||||
|
||||
async def _load_history(self, conv_id: str) -> list[dict]:
|
||||
result = await self.db.execute(
|
||||
select(Message)
|
||||
.where(Message.conversation_id == conv_id)
|
||||
.order_by(Message.created_at)
|
||||
)
|
||||
messages = result.scalars().all()
|
||||
return [{"role": m.role.value, "content": m.content} for m in messages]
|
||||
|
||||
async def _get_profile(self, conv_id: str) -> CustomerProfile:
|
||||
result = await self.db.execute(
|
||||
select(CustomerProfile).where(CustomerProfile.conversation_id == conv_id)
|
||||
)
|
||||
return result.scalar_one()
|
||||
|
||||
@staticmethod
|
||||
def _enrich_short_message(content: str, raw_info: dict, history: list[dict] | None = None) -> str:
|
||||
"""Enrich short user messages with conversation context.
|
||||
|
||||
Two sources: extractor output (primary), last substantive user
|
||||
message (fallback when extractor hasn't run yet).
|
||||
"""
|
||||
if len(content.strip()) >= 30:
|
||||
return content
|
||||
|
||||
facts = []
|
||||
|
||||
# Primary: extractor output
|
||||
if raw_info:
|
||||
facts = [f"{k}: {v}" for k, v in raw_info.items()
|
||||
if v and not str(k).startswith("_") and k != "联系方式"]
|
||||
|
||||
# Fallback: use last substantive user message as context
|
||||
if not facts and history:
|
||||
for msg in reversed(history[:-1]):
|
||||
if msg.get("role") == "user" and len(msg.get("content", "").strip()) >= 5:
|
||||
facts.append("客户之前说: " + msg["content"][:80])
|
||||
break
|
||||
|
||||
if not facts:
|
||||
return content
|
||||
|
||||
return (
|
||||
content
|
||||
+ "\n\n【系统上下文 - 系统标注,不要向客户提及】已知: "
|
||||
+ " | ".join(facts)
|
||||
+ "。不要重复询问这些已确认的信息!"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _build_context_reminder(raw_info: dict, latest_msg: str,
|
||||
history: list[dict] | None = None,
|
||||
source: str = "") -> str:
|
||||
"""Build a context reminder from extracted profile info."""
|
||||
# 职能边界提醒(评审#7: 前置到早退之前,首条消息画像为空也要生效)
|
||||
boundary_note = ""
|
||||
if BOUNDARY_TRIGGER_RE.search(latest_msg or ""):
|
||||
if BUDGET_TRIGGER_RE.search(latest_msg or ""):
|
||||
# 评审#9: 预算求推荐 → 预算路径(推荐+反问+引导),不一刀切要号
|
||||
boundary_note = (
|
||||
"⚠️ 职能边界提醒:客户报预算求推荐。推荐车型但绝不复述预算数字、"
|
||||
"不报车价 → 反问意向车型 → 顺势引导留联系方式。")
|
||||
else:
|
||||
boundary_note = (
|
||||
"⚠️ 职能边界提醒:客户正在询问价格/优惠/交付时效/实质安排。绝对禁止"
|
||||
"给出任何价格数字(包括复述客户自己的预算)、交付时间或任何承诺,"
|
||||
"按职能边界对应话术处理。")
|
||||
|
||||
# 联系方式状态前移(供 boundary/contact 提醒共用判定)
|
||||
contacts_all = normalize_contacts(raw_info.get("联系方式"))
|
||||
confirmed, suspect = split_validate(contacts_all)
|
||||
|
||||
# 索要我方联系方式提醒(15.2): 与 boundary_note 同样前移到早退之前,
|
||||
# 空画像首条消息也要生效;按 source 分支(widget/standalone 有按钮, app 无)。
|
||||
# 冒烟实证: 抽象指令("用App话术")GLM 会无视并背 prompt 里更显眼的 web 脚本——
|
||||
# 提醒必须携带该分支的逐字话术,不让模型自己挑。
|
||||
contact_note = ""
|
||||
if CONTACT_ASK_TRIGGER_RE.search(latest_msg or ""):
|
||||
if source == "app":
|
||||
if confirmed or suspect:
|
||||
# 已留资: 索要我方联系方式 → 逐字"经理主动联系"版,
|
||||
# 与"禁止再索要"提醒不再打架
|
||||
contact_note = (
|
||||
"⚠️ 客户正在索要我方联系方式,且客户已留过自己的联系方式。"
|
||||
"请逐字使用这句:\"Our manager will reach out to you at your "
|
||||
"WhatsApp shortly with the full quote!\"(中文同理)。"
|
||||
"绝对禁止输出任何号码或邮箱,也绝对禁止再索要联系方式。")
|
||||
else:
|
||||
contact_note = ("⚠️ 客户正在索要我方联系方式(最高意向信号)。当前为 App 会话,"
|
||||
"没有页面按钮——禁止使用任何 tap/button 按钮话术!请逐字使用这句:"
|
||||
"\"Drop your WhatsApp and our manager will reach out to you directly "
|
||||
"with the full quote!\"(中文同理引导客户留联系方式)。"
|
||||
"绝对禁止输出任何号码或邮箱。")
|
||||
else:
|
||||
contact_note = ("⚠️ 客户正在索要我方联系方式(最高意向信号)。请逐字使用这句指路:"
|
||||
"\"Just tap the WhatsApp button at the top of this chat — "
|
||||
"our manager replies fast!\"(邮箱同理指 Email 按钮)。"
|
||||
"绝对禁止口头报出任何号码或邮箱。")
|
||||
|
||||
if not raw_info:
|
||||
# 空画像:仅返回边界/联系提醒(可能为空串)
|
||||
return "\n".join(x for x in (boundary_note, contact_note) if x)
|
||||
|
||||
parts = ["[上下文提醒 - 这是一条系统指令,绝对不要向客户提及这段内容]"]
|
||||
parts.append("客户已明确告知以下信息,你绝对不能再问这些:")
|
||||
|
||||
for key, val in raw_info.items():
|
||||
if str(key).startswith("_") or key == "联系方式":
|
||||
continue # 内部状态与联系方式单独渲染
|
||||
if val:
|
||||
parts.append(f"{key}: {val}")
|
||||
|
||||
# 联系方式专项(D6) 与 职能边界(D6联动)
|
||||
if confirmed:
|
||||
shown = ", ".join(
|
||||
f"{k}: {' '.join(str(v).split())[:60]}" for k, v in confirmed.items())
|
||||
# 逐字话术直接引用 NO_CONTACT_ALREADY 常量(Task 5 已 import)——
|
||||
# 与零号码墙替换句同源,三处"经理主动联系"话术不漂移
|
||||
parts.append(f"⚠️ 客户已提供联系方式({shown})。绝对禁止再索要任何联系方式!"
|
||||
f"需要回应时可直接逐字使用:\"{NO_CONTACT_ALREADY}\"(中文同理)。"
|
||||
f"确认收到后继续解答客户问题即可,不必急着结束对话。")
|
||||
if boundary_note:
|
||||
parts.append(boundary_note)
|
||||
parts.append("客户已留过联系方式——边界请求直接告知'客户经理会把准确报价/"
|
||||
"安排发到你 WhatsApp'即可,禁止再索要联系方式。")
|
||||
elif suspect:
|
||||
shown = ", ".join(
|
||||
f"{k}: {' '.join(str(v).split())[:60]}" for k, v in suspect.items())
|
||||
parts.append(f"⚠️ 客户疑似已提供联系方式({shown})。禁止重新索要;"
|
||||
f"可礼貌地请客户确认一次。")
|
||||
if boundary_note:
|
||||
parts.append(boundary_note)
|
||||
|
||||
if boundary_note and not confirmed and not suspect:
|
||||
parts.append(boundary_note) # 未留资:边界提醒引导留资(职能边界话术自带)
|
||||
|
||||
# 15.2: 索要我方联系方式提醒注入(镜像 boundary_note 的挂载位)。
|
||||
# 互留提示仅限 web 来源且客户未留资(confirmed/suspect 都算"已留");
|
||||
# App 的反向留资话术本身就是主话术,不再叠加
|
||||
if contact_note:
|
||||
note = contact_note
|
||||
if source != "app" and not confirmed and not suspect:
|
||||
note += "客户尚未留过联系方式,可顺势轻问一句(每会话最多一次)。"
|
||||
parts.append(note)
|
||||
|
||||
if len(latest_msg.strip()) < 25:
|
||||
parts.append("⚠️ 客户发的是短消息!必须结合上面已知信息来理解,绝对不能当作独立问题!")
|
||||
|
||||
return "\n".join(parts)
|
||||
@@ -0,0 +1,129 @@
|
||||
import html
|
||||
import logging
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
from email.mime.text import MIMEText
|
||||
|
||||
import aiosmtplib
|
||||
|
||||
from app.config import settings
|
||||
|
||||
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
|
||||
)
|
||||
|
||||
|
||||
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 = "",
|
||||
) -> bool:
|
||||
tag = TAG_LABELS.get(intent_level, "未评估")
|
||||
contacts = dict(contact_info or {})
|
||||
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 contacts.items() if v
|
||||
]
|
||||
subject = f"[新询盘·{tag}] {_flat(country)}"
|
||||
if contact_parts:
|
||||
subject += " | " + ", ".join(contact_parts)
|
||||
msg["Subject"] = subject
|
||||
msg["From"] = f"【XYH】 <{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) -> bool:
|
||||
"""D5: 24h 内同联系方式再访,只发一行轻提醒。"""
|
||||
contact_parts = [
|
||||
f"{CONTACT_LABELS.get(k, k)}: {_flat(v)}" for k, v in (contact_info or {}).items() if v
|
||||
]
|
||||
msg = MIMEMultipart("alternative")
|
||||
msg["Subject"] = f"[线索回访] {' | '.join(contact_parts) or '已知客户'}再次来访"
|
||||
msg["From"] = f"【XYH】 <{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) -> bool:
|
||||
"""D4: 联系方式变化(改号/新增/无提示换值)后的轻量更正邮件,每会话上限3封。"""
|
||||
msg = MIMEMultipart("alternative")
|
||||
msg["Subject"] = f"[询盘·更正{count}] 客户联系方式已更新(以本封为准)"
|
||||
msg["From"] = f"【XYH】 <{settings.SMTP_USER}>"
|
||||
msg["To"] = settings.NOTIFY_EMAIL
|
||||
msg.attach(MIMEText(
|
||||
f"<html><body><p>这是本会话第 {count} 次更正(上限 3 次),请以本封为准:</p>"
|
||||
f"<table>{_contact_rows(contact_info)}</table>"
|
||||
f"</body></html>", "html", "utf-8"))
|
||||
return await _send(msg)
|
||||
@@ -0,0 +1,28 @@
|
||||
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": "加拿大", "AU": "澳大利亚", "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 "未知"
|
||||
@@ -0,0 +1,298 @@
|
||||
"""询盘推送 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.config import settings
|
||||
from app.main import async_session
|
||||
from app.models.database import (
|
||||
Conversation, CustomerProfile, Evaluation, IntentLevel, LeadPushLog,
|
||||
Message, MessageRole,
|
||||
)
|
||||
from app.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 {}
|
||||
profile_out = dict(raw_info)
|
||||
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"]
|
||||
|
||||
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": confirmed.get("phone"),
|
||||
"whatsapp": confirmed.get("whatsapp"),
|
||||
"email": confirmed.get("email"),
|
||||
"wechat": confirmed.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).
|
||||
|
||||
Mirrors hesu'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