diff --git a/.gitignore b/.gitignore index 91f5442..f7961c0 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,4 @@ apps/web/lib/api/openapi.json .claude/settings.local.json # os .DS_Store +.gstack/ diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index c240952..b87db76 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -774,6 +774,7 @@ def assemble(project_id, chapter_no): | POST | `/projects/:id/characters/generate` | `{需求, count?, role?}` | 角色卡(预览,待入库) | 200 | | POST | `/projects/:id/characters` | 角色卡 | 入库结果 | 201 | | POST | `/projects/:id/style` | 样本(`mode=update?`) | `{job_id}`(异步见 7.4) | 202 | +| GET | `/projects/:id/style` | — | 最新文风指纹 `{dimensions, evidence, version}` | 200 (无指纹 404) | | GET | `/jobs/:id` | — | 任务状态/进度/结果 | 200 | | POST | `/projects/:id/outline` | `{范围}` | 大纲(含伏笔窗口) | 200 | | POST | `/projects/:id/chapters/:no/draft` | `{}` | **SSE 流**草稿 | 200(stream) | diff --git a/CLAUDE.md b/CLAUDE.md index 0492c6c..7222c4c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,9 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Repository status -**This is a spec-only repository at the pre-implementation stage.** There is no application code yet — only four design documents. The next step is Phase 0 / T0.1 in `DEV_PLAN.md` (scaffold the monorepo). Do not assume any build/test tooling exists until you create it. +**Phase 0–5 (M1–M5) are all shipped; there is no active phase.** The monorepo is scaffolded and the full MVP loop runs end-to-end: 立项 → 设定库 → 大纲 → 写章(SSE) → 四审(一致性/伏笔/文风/节奏) → 验收事务,底层带多 provider 网关(路由/回退/熔断) + 声明式 skill 注册表(§5.5) + Kimi Code OAuth(K1)。Last green gate: backend ruff/format clean · mypy **154 files** · alembic **no drift** · pytest **434 passed**; frontend lint/typecheck clean · vitest **155 passed** · build OK. **The build/test tooling exists — see §Toolchain below; do not re-scaffold.** + +`PROGRESS.md` is the authoritative status ledger (current phase + archived M1–M5 detail + changelog). Read it before any task. **Next work if resumed**: DEV_PLAN §4 P2/后续 (向量检索 / 真任务队列 / 全书一致性扫描 / 社区市场), or the 对标竞品功能补齐 plan (`~/.claude/plans/streamed-giggling-fiddle.md` — AI续写/扩写、AI拆书、通用生成器框架、细纲、模板库). Product: an AI-assisted **Chinese web-novel writing workflow**, delivered as a web app. The core thesis (`PRODUCT_SPEC.md §1.5`) is to treat long-form novel writing like software engineering — establish architecture (worldbuilding/characters/outline), generate against spec, test every chapter, maintain a single source of truth. diff --git a/PRODUCT_SPEC.md b/PRODUCT_SPEC.md index 8fc77e6..ce68dc7 100644 --- a/PRODUCT_SPEC.md +++ b/PRODUCT_SPEC.md @@ -506,6 +506,7 @@ skills(id, scope, name, description, model, system_prompt, | `POST /projects/:id/world/generate` | 设计世界观 | 立项/写作 | worldbuilder 生成/扩展世界观、力量体系、势力、地理 | | `POST /projects/:id/characters/generate` | AI 生成角色 | 立项/写作 | 一句话生成完整角色卡,入库;批量传 `count` + 定位 | | `POST /projects/:id/style` | 学文风 | 立项 | 学习作者文风,生成文风指纹(`mode=update` 增量补样本) | +| `GET /projects/:id/style` | 查看文风 | 立项/写作 | 读取最新文风指纹(完整 16 维 + 原文证据 + 版本号;无指纹 404) | | `POST /projects/:id/outline` | 排大纲 | 规划 | 生成/更新分卷分章大纲,提示伏笔回收窗口 | | `POST /projects/:id/chapters/:no/draft` | 写本章 | 写作 | 注入记忆+文风,产出本章草稿(streaming) | | `POST /projects/:id/chapters/:no/review` | 审稿 | 审稿 | 四审并行,输出冲突/漂移/节奏报告 | diff --git a/PROGRESS.md b/PROGRESS.md index 26b7e0e..950ad83 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -8,33 +8,60 @@ --- -## 当前阶段:Phase 3 (M3) · 伏笔 + 节奏 +## 当前阶段:K1 · Kimi Code OAuth(订阅 plan 接入) -> 目标:伏笔账本/到期提醒/看板 + 节奏引擎 + 大纲。**契约先行**:C6 扩(foreshadow-analyst/pace-checker/outliner 三 spec + 输出 schema) → C4 扩(审图加两审分支 + outline 节点) → C3 扩(outline/foreshadow 端点)。 -> 执行波次(依赖驱动):A(T3.1‖T3.4) → B(T3.2‖T3.3) → C(T3.5) → D(T3.6) → E(T3.7)。 -> 计划已定的衔接决策: -> **M3-a** `foreshadow` 表已在 T0.2 建齐(含 status/expected_close_from/to/progress/links 列)→ **M3 无新建表迁移**;T3.1 仅 Repository + 纯函数状态机,落 `core/ww_core/domain/`(@backend owned,同 T2.3 先例,故 T3.1 负责据实记 @backend)。 -> **M3-b** 到期扫描(T3.2)挂在**验收后**触发——接 M2 验收事务 §5.5 步骤 3 当前留的 `TODO(M3)`(人物 latest_state/伏笔更新)占位。 -> **M3-c** 第三审(foreshadow/pace)复用 M2 `build_review_graph` 的 `review_specs` 扩展点并入并行分支(C4 已稳定);**文风第四审属 M4/T4.2**,M3 先到三审齐。 -> **M3-d** 到期判据为纯函数确定性代码(非 Agent):`current_ch > expected_close_to AND status≠CLOSED → OVERDUE`。 +> **背景**:用户要求接入 Kimi Code 订阅 plan(device-flow OAuth)。**已知情接受 ToS/封号风险**——研究确认:用订阅 plan 调用需伪造官方客户端 `User-Agent` + `X-Msh-*` 头,Kimi ToS 视为违规、可能封停会员(见变更日志研究结论)。用户明确选择此路径(非合规的 API Key 路径)。与既有 API-key провайдер `kimi` **分离**,新增独立 provider `kimi-code` 以便切换。 +> **集成契约(研究确认,实现时对照 `github.com/picassio/pi-kimi-coder` 校验)**: +> - device flow:`POST https://auth.kimi.com/api/oauth/device_authorization`(client_id `17e5f671-d194-4dfb-9706-5516cb48c098`,env `KIMI_CLIENT_ID` 可覆盖,scope `kimi-code`)→ 返回 user_code/verification_uri/device_code/interval;轮询 `POST https://auth.kimi.com/api/oauth/token`(grant device_code,处理 authorization_pending/slow_down)。refresh:同 token 端点 grant refresh_token。 +> - 推理:base `https://api.kimi.com/coding/v1`(OpenAI 兼容),model `kimi-for-coding`,`Authorization: Bearer ` + **必需伪造头**:`User-Agent`(如 `KimiCLI/1.x`) + `X-Msh-Platform`/`X-Msh-Version`/`X-Msh-Device-Name`/`X-Msh-Device-Model`/`X-Msh-Os-Version`/`X-Msh-Device-Id`(持久 UUID)。缺头→403。 +> - token:access ~15min / refresh ~30d → **建网关时按需刷新**(临近过期刷新并持久化)。存储:`provider_credentials` 加 `auth_type`(api_key|oauth) + `oauth_enc`(Fernet 加密 JSON `{access_token,refresh_token,expires_at}`)。 +> **执行波次**:A(@db 加列+迁移 ‖ @llm Kimi coding 适配器) → B(@backend OAuth device 服务 + 端点 + store + `_build_provider_adapter` 接线) → C(@frontend 设置页连接/轮询/断开 + 路由档位) → D(@qa E2E device-flow mock + 头断言)。 +> **测试纪律**:device flow 注入 fake httpx(零联网);适配器断言伪造头集;token 刷新逻辑单测;E2E mock auth+coding 两端点。 | 任务 | 状态 | 负责 | 依赖 | 契约 | 备注 | |---|---|---|---|---|---| -| T3.1 foreshadow Repository + 纯函数状态机 | ✅ @backend 2026-06-18 | @backend | T0.2✅ | — | `foreshadow_state.py`(ForeshadowStatus+transition/is_overdue/apply_overdue_scan/InvalidTransition)+`foreshadow_repo.py`(`ForeshadowLedger*`,register/list_by_status/transition/record_progress/scan_overdue,只 flush);37 测;写侧加 Ledger 前缀避撞 C5 读侧 | -| T3.2 到期扫描(BackgroundTask) + 登记/状态接口 | ✅ @backend 2026-06-18 | @backend | T2.4✅, T3.1✅ | C3✅扩 | accept 后 BackgroundTask `run_overdue_scan`(自建 session 置 OVERDUE);`POST/PATCH .../foreshadow` 登记/转移(重复/非法→VALIDATION 422);9 测全仓 205 passed | -| T3.3 foreshadow-analyst + pace-checker 节点 | ✅ @llm 2026-06-18 | @llm | T2.2✅ | C6✅扩/C4✅扩 | `foreshadow_spec`+`pace_spec`(+schema)并入 `REVIEW_SPECS`(三审齐);collect 分列落 conflicts/foreshadow_sug/pace;`normalize_review` 加 `foreshadow`/`pace` 事件;~30 测;文风审留 M4 | -| T3.4 outliner Agent + 伏笔窗口 | ✅ @llm 2026-06-18 | @llm | T1.1✅ | C6✅扩 | `packages/agents/`(outliner_spec+OutlineResult/OutlineChapter/ForeshadowWindow)+`orchestrator/outline_node.py`(run_outline 只读不写库);20 测;C6 扩稳定 | -| T3.5 API:outline + foreshadow | ✅ @backend 2026-06-18 | @backend | T3.1✅, T3.4✅ | C3✅扩 | `GET .../foreshadow?status=`(看板,续 foreshadow.router) + `POST .../outline`(独立 outline.py,run_outline analyst→upsert outline 表+commit);outline 写侧 repo;+15 测全仓 220 passed | -| T3.6 伏笔看板 + 大纲编辑器 + 节奏报告 | ✅ @frontend 2026-06-18 | @frontend | T3.3✅, T3.5✅ | — | `/foreshadow`(四泳道+登记/转移) + `/outline`(beats+窗口徽标) + 审稿页扩消费 foreshadow/pace(节拍图▁▃▅);gen:api 纳 M3 端点;vitest 69(+24) | -| T3.7 M3 E2E | ✅ @qa 2026-06-18 | @qa | T3.6✅ | — | `tests/test_m3_e2e.py` 3 用例真跑(非 skip):伏笔埋设→进展→验收后扫描 OVERDUE✓ + 大纲 upsert 含窗口✓ + 三审 SSE(**strict-xfail 钉住并发记账 bug**);DB 真源断言 | -| T3.8 [bugfix] 并行审记账并发安全 | ✅ @llm 2026-06-18 | @llm | T3.7✅ | — | `SqlAlchemyLedgerSink.record` 改 add-only(去 `await flush`);并发回归测试(RED 已验旧实现炸);E2E case3 转 XPASS(三审齐落库 foreshadow_sug=2/pace✓)。**待 @qa 解钉 T3.7 strict-xfail 收尾** | +| K1.1 provider_credentials 加 auth_type + oauth_enc 列 | ✅ | @db | — | C2扩 | 完成:models 加 auth_type/oauth_enc + api_key_enc 改 nullable;迁移 `1f011c42bd4d`;alembic check 无漂移。⚠️ K1.3 @backend 须把 `StoredCredential.api_key_enc` 改 `bytes\|None`(见下方请求 + memory)| +| K1.2 Kimi coding 适配器(伪造头 + OAuth bearer) | ✅ | @llm | — | C1扩 | 完成:`adapters/kimi_code.py`(KimiCodeAdapter 子类化 OpenAICompatAdapter + 伪造头 + 稳定 device_id);`build_adapter` 加 `kimi-code` 分支;UA 验证为 `KimiCLI/1.5`(pi-kimi-coder 仅设 UA,X-Msh-* 为契约附带,见 C1扩 校正);11 测全绿 | +| K1.3 OAuth device 服务 + 端点 + store + 接线 | ✅ | @backend | K1.1,K1.2 | C3扩 | 完成:`services/kimi_oauth.py`(start/poll/refresh,注入 httpx Protocol)+3 端点(start 202→jobs 后台轮询/disconnect/status,复用 run_job)+`StoredCredential` 收口(api_key_enc→nullable+auth_type/oauth_enc,K1.1 的 2 mypy red 已消)+store `upsert_oauth_credential`/`delete_credential`+`_build_provider_adapter` OAuth 按需刷新(300s 缓冲)。**token 不进 job 结果/日志/响应**。全仓门禁绿(ruff/format/mypy **154**/alembic 无漂移/pytest **430**)。OpenAPI 加 3 端点→**@frontend K1.4 须 `pnpm gen:api`** | +| K1.4 设置页 Kimi Code 连接流 | ✅ | @frontend | K1.3 | — | 完成:`gen:api` 纳入 3 OAuth 端点;`KimiCodeOauth` 连接区(显 user_code+开授权页+轮询 job→已连接/断开)+ 可编辑档位路由(含 `kimi-code:kimi-for-coding`);纯逻辑 `lib/settings/{kimiOauth,providers}` + 25 新 vitest;前端门禁全绿(gen:api/lint/typecheck/vitest 155/build)。运行时验真 auth.kimi.com 留 K1.5 | +| K1.5 K1 E2E | ✅ | @qa | K1.4 | — | `tests/test_k1_kimi_oauth_e2e.py`(4 用例,真 pg):device-flow(pending×2→成功)→加密 token 真落 pg(无明文+回环)→建网关断言 KimiCodeAdapter coding base+伪造头+bearer→refresh-on-build 更新存量 token(DB 真源)→disconnect 清除;token 全程不进 job 结果/状态/start 响应。全仓门禁绿(ruff/format/mypy **154**/alembic 无漂移/pytest **434**)。**K1 完成,可封板** | -**M3 出口(DoD)✅ 全部达成**:伏笔账本/到期提醒/看板;大纲含回收窗口;节奏报告(三审齐真持久化)。后端门禁绿(ruff / mypy **111 文件** / pytest **228 passed** / alembic **无漂移**);前端门禁绿(gen:api / lint / tsc / vitest **69** / build);E2E 真实 DB + mock 网关零 token 走通 伏笔埋设→进展→验收后扫描 OVERDUE→看板 + 大纲含窗口 + 三审齐 SSE/留痕,DB 真源逐项断言。T3.7 E2E 暴露并发记账 bug → T3.8 add-only 修复 → 解钉 xfail。**M3 完成。** 下一阶段 M4(文风)见 DEV_PLAN,进入时拉入本表。 +**K1 出口(DoD)✅ 全部达成**:作者在设置页用 Kimi Code OAuth 登录(device flow,显 user_code + 开授权页 + 轮询 job)→ token Fernet 加密入库(`oauth_enc`,全程不进 job 结果/日志/响应)→ 把档位路由到 `kimi-code:kimi-for-coding` → 建网关时构 `KimiCodeAdapter`(coding base + 伪造 UA/X-Msh-* 头 + bearer)→ token 临近过期建网关时自动刷新并回写。后端门禁绿(ruff/format 干净 / mypy **154** / alembic **无漂移** / pytest **434**);前端门禁绿(gen:api/lint/tsc/vitest **155**/build);E2E(`tests/test_k1_kimi_oauth_e2e.py` 4 用例)device-flow mock 零联网走通,DB 真源逐项断言(含 token 不泄漏 + refresh-on-build + disconnect 清除)。**K1 完成。** **⚠️ 仍需用户用真账号对真 auth.kimi.com/api.kimi.com 做一次联网冒烟(device-flow 需伪造官方客户端 UA → ToS 视为违规、可能封停会员,用户已知情自担);E2E/单测仅验逻辑、不联网。** + +--- + +## 状态:M1–M5 全部完成(含 M5 三项收尾余项已清理) + +> **当前无活跃阶段。** Phase 0–5(M1–M5)已全部交付,M5 的三项非阻塞收尾余项(①Codex 读端点、②Anthropic/Gemini 真实适配器选择、③`packages/skills` 纳入 workspace)亦已清理。各阶段详情见下方「已归档阶段」+「变更日志」。 +> 最终门禁(本次全绿):后端 ruff/format 干净、mypy **144 文件**、alembic **无漂移**、pytest **400 passed**;前端 lint/typecheck 干净、vitest **130 passed**、build OK。 +> **下一步若继续**:DEV_PLAN §4 的 P2/后续(向量检索 / 真任务队列 / 全书一致性扫描 / 社区市场),进入时把对应任务拉入本表。 +> **仍开放的非阻塞备注**:@devops 复核根 `uv.lock`(@backend 改了 `packages/skills/pyproject.toml` deps 后已 `uv sync` 重建 ww-skills);Anthropic/Gemini 真实回退已接线(`build_adapter` 工厂 + 按 provider 选适配器)但尚未对真 SDK 联网验证(单测/E2E 用 OpenAI 兼容 mock)。 --- ## 已归档阶段 +
Phase 5 · 生成 + 多 provider + 扩展 (M5) — ✅ 全部完成(2026-06-19) + +T5.1 worldbuilder + character-gen 节点(C6✅扩;群像防雷同注入已生成+已有 + 入库前 continuity 校验缝 `precheck_generated_cards`)@llm · T5.4 网关多 provider 韧性(C1✅扩;Anthropic/Gemini 适配器 + 回退链 + `CircuitBreaker` + tenacity 退避 + 能力协商/降级 `served_by.degraded`,无 OpenAPI 形变)@llm · T5.5 Skill 运行时 + 规则(C8 + C3✅扩;`SkillRegistry.load` 越权声明拒绝 + 表权限沙箱 filter_reads/partition_writes + `POST /rules`)@backend · T5.2 生成/入库 API(C3✅扩;world/characters generate 预览 + POST characters 入库双 gate:continuity 预检 409 + partition_writes 白名单 + schema→JSONB 形变 + GET rules/skills;`build_gateway_for_tier` 接线 chain_from_routing 回退)@backend · T5.3+T5.6 角色生成器 + 世界观设计器 + 设定库 Codex + 规则页 + 技能库 + 命令面板(⌘K)@frontend · T5.7 M5 E2E + 切 provider(真 Gateway deepseek 失败→openai 回退、DB ledger 记实际服务方、能力降级)@qa。 +出口达成:后端门禁绿(ruff/format 干净 / mypy 142 / pytest 391 / alembic 无漂移,复用 T0.2 既有表零建表迁移);前端门禁绿(gen:api / lint / tsc / vitest 125 / build);E2E 真 DB + mock 多 provider 零 token 走通。**M5 完成 → M1–M5 全部交付。** 三非阻塞余项(①Codex 读端点、②Anthropic/Gemini 真实适配器选择、③`packages/skills` 纳入 workspace)随后于同日全部清理(见变更日志 follow-up #1–#4)。 + +
+ +
Phase 4 · 文风 (M4) — ✅ 全部完成(2026-06-19) + +T4.2 style-auditor 双轨 + 第四审(C6✅扩/C4✅扩;`style_extract_spec`(analyst,16 维指纹带证据,不进 review 图) + `style_drift_spec`(name="style",light,第四审并入 REVIEW_SPECS) + `refiner_spec`(writer 纯文本),REVIEW_SPECS 四审齐 + collect/SSE 加 style)@llm · T4.1 jobs 长任务基建(C3.5✅;`JobRepo`/`SqlJobRepo` + `run_job` 独立 session + lifespan zombie reaper)@backend · T4.3 API style + refine(C3✅扩;`POST /style` 202+jobs BackgroundTask、`GET /style` 最新指纹、`POST /refine` 同步 diff 不写库)@backend · T4.4 文风页 + 漂移 + 回炉 + job 轮询(`useJobPoll` 状态机 + StyleUpload/FingerprintView + 审稿页第四审 StylePanel + RefineView diff 采纳合入)@frontend · T4.5 M4 E2E(学文风→202 job→后台跑完→指纹真落 pg version+1 + 第四审 SSE/`chapter_reviews.style` 真填 + 回炉不写库)@qa。 +出口达成:后端门禁绿(ruff/format 干净 / mypy 115 / pytest 296 / alembic 无漂移,全程零建表迁移,复用 T0.2 预留的 `style_fingerprint`/`chapter_reviews.style`/`jobs`);前端门禁绿(gen:api / lint / tsc / vitest 96 / build);E2E 真 DB + mock 网关零 token 走通 学文风→第四审打分→回炉,DB 真源逐项断言。@docs 已把 `GET /style` 回写 ARCH §7.2/PRODUCT_SPEC §6。 + +
+ +
Phase 3 · 伏笔 + 节奏 (M3) — ✅ 全部完成(2026-06-18) + +T3.1 foreshadow Repository + 纯函数状态机(OPEN/PARTIAL/CLOSED/OVERDUE 全转移 + 到期判据 + `ForeshadowLedger*` 写侧 repo)@backend · T3.4 outliner Agent + 伏笔窗口(C6✅扩;`outliner_spec` + `OutlineResult`(含 foreshadow_windows) + `run_outline` 只读节点)@llm · T3.3 foreshadow-analyst + pace-checker 节点(C6✅扩/C4✅扩;并入 REVIEW_SPECS 三审齐 + collect 分列 + SSE foreshadow/pace 事件)@llm · T3.2 到期扫描 BackgroundTask + 登记/状态接口(验收后 `run_overdue_scan` 自建 session 置 OVERDUE + `POST/PATCH .../foreshadow`,重复/非法→VALIDATION 422)@backend · T3.5 API outline + foreshadow(C3✅扩;`GET .../foreshadow?status=` 看板 + `POST .../outline` upsert outline 表含窗口)@backend · T3.6 伏笔看板 + 大纲编辑器 + 节奏报告(四泳道 + beats+窗口徽标 + 审稿页节拍图▁▃▅)@frontend · T3.7 M3 E2E(伏笔埋设→进展→验收后扫描 OVERDUE + 大纲含窗口 + 三审 SSE,strict-xfail 钉住并发记账 bug)@qa · T3.8 [bugfix] 并行审记账并发安全(`SqlAlchemyLedgerSink.record` 改 add-only 去 `await flush`,解 AsyncSession 并发 flush 冲突,xfail 转 XPASS 后解钉)@llm。 +出口达成:后端门禁绿(ruff / mypy 111 / pytest 228 passed(0 xfailed/0 failed)/ alembic 无漂移,`foreshadow` 表已 T0.2 建齐无新建表);前端门禁绿(gen:api / lint / tsc / vitest 69 / build);E2E 真 DB + mock 网关零 token 走通,DB 真源逐项断言。T3.7 暴露并发记账 bug → T3.8 修复 → 解钉 xfail。 + +
+
Phase 2 · 一致性 + 验收 (M2) — ✅ 全部完成(2026-06-18) T2.1 续审 AgentSpec+结构化输出契约+网关 instructor 接线(C6✅稳定,改 C1:`run()` 填 `parsed`)@llm · T2.2 LangGraph 并行审+collect 留痕+review SSE(C4✅扩)@llm · T2.3 摘要/审稿留痕 Repository+章节 version(@backend,表已 T0.2 建)· T2.4 验收事务+冲突 gate(单原子事务 + R2 事务外提炼 digest)@backend · T2.5 review API+历史(C3✅扩)@backend · T2.6 审稿报告页+冲突裁决+验收 gate @frontend · T2.7 M2 E2E(真 DB+多档位 mock 网关零 token)@qa。 @@ -63,6 +90,45 @@ T0.1 monorepo 骨架 ✅ @devops · T0.2 16 MVP 表迁移(无漂移,users st > 格式:`- [YYYY-MM-DD] @skill 完成/进展 Txx — 一句话结果 + 影响的契约/文件` +- [2026-06-20] @frontend — **✅ 接上两个新读端点:大纲页 / 写作工作台重访时回显已存内容**。`pnpm gen:api` 纳入 `GET .../outline` + `GET .../chapters/{no}/draft` + schema `DraftView`;`lib/api/types.ts` 加 `DraftView` 别名。新增两个 RSC 读 helper(仿 `fetchForeshadow`/`fetchProject` 服务端读、无 CORS):`fetchOutline(projectId)`→`OutlineChapterView[]`(200 解包 chapters,**任何错误/空降级为 `[]`** 不阻塞进页),`fetchDraft(projectId,chapterNo)`→`DraftView|null`(复用 `getJsonOrNull`,**404→null 当空编辑器**,不抛)。**大纲页**(`outline/page.tsx`) 进页 `fetchOutline` 作 `initialChapters` 种入 `OutlineEditor`(`useOutline` 已据非空初值置 `ready`)→ 重访显已生成大纲;生成流照常覆盖所显章节。**写作工作台**(`write/page.tsx` + `Workbench`) 进页 `fetchDraft` 拿 `content` 作编辑器初值(`useState(initialText)`)→ 重访重载已写正文;404/新章 → `""` 空编辑器;**SSE 流式写 + AbortController + PUT 自动保存全不动**(流起始空、仅 streaming/done/aborted 变化才覆盖,初值不反扑活跃流);导出 `WORKBENCH_CHAPTER_NO`(原 `M1_CHAPTER_NO`)供页面拉对应章草稿。新增 `lib/api/server.test.ts`(5 vitest,mock fetch 验解包/降级/404→null)。前端门禁全绿:gen:api / lint / typecheck / vitest **160**(155→+5) / build。文件:`lib/api/{types,server,server.test}.ts` · `app/projects/[id]/{outline,write}/page.tsx` · `components/workbench/Workbench.tsx`。消费 contracts C3扩(@backend 两读端点)。 +- [2026-06-20] @backend — **✅ `GET /projects/{id}/chapters/{no}/draft` 草稿读端点(补缺口,镜像 GET /outline)**。写作工作台先前只有 `POST .../draft`(SSE 流写)+`PUT .../draft`(自动保存) 无读端点 → 重访工作台编辑器空白、看似「未保存」(其实 `chapters` 行在库)。补对称读侧:`GET /projects/{id}/chapters/{no}/draft` → 200 `DraftView{project_id,chapter_no,volume,status,version,content,length}`(**比 PUT 的 `DraftResponse` 多 `content`**——读侧需正文重建编辑器);无草稿行/正文空白 → **404 `NOT_FOUND`**(工作台据此呈现空编辑器,仿其它缺资源端点)。**直接复用既有 `chapter_repo.get_draft`**(同续审 `_resolve_review_draft` 读缝,`ChapterDraftView` 已含全字段,无新 repo 方法/view);只读不写库;多版本固定返草稿版次(`DRAFT_VERSION=1`)。文件:`schemas/projects.py`(+`DraftView`) · `routers/projects.py`(+GET endpoint+import) · `tests/test_projects.py`(+2:回灌正文 / 无草稿 404)。**全仓门禁绿**:ruff/format 干净、mypy **154 Success**、alembic **无漂移**、pytest **444 passed**(442→+2)。OpenAPI 新增 1 端点 → **@frontend 须 `cd apps/web && pnpm gen:api`** + 工作台初次加载拉已保存草稿回灌编辑器。记 contracts C3扩。 +- [2026-06-20] @backend — **✅ `GET /projects/{id}/outline` 大纲读端点(补缺口,仿 Codex 读端点)**。大纲页先前只有 `POST .../outline`(生成+落库)无读端点 → 重访页面已落库大纲不回显、看似「未保存」。补对称读侧:`GET /projects/{id}/outline` → 200 `OutlineResponse{chapters:[OutlineChapterView{no,volume,beats:list[str],foreshadow_windows}]}`(与 POST **同形**,按 chapter_no 升序);项目存在无大纲 → **200 空列表**(非 404);项目不存在 → 404 `NOT_FOUND`。**复用 C5 读侧**:扩 `OutlineRepo`(domain) + `SqlOutlineRepo`(assemble 读侧) 加 `list_for_project`(仿 Character/WorldEntityRepo,order_by chapter_no,既有 `get` 不动);新注入缝 `get_outline_read_repo`(仿 `get_rules_read_repo`)。beats DB JSONB `{"beats":[...]}` → 解包裸 `list[str]`(同 POST)。文件:`routers/outline.py`(+GET) · `services/project_deps.py`(+dep) · `domain/repositories.py`(+protocol method) · `memory/sql_repositories.py`(+impl) · `tests/{test_outline.py(+3),fakes_projects.py(+FakeOutlineReadRepo)}` + 补 2 既有 outline fake(test_memory/test_projects)满足扩展后的 protocol。**全仓门禁绿**:ruff/format 干净、mypy **154 Success**、alembic **无漂移**、pytest **442 passed**(439→+3)。OpenAPI 新增 1 端点 → **@frontend 须 `cd apps/web && pnpm gen:api`** + 大纲页初次加载拉已持久化大纲。记 contracts C3扩。 + +- [2026-06-19] @llm — **K1.2 修头校正:恢复 opencode 规范的完整 7 头(UA `KimiCLI/1.37.0`)**。先前误信 `picassio/pi-kimi-coder`(UA-only)把头集裁成仅 UA,是分歧/错误参考导致的误修——真源改为 `ooojustin/opencode-kimi`(`src/headers.ts`+`src/constants.ts`,1:1 镜像 kimi-cli v1.37.0,已重新拉取 master 核对)。`kimi_code_headers()` 现发 7 头:`User-Agent=KimiCLI/1.37.0` + `X-Msh-Platform=kimi_cli`(字面常量)+ `X-Msh-Version=1.37.0` + `X-Msh-Device-Name`(hostname ASCII 化)+ `X-Msh-Device-Model`(`kimi_device_model()`:macOS=`macOS {mac_ver} {machine}`)+ `X-Msh-Os-Version`(`platform.version()`)+ `X-Msh-Device-Id`(稳定 32 位无连字符 `uuid4().hex`:env `KIMI_DEVICE_ID` 优先→否则持久化 `~/.kimi/device_id`)。恢复 `kimi_device_id()`/`kimi_device_model()` 助手 + 重加 imports(os/uuid/socket/platform/pathlib)+ `__init__` 重导出。文件:`packages/llm_gateway/ww_llm_gateway/adapters/kimi_code.py` + `__init__.py` + 两测(断全 7 头键 + 固定字面值 + device-id 32 位小写 hex 稳定 + host 派生值非空 ASCII,env 注入保 CI 确定)。更 contracts C1扩(7 头 + opencode 真源)+ gotcha(pi-kimi-coder 分歧→回退)。门禁绿:ruff/format 干净、mypy 干净(27 文件)、pytest **60 passed**(packages/llm_gateway)。无 OpenAPI 形变。 +- [2026-06-19] @backend — **K1.3 修正:device authorization 重新带 `scope=kimi-code`**(实测 401 后修)。K1.5 联网实测登录拿到真 JWT 但调 `api.kimi.com/coding/v1` 回 `401 Invalid Authentication`——根因 token 缺 coding entitlement(省略了 scope)。`start_device_authorization` 请求体加 `scope=kimi-code`(=`{"client_id":,"scope":"kimi-code"}`,模块常量 `KIMI_CODE_SCOPE`);scope 只放 device-auth,token 交换/刷新不带。文件:`apps/api/ww_api/services/kimi_oauth.py` + 单测 `test_kimi_oauth_service.py`(`test_start_device_authorization_parses_response_and_sends_scope` 断 `data["scope"]=="kimi-code"`)。修正 C3扩 contract + decision「省略 scope」表述 + 加 gotcha。无 OpenAPI 形变,前端无需 re-gen。门禁绿:ruff/format 干净、mypy 干净、pytest **101 passed**(apps/api)。 +- [2026-06-19] @orchestrator — **🎉 K1 完成 + 独立复核(Kimi Code OAuth 订阅 plan 接入)**。波次 A(K1.1 @db 加列+迁移 ‖ K1.2 @llm KimiCodeAdapter)→B(K1.3 @backend OAuth device 服务+端点+store+接线)→C(K1.4 @frontend 连接流+可编辑路由)→D(K1.5 @qa E2E) 全绿,契约先行、零返工、无新 bug。研究先行(device flow at auth.kimi.com、client_id、伪造 UA/X-Msh-*、coding base、kimi-for-coding、access~15m/refresh~30d)+ 对照 pi-kimi-coder/ooojustin/lemon07r 校验(省略 scope 匹配 kimi-cli v1.41.0+)。最终独立复跑确认:后端 ruff/format 干净、mypy **154 文件**、alembic **无漂移**(首个新迁移=provider_credentials 加 auth_type/oauth_enc)、pytest **434 passed**;前端 vitest **155**/build。能力闭环:设置页 device-flow 登录→token Fernet 加密入库(`oauth_enc`,不泄漏)→路由 `kimi-code:kimi-for-coding`→网关构 KimiCodeAdapter(伪造头+bearer)→临过期自动刷新。**封号风险用户已知情接受**;与既有 API-key provider `kimi` 分离可切换。**唯一未尽**:对真 auth.kimi.com/api.kimi.com 的真账号联网冒烟(含封号风险,用户自担)——代码/E2E 仅验逻辑零联网。 +- [2026-06-19] @qa — **K1.5 ✅ K1 E2E(Kimi Code OAuth device-flow 全 mock → 真 pg)**。新增 `tests/test_k1_kimi_oauth_e2e.py`(4 用例,零联网,真 pg):① `POST .../oauth/start`→202 `{job_id,user_code,verification_uri,interval}`,fake httpx 跑 device-auth + token(pending×2→成功) 全程;后台 `run_job` 轮询跑在**真 e2e_sm session**(override `get_session_factory`→e2e_sm + monkeypatch 模块级 `kimi_oauth._default_http_client`→同一 scripted fake + `kimi_oauth.asyncio.sleep`→no-op;端点 device-auth 走 `_default_http_client` dep 同样指 fake)→ `GET /jobs/{id}` done,result `{connected:true,provider:"kimi-code"}`;**DB 真源**:`provider_credentials` 行 `auth_type="oauth"`/`oauth_enc` 非空/`api_key_enc` null,密文不含明文 token + 解密回环 access/refresh;`GET .../oauth/status` connected 无 token。② 存 oauth + `tier_routing` writer→`kimi-code:kimi-for-coding` → `build_gateway_for_tier` → `KimiCodeAdapter`,其 `_client` 带 coding base `https://api.kimi.com/coding/v1` + UA `KimiCLI/1.5` + 全 X-Msh-* + `api_key`(=bearer)=存量 access token。③ 临近过期 oauth 包 → `_build_provider_adapter` 触发刷新(fake `project_deps.kimi_refresh` + 字符串 target monkeypatch `project_deps.httpx.AsyncClient`)→ 适配器用新 token + `oauth_enc`(DB 真源)更新为新包。④ `POST .../oauth/disconnect`→凭据行清除(DB 真源无 oauth)。**token 全程负向断言:不进 job 结果/状态响应/start 响应/密文明文**。全仓门禁绿:ruff/format 干净、mypy **154 Success**、alembic 无漂移、pytest **434 passed**(430→+4)。memory 记 1 gotcha。**K1(Kimi Code OAuth 订阅 plan 接入)全 5 子任务 ✅,可封板。⚠️ 仍未对真 auth.kimi.com/api.kimi.com 联网验证(含封号风险,用户自担)。** +- [2026-06-19] @frontend — **K1.4 ✅ 设置页 Kimi Code OAuth 连接流 + 可编辑档位路由**。`pnpm gen:api` 纳入 K1.3 的 3 端点(OAuthStart/Disconnect/StatusResponse),`lib/api/types.ts` 加 3 别名。纯逻辑(node-env vitest):`lib/settings/kimiOauth.ts`(`connectPhase` 状态机 idle/awaiting/connected/error + `toDeviceDisplay`/`authOpenUrl`/`toConnectionStatus`/`jobConnected`/`formatExpiresAt`,16 测)+ `lib/settings/providers.ts` 扩(`KNOWN_PROVIDERS` 加 `kimi-code`(auth:oauth, model:kimi-for-coding) + `API_KEY_PROVIDERS`/`TIERS`/`defaultModelFor` + 路由草稿纯函数 `toRoutingDrafts`/`applyProviderChange`/`draftsToRoutingInput`,9 测)。组件:`useKimiOauth` hook(**复用 `useJobPoll`**:connect=POST start→展示 user_code+`window.open` 授权页+轮询 job→done 拉 status;disconnect=POST→复位)+ `KimiCodeOauth.tsx`(连接区,user_code 用 `` 可读可选可聚焦 + motion-safe)。`ProvidersSettings.tsx` 改:档位路由从只读→**可编辑**(per-tier select provider + model input + 保存 PUT),API-key 凭据行只列 `API_KEY_PROVIDERS`(OAuth 提供商有独立连接区,无 key 输入),挂入 `KimiCodeOauth`。`server.ts` 加 `fetchKimiOauthStatus`,`page.tsx` `Promise.all` 拉 providers+oauth 状态传 `kimiOauth` prop(进页即显连接态)。**job 结果无 token,只读 connected**。前端门禁全绿:gen:api / lint / typecheck / vitest **155 passed**(130→+25)/ build。memory 记 1 gotcha。**⚠️ 运行时对真 auth.kimi.com 设备授权 + 真 api.kimi.com 调用验证留 K1.5/手动(含封号风险,用户自担)。** +- [2026-06-19] @backend — **K1.3 ✅ Kimi Code OAuth device-flow 服务 + 端点 + store + 网关接线(C3 扩稳定)**。新增 `apps/api/ww_api/services/kimi_oauth.py`(`start_device_authorization`/`poll_token`/`refresh`,注入 `AsyncHttpClient` Protocol 零联网;`TokenSet`+`encrypt_oauth_bundle`/`decrypt_oauth_bundle` 复用 Fernet;`needs_refresh` 300s 缓冲;client_id env `KIMI_CLIENT_ID` 可覆盖、device authorization 省略 scope)+ `routers/kimi_oauth.py`(`POST .../oauth/start`→202 `{job_id,user_code,verification_uri,...}`+ BackgroundTask `run_job` 后台循环轮询→成功存加密 oauth_enc+job done(结果只 `{connected,provider}`);`POST .../oauth/disconnect`→`{disconnected}`;`GET .../oauth/status`→`{connected,expires_at}` 无 token)+ `schemas/kimi_oauth.py`(3 schema)+ 注册 main.py。`services/credentials.py`:`StoredCredential` 加 `auth_type`/`oauth_enc`、`api_key_enc`→`bytes|None`(**K1.1 的 2 处 mypy red 消除**),`SqlCredentialStore` 读新列 + `upsert_oauth_credential`/`delete_credential`,`upsert_credential` 显式置 api_key 态。`services/project_deps.py`:`_build_provider_adapter` 对 `kimi-code`/`auth_type=oauth` 走 `_resolve_kimi_code_token`(解密→临近过期则经 httpx 刷新+持久化→access token 喂 `build_adapter("kimi-code",…)`);`provider_deps.py` `_PROVIDER_BASE_URLS` 加 `kimi-code`。**token 全程不进 job 结果/日志/响应**(仅 Fernet 密文存 oauth_enc)。TDD 先红后绿:`tests/test_kimi_oauth_service.py`(11) + `test_kimi_oauth_endpoints.py`(5) + `test_kimi_code_wiring.py`(3)(fake httpx 零联网)。**全仓门禁绿**:ruff/format 干净、mypy **154 源文件 Success**、alembic **无漂移**、pytest **430 passed**(400→+30)。OpenAPI 新增 3 端点 + 3 schema → **@frontend K1.4 须 `pnpm gen:api`**。memory 记 C3 扩(K1.3)+ 1 decision + 2 gotcha。**⚠️ 真 auth.kimi.com/api.kimi.com 联网验证(含封号风险)留 K1.5,用户自担。** +- [2026-06-19] @llm — **K1.2 ✅ Kimi Code 订阅 plan 适配器(C1 扩稳定)**。`packages/llm_gateway/ww_llm_gateway/adapters/kimi_code.py`:`KimiCodeAdapter` **子类化 `OpenAICompatAdapter`**(coding 端点 OpenAI 兼容,复用 complete/stream/结构化/瞬时错误包装/capabilities),provider 固定 `kimi-code`(与 API-key `kimi` 分离);`build_kimi_code_client(access_token, base_url?)` 构 `AsyncOpenAI(api_key=access_token, base_url="https://api.kimi.com/coding/v1", default_headers=kimi_code_headers())`——access_token 当 bearer,伪造头随客户端发。`kimi_code_headers()`:`User-Agent` + 全 `X-Msh-*`(Platform/Version/Device-Name/Device-Model/Os-Version/Device-Id)。`kimi_device_id()`:env `KIMI_DEVICE_ID` 优先,否则确定性 `uuid5` 缺省(**绝不每次随机**,稳定且来源可测/可配)。model `kimi-for-coding` **不硬编码**(经 `.complete(req, model)` 传入,路由关注点)。`factory.build_adapter` 加 `provider=="kimi-code"` 分支;包 `__init__` 导出全部符号。**契约校正(已对照 `github.com/picassio/pi-kimi-coder` `extensions/index.ts`)**:参考实现对 coding API **仅**设 `User-Agent: "KimiCLI/1.5"`(精确值,已采用)、**未**设 X-Msh-* 头——与 PROGRESS 契约「缺 X-Msh→403」不符;本实现以验证到的 UA 为准 + 附带 X-Msh-* 客户端标识头(真客户端会发、额外标识无害、最大化避免 403);**真 api.kimi.com 联网验证头集是否足够留 K1.3/K1.5(含封号风险,用户自担)**。TDD 先红后绿;`tests/test_kimi_code_adapter.py`(8) + `tests/test_kimi_code_factory.py`(3),不联网(断 base_url + default_headers + 稳定 device_id + 工厂分派)。门禁绿(隔离 `MYPY_CACHE_DIR=.mypy_cache_k12` 已清理):ruff/format 干净、mypy **27 源文件 Success**、pytest **58 passed**(47→+11)。**@backend 行动(K1.3)**:`_build_provider_adapter` 对 provider `kimi-code` 调 `build_adapter("kimi-code", api_key=<刷新后的 access_token>, base_url=…)`;token 刷新/获取归 K1.3。**无 OpenAPI 形变**,前端无需 re-gen。memory 记 C1 扩(K1.2)契约 + 1 gotcha。 +- [2026-06-19] @db — **K1.1 ✅ `provider_credentials` 加 OAuth 列(C2 扩稳定)**。`packages/db/ww_db/models.py` `ProviderCredential`:① 加 `auth_type: Mapped[str]`(`Text, nullable=False, server_default="'api_key'"`,值 api_key|oauth);② 加 `oauth_enc: Mapped[bytes|None]`(`LargeBinary, nullable=True`,持 Fernet 加密 JSON `{access_token,refresh_token,expires_at}`);③ `api_key_enc` 改 `nullable=True`(`Mapped[bytes|None]`,OAuth-only 行无 api_key、既有 api_key 行保值);`UniqueConstraint(owner_id,project_id,provider)` 不变。**首个新迁移**(M1–M5 全零建表)`migrations/versions/1f011c42bd4d_provider_credentials_oauth_columns.py`(down_revision=`220ca2e3d53f`):up=add_column auth_type/oauth_enc + alter api_key_enc→nullable,down 逆向;autogenerate 仅这 3 op 无 noise,手修模板重复 import + 改本仓 ruff 样式(见 gotcha)。`upgrade head` 已应用、`alembic check` **无漂移**。门禁:ruff/format 干净、pytest **400 passed**、alembic 无漂移;**mypy 余 2 处 `arg-type` 错(均在 @backend `apps/api/ww_api/services/credentials.py`,因 api_key_enc 改 nullable 打穿,K1.3 吸收,见下方 @backend 请求)**。memory 记 C2 扩 + 2 gotcha。 +- **[请求 → @backend,K1.3]**:K1.1 把 `provider_credentials.api_key_enc` 改 nullable(`bytes|None`),打穿到你拥有的 `apps/api/ww_api/services/credentials.py` mypy 报 2 处 `arg-type`(`StoredCredential.api_key_enc: bytes` ↳ 现 `bytes|None`,dataclass line 28 + store line 75/97)。@db 不越权改 apps/api。K1.3 接 OAuth store 时把 `StoredCredential.api_key_enc` 改 `bytes | None`(并加 `auth_type`/`oauth_enc` 字段 + OAuth 读写路径),mypy 即全绿。oauth_enc 持 Fernet 加密 JSON `{access_token,refresh_token,expires_at}`(key=`settings.credential_enc_key`,同 api_key_enc)。 + +- [2026-06-19] @orchestrator — **🎉 M1–M5 全部交付 + M5 余项收尾完成**。五个里程碑(Phase 0–5)全部 ✅;M5 三项非阻塞余项 + 本次台账归档共四项 follow-up 已清:**#1** Codex 读端点(`GET /projects/:id/characters` + `GET .../world_entities`,@backend)+ 前端接线(CodexPage 拉全量真源,去「会话内回显」局限,@frontend);**#2** `build_adapter` provider→适配器工厂 + anthropic mypy 修复(@llm),`build_gateway_for_tier` 按 provider 选适配器启用 Anthropic/Gemini 真实回退(@backend);**#3** `packages/skills` 纳入 uv workspace + skill impl(registry/permissions)从 `ww_core.domain` 迁入 `ww_skills`(@backend,@devops 加 SDK);**#4** 本次把 M5/M4/M3 三段折叠进「已归档阶段」、顶部改短状态行(@orchestrator)。最终门禁全绿:后端 ruff/format 干净、mypy **144 文件**、alembic **无漂移**、pytest **400 passed**;前端 lint/typecheck 干净、vitest **130 passed**、build OK。台账已归档,当前无活跃阶段。 +- [2026-06-19] @frontend — **M5 follow-up #1 ✅ Codex 展示真源(解余项①)**(消费 @backend C3 扩 Codex 读端点,无契约变更)。先 `pnpm gen:api` 纳入 `GET /projects/:id/characters`(`CharacterListResponse{characters?}`)+ `GET .../world_entities`(`WorldEntityListResponse{world_entities?}`)+ `lib/api/types` 加 2 别名。`lib/api/server` 加 `fetchCharacters`/`fetchWorldEntities`(200 空列表非 404,仿 `fetchForeshadow`);codex route(Server Component)`Promise.all` 拉 项目+人物+世界观全量 → 传 `CodexPage` 初始数据。`CodexPage` 人物 tab 渲染**跨会话持久化真源**(刷新不再空),本会话新入库卡经纯函数 `mergeCharacterCards`(按 name 去重、持久化优先、不重复)合并补显(generate→ingest 后仍即时见新行);世界观 tab 渲染入库真源(本期无世界观入库端点,故只真源 + 生成器预览,无 session 合并);时间线维持 P2 占位。纯逻辑 `lib/generation/cards.{mergeCharacterCards,mergeWorldEntities}` + 5 node-env 单测。前端门禁全绿:gen:api/lint/tsc/**vitest 130**(125→+5)/build。**Codex「会话内回显」局限已去除**(解 gotcha + PROGRESS 余项①)。memory 把 Codex 缺口 gotcha 标 RESOLVED。 +- [2026-06-19] @backend — **M5 follow-up R1 + R3 ✅**(三 fix,与并行 @llm `llm_gateway` 改动 disjoint)。**R1-Codex 读端点**(解余项①):加 `GET /projects/:id/characters`(`CharacterListResponse`)+ `GET /projects/:id/world_entities`(`WorldEntityListResponse`),复用 C5 读侧 `SqlCharacterRepo`/`SqlWorldEntityRepo`(经 `get_memory_repos`,不动 assemble)+ 反向 JSONB 解包(`{"items":[...]}`→list/`{"text":...}`→str/`{"rules":[...]}`→list),register main;C3 扩稳定。**R3-skill 迁包**(解余项③):`skill_registry.py`+`skill_permissions.py` 从 `ww_core.domain` **迁入** `packages/skills/ww_skills/`(`ww_skills/__init__` 改直接导出,删门面);importer(`generation.py`/`project_deps.py`/`test_generation.py`)改 `from ww_skills import …`;skill 单测移 `packages/skills/tests/`(13 测);删 ww_core.domain 两模块+__init__ 导出;`packages/skills/pyproject.toml` deps 校准(去 `ww-core`、补 `ww-llm-gateway`+`sqlalchemy`),`uv sync` 重建。**R1#2-build_adapter 接线**(解余项②的 apps 侧;@llm 的 `build_adapter` 工厂 + @devops 的 anthropic/google-genai SDK 均已落地,import 实解非 transient):`build_gateway_for_tier` 的 `_build_provider_adapter`(原 `_build_openai_compat_adapter`)改调 `ww_llm_gateway.build_adapter(provider, api_key=…, base_url=…)`——Anthropic/Gemini 传 base_url=None 走原生适配器,去掉直接 `AsyncOpenAI`/`OpenAICompatAdapter` import。门禁绿(隔离 `MYPY_CACHE_DIR=.mypy_cache_r1backend` 已清理):ruff/format 净、mypy **101 文件 0 error**、pytest `packages/core packages/skills apps/api` **246 passed**(226→+20:+10 迁移 skill 测 +3 Codex 读端点测 + 其余)。两新端点入 OpenAPI(验证 get 存在)。memory 登记 C3 扩(Codex 读端点)+ C8 实现位置更新 + build_adapter 接线 + 3 decisions。**@frontend 行动**:`cd apps/web && pnpm gen:api` 纳入 `GET characters`/`GET world_entities`,CodexPage 初始列表拉全量去掉「会话内」局限(解 gotcha Codex 缺口)。**@devops 行动**:复核根 `uv.lock`(@backend 改了 `packages/skills/pyproject.toml` deps,已 `uv sync` 重建 ww-skills)。**M5 三非阻塞余项①②③ 至此全部清除。** +- [2026-06-19] @llm — **R2 follow-up #2 ✅ 真多 provider 适配器选择**(C1 扩 follow-up #2 稳定;接 @devops 已加 `anthropic`+`google-genai` SDK 之后)。① **修真 SDK 装上后的 mypy 报错**:`instructor.from_anthropic(self._client)` 重载只收 SDK 具体 `AsyncAnthropic|...`、`AnthropicClient` Protocol 不匹配 → adapter 内 `cast("AsyncAnthropic", self._client)`(`TYPE_CHECKING` import,零运行时硬依赖)选中 AsyncInstructor 重载。gemini 走 `response_schema` 非 instructor,**无**该类错。② **新增 provider→适配器工厂** `factory.build_adapter(provider, *, api_key, base_url=None) -> ProviderAdapter`(包 `__init__` 导出):`anthropic`→`AnthropicAdapter`、`{gemini,google}`→`GeminiAdapter`、其余→`OpenAICompatAdapter(provider, AsyncOpenAI(...))`;从 api_key 构真实客户端(factory 处 `cast` 跨 Protocol read-only-attr 约束,adapter 仍声明 Protocol 形参、测试替身缝完好)。新增 `tests/test_build_adapter_factory.py` 6 测(按 provider 名断分派,不联网)。**门禁绿**(隔离 cache):ruff/format 净、mypy **24 源文件 0 错**、pytest **47 passed**(41→+6);全仓 `mypy packages apps` **144 文件 Success**(anthropic.py 报错确认消除、gemini 无同类错)。无 OpenAPI 形变(served_by 不出 API),前端无需 re-gen。**@backend 可在 `build_gateway_for_tier` 用 `build_adapter` 替换「一律 OpenAICompatAdapter」启用 anthropic/gemini 真实回退。** 记 1 gotcha(Protocol vs 真 SDK cast 边界)。 +- [2026-06-19] @orchestrator — **🎉 M5 完成 + 独立复核 ✅**(Wave C:T5.3+T5.6 ✅ + Wave D:T5.7 ✅)。波次 A(T5.1‖T5.4‖T5.5)→B(T5.2)→C(T5.3+T5.6)→D(T5.7) 全绿,契约先行、零返工、无新 bug。最终独立复跑确认:后端 ruff/format 干净、mypy **142 文件**、**alembic 无漂移**(M5 复用 T0.2 既有 world_entities/characters/rules/skills 表,全程零建表迁移)、pytest **391 passed**;前端 tsc 干净、**vitest 125 passed**。**M5(生成+多provider+扩展)出口 DoD 全达成**:worldbuilder/character-gen 生成→预览→入库(continuity 409 gate + partition_writes 白名单 + schema→JSONB 形变);网关多 provider 回退/熔断/能力降级(真 Gateway 回退链,E2E 验主失败→fallback 服务 + 记账记实际 provider);Skill registry + 表权限沙箱 + 规则页/技能库/命令面板(⌘K)。**M1–M5 全部交付。** 非阻塞余项:① Codex 读端点缺口(无 GET characters/world_entities,现以生成+会话内回显管理);② Anthropic/Gemini 真实回退待 @devops 加 SDK + 按 provider 选适配器类(E2E 用 OpenAI 兼容 mock 验证回退);③ `packages/skills` 纳入 uv workspace(现 impl 落 ww_core.domain + façade)。建议下一步:清理 PROGRESS 把 M3/M4/M5 折叠归档,或处理上述余项。 +- [2026-06-19] @qa — **M5 Wave D:T5.7✅ M5 E2E + 切 provider**(真 pg + mock 多 provider,零 token;无契约变更)。`tests/test_m5_e2e.py` 3 用例:**① 群像生成→入库闭环**(mock 网关按 `req.output_schema` 路由 CharacterGenResult/WorldGenResult/ContinuityReview):`POST /world/generate` 预览 + `POST /characters/generate` 预览 3 卡 → `POST /characters` 入库**双 gate**——precheck 有冲突且未 `acknowledge_conflicts` → **409 CONFLICT_UNRESOLVED**+`details.conflicts`(并断言此刻 characters 表无行,守不变量 #3)→ 带 `acknowledge_conflicts=true` 重发 201;**DB 真源**断言 characters 行真落 pg + **schema→JSONB 形变**(traits/speech_tics→`{"items":[...]}`、arc→`{"text":...}`、tags/relations 直落 list),`rejected_tables` 空(partition_writes 合法路径),4 条 ledger。**② 切 provider 回退**:HTTP 角色生成跑在**真 `Gateway`**(非 stub)deepseek(每次 `complete` 抛 `TransientProviderError` 耗尽重试)→openai 回退链上,端点仍 200;`served_by` 不出 API,故经 **DB 真源 `usage_ledger`** 断言实际服务方 = **openai(fallback)≠deepseek(primary)**(ARCH §4.5 记账记实际服务方)。**③ 能力降级**:`structured_output=False` 唯一 provider 直测 `Gateway.run` → `served_by.degraded=True`(§4.4)。坑:生成网关是**请求 scope 依赖**(`get_worldbuilder_gateway`/`get_character_gen_gateway`/`get_precheck_gateway`),直接 `app.dependency_overrides` 注假网关即可,**无需** monkeypatch `build_gateway_for_tier`(与 M4 学文风后台自建网关不同)。全仓门禁绿:ruff/format 净、mypy **142 文件**、**alembic 无漂移**、pytest **391 passed**(388→+3)。**未发现 REAL bug**。**M5 出口 DoD 全达成** → orchestrator 可封 M5。 +- [2026-06-19] @frontend — **M5 Wave C:T5.3+T5.6✅ 生成 + 设定库 + 规则 + 技能 + 命令面板**(消费 C3 扩 T5.2 + C8/T5.5,无契约变更)。先 `pnpm gen:api` 纳入 5 端点(world/characters generate + characters ingest + GET rules + GET skills)+ `lib/api/types` 加 12 别名。**T5.3**:① **角色生成器**(UX §6.6)`components/generation/CharacterGenerator`:一句话 brief + 数量(1–12) + 定位 → `POST /characters/generate` 预览卡 → `CharacterCardItem`(勾选入库 + 就地编辑名/弧光,默认全选)→ `POST /characters` 入库;**409 CONFLICT_UNRESOLVED 裁决流**:`useCharacterGen` 捕 409 → `extractIngestConflicts` 收窄 `details.conflicts`(复用 ReviewConflict 形) → `ConflictAdjudication` 面板展示 continuity 冲突 → 作者「已知悉强制入库」= 带 `acknowledge_conflicts=true` 重发 / 「取消改卡」。② **世界观设计器** `WorldGenerator`:`POST /world/generate` 预览 type/name/rules(本期无入库端点,仅预览参考)。③ **设定库 Codex**(UX §6.5)`codex/CodexPage` + `app/projects/[id]/codex`:人物/世界观/时间线三 tab(时间线 P2 占位);`?gen=character|world` 命令面板跳转直开对应 tab。**T5.6**:④ **规则页** `rules/RulesPage` + `app/.../rules`:四级规则列表(`groupByLevel`) + 新增(`useRules` 乐观追加+回滚,仿 useForeshadow)。⑤ **技能库** `skills/SkillsPage`(Server Component 纯读)+ `app/.../skills`:`GET /skills` 按 scope 分组只读注册表(name/tier/reads/writes)。⑥ **命令面板 ⌘K** `command/{CommandPalette,CommandPaletteMount}` + `lib/command/palette`:⌘K/Ctrl+K 开关、模糊过滤(`filterCommands`)、上下循环高亮(`moveHighlight`)、Esc/Enter,a11y(role=dialog/listbox/option + 自动聚焦 + prefers-reduced-motion);挂 RootLayout 对全局生效,读 pathname 注入项目上下文。⑦ LeftNav `ActiveNav` 加 codex/rules/skills(替换原 disabled 设定库占位)。纯逻辑全单测(node-env):`lib/{generation/cards,rules/rules,skills/skills,command/palette}.test`。前端门禁全绿:gen:api/lint/tsc/**vitest 125**(96→+29:cards 12 + rules 5 + skills 3 + palette 9)/build(codex/rules/skills 路由齐)。memory 记 2 gotcha + 1 gap(Codex 读端点缺口)。**M5 前端交付完成** → 下一步 **Wave D:T5.7(@qa) M5 E2E + 切 provider**。 +- [2026-06-19] @orchestrator — **M5 Wave B 收口 ✅**(T5.2,C3 扩稳定)。5 端点入 OpenAPI:world/characters generate 预览 + characters 入库(双 gate:continuity 预检 409 + partition_writes 白名单 + schema→DB 形变)+ GET rules/skills;`build_gateway_for_tier` 接线回退链(OpenAI 兼容齐,Anthropic/Gemini 真实切换余量 deferred 给 T5.7 + @devops SDK)。全仓门禁绿:ruff/format 干净、mypy **142 文件**、**alembic 无漂移**、pytest **388 passed**(371→+17)。下一步 **Wave C:T5.3+T5.6(@frontend 单 agent 顺序做)**——先 `pnpm gen:api`,做 角色生成器 + 世界观设计器 + 设定库 Codex + 规则页 + 技能库 + 命令面板(⌘K),入库流处理 409 裁决重发。 +- [2026-06-19] @backend — **M5 Wave B:T5.2✅ 生成/入库 API**(契约 **C3 扩稳定**;消费 Wave A 的 C6扩/C8/C1扩,与已合并 Wave A 无写冲突)。`routers/generation.py`(+ `skills_router`,已注册 main)落 5 端点:① `POST /world/generate`(worldbuilder writer 网关 `run_worldbuilder` → **预览**世界观,不入库,末尾 commit 落 ledger);② `POST /characters/generate`(character-gen writer 网关,注入已有角色防雷同 → **预览**角色卡);③ `POST /characters`(入库 **双 gate**:`precheck_generated_cards` continuity 预检 analyst 网关——有冲突且未 `acknowledge_conflicts` → **409 CONFLICT_UNRESOLVED**+冲突详情,作者裁决后放行;`partition_writes(character_gen_spec,...)` 白名单过滤越权写表;过 gate 写 `characters` 行,**schema list/str → DB JSONB dict 形变**);④ `GET /rules`(规则列表,复用 C5 读侧);⑤ `GET /skills`(技能库,经 registry)。新写侧 repo `ww_core.domain.{character_repo(traits/speech_tics→{"items"}/arc→{"text"}),world_entity_repo(rules→{"rules"})}`(只 flush,端点 commit)。**T5.4 follow-up 接线完成**:`build_gateway_for_tier` 据 DB `tier_routing.primary+fallback` 预备多 `OpenAICompatAdapter` + 注入 `chain_resolver=chain_from_routing(...)` 启用回退链;无路由行退回 `resolve_route`(单 provider,M1 兼容),无可用凭据→503。**仅 OpenAI 兼容 provider**(deepseek/kimi/qwen/glm/openai)——Anthropic/Gemini 真实回退需 @devops 加 SDK + provider_deps 据 provider 选适配器类(**余量 deferred 给 T5.7 切 provider**)。全仓门禁绿:ruff/format 净、mypy **142 文件**、**alembic 无漂移**(M5 复用 T0.2 既有表)、pytest **388 passed**(371→+17:13 生成/入库端点 + 4 网关回退接线 + 4 形变 helper)。新端点入 OpenAPI(验证 world/characters generate + POST characters + GET rules + GET skills 齐)。memory 登记 C3扩稳定 + 2 decisions(入库 gate / 形变)+ 2 gotchas。**@frontend(T5.3/T5.6) 行动**:先 `cd apps/web && pnpm gen:api` 纳入这 5 端点;入库流处理 409(展示冲突→裁决勾选→带 `acknowledge_conflicts` 重发)。下一步 **Wave C:T5.3+T5.6(@frontend)**(依赖 T5.2✅+T5.5✅,可起)。 +- [2026-06-19] @orchestrator — **M5 Wave A 收口 ✅**(T5.1 + T5.4 + T5.5 三 agent 并行,disjoint 目录 + 隔离 mypy cache,零写冲突零返工)。worldbuilder/character-gen 双 spec + 节点 + 入库前 continuity 校验缝(C6扩)‖ 网关多 provider 韧性:Anthropic/Gemini 适配器 + 回退链 + 熔断 + 能力降级(C1扩,无 OpenAPI 形变)‖ Skill registry + 表权限白名单(filter_reads/partition_writes)+ `POST /rules`(C8 + C3扩)。全仓门禁绿:ruff/format 干净、mypy **135 文件**、**alembic 无漂移**(M5 复用 T0.2 已建的 world_entities/characters/rules/skills 表,无建表迁移)、pytest **371 passed**(296→+75)。**Wave B 衔接待办**(T5.2 @backend 须处理):① 角色 schema `traits`/`speech_tics`(list)/`arc`(str) ↔ DB JSONB **dict** 形变,入库须转换;② 入库 gate 调 `precheck_generated_cards`(continuity 冲突拦截) + `partition_writes`(越权写丢弃审计);③ 补 `GET /rules` + `GET /skills` 供 T5.6 前端;④ 接线 `build_gateway_for_tier`→`chain_from_routing` 启用回退链(T5.7 切 provider 依赖)。**@devops 待办**:加 `anthropic`/`google-genai` SDK 依赖(适配器懒 import,单测不需);评估把 `packages/skills` 纳入 uv workspace(现 impl 落 ww_core.domain + ww_skills façade)。下一步 **Wave B:T5.2(@backend 生成/入库 API)**。 +- [2026-06-19] @llm — **M5 Wave A:T5.4✅ 网关多 provider 韧性**(契约 **C1 扩稳定**,仅扩 `packages/llm_gateway/`,与并行 T5.1 @llm 无文件冲突)。① **新适配器**`adapters/anthropic.py`(`AnthropicAdapter`:原生结构化经 instructor.from_anthropic 懒构建、`cache_control` 缓存断点、thinking 能力)+`adapters/gemini.py`(`GeminiAdapter`:`response_mime_type=json+response_schema` 结构化、thinking)——**均经注入的客户端 Protocol,不硬 import anthropic/google-genai SDK,单测零联网零依赖**。② **回退链 + 重试 + 熔断**`gateway.py`:`Gateway` 加 `chain_resolver`/`max_retries`/`breaker`(保留 `resolver=` M1 兼容→自动包成单元素链);沿链逐 provider→瞬时失败(tenacity 指数退避)重试 R 次→仍失败/熔断打开/无适配器→切下一个;首个成功者服务、非链首标 `served_by.fell_back`;链耗尽抛 `LLM_UNAVAILABLE`;记账记**实际服务方**(回退后不记主模型);流式仅首块前可切(产出后中途失败上抛不重连)。`CircuitBreaker(threshold/reset_seconds/clock)` 连续失败超阈值短时熔断、成功清零、冷却半开。③ **能力协商/降级**:结构化请求按 `capabilities().structured_output` 重排链优先选支持者;链上无支持者→降级首个可用(适配器自走 instructor JSON 提示)标 `served_by.degraded`。④ **瞬时错误契约**`errors.py`:`TransientProviderError`(三适配器按异常类名+status_code 包装)+网关视 `AppError(RATE_LIMITED)` 可重试。⑤ **routing.py**:`resolve_chain`/`chain_from_routing`(据 DB tier_routing 去重保序)/`ChainResolver` 别名导出。⑥ `ServedBy` 加 `degraded`(向后兼容,默认 False);`pyproject.toml` 加 `tenacity>=8.2`。**23 新测**(test_fallback_chain 10/test_capability_degradation 4/test_new_adapters 7/test_multi_provider_accounting 2),包内 **41 passed**;全仓门禁绿:ruff/format 净、mypy **135 文件**、core+api **226 passed**(无回归)。**无 OpenAPI 形变**(served_by 不出 API,前端无需 re-gen)。memory 登记 C1 扩 + 2 gotcha。**后续待办**:apps/api `build_gateway_for_tier` 接线回退链(预备多适配器+传 chain_resolver=chain_from_routing 包装)、@devops 加 anthropic/google-genai SDK 依赖供真实接线。下一步 Wave B(T5.2 @backend,依赖 T5.1)。 + +- [2026-06-19] @backend — **M5 Wave A:T5.5✅ Skill 运行时 + 规则**(契约 **C8 + C3 扩稳定**;与并行 T5.1/T5.4 @llm 无目录/写冲突,隔离 `MYPY_CACHE_DIR=.mypy_cache_t55`)。① **Skill registry loader**(`packages/core/ww_core/domain/skill_registry.py`):`SkillRecord`(frozen,skills 行声明快照) + `SkillRepo` Protocol + `SqlSkillRepo`(读 `skills` 表,裸 tier 非法→VALIDATION) + `SkillRegistry.load(repo)`(async,每条跑 `validate_declaration`,越权声明→AppError VALIDATION 加载中断;frozen `dict[name,AgentSpec]`)+ `get`/`names`/`list_scope`。复用 §5.1 网关机制(不重实现网关),不变量#2 只带 tier。② **表权限沙箱**(`skill_permissions.py`,纯函数/不可变,§5.6「声明式权限+apply 层白名单」非进程沙箱):`KNOWN_TABLES`(10 创作表白名单) + `filter_reads`(注入只喂声明 reads 表) + `partition_writes`(写库只应用声明 writes,越权→rejected 供审计 log+丢弃) + `validate_declaration`(reads/writes 越界→VALIDATION)。守不变量#3(allowed 仍过验收 gate,不开写后门)。③ **`POST /projects/:id/rules`**(`routers/rules.py`,已注册 main):`RuleCreateRequest{level:Literal[global/genre/style/project],content(min1)}`→**201** `RuleView{level,content}`,非法 level/空 content→FastAPI 422;写侧 `domain/rule_repo.py`(`RuleWriteRepo`/`SqlRuleWriteRepo`/`RuleWriteView`,只 flush,端点 commit);喂 assemble 四级 `merge_rules`。④ 注入缝 `get_rule_write_repo`/`get_skill_registry`(`project_deps`)。门禁绿(限本任务范围):ruff/format 净、`MYPY_CACHE_DIR=.mypy_cache_t55 mypy packages/skills packages/core apps/api` **0 error/94 文件**、pytest `packages/skills packages/core apps/api` **226 passed**(+18 新测:6 permissions + 4 registry + 2 rule_repo + 3 rules 端点 + 既有)。`POST /rules` 已入 OpenAPI(验证)。**实现位置决策**:registry/沙箱落 `ww_core.domain`(§5.6 强制点在编排/写库层 + `packages/skills` 当前非 uv workspace member,根 pyproject @devops 未接线);`ww_skills` 留再导出门面,待 @devops 纳入 workspace 可迁移(见 decisions/gotchas)。**@frontend(T5.6) 行动**:先 `cd apps/web && pnpm gen:api` 纳入 `POST /rules`(规则页);技能库经 `get_skill_registry`(若 T5.6 需 GET /skills 端点,请在此提请 @backend 补)。**@llm/T5.2 行动**:生成入库层用 `partition_writes` 守自定义 skill 产出白名单(仅声明 writes 表),仍过验收。 + +- [2026-06-19] @llm — **M5 Wave A 半程:T5.1✅**(C6 扩稳定,与并行 T5.4 @llm/T5.5 @backend 无目录/写冲突,隔离 MYPY_CACHE_DIR)。worldbuilder + character-gen 落地:① schema(`packages/agents/ww_agents/schemas.py`)`WorldEntityCard{type,name,rules:list[str]}`/`WorldGenResult{entities}` + `CharacterRelation{name,kind,note?}`/`CharacterCard{name,role,traits,backstory,arc,speech_tics,tags,relations}`/`CharacterGenResult{cards}`——**字段对齐 `world_entities`/`characters` DB 列**供 T5.2 ingest 直映。② spec(specs.py)`worldbuilder_spec`(writer,reads=projects,writes=world_entities,硬规则显式 prompt)、`character_gen_spec`(writer,reads=world_entities+characters,writes=characters,**system_prompt 注入「已生成卡+已有角色」差异化要求=M5-a 防雷同**)。③ 生成节点(`packages/core/ww_core/orchestrator/generation_node.py`,模块内自有 GatewayRun Protocol,不重复导出)`run_worldbuilder`/`run_character_gen`(`build_character_gen_context` 把已有+已生成卡序列化成 `name(role):traits` 简表注入,空时占位不崩,独立生成、网关失败上抛、parsed 违约 ValueError,只读不写库)。④ **入库前 continuity 校验缝**(ARCH §6.5)`precheck_generated_cards(spec=continuity_spec,*,cards,world_context,characters_context,...)->list[Conflict]`——编排器追加的检查、**非 character-gen 直接互调**(守 §5.4/不变量#1),T5.2 入库端点调它做 gate。门禁绿(限本任务文件):ruff/format 净、mypy 本任务 7 文件 0 error(`packages/agents packages/core` 余 3 error 全在并行 @backend T5.5 的 rule_repo/skill 测试)、pytest **246 passed**(+34 新测:22 spec 契约 + 12 节点/防雷同/校验缝)。memory 登记 **C6 扩稳定**。**T5.2(@backend) 行动**:`POST /world/generate`→run_worldbuilder、`/characters/generate`→run_character_gen(批量循环把已产卡传 generated_so_far)、`POST /characters` 入库前调 precheck_generated_cards gate;analyst/writer 网关复用 build_gateway_for_tier。下一步 **Wave B:T5.2**(依赖本任务✅,可起)。 +- [2026-06-19] @docs — spec 一致性回写:把 T4.3 顺带新增的 `GET /projects/:id/style`(最新指纹 `{dimensions, evidence, version}`,无指纹 404)补进 ARCHITECTURE.md §7.2 端点表(紧邻 `POST /style` 行)+ PRODUCT_SPEC.md §6 端点清单(紧邻 `POST /style` 行),消除 code↔spec 漂移。`memory/decisions.md` 该决策的「待回写规格」flag 至此**已解决**。仅改两份 spec,未动代码/测试。 +- [2026-06-19] @orchestrator — **M4 收官独立复核 ✅**。波次按计划走通 A(T4.2‖T4.1)→B(T4.3)→C(T4.4)→D(T4.5),每波契约先行 + 全仓门禁收口、零返工、无新 bug。最终独立复跑后端门禁确认绿:ruff/format 干净、mypy **115 文件**、**alembic 无漂移**(M4 全程零建表迁移,全靠 T0.2 预留的 `style_fingerprint`/`chapter_reviews.style`/`jobs` 列表)、pytest **296 passed**;前端 gen:api/lint/tsc/vitest **96**/build 绿。**M4(文风)出口 DoD 全达成**:学文风(16 维指纹带证据,版本化,异步 jobs+zombie reaper) + 漂移打分作为第四审真持久化(review 四并行分支+SSE `style`+`chapter_reviews.style`) + 回炉同步 diff(不写库守不变量#3)。Phase 4 完成;M1–M4 全部交付。下一阶段 M5(生成+多 provider+Skill) 见 DEV_PLAN,进入时归档 M3/M4 并拉入新表。 +- [2026-06-19] @qa — **🎉 M4 完成**(Wave D:T4.5✅ M4 E2E)。`tests/test_m4_e2e.py` 真 pg + 多档位 mock 网关(零 token)真跑通过 3 用例:① **学文风闭环**:`POST /style`→**202** `{job_id}`→BackgroundTask `run_job` 自建独立 session 跑 `run_style_extraction`(analyst 假适配器产 StyleFingerprintResult)→`SqlStyleFingerprintWriteRepo.append` 真落 pg→`GET /jobs/{id}` status=done(result.version/dims_count)→`GET /style` 最新指纹→`mode="update"` 学一次 version+1;DB 真源 `style_fingerprint` 2 行(version 1/2,dimensions/evidence)+ `jobs` 2 行 done。② **写章第四审**:学文风后 assemble stable_core 含指纹→`POST .../review` SSE 四审齐(continuity/foreshadow/pace/style)无 error + `section{name:"style"}` + `style{score:72,segments:[{idx:2,score:40,label:"机翻腔"}]}` 帧真发 + `GET .../reviews` 留痕 style(dict);DB 真源 `chapter_reviews.style` 列真填。③ **回炉**:`POST .../refine`→200 `{original,refined}`,refined≠original(writer 据 output_schema is None 返改写串)+ 不写库(无 chapter/review 行,不变量#3)+ writer 记账落 usage_ledger。**无新 bug**。关键坑(记 memory/gotchas):学文风 BackgroundTask 在独立 session 上**自造网关**(`build_gateway_for_tier` 从凭据建真 OpenAI 适配器)→E2E 无真凭据,须 monkeypatch `style.build_gateway_for_tier`→真 Gateway 包假适配器(ledger 绑入参 session);写侧 repo + `run_job` 默认 `SqlJobRepo` 保持真→指纹/job 真落 pg;override `get_session_factory`→`e2e_sm`(同测试 engine/loop)。最终全收口门禁绿:ruff/format 干净、mypy **115 文件**、pytest **296 passed**(293→+3)、**alembic 无漂移**(M4 无建表迁移)。**M4 出口 DoD 全达成 → Phase 4 可归档。** +- [2026-06-19] @frontend — **M4 Wave C:T4.4✅ 文风页 + 漂移 + 回炉 + job 轮询**(消费 C3 扩 + C4 扩,无契约变更)。先 `pnpm gen:api` 纳入 style/refine/GET-style + jobs 类型(`lib/api/types` 加 4 别名)。① **job 轮询**:新模式 `lib/jobs/job.ts`(纯状态机 `narrowJob`/`reducePoll`/`isTerminal`/`styleLearnResult`——`GET /jobs/{id}` 是松散 `{[k]:unknown}` 裸端点须安全收窄) + `useJobPoll.ts`(setTimeout 轮询,done/failed 终态停,卸载清理)。② **文风页** `app/projects/[id]/style/page.tsx`(RSC 取 project + `GET /style`→404 容忍返 null) + `components/style/`:`StyleUpload`(多段文本粘贴/选文件 `file.text()` 读成文本→空行切段→`POST /style`)、`useStyleLearn`(受理 202→`useJobPoll`→done 拉指纹)、`FingerprintView`(16 维行+可展开证据)。③ **审稿页第四审**:`lib/review/sse.ts` 加 `StyleEvent`/`StyleDriftReport`/`reduceReview` case "style"(替换式仿 pace)/`ReviewStreamState.style`/seed;`history`-style 收窄 `normalizeStyleDrift` 落 `lib/style/style.ts`;`StylePanel`(◔ 整体相似度% + 漂移段一键回炉)接进 ReviewReport(seed 扩四态)。④ **回炉** `RefineView` + `useRefine`(`POST /refine`→新旧 diff→采纳:把 refined 替换 finalText 中原段,经既有 draft 路径合入,不另写库守不变量#3;503 优雅提示)。LeftNav 开「文风」入口(`ActiveNav` 加 "style")。前端门禁全绿:gen:api/lint/tsc/**vitest 96**(+27:job 12 + style 10 + style-sse 5)/build(含 `/projects/[id]/style` 路由)。memory 记 1 gotcha。下一步 **Wave D:T4.5(@qa) M4 E2E**。 +- [2026-06-19] @orchestrator — **M4 Wave B 完成 ✅**(T4.3,C3 扩稳定)。style/refine 三端点入 OpenAPI:`POST /style`(202+jobs BackgroundTask 跑 run_style_extraction→StyleFingerprintWriteRepo.append)、`GET /style`(最新指纹含证据/版本)、`POST /refine`(writer 网关同步返 {original,refined} 不写库)。全仓门禁绿:ruff/format 干净、mypy **115 文件**、**alembic 无漂移**、pytest **293 passed**(+13)。`GET /style` 顺带补入(回写 ARCH §7.2/PRODUCT_SPEC §6 留 @docs)。下一步 **Wave C:T4.4(@frontend)**——先 `pnpm gen:api` 纳入 style/refine/GET-style + jobs 类型,再做文风页(上传/指纹/证据) + `lib/jobs/useJobPoll` + 审稿页第四审 StylePanel + 回炉 diff。 +- [2026-06-19] @backend — **M4 Wave B:T4.3✅ API style + refine**(契约 **C3 扩稳定**)。三端点入 OpenAPI(独立 `routers/style.py`,仿 outline.py,已注册):① `POST /projects/:id/style` → **202** `{job_id}`(学文风走 jobs,M4-c):`job_repo.create("style_learn")`+commit → `background_tasks.add_task(run_job, session_factory, job.id, work)`;`work` 在 `run_job` **自建独立 session** 上自造 analyst 网关+写侧 repo → `run_style_extraction` → 拆 `dimensions_json`/`evidence_json` → `append`(version+1) → `{version,dims_count}` 落 `jobs.result`;无凭据 → dep 解析阶段 **503**(调度 job 前拦下,不凭空写注定失败的 job)。② `POST /projects/:id/chapters/:no/refine` → 同步 200 `{original,refined}`(writer 网关 `run(refiner_spec)`,`output_schema=None`→`resp.text`;不写库守不变量#3;末尾 commit 让 usage_ledger 落库)。③ **顺带补 `GET /projects/:id/style`** → 最新指纹 `{dimensions,evidence,version}`(UX §6.9;ARCH §7.2 原缺读端点——决策见 decisions,**待 @docs 回写 ARCH §7.2/PRODUCT_SPEC §6**);不动 C5 读侧 `SqlStyleRepo`(assemble 行为不变)。新建写侧 `domain/style_repo.py`(`StyleFingerprintWriteRepo`/`Sql*`,`append` 只 flush + `latest` 含证据/版本);`project_deps` 加 `get_style_write_repo`/`get_style_extract_gateway`(analyst)/`get_refine_gateway`(writer)。全仓门禁绿:ruff/format 干净、mypy **115 文件**、**alembic 无漂移**(M4 无建表)、pytest **293 passed**(280→+13)。memory 登记 C3 扩 + 2 decisions + 1 gotcha。**@frontend 行动**:T4.4 前 `cd apps/web && pnpm gen:api` 纳入 style/refine/GET style + jobs 轮询类型。下一步 **Wave C:T4.4(@frontend)**。 +- [2026-06-19] @orchestrator — **M4 Wave A 收口 ✅**(T4.2 + T4.1 全绿)。全仓门禁收口:ruff/format 干净、mypy **111 文件**、**alembic 无漂移**(M4 无建表迁移)、pytest **280 passed**(M3 基线 228 → +52:T4.2 37 + T4.1 15)。契约稳定:C6扩/C4扩(StyleFingerprintResult/StyleDriftReview/四审齐/SSE style 事件)、C3.5(JobRepo/run_job)。下一步 **Wave B:T4.3(@backend)**——单 @backend agent 顺序做 style/refine 两端点:新建 `StyleFingerprintWriteRepo` + `routers/style.py`(`POST /style` 202 调 `job_repo.create`+`run_job`(work=`run_style_extraction`→`StyleFingerprintWriteRepo.append`);`POST /refine` 同步 `refiner_spec` writer 网关返 {original,refined}),C3 扩;评估顺带 `GET /projects/:id/style`。 +- [2026-06-19] @backend — **M4 Wave A:T4.1✅ jobs 长任务基建**(契约 **C3.5 稳定**,不碰 style 业务,与并行 T4.2 @llm 无目录/写冲突)。`packages/core/ww_core/domain/job_repo.py`:`JobRepo` Protocol + `SqlJobRepo`(create/set_running/set_progress 夹取/complete/fail/get/reap_zombies) + frozen `JobView`——状态写**只 flush**(提交归 `run_job`/端点),唯 `reap_zombies` 启动期独立调用故**自 commit**。`apps/api/ww_api/services/job_runner.py`:通用 `run_job(session_factory, job_id, work)` **自建独立 session**(仿 `run_overdue_scan`)→set_running→work→complete/异常→新 session fail,异常吞不冒泡;最小 `JobLifecycleRepo` Protocol + 可注入 `repo_factory` 缝。`get_job_repo` 加进 `project_deps`;`main.py` lifespan 在 `seed_stub_user` 后跑 reaper(M4-d,幂等自 commit)。门禁绿(限本任务文件):ruff/format 干净、mypy 本任务文件 0 error(`packages/core apps/api` 余 7 error 全在 @llm 在做的 `test_style_*`)、pytest **15 新测**(13 job_repo+2 job_runner),`packages/core` **130 passed** / `apps/api` **50 passed**。memory 登记 C3.5。**T4.3 行动**:`POST /style` 调 `job_repo.create` + `background_tasks.add_task(run_job, session_factory, job.id, work=…)`。 +- [2026-06-19] @llm — **M4 Wave A 半程:T4.2✅**(C6 扩 + C4 扩稳定)。style-auditor 双轨落地:①提取轨 `style_extract_spec`(analyst,独立生成仿 outliner,**不进 review 图**)+`StyleFingerprintResult{dimensions:[StyleDimension{name,value,evidence}]}`(16 维带证据)+`orchestrator/style_extract_node.py`(`build_style_extract_request`/`run_style_extraction`,模块内自有 GatewayRun Protocol,网关失败上抛/parsed 违约抛 ValueError,只读不写库);②漂移轨第四审 `style_drift_spec`(name="style",light,只读)+`StyleDriftReview{score=100,segments:[StyleDriftSegment{idx,score,label?}]}`(默认值即无指纹降级态)→`REVIEW_SPECS` 四审齐;collect 加 `STYLE`/`extract_style`→record `style=` 列;sse 加 `EVENT_STYLE`/`style_event`+`_section_result_events` STYLE 分支(`style{score,segments}`,字典序 continuity None: + self.rows: dict[tuple[uuid.UUID, int], OutlineView] = {} + + def add_chapter( + self, + project_id: uuid.UUID, + *, + volume: int, + chapter_no: int, + beats: list[str], + foreshadow_windows: list[dict[str, Any]] | None = None, + ) -> None: + self.rows[(project_id, chapter_no)] = OutlineView( + volume=volume, + chapter_no=chapter_no, + beats={"beats": list(beats)}, + foreshadow_windows=[dict(w) for w in (foreshadow_windows or [])], + ) + + async def get(self, project_id: uuid.UUID, chapter_no: int) -> OutlineView | None: + return self.rows.get((project_id, chapter_no)) + + async def list_for_project(self, project_id: uuid.UUID) -> list[OutlineView]: + views = [v for (p, _), v in self.rows.items() if p == project_id] + return sorted(views, key=lambda v: v.chapter_no) + + class FakeSessionFactory: """到期扫描的独立 session 工厂替身:`()` → async-CM 产 `FakeSession`。 @@ -332,20 +374,106 @@ class FakeSessionFactory: return _cm() +class FakeStyleWriteRepo: + """实现 `StyleFingerprintWriteRepo` Protocol(内存版本化 append + 最新读取)。 + + `append` 模拟 version=当前 max+1(首次 1);`latest` 取最大 version 行。 + 供 `GET /style` 读侧 + 学文风 work(mode=update → 第二次 append version+1)断言。 + """ + + def __init__(self) -> None: + # version -> (dimensions, evidence) + self.rows: dict[int, tuple[dict[str, Any], dict[str, Any]]] = {} + self.append_calls = 0 + + async def append( + self, + project_id: uuid.UUID, + *, + dimensions_json: dict[str, Any], + evidence_json: dict[str, Any], + ) -> int: + self.append_calls += 1 + next_version = (max(self.rows) if self.rows else 0) + 1 + self.rows[next_version] = (dict(dimensions_json), dict(evidence_json)) + return next_version + + async def latest(self, project_id: uuid.UUID) -> StyleFingerprintView | None: + if not self.rows: + return None + version = max(self.rows) + dimensions, evidence = self.rows[version] + return StyleFingerprintView(dimensions=dimensions, evidence=evidence, version=version) + + +class FakeJobRepo: + """实现 `JobRepo` Protocol(内存):create/get/状态流转,供 `POST /style` 断言。""" + + def __init__(self) -> None: + self.rows: dict[uuid.UUID, JobView] = {} + + async def create(self, project_id: uuid.UUID | None, kind: str) -> JobView: + view = JobView(id=uuid.uuid4(), kind=kind, status=STATUS_QUEUED, progress=0) + self.rows[view.id] = view + return view + + async def set_running(self, job_id: uuid.UUID) -> JobView: + view = self.rows[job_id].model_copy(update={"status": STATUS_RUNNING}) + self.rows[job_id] = view + return view + + async def set_progress(self, job_id: uuid.UUID, pct: int) -> JobView: + view = self.rows[job_id].model_copy(update={"progress": max(0, min(100, pct))}) + self.rows[job_id] = view + return view + + async def complete(self, job_id: uuid.UUID, result: dict[str, Any]) -> JobView: + view = self.rows[job_id].model_copy( + update={"status": STATUS_DONE, "progress": PROGRESS_COMPLETE, "result": dict(result)} + ) + self.rows[job_id] = view + return view + + async def fail(self, job_id: uuid.UUID, error: str) -> JobView: + view = self.rows[job_id].model_copy(update={"status": STATUS_FAILED, "error": error}) + self.rows[job_id] = view + return view + + async def get(self, job_id: uuid.UUID) -> JobView | None: + return self.rows.get(job_id) + + async def reap_zombies(self) -> int: + return 0 + + class FakeReviewGateway: """实现审稿/digest 节点最小依赖(`run`):吐固定结构化 `parsed`,绝不联网。""" - def __init__(self, parsed: BaseModel | None = None, error: Exception | None = None) -> None: + def __init__( + self, + parsed: BaseModel | None = None, + error: Exception | None = None, + *, + text: str | None = None, + ) -> None: self._parsed = parsed self._error = error + # `text` 覆盖纯文本产出(refiner output_schema=None → 端点消费 resp.text)。 + self._text = text self.requests: list[LlmRequest] = [] async def run(self, req: LlmRequest) -> LlmResponse: self.requests.append(req) if self._error is not None: raise self._error + if self._text is not None: + out_text = self._text + elif self._parsed is not None: + out_text = self._parsed.model_dump_json() + else: + out_text = "{}" return LlmResponse( - text=self._parsed.model_dump_json() if self._parsed else "{}", + text=out_text, parsed=self._parsed, usage=Usage( provider="fake", diff --git a/apps/api/tests/fakes_providers.py b/apps/api/tests/fakes_providers.py index a31d8b8..a945855 100644 --- a/apps/api/tests/fakes_providers.py +++ b/apps/api/tests/fakes_providers.py @@ -15,21 +15,34 @@ class FakeCredentialStore: """实现 `CredentialStore` Protocol 的内存版。""" def __init__(self) -> None: - # key: (owner_id, provider) → 密文 + # key: (owner_id, provider) → api_key 密文 self.creds: dict[tuple[uuid.UUID, str], bytes] = {} + # key: (owner_id, provider) → oauth 密文(OAuth 凭据,auth_type="oauth") + self.oauth: dict[tuple[uuid.UUID, str], bytes] = {} self.routing: dict[str, StoredRouting] = {} async def list_credentials(self, owner_id: uuid.UUID) -> list[StoredCredential]: - return [ + rows = [ StoredCredential(provider=p, api_key_enc=blob) for (o, p), blob in self.creds.items() if o == owner_id ] + rows += [ + StoredCredential(provider=p, api_key_enc=None, auth_type="oauth", oauth_enc=blob) + for (o, p), blob in self.oauth.items() + if o == owner_id + ] + return rows async def list_routing(self) -> list[StoredRouting]: return list(self.routing.values()) async def get_credential(self, owner_id: uuid.UUID, provider: str) -> StoredCredential | None: + oauth_blob = self.oauth.get((owner_id, provider)) + if oauth_blob is not None: + return StoredCredential( + provider=provider, api_key_enc=None, auth_type="oauth", oauth_enc=oauth_blob + ) blob = self.creds.get((owner_id, provider)) if blob is None: return None @@ -39,6 +52,19 @@ class FakeCredentialStore: self, owner_id: uuid.UUID, provider: str, api_key_enc: bytes ) -> None: self.creds[(owner_id, provider)] = api_key_enc + self.oauth.pop((owner_id, provider), None) + + async def upsert_oauth_credential( + self, owner_id: uuid.UUID, provider: str, oauth_enc: bytes + ) -> None: + self.oauth[(owner_id, provider)] = oauth_enc + self.creds.pop((owner_id, provider), None) + + async def delete_credential(self, owner_id: uuid.UUID, provider: str) -> bool: + had = (owner_id, provider) in self.creds or (owner_id, provider) in self.oauth + self.creds.pop((owner_id, provider), None) + self.oauth.pop((owner_id, provider), None) + return had async def upsert_routing(self, routing: StoredRouting) -> None: self.routing[routing.tier] = routing diff --git a/apps/api/tests/test_gateway_fallback_wiring.py b/apps/api/tests/test_gateway_fallback_wiring.py new file mode 100644 index 0000000..8684f56 --- /dev/null +++ b/apps/api/tests/test_gateway_fallback_wiring.py @@ -0,0 +1,100 @@ +"""T5.2/T5.4 接线:`build_gateway_for_tier` 据 DB tier_routing 装配多 provider 回退链。 + +不联网:只断言网关被装配了**多个适配器** + 注入了回退链解析器(chain_resolver), +故链长 > 1。真实回退/降级行为由 packages/llm_gateway 单测 + T5.7 E2E 覆盖。 +""" + +from __future__ import annotations + +import os + +import pytest +from fakes_projects import FakeSession +from fakes_providers import FakeCredentialStore +from ww_api.services.credentials import STUB_OWNER_ID, StoredRouting + +# 真 Fernet key(测试加解密凭据用;仅本测试,非生产密钥)。 +_ENC_KEY = "imB6TEcV2AzlpXjR4NjTgK_9Zt-OoqaLFQc3XqBQ0CQ=" + + +def _store_with_creds(*providers: str) -> FakeCredentialStore: + from ww_api.security.credentials import encrypt_api_key + + store = FakeCredentialStore() + for p in providers: + store.creds[(STUB_OWNER_ID, p)] = encrypt_api_key("sk-test", key=_ENC_KEY) + return store + + +@pytest.mark.asyncio +async def test_build_gateway_wires_fallback_chain_with_multiple_adapters() -> None: + os.environ["CREDENTIAL_ENC_KEY"] = _ENC_KEY + from ww_config import get_settings + + get_settings.cache_clear() + from ww_api.services.project_deps import build_gateway_for_tier + + store = _store_with_creds("deepseek", "kimi") + store.routing["writer"] = StoredRouting( + tier="writer", provider="deepseek", model="deepseek-chat", fallback=["kimi:moonshot-v1-8k"] + ) + + gateway = await build_gateway_for_tier(FakeSession(), store, "writer") + + # 两个 provider 的适配器都装配进网关 → 回退链可绕过首个失败者。 + assert set(gateway._adapters) == {"deepseek", "kimi"} # noqa: SLF001 + # chain_resolver 注入 → 链长 2(去重保序)。 + chain = gateway._resolve_chain("writer") # noqa: SLF001 + assert [r.provider for r in chain] == ["deepseek", "kimi"] + + +@pytest.mark.asyncio +async def test_build_gateway_single_provider_still_works() -> None: + """无 fallback 的路由行:单元素链(M1 行为不变,向后兼容)。""" + os.environ["CREDENTIAL_ENC_KEY"] = _ENC_KEY + from ww_config import get_settings + + get_settings.cache_clear() + from ww_api.services.project_deps import build_gateway_for_tier + + store = _store_with_creds("deepseek") + store.routing["analyst"] = StoredRouting( + tier="analyst", provider="deepseek", model="deepseek-chat", fallback=[] + ) + + gateway = await build_gateway_for_tier(FakeSession(), store, "analyst") + assert set(gateway._adapters) == {"deepseek"} # noqa: SLF001 + chain = gateway._resolve_chain("analyst") # noqa: SLF001 + assert len(chain) == 1 + + +@pytest.mark.asyncio +async def test_build_gateway_no_routing_row_falls_back_to_global_default() -> None: + """无 DB 路由行 → 退回全局 resolve_route(单 provider)。""" + os.environ["CREDENTIAL_ENC_KEY"] = _ENC_KEY + from ww_config import get_settings + + get_settings.cache_clear() + from ww_api.services.project_deps import build_gateway_for_tier + + store = _store_with_creds("deepseek") # 无 routing 行 + gateway = await build_gateway_for_tier(FakeSession(), store, "writer") + assert "deepseek" in gateway._adapters # noqa: SLF001 + + +@pytest.mark.asyncio +async def test_build_gateway_no_usable_credentials_raises_503() -> None: + os.environ["CREDENTIAL_ENC_KEY"] = _ENC_KEY + from ww_config import get_settings + + get_settings.cache_clear() + from ww_api.services.project_deps import build_gateway_for_tier + from ww_shared import AppError, ErrorCode + + store = FakeCredentialStore() # 无任何凭据 + store.routing["writer"] = StoredRouting( + tier="writer", provider="deepseek", model="deepseek-chat", fallback=["kimi:moonshot-v1-8k"] + ) + with pytest.raises(AppError) as exc: + await build_gateway_for_tier(FakeSession(), store, "writer") + assert exc.value.code == ErrorCode.LLM_UNAVAILABLE diff --git a/apps/api/tests/test_generation.py b/apps/api/tests/test_generation.py new file mode 100644 index 0000000..5b11214 --- /dev/null +++ b/apps/api/tests/test_generation.py @@ -0,0 +1,506 @@ +"""T5.2 生成/入库 + 读端点测试(内存替身,无 DB/无网络)。 + +覆盖: +- POST /world/generate:mock 网关产 WorldGenResult → 预览 + commit(落 ledger);项目不存在 404。 +- POST /characters/generate:mock 网关产 CharacterGenResult → 预览(含已有角色注入防雷同)。 +- POST /characters(入库):precheck 无冲突 → 写库 + 201;有冲突且未确认 → 409;确认后放行写库。 +- 无凭据 → 503(override 网关 dep 抛 LLM_UNAVAILABLE)。 +- GET /rules:规则列表;GET /skills:技能库列表。 +""" + +from __future__ import annotations + +import os +import uuid +from typing import Any + +import httpx +import pytest +from fakes_projects import FakeProjectRepo, FakeSession +from test_projects import _empty_memory_repos +from ww_agents import ( + AgentSpec, + CharacterCard, + CharacterGenResult, + Conflict, + ContinuityReview, + WorldEntityCard, + WorldGenResult, +) +from ww_core.domain.character_repo import CharacterWriteView +from ww_core.domain.project_repo import ProjectCreate +from ww_core.domain.repositories import RuleView +from ww_llm_gateway.types import LlmRequest, LlmResponse, ServedBy, Usage +from ww_shared import AppError, ErrorCode +from ww_skills import SkillRegistry + +STUB_OWNER = uuid.UUID(int=1) + + +class _SchemaRoutingGateway: + """按 `req.output_schema` 返对应 parsed(world/character/precheck 各拿自己的产物)。""" + + def __init__(self, by_schema: dict[type[Any] | None, Any]) -> None: + self._by_schema = by_schema + self.calls: list[type[Any] | None] = [] + + async def run(self, req: LlmRequest) -> LlmResponse: + schema = req.output_schema + self.calls.append(schema) + parsed = self._by_schema.get(schema) + return LlmResponse( + text=parsed.model_dump_json() if parsed is not None else "{}", + parsed=parsed, + usage=Usage( + provider="fake", + model="fake", + input_tokens=1, + output_tokens=1, + cost_minor=0, + currency="USD", + ), + served_by=ServedBy(provider="fake", model="fake"), + ) + + +class _FakeCharacterWriteRepo: + def __init__(self) -> None: + self.rows: list[dict[str, Any]] = [] + + async def create( + self, + project_id: uuid.UUID, + *, + name: str, + role: str, + traits: list[str], + backstory: str, + arc: str, + speech_tics: list[str], + tags: list[Any], + relations: list[dict[str, Any]], + ) -> CharacterWriteView: + self.rows.append( + { + "project_id": project_id, + "name": name, + "role": role, + "traits": list(traits), + "arc": arc, + "speech_tics": list(speech_tics), + "tags": list(tags), + "relations": [dict(r) for r in relations], + } + ) + return CharacterWriteView(id=uuid.uuid4(), name=name, role=role) + + +class _FakeRulesReadRepo: + def __init__(self, rules: list[RuleView] | None = None) -> None: + self._rules = rules or [] + + async def all_for_project(self, project_id: uuid.UUID) -> list[RuleView]: + return list(self._rules) + + +def _world_result() -> WorldGenResult: + return WorldGenResult( + entities=[WorldEntityCard(type="力量体系", name="灵脉", rules=["灵力守恒", "禁止跨界"])] + ) + + +def _character_result() -> CharacterGenResult: + return CharacterGenResult( + cards=[ + CharacterCard( + name="叶寒", + role="主角", + traits=["腹黑", "护短"], + backstory="孤儿出身", + arc="从孤儿到帝王", + speech_tics=["哼"], + tags=["天才"], + relations=[], + ) + ] + ) + + +def _no_conflicts() -> ContinuityReview: + return ContinuityReview(conflicts=[]) + + +def _with_conflicts() -> ContinuityReview: + return ContinuityReview( + conflicts=[Conflict(type="设定违例", where="叶寒卡", refs=["灵脉"], suggestion="改设定")] + ) + + +def _skill_registry() -> SkillRegistry: + spec = AgentSpec( + name="custom-namer", + tier="light", + system_prompt="取名", + input_schema=None, + output_schema=None, + reads=["characters"], + writes=["characters"], + scope="custom", + ) + return SkillRegistry({spec.name: spec}) + + +def _make_app( + *, + project_repo: FakeProjectRepo, + gateway: Any, + char_repo: _FakeCharacterWriteRepo | None = None, + session: FakeSession | None = None, + rules_repo: _FakeRulesReadRepo | None = None, + registry: SkillRegistry | None = None, + memory: Any = None, + no_creds: bool = False, +) -> tuple[Any, FakeSession]: + os.environ.setdefault("CREDENTIAL_ENC_KEY", "x" * 44) + from ww_api.main import create_app + from ww_api.services.project_deps import ( + get_character_gen_gateway, + get_character_write_repo, + get_memory_repos, + get_precheck_gateway, + get_project_repo, + get_rules_read_repo, + get_skill_registry, + get_worldbuilder_gateway, + ) + from ww_db import get_session + + session = session or FakeSession() + char_repo = char_repo or _FakeCharacterWriteRepo() + rules_repo = rules_repo or _FakeRulesReadRepo() + registry = registry or _skill_registry() + + async def _raise_no_creds() -> object: + raise AppError(ErrorCode.LLM_UNAVAILABLE, "未配置凭据", {"provider": "deepseek"}) + + app = create_app() + app.dependency_overrides[get_project_repo] = lambda: project_repo + app.dependency_overrides[get_memory_repos] = ( + (lambda: memory) if memory is not None else _empty_memory_repos + ) + app.dependency_overrides[get_character_write_repo] = lambda: char_repo + app.dependency_overrides[get_rules_read_repo] = lambda: rules_repo + app.dependency_overrides[get_skill_registry] = lambda: registry + app.dependency_overrides[get_session] = lambda: session + gw = _raise_no_creds if no_creds else (lambda: gateway) + app.dependency_overrides[get_worldbuilder_gateway] = gw + app.dependency_overrides[get_character_gen_gateway] = gw + app.dependency_overrides[get_precheck_gateway] = gw + return app, session + + +async def _seed_project(repo: FakeProjectRepo) -> uuid.UUID: + view = await repo.create(STUB_OWNER, ProjectCreate(title="测试作品", genre="玄幻")) + return uuid.UUID(str(view.id)) + + +def _client(app: Any) -> httpx.AsyncClient: + transport = httpx.ASGITransport(app=app, raise_app_exceptions=False) + return httpx.AsyncClient(transport=transport, base_url="http://test") + + +# ---- 世界观生成 ---- + + +@pytest.mark.asyncio +async def test_generate_world_returns_preview_and_commits_ledger() -> None: + repo = FakeProjectRepo() + pid = await _seed_project(repo) + gateway = _SchemaRoutingGateway({WorldGenResult: _world_result()}) + app, session = _make_app(project_repo=repo, gateway=gateway) + + async with _client(app) as client: + resp = await client.post(f"/projects/{pid}/world/generate", json={"brief": "东方玄幻"}) + + assert resp.status_code == 200 + body = resp.json() + assert body["entities"][0]["name"] == "灵脉" + assert body["entities"][0]["rules"] == ["灵力守恒", "禁止跨界"] + assert session.commits == 1 # 预览不写业务表,但落 ledger + + +@pytest.mark.asyncio +async def test_generate_world_unknown_project_404() -> None: + repo = FakeProjectRepo() + gateway = _SchemaRoutingGateway({WorldGenResult: _world_result()}) + app, _ = _make_app(project_repo=repo, gateway=gateway) + async with _client(app) as client: + resp = await client.post(f"/projects/{uuid.uuid4()}/world/generate", json={"brief": "x"}) + assert resp.status_code == 404 + assert resp.json()["error"]["code"] == ErrorCode.NOT_FOUND + + +@pytest.mark.asyncio +async def test_generate_world_no_credentials_503() -> None: + repo = FakeProjectRepo() + pid = await _seed_project(repo) + app, session = _make_app(project_repo=repo, gateway=object(), no_creds=True) + async with _client(app) as client: + resp = await client.post(f"/projects/{pid}/world/generate", json={"brief": "x"}) + assert resp.status_code == 503 + assert resp.json()["error"]["code"] == ErrorCode.LLM_UNAVAILABLE + assert session.commits == 0 + + +# ---- 角色生成 ---- + + +@pytest.mark.asyncio +async def test_generate_characters_returns_preview() -> None: + repo = FakeProjectRepo() + pid = await _seed_project(repo) + gateway = _SchemaRoutingGateway({CharacterGenResult: _character_result()}) + app, session = _make_app(project_repo=repo, gateway=gateway) + + async with _client(app) as client: + resp = await client.post( + f"/projects/{pid}/characters/generate", json={"brief": "天才主角", "count": 1} + ) + + assert resp.status_code == 200 + card = resp.json()["cards"][0] + assert card["name"] == "叶寒" + assert card["traits"] == ["腹黑", "护短"] + assert card["arc"] == "从孤儿到帝王" + assert session.commits == 1 + + +# ---- 角色入库(gate)---- + + +@pytest.mark.asyncio +async def test_ingest_characters_no_conflict_writes_and_201() -> None: + repo = FakeProjectRepo() + pid = await _seed_project(repo) + gateway = _SchemaRoutingGateway({ContinuityReview: _no_conflicts()}) + char_repo = _FakeCharacterWriteRepo() + app, session = _make_app(project_repo=repo, gateway=gateway, char_repo=char_repo) + + async with _client(app) as client: + resp = await client.post( + f"/projects/{pid}/characters", + json={ + "cards": [ + { + "name": "叶寒", + "role": "主角", + "traits": ["腹黑"], + "backstory": "孤儿", + "arc": "成长", + "speech_tics": ["哼"], + "tags": ["天才"], + "relations": [], + } + ] + }, + ) + + assert resp.status_code == 201 + body = resp.json() + assert body["created"] == ["叶寒"] + assert body["rejected_tables"] == [] + assert len(char_repo.rows) == 1 + assert char_repo.rows[0]["traits"] == ["腹黑"] + assert session.commits == 1 + + +@pytest.mark.asyncio +async def test_ingest_characters_with_conflict_blocks_409() -> None: + repo = FakeProjectRepo() + pid = await _seed_project(repo) + gateway = _SchemaRoutingGateway({ContinuityReview: _with_conflicts()}) + char_repo = _FakeCharacterWriteRepo() + app, session = _make_app(project_repo=repo, gateway=gateway, char_repo=char_repo) + + async with _client(app) as client: + resp = await client.post( + f"/projects/{pid}/characters", + json={"cards": [{"name": "叶寒", "role": "主角", "backstory": "孤儿", "arc": "成长"}]}, + ) + + assert resp.status_code == 409 + err = resp.json()["error"] + assert err["code"] == ErrorCode.CONFLICT_UNRESOLVED + assert err["details"]["conflict_count"] == 1 + assert err["details"]["conflicts"][0]["type"] == "设定违例" + # 未写库(gate 拦下),但落 precheck ledger。 + assert len(char_repo.rows) == 0 + assert session.commits == 1 + + +@pytest.mark.asyncio +async def test_ingest_characters_acknowledged_conflict_writes() -> None: + repo = FakeProjectRepo() + pid = await _seed_project(repo) + gateway = _SchemaRoutingGateway({ContinuityReview: _with_conflicts()}) + char_repo = _FakeCharacterWriteRepo() + app, _ = _make_app(project_repo=repo, gateway=gateway, char_repo=char_repo) + + async with _client(app) as client: + resp = await client.post( + f"/projects/{pid}/characters", + json={ + "cards": [{"name": "叶寒", "role": "主角", "backstory": "孤儿", "arc": "成长"}], + "acknowledge_conflicts": True, + }, + ) + + assert resp.status_code == 201 + assert len(char_repo.rows) == 1 + + +# ---- 读端点 ---- + + +@pytest.mark.asyncio +async def test_list_rules_returns_rules() -> None: + repo = FakeProjectRepo() + pid = await _seed_project(repo) + rules_repo = _FakeRulesReadRepo( + [ + RuleView(level="project", content="主角不复活"), + RuleView(level="global", content="无脏话"), + ] + ) + gateway = _SchemaRoutingGateway({}) + app, _ = _make_app(project_repo=repo, gateway=gateway, rules_repo=rules_repo) + + async with _client(app) as client: + resp = await client.get(f"/projects/{pid}/rules") + + assert resp.status_code == 200 + rules = resp.json()["rules"] + assert [r["content"] for r in rules] == ["主角不复活", "无脏话"] + + +@pytest.mark.asyncio +async def test_list_skills_returns_registry() -> None: + repo = FakeProjectRepo() + gateway = _SchemaRoutingGateway({}) + app, _ = _make_app(project_repo=repo, gateway=gateway) + + async with _client(app) as client: + resp = await client.get("/skills") + + assert resp.status_code == 200 + skills = resp.json()["skills"] + assert skills[0]["name"] == "custom-namer" + assert skills[0]["scope"] == "custom" + assert skills[0]["writes"] == ["characters"] + + +# ---- 设定库 Codex 读端点(GET characters / world_entities)---- + + +def _codex_memory() -> Any: + """MemoryRepos:角色/世界观各一行(DB JSONB dict 形,验反向解包到 API list/str)。""" + from test_projects import ( + _EmptyDigestRepo, + _EmptyForeshadowRepo, + _EmptyOutlineRepo, + _EmptyRulesRepo, + _EmptyStyleRepo, + _StubProjectSpecRepo, + ) + from ww_core.domain.repositories import ( + CharacterView, + MemoryRepos, + WorldEntityView, + ) + + class _CharRepo: + async def list_for_project(self, project_id: uuid.UUID) -> list[CharacterView]: + return [ + CharacterView( + name="叶寒", + role="主角", + traits={"items": ["腹黑", "护短"]}, + backstory="孤儿出身", + arc={"text": "从孤儿到帝王"}, + speech_tics={"items": ["哼"]}, + tags=["天才"], + relations=[], + ) + ] + + class _WorldRepo: + async def list_for_project(self, project_id: uuid.UUID) -> list[WorldEntityView]: + return [ + WorldEntityView( + type="力量体系", + name="灵脉", + rules={"rules": ["灵力守恒", "禁止跨界"]}, + ) + ] + + return MemoryRepos( + outline=_EmptyOutlineRepo(), + character=_CharRepo(), + world_entity=_WorldRepo(), + digest=_EmptyDigestRepo(), + foreshadow=_EmptyForeshadowRepo(), + style=_EmptyStyleRepo(), + rules=_EmptyRulesRepo(), + project=_StubProjectSpecRepo(), + ) + + +@pytest.mark.asyncio +async def test_list_characters_unpacks_jsonb_to_api_shape() -> None: + repo = FakeProjectRepo() + pid = await _seed_project(repo) + app, _ = _make_app(project_repo=repo, gateway=_SchemaRoutingGateway({}), memory=_codex_memory()) + + async with _client(app) as client: + resp = await client.get(f"/projects/{pid}/characters") + + assert resp.status_code == 200 + chars = resp.json()["characters"] + assert len(chars) == 1 + card = chars[0] + # DB JSONB dict → API list/str(反向解包,入库形变的逆向)。 + assert card["name"] == "叶寒" + assert card["traits"] == ["腹黑", "护短"] + assert card["speech_tics"] == ["哼"] + assert card["arc"] == "从孤儿到帝王" + assert card["tags"] == ["天才"] + + +@pytest.mark.asyncio +async def test_list_world_entities_unpacks_rules_to_list() -> None: + repo = FakeProjectRepo() + pid = await _seed_project(repo) + app, _ = _make_app(project_repo=repo, gateway=_SchemaRoutingGateway({}), memory=_codex_memory()) + + async with _client(app) as client: + resp = await client.get(f"/projects/{pid}/world_entities") + + assert resp.status_code == 200 + entities = resp.json()["world_entities"] + assert len(entities) == 1 + assert entities[0]["type"] == "力量体系" + assert entities[0]["name"] == "灵脉" + assert entities[0]["rules"] == ["灵力守恒", "禁止跨界"] + + +@pytest.mark.asyncio +async def test_list_characters_empty_when_none_ingested() -> None: + repo = FakeProjectRepo() + pid = await _seed_project(repo) + app, _ = _make_app(project_repo=repo, gateway=_SchemaRoutingGateway({})) + + async with _client(app) as client: + resp = await client.get(f"/projects/{pid}/characters") + + assert resp.status_code == 200 + assert resp.json()["characters"] == [] diff --git a/apps/api/tests/test_job_runner.py b/apps/api/tests/test_job_runner.py new file mode 100644 index 0000000..4289a87 --- /dev/null +++ b/apps/api/tests/test_job_runner.py @@ -0,0 +1,153 @@ +"""T4.1 通用长任务 runner 单测(ARCH §7.4)。 + +直接 `await run_job(...)`(不起后台线程、不连真 DB),注入 fake session 工厂 + fake job +repo + fake work,断言: +- 成功路:set_running → work → complete(result) → commit; +- 失败路:work 抛 → 回滚 → 新 session 里 fail(error) → commit,且异常不冒泡。 + +独立 session 纪律 = `run_job` 自建 session(经 session_factory),与 `run_overdue_scan` +先例一致。这里的 fake session 工厂记录开了几次 session + 各次 commit。 +""" + +from __future__ import annotations + +import uuid +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager +from dataclasses import dataclass, field +from typing import Any + +from sqlalchemy.ext.asyncio import AsyncSession +from ww_api.services.job_runner import run_job +from ww_core.domain.job_repo import ( + PROGRESS_COMPLETE, + STATUS_DONE, + STATUS_FAILED, + STATUS_RUNNING, + JobView, +) + +JOB_ID = uuid.UUID("00000000-0000-0000-0000-0000000000aa") + + +class _FakeSession: + """最小 fake:记录 commit 次数(提交边界断言)。""" + + def __init__(self) -> None: + self.commits = 0 + + async def commit(self) -> None: + self.commits += 1 + + +class _FakeSessionFactory: + """独立 session 工厂替身:`()` → async-CM 产新 `_FakeSession`,记录开了几次。""" + + def __init__(self) -> None: + self.sessions: list[_FakeSession] = [] + + def __call__(self) -> Any: + session = _FakeSession() + self.sessions.append(session) + + @asynccontextmanager + async def _cm() -> AsyncIterator[_FakeSession]: + yield session + + return _cm() + + +@dataclass +class _FakeJobRepo: + """内存 job repo:记录状态流转 + result/error(不触 DB)。""" + + status: str = "queued" + progress: int = 0 + result: dict[str, Any] | None = None + error: str | None = None + calls: list[str] = field(default_factory=list) + + def _view(self, job_id: uuid.UUID) -> JobView: + return JobView( + id=job_id, + kind="style_learn", + status=self.status, + progress=self.progress, + result=self.result, + error=self.error, + ) + + async def set_running(self, job_id: uuid.UUID) -> JobView: + self.calls.append("set_running") + self.status = STATUS_RUNNING + return self._view(job_id) + + async def complete(self, job_id: uuid.UUID, result: dict[str, Any]) -> JobView: + self.calls.append("complete") + self.status = STATUS_DONE + self.progress = PROGRESS_COMPLETE + self.result = dict(result) + return self._view(job_id) + + async def fail(self, job_id: uuid.UUID, error: str) -> JobView: + self.calls.append("fail") + self.status = STATUS_FAILED + self.error = error + return self._view(job_id) + + +# ---- success path ---- + + +async def test_run_job_success_sets_done_and_commits() -> None: + factory = _FakeSessionFactory() + repo = _FakeJobRepo() + received: list[AsyncSession] = [] + + async def work(session: AsyncSession) -> dict[str, Any]: + received.append(session) + return {"version": 1, "dims_count": 16} + + await run_job( + factory, + JOB_ID, + work, + repo_factory=lambda _s: repo, + ) + + assert repo.calls == ["set_running", "complete"] + assert repo.status == STATUS_DONE + assert repo.progress == PROGRESS_COMPLETE + assert repo.result == {"version": 1, "dims_count": 16} + # 自建了一个独立 session 且提交了一次 + assert len(factory.sessions) == 1 + assert factory.sessions[0].commits == 1 + # work 拿到的就是 run_job 自建的 session + assert len(received) == 1 + + +# ---- failure path ---- + + +async def test_run_job_failure_sets_failed_and_does_not_raise() -> None: + factory = _FakeSessionFactory() + repo = _FakeJobRepo() + + async def work(_session: AsyncSession) -> dict[str, Any]: + raise RuntimeError("extraction blew up") + + # 异常被吞(后台任务边界),不冒泡 + await run_job( + factory, + JOB_ID, + work, + repo_factory=lambda _s: repo, + ) + + assert "complete" not in repo.calls + assert "fail" in repo.calls + assert repo.status == STATUS_FAILED + assert repo.error == "extraction blew up" + # 失败置态在一个**全新** session 里完成并 commit(原事务作废) + assert len(factory.sessions) == 2 + assert factory.sessions[1].commits == 1 diff --git a/apps/api/tests/test_kimi_code_key_wiring.py b/apps/api/tests/test_kimi_code_key_wiring.py new file mode 100644 index 0000000..bbf1e79 --- /dev/null +++ b/apps/api/tests/test_kimi_code_key_wiring.py @@ -0,0 +1,77 @@ +"""接线测试:`kimi-code-key` 作为**普通 api_key** provider 经既有路径建干净 coding 适配器。 + +不联网。与 OAuth 的 `kimi-code` 对照——`kimi-code-key`: +- 走 api_key 凭据路径(Fernet `api_key_enc`),**不**触发 OAuth 刷新分支; +- `build_gateway_for_tier` 据 DB `tier_routing` 路由 → `_build_provider_adapter` → + `build_adapter("kimi-code-key", ...)` → `KimiCodeKeyAdapter`(coding base + 纯 bearer); +- 适配器**不带**伪造 UA / X-Msh-* 头,结构化走 JSON 模式。 +""" + +from __future__ import annotations + +import os + +import pytest +from fakes_projects import FakeSession +from fakes_providers import FakeCredentialStore +from ww_api.services.credentials import STUB_OWNER_ID, StoredRouting +from ww_api.services.project_deps import _build_provider_adapter, build_gateway_for_tier +from ww_llm_gateway.adapters.kimi_code_key import KIMI_CODE_KEY_PROVIDER, KimiCodeKeyAdapter + +_ENC_KEY = "imB6TEcV2AzlpXjR4NjTgK_9Zt-OoqaLFQc3XqBQ0CQ=" +_API_KEY = "kimi-console-static-key-not-real" + + +def _set_env() -> None: + os.environ["CREDENTIAL_ENC_KEY"] = _ENC_KEY + from ww_config import get_settings + + get_settings.cache_clear() + + +def _store_with_key() -> FakeCredentialStore: + from ww_api.security.credentials import encrypt_api_key + + store = FakeCredentialStore() + store.creds[(STUB_OWNER_ID, KIMI_CODE_KEY_PROVIDER)] = encrypt_api_key(_API_KEY, key=_ENC_KEY) + return store + + +@pytest.mark.asyncio +async def test_build_provider_adapter_for_kimi_code_key_sends_spoofed_headers() -> None: + _set_env() + store = _store_with_key() + + adapter = await _build_provider_adapter(store, KIMI_CODE_KEY_PROVIDER) + + assert isinstance(adapter, KimiCodeKeyAdapter) + assert adapter.provider == "kimi-code-key" + client = adapter._client # noqa: SLF001 + assert str(client.base_url).rstrip("/") == "https://api.kimi.com/coding/v1" + assert client.api_key == _API_KEY + # 实测:coding 端点据 UA 做 allow-list 门禁,缺伪造头→403,故静态 key 也必须发伪造头。 + headers = client.default_headers + assert any(str(k).lower().startswith("x-msh-") for k in headers) + assert "KimiCLI" in str(headers.get("User-Agent", "")) + + +@pytest.mark.asyncio +async def test_build_gateway_for_tier_routes_kimi_code_key_for_coding_model() -> None: + _set_env() + store = _store_with_key() + # 把 writer 档位路由到 kimi-code-key:kimi-for-coding(无回退)。 + store.routing["writer"] = StoredRouting( + tier="writer", + provider=KIMI_CODE_KEY_PROVIDER, + model="kimi-for-coding", + fallback=[], + ) + + gateway = await build_gateway_for_tier(FakeSession(), store, "writer") + + assert set(gateway._adapters) == {KIMI_CODE_KEY_PROVIDER} # noqa: SLF001 + adapter = gateway._adapters[KIMI_CODE_KEY_PROVIDER] # noqa: SLF001 + assert isinstance(adapter, KimiCodeKeyAdapter) + chain = gateway._resolve_chain("writer") # noqa: SLF001 + assert [r.provider for r in chain] == [KIMI_CODE_KEY_PROVIDER] + assert chain[0].model == "kimi-for-coding" diff --git a/apps/api/tests/test_kimi_code_wiring.py b/apps/api/tests/test_kimi_code_wiring.py new file mode 100644 index 0000000..3ec4ec2 --- /dev/null +++ b/apps/api/tests/test_kimi_code_wiring.py @@ -0,0 +1,125 @@ +"""K1.3 接线测试:`_build_provider_adapter` 为 kimi-code 走 OAuth 路径 + 按需刷新。 + +不联网:refresh 路径 monkeypatch httpx + kimi_oauth.refresh。断言: +- 凭据未临近过期 → 直接用现有 access token 建 KimiCodeAdapter(不刷新); +- 临近过期 → 调 refresh + 持久化新包 + 用新 access token 建适配器; +- 无 oauth 凭据 → LLM_UNAVAILABLE。 +""" + +from __future__ import annotations + +import os +from datetime import UTC, datetime, timedelta +from typing import Any + +import pytest +from fakes_providers import FakeCredentialStore +from ww_api.services.credentials import STUB_OWNER_ID +from ww_api.services.kimi_oauth import TokenSet, decrypt_oauth_bundle, encrypt_oauth_bundle +from ww_llm_gateway.adapters.kimi_code import KIMI_CODE_PROVIDER, KimiCodeAdapter +from ww_shared import AppError, ErrorCode + +_ENC_KEY = "imB6TEcV2AzlpXjR4NjTgK_9Zt-OoqaLFQc3XqBQ0CQ=" + + +def _store_with_token(token: TokenSet) -> FakeCredentialStore: + store = FakeCredentialStore() + store.oauth[(STUB_OWNER_ID, KIMI_CODE_PROVIDER)] = encrypt_oauth_bundle(token, key=_ENC_KEY) + return store + + +def _set_env() -> None: + os.environ["CREDENTIAL_ENC_KEY"] = _ENC_KEY + from ww_config import get_settings + + get_settings.cache_clear() + + +@pytest.mark.asyncio +async def test_build_adapter_uses_existing_token_when_fresh() -> None: + _set_env() + from ww_api.services.project_deps import _build_provider_adapter + + fresh = TokenSet( + access_token="fresh-acc", + refresh_token="ref", + expires_at=datetime.now(UTC) + timedelta(seconds=900), + ) + store = _store_with_token(fresh) + + adapter = await _build_provider_adapter(store, KIMI_CODE_PROVIDER) + + assert isinstance(adapter, KimiCodeAdapter) + # 未刷新:oauth 凭据未变。 + cred = store.oauth[(STUB_OWNER_ID, KIMI_CODE_PROVIDER)] + assert decrypt_oauth_bundle(cred, key=_ENC_KEY).access_token == "fresh-acc" + + +@pytest.mark.asyncio +async def test_build_adapter_refreshes_when_near_expiry(monkeypatch: pytest.MonkeyPatch) -> None: + _set_env() + from ww_api.services import project_deps + from ww_api.services.project_deps import _build_provider_adapter + + near = TokenSet( + access_token="old-acc", + refresh_token="old-ref", + expires_at=datetime.now(UTC) + timedelta(seconds=30), # 临近过期 + ) + store = _store_with_token(near) + + refreshed = TokenSet( + access_token="new-acc", + refresh_token="new-ref", + expires_at=datetime.now(UTC) + timedelta(seconds=900), + ) + + async def _fake_refresh(_http: Any, _refresh_token: str) -> TokenSet: + assert _refresh_token == "old-ref" + return refreshed + + # 替换 refresh + httpx.AsyncClient(避免真联网)。 + monkeypatch.setattr(project_deps, "kimi_refresh", _fake_refresh) + + class _FakeAsyncClient: + async def __aenter__(self) -> _FakeAsyncClient: + return self + + async def __aexit__(self, *exc: object) -> None: + return None + + monkeypatch.setattr( + "ww_api.services.project_deps.httpx.AsyncClient", lambda *a, **k: _FakeAsyncClient() + ) + + adapter = await _build_provider_adapter(store, KIMI_CODE_PROVIDER) + + assert isinstance(adapter, KimiCodeAdapter) + # 刷新后新包持久化(下次复用刷新结果)。 + cred = store.oauth[(STUB_OWNER_ID, KIMI_CODE_PROVIDER)] + assert decrypt_oauth_bundle(cred, key=_ENC_KEY).access_token == "new-acc" + + +@pytest.mark.asyncio +async def test_build_adapter_no_credential_returns_none() -> None: + _set_env() + from ww_api.services.project_deps import _build_provider_adapter + + store = FakeCredentialStore() # 无任何凭据 + adapter = await _build_provider_adapter(store, KIMI_CODE_PROVIDER) + assert adapter is None + + +@pytest.mark.asyncio +async def test_build_adapter_raises_when_oauth_missing_blob() -> None: + """auth_type=oauth 但 oauth_enc=None(数据不一致)→ LLM_UNAVAILABLE。""" + _set_env() + from ww_api.services.credentials import StoredCredential + from ww_api.services.project_deps import _resolve_kimi_code_token + + bad = StoredCredential( + provider=KIMI_CODE_PROVIDER, api_key_enc=None, auth_type="oauth", oauth_enc=None + ) + with pytest.raises(AppError) as exc: + await _resolve_kimi_code_token(FakeCredentialStore(), bad, _ENC_KEY) + assert exc.value.code == ErrorCode.LLM_UNAVAILABLE diff --git a/apps/api/tests/test_kimi_oauth_endpoints.py b/apps/api/tests/test_kimi_oauth_endpoints.py new file mode 100644 index 0000000..8a4fdd4 --- /dev/null +++ b/apps/api/tests/test_kimi_oauth_endpoints.py @@ -0,0 +1,213 @@ +"""K1.3 端点测试:Kimi Code OAuth start/disconnect/status(内存替身,无 DB/无网络)。 + +覆盖: +- POST .../start → 202 + user_code/verification_uri + 创建 job;后台轮询 poll_token 成功 + → 存加密 oauth_enc + job done;**token 不进 job 结果/响应**; +- 后台轮询 authorization_pending → 继续轮询直到成功; +- POST .../disconnect → 清除凭据行; +- GET .../status → connected 真值(无 token 本体)。 + +后台 work 用 `run_job` 自建独立 session(请求 session 已关闭)——测试经依赖 override +注 fake http + fake store(共享实例,断后台落库)+ FakeSessionFactory;monkeypatch +`kimi_oauth.asyncio.sleep` 为 no-op(避免真等 interval 秒)。 +""" + +from __future__ import annotations + +import os +from typing import Any + +import pytest +from cryptography.fernet import Fernet +from fakes_providers import FakeCredentialStore +from fastapi.testclient import TestClient +from ww_api.services.credentials import STUB_OWNER_ID +from ww_api.services.kimi_oauth import decrypt_oauth_bundle +from ww_db import get_session + + +class _FakeResponse: + def __init__(self, status_code: int, body: dict[str, Any]) -> None: + self.status_code = status_code + self._body = body + + def json(self) -> Any: + return self._body + + +class _ScriptedHttp: + """按调用序号返回预设响应(device auth → poll attempts)。aclose 无操作。""" + + def __init__(self, responses: list[_FakeResponse]) -> None: + self._responses = responses + self.calls: list[tuple[str, dict[str, str]]] = [] + + async def post(self, url: str, *, data: dict[str, str]) -> _FakeResponse: + idx = len(self.calls) + self.calls.append((url, data)) + return self._responses[idx] + + async def aclose(self) -> None: + return None + + +def _make_client( + *, + store: FakeCredentialStore, + http: _ScriptedHttp, + enc_key: str, + monkeypatch: pytest.MonkeyPatch, +) -> TestClient: + os.environ["CREDENTIAL_ENC_KEY"] = enc_key + from ww_config import get_settings + + get_settings.cache_clear() + + from fakes_projects import FakeJobRepo, FakeSession, FakeSessionFactory + from ww_api import routers + from ww_api.main import create_app + from ww_api.services import job_runner + from ww_api.services.project_deps import ( + get_job_repo, + get_session_factory, + ) + from ww_api.services.provider_deps import get_credential_store + + # 后台轮询不真等 interval 秒。 + async def _no_sleep(_seconds: float) -> None: + return None + + monkeypatch.setattr("ww_api.routers.kimi_oauth.asyncio.sleep", _no_sleep) + # 后台 work 自建 SqlCredentialStore(session)——指到共享 fake store(断后台落库)。 + monkeypatch.setattr(routers.kimi_oauth, "SqlCredentialStore", lambda _session: store) + # 后台 work 内调模块级 `_default_http_client()` 自建 http(非 dep)——monkeypatch 它指到 + # scripted fake,否则后台轮询会真联网(见 device-flow 注入纪律)。 + monkeypatch.setattr(routers.kimi_oauth, "_default_http_client", lambda: http) + # `run_job` 默认 repo_factory 构 `SqlJobRepo(session)`——FakeSession 无 .execute, + # 故 monkeypatch 模块级 `job_runner.SqlJobRepo` 指到共享 fake(见 memory/gotchas)。 + job_repo = FakeJobRepo() + monkeypatch.setattr(job_runner, "SqlJobRepo", lambda _session: job_repo) + + app = create_app() + app.dependency_overrides[get_credential_store] = lambda: store + app.dependency_overrides[get_job_repo] = lambda: job_repo + app.dependency_overrides[get_session] = lambda: FakeSession() + app.dependency_overrides[get_session_factory] = lambda: FakeSessionFactory() + app.dependency_overrides[routers.kimi_oauth._default_http_client] = lambda: http + return TestClient(app) + + +def _device_auth_response() -> _FakeResponse: + return _FakeResponse( + 200, + { + "device_code": "dev-1", + "user_code": "WXYZ-9999", + "verification_uri": "https://kimi.com/device", + "verification_uri_complete": "https://kimi.com/device?code=WXYZ-9999", + "expires_in": 600, + "interval": 5, + }, + ) + + +def _token_response() -> _FakeResponse: + return _FakeResponse( + 200, {"access_token": "secret-acc", "refresh_token": "secret-ref", "expires_in": 900} + ) + + +def test_start_returns_202_and_polls_to_success(monkeypatch: pytest.MonkeyPatch) -> None: + enc_key = Fernet.generate_key().decode() + store = FakeCredentialStore() + # device auth → poll #1 success(TestClient 同步跑完 background task)。 + http = _ScriptedHttp([_device_auth_response(), _token_response()]) + client = _make_client(store=store, http=http, enc_key=enc_key, monkeypatch=monkeypatch) + + resp = client.post("/settings/providers/kimi-code/oauth/start") + + assert resp.status_code == 202 + body = resp.json() + assert body["user_code"] == "WXYZ-9999" + assert body["verification_uri"] == "https://kimi.com/device" + assert body["interval"] == 5 + assert "job_id" in body + # 响应绝不含 token。 + assert "secret-acc" not in resp.text + assert "secret-ref" not in resp.text + + # 后台轮询成功 → 加密 oauth 凭据落库(共享 store)。 + cred = store.oauth.get((STUB_OWNER_ID, "kimi-code")) + assert cred is not None + token = decrypt_oauth_bundle(cred, key=enc_key) + assert token.access_token == "secret-acc" + assert token.refresh_token == "secret-ref" + + +def test_start_polls_through_authorization_pending(monkeypatch: pytest.MonkeyPatch) -> None: + enc_key = Fernet.generate_key().decode() + store = FakeCredentialStore() + http = _ScriptedHttp( + [ + _device_auth_response(), + _FakeResponse(400, {"error": "authorization_pending"}), + _token_response(), + ] + ) + client = _make_client(store=store, http=http, enc_key=enc_key, monkeypatch=monkeypatch) + + resp = client.post("/settings/providers/kimi-code/oauth/start") + + assert resp.status_code == 202 + # pending 后继续轮询 → 第二次成功落库。 + assert (STUB_OWNER_ID, "kimi-code") in store.oauth + + +def test_disconnect_clears_credential(monkeypatch: pytest.MonkeyPatch) -> None: + enc_key = Fernet.generate_key().decode() + store = FakeCredentialStore() + store.oauth[(STUB_OWNER_ID, "kimi-code")] = b"blob" + http = _ScriptedHttp([]) + client = _make_client(store=store, http=http, enc_key=enc_key, monkeypatch=monkeypatch) + + resp = client.post("/settings/providers/kimi-code/oauth/disconnect") + + assert resp.status_code == 200 + assert resp.json()["disconnected"] is True + assert (STUB_OWNER_ID, "kimi-code") not in store.oauth + + +def test_status_reflects_connection(monkeypatch: pytest.MonkeyPatch) -> None: + from datetime import UTC, datetime, timedelta + + from ww_api.services.kimi_oauth import TokenSet, encrypt_oauth_bundle + + enc_key = Fernet.generate_key().decode() + store = FakeCredentialStore() + expires = datetime.now(UTC) + timedelta(seconds=900) + token = TokenSet(access_token="a", refresh_token="r", expires_at=expires) + store.oauth[(STUB_OWNER_ID, "kimi-code")] = encrypt_oauth_bundle(token, key=enc_key) + http = _ScriptedHttp([]) + client = _make_client(store=store, http=http, enc_key=enc_key, monkeypatch=monkeypatch) + + resp = client.get("/settings/providers/kimi-code/oauth/status") + + assert resp.status_code == 200 + body = resp.json() + assert body["connected"] is True + assert body["expires_at"] is not None + # 状态绝不回 token 本体。 + assert "access_token" not in body + assert "refresh_token" not in body + + +def test_status_not_connected_when_no_credential(monkeypatch: pytest.MonkeyPatch) -> None: + enc_key = Fernet.generate_key().decode() + store = FakeCredentialStore() + http = _ScriptedHttp([]) + client = _make_client(store=store, http=http, enc_key=enc_key, monkeypatch=monkeypatch) + + resp = client.get("/settings/providers/kimi-code/oauth/status") + + assert resp.status_code == 200 + assert resp.json()["connected"] is False diff --git a/apps/api/tests/test_kimi_oauth_service.py b/apps/api/tests/test_kimi_oauth_service.py new file mode 100644 index 0000000..32b8a58 --- /dev/null +++ b/apps/api/tests/test_kimi_oauth_service.py @@ -0,0 +1,184 @@ +"""K1.3 单测:Kimi Code OAuth device-flow 服务(fake httpx,绝不联网)。 + +覆盖:start_device_authorization、poll_token(成功/pending/slow_down/失败)、refresh、 +encrypt/decrypt round-trip、needs_refresh 启发式。 +""" + +from __future__ import annotations + +from datetime import UTC, datetime, timedelta +from typing import Any + +import pytest +from cryptography.fernet import Fernet +from ww_api.services.kimi_oauth import ( + DEVICE_AUTHORIZATION_URL, + TOKEN_URL, + AuthorizationPending, + SlowDown, + TokenSet, + decrypt_oauth_bundle, + encrypt_oauth_bundle, + needs_refresh, + poll_token, + refresh, + start_device_authorization, +) +from ww_shared import AppError, ErrorCode + + +class _FakeResponse: + def __init__(self, status_code: int, body: dict[str, Any]) -> None: + self.status_code = status_code + self._body = body + + def json(self) -> Any: + return self._body + + +class _FakeHttp: + """记录每次 post 的 url + data,按 url 返回预设响应。""" + + def __init__(self, responses: list[_FakeResponse]) -> None: + self._responses = responses + self.calls: list[tuple[str, dict[str, str]]] = [] + + async def post(self, url: str, *, data: dict[str, str]) -> _FakeResponse: + self.calls.append((url, data)) + return self._responses[len(self.calls) - 1] + + +@pytest.mark.asyncio +async def test_start_device_authorization_parses_response_and_sends_scope() -> None: + http = _FakeHttp( + [ + _FakeResponse( + 200, + { + "device_code": "dev-123", + "user_code": "ABCD-1234", + "verification_uri": "https://kimi.com/device", + "verification_uri_complete": "https://kimi.com/device?code=ABCD-1234", + "expires_in": 600, + "interval": 5, + }, + ) + ] + ) + + device = await start_device_authorization(http) + + assert device.device_code == "dev-123" + assert device.user_code == "ABCD-1234" + assert device.interval == 5 + assert device.expires_in == 600 + # 请求打到 device authorization 端点,且**带 scope=kimi-code**(coding entitlement)。 + url, data = http.calls[0] + assert url == DEVICE_AUTHORIZATION_URL + assert "client_id" in data + assert data["scope"] == "kimi-code" + + +@pytest.mark.asyncio +async def test_poll_token_success_returns_token_set() -> None: + http = _FakeHttp( + [ + _FakeResponse( + 200, + { + "access_token": "acc-xyz", + "refresh_token": "ref-xyz", + "expires_in": 900, + }, + ) + ] + ) + + token = await poll_token(http, "dev-123") + + assert token.access_token == "acc-xyz" + assert token.refresh_token == "ref-xyz" + assert token.expires_at > datetime.now(UTC) + # 打到 token 端点 + device_code grant。 + url, data = http.calls[0] + assert url == TOKEN_URL + assert data["device_code"] == "dev-123" + assert data["grant_type"].endswith("device_code") + + +@pytest.mark.asyncio +async def test_poll_token_authorization_pending_raises_pending() -> None: + http = _FakeHttp([_FakeResponse(400, {"error": "authorization_pending"})]) + with pytest.raises(AuthorizationPending): + await poll_token(http, "dev-123") + + +@pytest.mark.asyncio +async def test_poll_token_slow_down_raises_slow_down() -> None: + http = _FakeHttp([_FakeResponse(400, {"error": "slow_down"})]) + with pytest.raises(SlowDown): + await poll_token(http, "dev-123") + + +@pytest.mark.asyncio +async def test_poll_token_expired_raises_app_error() -> None: + http = _FakeHttp([_FakeResponse(400, {"error": "expired_token"})]) + with pytest.raises(AppError) as exc: + await poll_token(http, "dev-123") + assert exc.value.code == ErrorCode.LLM_UNAVAILABLE + + +@pytest.mark.asyncio +async def test_refresh_returns_new_token_set() -> None: + http = _FakeHttp( + [ + _FakeResponse( + 200, {"access_token": "new-acc", "refresh_token": "new-ref", "expires_in": 900} + ) + ] + ) + + token = await refresh(http, "ref-old") + + assert token.access_token == "new-acc" + assert token.refresh_token == "new-ref" + url, data = http.calls[0] + assert url == TOKEN_URL + assert data["grant_type"] == "refresh_token" + assert data["refresh_token"] == "ref-old" + + +@pytest.mark.asyncio +async def test_refresh_failure_raises_app_error() -> None: + http = _FakeHttp([_FakeResponse(400, {"error": "invalid_grant"})]) + with pytest.raises(AppError) as exc: + await refresh(http, "ref-bad") + assert exc.value.code == ErrorCode.LLM_UNAVAILABLE + + +def test_encrypt_decrypt_oauth_bundle_round_trip() -> None: + key = Fernet.generate_key().decode() + expires = datetime.now(UTC) + timedelta(seconds=900) + token = TokenSet(access_token="acc", refresh_token="ref", expires_at=expires) + + blob = encrypt_oauth_bundle(token, key=key) + restored = decrypt_oauth_bundle(blob, key=key) + + assert restored.access_token == "acc" + assert restored.refresh_token == "ref" + assert restored.expires_at == expires + # 密文不含明文 token(加密真生效)。 + assert b"acc" not in blob + assert b"ref" not in blob + + +def test_needs_refresh_true_when_near_expiry() -> None: + now = datetime.now(UTC) + near = TokenSet(access_token="a", refresh_token="r", expires_at=now + timedelta(seconds=60)) + assert needs_refresh(near, now=now) is True + + +def test_needs_refresh_false_when_fresh() -> None: + now = datetime.now(UTC) + fresh = TokenSet(access_token="a", refresh_token="r", expires_at=now + timedelta(seconds=900)) + assert needs_refresh(fresh, now=now) is False diff --git a/apps/api/tests/test_outline.py b/apps/api/tests/test_outline.py index ba23ad2..1adccb3 100644 --- a/apps/api/tests/test_outline.py +++ b/apps/api/tests/test_outline.py @@ -16,6 +16,7 @@ import httpx import pytest from fakes_projects import ( FakeForeshadowRepo, + FakeOutlineReadRepo, FakeOutlineWriteRepo, FakeProjectRepo, FakeReviewGateway, @@ -186,6 +187,84 @@ async def test_generate_outline_upper_error_propagates() -> None: assert session.commits == 0 +# ---- 大纲读取(GET /outline)---- + + +def _make_read_client( + *, + project_repo: FakeProjectRepo, + outline_read_repo: FakeOutlineReadRepo, +) -> httpx.AsyncClient: + """装配 GET /outline 测试客户端(注入 fake 项目 repo + 读侧 outline repo)。""" + os.environ.setdefault("CREDENTIAL_ENC_KEY", "x" * 44) + from ww_api.main import create_app + from ww_api.services.project_deps import get_outline_read_repo, get_project_repo + + app = create_app() + app.dependency_overrides[get_project_repo] = lambda: project_repo + app.dependency_overrides[get_outline_read_repo] = lambda: outline_read_repo + + transport = httpx.ASGITransport(app=app, raise_app_exceptions=False) + return httpx.AsyncClient(transport=transport, base_url="http://test") + + +@pytest.mark.asyncio +async def test_get_outline_returns_persisted_chapters_in_order_with_unpacked_beats() -> None: + project_repo = FakeProjectRepo() + pid = await _seed_project(project_repo) + read_repo = FakeOutlineReadRepo() + # 乱序入种,断言端点按 chapter_no 升序返回。 + read_repo.add_chapter(pid, volume=1, chapter_no=2, beats=["冲突升级"]) + read_repo.add_chapter( + pid, + volume=1, + chapter_no=1, + beats=["开篇引入主角", "埋下信物伏笔"], + foreshadow_windows=[{"code": "F1", "plant_chapter": 1, "expected_close_to": 10}], + ) + client = _make_read_client(project_repo=project_repo, outline_read_repo=read_repo) + + async with client: + resp = await client.get(f"/projects/{pid}/outline") + + assert resp.status_code == 200 + body = resp.json() + assert [c["no"] for c in body["chapters"]] == [1, 2] + ch1 = body["chapters"][0] + # beats 由 DB dict `{"beats": [...]}` 解包成裸 list(与 POST 响应同形)。 + assert ch1["beats"] == ["开篇引入主角", "埋下信物伏笔"] + assert ch1["foreshadow_windows"][0]["code"] == "F1" + assert ch1["foreshadow_windows"][0]["expected_close_to"] == 10 + assert body["chapters"][1]["beats"] == ["冲突升级"] + + +@pytest.mark.asyncio +async def test_get_outline_returns_empty_list_when_no_outline() -> None: + project_repo = FakeProjectRepo() + pid = await _seed_project(project_repo) + client = _make_read_client(project_repo=project_repo, outline_read_repo=FakeOutlineReadRepo()) + + async with client: + resp = await client.get(f"/projects/{pid}/outline") + + # 项目存在但无大纲 → 200 空列表(非 404)。 + assert resp.status_code == 200 + assert resp.json()["chapters"] == [] + + +@pytest.mark.asyncio +async def test_get_outline_unknown_project_404() -> None: + client = _make_read_client( + project_repo=FakeProjectRepo(), outline_read_repo=FakeOutlineReadRepo() + ) + + async with client: + resp = await client.get(f"/projects/{uuid.uuid4()}/outline") + + assert resp.status_code == 404 + assert resp.json()["error"]["code"] == ErrorCode.NOT_FOUND + + # ---- 伏笔看板 ---- diff --git a/apps/api/tests/test_projects.py b/apps/api/tests/test_projects.py index 089be46..836459f 100644 --- a/apps/api/tests/test_projects.py +++ b/apps/api/tests/test_projects.py @@ -16,6 +16,7 @@ from ww_core.domain.repositories import ( ForeshadowView, MemoryRepos, OutlineView, + ProjectSpecView, RuleView, StyleView, WorldEntityView, @@ -27,6 +28,9 @@ class _EmptyOutlineRepo: async def get(self, project_id: uuid.UUID, chapter_no: int) -> OutlineView | None: return None + async def list_for_project(self, project_id: uuid.UUID) -> list[OutlineView]: + return [] + class _EmptyCharacterRepo: async def list_for_project(self, project_id: uuid.UUID) -> list[CharacterView]: @@ -58,6 +62,11 @@ class _EmptyRulesRepo: return [] +class _StubProjectSpecRepo: + async def spec(self, project_id: uuid.UUID) -> ProjectSpecView | None: + return ProjectSpecView(title="测试作品", premise="测试前提") + + def _empty_memory_repos() -> MemoryRepos: return MemoryRepos( outline=_EmptyOutlineRepo(), @@ -67,6 +76,7 @@ def _empty_memory_repos() -> MemoryRepos: foreshadow=_EmptyForeshadowRepo(), style=_EmptyStyleRepo(), rules=_EmptyRulesRepo(), + project=_StubProjectSpecRepo(), ) @@ -208,3 +218,30 @@ async def test_put_draft_is_idempotent() -> None: assert saved.content == "改稿更长" assert saved.version == 1 assert r2.json()["length"] == len("改稿更长") + + +@pytest.mark.asyncio +async def test_get_draft_returns_saved_content() -> None: + chapter_repo = FakeChapterRepo() + client, _, _, _ = _make_client(chapter_repo=chapter_repo) + pid = uuid.uuid4() + async with client: + await client.put(f"/projects/{pid}/chapters/5/draft", json={"text": "阿福重访工作台"}) + resp = await client.get(f"/projects/{pid}/chapters/5/draft") + assert resp.status_code == 200 + body = resp.json() + assert body["content"] == "阿福重访工作台" + assert body["status"] == "draft" + assert body["version"] == 1 + assert body["chapter_no"] == 5 + assert body["length"] == len("阿福重访工作台") + + +@pytest.mark.asyncio +async def test_get_draft_404_when_no_draft() -> None: + client, _, _, _ = _make_client() + pid = uuid.uuid4() + async with client: + resp = await client.get(f"/projects/{pid}/chapters/9/draft") + assert resp.status_code == 404 + assert resp.json()["error"]["code"] == ErrorCode.NOT_FOUND diff --git a/apps/api/tests/test_rules.py b/apps/api/tests/test_rules.py new file mode 100644 index 0000000..2150473 --- /dev/null +++ b/apps/api/tests/test_rules.py @@ -0,0 +1,87 @@ +"""T5.5 POST /projects/:id/rules 端点(内存替身,无 DB/无网络)。 + +覆盖: +- 201 + 回显 level/content + 端点 commit; +- 非法 level → 422(Pydantic Literal 校验,FastAPI 422); +- 空 content → 422。 +""" + +from __future__ import annotations + +import uuid + +import httpx +import pytest +from fakes_projects import FakeSession +from ww_core.domain.rule_repo import RuleWriteView + + +class _FakeRuleWriteRepo: + def __init__(self) -> None: + self.rows: list[RuleWriteView] = [] + + async def create(self, project_id: uuid.UUID, *, level: str, content: str) -> RuleWriteView: + view = RuleWriteView(project_id=project_id, level=level, content=content) + self.rows.append(view) + return view + + +def _make_client() -> tuple[httpx.AsyncClient, _FakeRuleWriteRepo, FakeSession]: + import os + + os.environ.setdefault("CREDENTIAL_ENC_KEY", "x" * 44) + from ww_api.main import create_app + from ww_api.services.project_deps import get_rule_write_repo + from ww_db import get_session + + repo = _FakeRuleWriteRepo() + session = FakeSession() + + app = create_app() + app.dependency_overrides[get_rule_write_repo] = lambda: repo + app.dependency_overrides[get_session] = lambda: session + transport = httpx.ASGITransport(app=app) + client = httpx.AsyncClient(transport=transport, base_url="http://test") + return client, repo, session + + +@pytest.mark.asyncio +async def test_create_rule_returns_201_and_commits() -> None: + client, repo, session = _make_client() + pid = uuid.uuid4() + async with client: + resp = await client.post( + f"/projects/{pid}/rules", + json={"level": "project", "content": "主角不许中途复活"}, + ) + assert resp.status_code == 201 + body = resp.json() + assert body["level"] == "project" + assert body["content"] == "主角不许中途复活" + assert session.commits == 1 + assert len(repo.rows) == 1 + assert repo.rows[0].project_id == pid + + +@pytest.mark.asyncio +async def test_create_rule_invalid_level_returns_422() -> None: + client, _repo, _session = _make_client() + pid = uuid.uuid4() + async with client: + resp = await client.post( + f"/projects/{pid}/rules", + json={"level": "nonsense", "content": "x"}, + ) + assert resp.status_code == 422 + + +@pytest.mark.asyncio +async def test_create_rule_empty_content_returns_422() -> None: + client, _repo, _session = _make_client() + pid = uuid.uuid4() + async with client: + resp = await client.post( + f"/projects/{pid}/rules", + json={"level": "global", "content": ""}, + ) + assert resp.status_code == 422 diff --git a/apps/api/tests/test_style.py b/apps/api/tests/test_style.py new file mode 100644 index 0000000..904efad --- /dev/null +++ b/apps/api/tests/test_style.py @@ -0,0 +1,394 @@ +"""T4.3 端点测试:学文风(jobs 异步)+ 回炉 + 最新指纹(内存替身,无 DB/无网络)。 + +覆盖: +- POST /style:写一行 job 返 202 `{job_id}`;BackgroundTask 跑完写 style_fingerprint + + 置 job done(ASGITransport 下 background task 在请求返回前跑完,见 gotcha); +- mode="update" → 第二次 append version+1; +- 项目不存在 → 404;无凭据 → 503(dep 解析阶段拦下,未写 job); +- GET /style:返最新指纹(dimensions/evidence/version);无指纹 → 404; +- POST /refine:返 {original, refined}(refiner 纯文本)+ 末尾 commit(记账落库);无凭据 → 503。 + +学文风 work 在 `run_job` 自建独立 session 上自造网关 + repo(请求 session 已关闭)—— +测试 monkeypatch `build_gateway_for_tier`(产 StyleFingerprintResult)+ 写侧 repo +(指到共享 fake repo),并 override `get_session_factory` 为 FakeSessionFactory,断言后台落库。 +""" + +from __future__ import annotations + +import os +import uuid + +import httpx +import pytest +from fakes_projects import ( + FakeJobRepo, + FakeProjectRepo, + FakeReviewGateway, + FakeSession, + FakeSessionFactory, + FakeStyleWriteRepo, +) +from fastapi import FastAPI +from ww_agents import StyleDimension, StyleFingerprintResult +from ww_core.domain.job_repo import STATUS_DONE +from ww_core.domain.project_repo import ProjectCreate +from ww_shared import AppError, ErrorCode + + +def _fingerprint() -> StyleFingerprintResult: + return StyleFingerprintResult( + dimensions=[ + StyleDimension(name="句长节奏", value="短句为主", evidence=["他来了。", "她走了。"]), + StyleDimension(name="叙事人称", value="第三人称", evidence=["他看着远方。"]), + ] + ) + + +async def _seed_project(repo: FakeProjectRepo) -> uuid.UUID: + view = await repo.create(uuid.UUID(int=1), ProjectCreate(title="测试作品", genre="玄幻")) + return uuid.UUID(str(view.id)) + + +def _app_with_overrides( + *, + project_repo: FakeProjectRepo, + job_repo: FakeJobRepo | None = None, + style_repo: FakeStyleWriteRepo | None = None, + session: FakeSession | None = None, + session_factory: FakeSessionFactory | None = None, + extract_gateway: object | None = None, + refine_gateway: object | None = None, +) -> FastAPI: + os.environ.setdefault("CREDENTIAL_ENC_KEY", "x" * 44) + from ww_api.main import create_app + from ww_api.services.project_deps import ( + get_job_repo, + get_project_repo, + get_refine_gateway, + get_session_factory, + get_style_extract_gateway, + get_style_write_repo, + ) + from ww_db import get_session + + app = create_app() + app.dependency_overrides[get_project_repo] = lambda: project_repo + app.dependency_overrides[get_session] = lambda: session or FakeSession() + if job_repo is not None: + app.dependency_overrides[get_job_repo] = lambda: job_repo + if style_repo is not None: + app.dependency_overrides[get_style_write_repo] = lambda: style_repo + if session_factory is not None: + app.dependency_overrides[get_session_factory] = lambda: session_factory + if extract_gateway is not None: + app.dependency_overrides[get_style_extract_gateway] = lambda: extract_gateway + if refine_gateway is not None: + app.dependency_overrides[get_refine_gateway] = lambda: refine_gateway + return app + + +def _client(app: FastAPI) -> httpx.AsyncClient: + transport = httpx.ASGITransport(app=app, raise_app_exceptions=False) + return httpx.AsyncClient(transport=transport, base_url="http://test") + + +# ---- POST /style(学文风 jobs 异步)---- + + +@pytest.mark.asyncio +async def test_learn_style_returns_202_and_writes_job_row( + monkeypatch: pytest.MonkeyPatch, +) -> None: + project_repo = FakeProjectRepo() + pid = await _seed_project(project_repo) + job_repo = FakeJobRepo() + session = FakeSession() + app = _app_with_overrides( + project_repo=project_repo, + job_repo=job_repo, + session=session, + session_factory=FakeSessionFactory(), + extract_gateway=FakeReviewGateway(parsed=_fingerprint()), + ) + + # background task work 触网关/repo → monkeypatch 成 no-op fake(本用例不验落库); + # run_job 默认 repo_factory 用 SqlJobRepo(FakeSession) 会触 .execute → 改用 fake job repo。 + import ww_api.routers.style as style_mod + import ww_api.services.job_runner as runner_mod + + async def _noop_work(_session: object) -> dict[str, int]: + return {"version": 1, "dims_count": 2} + + monkeypatch.setattr(style_mod, "_make_style_learn_work", lambda *_a, **_k: _noop_work) + monkeypatch.setattr(runner_mod, "SqlJobRepo", lambda _s: job_repo) + + async with _client(app) as client: + resp = await client.post(f"/projects/{pid}/style", json={"samples": ["样本正文一"]}) + + assert resp.status_code == 202 + body = resp.json() + job_id = uuid.UUID(body["job_id"]) + # job 行已创建 + 请求阶段 commit 一次(供前端立即轮询)。 + assert job_id in job_repo.rows + assert job_repo.rows[job_id].kind == "style_learn" + assert session.commits == 1 + + +@pytest.mark.asyncio +async def test_learn_style_background_task_writes_fingerprint_and_completes_job( + monkeypatch: pytest.MonkeyPatch, +) -> None: + project_repo = FakeProjectRepo() + pid = await _seed_project(project_repo) + job_repo = FakeJobRepo() + style_repo = FakeStyleWriteRepo() + session_factory = FakeSessionFactory() + + import ww_api.routers.style as style_mod + + # work 内 build_gateway_for_tier → fake 网关产指纹;写侧 repo → 共享 fake repo。 + async def _fake_build_gateway(_session, _store, _tier): # type: ignore[no-untyped-def] + return FakeReviewGateway(parsed=_fingerprint()) + + monkeypatch.setattr(style_mod, "build_gateway_for_tier", _fake_build_gateway) + monkeypatch.setattr(style_mod, "SqlStyleFingerprintWriteRepo", lambda _s: style_repo) + # `run_job` 用 SqlJobRepo(默认 repo_factory)会触 FakeSession.execute → 改用 fake job repo。 + import ww_api.services.job_runner as runner_mod + + monkeypatch.setattr(runner_mod, "SqlJobRepo", lambda _s: job_repo) + + app = _app_with_overrides( + project_repo=project_repo, + job_repo=job_repo, + session=FakeSession(), + session_factory=session_factory, + extract_gateway=FakeReviewGateway(parsed=_fingerprint()), + ) + + async with _client(app) as client: + resp = await client.post( + f"/projects/{pid}/style", json={"samples": ["样本一", "样本二"], "mode": "create"} + ) + + assert resp.status_code == 202 + job_id = uuid.UUID(resp.json()["job_id"]) + # BackgroundTask 已跑完(ASGITransport 等其完成):style_fingerprint 落库 + job done。 + assert style_repo.append_calls == 1 + assert style_repo.rows[1][0] == {"句长节奏": "短句为主", "叙事人称": "第三人称"} + assert style_repo.rows[1][1]["句长节奏"] == ["他来了。", "她走了。"] + assert job_repo.rows[job_id].status == STATUS_DONE + assert job_repo.rows[job_id].result == {"version": 1, "dims_count": 2} + + +@pytest.mark.asyncio +async def test_learn_style_update_mode_bumps_version(monkeypatch: pytest.MonkeyPatch) -> None: + project_repo = FakeProjectRepo() + pid = await _seed_project(project_repo) + job_repo = FakeJobRepo() + style_repo = FakeStyleWriteRepo() + # 预置 version 1(首学已存在)。 + await style_repo.append(pid, dimensions_json={"x": "y"}, evidence_json={"x": []}) + + import ww_api.routers.style as style_mod + import ww_api.services.job_runner as runner_mod + + async def _fake_build_gateway(_session, _store, _tier): # type: ignore[no-untyped-def] + return FakeReviewGateway(parsed=_fingerprint()) + + monkeypatch.setattr(style_mod, "build_gateway_for_tier", _fake_build_gateway) + monkeypatch.setattr(style_mod, "SqlStyleFingerprintWriteRepo", lambda _s: style_repo) + monkeypatch.setattr(runner_mod, "SqlJobRepo", lambda _s: job_repo) + + app = _app_with_overrides( + project_repo=project_repo, + job_repo=job_repo, + session=FakeSession(), + session_factory=FakeSessionFactory(), + extract_gateway=FakeReviewGateway(parsed=_fingerprint()), + ) + + async with _client(app) as client: + resp = await client.post( + f"/projects/{pid}/style", json={"samples": ["新样本"], "mode": "update"} + ) + + assert resp.status_code == 202 + job_id = uuid.UUID(resp.json()["job_id"]) + # 第二次 append → version 2(version+1)。 + assert max(style_repo.rows) == 2 + assert job_repo.rows[job_id].result == {"version": 2, "dims_count": 2} + + +@pytest.mark.asyncio +async def test_learn_style_unknown_project_404() -> None: + project_repo = FakeProjectRepo() + app = _app_with_overrides( + project_repo=project_repo, + job_repo=FakeJobRepo(), + session_factory=FakeSessionFactory(), + extract_gateway=FakeReviewGateway(parsed=_fingerprint()), + ) + async with _client(app) as client: + resp = await client.post(f"/projects/{uuid.uuid4()}/style", json={"samples": ["x"]}) + assert resp.status_code == 404 + assert resp.json()["error"]["code"] == ErrorCode.NOT_FOUND + + +@pytest.mark.asyncio +async def test_learn_style_without_credentials_503_and_no_job() -> None: + project_repo = FakeProjectRepo() + pid = await _seed_project(project_repo) + job_repo = FakeJobRepo() + session = FakeSession() + + async def _no_creds() -> object: + raise AppError(ErrorCode.LLM_UNAVAILABLE, "未配置凭据", {"provider": "deepseek"}) + + app = _app_with_overrides( + project_repo=project_repo, + job_repo=job_repo, + session=session, + session_factory=FakeSessionFactory(), + ) + from ww_api.services.project_deps import get_style_extract_gateway + + app.dependency_overrides[get_style_extract_gateway] = _no_creds + + async with _client(app) as client: + resp = await client.post(f"/projects/{pid}/style", json={"samples": ["x"]}) + + assert resp.status_code == 503 + assert resp.json()["error"]["code"] == ErrorCode.LLM_UNAVAILABLE + # 凭据探测在调度 job 之前 → 未写 job、未 commit。 + assert job_repo.rows == {} + assert session.commits == 0 + + +@pytest.mark.asyncio +async def test_learn_style_empty_samples_422() -> None: + project_repo = FakeProjectRepo() + pid = await _seed_project(project_repo) + app = _app_with_overrides( + project_repo=project_repo, + job_repo=FakeJobRepo(), + session_factory=FakeSessionFactory(), + extract_gateway=FakeReviewGateway(parsed=_fingerprint()), + ) + async with _client(app) as client: + resp = await client.post(f"/projects/{pid}/style", json={"samples": []}) + assert resp.status_code == 422 + + +# ---- GET /style(最新指纹)---- + + +@pytest.mark.asyncio +async def test_get_style_returns_latest_fingerprint() -> None: + project_repo = FakeProjectRepo() + pid = await _seed_project(project_repo) + style_repo = FakeStyleWriteRepo() + await style_repo.append( + pid, dimensions_json={"句长": "短"}, evidence_json={"句长": ["他来了。"]} + ) + app = _app_with_overrides(project_repo=project_repo, style_repo=style_repo) + + async with _client(app) as client: + resp = await client.get(f"/projects/{pid}/style") + + assert resp.status_code == 200 + body = resp.json() + assert body["version"] == 1 + assert body["dimensions"] == {"句长": "短"} + assert body["evidence"] == {"句长": ["他来了。"]} + + +@pytest.mark.asyncio +async def test_get_style_no_fingerprint_404() -> None: + project_repo = FakeProjectRepo() + pid = await _seed_project(project_repo) + app = _app_with_overrides(project_repo=project_repo, style_repo=FakeStyleWriteRepo()) + async with _client(app) as client: + resp = await client.get(f"/projects/{pid}/style") + assert resp.status_code == 404 + assert resp.json()["error"]["code"] == ErrorCode.NOT_FOUND + + +@pytest.mark.asyncio +async def test_get_style_unknown_project_404() -> None: + app = _app_with_overrides(project_repo=FakeProjectRepo(), style_repo=FakeStyleWriteRepo()) + async with _client(app) as client: + resp = await client.get(f"/projects/{uuid.uuid4()}/style") + assert resp.status_code == 404 + + +# ---- POST /refine(同步回炉)---- + + +@pytest.mark.asyncio +async def test_refine_returns_original_and_refined_and_commits() -> None: + project_repo = FakeProjectRepo() + pid = await _seed_project(project_repo) + session = FakeSession() + gateway = FakeReviewGateway(text="重写后的段落。") + app = _app_with_overrides(project_repo=project_repo, session=session, refine_gateway=gateway) + + async with _client(app) as client: + resp = await client.post( + f"/projects/{pid}/chapters/1/refine", + json={"segment": "原始段落。", "instruction": "去掉机翻腔"}, + ) + + assert resp.status_code == 200 + body = resp.json() + assert body["original"] == "原始段落。" + assert body["refined"] == "重写后的段落。" + # writer 档 + 末尾 commit(记账落库)。 + assert gateway.requests[0].tier == "writer" + assert session.commits == 1 + # 指令进入了输入文本。 + assert "去掉机翻腔" in str(gateway.requests[0].input) + + +@pytest.mark.asyncio +async def test_refine_unknown_project_404() -> None: + app = _app_with_overrides( + project_repo=FakeProjectRepo(), refine_gateway=FakeReviewGateway(text="x") + ) + async with _client(app) as client: + resp = await client.post( + f"/projects/{uuid.uuid4()}/chapters/1/refine", json={"segment": "原段"} + ) + assert resp.status_code == 404 + assert resp.json()["error"]["code"] == ErrorCode.NOT_FOUND + + +@pytest.mark.asyncio +async def test_refine_empty_segment_422() -> None: + project_repo = FakeProjectRepo() + pid = await _seed_project(project_repo) + app = _app_with_overrides(project_repo=project_repo, refine_gateway=FakeReviewGateway(text="x")) + async with _client(app) as client: + resp = await client.post(f"/projects/{pid}/chapters/1/refine", json={"segment": ""}) + assert resp.status_code == 422 + + +@pytest.mark.asyncio +async def test_refine_without_credentials_503() -> None: + project_repo = FakeProjectRepo() + pid = await _seed_project(project_repo) + session = FakeSession() + + async def _no_creds() -> object: + raise AppError(ErrorCode.LLM_UNAVAILABLE, "未配置凭据", {"provider": "deepseek"}) + + app = _app_with_overrides(project_repo=project_repo, session=session) + from ww_api.services.project_deps import get_refine_gateway + + app.dependency_overrides[get_refine_gateway] = _no_creds + + async with _client(app) as client: + resp = await client.post(f"/projects/{pid}/chapters/1/refine", json={"segment": "原段"}) + + assert resp.status_code == 503 + assert resp.json()["error"]["code"] == ErrorCode.LLM_UNAVAILABLE + assert session.commits == 0 diff --git a/apps/api/ww_api/main.py b/apps/api/ww_api/main.py index deeb41b..2df20a2 100644 --- a/apps/api/ww_api/main.py +++ b/apps/api/ww_api/main.py @@ -6,7 +6,10 @@ from collections.abc import AsyncIterator from contextlib import asynccontextmanager from fastapi import FastAPI, Request +from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import JSONResponse +from ww_config import get_settings +from ww_core.domain.job_repo import SqlJobRepo from ww_db import get_sessionmaker from ww_shared import AppError, ErrorBody, ErrorEnvelope @@ -14,11 +17,15 @@ from ww_api.logging_config import configure_logging, get_logger from ww_api.middleware import request_id_middleware from ww_api.routers import ( foreshadow, + generation, health, jobs, + kimi_oauth, outline, projects, + rules, settings_providers, + style, ) from ww_api.services.project_deps import seed_stub_user @@ -31,11 +38,28 @@ async def _lifespan(app: FastAPI) -> AsyncIterator[None]: # 幂等 seed 单用户 stub——所有 owner_id FK 依赖它(见 memory/gotchas)。 async with get_sessionmaker()() as session: await seed_stub_user(session) + # zombie reaper(M4-d / §7.4 缓解):进程重启会丢未跑完的 BackgroundTask,把残留 + # status=running 的 job 标 failed(让用户看到失败可重试,而非进度条永转)。自 commit、幂等。 + async with get_sessionmaker()() as session: + reaped = await SqlJobRepo(session).reap_zombies() + if reaped: + log.info("job_zombies_reaped", count=reaped) yield def create_app() -> FastAPI: app = FastAPI(title="网文创作工作流 API", version="0.0.0", lifespan=_lifespan) + # CORS:前端(Next.js)与 API 分端口/跨源,浏览器端 `api.POST/PUT` 需 CORS 放行 + # (RSC 服务端取数同源、无需 CORS,故此前同进程测试从未暴露此缺口)。原型单用户、 + # 本地开发:放行 localhost:3000;可经 env `CORS_ORIGINS`(逗号分隔)覆盖。 + settings = get_settings() + app.add_middleware( + CORSMiddleware, + allow_origins=settings.cors_origins, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], + ) app.middleware("http")(request_id_middleware) @app.exception_handler(AppError) @@ -57,7 +81,12 @@ def create_app() -> FastAPI: app.include_router(projects.router) app.include_router(foreshadow.router) app.include_router(outline.router) + app.include_router(rules.router) + app.include_router(style.router) + app.include_router(generation.router) + app.include_router(generation.skills_router) app.include_router(settings_providers.router) + app.include_router(kimi_oauth.router) return app diff --git a/apps/api/ww_api/routers/generation.py b/apps/api/ww_api/routers/generation.py new file mode 100644 index 0000000..4353770 --- /dev/null +++ b/apps/api/ww_api/routers/generation.py @@ -0,0 +1,427 @@ +"""生成/入库端点(C3 扩 / ARCH §6.5 角色生成器 / §5.4 / §7.2;不变量 #1/#3/#9)。 + +三类端点 + 两个读端点(M5-d 生成走即时返回:预览 → 作者确认 → 入库,非 jobs): +- `POST /projects/:id/world/generate`:worldbuilder(writer 网关)→ **预览**世界观实体(不入库)。 +- `POST /projects/:id/characters/generate`:character-gen(writer 网关,群像防雷同)→ 预览角色卡。 +- `POST /projects/:id/characters`:作者确认的角色卡入库——**入库前 gate**: + 1. `precheck_generated_cards`(continuity 预检,analyst 网关)比对生成卡 vs 真相源; + 有冲突且未 `acknowledge_conflicts` → **409 CONFLICT_UNRESOLVED**(不静默入库,仿 accept gate, + 守不变量 #3);作者确认后放行。 + 2. `partition_writes`:按 character-gen 声明的 `writes` 白名单过滤(越权表丢弃 + 审计),守 §5.6。 + 3. 写 `characters` 行(schema list/str → DB JSONB dict 形变,见 character_repo)。 +- `GET /projects/:id/rules`:规则列表(规则页,T5.6)。 +- `GET /skills`:技能库列表(技能库 UI,T5.6)。 + +提交边界:网关 ledger + 角色写侧均只 flush → 端点末尾一次 `commit()`(生成预览也 commit +ledger,否则 usage 静默丢失,同 draft/review 纪律)。无凭据 → `LLM_UNAVAILABLE`(503)。 +""" + +from __future__ import annotations + +import uuid +from typing import Annotated + +from fastapi import APIRouter, Depends, Request +from sqlalchemy.ext.asyncio import AsyncSession +from ww_agents import ( + CharacterCard, + CharacterRelation, + character_gen_spec, + continuity_spec, + worldbuilder_spec, +) +from ww_core.domain import ( + CharacterWriteRepo, + ProjectRepo, +) +from ww_core.domain.repositories import MemoryRepos, RulesRepo +from ww_core.orchestrator import ( + precheck_generated_cards, + run_character_gen, + run_worldbuilder, +) +from ww_db import get_session +from ww_llm_gateway import Gateway +from ww_shared import AppError, ErrorCode +from ww_skills import SkillRegistry, partition_writes + +from ww_api.logging_config import get_logger +from ww_api.schemas.generation import ( + CharacterCardView, + CharacterGenerateRequest, + CharacterGenPreviewResponse, + CharacterIngestRequest, + CharacterIngestResponse, + CharacterListResponse, + CharacterRelationView, + IngestConflictView, + RuleListResponse, + SkillListResponse, + SkillView, + WorldEntityCardView, + WorldEntityListResponse, + WorldGenerateRequest, + WorldGenPreviewResponse, +) +from ww_api.schemas.rules import RuleView +from ww_api.services.credentials import STUB_OWNER_ID +from ww_api.services.project_deps import ( + get_character_gen_gateway, + get_character_write_repo, + get_memory_repos, + get_precheck_gateway, + get_project_repo, + get_rules_read_repo, + get_skill_registry, + get_worldbuilder_gateway, +) + +log = get_logger("ww.api.generation") + +router = APIRouter(prefix="/projects", tags=["generation"]) +skills_router = APIRouter(prefix="/skills", tags=["skills"]) + +ProjectRepoDep = Annotated[ProjectRepo, Depends(get_project_repo)] +MemoryReposDep = Annotated[MemoryRepos, Depends(get_memory_repos)] +CharacterWriteRepoDep = Annotated[CharacterWriteRepo, Depends(get_character_write_repo)] +RulesReadRepoDep = Annotated[RulesRepo, Depends(get_rules_read_repo)] +SkillRegistryDep = Annotated[SkillRegistry, Depends(get_skill_registry)] +WorldGatewayDep = Annotated[Gateway, Depends(get_worldbuilder_gateway)] +CharacterGatewayDep = Annotated[Gateway, Depends(get_character_gen_gateway)] +PrecheckGatewayDep = Annotated[Gateway, Depends(get_precheck_gateway)] +SessionDep = Annotated[AsyncSession, Depends(get_session)] + + +def _project_context(title: str, genre: str | None, premise: str | None, theme: str | None) -> str: + """确定性序列化作品设定(喂 worldbuilder;无时间戳/UUID)。""" + lines = [f"标题:{title}"] + if genre: + lines.append(f"题材:{genre}") + if premise: + lines.append(f"前提:{premise}") + if theme: + lines.append(f"主题:{theme}") + return "\n".join(lines) + + +def _render_characters_context(cards: list[CharacterCard]) -> str: + """已有角色简表(喂 character-gen 防雷同 + precheck 真相源)。确定性、保序。""" + if not cards: + return "" + return "\n".join(f"- {c.name}({c.role}):{'、'.join(c.traits) or '(未列)'}" for c in cards) + + +async def _existing_characters(memory: MemoryRepos, project_id: uuid.UUID) -> list[CharacterCard]: + """把已有角色读侧视图转成 `CharacterCard`(喂防雷同 / precheck)。 + + DB JSONB dict 列 → schema list/str 反向解包(与写侧形变互逆;缺则空/占位)。 + """ + views = await memory.character.list_for_project(project_id) + cards: list[CharacterCard] = [] + for v in views: + traits = list((v.traits or {}).get("items", [])) if isinstance(v.traits, dict) else [] + tics = ( + list((v.speech_tics or {}).get("items", [])) if isinstance(v.speech_tics, dict) else [] + ) + arc = (v.arc or {}).get("text", "") if isinstance(v.arc, dict) else "" + cards.append( + CharacterCard( + name=v.name, + role=v.role or "", + traits=traits, + backstory=v.backstory or "", + arc=arc or "", + speech_tics=tics, + tags=list(v.tags or []), + relations=[], + ) + ) + return cards + + +async def _world_context(memory: MemoryRepos, project_id: uuid.UUID) -> str: + """已有世界观硬规则简表(喂 character-gen / precheck 真相源)。""" + views = await memory.world_entity.list_for_project(project_id) + lines: list[str] = [] + for v in views: + rules = list((v.rules or {}).get("rules", [])) if isinstance(v.rules, dict) else [] + rule_text = ";".join(rules) if rules else "(无硬规则)" + lines.append(f"- [{v.type}] {v.name}:{rule_text}") + return "\n".join(lines) + + +# ---- 世界观生成(预览,不入库)---- + + +@router.post("/{project_id}/world/generate") +async def generate_world( + project_id: uuid.UUID, + body: WorldGenerateRequest, + request: Request, + project_repo: ProjectRepoDep, + gateway: WorldGatewayDep, + session: SessionDep, +) -> WorldGenPreviewResponse: + """生成世界观实体预览(不持久化;作者确认后另入库)。无凭据 → 503;项目不存在 → 404。""" + request_id = getattr(request.state, "request_id", None) + project = await project_repo.get(STUB_OWNER_ID, project_id) + if project is None: + raise AppError(ErrorCode.NOT_FOUND, f"project not found: {project_id}") + + project_context = _project_context(project.title, project.genre, project.premise, project.theme) + result = await run_worldbuilder( + worldbuilder_spec, + brief=body.brief, + project_context=project_context, + gateway=gateway, + user_id=STUB_OWNER_ID, + project_id=project_id, + ) + # 提交边界:预览不写业务表,但网关 ledger 只 flush → 末尾 commit 落 usage(否则丢失)。 + await session.commit() + log.info( + "world_generate_done", + project_id=str(project_id), + request_id=request_id, + entity_count=len(result.entities), + ) + return WorldGenPreviewResponse( + entities=[ + WorldEntityCardView(type=e.type, name=e.name, rules=list(e.rules)) + for e in result.entities + ] + ) + + +# ---- 角色生成(预览,不入库;群像防雷同)---- + + +@router.post("/{project_id}/characters/generate") +async def generate_characters( + project_id: uuid.UUID, + body: CharacterGenerateRequest, + request: Request, + project_repo: ProjectRepoDep, + memory: MemoryReposDep, + gateway: CharacterGatewayDep, + session: SessionDep, +) -> CharacterGenPreviewResponse: + """生成角色卡预览(不持久化;注入已有角色防雷同)。无凭据 → 503;项目不存在 → 404。""" + request_id = getattr(request.state, "request_id", None) + project = await project_repo.get(STUB_OWNER_ID, project_id) + if project is None: + raise AppError(ErrorCode.NOT_FOUND, f"project not found: {project_id}") + + existing = await _existing_characters(memory, project_id) + world_context = await _world_context(memory, project_id) + result = await run_character_gen( + character_gen_spec, + brief=body.brief, + count=body.count, + role=body.role, + world_context=world_context, + existing_chars=existing, + generated_so_far=[], + gateway=gateway, + user_id=STUB_OWNER_ID, + project_id=project_id, + ) + await session.commit() + log.info( + "characters_generate_done", + project_id=str(project_id), + request_id=request_id, + card_count=len(result.cards), + ) + return CharacterGenPreviewResponse(cards=[_card_to_view(c) for c in result.cards]) + + +# ---- 角色入库(作者确认后;continuity gate + 权限白名单)---- + + +@router.post("/{project_id}/characters", status_code=201) +async def ingest_characters( + project_id: uuid.UUID, + body: CharacterIngestRequest, + request: Request, + project_repo: ProjectRepoDep, + memory: MemoryReposDep, + char_repo: CharacterWriteRepoDep, + gateway: PrecheckGatewayDep, + session: SessionDep, +) -> CharacterIngestResponse: + """入库作者确认的角色卡。有 continuity 冲突且未确认 → 409;越权写表丢弃 + 审计。""" + request_id = getattr(request.state, "request_id", None) + project = await project_repo.get(STUB_OWNER_ID, project_id) + if project is None: + raise AppError(ErrorCode.NOT_FOUND, f"project not found: {project_id}") + + cards = [_view_to_card(v) for v in body.cards] + + # gate 1:入库前 continuity 预检(编排器追加的检查,非 character-gen 互调,守不变量 #1)。 + world_context = await _world_context(memory, project_id) + existing = await _existing_characters(memory, project_id) + conflicts = await precheck_generated_cards( + continuity_spec, + cards=cards, + world_context=world_context, + characters_context=_render_characters_context(existing), + gateway=gateway, + user_id=STUB_OWNER_ID, + project_id=project_id, + ) + if conflicts and not body.acknowledge_conflicts: + # 仿 accept 的冲突 gate:不静默入库,须作者裁决/确认(不变量 #3)。 + await session.commit() # 落 precheck 网关 usage(否则丢失);不写业务表。 + log.info( + "characters_ingest_blocked_by_conflicts", + project_id=str(project_id), + request_id=request_id, + conflict_count=len(conflicts), + ) + raise AppError( + ErrorCode.CONFLICT_UNRESOLVED, + "生成角色卡与现有设定存在 continuity 冲突,请裁决后确认入库", + { + "conflicts": [ + IngestConflictView( + type=c.type, where=c.where, refs=list(c.refs), suggestion=c.suggestion + ).model_dump() + for c in conflicts + ], + "conflict_count": len(conflicts), + }, + ) + + # gate 2:权限白名单——character-gen 只声明 writes=["characters"],越权产出丢弃 + 审计。 + allowed, rejected = partition_writes(character_gen_spec, {"characters": cards}) + if rejected: + log.warning( + "characters_ingest_rejected_over_permission", + project_id=str(project_id), + request_id=request_id, + rejected_tables=rejected, + ) + + created: list[str] = [] + for card in allowed.get("characters", []): + view = await char_repo.create( + project_id, + name=card.name, + role=card.role, + traits=list(card.traits), + backstory=card.backstory, + arc=card.arc, + speech_tics=list(card.speech_tics), + tags=list(card.tags), + relations=[r.model_dump() for r in card.relations], + ) + created.append(view.name) + + # 提交边界:网关 ledger(precheck)+ 角色写侧均只 flush → 端点末尾一次 commit。 + await session.commit() + log.info( + "characters_ingest_done", + project_id=str(project_id), + request_id=request_id, + created_count=len(created), + ) + return CharacterIngestResponse(created=created, rejected_tables=rejected) + + +# ---- 读端点:规则列表 + 技能库(T5.6 前端)---- + + +@router.get("/{project_id}/characters") +async def list_characters( + project_id: uuid.UUID, + memory: MemoryReposDep, +) -> CharacterListResponse: + """已入库角色全量列表(设定库 Codex 真源;复用 C5 读侧 SqlCharacterRepo)。 + + DB JSONB dict 列 → API list/str 反向解包(入库形变的逆向,见 `_existing_characters`)。 + """ + cards = await _existing_characters(memory, project_id) + return CharacterListResponse(characters=[_card_to_view(c) for c in cards]) + + +@router.get("/{project_id}/world_entities") +async def list_world_entities( + project_id: uuid.UUID, + memory: MemoryReposDep, +) -> WorldEntityListResponse: + """已入库世界观实体全量列表(设定库 Codex 真源;复用 C5 读侧 SqlWorldEntityRepo)。 + + DB `rules` JSONB dict `{"rules":[...]}` → 裸 list(worldbuilder 形变的逆向)。 + """ + views = await memory.world_entity.list_for_project(project_id) + entities = [ + WorldEntityCardView( + type=v.type, + name=v.name, + rules=(list((v.rules or {}).get("rules", [])) if isinstance(v.rules, dict) else []), + ) + for v in views + ] + return WorldEntityListResponse(world_entities=entities) + + +@router.get("/{project_id}/rules") +async def list_rules( + project_id: uuid.UUID, + repo: RulesReadRepoDep, +) -> RuleListResponse: + """规则列表(按读侧顺序)。规则页用。""" + rules = await repo.all_for_project(project_id) + return RuleListResponse(rules=[RuleView(level=r.level, content=r.content) for r in rules]) + + +@skills_router.get("") +async def list_skills(registry: SkillRegistryDep) -> SkillListResponse: + """技能库列表(按 name 升序)。技能库 UI 用。""" + skills = [ + SkillView( + name=spec.name, + scope=spec.scope, + tier=spec.tier, + reads=list(spec.reads), + writes=list(spec.writes), + genre=spec.genre, + ) + # registry.names() 已排序;按名取 spec 保证确定性顺序。 + for spec in (registry.get(name) for name in registry.names()) + ] + return SkillListResponse(skills=skills) + + +# ---- schema <-> ww_agents 卡转换 ---- + + +def _card_to_view(card: CharacterCard) -> CharacterCardView: + return CharacterCardView( + name=card.name, + role=card.role, + traits=list(card.traits), + backstory=card.backstory, + arc=card.arc, + speech_tics=list(card.speech_tics), + tags=list(card.tags), + relations=[ + CharacterRelationView(name=r.name, kind=r.kind, note=r.note) for r in card.relations + ], + ) + + +def _view_to_card(view: CharacterCardView) -> CharacterCard: + return CharacterCard( + name=view.name, + role=view.role, + traits=list(view.traits), + backstory=view.backstory, + arc=view.arc, + speech_tics=list(view.speech_tics), + tags=list(view.tags), + relations=[ + CharacterRelation(name=r.name, kind=r.kind, note=r.note) for r in view.relations + ], + ) diff --git a/apps/api/ww_api/routers/kimi_oauth.py b/apps/api/ww_api/routers/kimi_oauth.py new file mode 100644 index 0000000..97352e9 --- /dev/null +++ b/apps/api/ww_api/routers/kimi_oauth.py @@ -0,0 +1,190 @@ +"""Kimi Code OAuth device-flow 端点(C3 扩 K1.3 / ARCH §7.2 / §7.4 jobs)。 + +订阅 plan device-flow 登录:作者在设置页点「连接 Kimi Code」→ 后端发起 device +authorization → 返回 202 `{job_id, user_code, verification_uri, ...}` → 前端展示 user_code ++ 打开授权页 + 轮询 `GET /jobs/{id}`;后端经 BackgroundTask `run_job` **后台轮询** token +端点直到授权成功(加密存 `oauth_enc` + job done)或过期/拒绝(job failed)。 + +三端点(挂 `kimi_oauth.router`,已注册): +- `POST /settings/providers/kimi-code/oauth/start` → 202 `OAuthStartResponse` +- `POST /settings/providers/kimi-code/oauth/disconnect` → 200 `OAuthDisconnectResponse` +- `GET /settings/providers/kimi-code/oauth/status` → 200 `OAuthStatusResponse` + +**token 绝不出边界**:响应/job 结果/日志只含 user_code/connected 等非密信息;access/refresh +token 仅以 Fernet 密文存 `provider_credentials.oauth_enc`。 +""" + +from __future__ import annotations + +import asyncio +from typing import Annotated, Any + +import httpx +from fastapi import APIRouter, BackgroundTasks, Depends, Request, Response +from sqlalchemy.ext.asyncio import AsyncSession +from ww_config import get_settings +from ww_core.domain import JobRepo +from ww_db import get_session +from ww_llm_gateway.adapters.kimi_code import KIMI_CODE_PROVIDER +from ww_shared import AppError, ErrorCode + +from ww_api.logging_config import get_logger +from ww_api.schemas.kimi_oauth import ( + OAuthDisconnectResponse, + OAuthStartResponse, + OAuthStatusResponse, +) +from ww_api.services.credentials import ( + AUTH_TYPE_OAUTH, + STUB_OWNER_ID, + CredentialStore, + SqlCredentialStore, +) +from ww_api.services.foreshadow_scan import SessionFactory +from ww_api.services.job_runner import run_job +from ww_api.services.kimi_oauth import ( + AsyncHttpClient, + AuthorizationPending, + DeviceAuth, + SlowDown, + decrypt_oauth_bundle, + encrypt_oauth_bundle, + poll_token, + start_device_authorization, +) +from ww_api.services.project_deps import get_job_repo, get_session_factory +from ww_api.services.provider_deps import get_credential_store + +log = get_logger("ww.api.kimi_oauth") + +router = APIRouter(prefix="/settings/providers/kimi-code/oauth", tags=["kimi-oauth"]) + +CredentialStoreDep = Annotated[CredentialStore, Depends(get_credential_store)] +JobRepoDep = Annotated[JobRepo, Depends(get_job_repo)] +SessionDep = Annotated[AsyncSession, Depends(get_session)] +SessionFactoryDep = Annotated[SessionFactory, Depends(get_session_factory)] + +_JOB_KIND_KIMI_OAUTH = "kimi_oauth" + +#: device flow 轮询安全上限(防止 work 在异常 interval 下无限循环;按 expires_in 兜底)。 +_MAX_POLL_ATTEMPTS = 200 + + +def _default_http_client() -> AsyncHttpClient: + return httpx.AsyncClient(timeout=30.0) + + +def _make_poll_work(device: DeviceAuth) -> Any: + """构造后台轮询工作闭包:循环 poll token 直到成功/过期/拒绝。 + + `work(session)` 自建 httpx 客户端 + 凭据 store(用 `run_job` 传入的独立 session)→ + 按 `interval` 轮询 token → 成功则加密存 `oauth_enc` 并返回非密 job 结果 → 过期/拒绝则 + 抛 AppError(`run_job` 置 job failed)。**token 绝不进 job 结果/日志**。 + """ + + async def work(session: AsyncSession) -> dict[str, Any]: + enc_key = get_settings().credential_enc_key + store = SqlCredentialStore(session) + interval = max(1, device.interval) + + http = _default_http_client() + try: + for _ in range(_MAX_POLL_ATTEMPTS): + await asyncio.sleep(interval) + try: + token = await poll_token(http, device.device_code) + except AuthorizationPending: + continue + except SlowDown: + interval += 5 + continue + # 成功:加密 token 包入库(auth_type=oauth)。明文 token 不进结果/日志。 + blob = encrypt_oauth_bundle(token, key=enc_key) + await store.upsert_oauth_credential(STUB_OWNER_ID, KIMI_CODE_PROVIDER, blob) + return {"connected": True, "provider": KIMI_CODE_PROVIDER} + # 轮询次数耗尽(视作过期)。 + raise AppError( + ErrorCode.LLM_UNAVAILABLE, + "Kimi 设备授权轮询超时", + {"provider": KIMI_CODE_PROVIDER}, + ) + finally: + # `httpx.AsyncClient` 有 `aclose`(最小 Protocol 无;测试 fake 也实现了它)。 + aclose = getattr(http, "aclose", None) + if aclose is not None: + await aclose() + + return work + + +@router.post("/start", status_code=202) +async def start_oauth( + request: Request, + response: Response, + background_tasks: BackgroundTasks, + job_repo: JobRepoDep, + session: SessionDep, + session_factory: SessionFactoryDep, + http: Annotated[AsyncHttpClient, Depends(_default_http_client)], +) -> OAuthStartResponse: + """发起 device authorization:返回 202 + user_code/verification_uri,后台轮询 token。 + + 创建一行 `jobs(kind="kimi_oauth")`(202 前持久化供轮询)→ 调度 BackgroundTask 后台 + 轮询。前端展示 user_code + 打开 verification_uri + 轮询 `GET /jobs/{id}`。 + """ + request_id = getattr(request.state, "request_id", None) + + device = await start_device_authorization(http) + + job = await job_repo.create(None, _JOB_KIND_KIMI_OAUTH) + await session.commit() # job 行需在 202 前持久化(供前端立即轮询)。 + + background_tasks.add_task( + run_job, + session_factory, + job.id, + _make_poll_work(device), + request_id=request_id, + ) + + log.info( + "kimi_oauth_started", + request_id=request_id, + job_id=str(job.id), + user_code=device.user_code, + ) + response.status_code = 202 + return OAuthStartResponse( + job_id=job.id, + user_code=device.user_code, + verification_uri=device.verification_uri, + verification_uri_complete=device.verification_uri_complete, + expires_in=device.expires_in, + interval=device.interval, + ) + + +@router.post("/disconnect") +async def disconnect_oauth( + request: Request, + store: CredentialStoreDep, +) -> OAuthDisconnectResponse: + """断开 Kimi Code:删除 OAuth 凭据行(token 一并消失)。""" + request_id = getattr(request.state, "request_id", None) + deleted = await store.delete_credential(STUB_OWNER_ID, KIMI_CODE_PROVIDER) + log.info("kimi_oauth_disconnected", request_id=request_id, deleted=deleted) + return OAuthDisconnectResponse(disconnected=deleted) + + +@router.get("/status") +async def oauth_status(store: CredentialStoreDep) -> OAuthStatusResponse: + """连接状态:是否已连接 + access token 过期时刻(**无 token 本体**)。""" + enc_key = get_settings().credential_enc_key + cred = await store.get_credential(STUB_OWNER_ID, KIMI_CODE_PROVIDER) + if cred is None or cred.auth_type != AUTH_TYPE_OAUTH or cred.oauth_enc is None: + return OAuthStatusResponse(connected=False) + try: + token = decrypt_oauth_bundle(cred.oauth_enc, key=enc_key) + except Exception: # noqa: BLE001 — 解密失败视作未连接(不泄露原因到响应) + return OAuthStatusResponse(connected=False) + return OAuthStatusResponse(connected=True, expires_at=token.expires_at.isoformat()) diff --git a/apps/api/ww_api/routers/outline.py b/apps/api/ww_api/routers/outline.py index cde080a..a019dfc 100644 --- a/apps/api/ww_api/routers/outline.py +++ b/apps/api/ww_api/routers/outline.py @@ -22,7 +22,7 @@ from fastapi import APIRouter, Depends, Request from sqlalchemy.ext.asyncio import AsyncSession from ww_agents import outliner_spec from ww_core.domain import ForeshadowLedgerRepo, OutlineWriteRepo, ProjectRepo -from ww_core.domain.repositories import MemoryRepos +from ww_core.domain.repositories import MemoryRepos, OutlineRepo from ww_core.orchestrator import run_outline from ww_db import get_session from ww_llm_gateway import Gateway @@ -41,6 +41,7 @@ from ww_api.services.project_deps import ( get_foreshadow_repo, get_memory_repos, get_outline_gateway, + get_outline_read_repo, get_outline_write_repo, get_project_repo, ) @@ -53,6 +54,7 @@ ProjectRepoDep = Annotated[ProjectRepo, Depends(get_project_repo)] ForeshadowRepoDep = Annotated[ForeshadowLedgerRepo, Depends(get_foreshadow_repo)] MemoryReposDep = Annotated[MemoryRepos, Depends(get_memory_repos)] OutlineWriteRepoDep = Annotated[OutlineWriteRepo, Depends(get_outline_write_repo)] +OutlineReadRepoDep = Annotated[OutlineRepo, Depends(get_outline_read_repo)] OutlineGatewayDep = Annotated[Gateway, Depends(get_outline_gateway)] SessionDep = Annotated[AsyncSession, Depends(get_session)] @@ -133,3 +135,42 @@ async def generate_outline( chapter_count=len(chapters), ) return OutlineResponse(chapters=chapters) + + +@router.get("/{project_id}/outline") +async def get_outline( + project_id: uuid.UUID, + request: Request, + project_repo: ProjectRepoDep, + outline_repo: OutlineReadRepoDep, +) -> OutlineResponse: + """读取已持久化的大纲(逐章,按 chapter_no 升序)。 + + 项目不存在 → 404;项目存在但尚无大纲 → 200 空列表(非 404,页面初次访问的常态)。 + DB `outline.beats` 是 JSONB `{"beats": [...]}` → 解包成裸 `list[str]`(与 POST 响应同形, + 前端 OpenAPI 类型对齐)。读侧复用 C5 assemble 的 `OutlineRepo`,不写库。 + """ + request_id = getattr(request.state, "request_id", None) + + project = await project_repo.get(STUB_OWNER_ID, project_id) + if project is None: + raise AppError(ErrorCode.NOT_FOUND, f"project not found: {project_id}") + + views = await outline_repo.list_for_project(project_id) + chapters = [ + OutlineChapterView( + no=view.chapter_no, + volume=view.volume, + beats=list(view.beats.get("beats", [])), + foreshadow_windows=[ForeshadowWindowView(**w) for w in view.foreshadow_windows], + ) + for view in views + ] + + log.info( + "outline_read", + project_id=str(project_id), + request_id=request_id, + chapter_count=len(chapters), + ) + return OutlineResponse(chapters=chapters) diff --git a/apps/api/ww_api/routers/projects.py b/apps/api/ww_api/routers/projects.py index 0b0f4c9..0ac4b88 100644 --- a/apps/api/ww_api/routers/projects.py +++ b/apps/api/ww_api/routers/projects.py @@ -45,6 +45,7 @@ from ww_api.schemas.projects import ( AcceptResponse, DraftResponse, DraftSaveRequest, + DraftView, ProjectCreateRequest, ProjectListResponse, ProjectResponse, @@ -198,6 +199,41 @@ async def save_draft( ) +@router.get("/{project_id}/chapters/{chapter_no}/draft") +async def get_draft( + project_id: uuid.UUID, + chapter_no: int, + repo: ChapterRepoDep, +) -> DraftView: + """读取已保存草稿(含正文),供工作台重访时重载编辑器(镜像 GET /outline 读侧)。 + + 复用 `chapter_repo.get_draft`(与续审 `_resolve_review_draft` 同一读 seam);只读不写库。 + 无草稿行(含空正文)→ 404 NOT_FOUND,工作台据此呈现空编辑器(与其它「缺资源」端点一致)。 + 多版本时该读取固定返草稿版次(DRAFT_VERSION)这条可编辑工作副本,与 PUT 保存的同一行。 + """ + view = await repo.get_draft(project_id, chapter_no) + if view is None or not view.content.strip(): + raise AppError( + ErrorCode.NOT_FOUND, + f"chapter {chapter_no} has no saved draft", + ) + log.info( + "draft_read", + project_id=str(project_id), + chapter_no=chapter_no, + length=len(view.content), + ) + return DraftView( + project_id=view.project_id, + chapter_no=view.chapter_no, + volume=view.volume, + status=view.status, + version=view.version, + content=view.content, + length=len(view.content), + ) + + async def _resolve_review_draft( body: ReviewRequest, chapter_repo: ChapterRepo, diff --git a/apps/api/ww_api/routers/rules.py b/apps/api/ww_api/routers/rules.py new file mode 100644 index 0000000..c03d079 --- /dev/null +++ b/apps/api/ww_api/routers/rules.py @@ -0,0 +1,53 @@ +"""规则端点(C3 扩 / PRODUCT_SPEC §7 `POST /projects/:id/rules`;不变量 #3)。 + +POST /projects/:id/rules:作者显式加规则——审稿发现的问题/亮点随手沉淀为项目规则。 +写一行 `rules`(level + content),喂给 assemble 的四级合并(`merge_rules`)。 + +加规则是**作者显式动作**(不变量 #3:规则入库不经 AI 静默写库)。`level` 合法性由 +`RuleCreateRequest` 的 Literal 校验(非法 → FastAPI 422)。 + +提交边界:`RuleWriteRepo.create` 只 `flush()`,端点写后 `await session.commit()` +(仿 foreshadow/outline 写侧,见 memory/gotchas)。 +""" + +from __future__ import annotations + +import uuid +from typing import Annotated + +from fastapi import APIRouter, Depends, Request +from sqlalchemy.ext.asyncio import AsyncSession +from ww_core.domain import RuleWriteRepo +from ww_db import get_session + +from ww_api.logging_config import get_logger +from ww_api.schemas.rules import RuleCreateRequest, RuleView +from ww_api.services.project_deps import get_rule_write_repo + +log = get_logger("ww.api.rules") + +router = APIRouter(prefix="/projects", tags=["rules"]) + +RuleWriteRepoDep = Annotated[RuleWriteRepo, Depends(get_rule_write_repo)] +SessionDep = Annotated[AsyncSession, Depends(get_session)] + + +@router.post("/{project_id}/rules", status_code=201) +async def create_rule( + project_id: uuid.UUID, + body: RuleCreateRequest, + request: Request, + repo: RuleWriteRepoDep, + session: SessionDep, +) -> RuleView: + """新增一条规则(201)。非法 level / 空 content → FastAPI 422。""" + request_id = getattr(request.state, "request_id", None) + view = await repo.create(project_id, level=body.level, content=body.content) + await session.commit() + log.info( + "rule_created", + project_id=str(project_id), + request_id=request_id, + level=body.level, + ) + return RuleView(level=view.level, content=view.content) diff --git a/apps/api/ww_api/routers/style.py b/apps/api/ww_api/routers/style.py new file mode 100644 index 0000000..9cc6d7a --- /dev/null +++ b/apps/api/ww_api/routers/style.py @@ -0,0 +1,225 @@ +"""文风端点:学文风(异步长任务)+ 回炉 + 最新指纹读取 +(C3 扩 / ARCH §5.4 style-auditor 提取轨 / §7.2 / §7.4 jobs / UX §6.9 / §8.3)。 + +独立 router(仿 outline.py),挂 `app.include_router`。三端点: + +- `POST /projects/{id}/style` → 202 `{job_id}`:学文风走 jobs(M4-c)。立即写一行 job + 返 202,提取经 BackgroundTask `run_job` 跑(**自建独立 session**,请求 session 已关闭)。 + 无凭据 → 流前 `LLM_UNAVAILABLE`(503,在调度 job 之前拦下,避免凭空写注定失败的 job)。 +- `POST /projects/{id}/chapters/{no}/refine` → 200 `{original, refined}`:同步回炉,writer + 网关纯文本重写选中段;不写库(不变量 #3,作者采纳经既有 draft 自动保存合入);末尾 + `commit()` 让 usage_ledger 落库(网关 ledger add-only,同 draft/review 纪律)。 +- `GET /projects/{id}/style` → 200 最新指纹(完整 16 维 + 证据 + 版本,UX §6.9)。 + +不变量 #2:经 `get_*_gateway`(analyst/writer 档)注入,spec 只声明档位不传 model。 +不变量 #3:提取/回炉 agent 只读不写其它表;文风指纹落库经此作者发起的学文风路径。 +""" + +from __future__ import annotations + +import uuid +from typing import Annotated, Any + +from fastapi import APIRouter, BackgroundTasks, Depends, Request, Response +from sqlalchemy.ext.asyncio import AsyncSession +from ww_agents import refiner_spec, style_extract_spec +from ww_core.domain import JobRepo, ProjectRepo, StyleFingerprintWriteRepo +from ww_core.domain.style_repo import SqlStyleFingerprintWriteRepo +from ww_core.orchestrator import run_style_extraction +from ww_db import get_session +from ww_llm_gateway import Gateway +from ww_llm_gateway.types import Block, LlmRequest, Scope +from ww_shared import AppError, ErrorCode + +from ww_api.logging_config import get_logger +from ww_api.schemas.style import ( + RefineRequest, + RefineResponse, + StyleFingerprintResponse, + StyleLearnRequest, + StyleLearnResponse, +) +from ww_api.services.credentials import STUB_OWNER_ID, SqlCredentialStore +from ww_api.services.foreshadow_scan import SessionFactory +from ww_api.services.job_runner import run_job +from ww_api.services.project_deps import ( + build_gateway_for_tier, + get_job_repo, + get_project_repo, + get_refine_gateway, + get_session_factory, + get_style_extract_gateway, + get_style_write_repo, +) + +log = get_logger("ww.api.style") + +router = APIRouter(prefix="/projects", tags=["style"]) + +ProjectRepoDep = Annotated[ProjectRepo, Depends(get_project_repo)] +JobRepoDep = Annotated[JobRepo, Depends(get_job_repo)] +StyleWriteRepoDep = Annotated[StyleFingerprintWriteRepo, Depends(get_style_write_repo)] +StyleExtractGatewayDep = Annotated[Gateway, Depends(get_style_extract_gateway)] +RefineGatewayDep = Annotated[Gateway, Depends(get_refine_gateway)] +SessionDep = Annotated[AsyncSession, Depends(get_session)] +SessionFactoryDep = Annotated[SessionFactory, Depends(get_session_factory)] + +_JOB_KIND_STYLE_LEARN = "style_learn" + + +def _split_fingerprint(result: Any) -> tuple[dict[str, str], dict[str, list[str]]]: + """把 `StyleFingerprintResult` 拆成 DB 两列:`{name:value}` + `{name:[evidence]}`。""" + dimensions = {dim.name: dim.value for dim in result.dimensions} + evidence = {dim.name: list(dim.evidence) for dim in result.dimensions} + return dimensions, evidence + + +def _make_style_learn_work(project_id: uuid.UUID, samples_text: str) -> Any: + """构造学文风后台工作闭包:在 `run_job` 自建的独立 session 上跑提取 + 落库。 + + `work(session)` 自建 analyst 网关(从凭据)+ 写侧 repo(用 `run_job` 传入的独立 + session)→ `run_style_extraction` → 拆指纹 → `append`(版本化)→ 返回 result 摘要 + `{version, dims_count}`。提交归 `run_job`(业务写 + job done 同一事务一次 commit)。 + """ + + async def work(session: AsyncSession) -> dict[str, Any]: + store = SqlCredentialStore(session) + gateway = await build_gateway_for_tier(session, store, "analyst") + result = await run_style_extraction( + style_extract_spec, + samples_text=samples_text, + gateway=gateway, + user_id=STUB_OWNER_ID, + project_id=project_id, + ) + dimensions, evidence = _split_fingerprint(result) + repo = SqlStyleFingerprintWriteRepo(session) + version = await repo.append(project_id, dimensions_json=dimensions, evidence_json=evidence) + return {"version": version, "dims_count": len(dimensions)} + + return work + + +@router.post("/{project_id}/style", status_code=202) +async def learn_style( + project_id: uuid.UUID, + body: StyleLearnRequest, + request: Request, + response: Response, + background_tasks: BackgroundTasks, + project_repo: ProjectRepoDep, + job_repo: JobRepoDep, + gateway: StyleExtractGatewayDep, # 凭据探测(无凭据 → dep 解析阶段 503) + session: SessionDep, + session_factory: SessionFactoryDep, +) -> StyleLearnResponse: + """学文风:写一行 job 返 202,提取经 BackgroundTask 异步跑。 + + 项目不存在 → 404;无凭据 → 503(在调度 job 前拦下)。`mode` 不影响落库逻辑 + (写侧始终 append 新版本,version+1 自动),仅供前端区分首学/更新语义。 + """ + request_id = getattr(request.state, "request_id", None) + + project = await project_repo.get(STUB_OWNER_ID, project_id) + if project is None: + raise AppError(ErrorCode.NOT_FOUND, f"project not found: {project_id}") + + # `gateway` 已在 dep 解析阶段验过凭据(无凭据 → 503);提取本体在 BackgroundTask + # 里用独立 session 重新构网关跑,此处不复用它(请求 session 即将关闭)。 + samples_text = "\n\n".join(body.samples) + + job = await job_repo.create(project_id, _JOB_KIND_STYLE_LEARN) + await session.commit() # job 行需在 202 返回前持久化(供前端立即轮询)。 + + background_tasks.add_task( + run_job, + session_factory, + job.id, + _make_style_learn_work(project_id, samples_text), + request_id=request_id, + ) + + log.info( + "style_learn_scheduled", + project_id=str(project_id), + request_id=request_id, + job_id=str(job.id), + sample_count=len(body.samples), + mode=body.mode, + ) + response.status_code = 202 + return StyleLearnResponse(job_id=job.id) + + +@router.get("/{project_id}/style") +async def get_style( + project_id: uuid.UUID, + project_repo: ProjectRepoDep, + style_repo: StyleWriteRepoDep, +) -> StyleFingerprintResponse: + """取最新文风指纹(完整 16 维 + 证据 + 版本)。项目不存在 → 404;无指纹 → 404。""" + project = await project_repo.get(STUB_OWNER_ID, project_id) + if project is None: + raise AppError(ErrorCode.NOT_FOUND, f"project not found: {project_id}") + + latest = await style_repo.latest(project_id) + if latest is None: + raise AppError(ErrorCode.NOT_FOUND, f"no style fingerprint for project: {project_id}") + return StyleFingerprintResponse( + dimensions=latest.dimensions, + evidence=latest.evidence, + version=latest.version, + ) + + +def _build_refine_input(segment: str, instruction: str | None) -> str: + """组回炉输入:待重写段 + 可选指令(保留上下文语气,只重写该段)。""" + parts = [f"【待重写段落】\n{segment}"] + if instruction: + parts.append(f"【改写指令】\n{instruction}") + return "\n\n".join(parts) + + +@router.post("/{project_id}/chapters/{chapter_no}/refine") +async def refine_segment( + project_id: uuid.UUID, + chapter_no: int, + body: RefineRequest, + request: Request, + project_repo: ProjectRepoDep, + gateway: RefineGatewayDep, + session: SessionDep, +) -> RefineResponse: + """同步回炉:writer 网关纯文本重写选中段,返回 {original, refined}。 + + 项目不存在 → 404;无凭据 → 503。不写库(不变量 #3);末尾 `commit()` 让网关 + usage_ledger 落库(add-only,否则记账静默丢失,同 draft/review 纪律)。 + """ + request_id = getattr(request.state, "request_id", None) + + project = await project_repo.get(STUB_OWNER_ID, project_id) + if project is None: + raise AppError(ErrorCode.NOT_FOUND, f"project not found: {project_id}") + + req = LlmRequest( + tier=refiner_spec.tier, # 不变量 #2:writer 档,不传 model + system=[Block(text=refiner_spec.system_prompt, cache=True)], + input=_build_refine_input(body.segment, body.instruction), + output_schema=None, # refiner 纯文本(无结构化 schema) + scope=Scope(user_id=STUB_OWNER_ID, project_id=project_id), + ) + resp = await gateway.run(req) + + # 提交边界:网关 ledger add-only → 端点末尾 commit(否则记账静默丢失,见 gotcha)。 + await session.commit() + + log.info( + "refine_done", + project_id=str(project_id), + chapter_no=chapter_no, + request_id=request_id, + segment_len=len(body.segment), + refined_len=len(resp.text), + has_instruction=body.instruction is not None, + ) + return RefineResponse(original=body.segment, refined=resp.text) diff --git a/apps/api/ww_api/schemas/generation.py b/apps/api/ww_api/schemas/generation.py new file mode 100644 index 0000000..62fa78f --- /dev/null +++ b/apps/api/ww_api/schemas/generation.py @@ -0,0 +1,163 @@ +"""生成/入库端点的请求/响应 schema(C3 扩 / ARCH §6.5 / §7.2)。 + +snake_case;前端经 OpenAPI 生成 TS 类型消费。改字段 → 前端必须 `pnpm gen:api`。 + +生成走**即时返回**(预览 → 作者确认 → 入库),非 jobs 长任务(M5-d): +- `POST /world/generate` / `POST /characters/generate`:返回**预览**(不持久化)。 +- `POST /characters`(入库):作者确认的角色卡 → 过 continuity 预检 gate + 权限白名单 → 写库。 + +视图字段贴 `ww_agents.WorldEntityCard`/`CharacterCard`(保持生成产物形:traits/speech_tics +是 list、arc 是 str;入库时由写侧 repo 转 DB JSONB 形,见 character_repo)。 +""" + +from __future__ import annotations + +from pydantic import BaseModel, Field + +from ww_api.schemas.rules import RuleView + +# 批量生成数量上限(防一次性巨量调用;原型保守值)。 +MAX_GENERATE_COUNT = 12 + + +# ---- 世界观生成(预览)---- + + +class WorldGenerateRequest(BaseModel): + """POST /projects/:id/world/generate:据需求生成世界观实体(预览)。""" + + brief: str = Field(min_length=1, description="世界观生成需求(题材/设定方向/约束)") + + +class WorldEntityCardView(BaseModel): + """单个世界观实体卡(贴 ww_agents.WorldEntityCard;预览 + 入库共用形)。""" + + type: str = Field(description="实体类型(势力 / 地理 / 力量体系 / 物品 / 概念 等)") + name: str = Field(description="实体名") + rules: list[str] = Field(default_factory=list, description="该实体的硬规则清单") + + +class WorldGenPreviewResponse(BaseModel): + """世界观生成预览(不持久化;作者确认后另走入库,本期入库端点为角色)。""" + + entities: list[WorldEntityCardView] = Field(default_factory=list) + + +# ---- 角色生成(预览)---- + + +class CharacterGenerateRequest(BaseModel): + """POST /projects/:id/characters/generate:据需求生成角色卡(预览,群像防雷同)。""" + + brief: str = Field(min_length=1, description="角色生成需求") + count: int = Field(default=1, ge=1, le=MAX_GENERATE_COUNT, description="生成数量") + role: str | None = Field(default=None, description="角色定位(主角/CP/对手/导师/工具人 等)") + + +class CharacterRelationView(BaseModel): + """单条人物关系(贴 ww_agents.CharacterRelation)。""" + + name: str + kind: str + note: str | None = None + + +class CharacterCardView(BaseModel): + """单张角色卡(贴 ww_agents.CharacterCard;预览 + 入库请求共用形)。""" + + name: str = Field(description="角色名") + role: str = Field(description="角色定位") + traits: list[str] = Field(default_factory=list, description="性格特质清单") + backstory: str = Field(description="背景故事") + arc: str = Field(description="人物弧光(一句话)") + speech_tics: list[str] = Field(default_factory=list, description="口癖/语言风格") + tags: list[str] = Field(default_factory=list, description="人设标签/萌点") + relations: list[CharacterRelationView] = Field(default_factory=list, description="关系网") + + +class CharacterGenPreviewResponse(BaseModel): + """角色生成预览(不持久化;作者确认后经 POST /characters 入库)。""" + + cards: list[CharacterCardView] = Field(default_factory=list) + + +# ---- 角色入库(作者确认后;过 continuity gate + 权限白名单)---- + + +class CharacterIngestRequest(BaseModel): + """POST /projects/:id/characters:把作者确认的角色卡入库。 + + `acknowledge_conflicts`:作者已查看并接受预检冲突时置 `true`,越过 continuity gate + (仿 accept 的冲突裁决:不静默入库,须作者确认;默认 false → 有冲突即 409,见端点)。 + """ + + cards: list[CharacterCardView] = Field(min_length=1, description="待入库的角色卡(至少 1 张)") + acknowledge_conflicts: bool = Field( + default=False, description="作者已知悉并接受 continuity 冲突 → 放行入库" + ) + + +class IngestConflictView(BaseModel): + """入库预检检出的单条 continuity 冲突(贴 ww_agents.Conflict 五类)。""" + + type: str + where: str + refs: list[str] = Field(default_factory=list) + suggestion: str + + +class CharacterIngestResponse(BaseModel): + """角色入库结果(201):写入的角色 + 被白名单丢弃的越权表(审计)。""" + + created: list[str] = Field(default_factory=list, description="写入的角色名(按入参顺序)") + rejected_tables: list[str] = Field( + default_factory=list, description="被权限白名单丢弃的越权写表名(审计;正常为空)" + ) + + +# ---- 读端点:设定库 Codex(角色 / 世界观全量列表)---- + + +class CharacterListResponse(BaseModel): + """GET /projects/:id/characters:已入库角色全量列表(设定库 Codex 真源)。 + + DB JSONB dict 列 → API list/str 反向解包(入库形变的逆向,见 character_repo): + `{"items":[...]}`→list、`{"text":...}`→str;tags/relations 直落 list。 + """ + + characters: list[CharacterCardView] = Field(default_factory=list) + + +class WorldEntityListResponse(BaseModel): + """GET /projects/:id/world_entities:已入库世界观实体全量列表(设定库 Codex 真源)。 + + DB JSONB dict 列 `{"rules":[...]}`→裸 list(worldbuilder 形变的逆向)。 + """ + + world_entities: list[WorldEntityCardView] = Field(default_factory=list) + + +# ---- 读端点:规则列表 + 技能库(T5.6 前端)---- + + +class RuleListResponse(BaseModel): + """GET /projects/:id/rules:规则列表(规则页)。""" + + rules: list[RuleView] = Field(default_factory=list) + + +class SkillView(BaseModel): + """单个 skill 的声明式视图(技能库 UI)。""" + + name: str + scope: str = Field(description="builtin / custom / community") + tier: str = Field(description="能力档位(writer / analyst / light)") + reads: list[str] = Field(default_factory=list) + writes: list[str] = Field(default_factory=list) + genre: str | None = None + + +class SkillListResponse(BaseModel): + """GET /skills:技能库列表(按 name 升序)。""" + + skills: list[SkillView] = Field(default_factory=list) diff --git a/apps/api/ww_api/schemas/kimi_oauth.py b/apps/api/ww_api/schemas/kimi_oauth.py new file mode 100644 index 0000000..0dc3836 --- /dev/null +++ b/apps/api/ww_api/schemas/kimi_oauth.py @@ -0,0 +1,39 @@ +"""Kimi Code OAuth device-flow 端点的响应 schema(C3 扩 K1.3 / ARCH §7.2 / §7.4)。 + +snake_case(命名契约,见 memory/gotchas)。前端经 OpenAPI→TS 客户端消费——改字段须 +`pnpm gen:api` 重生成。**响应绝不含 access/refresh token**(只 user_code + 轮询信息)。 +""" + +from __future__ import annotations + +import uuid + +from pydantic import BaseModel + + +class OAuthStartResponse(BaseModel): + """device flow 启动:返回 job_id(轮询 `GET /jobs/{id}`)+ 用户面展示信息。 + + 前端展示 `user_code`、打开 `verification_uri`(或 `verification_uri_complete`),并按 + `interval` 轮询 job 直到 `done`(已连接)/`failed`(过期/拒绝)。**无 token**。 + """ + + job_id: uuid.UUID + user_code: str + verification_uri: str + verification_uri_complete: str | None = None + expires_in: int + interval: int + + +class OAuthStatusResponse(BaseModel): + """连接状态:是否已连接 + access token 过期时刻(ISO8601;**无 token 本体**)。""" + + connected: bool + expires_at: str | None = None + + +class OAuthDisconnectResponse(BaseModel): + """断开:是否删到凭据行。""" + + disconnected: bool diff --git a/apps/api/ww_api/schemas/projects.py b/apps/api/ww_api/schemas/projects.py index 8307c2e..12ca4fd 100644 --- a/apps/api/ww_api/schemas/projects.py +++ b/apps/api/ww_api/schemas/projects.py @@ -59,6 +59,22 @@ class DraftResponse(BaseModel): length: int +class DraftView(BaseModel): + """GET .../draft:回灌已保存草稿(含正文,供工作台重载编辑器)。 + + 与 PUT 的 `DraftResponse` 同元信息,**额外带 `content`**——读侧需要正文以重建编辑器, + 保存侧不回灌正文故不带。`length` 仍按 content 字符数派生。 + """ + + project_id: uuid.UUID + chapter_no: int + volume: int + status: str + version: int + content: str + length: int + + # ---- 审稿(T2.5)---- diff --git a/apps/api/ww_api/schemas/rules.py b/apps/api/ww_api/schemas/rules.py new file mode 100644 index 0000000..1ce90b1 --- /dev/null +++ b/apps/api/ww_api/schemas/rules.py @@ -0,0 +1,28 @@ +"""规则端点的请求/响应 schema(C3 扩 / PRODUCT_SPEC §7 POST /rules)。 + +snake_case;前端经 OpenAPI 生成 TS 类型消费。改字段 → 前端必须 `pnpm gen:api`。 +`level` ∈ global/genre/style/project(四级合并优先级,见 memory `merge_rules`)。 +加规则是作者显式动作——审稿发现的问题/亮点随手沉淀为规则(非 AI 静默写库,不变量 #3)。 +""" + +from __future__ import annotations + +from typing import Literal + +from pydantic import BaseModel, Field + +RuleLevel = Literal["global", "genre", "style", "project"] + + +class RuleCreateRequest(BaseModel): + """POST /projects/:id/rules:新增一条规则。""" + + level: RuleLevel = Field(description="规则级别(global/genre/style/project,越具体越优先)") + content: str = Field(min_length=1, description="规则正文") + + +class RuleView(BaseModel): + """规则视图(创建后回显;snake_case)。""" + + level: str + content: str diff --git a/apps/api/ww_api/schemas/style.py b/apps/api/ww_api/schemas/style.py new file mode 100644 index 0000000..da26d65 --- /dev/null +++ b/apps/api/ww_api/schemas/style.py @@ -0,0 +1,47 @@ +"""文风学习 + 回炉端点的请求/响应 schema(C3 扩 / ARCH §7.2 / UX §6.9 / §8.3)。 + +snake_case(命名契约,见 memory/gotchas)。前端经 OpenAPI→TS 客户端消费——改字段 +须 `pnpm gen:api` 重生成。 +""" + +from __future__ import annotations + +import uuid +from typing import Any, Literal + +from pydantic import BaseModel, Field + + +class StyleLearnRequest(BaseModel): + """学文风:上传样本正文(前端可读文件转文本)+ 模式(首学 / 更新)。""" + + samples: list[str] = Field(min_length=1) + mode: Literal["create", "update"] = "create" + + +class StyleLearnResponse(BaseModel): + """学文风受理:返回 job_id(长任务,走 `GET /jobs/{id}` 轮询)。""" + + job_id: uuid.UUID + + +class StyleFingerprintResponse(BaseModel): + """最新文风指纹(`GET /style`):完整 16 维 + 证据 + 版本(对齐 UX §6.9)。""" + + dimensions: dict[str, Any] = Field(default_factory=dict) + evidence: dict[str, Any] = Field(default_factory=dict) + version: int + + +class RefineRequest(BaseModel): + """回炉:重写选中段(可选改写指令)。""" + + segment: str = Field(min_length=1) + instruction: str | None = None + + +class RefineResponse(BaseModel): + """回炉结果:原段 + 重写段(不写库,作者采纳经既有 draft 自动保存合入)。""" + + original: str + refined: str diff --git a/apps/api/ww_api/services/credentials.py b/apps/api/ww_api/services/credentials.py index dff9fda..bb42221 100644 --- a/apps/api/ww_api/services/credentials.py +++ b/apps/api/ww_api/services/credentials.py @@ -20,12 +20,24 @@ from ww_llm_gateway.adapters.base import Capabilities STUB_OWNER_ID = uuid.UUID(int=1) +# 凭据认证类型(`provider_credentials.auth_type`,见 C2 扩 K1.1)。 +AUTH_TYPE_API_KEY = "api_key" +AUTH_TYPE_OAUTH = "oauth" + + @dataclass(frozen=True) class StoredCredential: - """存储层视图:含密文,绝不出 API 边界(路由仅取 provider 并掩码)。""" + """存储层视图:含密文,绝不出 API 边界(路由仅取 provider 并掩码)。 + + 一行二选一:`auth_type="api_key"` → `api_key_enc` 有值、`oauth_enc=None`; + `auth_type="oauth"`(Kimi Code device-flow,K1.3)→ `oauth_enc` 有值、`api_key_enc=None` + (持 Fernet 加密的 `{access_token,refresh_token,expires_at}` JSON 包)。 + """ provider: str - api_key_enc: bytes + api_key_enc: bytes | None + auth_type: str = AUTH_TYPE_API_KEY + oauth_enc: bytes | None = None @dataclass(frozen=True) @@ -51,6 +63,12 @@ class CredentialStore(Protocol): self, owner_id: uuid.UUID, provider: str, api_key_enc: bytes ) -> None: ... + async def upsert_oauth_credential( + self, owner_id: uuid.UUID, provider: str, oauth_enc: bytes + ) -> None: ... + + async def delete_credential(self, owner_id: uuid.UUID, provider: str) -> bool: ... + async def upsert_routing(self, routing: StoredRouting) -> None: ... @@ -72,7 +90,15 @@ class SqlCredentialStore: select(ProviderCredential).where(ProviderCredential.owner_id == owner_id) ) ).scalars() - return [StoredCredential(provider=r.provider, api_key_enc=r.api_key_enc) for r in rows] + return [ + StoredCredential( + provider=r.provider, + api_key_enc=r.api_key_enc, + auth_type=r.auth_type, + oauth_enc=r.oauth_enc, + ) + for r in rows + ] async def list_routing(self) -> list[StoredRouting]: rows = (await self._session.execute(select(TierRouting))).scalars() @@ -94,7 +120,12 @@ class SqlCredentialStore: ).scalar_one_or_none() if row is None: return None - return StoredCredential(provider=row.provider, api_key_enc=row.api_key_enc) + return StoredCredential( + provider=row.provider, + api_key_enc=row.api_key_enc, + auth_type=row.auth_type, + oauth_enc=row.oauth_enc, + ) async def upsert_credential( self, owner_id: uuid.UUID, provider: str, api_key_enc: bytes @@ -117,12 +148,68 @@ class SqlCredentialStore: project_id=None, provider=provider, api_key_enc=api_key_enc, + auth_type=AUTH_TYPE_API_KEY, + oauth_enc=None, ) ) else: existing.api_key_enc = api_key_enc + existing.auth_type = AUTH_TYPE_API_KEY + existing.oauth_enc = None await self._session.commit() + async def upsert_oauth_credential( + self, owner_id: uuid.UUID, provider: str, oauth_enc: bytes + ) -> None: + """写/更新 OAuth 凭据行(Kimi Code device-flow,K1.3)。 + + `auth_type="oauth"`、`oauth_enc=`、`api_key_enc=None`。 + 显式 read-modify-write(同 `upsert_credential`:含可空 project_id 的唯一约束不能用 + PG `ON CONFLICT`,见 memory/gotchas)。明文 token 绝不进此层(已加密)。 + """ + existing = ( + await self._session.execute( + select(ProviderCredential).where( + ProviderCredential.owner_id == owner_id, + ProviderCredential.project_id.is_(None), + ProviderCredential.provider == provider, + ) + ) + ).scalar_one_or_none() + if existing is None: + self._session.add( + ProviderCredential( + owner_id=owner_id, + project_id=None, + provider=provider, + api_key_enc=None, + auth_type=AUTH_TYPE_OAUTH, + oauth_enc=oauth_enc, + ) + ) + else: + existing.api_key_enc = None + existing.auth_type = AUTH_TYPE_OAUTH + existing.oauth_enc = oauth_enc + await self._session.commit() + + async def delete_credential(self, owner_id: uuid.UUID, provider: str) -> bool: + """删除凭据行(OAuth disconnect / 撤销)。返回是否删到行。""" + existing = ( + await self._session.execute( + select(ProviderCredential).where( + ProviderCredential.owner_id == owner_id, + ProviderCredential.project_id.is_(None), + ProviderCredential.provider == provider, + ) + ) + ).scalar_one_or_none() + if existing is None: + return False + await self._session.delete(existing) + await self._session.commit() + return True + async def upsert_routing(self, routing: StoredRouting) -> None: existing = ( await self._session.execute( diff --git a/apps/api/ww_api/services/job_runner.py b/apps/api/ww_api/services/job_runner.py new file mode 100644 index 0000000..cc2d4a7 --- /dev/null +++ b/apps/api/ww_api/services/job_runner.py @@ -0,0 +1,97 @@ +"""通用长任务 runner(BackgroundTask 跑 `jobs` 表上的异步工作;ARCH §7.4)。 + +T4.3「学文风走 jobs」复用此基建:`POST /style` 立即写一行 `jobs(status=queued)` 返 +202 `{job_id}`,再经 FastAPI `BackgroundTasks` 登记 `run_job(...)` 跑真正的提取工作。 + +**独立 session 纪律**(同 `services/foreshadow_scan.run_overdue_scan` 先例 + memory/gotchas): +BackgroundTask 在请求-response 发回、请求 session 关闭**之后**才跑——故 `run_job` +**自建新 session**(经 `session_factory`),绝不复用请求 session。 + +`work: Callable[[AsyncSession], Awaitable[dict]]` 是业务逻辑缝(T4.3 部分应用「跑提取 +→ 写 style_fingerprint → 返回 result 摘要」)。`work` 拿到的 session 与 job 状态写同一 +session → 一次 `commit()` 一并落库(业务写 + job done 原子)。 + +可测性:`run_job` 经可注入 `session_factory`/`repo_factory` 缝——单测直接 `await` 它, +注 fake session 工厂 + fake job repo + fake work(**不起后台线程、不连真 DB**),断言 +成功路置 done、异常路置 failed。 +""" + +from __future__ import annotations + +import uuid +from collections.abc import Awaitable, Callable +from typing import Any, Protocol + +import structlog +from sqlalchemy.ext.asyncio import AsyncSession +from ww_core.domain.job_repo import JobView, SqlJobRepo + +from ww_api.services.foreshadow_scan import SessionFactory + +log = structlog.get_logger(__name__) + +# 业务工作缝:拿 session 跑真正的长任务,返回写回 job.result 的摘要 dict。 +JobWork = Callable[[AsyncSession], Awaitable[dict[str, Any]]] + + +class JobLifecycleRepo(Protocol): + """`run_job` 对 job repo 的**最小**依赖(仅生命周期三态写)——便于注入 fake。""" + + async def set_running(self, job_id: uuid.UUID) -> JobView: ... + + async def complete(self, job_id: uuid.UUID, result: dict[str, Any]) -> JobView: ... + + async def fail(self, job_id: uuid.UUID, error: str) -> JobView: ... + + +# repo 工厂:从新 session 造 job repo。默认建 SQL 实现;测试注 fake(避免真连 DB)。 +JobRepoFactory = Callable[[AsyncSession], JobLifecycleRepo] + + +def _default_repo_factory(session: AsyncSession) -> JobLifecycleRepo: + return SqlJobRepo(session) + + +async def run_job( + session_factory: SessionFactory, + job_id: uuid.UUID, + work: JobWork, + *, + request_id: str | None = None, + repo_factory: JobRepoFactory = _default_repo_factory, +) -> None: + """跑一个长任务:新建独立 session → set_running → await work → complete/fail → commit。 + + 成功:`complete(job_id, result)`(status=done, progress=100, result=work 返回值)后 commit。 + 异常:回滚 work 的部分写 → 新 session 里 `fail(job_id, str(exc))` → commit(job 失败可见)。 + 任何异常都被吞(后台任务边界,不冒泡崩进程);失败置态本身再炸只记日志。 + `session_factory`/`repo_factory` 是可注入缝:测试直接 await、注 fake,绝不联网/起线程。 + """ + try: + async with session_factory() as session: + repo = repo_factory(session) + await repo.set_running(job_id) + result = await work(session) + await repo.complete(job_id, result) + await session.commit() + log.info("job_done", job_id=str(job_id), request_id=request_id) + except Exception as exc: # noqa: BLE001 — 后台任务边界:记错误 + 置 job failed,不冒泡。 + log.error("job_failed", job_id=str(job_id), request_id=request_id, error=str(exc)) + await _mark_failed(session_factory, job_id, str(exc), repo_factory, request_id) + + +async def _mark_failed( + session_factory: SessionFactory, + job_id: uuid.UUID, + error: str, + repo_factory: JobRepoFactory, + request_id: str | None, +) -> None: + """在一个**全新** session 里把 job 置 failed(前一 session 的事务已因异常作废)。""" + try: + async with session_factory() as session: + repo = repo_factory(session) + await repo.fail(job_id, error) + await session.commit() + except Exception as exc: # noqa: BLE001 — 置失败态本身再炸只记日志,不冒泡。 + log.error("job_fail_mark_failed", job_id=str(job_id), request_id=request_id, error=str(exc)) diff --git a/apps/api/ww_api/services/kimi_oauth.py b/apps/api/ww_api/services/kimi_oauth.py new file mode 100644 index 0000000..a46cda5 --- /dev/null +++ b/apps/api/ww_api/services/kimi_oauth.py @@ -0,0 +1,240 @@ +"""Kimi Code OAuth device-flow 客户端(K1.3 / PROGRESS K1)。 + +Kimi 订阅 plan 走 OAuth 2.0 **device authorization flow**(RFC 8628): + +1. `start_device_authorization` → `POST .../device_authorization`,拿 `device_code` + + `user_code` + `verification_uri`(用户在浏览器授权)+ 轮询 `interval`/过期 `expires_in`。 +2. `poll_token`(一次尝试,调用方按 `interval` 循环)→ `POST .../token` + (grant=device_code);`authorization_pending` → 继续轮询、`slow_down` → 增大间隔、 + `expired_token`/`access_denied` → 停止失败;成功 → access/refresh token。 +3. `refresh` → 同 token 端点(grant=refresh_token),换新的 access/refresh token。 + +**httpx 注入**:所有 HTTP 经注入的 `AsyncHttpClient` Protocol(= `httpx.AsyncClient` +的 `.post` 子集)——测试注 fake,**绝不联网**。 + +**token 不落明文**:`TokenSet` 序列化为 JSON 串经 Fernet 加密入 `provider_credentials.oauth_enc` +(`encrypt_oauth_bundle`/`decrypt_oauth_bundle`),明文 token 绝不进日志/响应/job 结果。 + +研究确认(对照 `github.com/ooojustin/opencode-kimi` `constants.ts` + `picassio/pi-kimi-coder`): +- client_id `17e5f671-d194-4dfb-9706-5516cb48c098`(env `KIMI_CLIENT_ID` 可覆盖)。 +- device authorization **带 `scope=kimi-code`**(coding-agent OAuth scope):opencode-kimi + `constants.ts` 发送之;kimi-cli v1.41.0 已不发但服务端仍接受。实测省略 scope 拿到的 token + 缺 coding entitlement,调 `api.kimi.com/coding/v1` 回 `401 Invalid Authentication`,故重新带上。 +- scope 只放在 device_authorization 请求上;token 交换/刷新不带 scope(OAuth device flow 惯例)。 +""" + +from __future__ import annotations + +import json +import os +from dataclasses import dataclass +from datetime import UTC, datetime, timedelta +from typing import Any, Protocol + +from ww_shared import AppError, ErrorCode + +from ww_api.security.credentials import decrypt_api_key, encrypt_api_key + +#: Kimi OAuth 端点(device authorization + token)。 +KIMI_AUTH_BASE_URL = "https://auth.kimi.com/api/oauth" +DEVICE_AUTHORIZATION_URL = f"{KIMI_AUTH_BASE_URL}/device_authorization" +TOKEN_URL = f"{KIMI_AUTH_BASE_URL}/token" + +#: 默认 client_id(kimi-cli 公开常量;env `KIMI_CLIENT_ID` 可覆盖)。 +DEFAULT_CLIENT_ID = "17e5f671-d194-4dfb-9706-5516cb48c098" +ENV_CLIENT_ID = "KIMI_CLIENT_ID" + +GRANT_DEVICE_CODE = "urn:ietf:params:oauth:grant-type:device_code" +GRANT_REFRESH_TOKEN = "refresh_token" + +#: coding-agent OAuth scope(device authorization 专用;缺它 token 无 coding entitlement)。 +KIMI_CODE_SCOPE = "kimi-code" + +#: device flow 默认轮询间隔(秒)——服务端未给 `interval` 时的兜底。 +DEFAULT_POLL_INTERVAL = 5 + +#: token 刷新触发缓冲(秒):剩余寿命低于 max(300, 0.5*expires_in) 即刷新。 +MIN_REFRESH_BUFFER_SECONDS = 300 + + +def client_id() -> str: + """当前 OAuth client_id(env `KIMI_CLIENT_ID` 优先,否则公开默认值)。""" + return os.environ.get(ENV_CLIENT_ID) or DEFAULT_CLIENT_ID + + +class AsyncHttpClient(Protocol): + """`run_job`/服务对 HTTP 客户端的**最小**依赖(= `httpx.AsyncClient.post` 子集)。 + + 便于测试注 fake(绝不联网)。运行时传 `httpx.AsyncClient`。 + """ + + async def post(self, url: str, *, data: dict[str, str]) -> HttpResponse: ... + + +class HttpResponse(Protocol): + """HTTP 响应的最小读接口(`httpx.Response` 满足之)。""" + + status_code: int + + def json(self) -> Any: ... + + +@dataclass(frozen=True) +class DeviceAuth: + """device authorization 响应(用户面:展示 user_code + 打开 verification_uri)。""" + + device_code: str + user_code: str + verification_uri: str + verification_uri_complete: str | None + expires_in: int + interval: int + + +@dataclass(frozen=True) +class TokenSet: + """OAuth token 三元组(access 短期 / refresh 长期 / 服务端驱动过期时刻 UTC)。""" + + access_token: str + refresh_token: str + expires_at: datetime + + +class AuthorizationPending(Exception): + """device flow 轮询:用户尚未授权(继续轮询)。""" + + +class SlowDown(Exception): + """device flow 轮询:轮询过快(增大 interval 后继续)。""" + + +def _now() -> datetime: + return datetime.now(UTC) + + +def _expires_at(expires_in: int) -> datetime: + return _now() + timedelta(seconds=max(0, expires_in)) + + +def needs_refresh(token: TokenSet, *, now: datetime | None = None) -> bool: + """判定 access token 是否临近过期(剩余寿命 < `MIN_REFRESH_BUFFER_SECONDS`)。 + + 建网关时按需刷新(§token 刷新启发式);过期时刻已是服务端驱动的绝对时刻,故只需 + 与缓冲比较(无需原始 expires_in:缓冲固定 300s,对 ~15min access 足够)。 + """ + current = now or _now() + remaining = (token.expires_at - current).total_seconds() + return remaining < MIN_REFRESH_BUFFER_SECONDS + + +async def start_device_authorization(http: AsyncHttpClient) -> DeviceAuth: + """发起 device authorization(带 `scope=kimi-code` 以获取 coding entitlement)。""" + resp = await http.post( + DEVICE_AUTHORIZATION_URL, + data={"client_id": client_id(), "scope": KIMI_CODE_SCOPE}, + ) + if resp.status_code >= 400: + raise AppError( + ErrorCode.LLM_UNAVAILABLE, + "Kimi 设备授权请求失败", + {"status": resp.status_code}, + ) + body = resp.json() + return DeviceAuth( + device_code=str(body["device_code"]), + user_code=str(body["user_code"]), + verification_uri=str(body["verification_uri"]), + verification_uri_complete=( + str(body["verification_uri_complete"]) + if body.get("verification_uri_complete") + else None + ), + expires_in=int(body.get("expires_in", 0)), + interval=int(body.get("interval", DEFAULT_POLL_INTERVAL)), + ) + + +def _token_set_from_body(body: dict[str, Any]) -> TokenSet: + return TokenSet( + access_token=str(body["access_token"]), + refresh_token=str(body["refresh_token"]), + expires_at=_expires_at(int(body.get("expires_in", 0))), + ) + + +async def poll_token(http: AsyncHttpClient, device_code: str) -> TokenSet: + """轮询一次 token 端点(调用方按 interval 循环)。 + + `authorization_pending` → 抛 `AuthorizationPending`(继续轮询); + `slow_down` → 抛 `SlowDown`(增大 interval); + `expired_token`/`access_denied`/其它 → 抛 `AppError`(停止失败); + 成功 → `TokenSet`。 + """ + resp = await http.post( + TOKEN_URL, + data={ + "grant_type": GRANT_DEVICE_CODE, + "device_code": device_code, + "client_id": client_id(), + }, + ) + body = resp.json() + if resp.status_code >= 400 or body.get("error"): + error = str(body.get("error", "unknown_error")) + if error == "authorization_pending": + raise AuthorizationPending + if error == "slow_down": + raise SlowDown + # expired_token / access_denied / 其它 → 终止失败。 + raise AppError( + ErrorCode.LLM_UNAVAILABLE, + f"Kimi 设备授权失败:{error}", + {"error": error}, + ) + return _token_set_from_body(body) + + +async def refresh(http: AsyncHttpClient, refresh_token: str) -> TokenSet: + """用 refresh_token 换新 token 组(access 临近过期时建网关触发)。""" + resp = await http.post( + TOKEN_URL, + data={ + "grant_type": GRANT_REFRESH_TOKEN, + "refresh_token": refresh_token, + "client_id": client_id(), + }, + ) + body = resp.json() + if resp.status_code >= 400 or body.get("error"): + error = str(body.get("error", "unknown_error")) + raise AppError( + ErrorCode.LLM_UNAVAILABLE, + f"Kimi token 刷新失败:{error}", + {"error": error}, + ) + return _token_set_from_body(body) + + +def encrypt_oauth_bundle(token: TokenSet, *, key: str) -> bytes: + """把 `TokenSet` 序列化为 JSON 串并 Fernet 加密为 `oauth_enc` 密文。 + + JSON 形 `{access_token, refresh_token, expires_at(ISO8601)}`——明文 token 绝不出此函数。 + """ + bundle = json.dumps( + { + "access_token": token.access_token, + "refresh_token": token.refresh_token, + "expires_at": token.expires_at.isoformat(), + } + ) + return encrypt_api_key(bundle, key=key) + + +def decrypt_oauth_bundle(blob: bytes, *, key: str) -> TokenSet: + """解密 `oauth_enc` 密文回 `TokenSet`(reuse Fernet helper,工作在 str 上)。""" + bundle = json.loads(decrypt_api_key(blob, key=key)) + return TokenSet( + access_token=str(bundle["access_token"]), + refresh_token=str(bundle["refresh_token"]), + expires_at=datetime.fromisoformat(str(bundle["expires_at"])), + ) diff --git a/apps/api/ww_api/services/project_deps.py b/apps/api/ww_api/services/project_deps.py index 3376b0f..ee332fd 100644 --- a/apps/api/ww_api/services/project_deps.py +++ b/apps/api/ww_api/services/project_deps.py @@ -11,40 +11,58 @@ from __future__ import annotations from typing import Annotated +import httpx from fastapi import Depends -from openai import AsyncOpenAI from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from ww_config import get_settings from ww_core.domain import ForeshadowLedgerRepo, SqlForeshadowLedgerRepo from ww_core.domain.chapter_repo import ChapterRepo, SqlChapterRepo +from ww_core.domain.character_repo import CharacterWriteRepo, SqlCharacterWriteRepo from ww_core.domain.digest_repo import DigestAppendRepo, SqlDigestAppendRepo +from ww_core.domain.job_repo import JobRepo, SqlJobRepo from ww_core.domain.outline_write_repo import OutlineWriteRepo, SqlOutlineWriteRepo from ww_core.domain.project_repo import ProjectRepo, SqlProjectRepo -from ww_core.domain.repositories import MemoryRepos +from ww_core.domain.repositories import MemoryRepos, OutlineRepo, RulesRepo from ww_core.domain.review_repo import ReviewRepo, SqlReviewRepo -from ww_core.memory.sql_repositories import sql_memory_repos +from ww_core.domain.rule_repo import RuleWriteRepo, SqlRuleWriteRepo +from ww_core.domain.style_repo import SqlStyleFingerprintWriteRepo, StyleFingerprintWriteRepo +from ww_core.domain.world_entity_repo import SqlWorldEntityWriteRepo, WorldEntityWriteRepo +from ww_core.memory.sql_repositories import SqlOutlineRepo, SqlRulesRepo, sql_memory_repos from ww_db import get_session, get_sessionmaker from ww_db.models import User from ww_llm_gateway import ( Gateway, - OpenAICompatAdapter, + ProviderAdapter, + Route, SqlAlchemyLedgerSink, + build_adapter, + chain_from_routing, resolve_route, ) +from ww_llm_gateway.adapters.kimi_code import KIMI_CODE_PROVIDER from ww_llm_gateway.types import Tier from ww_shared import AppError, ErrorCode +from ww_skills import SkillRegistry, SqlSkillRepo from ww_api.security.credentials import ( CredentialKeyError, decrypt_api_key, ) from ww_api.services.credentials import ( + AUTH_TYPE_OAUTH, STUB_OWNER_ID, CredentialStore, SqlCredentialStore, + StoredCredential, ) from ww_api.services.foreshadow_scan import SessionFactory +from ww_api.services.kimi_oauth import ( + decrypt_oauth_bundle, + encrypt_oauth_bundle, + needs_refresh, +) +from ww_api.services.kimi_oauth import refresh as kimi_refresh from ww_api.services.provider_deps import _PROVIDER_BASE_URLS # 单用户 stub 的占位邮箱(多租户化时由真实主体替换)。 @@ -105,6 +123,16 @@ def get_foreshadow_repo( return SqlForeshadowLedgerRepo(session) +def get_job_repo( + session: Annotated[AsyncSession, Depends(get_session)], +) -> JobRepo: + """长任务写侧 repo(创建/进度/完成/失败;状态写只 flush,提交归 run_job/端点)。 + + 测试经 `app.dependency_overrides[get_job_repo]` 注入 fake(避免真连 DB)。 + """ + return SqlJobRepo(session) + + def get_outline_write_repo( session: Annotated[AsyncSession, Depends(get_session)], ) -> OutlineWriteRepo: @@ -112,6 +140,90 @@ def get_outline_write_repo( return SqlOutlineWriteRepo(session) +def get_rule_write_repo( + session: Annotated[AsyncSession, Depends(get_session)], +) -> RuleWriteRepo: + """规则写侧 repo(POST /rules:作者显式加规则;只 flush,端点提交)。测试经 override 注。""" + return SqlRuleWriteRepo(session) + + +async def get_skill_registry( + session: Annotated[AsyncSession, Depends(get_session)], +) -> SkillRegistry: + """从 `skills` 表加载声明式 skill registry(ARCH §5.6;越权声明 → VALIDATION)。 + + 每请求按 session 加载(registry 不可变快照)。测试经 `app.dependency_overrides` 注 fake repo + 或直接注 `SkillRegistry`。技能库 UI(T5.6)经此读 builtin/custom/community。 + """ + return await SkillRegistry.load(SqlSkillRepo(session)) + + +def get_style_write_repo( + session: Annotated[AsyncSession, Depends(get_session)], +) -> StyleFingerprintWriteRepo: + """文风指纹写侧 repo(`GET /style` 读最新 + 学文风后台任务 append 版本化指纹)。 + + 注:学文风的 `work` 在 `run_job` 自建的独立 session 上自造 repo(请求 session 已关闭), + 故本依赖只服务于 `GET /style` 读侧。测试经 `app.dependency_overrides` 注 fake。 + """ + return SqlStyleFingerprintWriteRepo(session) + + +def get_character_write_repo( + session: Annotated[AsyncSession, Depends(get_session)], +) -> CharacterWriteRepo: + """角色写侧 repo(POST /characters 入库:schema→DB 形变;只 flush,端点提交)。 + + 测试经 `app.dependency_overrides` 注 fake。 + """ + return SqlCharacterWriteRepo(session) + + +def get_world_entity_write_repo( + session: Annotated[AsyncSession, Depends(get_session)], +) -> WorldEntityWriteRepo: + """世界观实体写侧 repo(预留对称入库;当前生成端点只用其形变能力)。""" + return SqlWorldEntityWriteRepo(session) + + +def get_rules_read_repo( + session: Annotated[AsyncSession, Depends(get_session)], +) -> RulesRepo: + """规则读侧 repo(GET /rules 列表,复用 C5 assemble 读侧;测试经 override 注 fake)。""" + return SqlRulesRepo(session) + + +def get_outline_read_repo( + session: Annotated[AsyncSession, Depends(get_session)], +) -> OutlineRepo: + """大纲读侧 repo(GET /outline 列表,复用 C5 assemble 读侧;测试经 override 注 fake)。""" + return SqlOutlineRepo(session) + + +async def get_worldbuilder_gateway( + session: Annotated[AsyncSession, Depends(get_session)], +) -> Gateway: + """世界观生成(writer 档位)的可注入网关缝。测试经 override 注 mock(产 WorldGenResult)。""" + store = SqlCredentialStore(session) + return await build_gateway_for_tier(session, store, "writer") + + +async def get_character_gen_gateway( + session: Annotated[AsyncSession, Depends(get_session)], +) -> Gateway: + """角色生成(writer 档位)的可注入网关缝。测试经 override 注 mock(产 CharacterGenResult)。""" + store = SqlCredentialStore(session) + return await build_gateway_for_tier(session, store, "writer") + + +async def get_precheck_gateway( + session: Annotated[AsyncSession, Depends(get_session)], +) -> Gateway: + """入库前 continuity 预检(analyst 档位)的可注入网关缝。测试注 mock(产 ContinuityReview)。""" + store = SqlCredentialStore(session) + return await build_gateway_for_tier(session, store, "analyst") + + def get_session_factory() -> SessionFactory: """验收后到期扫描的**独立 session 工厂**缝。 @@ -122,42 +234,125 @@ def get_session_factory() -> SessionFactory: return get_sessionmaker() -async def build_gateway_for_tier( - session: AsyncSession, store: CredentialStore, tier: Tier -) -> Gateway: - """据指定档位路由解密对应 provider 凭据 → 建网关(解析器仍为全局 `resolve_route`)。 +async def _build_provider_adapter(store: CredentialStore, provider: str) -> ProviderAdapter | None: + """据 provider 解密凭据 → 经 `build_adapter` 工厂建对应 provider 适配器(T5.4 follow-up)。 - 无凭据/未知 provider → `LLM_UNAVAILABLE`(友好提示,前端引导去配置)。 - 解析器用 `resolve_route`(按 tier 路由);这里只决定**要预备哪个 provider 的适配器**。 + `build_adapter(provider, *, api_key, base_url=None)` 按 provider 选适配器类: + OpenAI 兼容(deepseek/kimi/qwen/glm/openai)经 `base_url` 走 OpenAI 兼容适配器; + Anthropic/Gemini 走各自原生适配器(无需 `base_url`);`kimi-code` 走 OAuth bearer + + coding base + 伪造头(**access token 经 `_resolve_kimi_code_token` 按需刷新**,K1.3)。 + + 返回 `None` 表示该 provider 未配置凭据——回退链里缺位时网关会跳到下一个,故宽容返回 + None(不直接抛)。 """ - settings = get_settings() - route = resolve_route(tier) - base_url = _PROVIDER_BASE_URLS.get(route.provider) - if base_url is None: - raise AppError( - ErrorCode.LLM_UNAVAILABLE, - f"{tier} 档位 provider {route.provider} 暂不支持", - {"provider": route.provider, "tier": tier}, - ) - cred = await store.get_credential(STUB_OWNER_ID, route.provider) + cred = await store.get_credential(STUB_OWNER_ID, provider) if cred is None: - raise AppError( - ErrorCode.LLM_UNAVAILABLE, - f"{tier} 档位 provider {route.provider} 未配置凭据,请先在设置中配置", - {"provider": route.provider, "tier": tier}, + return None + settings = get_settings() + + if cred.auth_type == AUTH_TYPE_OAUTH or provider == KIMI_CODE_PROVIDER: + # OAuth 凭据(Kimi Code):解密 token 包 → 临近过期则刷新并持久化 → access token + # 当 api_key 喂工厂(工厂为 kimi-code 构建带伪造头 + coding base 的客户端)。 + access_token = await _resolve_kimi_code_token(store, cred, settings.credential_enc_key) + return build_adapter( + provider, api_key=access_token, base_url=_PROVIDER_BASE_URLS.get(provider) ) + + if cred.api_key_enc is None: + # api_key 凭据但密文缺失(数据不一致)——视作未配置,回退链跳过。 + return None try: api_key = decrypt_api_key(cred.api_key_enc, key=settings.credential_enc_key) except CredentialKeyError as exc: raise AppError(ErrorCode.INTERNAL, str(exc)) from exc + # OpenAI 兼容 provider 需 base_url;Anthropic/Gemini 走原生 SDK(base_url=None)。 + base_url = _PROVIDER_BASE_URLS.get(provider) + return build_adapter(provider, api_key=api_key, base_url=base_url) - client = AsyncOpenAI(api_key=api_key, base_url=base_url) - adapter = OpenAICompatAdapter(provider=route.provider, client=client) - return Gateway( - adapters={route.provider: adapter}, - ledger=SqlAlchemyLedgerSink(session), - resolver=resolve_route, - ) + +async def _resolve_kimi_code_token( + store: CredentialStore, cred: StoredCredential, enc_key: str +) -> str: + """解密 Kimi Code OAuth token 包 → 临近过期时刷新并持久化 → 返回当前 access token。 + + 刷新经一个**临时 httpx 客户端**(与 token 端点交互);新 token 包经 + `store.upsert_oauth_credential` 持久化(下次建网关复用刷新结果)。明文 token 绝不进 + 日志/响应。无 `oauth_enc` → `LLM_UNAVAILABLE`(未连接 Kimi Code)。 + """ + if cred.oauth_enc is None: + raise AppError( + ErrorCode.LLM_UNAVAILABLE, + f"{KIMI_CODE_PROVIDER} 未连接(无 OAuth 凭据),请先在设置中连接 Kimi Code", + {"provider": KIMI_CODE_PROVIDER}, + ) + try: + token = decrypt_oauth_bundle(cred.oauth_enc, key=enc_key) + except CredentialKeyError as exc: + raise AppError(ErrorCode.INTERNAL, str(exc)) from exc + + if not needs_refresh(token): + return token.access_token + + # 临近过期 → 刷新并持久化新包。 + async with httpx.AsyncClient() as http: + refreshed = await kimi_refresh(http, token.refresh_token) + new_blob = encrypt_oauth_bundle(refreshed, key=enc_key) + await store.upsert_oauth_credential(STUB_OWNER_ID, KIMI_CODE_PROVIDER, new_blob) + return refreshed.access_token + + +async def build_gateway_for_tier( + session: AsyncSession, store: CredentialStore, tier: Tier +) -> Gateway: + """据指定档位路由 + DB `tier_routing.fallback` 装配**多 provider 回退链**网关(T5.4 接线)。 + + 流程(§4.3 三级解析 / §4.5 回退链): + 1. 读 DB `tier_routing` 取该 tier 的 primary `provider:model` + fallback 列表(缺则退回 + 全局 `resolve_route`,单 provider,向后兼容)。 + 2. 为 primary + fallback 里**每个能建出适配器**的 provider 预备 OpenAI 兼容适配器 + (未知 base_url / 未配凭据的 provider 跳过——回退链自然绕过它)。 + 3. 至少要有一个可用适配器,否则 `LLM_UNAVAILABLE`(无任何凭据可用)。 + 4. 注入 `chain_resolver=chain_from_routing(...)`(多元素链,启用回退);无 DB 路由时 + 用 `resolver=resolve_route`(单路由,M1 行为不变)。 + + 单 provider 配置仍走单元素链(网关把它当无回退处理),**不破既有行为**。 + """ + ledger = SqlAlchemyLedgerSink(session) + + stored = next((r for r in await store.list_routing() if r.tier == tier), None) + if stored is None: + # 无 DB 路由行:退回全局默认(单 provider,M1 兼容)。 + route = resolve_route(tier) + adapter = await _build_provider_adapter(store, route.provider) + if adapter is None: + raise AppError( + ErrorCode.LLM_UNAVAILABLE, + f"{tier} 档位 provider {route.provider} 未配置凭据,请先在设置中配置", + {"provider": route.provider, "tier": tier}, + ) + return Gateway(adapters={route.provider: adapter}, ledger=ledger, resolver=resolve_route) + + # DB 路由:primary + fallback 构链;为每个可建的 provider 预备适配器。 + primary_spec = f"{stored.provider}:{stored.model}" + chain: list[Route] = chain_from_routing(tier, primary_spec, list(stored.fallback)) + adapters: dict[str, ProviderAdapter] = {} + for route in chain: + if route.provider in adapters: + continue + built = await _build_provider_adapter(store, route.provider) + if built is not None: + adapters[route.provider] = built + if not adapters: + raise AppError( + ErrorCode.LLM_UNAVAILABLE, + f"{tier} 档位无任何已配置凭据的 provider,请先在设置中配置", + {"providers": [r.provider for r in chain], "tier": tier}, + ) + + def _resolver(_tier: Tier) -> list[Route]: + return chain_from_routing(_tier, primary_spec, list(stored.fallback)) + + return Gateway(adapters=adapters, ledger=ledger, chain_resolver=_resolver) async def build_writer_gateway(session: AsyncSession, store: CredentialStore) -> Gateway: @@ -195,3 +390,25 @@ async def get_outline_gateway( """大纲生成(analyst 档位)的可注入网关缝。测试经 override 注 mock(产 OutlineResult)。""" store = SqlCredentialStore(session) return await build_gateway_for_tier(session, store, "analyst") + + +async def get_style_extract_gateway( + session: Annotated[AsyncSession, Depends(get_session)], +) -> Gateway: + """学文风提取(analyst 档位)的可注入网关缝。 + + `POST /style` 在 dep 解析阶段构建网关 → 无凭据时这里抛 `LLM_UNAVAILABLE`(503, + 调度 job 之前拦下,避免凭空写一行注定失败的 job)。提取本体在 BackgroundTask 里 + 用 `run_job` 自建的独立 session 重新构网关跑(请求 session 已关闭);本依赖确保 + 凭据探测在请求阶段发生。测试经 override 注 mock(产 StyleFingerprintResult)。 + """ + store = SqlCredentialStore(session) + return await build_gateway_for_tier(session, store, "analyst") + + +async def get_refine_gateway( + session: Annotated[AsyncSession, Depends(get_session)], +) -> Gateway: + """回炉(writer 档位)的可注入网关缝。测试经 override 注 mock(产纯文本重写段)。""" + store = SqlCredentialStore(session) + return await build_gateway_for_tier(session, store, "writer") diff --git a/apps/api/ww_api/services/provider_deps.py b/apps/api/ww_api/services/provider_deps.py index a82c946..8fbae9d 100644 --- a/apps/api/ww_api/services/provider_deps.py +++ b/apps/api/ww_api/services/provider_deps.py @@ -34,6 +34,10 @@ _PROVIDER_BASE_URLS: dict[str, str] = { "qwen": "https://dashscope.aliyuncs.com/compatible-mode/v1", "glm": "https://open.bigmodel.cn/api/paas/v4", "openai": "https://api.openai.com/v1", + # Kimi 订阅 plan(OAuth device-flow,K1.3):coding 端点(OpenAI 兼容 + 伪造头)。 + "kimi-code": "https://api.kimi.com/coding/v1", + # Kimi 订阅 plan(静态 Console Key,ToS 合规):同一 coding 端点,纯 bearer 无伪造头。 + "kimi-code-key": "https://api.kimi.com/coding/v1", } @@ -68,6 +72,13 @@ class GatewayProviderProbe: f"未知提供商 {provider}", {"provider": provider}, ) + if cred.api_key_enc is None: + # api_key 探测不支持 OAuth 凭据(无 api_key 密文)——OAuth provider 走专属端点。 + raise AppError( + ErrorCode.VALIDATION, + f"provider {provider} 为 OAuth 凭据,不支持 api_key 连接测试", + {"provider": provider}, + ) try: api_key = decrypt_api_key(cred.api_key_enc, key=self._enc_key) except CredentialKeyError as exc: diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx index c8daec6..2170895 100644 --- a/apps/web/app/layout.tsx +++ b/apps/web/app/layout.tsx @@ -1,6 +1,7 @@ import type { Metadata } from "next"; import "./globals.css"; +import { CommandPaletteMount } from "@/components/command/CommandPaletteMount"; import { ToastProvider } from "@/components/Toast"; export const metadata: Metadata = { @@ -16,7 +17,10 @@ export default function RootLayout({ return ( - {children} + + {children} + + ); diff --git a/apps/web/app/projects/[id]/codex/page.tsx b/apps/web/app/projects/[id]/codex/page.tsx new file mode 100644 index 0000000..09f7619 --- /dev/null +++ b/apps/web/app/projects/[id]/codex/page.tsx @@ -0,0 +1,42 @@ +import { notFound } from "next/navigation"; + +import { CodexPage } from "@/components/codex/CodexPage"; +import { + fetchCharacters, + fetchProject, + fetchWorldEntities, +} from "@/lib/api/server"; + +interface PageProps { + params: Promise<{ id: string }>; + searchParams: Promise<{ gen?: string }>; +} + +// 设定库 Codex 页(UX §6.5)。Server Component 取项目 + 跨会话全量人物/世界观真源; +// CodexPage(Client)承载生成/管理 + 本会话新入库回显。 +// ?gen=character|world 由命令面板动作跳转,进页直开对应生成器 tab。 +export default async function CodexRoutePage({ + params, + searchParams, +}: PageProps) { + const { id } = await params; + const { gen } = await searchParams; + const initialTab = gen === "world" ? "world" : "characters"; + try { + const [project, characters, worldEntities] = await Promise.all([ + fetchProject(id), + fetchCharacters(id), + fetchWorldEntities(id), + ]); + return ( + + ); + } catch { + notFound(); + } +} diff --git a/apps/web/app/projects/[id]/outline/page.tsx b/apps/web/app/projects/[id]/outline/page.tsx index 9044ed9..3a12da2 100644 --- a/apps/web/app/projects/[id]/outline/page.tsx +++ b/apps/web/app/projects/[id]/outline/page.tsx @@ -1,19 +1,23 @@ import { notFound } from "next/navigation"; import { OutlineEditor } from "@/components/outline/OutlineEditor"; -import { fetchProject } from "@/lib/api/server"; +import { fetchOutline, fetchProject } from "@/lib/api/server"; interface PageProps { params: Promise<{ id: string }>; } -// 大纲编辑器页(UX §6.7)。Server Component 取项目;OutlineEditor(Client)按需 -// 触发 POST .../outline 生成大纲(M3 无 GET 大纲端点,进页空、生成后填充)。 +// 大纲编辑器页(UX §6.7)。Server Component 取项目 + 已存大纲(GET .../outline, +// 空/错误降级为空列表)作为初始章节,重访时显已生成的大纲;OutlineEditor(Client) +// 仍按需触发 POST .../outline 生成/覆盖所显章节。 export default async function OutlinePage({ params }: PageProps) { const { id } = await params; try { const project = await fetchProject(id); - return ; + const initialChapters = await fetchOutline(id); + return ( + + ); } catch { notFound(); } diff --git a/apps/web/app/projects/[id]/review/page.tsx b/apps/web/app/projects/[id]/review/page.tsx index ff19a8e..5706c80 100644 --- a/apps/web/app/projects/[id]/review/page.tsx +++ b/apps/web/app/projects/[id]/review/page.tsx @@ -1,7 +1,7 @@ import { notFound } from "next/navigation"; import { ReviewReport } from "@/components/review/ReviewReport"; -import { fetchProject, fetchReviews } from "@/lib/api/server"; +import { fetchDraft, fetchProject, fetchReviews } from "@/lib/api/server"; import { latestReview } from "@/lib/review/history"; interface PageProps { @@ -20,12 +20,14 @@ export default async function ReviewPage({ params, searchParams }: PageProps) { try { const project = await fetchProject(id); const history = await fetchReviews(id, chapterNo); + // 终稿初值取已存草稿(GET .../draft,404→null→"");否则 final_text 为空 → accept 422。 + const draft = await fetchDraft(id, chapterNo); return ( ); } catch { diff --git a/apps/web/app/projects/[id]/rules/page.tsx b/apps/web/app/projects/[id]/rules/page.tsx new file mode 100644 index 0000000..446eb92 --- /dev/null +++ b/apps/web/app/projects/[id]/rules/page.tsx @@ -0,0 +1,20 @@ +import { notFound } from "next/navigation"; + +import { RulesPage } from "@/components/rules/RulesPage"; +import { fetchProject, fetchRules } from "@/lib/api/server"; + +interface PageProps { + params: Promise<{ id: string }>; +} + +// 规则页(UX §7)。Server Component 取项目 + 规则列表;RulesPage(Client)承载新增。 +export default async function RulesRoutePage({ params }: PageProps) { + const { id } = await params; + try { + const project = await fetchProject(id); + const rules = await fetchRules(id); + return ; + } catch { + notFound(); + } +} diff --git a/apps/web/app/projects/[id]/skills/page.tsx b/apps/web/app/projects/[id]/skills/page.tsx new file mode 100644 index 0000000..00c4d39 --- /dev/null +++ b/apps/web/app/projects/[id]/skills/page.tsx @@ -0,0 +1,20 @@ +import { notFound } from "next/navigation"; + +import { SkillsPage } from "@/components/skills/SkillsPage"; +import { fetchProject, fetchSkills } from "@/lib/api/server"; + +interface PageProps { + params: Promise<{ id: string }>; +} + +// 技能库页(UX §7)。Server Component 取项目 + 技能注册表(只读)。 +export default async function SkillsRoutePage({ params }: PageProps) { + const { id } = await params; + try { + const project = await fetchProject(id); + const skills = await fetchSkills(); + return ; + } catch { + notFound(); + } +} diff --git a/apps/web/app/projects/[id]/style/page.tsx b/apps/web/app/projects/[id]/style/page.tsx new file mode 100644 index 0000000..b4cf393 --- /dev/null +++ b/apps/web/app/projects/[id]/style/page.tsx @@ -0,0 +1,27 @@ +import { notFound } from "next/navigation"; + +import { StylePage } from "@/components/style/StylePage"; +import { fetchProject, fetchStyleFingerprint } from "@/lib/api/server"; +import { normalizeFingerprint } from "@/lib/style/style"; + +interface PageProps { + params: Promise<{ id: string }>; +} + +// 文风页(UX §6.9)。Server Component 取项目 + 最新指纹(GET /style,无则 null); +// StylePage(Client)承载上传/学文风/指纹展示交互。 +export default async function StyleRoutePage({ params }: PageProps) { + const { id } = await params; + try { + const project = await fetchProject(id); + const fingerprint = await fetchStyleFingerprint(id); + return ( + + ); + } catch { + notFound(); + } +} diff --git a/apps/web/app/projects/[id]/write/page.tsx b/apps/web/app/projects/[id]/write/page.tsx index 696a8a1..6f653be 100644 --- a/apps/web/app/projects/[id]/write/page.tsx +++ b/apps/web/app/projects/[id]/write/page.tsx @@ -1,19 +1,36 @@ import { notFound } from "next/navigation"; import { Workbench } from "@/components/workbench/Workbench"; -import { fetchProject } from "@/lib/api/server"; +import { fetchDraft, fetchProject } from "@/lib/api/server"; +import type { ProjectResponse } from "@/lib/api/types"; +import { WORKBENCH_CHAPTER_NO } from "@/lib/workbench/chapter"; interface PageProps { params: Promise<{ id: string }>; } -// 写作工作台页(UX §6.3)。Server Component 取项目,Workbench 负责编辑/流式/保存。 +// 写作工作台页(UX §6.3)。Server Component 取项目 + 当前章已存草稿(GET .../draft, +// 404→null=空编辑器),把正文作为编辑器初值种入,重访时重载已写章节;Workbench +// 负责编辑/流式/保存(流式照常覆盖初值)。 export default async function WritePage({ params }: PageProps) { const { id } = await params; + + // 仅当项目确实取不到时才判 404;草稿/瞬时错误不应把整页变成「找不到页面」。 + let project: ProjectResponse; try { - const project = await fetchProject(id); - return ; + project = await fetchProject(id); } catch { notFound(); } + + // 草稿读取失败(后端瞬时错误 / 尚无草稿)→ 空编辑器初值,绝不 404。 + let initialText = ""; + try { + const draft = await fetchDraft(id, WORKBENCH_CHAPTER_NO); + initialText = draft?.content ?? ""; + } catch { + initialText = ""; + } + + return ; } diff --git a/apps/web/app/settings/providers/page.tsx b/apps/web/app/settings/providers/page.tsx index 1737780..7b511ab 100644 --- a/apps/web/app/settings/providers/page.tsx +++ b/apps/web/app/settings/providers/page.tsx @@ -1,14 +1,26 @@ import { AppShell } from "@/components/AppShell"; import { ProvidersSettings } from "@/components/settings/ProvidersSettings"; -import { fetchProviders } from "@/lib/api/server"; +import { fetchKimiOauthStatus, fetchProviders } from "@/lib/api/server"; import type { ProvidersResponse } from "@/lib/api/types"; -// 模型与提供商设置(UX §6.10)。Server Component 取初始数据。 +// 模型与提供商设置(UX §6.10)。Server Component 取初始数据(含 Kimi Code OAuth 状态)。 export default async function ProvidersSettingsPage() { let initial: ProvidersResponse = { providers: [], tier_routing: [] }; + let kimiOauth = { connected: false, expiresAt: null as string | null }; let loadError = false; try { - initial = await fetchProviders(); + const [providers, oauthStatus] = await Promise.all([ + fetchProviders(), + fetchKimiOauthStatus(), + ]); + initial = providers; + kimiOauth = { + connected: oauthStatus.connected === true, + expiresAt: + typeof oauthStatus.expires_at === "string" + ? oauthStatus.expires_at + : null, + }; } catch { loadError = true; } @@ -21,7 +33,7 @@ export default async function ProvidersSettingsPage() { 无法连接后端服务,请确认 API 已启动后刷新。

) : ( - + )} diff --git a/apps/web/components/AppShell.tsx b/apps/web/components/AppShell.tsx index ef2afd5..5af827d 100644 --- a/apps/web/components/AppShell.tsx +++ b/apps/web/components/AppShell.tsx @@ -1,7 +1,7 @@ import Link from "next/link"; import type { ReactNode } from "react"; -import { LeftNav } from "./LeftNav"; +import { LeftNav, type ActiveNav } from "./LeftNav"; interface AppShellProps { children: ReactNode; @@ -11,7 +11,7 @@ interface AppShellProps { // 项目内页给出 projectId → 左导航展开项目级入口(大纲/伏笔/审稿,UX §3.1)。 projectId?: string; // 当前激活的项目级入口键(用于高亮)。 - activeNav?: "write" | "outline" | "foreshadow" | "review"; + activeNav?: ActiveNav; } // 全局外壳:顶栏(64px) + 左导航 + 主区(UX §5.1)。 diff --git a/apps/web/components/LeftNav.tsx b/apps/web/components/LeftNav.tsx index 376f17f..65fafb4 100644 --- a/apps/web/components/LeftNav.tsx +++ b/apps/web/components/LeftNav.tsx @@ -3,7 +3,15 @@ import Link from "next/link"; import { usePathname } from "next/navigation"; -export type ActiveNav = "write" | "outline" | "foreshadow" | "review"; +export type ActiveNav = + | "write" + | "outline" + | "foreshadow" + | "review" + | "style" + | "codex" + | "rules" + | "skills"; interface LeftNavProps { // 项目内页传 projectId → 展开项目级入口(大纲/伏笔/审稿,UX §3.1)。 @@ -50,8 +58,30 @@ function projectItems(projectId: string): NavItem[] { enabled: true, key: "review", }, - { href: "#codex", label: "设定库", enabled: false }, - { href: "#style", label: "文风", enabled: false }, + { + href: `/projects/${projectId}/style`, + label: "文风", + enabled: true, + key: "style", + }, + { + href: `/projects/${projectId}/codex`, + label: "设定库", + enabled: true, + key: "codex", + }, + { + href: `/projects/${projectId}/rules`, + label: "规则", + enabled: true, + key: "rules", + }, + { + href: `/projects/${projectId}/skills`, + label: "技能库", + enabled: true, + key: "skills", + }, ]; } diff --git a/apps/web/components/codex/CodexPage.tsx b/apps/web/components/codex/CodexPage.tsx new file mode 100644 index 0000000..803cb8c --- /dev/null +++ b/apps/web/components/codex/CodexPage.tsx @@ -0,0 +1,167 @@ +"use client"; + +import { useMemo, useState } from "react"; + +import { AppShell } from "@/components/AppShell"; +import { CharacterGenerator } from "@/components/generation/CharacterGenerator"; +import { WorldGenerator } from "@/components/generation/WorldGenerator"; +import type { + CharacterCardView, + ProjectResponse, + WorldEntityCardView, +} from "@/lib/api/types"; +import { + mergeCharacterCards, + worldEntityRules, +} from "@/lib/generation/cards"; + +type CodexTab = "characters" | "world" | "timeline"; + +interface CodexPageProps { + project: ProjectResponse; + // 后端读端点(GET .../characters / .../world_entities)拉来的跨会话全量真源。 + initialCharacters: CharacterCardView[]; + initialWorldEntities: WorldEntityCardView[]; + // 进页可经 ?gen=character|world 直接打开对应生成器(命令面板动作跳转)。 + initialTab?: CodexTab; +} + +const TABS: { key: CodexTab; label: string }[] = [ + { key: "characters", label: "人物" }, + { key: "world", label: "世界观" }, + { key: "timeline", label: "时间线" }, +]; + +// 设定库 Codex(UX §6.5):管理人物 / 世界观 / 时间线。 +// 初始列表来自后端读端点(跨会话全量真源);本会话新入库的角色卡再合并补显, +// 故刷新后不再为空、且不与真源重复(按 name 去重,见 mergeCharacterCards)。 +// 世界观本期无入库端点(仅生成预览)→ 展示真源 + 生成器预览。时间线为 P2/派生,暂占位。 +export function CodexPage({ + project, + initialCharacters, + initialWorldEntities, + initialTab = "characters", +}: CodexPageProps) { + const [tab, setTab] = useState(initialTab); + // 本会话内新入库的角色(即时回显,无需刷新即见)。 + const [sessionCards, setSessionCards] = useState([]); + + // 真源(初始全量)+ 本会话新卡,按 name 去重合并。 + const characters = useMemo( + () => mergeCharacterCards(initialCharacters, sessionCards), + [initialCharacters, sessionCards], + ); + + return ( + +
+
+ {TABS.map((t) => ( + + ))} +
+ +
+ {tab === "characters" ? ( +
+
+

+ 已入库人物({characters.length}) +

+ {characters.length > 0 ? ( +
    + {characters.map((c, i) => ( +
  • + {c.name}({c.role}) +
  • + ))} +
+ ) : ( +

+ 暂无入库人物,先在下方生成并入库。 +

+ )} +
+ + setSessionCards((prev) => [...prev, ...cards]) + } + /> +
+ ) : null} + + {tab === "world" ? ( +
+
+

+ 已入库世界观({initialWorldEntities.length}) +

+ {initialWorldEntities.length > 0 ? ( +
+ {initialWorldEntities.map((entity, i) => ( +
+
+ + {entity.name} + + + {entity.type} + +
+ {worldEntityRules(entity).length > 0 ? ( +
    + {worldEntityRules(entity).map((rule, j) => ( +
  • {rule}
  • + ))} +
+ ) : ( +

(无显式硬规则)

+ )} +
+ ))} +
+ ) : ( +

+ 暂无入库世界观,可在下方生成预览参考。 +

+ )} +
+ +
+ ) : null} + + {tab === "timeline" ? ( +
+ 时间线为派生视图(P2),将由章节摘要 + 伏笔窗口自动汇总,敬请期待。 +
+ ) : null} +
+
+
+ ); +} diff --git a/apps/web/components/command/CommandPalette.tsx b/apps/web/components/command/CommandPalette.tsx new file mode 100644 index 0000000..786cf60 --- /dev/null +++ b/apps/web/components/command/CommandPalette.tsx @@ -0,0 +1,152 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useRouter } from "next/navigation"; + +import { + filterCommands, + globalCommands, + moveHighlight, + projectCommands, + type Command, +} from "@/lib/command/palette"; + +// 从 pathname 抽取当前 projectId(/projects//...)。 +function projectIdFromPath(pathname: string): string | null { + const m = pathname.match(/^\/projects\/([^/]+)/); + return m ? m[1] : null; +} + +interface CommandPaletteProps { + pathname: string; +} + +// 命令面板(⌘K,UX §7):键盘触发的快速导航/动作。 +// 焦点管理(打开自动聚焦输入框、Esc 关闭、上下选择、回车执行)+ a11y(role=dialog/listbox)。 +export function CommandPalette({ pathname }: CommandPaletteProps) { + const router = useRouter(); + const [open, setOpen] = useState(false); + const [query, setQuery] = useState(""); + const [highlight, setHighlight] = useState(0); + const inputRef = useRef(null); + + const projectId = projectIdFromPath(pathname); + const commands = useMemo( + () => + projectId + ? [...projectCommands(projectId), ...globalCommands()] + : globalCommands(), + [projectId], + ); + const results = useMemo( + () => filterCommands(commands, query), + [commands, query], + ); + + const close = useCallback((): void => { + setOpen(false); + setQuery(""); + setHighlight(0); + }, []); + + const run = useCallback( + (cmd: Command | undefined): void => { + if (!cmd) return; + close(); + if (cmd.href) router.push(cmd.href); + }, + [close, router], + ); + + // ⌘K / Ctrl+K 全局开关。 + useEffect(() => { + const onKey = (e: KeyboardEvent): void => { + if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") { + e.preventDefault(); + setOpen((prev) => !prev); + } + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, []); + + // 打开时聚焦输入框;切查询重置高亮。 + useEffect(() => { + if (open) inputRef.current?.focus(); + }, [open]); + useEffect(() => { + setHighlight(0); + }, [query]); + + if (!open) return null; + + const onInputKey = (e: React.KeyboardEvent): void => { + if (e.key === "Escape") { + e.preventDefault(); + close(); + } else if (e.key === "ArrowDown") { + e.preventDefault(); + setHighlight((h) => moveHighlight(h, 1, results.length)); + } else if (e.key === "ArrowUp") { + e.preventDefault(); + setHighlight((h) => moveHighlight(h, -1, results.length)); + } else if (e.key === "Enter") { + e.preventDefault(); + run(results[highlight]); + } + }; + + return ( +
+
e.stopPropagation()} + > + setQuery(e.target.value)} + onKeyDown={onInputKey} + placeholder="搜索命令或页面…(写本章 / 审稿 / 生成角色 / 跳转伏笔 / 搜设定)" + aria-label="命令搜索" + aria-controls="command-list" + className="w-full border-b border-line bg-bg px-4 py-3 text-sm text-ink outline-none" + /> +
    + {results.length === 0 ? ( +
  • 无匹配命令
  • + ) : ( + results.map((cmd, i) => ( +
  • setHighlight(i)} + onClick={() => run(cmd)} + className={`flex cursor-pointer items-center justify-between px-4 py-2 text-sm ${ + i === highlight + ? "bg-[var(--color-cinnabar-wash)] text-cinnabar" + : "text-ink" + }`} + > + {cmd.title} + {cmd.group} +
  • + )) + )} +
