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>
This commit is contained in:
@@ -37,7 +37,7 @@
|
||||
- 上游吸收:input-placeholder 可配置化(品牌配置组,public.py 下发,widget/chat data-placeholder 管道);7 个测试文件(test_channel_hygiene / test_chat_rest_ip / test_lead_push_cc / test_cc_integration / test_ip_service_region / test_prompt_static / test_placeholder_static,commit d1aed8e)。
|
||||
- 三不带:blog.html(static/ 无此文件)、Redis(app+scripts 零命中)、旧版 whatsapp-cc-completion(零命中,jc 版 cc_completion.py 取代)。
|
||||
|
||||
**当前能力一览**:多租户引擎(TenantRuntime 隔离边界,engine 不感知 platform);站点模型(tenant_config.Site,一租户多站点覆盖);词表/提示词/品牌全配置化(seed_profiles 出厂包 + platform.db 热加载);Host 路由(host_middleware,未知 Host 404);WS 防串库(租户化 client key + 会话站点标);WS 每 IP 连接上限(conn_limiter,租户级 ws_conn_limit 配置)。
|
||||
**当前能力一览**:多租户引擎(TenantRuntime 隔离边界,engine 不感知 platform);站点模型(tenant_config.Site,一租户多站点覆盖);词表/提示词/品牌全配置化(seed_profiles 出厂包 + 文件级 registry_reload 热加载,Phase 2 升级为 platform.db);Host 路由(host_middleware,未知 Host 404);WS 防串库(租户化 client key + 会话站点标);WS 每 IP 连接上限(conn_limiter,租户级 ws_conn_limit 配置)。
|
||||
|
||||
## Phase 2 清理单(前序审查挂账,集中列此)
|
||||
|
||||
@@ -51,6 +51,8 @@
|
||||
8. **话术含引号/换行的转义**——编辑器批次(`_format_deflection_pool` docstring 有注)。
|
||||
9. **产品决策项**:国名纠正 e164 变化时是否补发更正邮件(上游行为更优);REST/WS 服务端 IP 口径是否统一(出处:上游吸收审查)。
|
||||
10. **avatar 从 seed_profiles 迁 uploads 卷**——上传功能落地时(出处:品牌下发审查)。
|
||||
11. **/health 分级健康**——Phase 4 部署批次(进程存活之外按依赖分级上报,健康检查可区分"进程活"与"依赖通";出处:终审审查 M-3)。
|
||||
12. **PromptProvider.from_dir 的 FileNotFoundError 带路径上下文**——提示词目录缺失时异常信息含实际路径,定位不用猜(出处:终审审查 M-4)。
|
||||
|
||||
## Phase 2 计划编写注意
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
**现状能力**(Phase 1 收尾时点):
|
||||
|
||||
- 多租户引擎:TenantRuntime 隔离边界,engine 包不感知平台层,可整体拎走独立部署。
|
||||
- 站点模型:一租户多站点(品牌/主题/站点级覆盖),词表、提示词、品牌全部配置化(seed_profiles 出厂包 + platform.db 热加载)。
|
||||
- 站点模型:一租户多站点(品牌/主题/站点级覆盖),词表、提示词、品牌全部配置化(seed_profiles 出厂包 + 文件级 registry_reload 热加载,Phase 2 升级为 platform.db)。
|
||||
- Host 路由:按域名解析租户,未知 Host 404;WS 通道租户化客户端 key 防串库,另有限流与每 IP 连接上限。
|
||||
- 引擎行为:cc 区号补全判定树、多语言墙(中/西/葡借道/俄/阿)、dual-contact 分步双要、CC 强制墙、lead 邮件推送。
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ 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
|
||||
@@ -71,6 +72,8 @@ def context_seed_map(config_data: dict | None) -> dict:
|
||||
# - 原则: 误触发低害(多一条提醒),漏触发高危(红线失守),词表偏宽;上线后漏词案例回流补词
|
||||
# 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
|
||||
@@ -97,11 +100,14 @@ LANG_NOTE_ZH = DEFAULT_WALL.reminders["language_follow"]["zh"]
|
||||
LANG_NOTE_ES = DEFAULT_WALL.reminders["language_follow"]["es"]
|
||||
|
||||
|
||||
def _lang_note_for(msg: str) -> str:
|
||||
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 LANG_NOTE_ZH
|
||||
return w.reminders["language_follow"]["zh"]
|
||||
if _ES_FEATURE_RE.search(msg or ""):
|
||||
return LANG_NOTE_ES
|
||||
return w.reminders["language_follow"]["es"]
|
||||
return ""
|
||||
|
||||
|
||||
@@ -157,45 +163,60 @@ def _cc_intl(wa_state: dict | None) -> str:
|
||||
return str((wa_state or {}).get("intl") or cc_completion.format_intl(e164))
|
||||
|
||||
|
||||
def _cc_script(wa_state: dict | None, lang: str) -> str:
|
||||
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 注入/墙替换/测试断言三方同源,统一走本函数。"""
|
||||
(_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 = {"zh": _CC_ECHO_ZH, "es": _CC_ECHO_ES}.get(lang, _CC_ECHO_EN)
|
||||
return echo.format(intl=intl)
|
||||
return {"zh": _CC_ASK_ZH, "es": _CC_ASK_ES}.get(lang, _CC_ASK_EN)
|
||||
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) -> str:
|
||||
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)。"""
|
||||
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)
|
||||
return {"zh": _CC_WALL_ZH, "es": _CC_WALL_ES}.get(lang, _CC_WALL_EN)
|
||||
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) -> str:
|
||||
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 词表数据化: 指引话术数据源 = DEFAULT_WALL.reminders.cc_guide
|
||||
({intl}/{cc_script} 占位由本函数 .format() 回填,值逐字等价)。"""
|
||||
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 DEFAULT_WALL.reminders["cc_guide"]["echo"].format(
|
||||
intl=intl, cc_script=_cc_script(wa_state, lang))
|
||||
return DEFAULT_WALL.reminders["cc_guide"]["ask"].format(
|
||||
cc_script=_cc_script(wa_state, lang))
|
||||
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 判定树设计收编)。
|
||||
纯函数,返回(注入文案,计数键)。
|
||||
@@ -208,6 +229,9 @@ def _contact_completion_note(
|
||||
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 "", []
|
||||
@@ -222,7 +246,9 @@ def _contact_completion_note(
|
||||
return "", []
|
||||
if not cc_completion.cc_settled((info.get("_cc") or {}).get("whatsapp")):
|
||||
return "", [] # whatsapp 确认流未落定: 邮箱轻补串行等待
|
||||
return (_EMAIL_NOTE_ZH if lang == "zh" else _EMAIL_NOTE_EN), ["_email_asked"]
|
||||
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:
|
||||
@@ -369,6 +395,11 @@ class ConversationManager:
|
||||
# 的 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,
|
||||
@@ -499,8 +530,8 @@ class ConversationManager:
|
||||
|
||||
context_reminder = self._build_context_reminder(
|
||||
effective_raw, content, history, source=conv.source.value,
|
||||
contact_flags=self._contact_flags)
|
||||
if CONTACT_ASK_TRIGGER_RE.search(content or ""):
|
||||
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 状态同事务落库——
|
||||
@@ -512,7 +543,7 @@ class ConversationManager:
|
||||
_, 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"])
|
||||
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:
|
||||
@@ -577,12 +608,13 @@ class ConversationManager:
|
||||
pending += token
|
||||
complete, pending = split_sentences(pending)
|
||||
for sentence in complete:
|
||||
if violates_boundary(sentence):
|
||||
if violates_boundary(sentence, wall=self._wall):
|
||||
violated = True
|
||||
dirty = sentence
|
||||
violation = "boundary"
|
||||
break
|
||||
if no_contact_value(sentence, allow_number=cc_allow_number):
|
||||
if no_contact_value(sentence, allow_number=cc_allow_number,
|
||||
wall=self._wall):
|
||||
violated = True
|
||||
dirty = sentence
|
||||
violation = "no_contact"
|
||||
@@ -590,7 +622,8 @@ class ConversationManager:
|
||||
# Task 5b 再索要墙: 已留资时索要句静默丢弃(丢句不掐断,
|
||||
# 其余句子照常流出——软于两墙的掐断+替换);
|
||||
# 渠道精确化: 只丢索要已在手渠道的句子
|
||||
if has_contact and asks_contact(sentence, _confirmed_channels):
|
||||
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
|
||||
@@ -604,15 +637,17 @@ class ConversationManager:
|
||||
if violated:
|
||||
break # 提前掐断生成,省 token
|
||||
if not violated and pending:
|
||||
if violates_boundary(pending):
|
||||
if violates_boundary(pending, wall=self._wall):
|
||||
violated = True
|
||||
dirty = pending
|
||||
violation = "boundary"
|
||||
elif no_contact_value(pending, allow_number=cc_allow_number):
|
||||
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):
|
||||
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])
|
||||
@@ -636,21 +671,26 @@ class ConversationManager:
|
||||
# NO_CONTACT_ALREADY——GLM 有 3/3 回显倾向,该路径概率高,
|
||||
# 客户该收到确认/追问而非"稍后联系"虚假承诺;
|
||||
# 价格墙路径(violation=="boundary")不变
|
||||
closing = _cc_wall_template(_wa_cc, _cc_lang_of(content))
|
||||
closing = _cc_wall_template(_wa_cc, _cc_lang_of(content),
|
||||
wall=self._wall)
|
||||
else:
|
||||
# Task 9 收口: 视图直取 DEFAULT_WALL.no_contact(单一
|
||||
# 事实源),不再经 boundary_filter 的模块级常量转引
|
||||
closing = (DEFAULT_WALL.no_contact["already"] if has_contact
|
||||
else (DEFAULT_WALL.no_contact["app"]
|
||||
# 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 DEFAULT_WALL.no_contact["web"]))
|
||||
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) if emitted
|
||||
closing = (pivot_for(content, has_contact, wall=self._wall)
|
||||
if emitted
|
||||
else deflection_for(content, _boundary_rotate(),
|
||||
has_contact=has_contact))
|
||||
has_contact=has_contact,
|
||||
wall=self._wall))
|
||||
# Minor-5(审查修复): 已流出前缀与替换句之间补一个空格分隔
|
||||
# ("...xxxx? Got it —"——英文衔接无空格会粘连);CC/触发轮缓冲
|
||||
# 未流出,emitted 恒空,天然不受影响。空格随 closing 一起流出,
|
||||
@@ -690,7 +730,8 @@ class ConversationManager:
|
||||
logger.warning(
|
||||
"CC enforcement wall replaced reply (conv=%s): %r",
|
||||
conv.id, cc_full[:60])
|
||||
cc_template = _cc_wall_template(_wa_cc, cc_lang)
|
||||
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]}
|
||||
@@ -702,7 +743,7 @@ 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)
|
||||
fallback = DEFAULT_WALL.no_contact["already"]
|
||||
fallback = self._wall.no_contact["already"]
|
||||
for i in range(0, len(fallback), 40):
|
||||
yield {"type": "stream_chunk",
|
||||
"content": fallback[i:i + 40]}
|
||||
@@ -1141,24 +1182,29 @@ class ConversationManager:
|
||||
def _build_context_reminder(raw_info: dict, latest_msg: str,
|
||||
history: list[dict] | None = None,
|
||||
source: str = "",
|
||||
contact_flags: dict | None = None) -> 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)的数据源 =
|
||||
DEFAULT_WALL.reminders(seed_profiles/jiancai/wall.json);
|
||||
墙 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 BOUNDARY_TRIGGER_RE.search(latest_msg or ""):
|
||||
if BUDGET_TRIGGER_RE.search(latest_msg or ""):
|
||||
if w.triggers["boundary"].search(latest_msg or ""):
|
||||
if w.triggers["budget"].search(latest_msg or ""):
|
||||
# 评审#9: 预算求推荐 → 预算路径(推荐+反问+引导),不一刀切要号
|
||||
boundary_note = DEFAULT_WALL.reminders["boundary"]["budget"]
|
||||
boundary_note = w.reminders["boundary"]["budget"]
|
||||
else:
|
||||
boundary_note = DEFAULT_WALL.reminders["boundary"]["normal"]
|
||||
boundary_note = w.reminders["boundary"]["normal"]
|
||||
|
||||
# 联系方式状态前移(供 boundary/contact 提醒共用判定)
|
||||
contacts_all = normalize_contacts(raw_info.get("联系方式"))
|
||||
@@ -1169,23 +1215,23 @@ class ConversationManager:
|
||||
# 冒烟实证: 抽象指令("用App话术")GLM 会无视并背 prompt 里更显眼的 web 脚本——
|
||||
# 提醒必须携带该分支的逐字话术,不让模型自己挑。
|
||||
contact_note = ""
|
||||
if CONTACT_ASK_TRIGGER_RE.search(latest_msg or ""):
|
||||
if w.triggers["contact_ask"].search(latest_msg or ""):
|
||||
if source == "app":
|
||||
if confirmed or suspect:
|
||||
# 已留资: 索要我方联系方式 → 逐字"经理主动联系"版,
|
||||
# 与"禁止再索要"提醒不再打架
|
||||
# Task 3 收尾: 第三处禁令同步改写(禁令集完整)——与前两处
|
||||
# 同语义,只禁已提供过的渠道,缺失渠道放行轻补一次
|
||||
contact_note = DEFAULT_WALL.reminders["contact_ask"]["app_already"]
|
||||
contact_note = w.reminders["contact_ask"]["app_already"]
|
||||
else:
|
||||
contact_note = DEFAULT_WALL.reminders["contact_ask"]["app"]
|
||||
contact_note = w.reminders["contact_ask"]["app"]
|
||||
else:
|
||||
contact_note = DEFAULT_WALL.reminders["contact_ask"]["web"].format(
|
||||
no_contact_web=DEFAULT_WALL.no_contact["web"])
|
||||
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)
|
||||
lang_note = _lang_note_for(latest_msg, wall=wall)
|
||||
|
||||
if not raw_info:
|
||||
# 空画像:仅返回边界/联系/语言提醒(可能为空串)
|
||||
@@ -1213,7 +1259,7 @@ class ConversationManager:
|
||||
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"])
|
||||
email_ask=flags["email_light_ask"], wall=wall)
|
||||
|
||||
# Task 4(spec §4 回显确认轮): cc 等待态注入确认句指引——有 e164 用
|
||||
# 回显模板(逐字 intl),no_ip 用 ASK 追问句;与邮箱轻补 cc_settled
|
||||
@@ -1221,30 +1267,31 @@ class ConversationManager:
|
||||
# attempts 管理(轮末 transit 递增),提醒每轮等待态都在场。
|
||||
# P3: 语言路由扩 ES(_cc_lang_of),西语轮注入西语模板。
|
||||
cc_note = (_cc_confirm_note(
|
||||
(raw_info.get("_cc") or {}).get("whatsapp"), _cc_lang_of(latest_msg))
|
||||
(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())
|
||||
# 逐字话术直接引用 DEFAULT_WALL.no_contact["already"](Task 9 收口:
|
||||
# 逐字话术直接引用墙 no_contact["already"](Task 9 收口:
|
||||
# 视图直取单一事实源)——与零号码墙替换句同源,三处"经理主动联系"
|
||||
# 话术不漂移
|
||||
# Task 3 防自锁改写(spec 4.1-6): 原"绝对禁止再索要任何联系方式!"
|
||||
# 会抑制缺失渠道轻补——只禁已提供过的渠道,缺失渠道放行一次
|
||||
parts.append(DEFAULT_WALL.reminders["lead_given"]["forbidden"].format(
|
||||
parts.append(w.reminders["lead_given"]["forbidden"].format(
|
||||
shown=shown))
|
||||
parts.append(DEFAULT_WALL.reminders["lead_given"]["respond_script"].format(
|
||||
no_contact_already=DEFAULT_WALL.no_contact["already"]))
|
||||
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(DEFAULT_WALL.reminders["lead_given"]["boundary_tail"])
|
||||
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(DEFAULT_WALL.reminders["lead_given"]["suspect"].format(
|
||||
parts.append(w.reminders["lead_given"]["suspect"].format(
|
||||
shown=shown))
|
||||
if boundary_note:
|
||||
parts.append(boundary_note)
|
||||
|
||||
@@ -2,7 +2,6 @@ fastapi>=0.115.0
|
||||
uvicorn[standard]>=0.34.0
|
||||
sqlalchemy[asyncio]>=2.0.0
|
||||
aiosqlite>=0.20.0
|
||||
alembic>=1.14.0
|
||||
zhipuai>=2.1.5
|
||||
aiosmtplib>=3.0.0
|
||||
httpx>=0.27.0
|
||||
|
||||
@@ -17,6 +17,10 @@
|
||||
"welcome": "Hey! What kind of car are you looking for?",
|
||||
"topics": ["Get Quotation", "Distributor Cooperation"]
|
||||
},
|
||||
"email": {
|
||||
"sender_name": "XYH",
|
||||
"notify_emails": ["18339827479@163.com"]
|
||||
},
|
||||
"push": {
|
||||
"prefix": "ai-kefu"
|
||||
},
|
||||
|
||||
@@ -26,6 +26,10 @@
|
||||
"welcome": "What kind of piping project are you working on?",
|
||||
"topics": ["Get Quotation", "Pipe Specifications", "Delivery & Shipping"]
|
||||
},
|
||||
"email": {
|
||||
"sender_name": "HESUPIPE",
|
||||
"notify_emails": ["hesu@hesuu.com"]
|
||||
},
|
||||
"push": {
|
||||
"prefix": "ai-kefu-jc"
|
||||
},
|
||||
|
||||
@@ -107,6 +107,22 @@ def build_jiancai_runtime():
|
||||
return _JIANCAI_RUNTIME
|
||||
|
||||
|
||||
_AUTO_RUNTIME = None
|
||||
|
||||
|
||||
def build_auto_runtime():
|
||||
"""(非夹具)auto TenantRuntime——C-1 终审回归锁用(两租户墙差异断言),
|
||||
纪律同 build_jiancai_runtime: 只走加载期,无 engine 无句柄,
|
||||
模块级缓存单例跨测试复用。"""
|
||||
global _AUTO_RUNTIME
|
||||
if _AUTO_RUNTIME is None:
|
||||
reg = TenantRegistry.load_from_dir(
|
||||
SEED_DIR, data_dir=REPO_ROOT / "tenants")
|
||||
_AUTO_RUNTIME = next(
|
||||
rt for rt in reg.runtimes() if rt.config.slug == "auto")
|
||||
return _AUTO_RUNTIME
|
||||
|
||||
|
||||
# Task 7: app_client 默认域名——既有测试不带 host 头,默认落 jiancai
|
||||
# (与 5b 之前 default_runtime 兜底的语义一致,既有断言零改动)
|
||||
JIANCAI_HOST = "chat2.agent.hnxinyouhe.com"
|
||||
|
||||
@@ -103,6 +103,24 @@ def test_send_lead_email_uses_tenant_sender_and_recipients(monkeypatch):
|
||||
assert str(captured["msg"]["To"]) == "x@t.com, y@t.com"
|
||||
|
||||
|
||||
def test_seed_tenants_ship_email_group(monkeypatch):
|
||||
"""I-1(终审): 两租户 seed 自带 email 组——resolve_email_settings 各自
|
||||
返回正确收件人/发件名(auto: XYH→163 信箱; jiancai: HESUPIPE→hesuu.com),
|
||||
不再依赖 env NOTIFY_EMAIL 平台兜底(patch 成异值证明 seed 槽胜出)。
|
||||
收件人取值依据 brand_values.json: auto=notify_email 环境实际值,
|
||||
jiancai=品牌邮箱(notify_email 记录的是 notify@example.com 占位)。
|
||||
From 头渲染时外层再包【】(send_* 的 msg["From"] 拼法),seed 存裸名。"""
|
||||
monkeypatch.setattr(settings, "NOTIFY_EMAIL", "env-notify@x.com")
|
||||
from tests.conftest import build_auto_runtime
|
||||
au = email_service.resolve_email_settings(build_auto_runtime().config.data)
|
||||
jc_r = email_service.resolve_email_settings(
|
||||
build_jiancai_runtime().config.data)
|
||||
assert au["sender_name"] == "XYH"
|
||||
assert au["notify_emails"] == ["18339827479@163.com"]
|
||||
assert jc_r["sender_name"] == "HESUPIPE"
|
||||
assert jc_r["notify_emails"] == ["hesu@hesuu.com"]
|
||||
|
||||
|
||||
# ---- push ----------------------------------------------------------------
|
||||
|
||||
def _patch_push_env(monkeypatch):
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
"""C-1(终审审查)回归锁: 墙的执行侧必须走租户管道。
|
||||
|
||||
修复前缺陷: conversation.py 执行侧(过滤/替换/提醒)全走模块级
|
||||
DEFAULT_WALL(jiancai 出厂),runtime.compiled_wall 全库只有 AIService 的
|
||||
$DEFLECTION_POOL 一处消费——auto 租户会话实际跑的是 jc 墙(多拦/替换
|
||||
文案串租户)。本文件用两租户真 runtime + 脚本化假 AI 端到端断言:
|
||||
|
||||
- 价格墙: jc 独有 pattern([€£]\\s?\\d,auto 词表无此组)命中的句子在
|
||||
jiancai 被拦+替换(收尾来自 jc pivots 池),在 auto 放行逐字流出;
|
||||
- 零号码墙: 同一邮箱违规句两租户都拦,替换句精确等于各自墙的
|
||||
no_contact["web"](jc "within 12 hours" vs auto "replies fast!");
|
||||
- 提醒侧: contact_ask 触发消息的注入 context 携带各自墙的
|
||||
no_contact["web"](web 模板占位 {no_contact_web} 回填)。
|
||||
"""
|
||||
import asyncio
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.engine.models import Message, MessageRole
|
||||
from app.engine.services.conversation import ConversationManager
|
||||
from tests.conftest import build_auto_runtime, build_jiancai_runtime
|
||||
|
||||
# 判别句选取依据(两墙 diff 实证,勿随手改文案——判别力依赖词表差异):
|
||||
# - PRICE_REPLY 撞 jc 独有的 [€£]\s?\d 组(auto 词表无),零号码两墙都不拦;
|
||||
# - EMAIL_REPLY 撞零号码墙 any_email(两墙同拦,替换文案不同);
|
||||
# - 末句无尾随空白 → 以 pending 尾巴形态裁决,走同一条最终检查路径。
|
||||
PRICE_REPLY = "Thanks for asking. The total is €500 for one set."
|
||||
EMAIL_REPLY = "Sure, you can also email me at sales@example.com anytime."
|
||||
USER_MSG = "do you ship to Nigeria?"
|
||||
|
||||
|
||||
class _ScriptedAI:
|
||||
"""固定回复脚本桩: 小块 token 流出 reply(走真实 split_sentences/
|
||||
逐句门控路径),捕获注入的 context(提醒侧断言用)。"""
|
||||
|
||||
def __init__(self, reply):
|
||||
self._reply = reply
|
||||
self.captured_context = None
|
||||
|
||||
async def stream_chat(self, messages, context=""):
|
||||
self.captured_context = context
|
||||
for i in range(0, len(self._reply), 7):
|
||||
yield self._reply[i:i + 7]
|
||||
|
||||
async def extract_info(self, messages, existing_info):
|
||||
return {}
|
||||
|
||||
async def evaluate(self, profile, recent_messages):
|
||||
return {"intent_level": "pending", "reasoning": "",
|
||||
"contact_info": {}, "info_sufficient": False}
|
||||
|
||||
|
||||
def _send(async_db, runtime, reply, user_msg=USER_MSG):
|
||||
"""一轮会话 → (落库的助手回复, 注入 context)。落库文本与流出统一
|
||||
(stream_process_message 契约),取 Message 表即可代表客户所见。"""
|
||||
|
||||
async def run():
|
||||
async with async_db() as db:
|
||||
ai = _ScriptedAI(reply)
|
||||
mgr = ConversationManager(db, runtime=runtime, ai_service=ai)
|
||||
conv = await mgr.create_conversation(source=None) # standalone
|
||||
async for _ in mgr.stream_process_message(conv.session_id,
|
||||
user_msg):
|
||||
pass
|
||||
msgs = (await db.execute(
|
||||
select(Message).where(Message.conversation_id == conv.id)
|
||||
.order_by(Message.created_at))).scalars().all()
|
||||
replies = [m.content for m in msgs
|
||||
if m.role == MessageRole.assistant]
|
||||
return (replies[-1] if replies else ""), ai.captured_context
|
||||
return asyncio.run(run())
|
||||
|
||||
|
||||
def test_price_wall_pattern_is_tenant_scoped(async_db):
|
||||
"""jc 独有 [€£] 价格 pattern: jiancai 拦截替换(€ 数字不流出,收尾 =
|
||||
jc pivots 池首句);auto 放行逐字流出——同一引擎代码,墙随租户。"""
|
||||
jc = build_jiancai_runtime()
|
||||
au = build_auto_runtime()
|
||||
# 判别前提(词表趋同护栏): 该句必须只撞 jc 墙,否则本测试失去判别力
|
||||
from app.engine.services.boundary_filter import violates_boundary
|
||||
assert violates_boundary(PRICE_REPLY, wall=jc.compiled_wall) is True
|
||||
assert violates_boundary(PRICE_REPLY, wall=au.compiled_wall) is False
|
||||
|
||||
jc_reply, _ = _send(async_db, jc, PRICE_REPLY)
|
||||
assert "€500" not in jc_reply
|
||||
# 已流出干净前缀("Thanks for asking.")→ 违规后接 pivot 过渡句收尾
|
||||
assert jc_reply.endswith(jc.compiled_wall.pivots["en"][0])
|
||||
|
||||
au_reply, _ = _send(async_db, au, PRICE_REPLY)
|
||||
assert au_reply == PRICE_REPLY # auto 墙无此 pattern → 整句放行
|
||||
|
||||
|
||||
def test_no_contact_replacement_copy_from_tenant_pool(async_db):
|
||||
"""零号码墙(两租户同拦同一邮箱句): 替换句精确等于各自墙的
|
||||
no_contact["web"](standalone 源+未留资 → web 变体)。修复前 auto
|
||||
会话收的是 jc 文案(12 hours),本测试即失败。"""
|
||||
jc = build_jiancai_runtime()
|
||||
au = build_auto_runtime()
|
||||
assert (jc.compiled_wall.no_contact["web"]
|
||||
!= au.compiled_wall.no_contact["web"]) # 判别前提
|
||||
|
||||
jc_reply, _ = _send(async_db, jc, EMAIL_REPLY)
|
||||
au_reply, _ = _send(async_db, au, EMAIL_REPLY)
|
||||
assert jc_reply == jc.compiled_wall.no_contact["web"]
|
||||
assert au_reply == au.compiled_wall.no_contact["web"]
|
||||
|
||||
|
||||
def test_contact_ask_reminder_uses_tenant_wall_text(async_db):
|
||||
"""提醒侧同锁: contact_ask 触发消息的注入 context 携带各自墙的
|
||||
no_contact["web"] 文案(web 模板 {no_contact_web} 回填),互不渗漏。"""
|
||||
jc = build_jiancai_runtime()
|
||||
au = build_auto_runtime()
|
||||
jc_web = jc.compiled_wall.no_contact["web"]
|
||||
au_web = au.compiled_wall.no_contact["web"]
|
||||
|
||||
_, jc_ctx = _send(async_db, jc, "ok.", user_msg="how can I contact you?")
|
||||
_, au_ctx = _send(async_db, au, "ok.", user_msg="how can I contact you?")
|
||||
assert jc_web in jc_ctx
|
||||
assert au_web in au_ctx
|
||||
assert au_web not in jc_ctx
|
||||
assert jc_web not in au_ctx
|
||||
Reference in New Issue
Block a user