Files
ai-kefu-admin/tests/test_wall_execution_tenant.py
浅念 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

122 lines
5.6 KiB
Python

"""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