+
+
+ ); +} diff --git a/apps/web/components/command/CommandPaletteMount.tsx b/apps/web/components/command/CommandPaletteMount.tsx new file mode 100644 index 0000000..986efd2 --- /dev/null +++ b/apps/web/components/command/CommandPaletteMount.tsx @@ -0,0 +1,12 @@ +"use client"; + +import { usePathname } from "next/navigation"; + +import { CommandPalette } from "./CommandPalette"; + +// 全局挂载命令面板(⌘K):读当前 pathname 注入项目上下文。 +// 放在 RootLayout 内,对所有页面生效。 +export function CommandPaletteMount() { + const pathname = usePathname(); + return ; +} diff --git a/apps/web/components/generation/CharacterCardItem.tsx b/apps/web/components/generation/CharacterCardItem.tsx new file mode 100644 index 0000000..8228dd3 --- /dev/null +++ b/apps/web/components/generation/CharacterCardItem.tsx @@ -0,0 +1,97 @@ +"use client"; + +import type { CharacterCardView } from "@/lib/api/types"; + +interface CharacterCardItemProps { + card: CharacterCardView; + selected: boolean; + onToggle: () => void; + // 入库前作者就地编辑名/弧光(最小可编辑面,避免重型表单)。 + onEdit?: (patch: Partial) => void; +} + +// 单张角色预览卡(UX §6.6):勾选入库 + 关键字段就地编辑。 +export function CharacterCardItem({ + card, + selected, + onToggle, + onEdit, +}: CharacterCardItemProps) { + return ( +
+
+ + {onEdit ? ( + onEdit({ name: e.target.value })} + aria-label="角色名" + className="flex-1 rounded border border-line bg-bg px-2 py-1 font-serif text-base text-ink" + /> + ) : ( + {card.name} + )} + + {card.role} + +
+ + {card.traits && card.traits.length > 0 ? ( +

+ 特质: + {card.traits.join("、")} +

+ ) : null} + +

