6fddc0eaac
Co-Authored-By: Claude Code <noreply@anthropic.com>
1097 lines
57 KiB
Python
1097 lines
57 KiB
Python
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 (
|
||
cc_completion, email_service, ip_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, digits_only, 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"sample|certificat|\bmoq\b|minimum\s+order|min\s+order|"
|
||
r"container\s+loading|fit\s+in|per\s+container|warranty|质保|保修|"
|
||
r"book(ing)?\s|reserv|arrange|"
|
||
r"delivery|lead\s+time|arriv|"
|
||
r"how\s+long\s+(does|will|would|to).*(take|arriv|deliver|ship)|"
|
||
r"roughly|ballpark|just\s+tell|come\s+on|at\s+least\s+(a|some|give)|"
|
||
r"budget|预算|价格|价钱|多少钱|报价|优惠|折扣|便宜|贵|划算|"
|
||
r"总价|落地|运费|关税|免费|"
|
||
r"到货|多久能到|交货|时效|发货|样品|证书|装柜|起订|"
|
||
r"预约|下单|定金|安排|"
|
||
r"大概|估个|估一|随便说|说个|求你|求了",
|
||
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,
|
||
)
|
||
|
||
# 语言跟随提醒(Task 11 真人验收发现): glm-4-flash 对拉丁字符占比高的中文
|
||
# 消息(DN800/HDPE 规格混排)会整轮漂成英文,system.md 语言规则压不住。修复位 =
|
||
# 当轮 context 提醒(prompt 最末、最新鲜)——与 contact-ask 提醒"逐字话术防
|
||
# 漂移"同款方法论。2026-10-08 Task 5 收尾: 语言规则放开(模板句跟随客户语言
|
||
# 自然表达)后,ES note 的"整句使用英文原版/绝不翻译成西语"与新规则相反,
|
||
# 已同步删除;两条 note 统一补"补全号码逐字照抄"(回显确认轮唯一例外)。
|
||
_CJK_RE = re.compile(r"[一-鿿]")
|
||
_ES_FEATURE_RE = re.compile(r"[áéíóúñãç¿¡]")
|
||
LANG_NOTE_ZH = (
|
||
"⚠️ 语言提醒:客户这条消息是中文——你的回复必须全程使用中文,"
|
||
"一个英文单词都不能出现;模板句跟随客户语言自然表达(系统提醒提供的"
|
||
"逐字话术优先用其语言版本,无则自然翻译保持意思与礼貌度);系统提供的"
|
||
"补全号码逐字照抄,绝不改动数字。"
|
||
)
|
||
LANG_NOTE_ES = (
|
||
"⚠️ 语言提醒:客户这条消息是西语——你的回复全程用西语,模板句跟随客户"
|
||
"语言自然表达(系统提醒提供的逐字话术优先用其语言版本,无则自然翻译"
|
||
"保持意思与礼貌度);系统提供的补全号码逐字照抄,绝不改动数字。"
|
||
)
|
||
|
||
|
||
def _lang_note_for(msg: str) -> str:
|
||
if _CJK_RE.search(msg or ""):
|
||
return LANG_NOTE_ZH
|
||
if _ES_FEATURE_RE.search(msg or ""):
|
||
return LANG_NOTE_ES
|
||
return ""
|
||
|
||
|
||
# Task 3(spec §4.1-2/§4.4, 2026-10-08): 缺失渠道轻补逐字模板——与 system.md
|
||
# 联系方式引导策略节同源。注入的是"缺失渠道"(客户没给过的),与再索要墙的
|
||
# 渠道精确化(Task 1)配套: 墙只拦已在手渠道的索要,这些话术恰好问缺失渠道。
|
||
# CC 强制墙(2026-10-08 增补设计 §2)复用同源正文: _CC_ASK_* 是追问句正文;
|
||
# _CC_WALL_* 是墙的整段替换句(EN 加 "Got it — " 衔接头;ZH 与正文同句)。
|
||
# 2026-10-08 判定树设计 Task 3: cc 轻补注入分支已删(判定树接管),_CC_NOTE_*
|
||
# 不再运行时注入——常量保留作 no_ip 路径/Task 4 回显模板的正文基准
|
||
# (test_boundary_filter / test_system_prompt_dual_contact 仍锚定其正文)。
|
||
_CC_ASK_EN = (
|
||
"Could you send the full number with the country code, "
|
||
"like +966 5x xxx xxxx? Just so our manager can reach you directly.")
|
||
_CC_ASK_ZH = (
|
||
"麻烦重发一下带国家代码的完整号码呗(比如 +966 5x xxx xxxx)?"
|
||
"客户经理直接加你。")
|
||
_CC_WALL_EN = "Got it — " + _CC_ASK_EN
|
||
_CC_WALL_ZH = _CC_ASK_ZH
|
||
_CC_NOTE_EN = (
|
||
"⚠️ 轻补指引(系统指令,绝对不要向客户提及这段说明):客户留下的号码不含"
|
||
"国家代码,客户经理无法直接回拨。请逐字使用这句:\"" + _CC_ASK_EN + "\" "
|
||
"仅此一次,客户不给就不再提。")
|
||
_CC_NOTE_ZH = (
|
||
"⚠️ 轻补指引(系统指令,绝对不要向客户提及这段说明):客户留下的号码不含"
|
||
"国家代码,客户经理无法直接回拨。请逐字使用这句:\"" + _CC_ASK_ZH + "\" "
|
||
"仅此一次,客户不给就不再提。")
|
||
# Task 4(spec §4 回显确认轮/§4b): 判定树给出 e164 建议的等待态轮用回显确认
|
||
# 模板——{intl} 由 Python format_intl 算好填入(reminder/墙替换/测试断言三方
|
||
# 同源引用本常量),GLM 只逐字复述不拼号。no_ip 轮(无可回显号)仍走 _CC_ASK_*
|
||
# (上方保留不拆),两条路径按 _cc.whatsapp 的 e164 在场分流。
|
||
_CC_ECHO_EN = (
|
||
"Got it — just to confirm: our manager will reach you at {intl} — is that right?")
|
||
_CC_ECHO_ZH = (
|
||
"收到——跟您确认下:客户经理会通过 {intl} 联系您,这个号码对吗?")
|
||
_EMAIL_NOTE_EN = (
|
||
"⚠️ 轻补指引(系统指令,绝对不要向客户提及这段说明):客户已留号码但还没"
|
||
"留邮箱。请逐字使用这句:\"Got it! And an email address would help too — "
|
||
"our manager will send the formal quotation PDF there.\" "
|
||
"仅此一次,客户不给就不再提。")
|
||
_EMAIL_NOTE_ZH = (
|
||
"⚠️ 轻补指引(系统指令,绝对不要向客户提及这段说明):客户已留号码但还没"
|
||
"留邮箱。请逐字使用这句:\"收到!邮箱也留一个呗?客户经理把正式报价单 PDF "
|
||
"发你邮箱。\" 仅此一次,客户不给就不再提。")
|
||
|
||
|
||
def _cc_intl(wa_state: dict | None) -> str:
|
||
"""等待态的分组展示号: intl 键缺失时按 e164 防御重算(存量/迁移数据)。"""
|
||
e164 = str((wa_state or {}).get("e164") or "")
|
||
if not e164:
|
||
return ""
|
||
return str((wa_state or {}).get("intl") or cc_completion.format_intl(e164))
|
||
|
||
|
||
def _cc_script(wa_state: dict | None, lang: str) -> str:
|
||
"""CC 等待态本轮逐字话术(Task 4, spec §4): 有 e164 → 回显确认句
|
||
(intl 由 Python 算好填入,GLM 不拼号);no_ip → 追问句(_CC_ASK_* 服务)。
|
||
reminder 注入/墙替换/测试断言三方同源,统一走本函数。"""
|
||
if str((wa_state or {}).get("e164") or ""):
|
||
intl = _cc_intl(wa_state)
|
||
return (_CC_ECHO_ZH if lang == "zh" else _CC_ECHO_EN).format(intl=intl)
|
||
return _CC_ASK_ZH if lang == "zh" else _CC_ASK_EN
|
||
|
||
|
||
def _cc_wall_template(wa_state: dict | None, lang: str) -> str:
|
||
"""CC 触发轮墙整段替换句: 回显轮 = ECHO 模板(自带 "Got it —" 衔接头);
|
||
no_ip 轮 = _CC_WALL_*(ASK 追问句 + 衔接头,沿用 Task 3 形态)。"""
|
||
if str((wa_state or {}).get("e164") or ""):
|
||
return _cc_script(wa_state, lang)
|
||
return _CC_WALL_ZH if lang == "zh" else _CC_WALL_EN
|
||
|
||
|
||
def _cc_confirm_note(wa_state: dict | None, lang: str) -> str:
|
||
"""cc 等待态确认句注入(Task 4, spec §4/§4b): cc_due 才注入,与邮箱轻补
|
||
cc_settled 串行互斥(两个"逐字使用这句"不同轮竞争)。外壳仿轻补指引:
|
||
⚠️ 系统指令声明 + 逐字话术 + 客户答复后致谢不追问。回显分支带零号码
|
||
唯一例外(4b-3 让位语义重建: 仅系统提供的补全号原文可写出)。"""
|
||
if not wa_state or not cc_completion.cc_due(wa_state):
|
||
return ""
|
||
if str(wa_state.get("e164") or ""):
|
||
intl = _cc_intl(wa_state)
|
||
return (
|
||
"⚠️ 号码确认指引(系统指令,绝对不要向客户提及这段说明):客户留下"
|
||
"的号码不含国家代码,系统已按 IP 推测补全为 " + intl +
|
||
"。请逐字使用这句:\"" + _cc_script(wa_state, lang) + "\" "
|
||
"本轮回复绝不写出任何号码数字——唯一例外是上面系统提供的补全号"
|
||
"原文(逐字照抄,禁止自编或改动数字)。客户答复确认或纠正后,"
|
||
"致谢即可,不再追问号码。")
|
||
return (
|
||
"⚠️ 号码确认指引(系统指令,绝对不要向客户提及这段说明):客户留下"
|
||
"的号码不含国家代码,且系统无法按 IP 推测补全。请逐字使用这句:\""
|
||
+ _cc_script(wa_state, lang) + "\" 仅此一次,客户不给就不再提。"
|
||
"客户答复后致谢即可,不再追问号码。")
|
||
|
||
|
||
def _contact_completion_note(
|
||
raw_info: dict | None, confirmed: dict | None, suspect: dict | None,
|
||
lang: str = "en",
|
||
) -> tuple[str, list[str]]:
|
||
"""邮箱轻补提醒(spec §4.2/§4.3b,2026-10-08 判定树设计收编)。
|
||
纯函数,返回(注入文案,计数键)。
|
||
|
||
cc 追问分支已删除——号码区号补全由轮前判定树钩子接管(_cc 状态机,
|
||
_cc_pre_turn),确认提醒由 cc_due 驱动(Task 4 接)。本函数只管邮箱轻补,
|
||
且串行解锁条件改为 cc_settled(_cc.whatsapp): 确认流未落定时注入邮箱
|
||
轻补会让两个"逐字使用这句"同轮竞争(旧 §4.3b 场景①教训);落定即解锁。
|
||
带 + 号客户无 _cc 状态 → cc_settled(None)=True,旧轻补行为不回归。
|
||
suspect 仅为与调用处画像判定同签名,规则只看 confirmed(spec §4.2)。
|
||
"""
|
||
info = raw_info or {}
|
||
numbers = [str(v).strip() for k, v in (confirmed or {}).items()
|
||
if k in ("whatsapp", "phone") and v]
|
||
if not numbers:
|
||
return "", []
|
||
if "email" in (confirmed or {}):
|
||
return "", []
|
||
if info.get("_email_asked"):
|
||
return "", []
|
||
if not cc_completion.cc_settled((info.get("_cc") or {}).get("whatsapp")):
|
||
return "", [] # whatsapp 确认流未落定: 邮箱轻补串行等待
|
||
return (_EMAIL_NOTE_ZH if lang == "zh" else _EMAIL_NOTE_EN), ["_email_asked"]
|
||
|
||
|
||
def _cc_pre_turn(raw_info: dict, confirmed: dict, ip_iso: str | None) -> bool:
|
||
"""轮前判定树钩子(spec §6-1/§3/§4): 对 confirmed 号码建/重置/迁移 _cc。
|
||
|
||
- 多渠道策略: 仅 whatsapp 组走确认流;phone 与 whatsapp 同 canon 时共享
|
||
whatsapp 组状态(不建独立键),异号时 phone 组只记静默补全态
|
||
(ip_proposed/naive/no_ip,不进确认流)——确认流的消费与门控
|
||
(cc_due/cc_settled/轮末 transit)一律只看 _cc.whatsapp,phone 态
|
||
仅供出站渲染(Task 6)。
|
||
- canon 变化即重置重走判定树(旧补全永不跟随新号);同 canon 保留原状态
|
||
(attempts 只被 canon 变化重置,spec §4)。
|
||
- 显式 +/00 号码不建状态(无态=天然落定);prefix_normalized 静默规范化
|
||
态(confirmed=true)照写,供出站渲染无标注输出。
|
||
- 存量迁移(spec §4 尾): 旧 _cc_asked=True 且 whatsapp 裸号尚无 _cc 组 →
|
||
一次性落 fallback 态(已问过不再追问,直通邮箱轻补);判定树算出的
|
||
e164 建议保留(首封可拨)。_cc 组一旦存在即不再触发。
|
||
就地更新 raw_info["_cc"],返回是否有变更(调用方与计数写回同事务落库)。
|
||
"""
|
||
before = raw_info.get("_cc")
|
||
cc = dict(before or {})
|
||
migrate = bool(raw_info.get("_cc_asked"))
|
||
wa = str(confirmed.get("whatsapp") or "").strip()
|
||
ph = str(confirmed.get("phone") or "").strip()
|
||
|
||
def _refresh(channel: str, value: str) -> None:
|
||
existing = cc.get(channel)
|
||
if existing is not None \
|
||
and str(existing.get("canon") or "") == digits_only(value):
|
||
return # 同 canon: 保留原状态(attempts 不重置)
|
||
kind, state = cc_completion.classify(value, ip_iso)
|
||
if state is None:
|
||
cc.pop(channel, None) # 显式 +/00(或防御性垃圾输入): 无态即落定
|
||
return
|
||
st = dict(state)
|
||
if (migrate and channel == "whatsapp" and existing is None
|
||
and kind == cc_completion.NEED_CC):
|
||
st["confirmed"] = False
|
||
st["source"] = "fallback" # 存量迁移: 已问过,不再追问
|
||
st.pop("suggest_ask_full", None)
|
||
cc[channel] = st
|
||
|
||
if wa:
|
||
_refresh("whatsapp", wa)
|
||
else:
|
||
cc.pop("whatsapp", None) # 号码不再在手: 状态清,防残留跟随
|
||
if ph:
|
||
wa_state = cc.get("whatsapp")
|
||
if wa_state is not None \
|
||
and str(wa_state.get("canon") or "") == digits_only(ph):
|
||
cc.pop("phone", None) # 同 canon 组归并: phone 共享 whatsapp 状态
|
||
else:
|
||
_refresh("phone", ph)
|
||
else:
|
||
cc.pop("phone", None)
|
||
|
||
if cc:
|
||
raw_info["_cc"] = cc
|
||
else:
|
||
raw_info.pop("_cc", None)
|
||
return raw_info.get("_cc") != before
|
||
|
||
|
||
# 方案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 product_name/product_id
|
||
# mapped to app_* keys. WS path passes no context -> raw_info stays {}.
|
||
raw_info: dict = {}
|
||
if context:
|
||
if context.get("product_name"):
|
||
raw_info["app_product_name"] = context["product_name"]
|
||
if context.get("product_id"):
|
||
raw_info["app_product_id"] = context["product_id"]
|
||
profile = CustomerProfile(conversation_id=conv.id, raw_info=raw_info)
|
||
self.db.add(profile)
|
||
await self.db.commit()
|
||
|
||
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 同轮竞态消除: 当前消息即时正则扫描——仅并入本轮内存视图
|
||
# (提醒/话术路由/轮前 cc 判定树),不落库;持久化仍由轮末
|
||
# extractor+scanner 负责,邮件触发时序不变。
|
||
# 合并方向(Task 3 修正): 当前消息捕获覆盖存量——客户当轮改号时,
|
||
# 同轮视图必须看到新号(轮前判定树 canon 变化即重置依赖此方向;
|
||
# 与轮末 Task 12 "最新显式自述优先"同语义)。存量只在当前消息
|
||
# 未提及的渠道上补位。
|
||
current_scan = scan_user_messages([{"role": "user", "content": content}])
|
||
effective_raw = dict(profile.raw_info or {})
|
||
if current_scan:
|
||
effective_raw["联系方式"] = merge_contacts(
|
||
effective_raw.get("联系方式"), current_scan)
|
||
|
||
# 联系方式视图(轮前钩子/计数写回/再索要墙区共用;原在 reminder 之后
|
||
# 计算,前移只为喂轮前钩子,输入相同取值不变)
|
||
_contacts_now = normalize_contacts(effective_raw.get("联系方式"))
|
||
_confirmed_now, _suspect_now = split_validate(_contacts_now)
|
||
|
||
# Task 3 轮前判定树钩子(spec §6-1;评审 Step 3b 钉死顺序: 必须在
|
||
# _build_context_reminder 之前)——即时扫描并入裸号后同步跑判定树
|
||
# (纯计算毫秒级)建/重置/迁移 _cc,当轮即可被墙门控/轮末消费/出站
|
||
# 渲染读到;落库与下方一次性计数写回同事务。
|
||
_ip_iso = ip_service.resolve_region_code(
|
||
(conv.client_info or {}).get("ip"))
|
||
_cc_changed = _cc_pre_turn(effective_raw, _confirmed_now, _ip_iso)
|
||
_wa_cc = (effective_raw.get("_cc") or {}).get("whatsapp")
|
||
|
||
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)
|
||
|
||
# Task 3(spec §4.3/§6-1): 一次性计数写回 + _cc 状态同事务落库——
|
||
# _build_context_reminder 是 staticmethod 收内存副本,计数键/状态须由
|
||
# 调用方在流式开始前显式落库;轮末 extractor 合并起点是
|
||
# dict(profile.raw_info or {}),提前提交可存活。与 reminder 内部同参
|
||
# 调用(上方纯函数),保证写回与注入永不漂移;条件扩为"计数或 _cc
|
||
# 变更任一存在"(评审 Step 3b)。
|
||
_, completion_marks = _contact_completion_note(
|
||
effective_raw, _confirmed_now, _suspect_now,
|
||
"zh" if _CJK_RE.search(content or "") else "en")
|
||
if completion_marks or _cc_changed:
|
||
raw = dict(profile.raw_info or {})
|
||
for key in completion_marks:
|
||
raw[key] = True
|
||
if _cc_changed:
|
||
if effective_raw.get("_cc"):
|
||
raw["_cc"] = effective_raw["_cc"]
|
||
else:
|
||
raw.pop("_cc", None)
|
||
profile.raw_info = raw
|
||
self.db.add(profile)
|
||
await self.db.commit()
|
||
# warning 级(2026-10-08 第四轮验收): 生产曾现 2/5 裸号轮墙未介入的
|
||
# 非确定性异常(同输入容器内链路复现正常)——本行生产可见,异常会话
|
||
# 可立刻分辨"计数/状态/注入是否发生",供观察期定位
|
||
logger.warning("Contact completion state persisted (conv=%s): "
|
||
"marks=%s cc_changed=%s",
|
||
conv.id, completion_marks, _cc_changed)
|
||
|
||
# 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 版
|
||
# 渠道精确化(2026-10-08): confirmed 渠道集合传给再索要墙——
|
||
# 索要缺失渠道(如已有 phone 问邮箱)放行,是 Task 3 邮箱轻补的前提
|
||
# (confirmed/suspect 已在 Task 3 写回处算好,此处复用)
|
||
has_contact = bool(_confirmed_now)
|
||
_confirmed_channels = set(_confirmed_now)
|
||
# CC 强制墙(2026-10-08 cc 设计): 门控改线判定树——cc_due(_cc.whatsapp)
|
||
# 等待态即触发(旧 `completion_marks == ["_cc_asked"]` 门控随 cc 轻补
|
||
# 分支删除而消亡,_cc_asked 停写)。流式无法撤回 → 触发轮不逐句流出,
|
||
# 整段缓冲到流末统一裁决(缓冲期间价格/零号码/再索要三墙照常逐句
|
||
# 检查,优先级不变)。Task 4(spec §4/§4b): 回显轮(等待态有 e164)裁决
|
||
# 锚 = 注入 e164 的 digits 等值在场,替换句 = ECHO 模板;no_ip 轮
|
||
# (无可回显号)沿用 ASK 模板与 "country code" 问句短语锚。
|
||
cc_wall = cc_completion.cc_due(_wa_cc)
|
||
# Task 4 白名单传参(spec §4): 零号码墙逐段放行系统注入的补全号——
|
||
# 仅回显轮(等待态有 e164)传,其余轮 None 保持原行为(全拦)
|
||
cc_allow_number = ((_wa_cc or {}).get("e164") if cc_wall else None)
|
||
|
||
yield {"type": "stream_start"}
|
||
emitted = ""
|
||
pending = ""
|
||
violated = False
|
||
dropped_ask = False # Task 5b: 本轮是否有索要句被再索要墙丢弃
|
||
dirty = ""
|
||
cc_buffer: list[str] = [] # CC 触发轮: 已过墙句子的延迟流出缓冲
|
||
# 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, allow_number=cc_allow_number):
|
||
violated = True
|
||
dirty = sentence
|
||
violation = "no_contact"
|
||
break
|
||
# Task 5b 再索要墙: 已留资时索要句静默丢弃(丢句不掐断,
|
||
# 其余句子照常流出——软于两墙的掐断+替换);
|
||
# 渠道精确化: 只丢索要已在手渠道的句子
|
||
if has_contact and asks_contact(sentence, _confirmed_channels):
|
||
logger.warning("Re-ask wall dropped sentence (conv=%s): %r",
|
||
conv.id, sentence[:60])
|
||
dropped_ask = True
|
||
continue
|
||
if cc_wall:
|
||
cc_buffer.append(sentence) # 墙照检,流出延后到流末
|
||
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, allow_number=cc_allow_number):
|
||
violated = True
|
||
dirty = pending
|
||
violation = "no_contact"
|
||
elif has_contact and asks_contact(pending, _confirmed_channels):
|
||
# Task 5b 再索要墙: 尾巴是索要句 → 静默丢弃(不流出不掐断)
|
||
logger.warning("Re-ask wall dropped tail (conv=%s): %r",
|
||
conv.id, pending[:60])
|
||
dropped_ask = True
|
||
elif cc_wall:
|
||
cc_buffer.append(pending)
|
||
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])
|
||
if cc_wall:
|
||
# Important-2(审查修复) + Task 4: CC 触发轮回显被拦 →
|
||
# closing 改用本轮 CC 模板(回显轮 ECHO/no_ip 轮 ASK)而非
|
||
# NO_CONTACT_ALREADY——GLM 有 3/3 回显倾向,该路径概率高,
|
||
# 客户该收到确认/追问而非"稍后联系"虚假承诺;
|
||
# 价格墙路径(violation=="boundary")不变
|
||
closing = _cc_wall_template(
|
||
_wa_cc, "zh" if _CJK_RE.search(content or "") else "en")
|
||
else:
|
||
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))
|
||
# Minor-5(审查修复): 已流出前缀与替换句之间补一个空格分隔
|
||
# ("...xxxx? Got it —"——英文衔接无空格会粘连);CC/触发轮缓冲
|
||
# 未流出,emitted 恒空,天然不受影响。空格随 closing 一起流出,
|
||
# 客户所见与落库保持一致
|
||
if emitted and not emitted.endswith((" ", "\n")):
|
||
closing = " " + closing
|
||
for i in range(0, len(closing), 40):
|
||
yield {"type": "stream_chunk", "content": closing[i:i + 40]}
|
||
emitted += closing
|
||
# CC 强制墙裁决(2026-10-08 cc 设计;Task 4 改造 spec §4b-4): 触发轮
|
||
# 缓冲全文在此判定——回显轮(等待态有 e164)裁决锚 = **注入 e164 的
|
||
# digits 等值在场**(GLM 任意分组形态 "+966 50 123 4567"/"+966501234567"
|
||
# 均判在场;辅锚 "is that right"/"对吗" 随模板正文,不作独立判据——
|
||
# 号码在场才是回显的承重语义);no_ip 轮无可回显号,沿用问句短语锚
|
||
# ("country code"/"国家代码",与 _CC_ASK_* 模板句同词)。在场 → GLM
|
||
# 自发服从,缓冲原样流出零干预;不在场 → 整段替换为对应 ECHO/ASK
|
||
# 模板。violated(价格/零号码墙)优先于本墙——沿用双墙裁决"更具体
|
||
# 话术优先"惯例;_cc 状态已在流前落库,不受替换影响。缓冲轮全部
|
||
# 实义句被再索要墙丢弃时 cc_buffer 为空 → 同样走替换(模板句即
|
||
# 本轮唯一实义输出,天然不触发下方兜底)。
|
||
if cc_wall and not violated:
|
||
cc_full = "".join(cc_buffer)
|
||
cc_lang = "zh" if _CJK_RE.search(content or "") else "en"
|
||
wa_e164 = str((_wa_cc or {}).get("e164") or "")
|
||
if wa_e164:
|
||
obeyed = digits_only(wa_e164) in digits_only(cc_full)
|
||
else:
|
||
obeyed = ("country code" in cc_full.lower()
|
||
or "国家代码" in cc_full)
|
||
if obeyed:
|
||
for i in range(0, len(cc_full), 40):
|
||
yield {"type": "stream_chunk", "content": cc_full[i:i + 40]}
|
||
emitted = cc_full
|
||
else:
|
||
logger.warning(
|
||
"CC enforcement wall replaced reply (conv=%s): %r",
|
||
conv.id, cc_full[:60])
|
||
cc_template = _cc_wall_template(_wa_cc, cc_lang)
|
||
for i in range(0, len(cc_template), 40):
|
||
yield {"type": "stream_chunk",
|
||
"content": cc_template[i:i + 40]}
|
||
emitted = cc_template
|
||
# 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("联系方式"))
|
||
|
||
# _ 前缀内部键(_cc 状态机/_email_asked 计数)不进 LLM 输入(Task 3 审查
|
||
# Minor 3 防御纵深——渲染/推送侧已过滤,此处防提示词侧意外回显)
|
||
new_info = await self.ai.extract_info(
|
||
full_history,
|
||
existing_info={k: v for k, v in (profile.raw_info or {}).items()
|
||
if not str(k).startswith("_")},
|
||
)
|
||
logger.info("Extractor result: %s", new_info)
|
||
# 出口卫生(spec §4 三封堵之②/③): cc_reply 是 extractor 的单轮瞬态
|
||
# 分类,提取后即剥离——绝不进 raw_info 持久层(消费点在轮末钩子
|
||
# _cc_round_end);同时防御性丢弃 extractor 回传的任何 `_` 前缀键,
|
||
# 防 LLM 回显 _cc 覆盖 Python 管理的状态机。(入口侧封堵①: 上方
|
||
# existing_info 已滤 `_`,复核通过)
|
||
cc_reply = None
|
||
if isinstance(new_info, dict):
|
||
cc_reply = new_info.pop("cc_reply", None)
|
||
stripped = {k: v for k, v in new_info.items()
|
||
if not str(k).startswith("_")}
|
||
if len(stripped) != len(new_info):
|
||
logger.warning(
|
||
"Dropped underscore keys from extractor output (conv=%s): %s",
|
||
conv.id, [k for k in new_info if str(k).startswith("_")])
|
||
new_info = stripped
|
||
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)
|
||
|
||
# Task 3 轮末 cc 钩子(spec §6-2): 联系方式四层合并完成后、D1 邮件
|
||
# 触发前——消费 cc_reply 推进状态机,并为本轮才入管道的号码补建 _cc
|
||
# (spec §5: 首封补全号/推送派生依赖已建好的 _cc)。
|
||
await self._cc_round_end(conv, profile, cc_reply)
|
||
|
||
# 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={k: v for k, v in (profile.raw_info or {}).items()
|
||
if not str(k).startswith("_")},
|
||
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 _cc_round_end(
|
||
self, conv: Conversation, profile: CustomerProfile,
|
||
cc_reply: dict | None,
|
||
) -> None:
|
||
"""轮末 cc 钩子(spec §6-2): 联系方式四层合并后、D1 邮件前调用。
|
||
|
||
- cc_reply 消费(双门控 Python 侧): 仅 whatsapp 组等待态(cc_due)消费
|
||
——无回应/漏填也推进(attempts 随轮递增,达上限落定 fallback);
|
||
落定态仅开迟到 country 升级口: 客户主动报国(非空 country)且
|
||
source 非 no_ip 耗尽 → transit 重算 e164(客户主动给的信息不
|
||
丢弃);迟到 affirm 不升级(对已标"未经确认"旧号的确认语义模糊,
|
||
confirmed 态更不重算——客户改主意请重发完整号,走 canon 重置)。
|
||
- canon 变化(换号/号消失)→ RESET 删组状态。
|
||
- 补建 pass: 对最终联系方式重跑判定树(_cc_pre_turn)——本轮才经
|
||
extractor 入管道的裸号/换号后的新号当场建状态,不下轮补课。
|
||
"""
|
||
original_cc = (profile.raw_info or {}).get("_cc")
|
||
ip_iso = ip_service.resolve_region_code(
|
||
(conv.client_info or {}).get("ip"))
|
||
raw = dict(profile.raw_info or {})
|
||
confirmed_end, _ = split_validate(normalize_contacts(raw.get("联系方式")))
|
||
wa_canon_end = digits_only(str(confirmed_end.get("whatsapp") or ""))
|
||
|
||
cc_map = dict(raw.get("_cc") or {})
|
||
state = cc_map.get("whatsapp")
|
||
|
||
reply = None
|
||
if isinstance(cc_reply, dict):
|
||
country = str(cc_reply.get("country") or "").strip()
|
||
if cc_reply.get("affirm") is not None or country:
|
||
if cc_completion.cc_due(state):
|
||
reply = cc_reply
|
||
elif (state is not None and not state.get("confirmed")
|
||
and country and state.get("source") != "no_ip"):
|
||
reply = cc_reply # 迟到 country 升级口
|
||
|
||
if state is not None:
|
||
if not wa_canon_end or wa_canon_end != str(state.get("canon") or ""):
|
||
cc_map.pop("whatsapp", None) # 换号/号消失 → RESET
|
||
elif reply is not None or cc_completion.cc_due(state):
|
||
cc_map["whatsapp"] = cc_completion.transit(state, reply)
|
||
if cc_map:
|
||
raw["_cc"] = cc_map
|
||
else:
|
||
raw.pop("_cc", None)
|
||
_cc_pre_turn(raw, confirmed_end, ip_iso)
|
||
if raw.get("_cc") != original_cc:
|
||
profile.raw_info = raw
|
||
self.db.add(profile)
|
||
|
||
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:
|
||
# 已留资: 索要我方联系方式 → 逐字"经理主动联系"版,
|
||
# 与"禁止再索要"提醒不再打架
|
||
# Task 3 收尾: 第三处禁令同步改写(禁令集完整)——与前两处
|
||
# 同语义,只禁已提供过的渠道,缺失渠道放行轻补一次
|
||
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 = ("⚠️ 客户正在索要我方联系方式(最高意向信号)。请逐字使用这句指路:"
|
||
f"\"{NO_CONTACT_WEB}\"(邮箱同理指 Email 按钮)。"
|
||
"绝对禁止口头报出任何号码或邮箱。")
|
||
|
||
# 语言跟随提醒(Task 11): 与 boundary/contact 提醒同挂载位——
|
||
# 首条消息画像为空也要生效(中文客户开场即漂英文是实测重灾区)
|
||
lang_note = _lang_note_for(latest_msg)
|
||
|
||
if not raw_info:
|
||
# 空画像:仅返回边界/联系/语言提醒(可能为空串)
|
||
return "\n".join(x for x in (boundary_note, contact_note, lang_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}")
|
||
|
||
# Task 3(spec §4.2/§4.3b): 邮箱轻补注入(cc 分支已删,判定树接管号码
|
||
# 区号补全——确认提醒由 cc_due 驱动,Task 4 接)。计数键(_marks)此处
|
||
# 弃用: 本函数是 staticmethod 收内存副本,写回由 stream 调用方以同参
|
||
# 调用纯函数取回并落库(纯函数保证两处判定不漂移)。计算前移到
|
||
# confirmed 块之前: 未落定轮邮箱轻补不注入(cc_settled 串行)。
|
||
completion_note, _marks = _contact_completion_note(
|
||
raw_info, confirmed, suspect,
|
||
lang="zh" if _CJK_RE.search(latest_msg or "") else "en")
|
||
|
||
# Task 4(spec §4 回显确认轮): cc 等待态注入确认句指引——有 e164 用
|
||
# 回显模板(逐字 intl),no_ip 用 ASK 追问句;与邮箱轻补 cc_settled
|
||
# 串行互斥,两个"逐字使用这句"永不同轮。无计数键: 轮次上限由状态机
|
||
# attempts 管理(轮末 transit 递增),提醒每轮等待态都在场。
|
||
cc_note = _cc_confirm_note(
|
||
(raw_info.get("_cc") or {}).get("whatsapp"),
|
||
"zh" if _CJK_RE.search(latest_msg or "") else "en")
|
||
|
||
# 联系方式专项(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)——
|
||
# 与零号码墙替换句同源,三处"经理主动联系"话术不漂移
|
||
# Task 3 防自锁改写(spec 4.1-6): 原"绝对禁止再索要任何联系方式!"
|
||
# 会抑制缺失渠道轻补——只禁已提供过的渠道,缺失渠道放行一次
|
||
parts.append(f"⚠️ 客户已提供联系方式({shown})。绝对禁止再索要客户已提供过"
|
||
f"的联系方式;缺失渠道仅可按轻补指引提一次;号码缺国家代码时"
|
||
f"按系统提醒回显补全后的完整号码请客户确认(仅系统提供的"
|
||
f"号码,禁止自编)。")
|
||
parts.append(f"需要回应时可直接逐字使用:\"{NO_CONTACT_ALREADY}\"(中文同理)。"
|
||
f"确认收到后继续解答客户问题即可,不必急着结束对话。")
|
||
if boundary_note:
|
||
parts.append(boundary_note)
|
||
# Task 3 防自锁改写: 尾部补缺失渠道豁免(与上方改写同因)
|
||
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) # 未留资:边界提醒引导留资(职能边界话术自带)
|
||
|
||
# Task 4 cc 确认指引注入(与下方邮箱轻补互斥——cc_due vs cc_settled)
|
||
if cc_note:
|
||
parts.append(cc_note)
|
||
|
||
# Task 3 轻补注入(计算已前移到 confirmed 块之前;cc_settled 未落定时
|
||
# note 为空,天然不注入)
|
||
if completion_note:
|
||
parts.append(completion_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("⚠️ 客户发的是短消息!必须结合上面已知信息来理解,绝对不能当作独立问题!")
|
||
|
||
if lang_note:
|
||
parts.append(lang_note)
|
||
|
||
return "\n".join(parts)
|