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:
@@ -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")
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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 ""
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
"""平台配置的消费视图单入口,Phase 3 编辑器/试金石与这里对接。
|
||||
|
||||
Task 10(清理单 #3)收拢: 六个 resolver 原散在各服务模块——
|
||||
resolve_email_settings ← email_service / resolve_push_settings ←
|
||||
lead_push_service / resolve_ai_settings ← ai_service / resolve_contact_flags
|
||||
+ context_seed_map ← conversation / name_to_region_extras ←
|
||||
ConversationManager.__init__ 内联取值。函数体原样搬入,行为不变;服务层
|
||||
与测试一律经本模块 import(单一定义点纪律,旧定义已删)。
|
||||
|
||||
依赖方向: 本模块在 platform 层,只 import engine 的 env settings
|
||||
(app.engine.config,与各服务原状一致),无新循环;不得反向 import 服务
|
||||
模块——resolver 是纯消费视图,不触服务逻辑。
|
||||
"""
|
||||
from app.engine.config import settings
|
||||
|
||||
# 平台默认发件人显示名(Task 9 前的硬编码值, 租户未配 sender_name 时沿用)
|
||||
_DEFAULT_SENDER_NAME = "HESUPIPE"
|
||||
|
||||
# model 的出厂默认值——视同"未设置"哨兵: 租户未定制(merged 值 == 出厂默认)
|
||||
# 时回退 env AI_MODEL,部署级模型选择不因接线失效。显式配了其他值则用租户的。
|
||||
_FACTORY_DEFAULT_MODEL = "glm-4-flash"
|
||||
|
||||
|
||||
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 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),
|
||||
}
|
||||
|
||||
|
||||
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 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)硬编码在 conversation——两租户
|
||||
差异(auto 是 vehicle_*)由此配置化;create_conversation 是唯一消费口(按
|
||||
映射值写 raw_info,白名单 = 本映射的键集合——审查 Minor-4: 单一取值
|
||||
路径,不留第二条内联读法)。
|
||||
"""
|
||||
engine = ((config_data or {}).get("engine")) or {}
|
||||
return dict(engine.get("context_seed_keys") or {})
|
||||
|
||||
|
||||
def name_to_region_extras(config_data: dict | None) -> dict | None:
|
||||
"""租户 misc.name_to_region_extras → CC 国名映射扩表(dict 或 None)。
|
||||
|
||||
Task 10 前是 ConversationManager.__init__ 的内联取值——``or None``
|
||||
语义原样保留(缺省/空 dict → None),manager 构造期消费一次,cc 判定树
|
||||
经 extras 参数逐轮取用(空表与 None 对查表方等价,均可 falsy 跳过)。
|
||||
"""
|
||||
misc = ((config_data or {}).get("misc")) or {}
|
||||
return misc.get("name_to_region_extras") or None
|
||||
@@ -19,7 +19,7 @@ DEFAULT_CONFIG: dict = {
|
||||
"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 一致
|
||||
# 双源防漂移: 出厂 model 须与 config_views._FACTORY_DEFAULT_MODEL 一致
|
||||
# (resolve_ai_settings 的"未设置"哨兵;测试强制同步)
|
||||
"model": "glm-4-flash"},
|
||||
"engine": {"context_seed_keys": {},
|
||||
|
||||
@@ -86,6 +86,51 @@ def _reset_platform_auth():
|
||||
_reset_for_test()
|
||||
|
||||
|
||||
# ---- Phase 2 Task 10: FakeAI 公共确定性桩(清理单 #1) --------------------------
|
||||
# 收敛自四份拷贝(test_lead_email_trigger / test_cc_integration /
|
||||
# test_channel_hygiene / test_lead_push_wiring,接口取超集: 前两份全量,
|
||||
# hygiene/push_wiring 只用 extract 传入——evaluate=None 即它们原固定返回)。
|
||||
|
||||
|
||||
class FakeAI:
|
||||
"""替代 GLM 的确定性桩: 构造参数即每轮返回值。
|
||||
|
||||
- extract: dict 或 list——第 N 轮客户消息取 seq[N-1](messages 为
|
||||
[u,a,u,a...] 全量历史),越界取末位;
|
||||
- evaluate: dict 或 None——None = 出厂 pending 默认;子类可覆写
|
||||
extract_info/evaluate 做捕获或定向注毒(既有 RecAI 用法);
|
||||
- eval_calls: evaluate 被调计数(结束守卫轮次断言用)。
|
||||
"""
|
||||
|
||||
def __init__(self, extract=None, evaluate=None):
|
||||
self._extract_seq = extract if isinstance(extract, list) else [extract or {}]
|
||||
self._evaluate = evaluate or {}
|
||||
self.eval_calls = 0
|
||||
|
||||
async def chat(self, messages, context=""):
|
||||
return "ok reply"
|
||||
|
||||
async def stream_chat(self, messages, context=""):
|
||||
for token in ("ok ", "reply"):
|
||||
yield token
|
||||
|
||||
async def extract_info(self, messages, existing_info):
|
||||
# 第 N 轮客户消息 → seq[N-1](messages 为 [u,a,u,a...] 全量历史)
|
||||
idx = min(max(len(messages) // 2 - 1, 0), len(self._extract_seq) - 1)
|
||||
return self._extract_seq[idx]
|
||||
|
||||
async def evaluate(self, profile, recent_messages):
|
||||
self.eval_calls += 1
|
||||
result = dict(self._evaluate)
|
||||
result.setdefault("intent_level", "pending")
|
||||
result.setdefault("reasoning", "")
|
||||
result.setdefault("contact_info", {})
|
||||
result.setdefault("info_sufficient", False)
|
||||
if result["intent_level"] not in ("interested", "not_interested", "pending"):
|
||||
result["intent_level"] = "pending"
|
||||
return result
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def jiancai_runtime():
|
||||
"""直连测试用的 jiancai TenantRuntime(Task 6: manager 的 runtime 必填)。
|
||||
|
||||
@@ -26,7 +26,7 @@ from sqlalchemy import select
|
||||
from app.engine.models import Evaluation
|
||||
from app.engine.services import email_service
|
||||
from app.engine.services.conversation import ConversationManager
|
||||
from tests.conftest import build_jiancai_runtime
|
||||
from tests.conftest import FakeAI, build_jiancai_runtime
|
||||
|
||||
KE_IP = "41.90.64.1" # 肯尼亚段(iptocc 实测 → KE)
|
||||
|
||||
@@ -34,38 +34,6 @@ KE_IP = "41.90.64.1" # 肯尼亚段(iptocc 实测 → KE)
|
||||
PENDING_TAIL = "(待确认补全号)"
|
||||
|
||||
|
||||
class FakeAI:
|
||||
"""替代 GLM 的确定性桩: 构造参数即每轮返回值。"""
|
||||
|
||||
def __init__(self, extract=None, evaluate=None):
|
||||
self._extract_seq = extract if isinstance(extract, list) else [extract or {}]
|
||||
self._evaluate = evaluate or {}
|
||||
self.eval_calls = 0
|
||||
|
||||
async def chat(self, messages, context=""):
|
||||
return "ok reply"
|
||||
|
||||
async def stream_chat(self, messages, context=""):
|
||||
for token in ("ok ", "reply"):
|
||||
yield token
|
||||
|
||||
async def extract_info(self, messages, existing_info):
|
||||
# 第 N 轮客户消息 → seq[N-1](messages 为 [u,a,u,a...] 全量历史)
|
||||
idx = min(max(len(messages) // 2 - 1, 0), len(self._extract_seq) - 1)
|
||||
return self._extract_seq[idx]
|
||||
|
||||
async def evaluate(self, profile, recent_messages):
|
||||
self.eval_calls += 1
|
||||
result = dict(self._evaluate)
|
||||
result.setdefault("intent_level", "pending")
|
||||
result.setdefault("reasoning", "")
|
||||
result.setdefault("contact_info", {})
|
||||
result.setdefault("info_sufficient", False)
|
||||
if result["intent_level"] not in ("interested", "not_interested", "pending"):
|
||||
result["intent_level"] = "pending"
|
||||
return result
|
||||
|
||||
|
||||
async def _send(manager, sid, text):
|
||||
async for _ in manager.stream_process_message(sid, text):
|
||||
pass
|
||||
|
||||
@@ -11,30 +11,11 @@ import asyncio
|
||||
|
||||
from app.engine.services import email_service
|
||||
from app.engine.services.conversation import ConversationManager
|
||||
from tests.conftest import build_jiancai_runtime
|
||||
from tests.conftest import FakeAI, build_jiancai_runtime
|
||||
|
||||
KE_IP = "41.90.64.1" # 肯尼亚段(iptocc 实测 → KE)
|
||||
|
||||
|
||||
class FakeAI:
|
||||
"""替代 GLM 的确定性桩(仅卫生测试所需的最小面)。"""
|
||||
|
||||
def __init__(self, extract=None):
|
||||
self._extract_seq = extract if isinstance(extract, list) else [extract or {}]
|
||||
|
||||
async def stream_chat(self, messages, context=""):
|
||||
for token in ("ok ", "reply"):
|
||||
yield token
|
||||
|
||||
async def extract_info(self, messages, existing_info):
|
||||
idx = min(max(len(messages) // 2 - 1, 0), len(self._extract_seq) - 1)
|
||||
return self._extract_seq[idx]
|
||||
|
||||
async def evaluate(self, profile, recent_messages):
|
||||
return {"intent_level": "pending", "reasoning": "",
|
||||
"contact_info": {}, "info_sufficient": False}
|
||||
|
||||
|
||||
async def _send(manager, sid, text):
|
||||
async for _ in manager.stream_process_message(sid, text):
|
||||
pass
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
"""Phase 2 Task 10(清理单 #1/#2/#3): config_views 单入口 + 限流 0 语义统一。
|
||||
|
||||
- #3 单入口: 六个配置消费视图(email/push/ai/contact_flags/context_seed_map/
|
||||
name_to_region_extras)同住 app.platform.config_views 且可调用——服务层与
|
||||
Phase 3 编辑器/试金石都从这里拿,散在各服务模块的定义已删(单一定义点);
|
||||
- #2 0 语义: ws_conn_limit 与 rate_limit_per_hour 行为统一——0 或负 =
|
||||
回退出厂默认(非全拒)。config_validation 已拒 0/负,这里是直写快照/
|
||||
旧库数据的纵深防御层;
|
||||
- #1 FakeAI 收敛(conftest 单一定义点,四份拷贝删)的证明 = 既有测试
|
||||
全绿,无新增断言。
|
||||
"""
|
||||
from app.engine.routes.chat_rest import _resolve_rate_limit
|
||||
from app.engine.services.conn_limiter import ConnectionLimiter
|
||||
from app.engine.services.rate_limit import DEFAULT_MAX
|
||||
from app.platform import config_views
|
||||
|
||||
|
||||
def test_config_views_single_entry():
|
||||
"""一次 import 拿全六个 resolver——单一定义点,散装定义已收拢。"""
|
||||
names = ("resolve_email_settings", "resolve_push_settings",
|
||||
"resolve_ai_settings", "resolve_contact_flags",
|
||||
"context_seed_map", "name_to_region_extras")
|
||||
for name in names:
|
||||
assert callable(getattr(config_views, name)), (
|
||||
f"config_views 缺消费视图 resolver: {name}")
|
||||
|
||||
|
||||
def test_name_to_region_extras_view_keeps_inline_semantics():
|
||||
"""ConversationManager.__init__ 内联取值的等价迁移: misc 组缺省/空
|
||||
dict → None(``or None`` 语义原样保留);有扩表 → 原样返回。"""
|
||||
assert config_views.name_to_region_extras(None) is None
|
||||
assert config_views.name_to_region_extras({}) is None
|
||||
assert config_views.name_to_region_extras(
|
||||
{"misc": {"name_to_region_extras": {}}}) is None
|
||||
assert config_views.name_to_region_extras(
|
||||
{"misc": {"name_to_region_extras": {"德意志": "DE"}}}) == {"德意志": "DE"}
|
||||
|
||||
|
||||
def test_conn_limiter_zero_or_negative_limit_falls_back():
|
||||
"""ws_conn_limit=0/负 → 构造默认(原语义 0=全拒;统一后与姊妹键
|
||||
rate_limit_per_hour 一致)。"""
|
||||
lim = ConnectionLimiter(limit=2)
|
||||
assert lim.try_acquire("t", "1.1.1.1", limit=0) is True # 0 ≠ 全拒
|
||||
assert lim.try_acquire("t", "1.1.1.1", limit=0) is True
|
||||
assert lim.try_acquire("t", "1.1.1.1", limit=0) is False # 默认 2 满
|
||||
assert lim.try_acquire("t", "2.2.2.2", limit=-3) is True # 负数同回退
|
||||
|
||||
|
||||
def test_chat_rest_rate_limit_zero_or_negative_falls_back():
|
||||
"""rate_limit_per_hour=0/负 → DEFAULT_MAX(chat_rest 取值处与
|
||||
ws_conn_limit 同款)。"""
|
||||
assert _resolve_rate_limit(
|
||||
{"engine": {"rate_limit_per_hour": 0}}) == DEFAULT_MAX
|
||||
assert _resolve_rate_limit(
|
||||
{"engine": {"rate_limit_per_hour": -5}}) == DEFAULT_MAX
|
||||
assert _resolve_rate_limit({}) == DEFAULT_MAX
|
||||
assert _resolve_rate_limit(None) == DEFAULT_MAX
|
||||
assert _resolve_rate_limit(
|
||||
{"engine": {"rate_limit_per_hour": 7}}) == 7
|
||||
@@ -10,40 +10,7 @@ from app.engine.models import (
|
||||
)
|
||||
from app.engine.services.conversation import CONTACT_ASK_TRIGGER_RE, ConversationManager
|
||||
from app.engine.services import email_service
|
||||
from tests.conftest import build_jiancai_runtime
|
||||
|
||||
|
||||
class FakeAI:
|
||||
"""替代 GLM 的确定性桩:构造参数即每轮返回值。"""
|
||||
|
||||
def __init__(self, extract=None, evaluate=None):
|
||||
self._extract_seq = extract if isinstance(extract, list) else [extract or {}]
|
||||
self._evaluate = evaluate or {}
|
||||
self.eval_calls = 0
|
||||
|
||||
async def chat(self, messages, context=""):
|
||||
return "ok reply"
|
||||
|
||||
async def stream_chat(self, messages, context=""):
|
||||
for token in ("ok ", "reply"):
|
||||
yield token
|
||||
|
||||
async def extract_info(self, messages, existing_info):
|
||||
# 第 N 轮客户消息 → seq[N-1](messages 为 [u,a,u,a...] 全量历史)
|
||||
idx = min(max(len(messages) // 2 - 1, 0), len(self._extract_seq) - 1)
|
||||
return self._extract_seq[idx]
|
||||
|
||||
async def evaluate(self, profile, recent_messages):
|
||||
self.eval_calls += 1
|
||||
result = dict(self._evaluate)
|
||||
result.setdefault("intent_level", "pending")
|
||||
result.setdefault("reasoning", "")
|
||||
result.setdefault("contact_info", {})
|
||||
result.setdefault("info_sufficient", False)
|
||||
if result["intent_level"] not in ("interested", "not_interested", "pending"):
|
||||
result["intent_level"] = "pending"
|
||||
return result
|
||||
|
||||
from tests.conftest import FakeAI, build_jiancai_runtime
|
||||
|
||||
INTERESTED = {
|
||||
"intent_level": "interested", "reasoning": "预算国家品类齐全",
|
||||
|
||||
@@ -6,26 +6,12 @@ from sqlalchemy import select
|
||||
from app.engine.models import ConversationSource, LeadPushLog
|
||||
from app.engine.services import email_service, lead_push_service
|
||||
from app.engine.services.conversation import ConversationManager
|
||||
from tests.conftest import build_jiancai_runtime
|
||||
from tests.conftest import FakeAI, build_jiancai_runtime
|
||||
|
||||
|
||||
class FakeAI:
|
||||
"""同 test_lead_email_trigger 的确定性桩。"""
|
||||
|
||||
def __init__(self, extract=None):
|
||||
self._extract_seq = extract if isinstance(extract, list) else [extract or {}]
|
||||
|
||||
async def stream_chat(self, messages, context=""):
|
||||
for token in ("ok ", "reply"):
|
||||
yield token
|
||||
|
||||
async def extract_info(self, messages, existing_info):
|
||||
idx = min(max(len(messages) // 2 - 1, 0), len(self._extract_seq) - 1)
|
||||
return self._extract_seq[idx]
|
||||
|
||||
async def evaluate(self, profile, recent_messages):
|
||||
return {"intent_level": "interested", "reasoning": "留号",
|
||||
"contact_info": {}, "info_sufficient": True}
|
||||
# 本文件场景依赖"interested"触发推送——conftest FakeAI(超集)经 evaluate
|
||||
# 参数注入,与原本地拷贝的固定返回值逐字等价(setdefault 补 contact_info={})
|
||||
_EVAL = {"intent_level": "interested", "reasoning": "留号",
|
||||
"info_sufficient": True}
|
||||
|
||||
|
||||
def _setup(async_db, monkeypatch, ai, lead_succeeds=True):
|
||||
@@ -81,7 +67,7 @@ def _logs(async_db):
|
||||
|
||||
|
||||
def test_lead_email_triggers_pending_push(async_db, monkeypatch):
|
||||
ai = FakeAI(extract={"联系方式": {"手机": "+254722111117"}})
|
||||
ai = FakeAI(extract={"联系方式": {"手机": "+254722111117"}}, evaluate=_EVAL)
|
||||
(manager, conv), sent, fired = _setup(async_db, monkeypatch, ai)
|
||||
_send(manager, conv.session_id, "my whatsapp is +254722111117")
|
||||
assert [s[0] for s in sent] == ["lead"]
|
||||
@@ -94,7 +80,7 @@ def test_lead_email_triggers_pending_push(async_db, monkeypatch):
|
||||
|
||||
def test_smtp_failure_still_pushes(async_db, monkeypatch):
|
||||
"""设计定案: 推送与 SMTP 成败解耦——邮件失败询盘照推。"""
|
||||
ai = FakeAI(extract={"联系方式": {"手机": "+254722111117"}})
|
||||
ai = FakeAI(extract={"联系方式": {"手机": "+254722111117"}}, evaluate=_EVAL)
|
||||
(manager, conv), sent, fired = _setup(
|
||||
async_db, monkeypatch, ai, lead_succeeds=False)
|
||||
_send(manager, conv.session_id, "my whatsapp is +254722111117")
|
||||
@@ -104,7 +90,7 @@ def test_smtp_failure_still_pushes(async_db, monkeypatch):
|
||||
|
||||
|
||||
def test_wechat_only_push_marked_skipped(async_db, monkeypatch):
|
||||
ai = FakeAI(extract={"联系方式": {"微信": "li_ming_88"}})
|
||||
ai = FakeAI(extract={"联系方式": {"微信": "li_ming_88"}}, evaluate=_EVAL)
|
||||
(manager, conv), sent, fired = _setup(async_db, monkeypatch, ai)
|
||||
_send(manager, conv.session_id, "add my wechat li_ming_88")
|
||||
assert [s[0] for s in sent] == ["lead"] # 我方邮件照发(wechat 算 confirmed)
|
||||
@@ -117,7 +103,8 @@ def test_wechat_only_push_marked_skipped(async_db, monkeypatch):
|
||||
def test_correction_push_event_update_after_success(async_db, monkeypatch):
|
||||
"""改号 → 更正推送;前次已 success(手动置位模拟 _run_push)→ event=update。"""
|
||||
ai = FakeAI(extract=[{"联系方式": {"手机": "+254722111117"}},
|
||||
{"联系方式": {"手机": "+254799999999"}}])
|
||||
{"联系方式": {"手机": "+254799999999"}}],
|
||||
evaluate=_EVAL)
|
||||
(manager, conv), sent, fired = _setup(async_db, monkeypatch, ai)
|
||||
_send(manager, conv.session_id, "my whatsapp is +254722111117")
|
||||
assert len(fired) == 1 and fired[0].push_type == "lead"
|
||||
@@ -139,13 +126,13 @@ def test_correction_push_event_update_after_success(async_db, monkeypatch):
|
||||
|
||||
def test_returning_push_follows_nudge(async_db, monkeypatch):
|
||||
"""24h 内同值另已发过 → 回访轻提醒 → 推送 push_type=returning,event=new。"""
|
||||
ai1 = FakeAI(extract={"联系方式": {"email": "same@x.com"}})
|
||||
ai1 = FakeAI(extract={"联系方式": {"email": "same@x.com"}}, evaluate=_EVAL)
|
||||
(m1, c1), _s1, f1 = _setup(async_db, monkeypatch, ai1)
|
||||
_send(m1, c1.session_id, "mail same@x.com")
|
||||
assert len(f1) == 1 and f1[0].push_type == "lead"
|
||||
asyncio.run(m1.db.close())
|
||||
|
||||
ai2 = FakeAI(extract={"联系方式": {"email": "same@x.com"}})
|
||||
ai2 = FakeAI(extract={"联系方式": {"email": "same@x.com"}}, evaluate=_EVAL)
|
||||
(m2, c2), sent2, f2 = _setup(async_db, monkeypatch, ai2)
|
||||
_send(m2, c2.session_id, "im back, same@x.com")
|
||||
assert [s[0] for s in sent2] == ["returning"]
|
||||
@@ -157,7 +144,7 @@ def test_returning_push_follows_nudge(async_db, monkeypatch):
|
||||
def test_push_disabled_writes_nothing(async_db, monkeypatch):
|
||||
"""PUSH_ENABLED=false(默认): 零日志零派发,既有行为完全不变。"""
|
||||
monkeypatch.setattr(lead_push_service.settings, "PUSH_ENABLED", False)
|
||||
ai = FakeAI(extract={"联系方式": {"手机": "+254722111117"}})
|
||||
ai = FakeAI(extract={"联系方式": {"手机": "+254722111117"}}, evaluate=_EVAL)
|
||||
|
||||
sent = []
|
||||
|
||||
@@ -205,7 +192,7 @@ def test_fire_end_to_end_single_loop(async_db, monkeypatch):
|
||||
return True
|
||||
|
||||
monkeypatch.setattr(email_service, "send_lead_email", fake_lead)
|
||||
ai = FakeAI(extract={"联系方式": {"手机": "+254722111117"}})
|
||||
ai = FakeAI(extract={"联系方式": {"手机": "+254722111117"}}, evaluate=_EVAL)
|
||||
|
||||
async def run():
|
||||
async with async_db() as db:
|
||||
@@ -232,7 +219,7 @@ def test_fire_happens_only_after_commit(async_db, monkeypatch):
|
||||
确定性落在提交前 → NoResultFound 被记录 → 变红;正确序 gate 不触发。
|
||||
"""
|
||||
from sqlalchemy import select as sa_select
|
||||
ai = FakeAI(extract={"联系方式": {"手机": "+254722111117"}})
|
||||
ai = FakeAI(extract={"联系方式": {"手机": "+254722111117"}}, evaluate=_EVAL)
|
||||
sent = []
|
||||
read_result = {}
|
||||
commit_gate: asyncio.Event | None = None
|
||||
|
||||
@@ -18,13 +18,16 @@ 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 import email_service
|
||||
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.conversation import ConversationManager
|
||||
from app.engine.services.rate_limit import _reset_for_test, check
|
||||
# Task 10: 消费视图 resolver 单入口(原 ai_service/conversation 直引与
|
||||
# email_service/lead_push_service 模块属性取值改为这里,断言不动)
|
||||
from app.platform.config_views import (
|
||||
context_seed_map, resolve_ai_settings, resolve_contact_flags,
|
||||
resolve_email_settings, resolve_push_settings,
|
||||
)
|
||||
from app.platform.tenant_config import TenantConfig
|
||||
from tests.conftest import build_jiancai_runtime
|
||||
|
||||
@@ -39,7 +42,7 @@ 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": {
|
||||
r = resolve_email_settings(_cfg({"email": {
|
||||
"smtp": {"host": "t.smtp", "port": 587, "user": "t@x.com",
|
||||
"password": "t-pass"},
|
||||
"sender_name": "Auto Co",
|
||||
@@ -59,7 +62,7 @@ def test_email_falls_back_to_env_settings(monkeypatch):
|
||||
("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)
|
||||
r = resolve_email_settings(_cfg({}).data)
|
||||
assert r == {
|
||||
"host": "env-host", "port": 465, "user": "env@x.com",
|
||||
"password": "env-pass", "sender_name": "HESUPIPE",
|
||||
@@ -73,7 +76,7 @@ def test_email_partial_smtp_override(monkeypatch):
|
||||
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(
|
||||
r = 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")
|
||||
@@ -112,8 +115,8 @@ def test_seed_tenants_ship_email_group(monkeypatch):
|
||||
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(
|
||||
au = resolve_email_settings(build_auto_runtime().config.data)
|
||||
jc_r = resolve_email_settings(
|
||||
build_jiancai_runtime().config.data)
|
||||
assert au["sender_name"] == "XYH"
|
||||
assert au["notify_emails"] == ["18339827479@163.com"]
|
||||
@@ -140,7 +143,7 @@ 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": {
|
||||
r = 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,
|
||||
@@ -158,7 +161,7 @@ def test_push_reads_tenant_section(monkeypatch):
|
||||
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)
|
||||
r = 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,
|
||||
@@ -308,7 +311,7 @@ def test_factory_model_sentinel_in_sync():
|
||||
与 DEFAULT_CONFIG 出厂 model 一致——默认值一旦漂移,"回退 env AI_MODEL"
|
||||
永不触发且无测试报错。"""
|
||||
from app.platform.defaults import DEFAULT_CONFIG
|
||||
from app.engine.services.ai_service import _FACTORY_DEFAULT_MODEL
|
||||
from app.platform.config_views import _FACTORY_DEFAULT_MODEL
|
||||
assert _FACTORY_DEFAULT_MODEL == DEFAULT_CONFIG["ai"]["model"]
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user