Files
ai-kefu-jc/app/services/conversation.py
T

856 lines
44 KiB
Python
Raw Normal View History

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"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,
)
2026-09-29 20:02:03 +08:00
# 语言跟随提醒(Task 11 真人验收发现): glm-4-flash 对拉丁字符占比高的中文
# 消息(DN800/HDPE 规格混排)会整轮漂成英文,system.md 语言规则压不住;西语客户
# 则倾向把英文模板句翻译成西语。修复位 = 当轮 context 提醒(prompt 最末、
# 最新鲜)——与 contact-ask 提醒"逐字话术防漂移"同款方法论。
_CJK_RE = re.compile(r"[一-鿿]")
_ES_FEATURE_RE = re.compile(r"[áéíóúñãç¿¡]")
LANG_NOTE_ZH = (
"⚠️ 语言提醒:客户这条消息是中文——你的回复必须全程使用中文,"
"除英文模板句保持英文原文外,一个英文单词都不能出现。"
)
LANG_NOTE_ES = (
"⚠️ 语言提醒:客户这条消息是西语——叙述部分用西语,但引导留资模板句必须"
"整句使用英文原版(\"Please leave your contact and our manager will send "
"the full quotation.\"),绝不翻译成西语。"
)
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 — "
# 衔接头,设计 §2 定稿文案;ZH 与正文同句)——两处逐字锚点不漂移。
_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 + "\" "
"仅此一次,客户不给就不再提。")
_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 _contact_completion_note(
raw_info: dict | None, confirmed: dict | None, suspect: dict | None,
lang: str = "en",
) -> tuple[str, list[str]]:
"""一次性缺失渠道轻补提醒(spec §4.2/§4.3/§4.3b)。纯函数,返回(注入文案,计数键)。
串行优先级(§4.3b——两提醒同轮并发时一次只注入一个,顺序固定):
1. cc 优先: confirmed 号码渠道(whatsapp/phone)均有值且均不以 +/00 开头、
_cc_asked 未标记 → 国家代码追问(引导重发完整号,走改号覆盖机制,
管道零改动)
2. email 轻补: (任一号码已带前缀 或 _cc_asked 已消耗)且 confirmed 含号码
渠道、不含 email、_email_asked 未标记 → 邮箱轻补
3. 其余(双渠道齐/仅 email/两计数均耗尽/confirmed 无号码渠道) → ("", [])
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 "", []
all_bare = all(not v.startswith(("+", "00")) for v in numbers)
if all_bare and not info.get("_cc_asked"):
return (_CC_NOTE_ZH if lang == "zh" else _CC_NOTE_EN), ["_cc_asked"]
if (not all_bare or info.get("_cc_asked")) \
and "email" not in (confirmed or {}) \
and not info.get("_email_asked"):
return (_EMAIL_NOTE_ZH if lang == "zh" else _EMAIL_NOTE_EN), ["_email_asked"]
return "", []
# 方案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 同轮竞态消除: 当前消息即时正则扫描——仅并入本轮内存视图
# (提醒/话术路由),不落库;持久化仍由轮末 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)
# Task 3(spec §4.3): 一次性计数写回——_build_context_reminder 是
# staticmethod 收内存副本,计数键须由调用方在流式开始前显式落库;
# 轮末 extractor 合并起点是 dict(profile.raw_info or {}),提前提交可存活。
# 与 reminder 内部同参调用(上方纯函数),保证写回与注入永不漂移。
_contacts_now = normalize_contacts(effective_raw.get("联系方式"))
_confirmed_now, _suspect_now = split_validate(_contacts_now)
_, completion_marks = _contact_completion_note(
effective_raw, _confirmed_now, _suspect_now,
"zh" if _CJK_RE.search(content or "") else "en")
if completion_marks:
raw = dict(profile.raw_info or {})
for key in completion_marks:
raw[key] = True
profile.raw_info = raw
self.db.add(profile)
await self.db.commit()
# warning 级(2026-10-08 第四轮验收): 生产曾现 2/5 裸号轮墙未介入的
# 非确定性异常(同输入容器内链路复现正常)——本行生产可见,异常会话
# 可立刻分辨"计数/注入是否发生",供观察期定位
logger.warning("Contact completion note injected (conv=%s): %s",
conv.id, completion_marks)
# 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 增补设计 §2): 本轮注入的是国家代码追问时,
# 输出侧确定性强制。流式无法撤回 → 触发轮不逐句流出,整段缓冲到
# 流末统一判 "country code" 在场与否(缓冲期间价格/零号码/再索要
# 三墙照常逐句检查,优先级不变)。提示词层全部保留,墙只是兜底。
cc_wall = completion_marks == ["_cc_asked"]
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):
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):
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(审查修复): CC 触发轮回显被拦 → closing 改用
# CC 模板句而非 NO_CONTACT_ALREADY——GLM 有 3/3 回显倾向,
# 该路径概率高,客户该收到追问而非"稍后联系"虚假承诺;
# 价格墙路径(violation=="boundary")不变
closing = (_CC_WALL_ZH if _CJK_RE.search(content or "")
else _CC_WALL_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 增补设计 §2): 触发轮缓冲全文在此判定——
# 含 "country code"(中文轮认 "国家代码",与 _CC_NOTE_ZH 注入的逐字
# 追问句同词)→ GLM 自发服从,缓冲原样流出,零干预;不含 → 整段替换
# 为 CC 模板句正文。violated(价格/零号码墙)优先于本墙——沿用双墙
# 裁决"更具体话术优先"惯例;_cc_asked 已在流前落库,一次性消耗不受
# 替换影响。缓冲轮全部实义句被再索要墙丢弃时 cc_buffer 为空 → 同样
# 走替换(模板句即本轮唯一实义输出,天然不触发下方兜底)。
if cc_wall and not violated:
cc_full = "".join(cc_buffer)
if "country code" in cc_full.lower() or "国家代码" in cc_full:
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_ZH if _CJK_RE.search(content or "")
else _CC_WALL_EN)
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_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)
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={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 _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 按钮)。"
"绝对禁止口头报出任何号码或邮箱。")
2026-09-29 20:02:03 +08:00
# 语言跟随提醒(Task 11): 与 boundary/contact 提醒同挂载位——
# 首条消息画像为空也要生效(中文客户开场即漂英文是实测重灾区)
lang_note = _lang_note_for(latest_msg)
if not raw_info:
2026-09-29 20:02:03 +08:00
# 空画像:仅返回边界/联系/语言提醒(可能为空串)
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 优先、邮箱次之,串行
# 注入。计数键(_marks)此处弃用: 本函数是 staticmethod 收内存副本,写回由
# stream 调用方以同参调用纯函数取回并落库(纯函数保证两处判定不漂移)。
# 计算前移到 confirmed 块之前: CC 追问轮 confirmed 块的确认句要让位。
completion_note, completion_marks = _contact_completion_note(
raw_info, confirmed, suspect,
lang="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"时可请客户重发完整号一次。")
if completion_marks == ["_cc_asked"]:
# 生产验收场景①修复(2026-10,三轮): CC 追问是对已在手渠道的重发
# 请求,不在"缺失渠道"例外内——同轮两个"逐字使用这句"竞争时 GLM
# 稳定服从更显眼的确认句,压制追问。本轮 confirmed 块让位: 唯一
# 逐字锚点=下方轻补指引的追问句;禁数字回显堵住"让位后 GLM 自创
# 确认句带出真实号码"的回归(墙的客户回显豁免会放行)
parts.append("本轮不要使用确认句,请逐字使用下面这条国家代码追问句请客户"
"重发带国家代码的完整号码(仅此一次,客户重发或明确不给后"
"下一轮恢复正常)。回复中绝不写出号码数字——提到号码一律用 "
"your WhatsApp 指代。")
else:
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 3 轻补注入(计算已前移到 confirmed 块之前——CC 轮让位判定需要)
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("⚠️ 客户发的是短消息!必须结合上面已知信息来理解,绝对不能当作独立问题!")
2026-09-29 20:02:03 +08:00
if lang_note:
parts.append(lang_note)
return "\n".join(parts)