c2f2ddf82f
真实 GLM 五轮实跑(EN/ES/ZH/换号/敏感问)+mock推送+admin三视图,核心项全过。 修复: 中文轮漂英文→语言跟随提醒; extractor裸字符串联系方式→500崩溃兜形; 号码回显/样品承诺/标记词泄漏 prompt加固; 测试env耦合断言; .env前缀对齐。 Co-Authored-By: Claude Code <noreply@anthropic.com>
680 lines
33 KiB
Python
680 lines
33 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 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,
|
||
)
|
||
|
||
# 语言跟随提醒(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 ""
|
||
|
||
|
||
# 方案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)
|
||
|
||
# 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 = ("⚠️ 客户正在索要我方联系方式(最高意向信号)。请逐字使用这句指路:"
|
||
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}")
|
||
|
||
# 联系方式专项(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("⚠️ 客户发的是短消息!必须结合上面已知信息来理解,绝对不能当作独立问题!")
|
||
|
||
if lang_note:
|
||
parts.append(lang_note)
|
||
|
||
return "\n".join(parts)
|