refactor: config_views 单入口 + 0语义统一 + FakeAI 收敛(清理单#1#2#3)

- #3 六个消费视图 resolver 收拢 app/platform/config_views(单一定义点):
  resolve_email/push/ai_settings ← 各服务文件, resolve_contact_flags/
  context_seed_map ← conversation, name_to_region_extras ← manager
  __init__ 内联取值(or None 语义原样)——函数体逐字搬入,服务层 import 之,
  旧定义删;Phase 3 编辑器/试金石与该模块对接
- #2 限流 0 语义统一: conn_limiter.try_acquire 的 limit 与 chat_rest 的
  rate_limit_per_hour 取值同款——0 或负 = 回退出厂默认(与姊妹键一致),
  原 conn_limiter 0=全拒改为回退;chat_rest 取值抽 _resolve_rate_limit
  纯函数供锚定
- #1 FakeAI 收敛进 conftest(超集: extract/evaluate/eval_calls),四份拷贝
  删(test_lead_email_trigger/test_cc_integration/test_channel_hygiene/
  test_lead_push_wiring——计划记三份,grep 实有四份;push_wiring 经
  evaluate 参数注入原本地固定返回值,断言零改动)

全量 558 passed(554 + 新增 4: 单入口/内联等价/两处 0 语义)

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-10-13 11:45:42 +08:00
parent acf5777ff2
commit 71e11c317f
15 changed files with 310 additions and 254 deletions
+9 -3
View File
@@ -125,6 +125,14 @@ class MessageRequest(BaseModel):
content: str
def _resolve_rate_limit(config_data: dict | None) -> int:
"""engine.rate_limit_per_hour 取值(Task 10): 0 或负 = 回退出厂默认
(与姊妹键 ws_conn_limit 一致)——config_validation 已拒 0/负,此处兜
直写快照/旧库数据。"""
v = ((config_data or {}).get("engine") or {}).get("rate_limit_per_hour")
return v if v and v > 0 else DEFAULT_MAX
@router.post("/message")
async def chat_message(req: MessageRequest, request: Request):
"""Process one app turn: stream-collect the AI reply, then evaluate.
@@ -142,9 +150,7 @@ async def chat_message(req: MessageRequest, request: Request):
runtime = get_runtime(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
limit = _resolve_rate_limit(runtime.config.data)
if not rate_check(runtime.config.slug, ip, dev, max_requests=limit):
raise HTTPException(status_code=429, detail="rate limit exceeded")
+2 -28
View File
@@ -6,9 +6,10 @@ from pathlib import Path
from app.engine.providers.base import AIProvider
from app.engine.providers.glm import GLMProvider
from app.engine.config import settings
from app.engine.services.boundary_filter import DEFAULT_WALL
from app.engine.wall_schema import CompiledWall
# Task 10: AI 参数 resolver 收拢至 config_views(单一定义点)
from app.platform.config_views import resolve_ai_settings
logger = logging.getLogger(__name__)
@@ -130,33 +131,6 @@ DEFAULT_PROVIDER = PromptProvider.from_dir(
Path(__file__).resolve().parents[3] / "seed_profiles" / "jiancai" / "prompts")
# model 的出厂默认值——视同"未设置"哨兵: 租户未定制(merged 值 == 出厂默认)
# 时回退 env AI_MODEL,部署级模型选择不因接线失效。显式配了其他值则用租户的。
_FACTORY_DEFAULT_MODEL = "glm-4-flash"
def resolve_ai_settings(config_data: dict | None) -> dict:
"""租户 config.ai → provider 实例化参数(Task 9 接线; env 回退)。
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")
if not model or model == _FACTORY_DEFAULT_MODEL:
model = settings.AI_MODEL or _FACTORY_DEFAULT_MODEL
return {
"provider": ai.get("provider") or settings.AI_PROVIDER,
"api_key": ai.get("api_key") or settings.ZHIPU_API_KEY,
"base_url": ai.get("base_url") or settings.ZHIPU_BASE_URL,
"model": model,
}
def create_provider(config_data: dict | None = None) -> AIProvider:
"""provider 工厂(Task 9: 按租户配置实例化 glm;None 走 env 平台默认)。"""
cfg = resolve_ai_settings(config_data)
+7 -2
View File
@@ -30,8 +30,13 @@ class ConnectionLimiter:
def try_acquire(self, tenant: str, ip: str, *,
limit: Optional[int] = None) -> bool:
"""未满则记账并返回 True; 已满返回 False(不记账)。"""
cap = self._limit if limit is None else limit
"""未满则记账并返回 True; 已满返回 False(不记账)。
limit 按次覆盖构造默认; 0 或负 = 回退出厂默认(与姊妹键
rate_limit_per_hour 一致)——config_validation 已拒 0/负,此处
兜直写快照/旧库数据,不让坏值炸成全拒(Task 10 前 0=全拒)。
"""
cap = limit if limit and limit > 0 else self._limit
key = (tenant, ip)
n = self._counts.get(key, 0)
if n >= cap:
+7 -31
View File
@@ -26,6 +26,12 @@ from app.engine.services.contact_pipeline import (
normalize_contacts, resolve_by_recency, scan_user_messages, split_validate,
)
from app.engine.wall_schema import CompiledWall
# Task 10: contact 开关/种子映射/国名扩表三个消费视图收拢至 config_views
# (单一定义点;原 resolve_contact_flags/context_seed_map 定义于本模块,
# name_to_region_extras 为 __init__ 内联取值)
from app.platform.config_views import (
context_seed_map, name_to_region_extras, resolve_contact_flags,
)
if TYPE_CHECKING:
from app.platform.registry import TenantRuntime
@@ -33,35 +39,6 @@ if TYPE_CHECKING:
logger = logging.getLogger(__name__)
# ---- Task 9: contact 行为开关 / context 种子白名单(config → 引擎消费口) ----
def resolve_contact_flags(config_data: dict | None) -> dict:
"""租户 config.contact → 联系方式行为开关。
cc_completion: 号码区号补全(判定树/确认状态机/CC 强制墙总闸);
email_light_ask: 邮箱轻补(缺失渠道一次性追问)。False = 该行为整体
跳过(引擎级门控: 不建状态/不注入/不写计数键)。
(template_languages 暂无运行期消费方, 不在此解析——Phase 2 后台批次
再接。)
"""
contact = ((config_data or {}).get("contact")) or {}
return {
"cc_completion": bool(contact.get("cc_completion", True)),
"email_light_ask": bool(contact.get("email_light_ask", True)),
}
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,白名单 = 本映射的键集合——审查 Minor-4: 单一取值
路径,不留第二条内联读法)。
"""
engine = ((config_data or {}).get("engine")) or {}
return dict(engine.get("context_seed_keys") or {})
# 职能边界触发词(Task 13.2, 评审#1/#4 修订): 命中即注入红线提醒。精修说明:
# - 裸 "how long"/"多久" 不收(规格参数类问题常用),英文要求 take/arrive/deliver/ship 邻接,
# 中文只收 到货/多久能到/交货 等明确交付语境
@@ -409,8 +386,7 @@ class ConversationManager:
# 生效时机: 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)
self._region_extras = name_to_region_extras(runtime.config.data)
@property
def session_factory(self):
+2 -28
View File
@@ -5,8 +5,9 @@ from email.mime.text import MIMEText
import aiosmtplib
from app.engine.config import settings
from app.engine.services.cc_completion import cc_state_for, outbound_e164, outbound_number
# Task 10: 发信参数 resolver 收拢至 config_views(单一定义点)
from app.platform.config_views import resolve_email_settings
logger = logging.getLogger(__name__)
@@ -16,33 +17,6 @@ TAG_LABELS = {
}
CONTACT_LABELS = {"phone": "手机", "whatsapp": "WhatsApp", "email": "邮箱", "wechat": "微信"}
# 平台默认发件人显示名(Task 9 前的硬编码值, 租户未配 sender_name 时沿用)
_DEFAULT_SENDER_NAME = "HESUPIPE"
def resolve_email_settings(config_data: dict | None) -> dict:
"""租户 config.email → 发信参数(Task 9 接线; env settings 回退)。
回退规则(语义钉在 test_config_merge: 显式 None = 清空/未设置):
- smtp 缺省或显式 None → 整段用 env SMTP_*;
- smtp 给了 dict → 段内逐键回退(缺键/空串 → env 对应值, 租户可只改
密码/只换 host);
- sender_name 空 → 平台默认(_DEFAULT_SENDER_NAME);
- notify_emails 空列表 → env NOTIFY_EMAIL 单元素。
"""
email = ((config_data or {}).get("email")) or {}
smtp = email.get("smtp") or {}
notify = [e for e in (email.get("notify_emails") or []) if e]
return {
"host": smtp.get("host") or settings.SMTP_HOST,
"port": smtp.get("port") or settings.SMTP_PORT,
"user": smtp.get("user") or settings.SMTP_USER,
"password": smtp.get("password") or settings.SMTP_PASS,
"sender_name": email.get("sender_name") or _DEFAULT_SENDER_NAME,
"notify_emails": notify or (
[settings.NOTIFY_EMAIL] if settings.NOTIFY_EMAIL else []),
}
def _esc(v) -> str:
return html.escape(str(v)) if v else ""
+6 -33
View File
@@ -23,48 +23,21 @@ import httpx
from sqlalchemy import func, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.engine.config import settings
# env settings 单例引用: Task 10 起 resolver 消费移至 config_views,本模块
# 不再直读 settings——保留 import 供测试 monkeypatch 锚点(svc.settings.PUSH_*
# 20+ 处;settings 是单例对象,经任一模块引用 patch 均全局生效)
from app.engine.config import settings # noqa: F401
from app.engine.models import (
Conversation, CustomerProfile, Evaluation, IntentLevel, LeadPushLog,
Message, MessageRole,
)
from app.engine.services.cc_completion import cc_state_for, completion_note, outbound_e164
from app.engine.services.ip_service import resolve_country
# Task 10: 推送参数 resolver 收拢至 config_views(单一定义点)
from app.platform.config_views import resolve_push_settings
logger = logging.getLogger(__name__)
def resolve_push_settings(config_data: dict | None) -> dict:
"""租户 config.push → 推送参数(Task 9 接线; env settings 回退)。
空槽回退哨兵(审查 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"解决。
"""
p = ((config_data or {}).get("push")) or {}
return {
"enabled": bool(p.get("enabled") or settings.PUSH_ENABLED),
"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,
# 数值哨兵: 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")
if p.get("retry_backoff_base") is not None
else settings.PUSH_RETRY_BACKOFF_BASE),
}
# 对方契约(docs/AI_INQUIRY_PUSH_API.md): phone/whatsapp/email 至少一个有效
_TRIO = ("phone", "whatsapp", "email")
_CST = timezone(timedelta(hours=8))