+ 背景: + {card.backstory} +

+ +
+ 弧光: + {onEdit ? ( + onEdit({ arc: e.target.value })} + aria-label="人物弧光" + className="flex-1 rounded border border-line bg-bg px-2 py-1 text-ink" + /> + ) : ( + {card.arc} + )} +
+ + {card.speech_tics && card.speech_tics.length > 0 ? ( +

+ 口癖: + {card.speech_tics.join("、")} +

+ ) : null} + + {card.relations && card.relations.length > 0 ? ( +
    + {card.relations.map((r, i) => ( +
  • + {r.kind}:{r.name} + {r.note ? `(${r.note})` : ""} +
  • + ))} +
+ ) : null} +
+ ); +} diff --git a/apps/web/components/generation/CharacterGenerator.tsx b/apps/web/components/generation/CharacterGenerator.tsx new file mode 100644 index 0000000..2e3e509 --- /dev/null +++ b/apps/web/components/generation/CharacterGenerator.tsx @@ -0,0 +1,171 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useState } from "react"; + +import type { CharacterCardView } from "@/lib/api/types"; +import { + MAX_CHARACTER_COUNT, + MIN_CHARACTER_COUNT, + updateCard, +} from "@/lib/generation/cards"; +import { useCharacterGen } from "@/lib/generation/useCharacterGen"; +import { CharacterCardItem } from "./CharacterCardItem"; +import { ConflictAdjudication } from "./ConflictAdjudication"; + +interface CharacterGeneratorProps { + projectId: string; + // 入库成功后回调(如刷新 Codex 本地列表)。 + onIngested?: (created: string[], cards: CharacterCardView[]) => void; +} + +// 角色生成器(UX §6.6):一句话需求 + 数量 + 定位 → 预览卡 → 选/改 → 入库(处理 409 裁决)。 +export function CharacterGenerator({ + projectId, + onIngested, +}: CharacterGeneratorProps) { + const gen = useCharacterGen(); + const [brief, setBrief] = useState(""); + const [count, setCount] = useState(MIN_CHARACTER_COUNT); + const [role, setRole] = useState(""); + // 编辑态卡片(预览返回后拷贝进本地,允许就地改)。 + const [drafts, setDrafts] = useState([]); + const [selected, setSelected] = useState>(new Set()); + + // 预览返回 → 同步进本地编辑态 + 默认全选。 + const syncDrafts = useCallback((cards: CharacterCardView[]) => { + setDrafts(cards); + setSelected(new Set(cards.map((_, i) => i))); + }, []); + + const onGenerate = useCallback(async () => { + await gen.generate({ projectId, brief, count, role }); + }, [gen, projectId, brief, count, role]); + + // gen.cards 变化(新预览)→ 重新同步本地草稿。 + const previewKey = gen.cards.map((c) => c.name).join("|"); + useEffect(() => { + if (gen.genStatus === "preview") syncDrafts(gen.cards); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [previewKey, gen.genStatus]); + + const toggle = (i: number): void => { + setSelected((prev) => { + const next = new Set(prev); + if (next.has(i)) next.delete(i); + else next.add(i); + return next; + }); + }; + + const selectedCards = useMemo( + () => drafts.filter((_, i) => selected.has(i)), + [drafts, selected], + ); + + const doIngest = useCallback( + async (acknowledge: boolean) => { + const ok = await gen.ingest(projectId, selectedCards, acknowledge); + if (ok) { + onIngested?.(gen.created, selectedCards); + setDrafts([]); + setSelected(new Set()); + } + }, + [gen, projectId, selectedCards, onIngested], + ); + + const generating = gen.genStatus === "generating"; + const ingesting = gen.ingestStatus === "ingesting"; + + return ( +
+
+

角色生成器

+ +
+ + + +
+
+ + {gen.genStatus === "preview" && drafts.length > 0 ? ( +
+

+ 已生成 {drafts.length} 张,选 {selectedCards.length} 张入库(可就地编辑名/弧光)。 +

+
+ {drafts.map((card, i) => ( + toggle(i)} + onEdit={(patch) => + setDrafts((prev) => + prev.map((c, j) => (j === i ? updateCard(c, patch) : c)), + ) + } + /> + ))} +
+ + {gen.ingestStatus === "conflict" && gen.conflicts ? ( + void doIngest(true)} + onCancel={() => gen.reset()} + /> + ) : ( + + )} +
+ ) : null} + + {gen.ingestStatus === "done" && gen.created.length > 0 ? ( +

已入库:{gen.created.join("、")}

+ ) : null} +
+ ); +} diff --git a/apps/web/components/generation/ConflictAdjudication.tsx b/apps/web/components/generation/ConflictAdjudication.tsx new file mode 100644 index 0000000..c21a706 --- /dev/null +++ b/apps/web/components/generation/ConflictAdjudication.tsx @@ -0,0 +1,75 @@ +"use client"; + +import type { IngestConflicts } from "@/lib/generation/cards"; + +interface ConflictAdjudicationProps { + conflicts: IngestConflicts; + busy: boolean; + // 作者已查看冲突,确认带 acknowledge 重发入库。 + onAcknowledge: () => void; + onCancel: () => void; +} + +// 入库 409 CONFLICT_UNRESOLVED 裁决面板(UX §7 / 不变量#3): +// 展示 continuity 预检冲突 → 作者裁决(确认入库 = acknowledge 重发 / 取消回去改卡)。 +export function ConflictAdjudication({ + conflicts, + busy, + onAcknowledge, + onCancel, +}: ConflictAdjudicationProps) { + return ( +
+

+ 发现 {conflicts.conflictCount} 处一致性冲突 +

+

+ 预检发现生成角色与既有真相源冲突。确认无碍可强制入库,或取消后调整角色卡。 +

+
    + {conflicts.conflicts.map((c, i) => ( +
  • +
    + + {c.type} + + {c.where ? ( + {c.where} + ) : null} +
    + {c.refs.length > 0 ? ( +

    + 涉及:{c.refs.join("、")} +

    + ) : null} + {c.suggestion ? ( +

    建议:{c.suggestion}

    + ) : null} +
  • + ))} +
+
+ + +
+
+ ); +} diff --git a/apps/web/components/generation/WorldGenerator.tsx b/apps/web/components/generation/WorldGenerator.tsx new file mode 100644 index 0000000..cec24ff --- /dev/null +++ b/apps/web/components/generation/WorldGenerator.tsx @@ -0,0 +1,78 @@ +"use client"; + +import { useCallback, useState } from "react"; + +import { worldEntityRules } from "@/lib/generation/cards"; +import { useWorldGen } from "@/lib/generation/useWorldGen"; + +interface WorldGeneratorProps { + projectId: string; +} + +// 世界观设计器(UX §6.5):一句话需求 → 预览实体卡(type/name/rules)。 +// 本期世界观入库端点未建(仅预览);预览结果供作者参考/复制。 +export function WorldGenerator({ projectId }: WorldGeneratorProps) { + const gen = useWorldGen(); + const [brief, setBrief] = useState(""); + + const onGenerate = useCallback(async () => { + await gen.generate(projectId, brief); + }, [gen, projectId, brief]); + + const generating = gen.status === "generating"; + + return ( +
+
+

世界观设计器

+