feat: 服务层配置接线——email/push/rate_limit/AI凭据/行为开关/映射表/种子键

Task 9: 服务层对全局 settings 的业务引用清零,每个 DEFAULT_CONFIG 组
都有真实消费方。resolve_*_settings(email/push/ai)以空槽回退 env
(平台默认层);contact 行为开关/种子白名单/国名扩表/限流上限经
manager 的 runtime.config 接线。收口: lead_push session_factory 必填、
config.py 死代码清理、PromptProvider frozen、no_contact 视图直取、
AIService wall 参数定型 CompiledWall。403 测试全绿(389+14)。

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-10-12 21:37:48 +08:00
parent 8453d3cc56
commit 32b582aac8
15 changed files with 676 additions and 164 deletions
+117 -38
View File
@@ -18,8 +18,7 @@ from app.engine.services import (
)
from app.engine.services.ai_service import AIService
from app.engine.services.boundary_filter import (
DEFAULT_WALL, NO_CONTACT_ALREADY, NO_CONTACT_APP, NO_CONTACT_WEB,
asks_contact, deflection_for, no_contact_value, pivot_for,
DEFAULT_WALL, asks_contact, deflection_for, no_contact_value, pivot_for,
split_sentences, violates_boundary,
)
from app.engine.services.contact_pipeline import (
@@ -32,6 +31,34 @@ if TYPE_CHECKING:
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 allowed_seed_keys(config_data: dict | None) -> set[str]:
"""context 种子白名单 = config.engine.context_seed_keys 的键集合。
Task 9 前白名单(product_name/product_id)硬编码在本模块——两租户差异
(auto 是 vehicle_*)由此配置化;create_conversation 按映射值写 raw_info。
"""
engine = ((config_data or {}).get("engine")) or {}
return {str(k) for k in (engine.get("context_seed_keys") or {})}
# 职能边界触发词(Task 13.2, 评审#1/#4 修订): 命中即注入红线提醒。精修说明:
# - 裸 "how long"/"多久" 不收(规格参数类问题常用),英文要求 take/arrive/deliver/ship 邻接,
# 中文只收 到货/多久能到/交货 等明确交付语境
@@ -166,7 +193,7 @@ def _cc_confirm_note(wa_state: dict | None, lang: str) -> str:
def _contact_completion_note(
raw_info: dict | None, confirmed: dict | None, suspect: dict | None,
lang: str = "en",
lang: str = "en", email_ask: bool = True,
) -> tuple[str, list[str]]:
"""邮箱轻补提醒(spec §4.2/§4.3b,2026-10-08 判定树设计收编)。
纯函数,返回(注入文案,计数键)。
@@ -177,7 +204,11 @@ def _contact_completion_note(
轻补会让两个"逐字使用这句"同轮竞争(旧 §4.3b 场景①教训);落定即解锁。
带 + 号客户无 _cc 状态 → cc_settled(None)=True,旧轻补行为不回归。
suspect 仅为与调用处画像判定同签名,规则只看 confirmed(spec §4.2)。
email_ask=False(Task 9: contact.email_light_ask 开关)→ 恒空,不注入
也不产计数键。
"""
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]
@@ -333,17 +364,23 @@ class ConversationManager:
self.db = db
# Task 6: runtime 必填(Task 5b 质量审查关门建议)——提示词/墙/会话
# 工厂全部租户化,无租户上下文的构造不再合法;默认 AI 服务用该租户
# 的 PromptProvider + compiled_wall.deflections 组装($DEFLECTION_POOL
# 单一事实源注入)。email/push 等 settings→config 业务接线是 Task 9。
# 的 PromptProvider + compiled_wall 组装($DEFLECTION_POOL 单一事实源
# 注入)。Task 9: AI 凭据/config 全部经 runtime.config.data 接线。
self.runtime = runtime
self.ai = ai_service or AIService(
prompts=runtime.prompts,
wall_deflections=runtime.compiled_wall.deflections)
wall=runtime.compiled_wall,
config_data=runtime.config.data)
# Task 9: contact 行为开关(cc 补全/邮箱轻补)与国名映射扩表——
# manager 生命周期内配置不变(reload 换 runtime 即换 manager 装配线)
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,lead_push_service 回退模块级兜底。"""
engine 时 None(仅测试直连场景——生产装配线总在 ensure_ready 之后)。"""
return self.runtime.session_factory
async def create_conversation(
@@ -365,14 +402,17 @@ class ConversationManager:
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 {}.
# 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:
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"]
engine_cfg = self.runtime.config.data.get("engine") or {}
seed_map = engine_cfg.get("context_seed_keys") or {}
for ctx_key, raw_key in seed_map.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()
@@ -446,13 +486,17 @@ class ConversationManager:
# _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)
_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)
effective_raw, content, history, source=conv.source.value,
contact_flags=self._contact_flags)
if CONTACT_ASK_TRIGGER_RE.search(content or ""):
logger.info("Contact-ask reminder fired (source=%s)", conv.source.value)
@@ -464,7 +508,8 @@ class ConversationManager:
# 变更任一存在"(评审 Step 3b)。
_, completion_marks = _contact_completion_note(
effective_raw, _confirmed_now, _suspect_now,
"zh" if _CJK_RE.search(content or "") else "en")
"zh" if _CJK_RE.search(content or "") else "en",
email_ask=self._contact_flags["email_light_ask"])
if completion_marks or _cc_changed:
raw = dict(profile.raw_info or {})
for key in completion_marks:
@@ -506,7 +551,10 @@ class ConversationManager:
# 检查,优先级不变)。Task 4(spec §4/§4b): 回显轮(等待态有 e164)裁决
# 锚 = 注入 e164 的 digits 等值在场,替换句 = ECHO 模板;no_ip 轮
# (无可回显号)沿用 ASK 模板与 "country code" 问句短语锚。
cc_wall = cc_completion.cc_due(_wa_cc)
# 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)
@@ -587,9 +635,12 @@ class ConversationManager:
# 价格墙路径(violation=="boundary")不变
closing = _cc_wall_template(_wa_cc, _cc_lang_of(content))
else:
closing = (NO_CONTACT_ALREADY if has_contact
else (NO_CONTACT_APP if conv.source.value == "app"
else NO_CONTACT_WEB))
# Task 9 收口: 视图直取 DEFAULT_WALL.no_contact(单一
# 事实源),不再经 boundary_filter 的模块级常量转引
closing = (DEFAULT_WALL.no_contact["already"] if has_contact
else (DEFAULT_WALL.no_contact["app"]
if conv.source.value == "app"
else DEFAULT_WALL.no_contact["web"]))
else:
logger.warning(
"Boundary filter intercepted sentence (conv=%s): %r | tail=%r",
@@ -648,10 +699,11 @@ class ConversationManager:
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):
fallback = DEFAULT_WALL.no_contact["already"]
for i in range(0, len(fallback), 40):
yield {"type": "stream_chunk",
"content": NO_CONTACT_ALREADY[i:i + 40]}
emitted = NO_CONTACT_ALREADY
"content": fallback[i:i + 40]}
emitted = fallback
yield {"type": "stream_end"}
ai_reply = emitted # 落库与流出统一为已流出文本(下方持久化)
@@ -797,8 +849,11 @@ class ConversationManager:
# 询盘推送(设计 8.1): 主事务提交后才派生后台任务——payload 内存
# 传递,任务不读库只写结果;fire 为同步函数,纳秒级返回不阻塞。
# session_factory 随任务传递: 写回本租户的库(Task 5b)
lead_push_service.fire_pending_pushes(pending_pushes, self.session_factory)
# 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",
@@ -880,7 +935,11 @@ class ConversationManager:
- 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"))
@@ -914,7 +973,8 @@ class ConversationManager:
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):
if signal and cc_completion.name_to_region(
signal, extras=self._region_extras):
reply = {"affirm": False, "country": signal}
if state is not None:
@@ -923,7 +983,8 @@ class ConversationManager:
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)
cc_map["whatsapp"] = cc_completion.transit(
state, reply, extras=self._region_extras)
if cc_map:
raw["_cc"] = cc_map
else:
@@ -956,6 +1017,7 @@ class ConversationManager:
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()):
@@ -963,7 +1025,8 @@ class ConversationManager:
pending_pushes, ev, conv, profile, confirmed,
"returning", evaluation, evaluated)
sent = await email_service.send_returning_lead_email(
confirmed, cc_info=cc_map)
confirmed, cc_info=cc_map,
config_data=self.runtime.config.data)
else:
await self._collect_push(
pending_pushes, ev, conv, profile, confirmed,
@@ -976,6 +1039,7 @@ class ConversationManager:
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
@@ -993,7 +1057,8 @@ class ConversationManager:
pending_pushes, ev, conv, profile, confirmed,
"correction", evaluation, evaluated)
if await email_service.send_correction_email(
confirmed, count=count, cc_info=cc_map):
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)
@@ -1009,11 +1074,13 @@ class ConversationManager:
) -> None:
"""询盘推送派发(设计 §8): 触发条件=进入发信分支,与 SMTP 成败解耦。
dispatch 内部吞异常(失败只记日志),这里无需再兜。
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)
@@ -1070,13 +1137,17 @@ class ConversationManager:
@staticmethod
def _build_context_reminder(raw_info: dict, latest_msg: str,
history: list[dict] | None = None,
source: str = "") -> str:
source: str = "",
contact_flags: dict | None = None) -> str:
"""Build a context reminder from extracted profile info.
Task 4 词表数据化: 六类轮中提醒话术(boundary/contact_ask/
language_follow/lead_given/cc_guide/email_light)的数据源 =
DEFAULT_WALL.reminders(seed_profiles/jiancai/wall.json);
{shown}/{no_contact_web}/{no_contact_already} 占位由本函数
.format() 回填,值与数据化前逐字等价。"""
.format() 回填,值与数据化前逐字等价。
contact_flags(Task 9): contact 行为开关——cc_completion=False 跳过
cc 确认指引,email_light_ask=False 跳过邮箱轻补;None = 全开
(直调本函数的旧测试语义)。"""
# 职能边界提醒(评审#7: 前置到早退之前,首条消息画像为空也要生效)
boundary_note = ""
if BOUNDARY_TRIGGER_RE.search(latest_msg or ""):
@@ -1107,7 +1178,7 @@ class ConversationManager:
contact_note = DEFAULT_WALL.reminders["contact_ask"]["app"]
else:
contact_note = DEFAULT_WALL.reminders["contact_ask"]["web"].format(
no_contact_web=NO_CONTACT_WEB)
no_contact_web=DEFAULT_WALL.no_contact["web"])
# 语言跟随提醒(Task 11): 与 boundary/contact 提醒同挂载位——
# 首条消息画像为空也要生效(中文客户开场即漂英文是实测重灾区)
@@ -1126,6 +1197,11 @@ class ConversationManager:
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 调用方以同参
@@ -1133,28 +1209,31 @@ class ConversationManager:
# confirmed 块之前: 未落定轮邮箱轻补不注入(cc_settled 串行)。
completion_note, _marks = _contact_completion_note(
raw_info, confirmed, suspect,
lang="zh" if _CJK_RE.search(latest_msg or "") else "en")
lang="zh" if _CJK_RE.search(latest_msg or "") else "en",
email_ask=flags["email_light_ask"])
# Task 4(spec §4 回显确认轮): cc 等待态注入确认句指引——有 e164 用
# 回显模板(逐字 intl),no_ip 用 ASK 追问句;与邮箱轻补 cc_settled
# 串行互斥,两个"逐字使用这句"永不同轮。无计数键: 轮次上限由状态机
# attempts 管理(轮末 transit 递增),提醒每轮等待态都在场。
# P3: 语言路由扩 ES(_cc_lang_of),西语轮注入西语模板。
cc_note = _cc_confirm_note(
cc_note = (_cc_confirm_note(
(raw_info.get("_cc") or {}).get("whatsapp"), _cc_lang_of(latest_msg))
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 5 已 import)——
# 与零号码墙替换句同源,三处"经理主动联系"话术不漂移
# 逐字话术直接引用 DEFAULT_WALL.no_contact["already"](Task 9 收口:
# 视图直取单一事实源)——与零号码墙替换句同源,三处"经理主动联系"
# 话术不漂移
# Task 3 防自锁改写(spec 4.1-6): 原"绝对禁止再索要任何联系方式!"
# 会抑制缺失渠道轻补——只禁已提供过的渠道,缺失渠道放行一次
parts.append(DEFAULT_WALL.reminders["lead_given"]["forbidden"].format(
shown=shown))
parts.append(DEFAULT_WALL.reminders["lead_given"]["respond_script"].format(
no_contact_already=NO_CONTACT_ALREADY))
no_contact_already=DEFAULT_WALL.no_contact["already"]))
if boundary_note:
parts.append(boundary_note)
# Task 3 防自锁改写: 尾部补缺失渠道豁免(与上方改写同因)