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:
+2
-2
@@ -93,8 +93,8 @@ def build_jiancai_runtime():
|
||||
|
||||
只走加载期(config/prompts/compiled_wall 就位),从不 ensure_ready——
|
||||
engine/session_factory 保持 None: 直连测试自管 async_db(NullPool 跨
|
||||
循环纪律),manager.session_factory 为 None 时 lead_push_service 回退
|
||||
模块级兜底,与旧 runtime=None 行为一致。无 engine 即无句柄,无需清理;
|
||||
循环纪律;Task 9 起 lead_push_service 无模块级 session 兜底,session_factory
|
||||
由调用方/测试显式传)。无 engine 即无句柄,无需清理;
|
||||
data_dir 仅占位(只拼 db_path 字符串,无任何 IO——从不 ensure_ready,
|
||||
不会创建 tenants/ 目录或库文件),模块级缓存单例跨测试复用。
|
||||
"""
|
||||
|
||||
@@ -7,7 +7,7 @@ from app.engine.services import email_service
|
||||
def _capture_send(monkeypatch):
|
||||
captured = {}
|
||||
|
||||
async def fake_send(msg):
|
||||
async def fake_send(msg, cfg=None):
|
||||
captured["msg"] = msg
|
||||
return True
|
||||
|
||||
|
||||
@@ -60,15 +60,16 @@ def _setup(async_db, monkeypatch, source=ConversationSource.widget, ai=None):
|
||||
sent = []
|
||||
|
||||
async def fake_lead(customer_profile, contact_info, reasoning,
|
||||
conversation_messages, intent_level="", cc_info=None):
|
||||
conversation_messages, intent_level="", cc_info=None,
|
||||
config_data=None):
|
||||
sent.append(("lead", dict(contact_info), intent_level))
|
||||
return True
|
||||
|
||||
async def fake_nudge(contact_info, cc_info=None):
|
||||
async def fake_nudge(contact_info, cc_info=None, config_data=None):
|
||||
sent.append(("nudge", dict(contact_info), ""))
|
||||
return True
|
||||
|
||||
async def fake_fix(contact_info, count=1, cc_info=None):
|
||||
async def fake_fix(contact_info, count=1, cc_info=None, config_data=None):
|
||||
sent.append(("fix", dict(contact_info), ""))
|
||||
return True
|
||||
|
||||
|
||||
@@ -335,19 +335,24 @@ def _mk_push_log(async_db, status="pending"):
|
||||
|
||||
|
||||
def _run_push_case(async_db, monkeypatch, do_post, log_id):
|
||||
"""注入 _do_post/_sleep/async_session 后驱动 _run_push,返回退避序列。"""
|
||||
"""注入 _do_post/_sleep 后驱动 _run_push(Task 9: session_factory 直传,
|
||||
不再借模块级 async_session——已删除),返回退避序列。"""
|
||||
from app.engine.services import lead_push_service as svc
|
||||
sleeps = []
|
||||
|
||||
async def fake_sleep(s):
|
||||
sleeps.append(s)
|
||||
|
||||
monkeypatch.setattr(svc, "_do_post", do_post)
|
||||
# Task 9: _do_post 多了 config_data 参数——适配层吸收,各用例 fake 保持单参
|
||||
async def adapted_do_post(payload, config_data=None):
|
||||
return await do_post(payload)
|
||||
|
||||
monkeypatch.setattr(svc, "_do_post", adapted_do_post)
|
||||
monkeypatch.setattr(svc, "_sleep", fake_sleep)
|
||||
monkeypatch.setattr(svc, "async_session", async_db)
|
||||
|
||||
async def run():
|
||||
await svc._run_push(log_id, {"external_key": "ai-kefu-x", "event": "new"})
|
||||
await svc._run_push(
|
||||
log_id, {"external_key": "ai-kefu-x", "event": "new"}, async_db)
|
||||
asyncio.run(run())
|
||||
return sleeps
|
||||
|
||||
@@ -482,7 +487,7 @@ def test_fire_pending_creates_tasks(monkeypatch):
|
||||
from app.engine.services import lead_push_service as svc
|
||||
pushed = []
|
||||
|
||||
async def fake_run(log_id, payload, session_factory=None):
|
||||
async def fake_run(log_id, payload, session_factory=None, config_data=None):
|
||||
pushed.append((log_id, payload))
|
||||
|
||||
monkeypatch.setattr(svc, "_run_push", fake_run)
|
||||
@@ -494,7 +499,7 @@ def test_fire_pending_creates_tasks(monkeypatch):
|
||||
]
|
||||
|
||||
async def run():
|
||||
svc.fire_pending_pushes(logs)
|
||||
svc.fire_pending_pushes(logs, None)
|
||||
await asyncio.sleep(0) # 同 loop 内让 create_task 跑一轮
|
||||
asyncio.run(run())
|
||||
assert pushed == [("id-pending", {"event": "new"})]
|
||||
@@ -503,7 +508,6 @@ def test_fire_pending_creates_tasks(monkeypatch):
|
||||
def test_sweep_stale_pending(async_db, monkeypatch):
|
||||
from datetime import datetime, timezone
|
||||
from app.engine.services import lead_push_service as svc
|
||||
monkeypatch.setattr(svc, "async_session", async_db)
|
||||
_seed_push_ctx(async_db)
|
||||
|
||||
async def run():
|
||||
@@ -524,7 +528,7 @@ def test_sweep_stale_pending(async_db, monkeypatch):
|
||||
updated_at=datetime(2020, 1, 1, tzinfo=timezone.utc))
|
||||
db.add_all([stale, fresh, done])
|
||||
await db.commit()
|
||||
n = await svc.sweep_stale_pending(stale_minutes=30)
|
||||
n = await svc.sweep_stale_pending(async_db, stale_minutes=30)
|
||||
assert n == 1
|
||||
async with async_db() as db:
|
||||
logs = list((await db.execute(select(LeadPushLog))).scalars())
|
||||
|
||||
@@ -33,15 +33,16 @@ def _setup(async_db, monkeypatch, ai, lead_succeeds=True):
|
||||
sent = []
|
||||
|
||||
async def fake_lead(customer_profile, contact_info, reasoning,
|
||||
conversation_messages, intent_level="", cc_info=None):
|
||||
conversation_messages, intent_level="", cc_info=None,
|
||||
config_data=None):
|
||||
sent.append(("lead", dict(contact_info)))
|
||||
return lead_succeeds
|
||||
|
||||
async def fake_nudge(contact_info, cc_info=None):
|
||||
async def fake_nudge(contact_info, cc_info=None, config_data=None):
|
||||
sent.append(("returning", dict(contact_info)))
|
||||
return lead_succeeds
|
||||
|
||||
async def fake_fix(contact_info, count=1, cc_info=None):
|
||||
async def fake_fix(contact_info, count=1, cc_info=None, config_data=None):
|
||||
sent.append(("correction", dict(contact_info)))
|
||||
return lead_succeeds
|
||||
|
||||
@@ -51,7 +52,8 @@ def _setup(async_db, monkeypatch, ai, lead_succeeds=True):
|
||||
|
||||
fired = []
|
||||
monkeypatch.setattr(lead_push_service, "fire_pending_pushes",
|
||||
lambda logs, session_factory=None: fired.extend(logs))
|
||||
lambda logs, session_factory=None, config_data=None:
|
||||
fired.extend(logs))
|
||||
monkeypatch.setattr(lead_push_service.settings, "PUSH_ENABLED", True)
|
||||
|
||||
async def run():
|
||||
@@ -160,14 +162,16 @@ def test_push_disabled_writes_nothing(async_db, monkeypatch):
|
||||
sent = []
|
||||
|
||||
async def fake_lead(customer_profile, contact_info, reasoning,
|
||||
conversation_messages, intent_level="", cc_info=None):
|
||||
conversation_messages, intent_level="", cc_info=None,
|
||||
config_data=None):
|
||||
sent.append("lead")
|
||||
return True
|
||||
|
||||
monkeypatch.setattr(email_service, "send_lead_email", fake_lead)
|
||||
fired = []
|
||||
monkeypatch.setattr(lead_push_service, "fire_pending_pushes",
|
||||
lambda logs, session_factory=None: fired.extend(logs))
|
||||
lambda logs, session_factory=None, config_data=None:
|
||||
fired.extend(logs))
|
||||
|
||||
async def run():
|
||||
async with async_db() as db:
|
||||
@@ -186,7 +190,7 @@ def test_fire_end_to_end_single_loop(async_db, monkeypatch):
|
||||
"""真 fire + 假 _run_push: 同一事件循环内 stream 完成后任务确实被派发执行。"""
|
||||
pushed = []
|
||||
|
||||
async def fake_run(log_id, payload, session_factory=None):
|
||||
async def fake_run(log_id, payload, session_factory=None, config_data=None):
|
||||
pushed.append(log_id)
|
||||
|
||||
monkeypatch.setattr(lead_push_service, "_run_push", fake_run)
|
||||
@@ -195,7 +199,8 @@ def test_fire_end_to_end_single_loop(async_db, monkeypatch):
|
||||
sent = []
|
||||
|
||||
async def fake_lead(customer_profile, contact_info, reasoning,
|
||||
conversation_messages, intent_level="", cc_info=None):
|
||||
conversation_messages, intent_level="", cc_info=None,
|
||||
config_data=None):
|
||||
sent.append("lead")
|
||||
return True
|
||||
|
||||
@@ -234,11 +239,12 @@ def test_fire_happens_only_after_commit(async_db, monkeypatch):
|
||||
fired = []
|
||||
|
||||
async def fake_lead(customer_profile, contact_info, reasoning,
|
||||
conversation_messages, intent_level="", cc_info=None):
|
||||
conversation_messages, intent_level="", cc_info=None,
|
||||
config_data=None):
|
||||
sent.append("lead")
|
||||
return True
|
||||
|
||||
async def fake_run(log_id, payload, session_factory=None):
|
||||
async def fake_run(log_id, payload, session_factory=None, config_data=None):
|
||||
async with async_db() as db:
|
||||
try:
|
||||
row = (await db.execute(
|
||||
@@ -253,9 +259,9 @@ def test_fire_happens_only_after_commit(async_db, monkeypatch):
|
||||
|
||||
real_fire = lead_push_service.fire_pending_pushes
|
||||
|
||||
def recording_fire(logs, session_factory=None):
|
||||
def recording_fire(logs, session_factory=None, config_data=None):
|
||||
fired.append(True)
|
||||
real_fire(logs, session_factory)
|
||||
real_fire(logs, session_factory, config_data)
|
||||
|
||||
monkeypatch.setattr(email_service, "send_lead_email", fake_lead)
|
||||
monkeypatch.setattr(lead_push_service, "_run_push", fake_run)
|
||||
|
||||
@@ -4,18 +4,18 @@ from app.engine.services.rate_limit import check, _reset_for_test
|
||||
def test_allows_under_cap():
|
||||
_reset_for_test()
|
||||
for _ in range(30):
|
||||
assert check("1.2.3.4", "dev1") is True
|
||||
assert check("t1", "1.2.3.4", "dev1") is True
|
||||
|
||||
|
||||
def test_blocks_over_cap():
|
||||
_reset_for_test()
|
||||
for _ in range(30):
|
||||
assert check("1.2.3.4", "dev1") is True
|
||||
assert check("1.2.3.4", "dev1") is False
|
||||
assert check("t1", "1.2.3.4", "dev1") is True
|
||||
assert check("t1", "1.2.3.4", "dev1") is False
|
||||
|
||||
|
||||
def test_isolated_buckets():
|
||||
_reset_for_test()
|
||||
for _ in range(30):
|
||||
check("1.2.3.4", "dev1")
|
||||
assert check("5.6.7.8", "dev2") is True # different ip+device unaffected
|
||||
check("t1", "1.2.3.4", "dev1")
|
||||
assert check("t1", "5.6.7.8", "dev2") is True # different ip+device unaffected
|
||||
|
||||
@@ -0,0 +1,305 @@
|
||||
"""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, allowed_seed_keys, 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"
|
||||
|
||||
|
||||
# ---- push ----------------------------------------------------------------
|
||||
|
||||
def _patch_push_env(monkeypatch):
|
||||
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", 10.0)
|
||||
monkeypatch.setattr(settings, "PUSH_MAX_ATTEMPTS", 3)
|
||||
monkeypatch.setattr(settings, "PUSH_RETRY_BACKOFF_BASE", 1.0)
|
||||
|
||||
|
||||
def test_push_reads_tenant_section(monkeypatch):
|
||||
"""PUSH 组全部字段: 租户段给了就用租户的。"""
|
||||
_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,
|
||||
}
|
||||
|
||||
|
||||
def test_push_empty_section_falls_back_to_env(monkeypatch):
|
||||
"""空槽(默认 False/空串)视同未设置 → 回退 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": 10.0, "max_attempts": 3,
|
||||
"retry_backoff_base": 1.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():
|
||||
"""种子键白名单 = config.engine.context_seed_keys 的键集合。"""
|
||||
cfg = _cfg({"engine": {"context_seed_keys": {
|
||||
"vehicle_name": "app_vehicle_name", "vehicle_id": "app_vehicle_id"}}})
|
||||
assert allowed_seed_keys(cfg.data) == {"vehicle_name", "vehicle_id"}
|
||||
# jiancai 出厂 = 旧硬编码白名单的配置化形态
|
||||
assert allowed_seed_keys(build_jiancai_runtime().config.data) == {
|
||||
"product_name", "product_id"}
|
||||
|
||||
|
||||
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"}
|
||||
Reference in New Issue
Block a user