diff --git a/app/engine/routes/chat_rest.py b/app/engine/routes/chat_rest.py index c976efd..270b4d7 100644 --- a/app/engine/routes/chat_rest.py +++ b/app/engine/routes/chat_rest.py @@ -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") diff --git a/app/engine/services/ai_service.py b/app/engine/services/ai_service.py index dbd4401..b19c603 100644 --- a/app/engine/services/ai_service.py +++ b/app/engine/services/ai_service.py @@ -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) diff --git a/app/engine/services/conn_limiter.py b/app/engine/services/conn_limiter.py index 7413397..b0c248c 100644 --- a/app/engine/services/conn_limiter.py +++ b/app/engine/services/conn_limiter.py @@ -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: diff --git a/app/engine/services/conversation.py b/app/engine/services/conversation.py index a469938..b479e98 100644 --- a/app/engine/services/conversation.py +++ b/app/engine/services/conversation.py @@ -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): diff --git a/app/engine/services/email_service.py b/app/engine/services/email_service.py index 2ba11a8..e0bbf1a 100644 --- a/app/engine/services/email_service.py +++ b/app/engine/services/email_service.py @@ -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 "" diff --git a/app/engine/services/lead_push_service.py b/app/engine/services/lead_push_service.py index ef3a107..000436d 100644 --- a/app/engine/services/lead_push_service.py +++ b/app/engine/services/lead_push_service.py @@ -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)) diff --git a/app/platform/config_views.py b/app/platform/config_views.py new file mode 100644 index 0000000..dc517fc --- /dev/null +++ b/app/platform/config_views.py @@ -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 diff --git a/app/platform/defaults.py b/app/platform/defaults.py index 11cd244..0785aff 100644 --- a/app/platform/defaults.py +++ b/app/platform/defaults.py @@ -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": {}, diff --git a/tests/conftest.py b/tests/conftest.py index a91491e..23a11a9 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -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 必填)。 diff --git a/tests/test_cc_integration.py b/tests/test_cc_integration.py index 93750b1..c5cf8f8 100644 --- a/tests/test_cc_integration.py +++ b/tests/test_cc_integration.py @@ -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 diff --git a/tests/test_channel_hygiene.py b/tests/test_channel_hygiene.py index db5e958..a6fba73 100644 --- a/tests/test_channel_hygiene.py +++ b/tests/test_channel_hygiene.py @@ -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 diff --git a/tests/test_config_views.py b/tests/test_config_views.py new file mode 100644 index 0000000..1736e2a --- /dev/null +++ b/tests/test_config_views.py @@ -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 diff --git a/tests/test_lead_email_trigger.py b/tests/test_lead_email_trigger.py index 102cd98..4477f72 100644 --- a/tests/test_lead_email_trigger.py +++ b/tests/test_lead_email_trigger.py @@ -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": "预算国家品类齐全", diff --git a/tests/test_lead_push_wiring.py b/tests/test_lead_push_wiring.py index 6053975..7a451f4 100644 --- a/tests/test_lead_push_wiring.py +++ b/tests/test_lead_push_wiring.py @@ -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 diff --git a/tests/test_tenant_config_wiring.py b/tests/test_tenant_config_wiring.py index 9e7f4d7..cb1f1ba 100644 --- a/tests/test_tenant_config_wiring.py +++ b/tests/test_tenant_config_wiring.py @@ -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"]