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
+45
View File
@@ -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 必填)。
+1 -33
View File
@@ -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
+1 -20
View File
@@ -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
+59
View File
@@ -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
+1 -34
View File
@@ -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": "预算国家品类齐全",
+15 -28
View File
@@ -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
+16 -13
View File
@@ -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"]