fix: 配置接线审查修复(push数值哨兵化/恒真测试修正/哨兵防漂移/种子映射消双路径)
Important-1: push 数值字段(timeout/max_attempts/retry_backoff_base) 出厂默认改 None 哨兵(与 ai.model 同款),resolve_push_settings 对 None 回退 env——根治 truthy 出厂默认永久遮住 env 覆盖;恒真测试修正(env 数值 patch 成 7.5/5/2.0 与出厂区分)+租户显式数值优先断言;ai.provider 遮 env 的边界记入 docstring。Minor-2: 出厂 model 哨兵与 DEFAULT_CONFIG 一致性测试 + defaults.py 双源注释(model/rate_limit_per_hour)。 Minor-4: allowed_seed_keys → context_seed_map(返回映射,单一取值路径)。 Minor-6/7/3/8: WS 配置冻结时机注释/name_to_region 查表序注释改准确/ chat_rest limit 0 语义注释/resolver None 路径仅供测试注释。404 全绿。 Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -122,6 +122,7 @@ async def chat_message(req: MessageRequest, request: Request):
|
||||
ip = request.client.host if request.client else ""
|
||||
dev = request.headers.get("X-Device-Id", "")
|
||||
engine_cfg = runtime.config.data.get("engine") or {}
|
||||
# 类型与 0 语义由 Phase 2 配置校验统一(当前: 空/0 回退 DEFAULT_MAX)
|
||||
limit = engine_cfg.get("rate_limit_per_hour") or DEFAULT_MAX
|
||||
if not rate_check(runtime.config.slug, ip, dev, max_requests=limit):
|
||||
raise HTTPException(status_code=429, detail="rate limit exceeded")
|
||||
|
||||
@@ -114,6 +114,9 @@ def resolve_ai_settings(config_data: dict | None) -> dict:
|
||||
api_key/base_url 空槽(None/空串)→ env 智谱凭据(平台默认集团 key,
|
||||
spec §5.5 "用平台默认"状态下租户侧不存密钥);model 空或等于出厂默认
|
||||
→ env AI_MODEL。
|
||||
已知边界: provider 出厂 "glm" 同样遮 env AI_PROVIDER——当前单 provider
|
||||
无实害,多 provider 接入时再改哨兵结构(与 push 数值同款纪律)。
|
||||
config_data=None 路径仅供测试直调,生产调用一律传 runtime.config.data。
|
||||
"""
|
||||
ai = ((config_data or {}).get("ai")) or {}
|
||||
model = ai.get("model")
|
||||
|
||||
@@ -298,7 +298,9 @@ def name_to_region(name: str | None,
|
||||
|
||||
容错:去首尾空白、大小写不敏感、弯撇号(U+2019)归一为 ASCII。
|
||||
Task 9: extras = misc.name_to_region_extras(租户扩表),可加键也可
|
||||
覆盖基础表同名键(租户纠偏)——查表序 extras 优先。
|
||||
覆盖基础表同名键(租户纠偏)。查表分两轮:先精确匹配(extras→基础
|
||||
表)、后 casefold 折叠(extras→基础表)——基础表的精确命中优先于
|
||||
extras 的大小写折叠命中(精确 > 折叠,非"extras 整体优先")。
|
||||
"""
|
||||
if not name:
|
||||
return None
|
||||
|
||||
@@ -50,14 +50,16 @@ def resolve_contact_flags(config_data: dict | None) -> dict:
|
||||
}
|
||||
|
||||
|
||||
def allowed_seed_keys(config_data: dict | None) -> set[str]:
|
||||
"""context 种子白名单 = config.engine.context_seed_keys 的键集合。
|
||||
def context_seed_map(config_data: dict | None) -> dict:
|
||||
"""context 种子映射 = config.engine.context_seed_keys(ctx_key → raw_key)。
|
||||
|
||||
Task 9 前白名单(product_name/product_id)硬编码在本模块——两租户差异
|
||||
(auto 是 vehicle_*)由此配置化;create_conversation 按映射值写 raw_info。
|
||||
(auto 是 vehicle_*)由此配置化;create_conversation 是唯一消费口(按
|
||||
映射值写 raw_info,白名单 = 本映射的键集合——审查 Minor-4: 单一取值
|
||||
路径,不留第二条内联读法)。
|
||||
"""
|
||||
engine = ((config_data or {}).get("engine")) or {}
|
||||
return {str(k) for k in (engine.get("context_seed_keys") or {})}
|
||||
return dict(engine.get("context_seed_keys") or {})
|
||||
|
||||
# 职能边界触发词(Task 13.2, 评审#1/#4 修订): 命中即注入红线提醒。精修说明:
|
||||
# - 裸 "how long"/"多久" 不收(规格参数类问题常用),英文要求 take/arrive/deliver/ship 邻接,
|
||||
@@ -372,7 +374,9 @@ class ConversationManager:
|
||||
wall=runtime.compiled_wall,
|
||||
config_data=runtime.config.data)
|
||||
# Task 9: contact 行为开关(cc 补全/邮箱轻补)与国名映射扩表——
|
||||
# manager 生命周期内配置不变(reload 换 runtime 即换 manager 装配线)
|
||||
# manager 生命周期内配置不变(reload 换 runtime 即换 manager 装配线;
|
||||
# 生效时机: WS 长连接内冻结至断连,REST 路径下一请求生效——Phase 2
|
||||
# 联调改配置后按此预期,勿误判"没生效")
|
||||
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)
|
||||
@@ -408,9 +412,8 @@ class ConversationManager:
|
||||
# WS path passes no context -> raw_info stays {}.
|
||||
raw_info: dict = {}
|
||||
if context:
|
||||
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():
|
||||
for ctx_key, raw_key in context_seed_map(
|
||||
self.runtime.config.data).items():
|
||||
if context.get(ctx_key):
|
||||
raw_info[raw_key] = context[ctx_key]
|
||||
profile = CustomerProfile(conversation_id=conv.id, raw_info=raw_info)
|
||||
|
||||
@@ -37,8 +37,14 @@ logger = logging.getLogger(__name__)
|
||||
def resolve_push_settings(config_data: dict | None) -> dict:
|
||||
"""租户 config.push → 推送参数(Task 9 接线; env settings 回退)。
|
||||
|
||||
出厂默认的空槽(False/空串)视同"未设置"→ 回退 env PUSH_*——部署级
|
||||
.env 开关是平台默认集的一部分,租户未配置时继承,不静默关停。
|
||||
空槽回退哨兵(审查 Important-1, 与 ai.model 同款纪律):
|
||||
- 布尔/字符串: falsy 出厂默认(False/空串)视同"未设置"→ 回退 env
|
||||
PUSH_*——部署级 .env 开关是平台默认集的一部分,租户未配置时继承,
|
||||
不静默关停;
|
||||
- 数值字段(timeout/max_attempts/retry_backoff_base): 出厂 None =
|
||||
"未设置"→ 回退 env(settings 自带默认 10.0/3/1.0,最终行为与出厂
|
||||
truthy 默认时一致),租户显式数值(含显式 0)优先于 env。
|
||||
config_data=None 路径仅供测试直调,生产调用一律传 runtime.config.data。
|
||||
已知边界: env 开 + 租户显式 false 无法区分于未配置(布尔空槽只有
|
||||
False),Phase 2 admin 落地时以"平台清 env、配置显式写 false"解决。
|
||||
"""
|
||||
@@ -48,10 +54,15 @@ def resolve_push_settings(config_data: dict | None) -> dict:
|
||||
"url": p.get("url") or settings.PUSH_API_URL,
|
||||
"api_key": p.get("api_key") or settings.PUSH_API_KEY,
|
||||
"prefix": p.get("prefix") or settings.PUSH_EXTERNAL_KEY_PREFIX,
|
||||
"timeout": p.get("timeout") or settings.PUSH_TIMEOUT_SECONDS,
|
||||
"max_attempts": p.get("max_attempts") or settings.PUSH_MAX_ATTEMPTS,
|
||||
# 数值哨兵: None(出厂/显式清空)→ env;显式数值优先
|
||||
"timeout": (p.get("timeout") if p.get("timeout") is not None
|
||||
else settings.PUSH_TIMEOUT_SECONDS),
|
||||
"max_attempts": (p.get("max_attempts")
|
||||
if p.get("max_attempts") is not None
|
||||
else settings.PUSH_MAX_ATTEMPTS),
|
||||
"retry_backoff_base": (p.get("retry_backoff_base")
|
||||
or settings.PUSH_RETRY_BACKOFF_BASE),
|
||||
if p.get("retry_backoff_base") is not None
|
||||
else settings.PUSH_RETRY_BACKOFF_BASE),
|
||||
}
|
||||
|
||||
# 对方契约(docs/AI_INQUIRY_PUSH_API.md): phone/whatsapp/email 至少一个有效
|
||||
|
||||
@@ -13,10 +13,19 @@ DEFAULT_CONFIG: dict = {
|
||||
"wall": {}, # wall 由租户 seed 的 wall.json 提供,出厂留空
|
||||
"contact": {"cc_completion": True, "email_light_ask": True, "template_languages": ["en", "zh", "es"]},
|
||||
"email": {"smtp": None, "sender_name": "", "notify_emails": []},
|
||||
"push": {"enabled": False, "url": "", "api_key": "", "prefix": "", "timeout": 10,
|
||||
"max_attempts": 3, "retry_backoff_base": 1.0},
|
||||
"ai": {"provider": "glm", "api_key": None, "base_url": None, "model": "glm-4-flash"},
|
||||
"engine": {"context_seed_keys": {}, "rate_limit_per_hour": 30,
|
||||
# 数值字段出厂哨兵 None = "未设置"(与 ai.model 同款纪律):
|
||||
# resolve_push_settings 对 None 回退 env PUSH_*(settings 自带部署级默认,
|
||||
# 最终行为不变)——truthy 出厂默认(10/3/1.0)会永久遮住 env 同名覆盖
|
||||
"push": {"enabled": False, "url": "", "api_key": "", "prefix": "",
|
||||
"timeout": None, "max_attempts": None, "retry_backoff_base": None},
|
||||
"ai": {"provider": "glm", "api_key": None, "base_url": None,
|
||||
# 双源防漂移: 出厂 model 须与 ai_service._FACTORY_DEFAULT_MODEL 一致
|
||||
# (resolve_ai_settings 的"未设置"哨兵;测试强制同步)
|
||||
"model": "glm-4-flash"},
|
||||
"engine": {"context_seed_keys": {},
|
||||
# 双源防漂移: 默认值须与 rate_limit.DEFAULT_MAX 一致
|
||||
# (chat_rest 空槽兜底读它)
|
||||
"rate_limit_per_hour": 30,
|
||||
# 双源防漂移: 默认值须与 conn_limiter.DEFAULT_WS_CONN_LIMIT 一致
|
||||
"ws_conn_limit": 10},
|
||||
"misc": {"name_to_region_extras": {}},
|
||||
|
||||
@@ -22,7 +22,7 @@ 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,
|
||||
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
|
||||
@@ -106,17 +106,21 @@ def test_send_lead_email_uses_tenant_sender_and_recipients(monkeypatch):
|
||||
# ---- 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", 10.0)
|
||||
monkeypatch.setattr(settings, "PUSH_MAX_ATTEMPTS", 3)
|
||||
monkeypatch.setattr(settings, "PUSH_RETRY_BACKOFF_BASE", 1.0)
|
||||
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 组全部字段: 租户段给了就用租户的。"""
|
||||
"""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",
|
||||
@@ -128,16 +132,19 @@ def test_push_reads_tenant_section(monkeypatch):
|
||||
"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/空串)视同未设置 → 回退 env PUSH_*。"""
|
||||
"""空槽(默认 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": 10.0, "max_attempts": 3,
|
||||
"retry_backoff_base": 1.0,
|
||||
"prefix": "env-pfx", "timeout": 7.5, "max_attempts": 5,
|
||||
"retry_backoff_base": 2.0,
|
||||
}
|
||||
|
||||
|
||||
@@ -269,15 +276,24 @@ def test_name_to_region_extras_merged():
|
||||
|
||||
|
||||
def test_chat_rest_seed_keys_from_config():
|
||||
"""种子键白名单 = config.engine.context_seed_keys 的键集合。"""
|
||||
"""种子键白名单 = context_seed_map 映射的键集合。"""
|
||||
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"}
|
||||
assert set(context_seed_map(cfg.data)) == {"vehicle_name", "vehicle_id"}
|
||||
# jiancai 出厂 = 旧硬编码白名单的配置化形态
|
||||
assert allowed_seed_keys(build_jiancai_runtime().config.data) == {
|
||||
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 键被丢弃。"""
|
||||
|
||||
|
||||
Reference in New Issue
Block a user