9ad02a778f
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>
340 lines
15 KiB
Python
340 lines
15 KiB
Python
"""Task 9: 服务层配置接线——DEFAULT_CONFIG 九组 → 真实消费方的缺口测试。
|
|
|
|
既有测试 monkeypatch 的都是 settings(环境层): 配置组没人读时它们照样绿,
|
|
两租户联调只会静默错配。本文件从 TenantConfig 出发逐组逼出消费点:
|
|
|
|
- email: resolve_email_settings 租户段优先, 空槽回退 env settings;
|
|
send_* 消费 resolved 产物(From/To 走租户发件名/通知列表)
|
|
- push: resolve_push_settings 全字段(租户段优先, 空槽回退 env)
|
|
- engine.rate_limit_per_hour + rate_limit 桶键含租户维度
|
|
- ai: resolve_ai_settings 覆盖生效; api_key/base_url/model 空槽回退 env
|
|
- contact: cc_completion/email_light_ask 行为开关在引擎级真实门控
|
|
- misc.name_to_region_extras ⊕ cc_completion.NAME_TO_REGION
|
|
- engine.context_seed_keys 种子白名单(create_conversation 消费)
|
|
"""
|
|
import asyncio
|
|
from dataclasses import replace
|
|
|
|
from sqlalchemy import select
|
|
|
|
from app.engine.config import settings
|
|
from app.engine.services import email_service, lead_push_service
|
|
from app.engine.services.ai_service import resolve_ai_settings
|
|
from app.engine.services.cc_completion import name_to_region
|
|
from app.engine.services.conversation import (
|
|
ConversationManager, context_seed_map, resolve_contact_flags,
|
|
)
|
|
from app.engine.services.rate_limit import _reset_for_test, check
|
|
from app.platform.tenant_config import TenantConfig
|
|
from tests.conftest import build_jiancai_runtime
|
|
|
|
|
|
def _cfg(snapshot):
|
|
return TenantConfig.from_snapshot("t1", "T", snapshot)
|
|
|
|
|
|
# ---- email ---------------------------------------------------------------
|
|
|
|
def test_email_reads_tenant_smtp(monkeypatch):
|
|
"""租户段优先于全局 settings: smtp/sender_name/notify_emails 全量覆盖。"""
|
|
monkeypatch.setattr(settings, "SMTP_HOST", "env-host")
|
|
monkeypatch.setattr(settings, "SMTP_USER", "env@x.com")
|
|
r = email_service.resolve_email_settings(_cfg({"email": {
|
|
"smtp": {"host": "t.smtp", "port": 587, "user": "t@x.com",
|
|
"password": "t-pass"},
|
|
"sender_name": "Auto Co",
|
|
"notify_emails": ["a@t.com", "b@t.com"],
|
|
}}).data)
|
|
assert r == {
|
|
"host": "t.smtp", "port": 587, "user": "t@x.com",
|
|
"password": "t-pass", "sender_name": "Auto Co",
|
|
"notify_emails": ["a@t.com", "b@t.com"],
|
|
}
|
|
|
|
|
|
def test_email_falls_back_to_env_settings(monkeypatch):
|
|
"""smtp=None(缺省/显式清空)→ env SMTP_*; 发件名空 → 平台默认
|
|
(旧硬编码【HESUPIPE】); notify_emails 空列表 → env NOTIFY_EMAIL。"""
|
|
for k, v in (("SMTP_HOST", "env-host"), ("SMTP_PORT", 465),
|
|
("SMTP_USER", "env@x.com"), ("SMTP_PASS", "env-pass"),
|
|
("NOTIFY_EMAIL", "notify@env.com")):
|
|
monkeypatch.setattr(settings, k, v)
|
|
r = email_service.resolve_email_settings(_cfg({}).data)
|
|
assert r == {
|
|
"host": "env-host", "port": 465, "user": "env@x.com",
|
|
"password": "env-pass", "sender_name": "HESUPIPE",
|
|
"notify_emails": ["notify@env.com"],
|
|
}
|
|
|
|
|
|
def test_email_partial_smtp_override(monkeypatch):
|
|
"""smtp 段内逐键回退: 只给 host, 其余(user/password/port)沿用 env。"""
|
|
monkeypatch.setattr(settings, "SMTP_HOST", "env-host")
|
|
monkeypatch.setattr(settings, "SMTP_PORT", 465)
|
|
monkeypatch.setattr(settings, "SMTP_USER", "env@x.com")
|
|
monkeypatch.setattr(settings, "SMTP_PASS", "env-pass")
|
|
r = email_service.resolve_email_settings(
|
|
_cfg({"email": {"smtp": {"host": "t.smtp"}}}).data)
|
|
assert (r["host"], r["port"], r["user"], r["password"]) == (
|
|
"t.smtp", 465, "env@x.com", "env-pass")
|
|
|
|
|
|
def test_send_lead_email_uses_tenant_sender_and_recipients(monkeypatch):
|
|
"""send_* 消费接线: From = 租户发件名, To = 租户通知列表。"""
|
|
captured = {}
|
|
|
|
async def fake_send(msg, cfg=None):
|
|
captured["msg"] = msg
|
|
return True
|
|
|
|
monkeypatch.setattr(email_service, "_send", fake_send)
|
|
cfg = _cfg({"email": {
|
|
"smtp": {"host": "h", "user": "t@x.com"},
|
|
"sender_name": "ACME",
|
|
"notify_emails": ["x@t.com", "y@t.com"],
|
|
}})
|
|
ok = asyncio.run(email_service.send_lead_email(
|
|
customer_profile={}, contact_info={"email": "c@x.com"},
|
|
reasoning="", conversation_messages=[],
|
|
config_data=cfg.data,
|
|
))
|
|
assert ok is True
|
|
assert str(captured["msg"]["From"]) == "【ACME】 <t@x.com>"
|
|
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):
|
|
"""env PUSH_* 全量 patch——数值刻意取与出厂不同的值(7.5/5/2.0):
|
|
出厂哨兵 None 不参与取值,回退是否真实生效由此可判(审查 Important-1,
|
|
修复前 env patch 成出厂同值 10/3/1.0 时数值断言恒真)。"""
|
|
monkeypatch.setattr(settings, "PUSH_ENABLED", True)
|
|
monkeypatch.setattr(settings, "PUSH_API_URL", "env-url")
|
|
monkeypatch.setattr(settings, "PUSH_API_KEY", "env-key")
|
|
monkeypatch.setattr(settings, "PUSH_EXTERNAL_KEY_PREFIX", "env-pfx")
|
|
monkeypatch.setattr(settings, "PUSH_TIMEOUT_SECONDS", 7.5)
|
|
monkeypatch.setattr(settings, "PUSH_MAX_ATTEMPTS", 5)
|
|
monkeypatch.setattr(settings, "PUSH_RETRY_BACKOFF_BASE", 2.0)
|
|
|
|
|
|
def test_push_reads_tenant_section(monkeypatch):
|
|
"""PUSH 组全部字段: 租户段给了就用租户的(显式数值优先于 env——
|
|
env 已 patch 成 7.5/5/2.0,断言 5/4/0.5 才证明读的是租户槽)。"""
|
|
_patch_push_env(monkeypatch)
|
|
r = lead_push_service.resolve_push_settings(_cfg({"push": {
|
|
"enabled": True, "url": "https://t.example/push", "api_key": "t-key",
|
|
"prefix": "t-pfx", "timeout": 5, "max_attempts": 4,
|
|
"retry_backoff_base": 0.5,
|
|
}}).data)
|
|
assert r == {
|
|
"enabled": True, "url": "https://t.example/push", "api_key": "t-key",
|
|
"prefix": "t-pfx", "timeout": 5, "max_attempts": 4,
|
|
"retry_backoff_base": 0.5,
|
|
}
|
|
# 租户显式数值优先于 env(哨兵化后 falsy-truthy 争议不再依赖巧合)
|
|
assert (r["timeout"], r["max_attempts"], r["retry_backoff_base"]) == (
|
|
5, 4, 0.5)
|
|
|
|
|
|
def test_push_empty_section_falls_back_to_env(monkeypatch):
|
|
"""空槽(默认 False/空串/数值 None 哨兵)视同未设置 → 回退 env PUSH_*。"""
|
|
_patch_push_env(monkeypatch)
|
|
r = lead_push_service.resolve_push_settings(_cfg({}).data)
|
|
assert r == {
|
|
"enabled": True, "url": "env-url", "api_key": "env-key",
|
|
"prefix": "env-pfx", "timeout": 7.5, "max_attempts": 5,
|
|
"retry_backoff_base": 2.0,
|
|
}
|
|
|
|
|
|
# ---- rate limit ----------------------------------------------------------
|
|
|
|
def test_rate_limit_key_includes_tenant():
|
|
"""桶键 = (tenant, ip, device): 同 ip+device 在两租户不共享预算;
|
|
limit 读 config.engine.rate_limit_per_hour。"""
|
|
_reset_for_test()
|
|
for _ in range(3):
|
|
assert check("tenant-a", "1.2.3.4", "dev1", max_requests=3) is True
|
|
assert check("tenant-a", "1.2.3.4", "dev1", max_requests=3) is False
|
|
assert check("tenant-b", "1.2.3.4", "dev1", max_requests=3) is True
|
|
|
|
limit = _cfg({"engine": {"rate_limit_per_hour": 2}})\
|
|
.data["engine"]["rate_limit_per_hour"]
|
|
assert check("t", "9.9.9.9", "d", max_requests=limit) is True
|
|
assert check("t", "9.9.9.9", "d", max_requests=limit) is True
|
|
assert check("t", "9.9.9.9", "d", max_requests=limit) is False
|
|
|
|
|
|
# ---- AI ------------------------------------------------------------------
|
|
|
|
def test_ai_settings_override_and_fallback(monkeypatch):
|
|
monkeypatch.setattr(settings, "ZHIPU_API_KEY", "env-key")
|
|
monkeypatch.setattr(settings, "ZHIPU_BASE_URL", "env-url")
|
|
monkeypatch.setattr(settings, "AI_MODEL", "glm-4-plus")
|
|
assert resolve_ai_settings(_cfg({"ai": {
|
|
"api_key": "t-key", "base_url": "t-url", "model": "glm-4-air",
|
|
}}).data) == {
|
|
"provider": "glm", "api_key": "t-key", "base_url": "t-url",
|
|
"model": "glm-4-air",
|
|
}
|
|
# 未覆盖回退 env: api_key/base_url 空槽(None)→ env;
|
|
# model 出厂默认值(glm-4-flash)视同未设置 → env AI_MODEL
|
|
r = resolve_ai_settings(_cfg({}).data)
|
|
assert r == {"provider": "glm", "api_key": "env-key",
|
|
"base_url": "env-url", "model": "glm-4-plus"}
|
|
|
|
|
|
# ---- contact 行为开关 ------------------------------------------------------
|
|
|
|
def test_contact_toggles_resolve():
|
|
assert resolve_contact_flags(_cfg({}).data) == {
|
|
"cc_completion": True, "email_light_ask": True}
|
|
assert resolve_contact_flags(_cfg({"contact": {
|
|
"cc_completion": False, "email_light_ask": False}}).data) == {
|
|
"cc_completion": False, "email_light_ask": False}
|
|
|
|
|
|
def _runtime_with(snapshot):
|
|
"""jiancai 运行时 + 覆盖配置段(共享加载期产物, 无 engine 无句柄)。"""
|
|
cfg = TenantConfig.from_snapshot("jiancai", "T", snapshot)
|
|
return replace(build_jiancai_runtime(), config=cfg)
|
|
|
|
|
|
class _PassThroughAI:
|
|
"""确定性桩: 不拦截不抽取, 只回固定句。"""
|
|
|
|
async def stream_chat(self, messages, context=""):
|
|
for token in ("ok ", "reply"):
|
|
yield token
|
|
|
|
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_and_get_raw(async_db, runtime, text):
|
|
async def run():
|
|
async with async_db() as db:
|
|
mgr = ConversationManager(db, runtime=runtime,
|
|
ai_service=_PassThroughAI())
|
|
conv = await mgr.create_conversation(source=None)
|
|
async for _ in mgr.stream_process_message(conv.session_id, text):
|
|
pass
|
|
return conv.id
|
|
cid = asyncio.run(run())
|
|
|
|
async def fetch():
|
|
from app.engine.models import CustomerProfile
|
|
async with async_db() as db:
|
|
profile = (await db.execute(select(CustomerProfile).where(
|
|
CustomerProfile.conversation_id == cid))).scalar_one()
|
|
return dict(profile.raw_info or {})
|
|
return asyncio.run(fetch())
|
|
|
|
|
|
def test_cc_completion_toggle_gates_engine(async_db):
|
|
"""cc_completion=False: 轮前判定树不建 _cc(裸号无状态机痕迹);
|
|
True(默认): 照建。"""
|
|
raw_off = _send_and_get_raw(
|
|
async_db, _runtime_with({"contact": {"cc_completion": False}}),
|
|
"my whatsapp is 0501234567")
|
|
assert "_cc" not in raw_off
|
|
assert raw_off.get("联系方式", {}).get("whatsapp") == "0501234567"
|
|
|
|
raw_on = _send_and_get_raw(async_db, build_jiancai_runtime(),
|
|
"my whatsapp is 0501234567")
|
|
assert raw_on.get("_cc", {}).get("whatsapp", {}).get("canon") == "0501234567"
|
|
|
|
|
|
def test_email_light_ask_toggle_gates_engine(async_db):
|
|
"""email_light_ask=False: 一次性计数键 _email_asked 不写;
|
|
True(默认): 写。"""
|
|
raw_off = _send_and_get_raw(
|
|
async_db, _runtime_with({"contact": {"email_light_ask": False}}),
|
|
"my whatsapp is +966501234567")
|
|
assert "_email_asked" not in raw_off
|
|
|
|
raw_on = _send_and_get_raw(async_db, build_jiancai_runtime(),
|
|
"my whatsapp is +966501234567")
|
|
assert raw_on.get("_email_asked") is True
|
|
|
|
|
|
# ---- 映射表 / 种子键 -------------------------------------------------------
|
|
|
|
def test_name_to_region_extras_merged():
|
|
"""misc.name_to_region_extras ⊕ NAME_TO_REGION: extras 可扩表也可覆盖。"""
|
|
extras = {"德意志": "DE", "沙特": "QA"}
|
|
assert name_to_region("德意志") is None # 基础表无此键
|
|
assert name_to_region("德意志", extras=extras) == "DE"
|
|
assert name_to_region("沙特", extras=extras) == "QA" # extras 覆盖基础表
|
|
assert name_to_region("中国", extras=extras) == "CN" # 基础表照常可用
|
|
assert name_to_region("SAUDI ARABIA", extras=extras) == "SA"
|
|
|
|
|
|
def test_chat_rest_seed_keys_from_config():
|
|
"""种子键白名单 = context_seed_map 映射的键集合。"""
|
|
cfg = _cfg({"engine": {"context_seed_keys": {
|
|
"vehicle_name": "app_vehicle_name", "vehicle_id": "app_vehicle_id"}}})
|
|
assert set(context_seed_map(cfg.data)) == {"vehicle_name", "vehicle_id"}
|
|
# jiancai 出厂 = 旧硬编码白名单的配置化形态
|
|
assert set(context_seed_map(build_jiancai_runtime().config.data)) == {
|
|
"product_name", "product_id"}
|
|
|
|
|
|
def test_factory_model_sentinel_in_sync():
|
|
"""哨兵防漂移(审查 Minor-2): resolve_ai_settings 的"未设置"哨兵必须
|
|
与 DEFAULT_CONFIG 出厂 model 一致——默认值一旦漂移,"回退 env AI_MODEL"
|
|
永不触发且无测试报错。"""
|
|
from app.platform.defaults import DEFAULT_CONFIG
|
|
from app.engine.services.ai_service import _FACTORY_DEFAULT_MODEL
|
|
assert _FACTORY_DEFAULT_MODEL == DEFAULT_CONFIG["ai"]["model"]
|
|
|
|
|
|
def test_seed_keys_drive_create_conversation(async_db):
|
|
"""create_conversation 按映射种子化: 白名单外的 context 键被丢弃。"""
|
|
|
|
async def run():
|
|
async with async_db() as db:
|
|
mgr = ConversationManager(
|
|
db, runtime=_runtime_with({"engine": {"context_seed_keys": {
|
|
"vehicle_name": "app_vehicle_name",
|
|
"vehicle_id": "app_vehicle_id"}}}))
|
|
conv = await mgr.create_conversation(context={
|
|
"vehicle_name": "Toyota Hilux", "vehicle_id": "v9",
|
|
"product_name": "should_be_ignored",
|
|
})
|
|
return conv.id
|
|
cid = asyncio.run(run())
|
|
|
|
from app.engine.models import CustomerProfile
|
|
|
|
async def fetch():
|
|
async with async_db() as db:
|
|
profile = (await db.execute(select(CustomerProfile).where(
|
|
CustomerProfile.conversation_id == cid))).scalar_one()
|
|
return profile.raw_info
|
|
assert asyncio.run(fetch()) == {
|
|
"app_vehicle_name": "Toyota Hilux", "app_vehicle_id": "v9"}
|