Files
ai-kefu-admin/app/engine/services/conversation.py
T
浅念 9ad02a778f fix: 终审修复——墙执行侧接租户管道(端到端回归锁)+seed补email组+措辞与死依赖清理
C-1(必须修): 墙的执行侧没接租户管道——runtime.compiled_wall 全库只有
AIService 的 $DEFLECTION_POOL 一处消费,conversation.py 执行侧全走模块级
DEFAULT_WALL(jiancai 出厂),auto 租户会话实际跑的是 jc 墙。修复:
- manager 构造时 self._wall = runtime.compiled_wall(与 AIService 同源);
- 6 处过滤调用(violates_boundary/no_contact_value/asks_contact 的句内
  与 pending 尾巴两路)、CC 墙模板/替换句、no_contact 收尾选择、
  pivot/deflection、兜底句、contact_ask 触发日志判定全部传 wall=self._wall;
- _build_context_reminder/_contact_completion_note/_cc_confirm_note/
  _cc_script/_cc_wall_template/_lang_note_for 加 wall=None 尾参
  (None = 模块级默认墙,旧测试直调语义不变;流式侧 _contact_completion_note
  双胞调用同传 wall,写回与注入不漂移);
- 模块级 DEFAULT_WALL 视图常量(BOUNDARY_TRIGGER_RE/_CC_*/_EMAIL_NOTE_* 等)
  保留——boundary_filter 出厂默认与旧测试锚定,boundary_filter 模块级
  DEFAULT_WALL 照旧,conversation 会话流内一律租户墙;
- 端到端回归锁 tests/test_wall_execution_tenant.py(两租户真 runtime +
  脚本化假 AI): jc 独有 [€£] pattern 句在 jiancai 拦截替换(收尾=jc
  pivots 池)/auto 放行逐字流出;同一邮箱违规句两租户各自精确收到自己
  no_contact["web"](12 hours vs replies fast!);contact_ask 提醒注入
  各自墙文案互不渗漏。已验证模拟旧 DEFAULT_WALL 行为时三测全败。

I-1: 两租户 seed 补 email 组(以 brand_values.json 实际值为准:
auto=notify_email 环境值,jiancai=品牌邮箱——其 notify_email 记录是
notify@example.com 占位): auto {sender_name: XYH, notify_emails:
[18339827479@163.com]}, jiancai {sender_name: HESUPIPE, notify_emails:
[hesu@hesuu.com]};seed 存裸名,From 头渲染时外层再包【】。补测
test_seed_tenants_ship_email_group: env NOTIFY_EMAIL patch 成异值证明
seed 槽胜出。

M-1/M-2: README/CLAUDE.md "platform.db 热加载"改为"文件级 registry_reload
热加载(Phase 2 升级为 platform.db)";requirements.txt 删 alembic
(全库零引用,grep 实证唯一命中即该行)。

M-3/M-4(挂账): CLAUDE.md Phase 2 清理单补两项——/health 分级健康
(Phase 4 部署批次)、PromptProvider.from_dir FileNotFoundError 带路径上下文。

全量回归: 463 passed(459 基线 + 4 新增),根目录与 tests/ 子目录同数。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-13 00:18:54 +08:00

1327 lines
69 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 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)