Files
ai-kefu-admin/app/engine/services/conversation.py
T

1327 lines
69 KiB
Python
Raw Normal View History

import logging
import re
import uuid
from collections.abc import AsyncGenerator
from datetime import datetime, timedelta, timezone
from typing import TYPE_CHECKING
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.engine.models import (
Conversation, ConversationStatus, ConversationSource,
Message, MessageRole, CustomerProfile, Evaluation, IntentLevel,
LeadPushLog,
)
from app.engine.services import (
cc_completion, email_service, ip_service, lead_push_service,
)
from app.engine.services.ai_service import AIService
from app.engine.services.boundary_filter import (
DEFAULT_WALL, asks_contact, deflection_for, no_contact_value, pivot_for,
split_sentences, violates_boundary,
)
from app.engine.services.contact_pipeline import (
canon, digits_only, latest_stated_contacts, merge_contacts,
normalize_contacts, resolve_by_recency, scan_user_messages, split_validate,
)
from app.engine.wall_schema import CompiledWall
if TYPE_CHECKING:
from app.platform.registry import TenantRuntime
logger = logging.getLogger(__name__)
# ---- Task 9: contact 行为开关 / context 种子白名单(config → 引擎消费口) ----
def resolve_contact_flags(config_data: dict | None) -> dict:
"""租户 config.contact → 联系方式行为开关。
cc_completion: 号码区号补全(判定树/确认状态机/CC 强制墙总闸);
email_light_ask: 邮箱轻补(缺失渠道一次性追问)。False = 该行为整体
跳过(引擎级门控: 不建状态/不注入/不写计数键)。
(template_languages 暂无运行期消费方, 不在此解析——Phase 2 后台批次
再接。)
"""
contact = ((config_data or {}).get("contact")) or {}
return {
"cc_completion": bool(contact.get("cc_completion", True)),
"email_light_ask": bool(contact.get("email_light_ask", True)),
}
def context_seed_map(config_data: dict | None) -> dict:
"""context 种子映射 = config.engine.context_seed_keys(ctx_key → raw_key)。
Task 9 前白名单(product_name/product_id)硬编码在本模块——两租户差异
(auto 是 vehicle_*)由此配置化;create_conversation 是唯一消费口(按
映射值写 raw_info,白名单 = 本映射的键集合——审查 Minor-4: 单一取值
路径,不留第二条内联读法)。
"""
engine = ((config_data or {}).get("engine")) or {}
return dict(engine.get("context_seed_keys") or {})
# 职能边界触发词(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但无词边界)
# - 原则: 误触发低害(多一条提醒),漏触发高危(红线失守),词表偏宽;上线后漏词案例回流补词
# Task 4 词表数据化: 三组触发正则的数据源 = DEFAULT_WALL(jiancai 出厂)
# .triggers 编译产物(seed_profiles/jiancai/wall.json),上方精修说明随数据留存
# C-1(终审): 本组模块常量降级为"默认墙视图"——供直调/旧测试按名锚定;
# 运行期触发词查找一律走租户墙(manager 的 self._wall.triggers)
BOUNDARY_TRIGGER_RE = DEFAULT_WALL.triggers["boundary"]
# 预算路径路由(评审#9): 命中 BOUNDARY 触发词且含预算词 → 走预算 playbook
BUDGET_TRIGGER_RE = DEFAULT_WALL.triggers["budget"]
# 索要我方联系方式触发(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 = DEFAULT_WALL.triggers["contact_ask"]
# 语言跟随提醒(Task 11 真人验收发现): glm-4-flash 对拉丁字符占比高的中文
# 消息(DN800/HDPE 规格混排)会整轮漂成英文,system.md 语言规则压不住。修复位 =
# 当轮 context 提醒(prompt 最末、最新鲜)——与 contact-ask 提醒"逐字话术防
# 漂移"同款方法论。2026-10-08 Task 5 收尾: 语言规则放开(模板句跟随客户语言
# 自然表达)后,ES note 的"整句使用英文原版/绝不翻译成西语"与新规则相反,
# 已同步删除;两条 note 统一补"补全号码逐字照抄"(回显确认轮唯一例外)。
# Task 4 词表数据化: 话术数据源 = DEFAULT_WALL.reminders.language_follow
_CJK_RE = re.compile(r"[一-鿿]")
_ES_FEATURE_RE = re.compile(r"[áéíóúñãç¿¡]")
LANG_NOTE_ZH = DEFAULT_WALL.reminders["language_follow"]["zh"]
LANG_NOTE_ES = DEFAULT_WALL.reminders["language_follow"]["es"]
def _lang_note_for(msg: str, wall: CompiledWall | None = None) -> str:
# C-1(终审): wall=None = 模块级默认墙(LANG_NOTE_* 常量即该默认视图,
# 旧测试锚定);manager 一律传租户墙
w = wall if wall is not None else DEFAULT_WALL
if _CJK_RE.search(msg or ""):
return w.reminders["language_follow"]["zh"]
if _ES_FEATURE_RE.search(msg or ""):
return w.reminders["language_follow"]["es"]
return ""
def _cc_lang_of(msg: str) -> str:
"""CC 话术语言路由(P3, 2026-10-08 验收): 汉字→ZH;西语特征(与
boundary_filter._lang_pool/_lang_note_for 同款重音符粗匹配)→ES;
否则 EN。只管 ECHO/ASK 模板选型——墙裁决锚(e164 digits)语言无关,
no_ip 问句短语锚另含西语同词(见 cc_wall 裁决段)。"""
if _CJK_RE.search(msg or ""):
return "zh"
if _ES_FEATURE_RE.search(msg or ""):
return "es"
return "en"
# 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 仍锚定其正文)。
# Task 4 词表数据化: CC/邮箱模板数据源 = DEFAULT_WALL.cc_templates 与
# .reminders.email_light(seed_profiles/jiancai/wall.json);_CC_WALL_*/_CC_NOTE_*
# 的静态组合("Got it — " 前缀/外壳+正文)在数据文件里存整句,值逐字等价。
_CC_ASK_EN = DEFAULT_WALL.cc_templates["ask"]["en"]
_CC_ASK_ZH = DEFAULT_WALL.cc_templates["ask"]["zh"]
# P3(2026-10-08 真实验收): 西语轮模板补齐——修复前 ECHO/ASK 只有 EN/ZH,
# 西语轮 GLM 照抄英文。WALL 沿用 EN 形态(衔接头 + 追问句正文)。
_CC_ASK_ES = DEFAULT_WALL.cc_templates["ask"]["es"]
_CC_WALL_EN = DEFAULT_WALL.cc_templates["wall"]["en"]
_CC_WALL_ZH = DEFAULT_WALL.cc_templates["wall"]["zh"]
_CC_WALL_ES = DEFAULT_WALL.cc_templates["wall"]["es"]
_CC_NOTE_EN = DEFAULT_WALL.cc_templates["note"]["en"]
_CC_NOTE_ZH = DEFAULT_WALL.cc_templates["note"]["zh"]
# Task 4(spec §4 回显确认轮/§4b): 判定树给出 e164 建议的等待态轮用回显确认
# 模板——{intl} 由 Python format_intl 算好填入(reminder/墙替换/测试断言三方
# 同源引用本常量),GLM 只逐字复述不拼号。no_ip 轮(无可回显号)仍走 _CC_ASK_*
# (上方保留不拆),两条路径按 _cc.whatsapp 的 e164 在场分流。
_CC_ECHO_EN = DEFAULT_WALL.cc_templates["echo"]["en"]
_CC_ECHO_ZH = DEFAULT_WALL.cc_templates["echo"]["zh"]
_CC_ECHO_ES = DEFAULT_WALL.cc_templates["echo"]["es"]
_EMAIL_NOTE_EN = DEFAULT_WALL.reminders["email_light"]["en"]
_EMAIL_NOTE_ZH = DEFAULT_WALL.reminders["email_light"]["zh"]
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,
wall: CompiledWall | None = None) -> str:
"""CC 等待态本轮逐字话术(Task 4, spec §4;P3 扩 ES): 有 e164 → 回显
确认句(intl 由 Python 算好填入,GLM 不拼号);no_ip → 追问句
(_CC_ASK_* 服务)。reminder 注入/墙替换/测试断言三方同源,统一走本函数。
C-1(终审): wall=None = 模块级默认墙(_CC_ECHO_*/_CC_ASK_* 常量即该
默认视图);manager 一律传租户墙。"""
w = wall if wall is not None else DEFAULT_WALL
if str((wa_state or {}).get("e164") or ""):
intl = _cc_intl(wa_state)
echo = w.cc_templates["echo"]
return {"zh": echo["zh"], "es": echo["es"]}.get(
lang, echo["en"]).format(intl=intl)
ask = w.cc_templates["ask"]
return {"zh": ask["zh"], "es": ask["es"]}.get(lang, ask["en"])
def _cc_wall_template(wa_state: dict | None, lang: str,
wall: CompiledWall | None = None) -> str:
"""CC 触发轮墙整段替换句: 回显轮 = ECHO 模板(自带 "Got it —" 衔接头);
no_ip 轮 = _CC_WALL_*(ASK 追问句 + 衔接头,沿用 Task 3 形态;P3 扩 ES)。
C-1(终审): wall=None = 模块级默认墙(_CC_WALL_* 常量即该默认视图);
manager 一律传租户墙。"""
if str((wa_state or {}).get("e164") or ""):
return _cc_script(wa_state, lang, wall=wall)
w = wall if wall is not None else DEFAULT_WALL
tmpl = w.cc_templates["wall"]
return {"zh": tmpl["zh"], "es": tmpl["es"]}.get(lang, tmpl["en"])
def _cc_confirm_note(wa_state: dict | None, lang: str,
wall: CompiledWall | None = None) -> str:
"""cc 等待态确认句注入(Task 4, spec §4/§4b): cc_due 才注入,与邮箱轻补
cc_settled 串行互斥(两个"逐字使用这句"不同轮竞争)。外壳仿轻补指引:
⚠️ 系统指令声明 + 逐字话术 + 客户答复后致谢不追问。回显分支带零号码
唯一例外(4b-3 让位语义重建: 仅系统提供的补全号原文可写出)。
Task 4 词表数据化: 指引话术数据源 = 墙 reminders.cc_guide
({intl}/{cc_script} 占位由本函数 .format() 回填,值逐字等价)。
C-1(终审): wall=None = 模块级默认墙;manager 一律传租户墙。"""
if not wa_state or not cc_completion.cc_due(wa_state):
return ""
w = wall if wall is not None else DEFAULT_WALL
if str(wa_state.get("e164") or ""):
intl = _cc_intl(wa_state)
return w.reminders["cc_guide"]["echo"].format(
intl=intl, cc_script=_cc_script(wa_state, lang, wall=wall))
return w.reminders["cc_guide"]["ask"].format(
cc_script=_cc_script(wa_state, lang, wall=wall))
def _contact_completion_note(
raw_info: dict | None, confirmed: dict | None, suspect: dict | None,
lang: str = "en", email_ask: bool = True,
wall: CompiledWall | None = None,
) -> 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)。
email_ask=False(Task 9: contact.email_light_ask 开关)→ 恒空,不注入
也不产计数键。
C-1(终审): wall=None = 模块级默认墙(_EMAIL_NOTE_* 常量即该默认视图);
manager 一律传租户墙(流式调用处与本函数在 reminder 内的调用同参,
写回与注入永不漂移)。
"""
if not email_ask:
return "", []
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 确认流未落定: 邮箱轻补串行等待
w = wall if wall is not None else DEFAULT_WALL
return ((w.reminders["email_light"]["zh"] if lang == "zh"
else w.reminders["email_light"]["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
# Task 6(spec §5 面板同步): 等待确认期间的联系方式值尾注——防"邮件带补全、
# 后台显裸号"的分裂。尾注全为非数字字符,canon() 纯数字提取不受影响
# (24h 去重/更正判定照常)。
_CC_PENDING_TAIL = "(待确认补全号)"
def _panel_contact_info(confirmed: dict, cc_map: dict | None) -> dict:
"""ev.contact_info 存储视图(Task 6 评审钉死): 裸号原文 + 等待态尾注,
e164 走派生不落本体——e164 替换本体会让"存储值 vs 每轮裸号确认值"
永远不同 canon,退化成每轮误判换号→更正连发/回访退化。
只标 whatsapp 确认流覆盖的号码(canon 与 _cc.whatsapp 一致,含同号
phone 镜像): phone 独立静默态不进确认流、attempts 永不推进,cc_due
恒真,若照标会永久滞留且语义失真。"""
wa = (cc_map or {}).get("whatsapp")
wa_canon = str((wa or {}).get("canon") or "")
pending = bool(wa_canon) and cc_completion.cc_due(wa)
out: dict = {}
for k, v in (confirmed or {}).items():
if (pending and k in ("phone", "whatsapp")
and digits_only(str(v)) == wa_canon):
out[k] = f"{v}{_CC_PENDING_TAIL}"
else:
out[k] = v
return out
def _channel_canons(confirmed: dict, cc_map: dict | None) -> dict[str, set[str]]:
"""canon 口径双保险(Task 6 评审钉死): 每渠道候选集 = 裸号 canon ∪
_cc.e164 的 canon——同号换形态(裸号↔+前缀完整形态)不误判换号
(更正连发)、回访不退化成全量询盘。只扩"新值侧": 若把当前 _cc 的
e164 canon 并入旧值/历史集,换号后新号恰为前缀完整形态时会自我满足,
真更正被吞。"""
out: dict[str, set[str]] = {}
for k, v in (confirmed or {}).items():
cands = {canon(str(v))}
state = cc_completion.cc_state_for(cc_map, k, str(v))
e164 = str((state or {}).get("e164") or "")
if e164:
cands.add(canon(e164))
out[k] = cands
return out
# P4'(2026-10-08 复验): 画像"目标国家"报国信号的占位值集——"无"/"未提及"
# 是写给 extractor 的指令标记(extractor.md 明令禁止作字段值输出,但 GLM
# 不保证),"未知"/"本地" 同为无信息量形态;命中占位集不当作报国消费。
_COUNTRY_PLACEHOLDERS = {"无", "未提及", "未知", "本地"}
def _country_signal(cur, pre) -> str | None:
"""P4' 画像报国信号(纯函数): 等待态轮画像"目标国家"键**新出现或值
变化**(cur=本轮合并后值, pre=轮前快照)且值有效(非空、非占位集)
→ 返回该值作为 country 信号;值不变(含轮前已有同值)→ None(去抖:
同国重算幂等,重复陈述无新信息)。国名是否可识别(NAME_TO_REGION
命中)由调用方判定——不命中时信号整体忽略,不消费。"""
c = str(cur or "").strip()
if not c or c in _COUNTRY_PLACEHOLDERS:
return None
if c == str(pre or "").strip():
return None
return c
# 方案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, *,
runtime: "TenantRuntime",
ai_service: AIService | None = None):
self.db = db
# Task 6: runtime 必填(Task 5b 质量审查关门建议)——提示词/墙/会话
# 工厂全部租户化,无租户上下文的构造不再合法;默认 AI 服务用该租户
# 的 PromptProvider + compiled_wall 组装($DEFLECTION_POOL 单一事实源
# 注入)。Task 9: AI 凭据/config 全部经 runtime.config.data 接线。
self.runtime = runtime
# C-1(终审审查): 执行侧墙接线——过滤/替换/提醒全走租户编译墙,
# 与 AIService 的 $DEFLECTION_POOL 同源(runtime.compiled_wall);
# 模块级 DEFAULT_WALL 仅供无租户上下文的直调默认(boundary_filter
# 出厂视图 + 本模块旧测试锚定),会话流内一律本墙。
self._wall = runtime.compiled_wall
self.ai = ai_service or AIService(
prompts=runtime.prompts,
wall=runtime.compiled_wall,
config_data=runtime.config.data)
# Task 9: contact 行为开关(cc 补全/邮箱轻补)与国名映射扩表——
# manager 生命周期内配置不变(reload 换 runtime 即换 manager 装配线;
# 生效时机: WS 长连接内冻结至断连,REST 路径下一请求生效——Phase 2
# 联调改配置后按此预期,勿误判"没生效")
self._contact_flags = resolve_contact_flags(runtime.config.data)
self._region_extras = (((runtime.config.data.get("misc")) or {})
.get("name_to_region_extras") or None)
@property
def session_factory(self):
"""推送后台任务写结果用的工厂(写回派发租户的库);runtime 未懒建
engine 时 None(仅测试直连场景——生产装配线总在 ensure_ready 之后)。"""
return self.runtime.session_factory
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 keys in the tenant's
# engine.context_seed_keys map (jiancai: product_*; auto: vehicle_* —
# Task 9 前白名单硬编码 product_name/product_id,现读租户配置).
# WS path passes no context -> raw_info stays {}.
raw_info: dict = {}
if context:
for ctx_key, raw_key in context_seed_map(
self.runtime.config.data).items():
if context.get(ctx_key):
raw_info[raw_key] = context[ctx_key]
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)
# P4'(2026-10-08 复验) 轮前画像快照: "目标国家"轮末信号判定基线。
# 必须在本轮任何 raw_info 写回发生前取值(下方计数写回/轮末 extractor
# 合并都会覆盖同一 ORM 实例);值为 str/None 不可变,持引用即安全。
pre_turn_country = (profile.raw_info or {}).get("目标国家")
# 联系方式视图(轮前钩子/计数写回/再索要墙区共用;原在 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,当轮即可被墙门控/轮末消费/出站
# 渲染读到;落库与下方一次性计数写回同事务。
# Task 9: contact.cc_completion=False → 判定树整体跳过(不建不迁
# _cc;存量 _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)
if self._contact_flags["cc_completion"] else False)
_wa_cc = (effective_raw.get("_cc") or {}).get("whatsapp")
context_reminder = self._build_context_reminder(
effective_raw, content, history, source=conv.source.value,
contact_flags=self._contact_flags, wall=self._wall)
if self._wall.triggers["contact_ask"].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",
email_ask=self._contact_flags["email_light_ask"], wall=self._wall)
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" 问句短语锚。
# Task 9: cc_completion=False → CC 强制墙同样关闭(开关管整个补全
# 行为族: 判定树/确认流/墙);存量等待态不触发缓冲整段裁决
cc_wall = (self._contact_flags["cc_completion"]
and 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, wall=self._wall):
violated = True
dirty = sentence
violation = "boundary"
break
if no_contact_value(sentence, allow_number=cc_allow_number,
wall=self._wall):
violated = True
dirty = sentence
violation = "no_contact"
break
# Task 5b 再索要墙: 已留资时索要句静默丢弃(丢句不掐断,
# 其余句子照常流出——软于两墙的掐断+替换);
# 渠道精确化: 只丢索要已在手渠道的句子
if has_contact and asks_contact(sentence, _confirmed_channels,
wall=self._wall):
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, wall=self._wall):
violated = True
dirty = pending
violation = "boundary"
elif no_contact_value(pending, allow_number=cc_allow_number,
wall=self._wall):
violated = True
dirty = pending
violation = "no_contact"
elif has_contact and asks_contact(pending, _confirmed_channels,
wall=self._wall):
# 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, _cc_lang_of(content),
wall=self._wall)
else:
# Task 9 收口: 视图直取墙 no_contact(单一事实源),
# 不经 boundary_filter 的模块级常量转引;C-1(终审):
# 取租户墙——替换文案各自来自自己的池
_w = self._wall
closing = (_w.no_contact["already"] if has_contact
else (_w.no_contact["app"]
if conv.source.value == "app"
else _w.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, wall=self._wall)
if emitted
else deflection_for(content, _boundary_rotate(),
has_contact=has_contact,
wall=self._wall))
# 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"/"国家代码"/"código de país",与 _CC_ASK_* 模板句
# 同词——P3 扩西语同词,西语服从句不再被误替换)。在场 → GLM
# 自发服从,缓冲原样流出零干预;不在场 → 整段替换为对应 ECHO/ASK
# 模板。violated(价格/零号码墙)优先于本墙——沿用双墙裁决"更具体
# 话术优先"惯例;_cc 状态已在流前落库,不受替换影响。缓冲轮全部
# 实义句被再索要墙丢弃时 cc_buffer 为空 → 同样走替换(模板句即
# 本轮唯一实义输出,天然不触发下方兜底)。
if cc_wall and not violated:
cc_full = "".join(cc_buffer)
cc_lang = _cc_lang_of(content)
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
or "código de país" in cc_full.lower())
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,
wall=self._wall)
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)
fallback = self._wall.no_contact["already"]
for i in range(0, len(fallback), 40):
yield {"type": "stream_chunk",
"content": fallback[i:i + 40]}
emitted = fallback
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)。cc_wall_fired =
# 本轮墙确有触发(P1+P2 首问轮判定输入,见 _cc_round_end)。
# P4': pre_turn_country = 轮前画像快照(本轮 extractor 合并前的
# "目标国家"值),画像报国信号的比对基线。
await self._cc_round_end(conv, profile, cc_reply, cc_wall_fired=cc_wall,
pre_turn_country=pre_turn_country)
# 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 由触发器更正分支管理,这里不覆盖。
# Task 6: 存储视图走面板尾注(等待态标注,e164 派生不落本体)
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 = _panel_contact_info(
confirmed_now, (profile.raw_info or {}).get("_cc"))
self.db.add(ev)
# 决策A(2026-08-18): 自动结束移除——会话永不因评估结果结束;
# evaluated 事件保留但 ended 恒为 False(客户端协议不变)。
await self.db.commit()
# 询盘推送(设计 8.1): 主事务提交后才派生后台任务——payload 内存
# 传递,任务不读库只写结果;fire 为同步函数,纳秒级返回不阻塞。
# session_factory 随任务传递: 写回本租户的库(Task 5b);config_data
# 同随任务传递(Task 9: 重试矩阵参数走租户配置)
lead_push_service.fire_pending_pushes(
pending_pushes, self.session_factory,
config_data=self.runtime.config.data)
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 窗口才正确。
# Task 6 双保险: join 画像取历史会话的 _cc——各渠道 e164 的 canon
# 一并入集(快照存裸号原文+尾注,客户以 + 前缀完整形态回访同号时
# 仍要命中,否则回访退化成全量询盘)。
result = await self.db.execute(
select(Evaluation.contact_info, CustomerProfile.raw_info)
.outerjoin(
CustomerProfile,
CustomerProfile.conversation_id == Evaluation.conversation_id)
.where(
Evaluation.email_sent.is_(True),
Evaluation.evaluated_at >= cutoff,
)
)
seen: set[str] = set()
for contact_info, raw_info in result:
for v in (contact_info or {}).values():
if v:
seen.add(canon(str(v)))
for state in ((raw_info or {}).get("_cc") or {}).values():
e164 = str((state or {}).get("e164") or "")
if e164:
seen.add(canon(e164))
return seen
async def _cc_round_end(
self, conv: Conversation, profile: CustomerProfile,
cc_reply: dict | None, cc_wall_fired: bool = False,
pre_turn_country: str | None = None,
) -> None:
"""轮末 cc 钩子(spec §6-2): 联系方式四层合并后、D1 邮件前调用。
- 首问轮免计免消费(P1+P2 一体修复,2026-10-08 真实验收): 轮前
新建的等待态 = 回显/追问**首次发出轮**——客户本轮还没机会回应
确认句,轮末既不消耗 attempts 也不消费 cc_reply(extractor 对
"确认句排给号消息之后"历史的任何回应分类必是误判,P2);attempts
从等待回应轮起计(报国/不理睬轮才递增)——修复前裸号轮轮末即耗
attempts=1,报国轮 country 分支消费到 2 直落 fallback,重算的
e164 永无回显确认机会(P1)。判据 = 本轮墙确有触发(cc_wall_fired)
且状态尚未标记 asked——统一覆盖轮前新建与轮末补建两条建态路径
的首个墙轮(补建态当轮无墙,首问轮顺延到下轮)。
- cc_reply 消费(双门控 Python 侧): 仅 whatsapp 组等待态(cc_due)消费
——无回应/漏填也推进(attempts 随轮递增,达上限落定 fallback);
落定态仅开迟到 country 升级口: 客户主动报国(非空 country)且
source 非 no_ip 耗尽 → transit 重算 e164(客户主动给的信息不
丢弃);迟到 affirm 不升级(对已标"未经确认"旧号的确认语义模糊,
confirmed 态更不重算——客户改主意请重发完整号,走 canon 重置)。
- P4'(2026-10-08 复验)画像报国信号兜底: extractor 对"画像已记、
客户重复陈述"倾向漏填 cc_reply(复验 2/2 稳定复现),报国不能赌
分类——等待态(cc_due)且非首问轮,本轮画像"目标国家"**新现或值
变化**(与轮前快照 pre_turn_country 比对,占位值不算)→ 视为
country 信号,name_to_region 转 ISO 后走 transit country 分支
(与 cc_reply.country 同路径)。cc_reply 显式分类优先(reply 已定
不介入);国名不识别 → 忽略(不重算,该轮按无回应语义消耗)。
- canon 变化(换号/号消失)→ RESET 删组状态。
- 补建 pass: 对最终联系方式重跑判定树(_cc_pre_turn)——本轮才经
extractor 入管道的裸号/换号后的新号当场建状态,不下轮补课。
- Task 9: contact.cc_completion=False → 整钩子跳过(轮前判定树
已不建态,此处不消费不推进;国名映射查表带租户 extras)。
"""
if not self._contact_flags["cc_completion"]:
return
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")
# 首问轮: cc_wall_fired ⇔ 轮前等待态(墙必触发);asked 标记由本钩子
# 独占管理(cc_completion 不感知),transit 的 dict 拷贝天然携带。
# canon 分支在其后——首问轮遇换号仍以 RESET 优先,标记随旧态作废。
first_ask = (state is not None and cc_wall_fired
and not state.get("asked")
and cc_completion.cc_due(state))
reply = None
if isinstance(cc_reply, dict) and not first_ask:
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 升级口
# P4' 画像报国信号兜底(仅 cc_reply 未消费时介入): raw 此时已是
# extractor 合并后的本轮终态,"目标国家"新现/值变化 = 客户本轮
# 陈述了新国家——确定性信号,不赌 extractor 是否把它分类进 cc_reply
if (reply is None and not first_ask
and cc_completion.cc_due(state)):
signal = _country_signal(raw.get("目标国家"), pre_turn_country)
if signal and cc_completion.name_to_region(
signal, extras=self._region_extras):
reply = {"affirm": False, "country": signal}
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 first_ask:
cc_map["whatsapp"] = {**state, "asked": True} # 首问轮免计
elif reply is not None or cc_completion.cc_due(state):
cc_map["whatsapp"] = cc_completion.transit(
state, reply, extras=self._region_extras)
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
# Task 6(spec §5): 出站渲染取 _cc(首封补全号/标注/主题行/推送派生);
# 轮末钩子已保证 _cc 与最终联系方式同 canon 同步
cc_map = (profile.raw_info or {}).get("_cc") or {}
ev = await self._get_or_init_evaluation(conv.id)
if not ev.email_sent:
# S3: 任一值已见于24h内其他会话 → 轻提醒;否则全量。
# Task 6 双保险: 比对候选集含 _cc.e164 的 canon(见 _channel_canons)
# Task 9: 邮件/推送参数走租户配置(config_data 透传给 resolve_*)
seen = await self._recently_emailed_canons()
if any(cands & seen
for cands in _channel_canons(confirmed, cc_map).values()):
await self._collect_push(
pending_pushes, ev, conv, profile, confirmed,
"returning", evaluation, evaluated)
sent = await email_service.send_returning_lead_email(
confirmed, cc_info=cc_map,
config_data=self.runtime.config.data)
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 "",
cc_info=cc_map,
config_data=self.runtime.config.data,
)
if sent:
ev.email_sent = True
ev.contact_info = _panel_contact_info(confirmed, cc_map)
self.db.add(ev)
else:
old_canons = {canon(str(v)) for v in (ev.contact_info or {}).values()}
# Task 6 双保险: 新值候选集含 _cc.e164 的 canon——确认落定
# (号码未变)不补发更正;真换号(两组候选都不在旧集)才走更正
if any(not (cands & old_canons)
for cands in _channel_canons(confirmed, cc_map).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, cc_info=cc_map,
config_data=self.runtime.config.data):
ev.correction_count = count
ev.contact_info = _panel_contact_info(confirmed, cc_map)
self.db.add(ev)
else:
# D4'(2026-08-18): 锁邮件不锁快照 —— 上限已到,
# admin 仍要看到客户最新号码
ev.contact_info = _panel_contact_info(confirmed, cc_map)
self.db.add(ev)
async def _collect_push(
self, pending, ev, conv, profile, confirmed,
push_type, evaluation, evaluated,
) -> None:
"""询盘推送派发(设计 §8): 触发条件=进入发信分支,与 SMTP 成败解耦。
dispatch 内部吞异常(失败只记日志),这里无需再兜。config_data =
租户配置(Task 9: 开关/前缀走 config, 空槽回退 env)。
"""
log = await lead_push_service.dispatch_lead_push(
self.db, ev, conv, profile, confirmed, push_type,
evaluation, evaluated,
config_data=self.runtime.config.data,
)
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 = "",
contact_flags: dict | None = None,
wall: CompiledWall | None = None) -> str:
"""Build a context reminder from extracted profile info.
Task 4 词表数据化: 六类轮中提醒话术(boundary/contact_ask/
language_follow/lead_given/cc_guide/email_light)的数据源 =
墙 reminders(seed_profiles/<slug>/wall.json);
{shown}/{no_contact_web}/{no_contact_already} 占位由本函数
.format() 回填,值与数据化前逐字等价。
contact_flags(Task 9): contact 行为开关——cc_completion=False 跳过
cc 确认指引,email_light_ask=False 跳过邮箱轻补;None = 全开
(直调本函数的旧测试语义)。
wall(C-1 终审): 租户编译墙(触发词/提醒话术/no_contact 全走它);
None = 模块级默认墙(直调/旧测试语义——BOUNDARY_TRIGGER_RE 等
模块常量即该默认视图)。"""
w = wall if wall is not None else DEFAULT_WALL
# 职能边界提醒(评审#7: 前置到早退之前,首条消息画像为空也要生效)
boundary_note = ""
if w.triggers["boundary"].search(latest_msg or ""):
if w.triggers["budget"].search(latest_msg or ""):
# 评审#9: 预算求推荐 → 预算路径(推荐+反问+引导),不一刀切要号
boundary_note = w.reminders["boundary"]["budget"]
else:
boundary_note = w.reminders["boundary"]["normal"]
# 联系方式状态前移(供 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 w.triggers["contact_ask"].search(latest_msg or ""):
if source == "app":
if confirmed or suspect:
# 已留资: 索要我方联系方式 → 逐字"经理主动联系"版,
# 与"禁止再索要"提醒不再打架
# Task 3 收尾: 第三处禁令同步改写(禁令集完整)——与前两处
# 同语义,只禁已提供过的渠道,缺失渠道放行轻补一次
contact_note = w.reminders["contact_ask"]["app_already"]
else:
contact_note = w.reminders["contact_ask"]["app"]
else:
contact_note = w.reminders["contact_ask"]["web"].format(
no_contact_web=w.no_contact["web"])
# 语言跟随提醒(Task 11): 与 boundary/contact 提醒同挂载位——
# 首条消息画像为空也要生效(中文客户开场即漂英文是实测重灾区)
lang_note = _lang_note_for(latest_msg, wall=wall)
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 9: contact 行为开关注入本函数(staticmethod 保持旧直调签名,
# None = 全开);cc 确认指引与邮箱轻补按开关短路
flags = (contact_flags if contact_flags is not None
else resolve_contact_flags(None))
# 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",
email_ask=flags["email_light_ask"], wall=wall)
# Task 4(spec §4 回显确认轮): cc 等待态注入确认句指引——有 e164 用
# 回显模板(逐字 intl),no_ip 用 ASK 追问句;与邮箱轻补 cc_settled
# 串行互斥,两个"逐字使用这句"永不同轮。无计数键: 轮次上限由状态机
# attempts 管理(轮末 transit 递增),提醒每轮等待态都在场。
# P3: 语言路由扩 ES(_cc_lang_of),西语轮注入西语模板。
cc_note = (_cc_confirm_note(
(raw_info.get("_cc") or {}).get("whatsapp"), _cc_lang_of(latest_msg),
wall=wall)
if flags["cc_completion"] else "")
# 联系方式专项(D6) 与 职能边界(D6联动)
if confirmed:
shown = ", ".join(
f"{k}: {' '.join(str(v).split())[:60]}" for k, v in confirmed.items())
# 逐字话术直接引用墙 no_contact["already"](Task 9 收口:
# 视图直取单一事实源)——与零号码墙替换句同源,三处"经理主动联系"
# 话术不漂移
# Task 3 防自锁改写(spec 4.1-6): 原"绝对禁止再索要任何联系方式!"
# 会抑制缺失渠道轻补——只禁已提供过的渠道,缺失渠道放行一次
parts.append(w.reminders["lead_given"]["forbidden"].format(
shown=shown))
parts.append(w.reminders["lead_given"]["respond_script"].format(
no_contact_already=w.no_contact["already"]))
if boundary_note:
parts.append(boundary_note)
# Task 3 防自锁改写: 尾部补缺失渠道豁免(与上方改写同因)
parts.append(w.reminders["lead_given"]["boundary_tail"])
elif suspect:
shown = ", ".join(
f"{k}: {' '.join(str(v).split())[:60]}" for k, v in suspect.items())
parts.append(w.reminders["lead_given"]["suspect"].format(
shown=shown))
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)