diff --git a/docs/superpowers/plans/2026-10-12-phase1-engine-merge-tenantization.md b/docs/superpowers/plans/2026-10-12-phase1-engine-merge-tenantization.md index 077b691..7b000b4 100644 --- a/docs/superpowers/plans/2026-10-12-phase1-engine-merge-tenantization.md +++ b/docs/superpowers/plans/2026-10-12-phase1-engine-merge-tenantization.md @@ -94,21 +94,9 @@ jc/pytest.ini 的 testpaths 已在 Task 1 建 `app.services` → `app.engine.services`,`app.providers` → `app.engine.providers`,`app.api.routes` → `app.engine.routes`,`app.models.database` → `app.engine.models`,`app.config` → `app.engine.config`。在 tests/ 里同样替换。 -- [ ] **Step 3: 写最小 app/main.py(过渡版,单租户直跑)** +- [ ] **Step 3: 过渡版 app/main.py —— 照抄 jc main.py 全文(含模块级 engine/async_session/Base 与 lifespan),只改 import 路径与去掉 admin 路由** -```python -from fastapi import FastAPI -from fastapi.staticfiles import StaticFiles -from app.engine.routes.chat import router as ws_router -from app.engine.routes.chat_rest import router as rest_router - -app = FastAPI(title="all-kefu (phase1 transitional)") -app.include_router(ws_router) -app.include_router(rest_router) -app.mount("/static", StaticFiles(directory="static"), name="static") -``` - -注意:jc 的 main.py 里有 lifespan 建表/迁移/sweep 逻辑——本步先不做(Task 5 由 Registry 逐租户做)。若 tests/conftest.py 依赖 jc 的 app 工厂,先按其原样改造指向新 main。 +⚠️ **不要写"最小 main"**:`chat.py`/`chat_rest.py`/`lead_push_service.py` 都 `from app.main import async_session`,jc 的 conftest 还 `from app.main import engine`——jc conftest 里文档化过 main→chat_rest 的循环导入次序坑。过渡期保留 jc main.py 的全局 engine/async_session 结构(Task 5 才由 Registry 接管),admin 路由 import 行删除。同时先把 `jc/static/` 整目录拷到 `static/`(Task 10 才改造,但 `StaticFiles(directory="static")` 需要目录先存在)。 - [ ] **Step 4: 跑全量测试建立基线** @@ -118,7 +106,7 @@ Expected: 除 admin 相关(已删)外全部 PASS。若有 FAIL,逐个修 - [ ] **Step 5: 提交** ```bash -git add -A && git commit -m "feat: jc 引擎主线搬入 app/engine(320 测试基线全绿)" +git add -A && git commit -m "feat: jc 引擎主线搬入 app/engine(315 测试基线全绿:320 去掉 admin 5 用例)" ``` --- @@ -238,15 +226,17 @@ git add app/platform tests/test_config_merge.py && git commit -m "feat: 出厂 ```json { - "price_patterns": [""], + "price_patterns": [""], "deflections": {"en": ["..."], "zh": ["..."], "es": ["..."]}, - "no_contact": {"replacement": "", "asked_replacement": "<...>"}, + "deflections_contact": {"en": [""], "zh": ["..."], "es": ["..."]}, + "no_contact": {"web": "", "app": "", "already": ""}, "triggers": {"boundary": ["..."], "budget": ["..."], "contact_ask": ["..."]}, - "anti_kill": ["DN", "PN", "SDR", "PE100", "SCH", "ISO4427", "20ft", "km", ""], "cc_templates": {"en": ["<回显确认模板 EN>"], "zh": [""], "es": [""]} } ``` +⚠️ **anti_kill 不设字段**:两个源项目的防误杀都不是独立常量,而是内嵌在正则断言里(如后顾放行、`(?!\s*(公里|km))` 排除)——没有可提取的"清单"。两租户防误杀差异由各自 price_patterns 自带断言体现,CompiledWall 不设 anti_kill。 + `seed_profiles/auto/wall.json`:同一结构,price_patterns 用上游 ai-kefu 的中英+$ 词表、anti_kill 换汽车清单(km/年款等)、deflections 去掉 es。 - [ ] **Step 2: 写编译器(先写失败测试)** @@ -303,9 +293,9 @@ def compile_wall(cfg: dict) -> CompiledWall: return CompiledWall( price_patterns=prices, deflections={k: list(v) for k, v in cfg.get("deflections", {}).items()}, + deflections_contact={k: list(v) for k, v in cfg.get("deflections_contact", {}).items()}, no_contact=dict(cfg.get("no_contact", {})), triggers=triggers, - anti_kill=tuple(cfg.get("anti_kill", [])), cc_templates={k: list(v) for k, v in cfg.get("cc_templates", {}).items()}, ) ``` @@ -335,8 +325,9 @@ git add -A && git commit -m "feat: 词表数据化——boundary/conversation - [ ] **Step 1: 写两个 tenant.json** +**seed_profiles/jiancai/tenant.json**(纯 JSON,无注释): + ```json -// seed_profiles/jiancai/tenant.json { "slug": "jiancai", "name": "HESUPIPE 建材", "status": "active", "domains": ["chat2.agent.hnxinyouhe.com", "localhost.jc"], "config": { "brand": {"title": "HESUPIPE Support", "color": "#066fc0", @@ -482,9 +473,11 @@ git add -A && git commit -m "feat: PromptProvider 化 + $DEFLECTION_POOL 单一 **Files:** - Create: `app/platform/host_middleware.py` -- Modify: `app/engine/routes/chat.py`、`chat_rest.py`、`app/main.py` +- Modify: `app/engine/routes/chat.py`、`chat_rest.py`、`app/main.py`、`tests/conftest.py` - Test: `tests/test_host_routing.py` +⚠️ conftest 需新增 `build_app(seed_dir, data_dir)` 工厂与 `app_client` 夹具(jc conftest 没有这个):临时目录建租户库 → Registry.load_from_dir → 挂中间件的 app → TestClient(可设 host 头)。后续 Task 9/10 的接线测试复用它。 + - [ ] **Step 1: 写失败测试** ```python @@ -567,33 +560,65 @@ def test_limit_per_ip(): --- -### Task 9: 推送异步化(httpx) +### Task 9: email/lead_push/rate_limit 租户配置接线 + +**背景(评审核实的事实):** jc 现状——email_service 读全局 settings(SMTP_*/NOTIFY_EMAIL,发件名硬编码【HESUPIPE】);lead_push_service 读 settings.PUSH_*;rate_limit 的 key=(ip, device)。既有测试 monkeypatch 的都是 settings,所以这个缺口**测试暴露不了**——必须新增接线测试逼出来。不接线的话,Phase 1 结束时两租户联调会静默错配(auto 用上 jc 的 SMTP/推送目标)。 + +另:jc 的 lead_push 已是 httpx.AsyncClient(`_do_post`)、glm 已走 executor,零同步 IO 纪律在主线已满足,无需改造(Task 12 做审计确认)。 **Files:** -- Modify: `app/engine/services/lead_push_service.py` -- Test: `tests/test_lead_push_async.py`(新建)+ 既有 push 测试回归 +- Modify: `app/engine/services/email_service.py`、`lead_push_service.py`、`rate_limit.py` +- Test: `tests/test_tenant_config_wiring.py` -- [ ] **Step 1: 写失败测试**(AsyncMock httpx): +- [ ] **Step 1: 写失败测试** ```python -import httpx -from unittest.mock import AsyncMock, MagicMock +# tests/test_tenant_config_wiring.py +from types import SimpleNamespace +from app.platform.tenant_config import TenantConfig -async def test_push_uses_httpx_async(monkeypatch): - resp = MagicMock(status_code=200) - resp.json = lambda: {"results": [{"external_key": "k", "status": "accepted"}]} - client = MagicMock() - client.post = AsyncMock(return_value=resp) - monkeypatch.setattr("app.engine.services.lead_push_service._open_client", - lambda: _Ctx(client)) - ... # dispatch 一条 pending → 断言 client.post awaited 一次、url/headers 正确 +def _cfg(): + snap = {"email": {"smtp": {"host": "s", "port": 465, "user": "u", "pass": "p"}, + "sender_name": "【T】", "notify_emails": ["a@b.c"]}, + "push": {"enabled": True, "url": "http://x", "api_key": "k", + "prefix": "t1", "timeout": 5, "max_attempts": 1, + "retry_backoff_base": 1.0}} + return TenantConfig.from_snapshot("t1", "T", ["t1.local"], snap) + +def test_email_reads_tenant_smtp(monkeypatch): + import app.engine.config as cfg_mod + monkeypatch.setattr(cfg_mod, "settings", + SimpleNamespace(SMTP_HOST="WRONG", SMTP_PORT=1, SMTP_USER="WRONG", + SMTP_PASS="WRONG", NOTIFY_EMAIL="wrong@x")) + resolved = resolve_email_settings(_cfg()) # 期望读租户段而非全局 + assert resolved.notify_emails == ["a@b.c"] and resolved.sender_name == "【T】" + +def test_push_reads_tenant_section(): + p = resolve_push_settings(_cfg()) + assert p.url == "http://x" and p.prefix == "t1" + +def test_rate_limit_key_includes_tenant(): + from app.engine.services.rate_limit import RateLimiter + lim = RateLimiter() + assert lim.check("t1", "1.1.1.1", "dev1", limit=2) is True + assert lim.check("t1", "1.1.1.1", "dev1", limit=2) is True + assert lim.check("t2", "1.1.1.1", "dev1", limit=2) is True # 换租户不受影响 ``` -- [ ] **Step 2: 改造**:同步 requests/urllib 调用 → `async with httpx.AsyncClient(timeout=cfg.timeout) as client: await client.post(url, json=payload, headers=...)`;重试矩阵逻辑不变(仍只重试 5xx/传输错误)。**事件循环零同步 IO** 纪律的落点(spec §6.1/§11)。 +- [ ] **Step 2: 跑测试确认失败** -- [ ] **Step 3: 既有 test_lead_push_* 回归**(原 mock 点从 sync 库改挂到新 _open_client/AsyncClient 工厂) +Run: `python -m pytest tests/test_tenant_config_wiring.py -v` +Expected: FAIL(resolve_* 函数不存在 / rate_limit 不收 tenant 参数) -- [ ] **Step 4: 提交** `git commit -m "feat: 推送改 httpx.AsyncClient(零同步 IO)"` +- [ ] **Step 3: 实现接线**:email_service/lead_push_service 顶部各加 `resolve_email_settings(config)` / `resolve_push_settings(config)`(读 `config.data` 对应组;email.smtp 为 None 时回退平台默认 = 本阶段 ProcessSettings 的 env 值);服务调用点从读 settings 改为接收经 ConversationManager 传入的租户 config;rate_limit 桶 key 改 `(tenant_slug, ip, device)`、limit 读 `config.engine.rate_limit_per_hour` + +- [ ] **Step 4: 既有 email/push 测试回归**:monkeypatch 点从 settings 迁到 resolve_*_settings 返回值(或直接传 config 夹具),断言不变 + +- [ ] **Step 5: 全量回归 + 提交** + +```bash +git add -A && git commit -m "feat: email/push/rate_limit 读租户配置段(服务层 settings 引用清零)" +``` --- @@ -638,7 +663,7 @@ def test_widget_keys_namespaced_by_slug(): **Files:** - Modify: `static/widget.js`(placeholder 可配置——对齐 ai-kefu 的 input-placeholder 实现,读 brand.placeholder + data-placeholder 覆盖) -- Test: 从 `D:\refactor\ai-kefu\tests\` 拷入 `test_channel_hygiene.py`、`test_chat_rest_ip.py`、`test_lead_push_cc.py`、`test_cc_integration.py`、test_prompt_static.py、test_ip_service_region.py → 修 import 跑绿(行为以 jc 实现为准,断言不适处改断言不改实现) +- Test: 从 `D:\refactor\ai-kefu\tests\` 拷入 `test_channel_hygiene.py`、`test_chat_rest_ip.py`、`test_lead_push_cc.py`、`test_cc_integration.py`、`test_prompt_static.py`、`test_ip_service_region.py`、`test_placeholder_static.py` → 修 import 跑绿(行为以 jc 实现为准,断言不适处改断言不改实现) - [ ] **Step 1: 拷 6 个测试文件、修 import、跑** - [ ] **Step 2: 红→绿循环**:失败断言逐条判断是"上游测的旧行为"(改断言)还是"jc 缺的修复"(补实现,注明同步自上游) @@ -648,10 +673,11 @@ def test_widget_keys_namespaced_by_slug(): ### Task 12: Phase 1 收尾 -- [ ] **Step 1: 全量回归** `python -m pytest -q` → 全绿,记录用例数(目标 ≥ 320 + 新增 ~20) -- [ ] **Step 2: spec §6.2 合并清单逐项打勾核对**(jc 六项取入/上游吸收/三不带:blog.html、Redis、旧版 whatsapp-cc) -- [ ] **Step 3: 更新 CLAUDE.md**(Phase 1 完成状态、本地起服务方法、测试基线数) -- [ ] **Step 4: 提交** `git commit -m "chore: Phase 1 收尾——引擎合并租户化完成"` +- [ ] **Step 1: 全量回归** `python -m pytest -q` → 全绿,记录用例数(基线 315 + 新增 ~20) +- [ ] **Step 2: 零同步 IO 审计**:`grep -rn "requests\.\|urllib\|time\.sleep" app/` 确认 async 路径无同步 IO(已核实:lead_push_service._do_post 为 httpx.AsyncClient、providers/glm.py 流式走 executor);审计结论写入 CLAUDE.md +- [ ] **Step 3: spec §6.2 合并清单逐项打勾核对**(jc 六项取入/上游吸收/三不带:blog.html、Redis、旧版 whatsapp-cc) +- [ ] **Step 4: 更新 CLAUDE.md**(Phase 1 完成状态、本地起服务方法、测试基线数) +- [ ] **Step 5: 提交** `git commit -m "chore: Phase 1 收尾——引擎合并租户化完成"` ---