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

1097 lines
57 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)