feat: M4 文风 + M5 生成/多provider/Skill + Kimi Code 订阅接入 + 本地联调修复

M4(文风): style-auditor 双轨(提取指纹/漂移第四审)+ jobs 长任务框架(zombie reaper) + 回炉 refine + GET /style read-back。
M5(生成+扩展): worldbuilder/character-gen(入库 continuity 409 gate + partition_writes 白名单 + schema→JSONB 形变);
  网关多 provider 回退链/熔断/能力降级(Anthropic/Gemini 适配器);Skill registry + 表权限沙箱 + 规则;
  前端 角色生成器/世界观/Codex/规则页/技能库/⌘K 命令面板。
K1(Kimi Code 订阅接入): OAuth device-flow(kimi-code)+ 静态 Console key(kimi-code-key)两路径;
  coding 端点 KimiCLI 伪造头(实测 UA allow-list 门禁,缺则 403)+ JSON 模式结构化(thinking ⊥ tool_choice)。
本地联调修复: CORS 中间件;assemble 注入 premise+「写第N章」指令(修空 prompt 400);
  GET /outline·/draft read-back + 大纲/工作台/审稿页重载;写页 client/server 常量边界 + notFound 健壮化;
  字数 toLocaleString locale 水合;审稿页终稿从已存草稿 seed(修 accept 422)。
门禁: backend ruff/mypy(157)/alembic 无漂移/pytest 451 · frontend lint/tsc/vitest/build。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Yaojia Wang
2026-06-20 10:39:58 +02:00
parent 5fb7bfb1de
commit 765dbdfbd4
161 changed files with 17330 additions and 208 deletions

View File

@@ -16,10 +16,66 @@
- 档位路由 `resolve_route(tier)->Route(provider,model)``config.tier_defaults`M1 仅全局默认;回退/熔断属 M5/T5.4**未实现**)。
- 记账 `SqlAlchemyLedgerSink(session)``UsageLedger`owner_id=scope.user_id 单用户 stub列名 `cache_read`)。成本表 `pricing.py`(未知 provider/model→0
### C1 扩展T5.4, 2026-06-19· 多 provider 韧性(回退链 + 熔断 + 能力协商/降级 owner @llm 状态: 稳定
- 来源:`ARCHITECTURE.md §4.4(能力协商/降级)/§4.5(回退/重试/熔断)`。**仅加字段/形参,不破既有调用**(旧 `Gateway(adapters, ledger, resolver=resolve_route)` 仍可用)。
- **`ServedBy` 加字段**C1 形变,**仅加可选 bool默认 False向后兼容**`ServedBy(provider, model, fell_back=False, degraded=False)``degraded` 标能力降级(所选 provider 不支持原生结构化输出,改走 instructor JSON-提示路径)。→ **OpenAPI 表面**`ServedBy` 不直接出 APIreview/draft SSE 不回 served_bysettings/providers 也不含它)→ 经核 **本次无 OpenAPI 形变,前端无需 re-gen**;若后续把 `served_by` 暴露到响应体,再触发 gen:api。
- **`Gateway` 构造扩**(关键字,全可选):`Gateway(adapters, ledger, *, chain_resolver: Callable[[Tier], list[Route]] | None=None, resolver: Callable[[Tier], Route] | None=None, max_retries=2, breaker: CircuitBreaker | None=None)``chain_resolver`(回退链)优先;`resolver`单路由M1 兼容)被自动包成单元素链;皆缺省回退到默认 `resolve_chain``run`/`stream` 行为:沿链逐 provider→瞬时失败退避重试 R 次→仍失败/熔断打开/无适配器则切下一个;首个成功者服务,非链首即 `served_by.fell_back=True`;链耗尽抛 `AppError(LLM_UNAVAILABLE)`。记账记**实际服务方** provider/model回退后不记主模型。流式仅在首块产出前可切产出后中途失败上抛不静默重连§4.5)。
- **回退链解析routing.py**`resolve_chain(tier)->list[Route]`(默认仅全局 `tier_defaults`,单元素);`chain_from_routing(tier, primary:str, fallback:list[str])->list[Route]`(据 DB `tier_routing` 行去重保序构链——供 apps/api 包成 `ChainResolver` 注入网关,**apps/api 待接线**`build_gateway_for_tier` 目前仍只注主 provider 适配器 + `resolver=resolve_route`,要启用回退须按 fallback 预备多个适配器 + 传 `chain_resolver`)。`ChainResolver = Callable[[Tier], list[Route]]` 类型别名导出。
- **熔断器**`CircuitBreaker(*, threshold=5, reset_seconds=30.0, clock=time.monotonic)``is_open(provider)`/`record_failure`/`record_success`;连续失败超阈值→短时熔断(网关直接跳过该 provider 走回退);成功清零;冷却窗口过半开放行。**默认每个 Gateway 实例自带一个 breaker**(进程内、非跨实例共享)——跨请求持久熔断需调用方注入共享 breaker。
- **瞬时错误契约**:适配器把厂商 429/超时/5xx/连接错误翻译为 `ww_llm_gateway.errors.TransientProviderError`OpenAI 兼容/Anthropic/Gemini 适配器均已包装,按异常类名 + status_code 判定);网关另把 `AppError(RATE_LIMITED)` 也视作可重试/可回退。非瞬时错误(内容策略拒绝等)原样上抛、不重试。
- **新适配器**(均经注入的客户端 Protocol**测试不联网、不硬 import 厂商 SDK**
- `AnthropicAdapter(provider, client: AnthropicClient, *, structured_client?)``capabilities()=(structured_output=True, prefix_cache=True, thinking=True)`;缓存断点经 system 块 `cache_control:{type:ephemeral}`§4.6);结构化经 `instructor.from_anthropic`(懒构建)。
- `GeminiAdapter(provider, client: GeminiClient)``capabilities()=(structured_output=True, prefix_cache=False, thinking=True)`;结构化经 `config.response_mime_type=application/json + response_schema`,文本回 `model_validate_json`
- `OpenAICompatAdapter` 不变(新增瞬时错误包装,参与回退链)。
- **依赖**`packages/llm_gateway/pyproject.toml``tenacity>=8.2`(已 `uv sync`;原为 instructor 传递依赖)。**`anthropic`/`google-genai` SDK 已由 @devops 加(已 `uv sync`**——适配器仍懒导入/注入客户端,单测零依赖。
#### C1 扩 follow-up #22026-06-19· `build_adapter` provider→适配器工厂 owner @llm 状态: 稳定
- 位置:`packages/llm_gateway/ww_llm_gateway/factory.py`,经包 `__init__` 导出 `build_adapter`
- **签名稳定apps/api `build_gateway_for_tier` 据此逐 provider 建适配器进 `adapters` dict**
`def build_adapter(provider: str, *, api_key: str, base_url: str | None = None) -> ProviderAdapter`
- **分派**`provider=="anthropic"``AnthropicAdapter`(懒 import `AsyncAnthropic(api_key=, base_url?)``provider in {"gemini","google"}``GeminiAdapter`(懒 import `genai.Client(api_key=)`**忽略 base_url**——SDK 无该形参其余deepseek/kimi/qwen/glm/openai…`OpenAICompatAdapter(provider, AsyncOpenAI(api_key=, base_url=))`
- 工厂从 api_key 构**真实**客户端(适配器内部仍是注入客户端 Protocol测试零联网故单测只断**按 provider 名选对适配器类 + provider 字段透传**`tests/test_build_adapter_factory.py`6 测),不联网。
- **@backend 行动**`build_gateway_for_tier` 现可调 `build_adapter(provider, api_key=…, base_url=…)` 替换「一律 `OpenAICompatAdapter`」,使回退链中的 anthropic/gemini 走对应专用适配器。**无 OpenAPI 形变**served_by 不出 API前端无需 re-gen。
#### C1 扩K1.2, 2026-06-19· Kimi Code 订阅 plan 适配器(伪造头 + OAuth bearer owner @llm 状态: 稳定
- 位置:`packages/llm_gateway/ww_llm_gateway/adapters/kimi_code.py`,经包 `__init__` 导出 `KimiCodeAdapter`/`build_kimi_code_client`/`kimi_code_headers`/`kimi_device_id`/`kimi_device_model`/`KIMI_CODE_BASE_URL`/`KIMI_CODE_USER_AGENT`/`KIMI_CLI_VERSION`/`KIMI_CODE_PLATFORM`/`KIMI_CODE_PROVIDER`
- **provider 名**`kimi-code`(与 API-key provider `kimi` 分离,便于档位切换)。`build_adapter("kimi-code", api_key=<access_token>, base_url=None)`**K1.3 接线的缝**——`api_key` 即 OAuth **access token**OpenAI SDK 自动发 `Authorization: Bearer <token>``base_url` 缺省 → `KIMI_CODE_BASE_URL`
- **base URL**`https://api.kimi.com/coding/v1`OpenAI 兼容;`KimiCodeAdapter` 子类化 `OpenAICompatAdapter`capabilities 继承structured_output/prefix_cache=True
- **model**`kimi-for-coding` 是**路由/档位关注点**,经 `.complete(req, model)` 传入,**不**在适配器硬编码。
- **必需伪造头 = 完整 7 头opencode 规范)**`User-Agent` + 6 个 `X-Msh-*``build_kimi_code_client``kimi_code_headers()` 作为 `AsyncOpenAI(default_headers=…)`,每次请求随客户端发出。
- **头集真源校正2026-06-19= `github.com/ooojustin/opencode-kimi``src/headers.ts` `kimiHeaders()` + `src/constants.ts`1:1 镜像 kimi-cli v1.37.0**。早先曾误信 `picassio/pi-kimi-coder`(仅 UA、不带 X-Msh-*)把头集裁成 UA-only——那是分歧/错误参考,已**回退到 opencode 完整 7 头**。coding API 校验全部 7 头偏差→Moonshot 后端 `access_terminated_error: only available for Coding Agents`403。7 头精确值:
- `User-Agent` = `KimiCLI/1.37.0``f"KimiCLI/{KIMI_CLI_VERSION}"`UA 前缀 = `KimiCLI/<version>`version 必须 == `X-Msh-Version`)。
- `X-Msh-Platform` = `kimi_cli`(字面常量字符串,**非** OS 名)。
- `X-Msh-Version` = `1.37.0`= UA 里的 CLI 版本)。
- `X-Msh-Device-Name` = 主机名 ASCII 化(`socket.gethostname()`/`platform.node()`,裁非 ASCII
- `X-Msh-Device-Model` = `kimi_device_model()`macOS `f"macOS {platform.mac_ver()[0]} {platform.machine()}"`(如 `"macOS 14.5 arm64"`Windows `f"Windows {release} {machine}"`;其它 `f"{system} {release} {machine}"``platform.machine()` 原样,返回 `arm64`/`x86_64`,不归一化)。
- `X-Msh-Os-Version` = `platform.version()`OS 内核版本串,≈ Node `os.version()`)。
- `X-Msh-Device-Id` = **稳定** UUID4 hex 无连字符32 位小写)。`kimi_device_id()`env `KIMI_DEVICE_ID` 优先 → 否则读/建 `~/.kimi/device_id`(首次写一次 `uuid.uuid4().hex`、之后复用;与 kimi-cli/opencode 共享路径)。**跨调用/进程稳定,绝不每次随机**。
- 单测(不联网,断构造出的客户端 base_url + default_headers全 7 头键 + 固定字面值 + device-id 32 位小写 hex 稳定 + host 派生值非空 ASCII + bearer + 工厂分派env `KIMI_DEVICE_ID` 注入保证 CI 确定):`tests/test_kimi_code_adapter.py` + `tests/test_kimi_code_factory.py`
- **@backend 行动K1.3**OAuth device 服务刷新 token 后,`_build_provider_adapter``build_gateway_for_tier`)对 provider `kimi-code``build_adapter("kimi-code", api_key=<当前 access_token>, base_url=…)` 即得带伪造头的适配器token 刷新/获取归 K1.3,本适配器只收当前 access token。**无 OpenAPI 形变**,前端无需 re-gen。
#### C1 扩kimi-code-key, 2026-06-20· Kimi Code 订阅 plan **静态 Console Key**ToS 合规变体 owner @llm 状态: 稳定
- 位置:`packages/llm_gateway/ww_llm_gateway/adapters/kimi_code_key.py`,经包 `__init__` 导出 `KimiCodeKeyAdapter`/`build_kimi_code_key_client`/`KIMI_CODE_KEY_PROVIDER`
- **provider 名**`kimi-code-key`(与 OAuth 的 `kimi-code`、moonshot 的 `kimi` 均分离)。`build_adapter("kimi-code-key", api_key=<console key>, base_url=None)` 走此分支。
- **与 OAuth `kimi-code` 的关键区别**`build_kimi_code_key_client` 构造**纯** `AsyncOpenAI(api_key=, base_url=KIMI_CODE_BASE_URL)`——**不设 `default_headers`**,即**不伪造 UA、不带 `X-Msh-*` 头**。Kimi Console`kimi.com/code/console`)签发的 Key 走订阅额度、命中同一 coding 端点(`https://api.kimi.com/coding/v1`+ 同 model `kimi-for-coding`,但 plain `Authorization: Bearer <key>` 即可openclaw + models.dev 确认ToS 允许第三方 Key仅 UA 篡改违规)→ 故这是**干净/合规的默认路径**(无封号风险,对照 `KimiCodeAdapter` 的伪造头变体)。
- **结构化输出**coding 端点 `kimi-for-coding` thinking 开启,与强制 `tool_choice` 互斥live 400`KimiCodeKeyAdapter` **复用** `kimi_code.build_kimi_code_structured_client`instructor `Mode.JSON`)——同 OAuth 变体的 JSON-mode 修复。
- 单测(不联网):`tests/test_kimi_code_key_factory.py`5 测:工厂分派 + coding base + bearer + **无 X-Msh-*/无 KimiCLI UA** + JSON-mode 结构化 + 显式 base_url 覆盖)。
- **@backend 接线**`kimi-code-key` 是**普通 api_key provider**auth_type="api_key",存 `api_key_enc`Fernet——经既有 `build_gateway_for_tier`/`_build_provider_adapter`/probe 路径**无改动**即可工作(只需 `provider_deps._PROVIDER_BASE_URLS` 注册 base_url。**不**触发 `_build_provider_adapter` 的 OAuth 刷新分支(其判据 `auth_type==oauth or provider=="kimi-code"` 不命中本 provider。**无 OpenAPI 形变**(用既有 `PUT /settings/providers`),前端无需 re-gen。
## C2 · DB schemaSQLAlchemy 模型 owner @db 状态: 待定义
- 来源:`ARCHITECTURE.md §3.1` DDL11 创作表 + chapter_reviews + 运营表无向量列users stub
- 消费方:`@backend`(Repository/记忆服务/验收)、`@llm`(Agent reads/writes)。
### C2 扩展K1.1, 2026-06-19· `provider_credentials` 加 OAuth 列 owner @db 状态: 稳定
- 背景K1 Kimi Code OAuth订阅 plan device-flow`provider_credentials` 表新增两列 + 改 `api_key_enc` 可空使一行可表「api_key 凭据」或「oauth 凭据」二选一。
- **模型变更(`packages/db/ww_db/models.py` `ProviderCredential`**
- 新增 `auth_type: Mapped[str]` = `Text, nullable=False, server_default="'api_key'"`(值域 `"api_key"` | `"oauth"`)。既有行迁移后默认 `'api_key'`,向后兼容。
- 新增 `oauth_enc: Mapped[bytes | None]` = `LargeBinary, nullable=True`。**持 Fernet 加密的 JSON 包** `{access_token, refresh_token, expires_at}`@backend K1.3 写/读:`encrypt`/`decrypt`key 取 `settings.credential_enc_key`,同 `api_key_enc` 的 Fernet
- `api_key_enc``nullable=False`**`nullable=True`**`Mapped[bytes | None]`——OAuth-only 行无 api_key既有 api_key 行迁移后仍保留其值。
- `UniqueConstraint("owner_id","project_id","provider")` **不变**OAuth provider 用独立 provider 名 `kimi-code`,与 api_key 的 `kimi` 分离,不撞约束)。
- **迁移**`packages/db/migrations/versions/1f011c42bd4d_provider_credentials_oauth_columns.py`down_revision=`220ca2e3d53f`。up: add_column auth_type/oauth_enc + alter_column api_key_enc→nullabledown 逆向。`alembic check` 无漂移。
- **⚠️ K1.3 @backend 必读**`api_key_enc` 现为 `bytes | None``apps/api/ww_api/services/credentials.py``StoredCredential.api_key_enc: bytes`dataclass 字段 line 28与 store 的 `r.api_key_enc` 赋值line 75/97mypy 报 `arg-type``bytes | None``bytes`)。**K1.1 不动 @backend 文件(目录所有权)**K1.3 接 OAuth store 时须把 `StoredCredential.api_key_enc` 改成 `bytes | None`(并加 `auth_type`/`oauth_enc` 字段/读写路径),届时 mypy 即绿。当前全仓 mypy 唯余此 2 处错(都在 credentials.py其余门禁绿ruff/format/alembic check/pytest 400 passed
## C3 · API / OpenAPI 端点 owner @backend 状态: 稳定M1 端点全落T1.7 settings + T1.4 projects/chapters, 2026-06-18
- 来源:`ARCHITECTURE.md §7.2` 端点清单(章节端点统一 `/projects/:id/chapters/:no/...`;含 `PUT .../draft`(自动保存)、`/refine``/jobs/:id``/reviews`)。
- 消费方:`@frontend`(经 OpenAPI→TS 客户端)。**改端点/字段 → 前端必须 `pnpm gen:api` 重生成客户端。**
@@ -54,6 +110,93 @@
- **beats 存储形**DB `outline.beats` 是 JSONB dict→写侧包成 `{"beats":[...]}` 落库API 出参 `OutlineChapterView.beats` 解包成裸 `list[str]`
- **@frontend 行动**T3.6 `pnpm gen:api` 纳入看板/大纲端点。
### C3 扩展T4.3, 2026-06-19· 文风端点:学文风(jobs 异步) + 回炉 + 最新指纹(独立 `routers/style.py`,已注册)
- snake_case改字段 → **@frontend 必须 `cd apps/web && pnpm gen:api`** 重生成 TS 客户端T4.4 前置)。
- `POST /projects/{id}/style``StyleLearnRequest{samples:list[str](min_length=1), mode:Literal["create","update"]="create"}`**202** `StyleLearnResponse{job_id:uuid}`。项目不存在→404 `NOT_FOUND`;无凭据→**503** `LLM_UNAVAILABLE`(在 `get_style_extract_gateway` dep 解析阶段拦下,**调度 job 之前**,不凭空写注定失败的 job。流程`job_repo.create(project_id,"style_learn")``session.commit()`job 行在 202 前持久化供轮询)→ `background_tasks.add_task(run_job, session_factory, job.id, work)``work``run_job` **自建独立 session** 上自造 analyst 网关(`build_gateway_for_tier(session, SqlCredentialStore(session), "analyst")`+ 写侧 repo → `run_style_extraction(style_extract_spec, samples_text="\n\n".join(samples), ...)` → 拆 `dimensions_json={name:value}`/`evidence_json={name:[evidence]}``SqlStyleFingerprintWriteRepo.append`version+1→ 返回 `{version, dims_count}`(落 `jobs.result`)。`run_job` 拥有 session 生命周期/commit业务写 + job done 同一事务)。`mode` 不改落库逻辑(写侧始终 append 新版本),仅供前端区分首学/更新语义。前端经 `GET /jobs/{job_id}` 轮询。
- `GET /projects/{id}/style` → 200 `StyleFingerprintResponse{dimensions:dict, evidence:dict, version:int}`最新版本完整指纹UX §6.9。项目不存在→404无指纹→404 `NOT_FOUND`。**新增端点**ARCH §7.2 原缺读指纹端点,见 decisions 2026-06-19。读侧用写侧 repo 的 `latest`(含 evidence/version**不动 C5 读侧** `SqlStyleRepo.latest`/`StyleView`(只 dimensionsassemble 用),故 assemble/C5 行为不变。
- `POST /projects/{id}/chapters/{no}/refine``RefineRequest{segment:str(min_length=1), instruction:str|None=None}` → 200 `RefineResponse{original:str, refined:str}`。同步回炉writer 网关(`get_refine_gateway``run(refiner_spec)``output_schema=None` → 取 `resp.text` 为 refined输入 = `【待重写段落】{segment}` + 可选 `【改写指令】{instruction}`。**不写库**(不变量 #3,作者采纳经既有 draft 自动保存合入);**末尾 `session.commit()`**(网关 ledger add-only否则 usage_ledger 静默丢失,同 draft/review 纪律。项目不存在→404无凭据→503。
- 注入缝(`services/project_deps.py``get_style_write_repo``SqlStyleFingerprintWriteRepo`,服务 `GET /style` 读侧)、`get_style_extract_gateway`(analyst学文风的凭据探测缝)、`get_refine_gateway`(writer)。测试经 `app.dependency_overrides` override学文风后台路径 monkeypatch `style.build_gateway_for_tier`/`style.SqlStyleFingerprintWriteRepo` + `job_runner.SqlJobRepo``run_job` 自建 repo不走 dep
- 写侧 repo`packages/core/ww_core/domain/style_repo.py`,经 `ww_core.domain` 导出):`StyleFingerprintWriteRepo`/`SqlStyleFingerprintWriteRepo`(加 `Write` 避撞 C5 读侧 `SqlStyleRepo`,仿 `DigestAppendRepo` 先例):`append(project_id, *, dimensions_json, evidence_json)->int`version=当前 max+1首次 1**只 flush 不 commit**+ `latest(project_id)->StyleFingerprintView|None`(含 dimensions/evidence/version`GET /style`)。
- **@frontend 行动**T4.4 前 `pnpm gen:api` 纳入 style/refine/GET style + jobs 轮询类型。
### C3 扩展T5.5, 2026-06-19· 规则端点 `POST /projects/:id/rules`(独立 `routers/rules.py`,已注册)
- snake_case改字段 → **@frontend 必须 `cd apps/web && pnpm gen:api`**T5.6 前置)。
- `POST /projects/{project_id}/rules``RuleCreateRequest{level:Literal["global","genre","style","project"], content:str(min_length=1)}`**201** `RuleView{level:str, content:str}`。非法 `level` / 空 `content`**FastAPI 422**Pydantic Literal/min_length 校验,非 AppError 信封)。
- 加规则是**作者显式动作**(不变量 #3:不经 AI 静默写库)。写一行 `rules`(绑 `project_id`),喂给 assemble 的四级合并 `merge_rules`global→genre→style→project
- 提交边界:`RuleWriteRepo.create``flush()`,端点写后 `await session.commit()`(仿 foreshadow/outline 写侧)。
- 写侧 repo`packages/core/ww_core/domain/rule_repo.py`,经 `ww_core.domain` 导出):`RuleWriteRepo`(Protocol)/`SqlRuleWriteRepo`/`RuleWriteView{project_id?,level,content}`(加 `Write` 避撞 C5 读侧 `domain.repositories.RuleView{level,content}`,仿 `OutlineWriteRepo` 先例)。
- 注入缝(`services/project_deps.py``get_rule_write_repo``SqlRuleWriteRepo`)。测试经 `app.dependency_overrides` override 注 fake repo + fake session断 commit 计数)。
- **@frontend 行动**T5.6 前 `pnpm gen:api` 纳入 `POST /rules`(规则页用)。
### C3 扩展T5.2, 2026-06-19· 生成/入库 + 读端点(独立 `routers/generation.py`已注册 owner @backend 状态: 稳定
- snake_case改字段 → **@frontend 必须 `cd apps/web && pnpm gen:api`**T5.6 前置)。生成走**即时返回**预览→作者确认→入库M5-d非 jobs
- `POST /projects/{project_id}/world/generate``WorldGenerateRequest{brief:str(min1)}`**200** `WorldGenPreviewResponse{entities:[WorldEntityCardView{type:str,name:str,rules:list[str]}]}`。**预览不入库**worldbuilder writer 网关 `run_worldbuilder`);末尾 `commit()` 仅落网关 ledger。项目不存在→404无凭据→**503** `LLM_UNAVAILABLE``get_worldbuilder_gateway` dep 解析阶段拦下)。
- `POST /projects/{project_id}/characters/generate``CharacterGenerateRequest{brief:str(min1), count:int=1(ge1,le12), role:str|None}`**200** `CharacterGenPreviewResponse{cards:[CharacterCardView{name,role,traits:list[str],backstory,arc:str,speech_tics:list[str],tags:list[str],relations:[CharacterRelationView{name,kind,note?}]}]}`。**预览不入库**character-gen writer 网关注入已有角色防雷同generated_so_far=[]);末尾 commit 落 ledger。404/503 同上。
- `POST /projects/{project_id}/characters`(入库)← `CharacterIngestRequest{cards:[CharacterCardView](min1), acknowledge_conflicts:bool=false}`**201** `CharacterIngestResponse{created:list[str], rejected_tables:list[str]}`。**入库 gate**:① `precheck_generated_cards`(continuity 预检, analyst 网关) 比对卡 vs 世界观/已有角色真相源——有冲突且 `acknowledge_conflicts=false`**409 `CONFLICT_UNRESOLVED`** + `details{conflicts:[{type,where,refs,suggestion}], conflict_count}`(仿 accept gate不静默入库不变量#3`acknowledge_conflicts=true` 即作者裁决放行)。② `partition_writes(character_gen_spec, {"characters":cards})` 白名单过滤(越权表→`rejected_tables` 审计 log+丢弃character-gen 只声明 writes=characters 故正常为空)。③ 写 `characters`schema list/str → DB JSONB **dict** 形变,见写侧 repo。404/503 同上。提交边界precheck 网关 ledger + 角色写侧均只 flush → 端点末尾一次 `commit()`(含冲突 409 路径也 commit 落 precheck usage
- `GET /projects/{project_id}/rules`**200** `RuleListResponse{rules:[RuleView{level,content}]}`(规则页;复用 C5 读侧 `SqlRulesRepo.all_for_project`,不动 assemble
- `GET /skills`(独立 `skills_router`prefix `/skills`)→ **200** `SkillListResponse{skills:[SkillView{name,scope,tier,reads:list[str],writes:list[str],genre?}]}`(技能库 UI`get_skill_registry` 读 registry按 name 升序)。
- 写侧 repo`packages/core/ww_core/domain/`,经 `ww_core.domain` 导出):`CharacterWriteRepo`/`SqlCharacterWriteRepo`/`CharacterWriteView{id,name,role}``character_repo.py`+ `WorldEntityWriteRepo`/`SqlWorldEntityWriteRepo`/`WorldEntityWriteView{id,type,name}``world_entity_repo.py`,预留对称入库)。**形变**`traits`/`speech_tics`(list)→`{"items":[...]}``arc`(str)→`{"text":...}``rules`(list)→`{"rules":[...]}``tags`/`relations`→直落 JSONB list仅 character 入库端点已落地world ingest 端点本期未建,仅 worldbuilder 走预览)。
- 注入缝(`services/project_deps.py``get_worldbuilder_gateway`(writer)/`get_character_gen_gateway`(writer)/`get_precheck_gateway`(analyst)/`get_character_write_repo`/`get_world_entity_write_repo`/`get_rules_read_repo`。测试经 `app.dependency_overrides` 注 fake repo + schema-routing fake 网关(按 `req.output_schema` 返 WorldGenResult/CharacterGenResult/ContinuityReview+ fake session断 commit 计数)。
- **`build_gateway_for_tier` 多 provider 接线T5.4 follow-up已完成**:据 DB `tier_routing` 取该 tier 的 `provider:model` + `fallback`,为每个可建适配器的 provider 预备适配器,注入 `chain_resolver=chain_from_routing(...)` 启用回退链。无 DB 路由行 → 退回全局 `resolve_route`(单 providerM1 兼容);无任何可用凭据 → 503。单 provider 配置 = 单元素链(行为不变)。
- **`build_adapter` per-provider 接线M5 R1 follow-up #22026-06-19已完成**`_build_provider_adapter`(原 `_build_openai_compat_adapter`)现调 `ww_llm_gateway.build_adapter(provider, api_key=…, base_url=…)`C1 扩 follow-up #2 工厂)替代「一律 `OpenAICompatAdapter`」——OpenAI 兼容 providerdeepseek/kimi/qwen/glm/openai`_PROVIDER_BASE_URLS` 传 base_urlAnthropic/Gemini 传 `base_url=None` 走原生适配器。这样 `tier_routing` 链里配置的 Anthropic/Gemini provider 也能拿到**真实适配器**参与回退(不再被 base_url 缺失跳过)。未配凭据的 provider 返 None回退链跳过。**无 OpenAPI 形变**served_by 不出 API。真实 Anthropic/Gemini 跑通仍需 @devops`anthropic`/`google-genai` SDK 依赖(适配器懒导入/注入客户端)。
- **@frontend 行动**T5.6 前 `pnpm gen:api` 纳入 world/characters generate + characters ingest + GET rules + GET skills。
### C3 扩展M5 R1 follow-up, 2026-06-19· 设定库 Codex 读端点GET characters / world_entities owner @backend 状态: 稳定
- 补 PROGRESS「Codex 读端点缺口」余项:设定库 Codex 现可展示跨会话**全量**已入库角色/世界观(前端先前只能「生成+本次入库会话内回显」。snake_case新增端点 → **@frontend 必须 `cd apps/web && pnpm gen:api`** 重生成 TS 客户端 + CodexPage 初始列表去掉「会话内」局限(见 gotcha 2026-06-19 @frontend Codex 缺口条)。
- `GET /projects/{project_id}/characters`**200** `CharacterListResponse{characters:[CharacterCardView]}`(已入库角色全量;复用 C5 读侧 `SqlCharacterRepo`,经 `get_memory_repos``memory.character.list_for_project`**不动 assemble**)。无行 → 空列表(非 404
- `GET /projects/{project_id}/world_entities`**200** `WorldEntityListResponse{world_entities:[WorldEntityCardView]}`(已入库世界观实体全量;复用 C5 读侧 `SqlWorldEntityRepo`)。
- **形变DB JSONB→APIT5.2 ingest 形变的逆向)**`characters.traits`/`speech_tics` dict `{"items":[...]}`→裸 list、`arc` dict `{"text":...}`→str、`tags`/`relations`→直落 list复用既有 `_existing_characters` helper`world_entities.rules` dict `{"rules":[...]}`→裸 list。
- **路由**:挂既有 `generation.router`prefix `/projects`GET `/{project_id}/characters` 与 POST 同路径FastAPI 按 method 区分,无冲突。注入缝复用 `get_memory_repos`(无新 dep。测试经 `app.dependency_overrides[get_memory_repos]` 注 fake `MemoryRepos`
- **@frontend 行动**`pnpm gen:api` 纳入 `GET /projects/:id/characters` + `GET /projects/:id/world_entities``lib/api/server``fetchCharacters`/`fetchWorldEntities` + CodexPage 初始列表拉全量。
### C3 扩展(大纲读端点 follow-up, 2026-06-20· `GET /projects/{id}/outline`(挂既有 `routers/outline.py`已注册 owner @backend 状态: 稳定
- 补缺口:大纲页先前**只有** `POST .../outline`(生成+持久化),无读端点 → 重访页面已落库的大纲不回显,看似「未保存」。仿 Codex 读端点GET characters/world_entities补对称读侧。
- `GET /projects/{project_id}/outline`tag `outline`)→ **200** `OutlineResponse{chapters:list[OutlineChapterView{no,volume,beats:list[str],foreshadow_windows:list[ForeshadowWindowView]}]}`(与 `POST .../outline` 响应**同形**,前端类型对齐),按 `chapter_no` 升序。项目存在但无大纲 → **200 空列表**(非 404项目不存在 → **404 `NOT_FOUND`**(仿 POST 的项目存在性检查)。**只读不写库**。
- **复用 C5 读侧**:扩 `OutlineRepo`(domain protocol) + `SqlOutlineRepo`(C5 assemble 读侧 repo) 加 `list_for_project(project_id)->list[OutlineView]`(仿 `CharacterRepo`/`WorldEntityRepo` 命名order_by chapter_no既有 `get(project_id, chapter_no)` 不动assemble 行为不变。新注入缝 `get_outline_read_repo`(`services/project_deps.py`,仿 `get_rules_read_repo`)。
- **beats 形变**DB `outline.beats` JSONB dict `{"beats":[...]}` → 出参 `OutlineChapterView.beats` 解包成裸 `list[str]`(同 POST 路径 / `OutlineWriteView._to_view`)。
- **@frontend 行动**`pnpm gen:api` 纳入 `GET /projects/:id/outline`;大纲页初次加载拉已持久化大纲(去掉「生成后才显示」局限)。
### C3 扩展(草稿读端点 follow-up, 2026-06-20· `GET /projects/{id}/chapters/{no}/draft`(挂既有 `routers/projects.py`已注册 owner @backend 状态: 稳定
- 补缺口:写作工作台先前**只有** `POST .../draft`(SSE 流写) + `PUT .../draft`(自动保存) 无读端点 → 重访工作台时编辑器空白、看似「未保存」(其实 `chapters` 行在库)。仿 `GET .../outline` 补对称读侧。
- `GET /projects/{project_id}/chapters/{chapter_no}/draft`**200** `DraftView{project_id, chapter_no, volume, status, version, content, length}`**比 PUT 的 `DraftResponse``content`**——读侧需正文重建编辑器;`length`=content 字符数派生)。无草稿行**或正文空白** → **404 `NOT_FOUND`**(工作台据此呈现空编辑器,与其它「缺资源」端点一致)。**只读不写库**。
- **复用读 seam**:直接调既有 `chapter_repo.get_draft(project_id, chapter_no)->ChapterDraftView|None`(同续审 `_resolve_review_draft` 的读缝);`ChapterDraftView` 已含全部字段,无需新 repo 方法/新 view。多版本时固定返草稿版次`DRAFT_VERSION=1`)那条可编辑工作副本,与 PUT 保存的同一行。
- **@frontend 行动**`pnpm gen:api` 纳入 `GET /projects/:id/chapters/:no/draft`(新 schema `DraftView`);工作台初次加载拉已保存草稿回灌编辑器(去掉「重访看似未保存」局限)。
### C3 扩展K1.3, 2026-06-19· Kimi Code OAuth device-flow 端点(独立 `routers/kimi_oauth.py`已注册 owner @backend 状态: 稳定
- snake_case新增 3 端点 + 3 schema → **@frontend 必须 `cd apps/web && pnpm gen:api`**K1.4 前置)。**响应/job 结果/日志绝不含 access/refresh token**(只 user_code/connected/expires_at 等非密信息token 仅以 Fernet 密文存 `provider_credentials.oauth_enc`
- `POST /settings/providers/kimi-code/oauth/start`**202** `OAuthStartResponse{job_id:uuid, user_code:str, verification_uri:str, verification_uri_complete:str|None, expires_in:int, interval:int}`。流程:`start_device_authorization(http)``job_repo.create(None,"kimi_oauth")` + `session.commit()`202 前持久化供轮询)→ `background_tasks.add_task(run_job, session_factory, job.id, _make_poll_work(device))`。后台 `work``run_job` 自建独立 session 上**循环 `poll_token`**`authorization_pending`→续轮询、`slow_down`→增大 interval、`expired`/`denied`→抛 AppError 置 job failed成功 → `upsert_oauth_credential`(加密包)+ job done结果只 `{connected:true, provider}`)。前端展示 user_code + 打开 verification_uri + 轮询 `GET /jobs/{id}`
- `POST /settings/providers/kimi-code/oauth/disconnect`**200** `OAuthDisconnectResponse{disconnected:bool}``delete_credential` 清 OAuth 凭据行)。
- `GET /settings/providers/kimi-code/oauth/status`**200** `OAuthStatusResponse{connected:bool, expires_at:str|None}`(解密 `oauth_enc` 取过期时刻;**无 token 本体**;解密失败/无凭据→`connected:false`)。
- **store OAuth 读写**`services/credentials.py`C2 扩 K1.1 收口):`StoredCredential``auth_type:str`/`oauth_enc:bytes|None``api_key_enc``bytes|None`K1.1 的 2 处 mypy red 已消)。新方法:`upsert_oauth_credential(owner,provider,oauth_enc)`auth_type="oauth"+清 api_key_encread-modify-write 守可空 project_id 约束)、`delete_credential(owner,provider)->bool``upsert_credential`api_key 路径)现显式 set auth_type="api_key"+清 oauth_enc。
- **`_build_provider_adapter` Kimi Code OAuth 接线**`services/project_deps.py`provider `kimi-code``auth_type="oauth"``_resolve_kimi_code_token`:解密 `oauth_enc``needs_refresh`(剩余寿命 < 300s则经临时 `httpx.AsyncClient` `kimi_oauth.refresh` + `upsert_oauth_credential` 持久化新包 刷新后的access token `build_adapter("kimi-code", api_key=<token>, base_url=…)`K1.2 工厂构带伪造头 + coding base `KimiCodeAdapter`)。 `oauth_enc``LLM_UNAVAILABLE``_PROVIDER_BASE_URLS` `"kimi-code": "https://api.kimi.com/coding/v1"`
- **服务缝**`services/kimi_oauth.py`注入 `AsyncHttpClient` Protocol = `httpx.AsyncClient.post` 子集,测试零联网):`start_device_authorization(http)->DeviceAuth``poll_token(http, device_code)->TokenSet`(一次尝试,`AuthorizationPending`/`SlowDown` 异常供调用方循环)、`refresh(http, refresh_token)->TokenSet``encrypt_oauth_bundle`/`decrypt_oauth_bundle`Fernet 复用 `security/credentials` `encrypt_api_key`/`decrypt_api_key`工作在 JSON 串上`needs_refresh(token)`client_id env `KIMI_CLIENT_ID` 可覆盖默认 `17e5f671-...`device authorization **带 `scope=kimi-code`**coding entitlement实测省略 scope`api.kimi.com/coding/v1` 401故重新带上token 交换/刷新不带 scope)。
- **@frontend 行动K1.4**`pnpm gen:api` 纳入 OAuth start/disconnect/status + 3 schema连接流 = start user_code + verification_uri 轮询 `GET /jobs/{job_id}` done已连接/failed过期/拒绝status 端点查连接态档位路由到 `kimi-code:kimi-for-coding`
## C8 · Skill registry + 表权限沙箱 owner @backend 状态: 稳定T5.5, 2026-06-19M5 R3 迁入 ww_skills 包, 2026-06-19
- 来源`ARCHITECTURE.md §5.6` / `PRODUCT_SPEC §5.5`声明式 Skill = AgentSpec「声明式权限 + apply 层白名单」,**非进程沙箱**)。消费方M5 编排/生成入库层技能库 UIT5.6 C3)。
- **实现位置M5 R3 已迁移**实现现落 `packages/skills/ww_skills/{skill_registry,skill_permissions}.py``packages/skills` 已是 uv workspace member)。`ww_skills/__init__.py` **直接导出**不再是再导出 ww_core 的门面)。消费方 `from ww_skills import SkillRegistry/SqlSkillRepo/SkillRecord/SkillRepo/partition_writes/filter_reads/validate_declaration/KNOWN_TABLES`。**`ww_core.domain` 已删除这两个模块 + `__init__` 导出**不再经 ww_core.domain 暴露 skill 符号)。历史T5.5 曾因 skills workspace member 暂落 ww_core.domain decisions现已迁回
- **registry**`SkillRecord{name,scope,tier:Tier,system_prompt,reads:list[str],writes:list[str],genre?}`frozen`skills` 行的声明式快照input/output JSON Schema 暂不携带 Python 类型——纯声明执行)。`SkillRepo`(Protocol)`.list_all()->list[SkillRecord]``SqlSkillRepo(session)` `skills` `tier` 非法 VALIDATION)。`SkillRegistry.load(repo)`classmethodasync)→ 每条跑 `validate_declaration`越权声明 **AppError VALIDATION**加载中断)→ frozen `dict[name,AgentSpec]``get(name)->AgentSpec` NOT_FOUND)、`names()->list[str]``list_scope(scope)->list[AgentSpec]`不变量 #2只带 `tier`不解析 modelmodel 解析在网关)。
- **表权限沙箱**`skill_permissions.py`纯函数不可变`KNOWN_TABLES`10 创作表白名单projects/characters/world_entities/outline/chapter_digests/foreshadow/style_fingerprint/rules/chapters/chapter_reviews系统/运营表不在列)。`filter_reads(spec, available)->dict`注入只喂声明 `reads` 的表`partition_writes(spec, produced)->(allowed:dict, rejected:list[str])`写库只应用声明 `writes`越权表名进 rejected 供审计 log + 丢弃`validate_declaration(spec)`reads/writes `KNOWN_TABLES` VALIDATION)。不变量 #3`allowed` 仍须过验收 gate 才真入库本层只裁剪白名单不开写后门
- 注入缝`get_skill_registry``SkillRegistry.load(SqlSkillRepo(session))`async dep)。T5.2 生成入库层应用 `partition_writes` 落自定义 skill 产出仅声明 writes 越权审计仍经验收
- **@llm 注意**自定义 skill 经网关执行时复用 §5.1 机制registry spec网关按 tier 路由apply 编排/入库须调 `filter_reads`/`partition_writes` 守白名单
## C3.5 · jobs 长任务基建(写/进度/回收 + run_job owner @backend 状态: 稳定T4.1, 2026-06-18
- 来源ARCH §7.4`jobs` + `GET /jobs/:id` 轮询BackgroundTasks 无专用队列)。`jobs` + `Job` 模型 + `GET /jobs/{id}` 读端点已 T0.3 建齐T4.1 **/进度/回收** **T4.3「学文风走 jobs」** 复用
- 位置`packages/core/ww_core/domain/job_repo.py` `ww_core.domain` 导出 `JobRepo`/`JobView`/`SqlJobRepo`+ `apps/api/ww_api/services/job_runner.py``run_job`+ `services/project_deps.get_job_repo` + lifespan reaper
- **`JobView`**(frozen Pydantic, snake_case)`id:uuid``kind:str``status:str``progress:int=0``result:dict|None=None``error:str|None=None`镜像 `GET /jobs/{id}` 出参)。
- **`JobRepo`** Protocol写方法** flush** commit提交归 `run_job`/端点 `reap_zombies` commit
- `async create(project_id: uuid|None, kind: str) -> JobView`status=`queued`, progress=0
- `async set_running(job_id) -> JobView`status=`running`
- `async set_progress(job_id, pct: int) -> JobView`pct 夹取到 0..100
- `async complete(job_id, result: dict) -> JobView`status=`done`, progress=100, result=<dict>
- `async fail(job_id, error: str) -> JobView`status=`failed`, error=<str>
- `async get(job_id) -> JobView | None`
- `async reap_zombies() -> int`bulk UPDATE `running``failed`(+error 文案),返回被改条数,**自 commit**
- 状态写方法对不存在的 job 抛 `LookupError`。状态常量导出:`STATUS_QUEUED/RUNNING/DONE/FAILED``PROGRESS_COMPLETE=100``job_repo` 模块级)。
- **`run_job` 缝**`services/job_runner.py``async def run_job(session_factory: SessionFactory, job_id: uuid, work: JobWork, *, request_id: str|None=None, repo_factory=_default_repo_factory) -> None`,其中 `JobWork = Callable[[AsyncSession], Awaitable[dict]]`。语义:**自建独立 session**`session_factory`,仿 `run_overdue_scan`,请求 session 已关闭)→ `set_running``await work(session)`(业务写与 job 状态写同一 session`complete(job_id, work 返回值)``commit`;异常路在**全新** session 里 `fail(job_id, str(exc))` + `commit`**异常被吞不冒泡**(后台任务边界)。`SessionFactory` 复用 `services/foreshadow_scan.SessionFactory`= `get_session_factory`/`get_sessionmaker()`)。`repo_factory: Callable[[AsyncSession], JobLifecycleRepo]`(最小 Protocol`set_running`/`complete`/`fail`)是可注入缝,单测注 fake。
- **lifespan reaper**M4-d`main.py` `_lifespan``seed_stub_user` 之后 `SqlJobRepo(session).reap_zombies()`(幂等、自 commit——进程重启丢 BackgroundTask 的残留 `running` 行标 `failed`,用户可见可重试。
- **T4.3 行动**`POST /style``job_repo.create(project_id,"style_learn")` + commit 返 202 `{job_id}``background_tasks.add_task(run_job, session_factory, job.id, work=<部分应用 run_style_extraction→StyleFingerprintWriteRepo.append 并返回 {version,dims_count} 摘要>)``work` 内自建 gatewayanalyst+ repo`run_job` 传入的独立 session。注入缝 `get_job_repo`/`get_session_factory` 测试经 `app.dependency_overrides` override。
## C4 · 编排器接口LangGraph 写章图 owner @llm 状态: 稳定M1/T1.3, 2026-06-18M2 扩四审/验收)
- 来源:`ARCHITECTURE.md §5.2`(图)/ §7.3SSE。**M1 仅单 `write` 节点**;并行四审/collect/interrupt(accept) 属 M2。
- 位置:`packages/core/ww_core/orchestrator/`
@@ -82,16 +225,48 @@
- `normalize_review` SSE向后兼容`section`/`conflict` 不变)新增:`foreshadow{kind,code?,title,where?,note?}`(每条建议一条)、`pace{water:[{where,reason}],hook:bool,beat_map:[int]}`一条。键按字典序确定性遍历continuity<foreshadow<pace)。incomplete 审仅发 `section` 不发结果
- 消费方前端 T3.6冲突就地标注 / 伏笔看板联动 / 节拍图 ▁▃▅)、E2E T3.7
### C4 扩展T4.2, 2026-06-19· 四审齐style 漂移并入)+ style SSE
- `REVIEW_SPECS = (continuity_spec, foreshadow_spec, pace_spec, style_drift_spec)``build_review_graph(...)` 默认四审齐图工厂/`run_review`/`make_review_node` spec 泛型**无改动** `req.output_schema` parsedstyle 天然套循环)。
- collect 列映射新增`extract_style(reviews)->dict|None` style ok 结果整 dict`collect_reviews` 末尾 `style=extract_style(reviews)` 传入 `review_repo.record(...)``chapter_reviews.style` JSONB 列已存在)。无指纹降级score=100/空段)仍是 `ok` 结果照常落库区别于 incompleteNone)。
- `normalize_review` SSE向后兼容新增`style{score:int, segments:[{idx:int,score:int,label:str|None}]}`一条)。键按字典序遍历 continuity<foreshadow<pace<style确定性顺序incomplete 审仅发 `section{status:incomplete}` 不发结果
- 常量/工厂导出 orchestrator `__init__``STYLE="style"``extract_style`collect`EVENT_STYLE="style"``style_event(*, score, segments)`sse)。
- 消费方前端 T4.4StylePanel 相似度 + 漂移段一键回炉`section{name:"style"}`/`style{...}` reduce)、E2E T4.5断言事件真发 + `chapter_reviews.style` 真填)。
### C6 扩展T4.2, 2026-06-19· style-auditor 双轨 + refiner spec/schema
- 位置`packages/agents/ww_agents/{schemas.py,specs.py}` `ww_agents` 导出提取节点在 `packages/core/ww_core/orchestrator/style_extract_node.py` orchestrator 导出)。
- **提取轨 schema**`StyleDimension{name:str, value:str, evidence:list[str]=[]}``StyleFingerprintResult{dimensions:list[StyleDimension]=[]}`16 = 9 通用 + 7 中文网文每维带原文证据)。
- **漂移轨 schema**`StyleDriftSegment{idx:int, score:int, label:str|None=None}``StyleDriftReview{score:int=100, segments:list[StyleDriftSegment]=[]}`**默认值即无指纹降级态**score=100/空段)。
- **`style_extract_spec`**(frozen `AgentSpec`)`name="style_extract"`, `tier="analyst"`, `output_schema=StyleFingerprintResult`, `reads=["style_fingerprint"]`, `writes=["style_fingerprint"]`声明式真写库经 T4.3)。独立生成仿 outliner**不进 review **。
- **`style_drift_spec`**`name="style"`(=section/列名), `tier="light"`, `output_schema=StyleDriftReview`, `reads=["style_fingerprint"]`, `writes=[]`只读第四审)。system_prompt 材料无指纹返回 score=100/空段」降级指令。
- **`refiner_spec`**`name="refiner"`, `tier="writer"`, `output_schema=None`纯文本重写段, `reads=[]`, `writes=[]`非持久端点同步返回 `{original,refined}` 不写库作者采纳经既有 draft 自动保存合入不变量 #3)。
- 提取节点缝**模块内自有 `GatewayRun` Protocol**不跨模块复用不在 `__init__` 重复导出 gotcha`build_style_extract_request(spec, *, samples_text, user_id, project_id)->LlmRequest`(`output_schema=StyleFingerprintResult`,`stream=False`,system 缓存断点前块) + `async run_style_extraction(spec, *, samples_text, gateway, user_id, project_id)->StyleFingerprintResult`(`gateway.run().parsed`**只读不写库**网关失败**直接上抛**不隔离parsed 违约抛 `ValueError`仿 run_outline)。
- 消费方T4.3 端点 `POST /style` BackgroundTask `run_style_extraction` `StyleFingerprintResult` `dimensions_json`/`evidence_json` `style_fingerprint` `POST .../refine` `refiner_spec`(writer 网关) `run()` 重写段返回 `{original,refined}`。**@frontend 行动**T4.4 `pnpm gen:api` 纳入 style/refine/jobs 端点
### C6 扩展T5.1, 2026-06-19· worldbuilder + character-gen spec/schema + 入库前 continuity 校验缝
- 状态**稳定**。位置`packages/agents/ww_agents/{schemas.py,specs.py}` `ww_agents` 导出生成节点在 `packages/core/ww_core/orchestrator/generation_node.py` orchestrator 导出)。**T5.2 入库端点据此构建**field 名已对齐 `world_entities`/`characters` DB T5.2 ingest 直接映射)。
- **worldbuilder schema**`WorldEntityCard{type:str, name:str, rules:list[str]=[]}`rules = **显式硬规则清单**,供 continuity 引用)、`WorldGenResult{entities:list[WorldEntityCard]=[]}`映射 `world_entities` type/name + rules JSONBDB `rules` JSONB入库可包成 `{"rules":[...]}` 或裸 list T5.2 定夺)。
- **character-gen schema**`CharacterRelation{name:str, kind:str, note:str|None=None}``CharacterCard{name:str, role:str, traits:list[str]=[], backstory:str, arc:str, speech_tics:list[str]=[], tags:list[str]=[], relations:list[CharacterRelation]=[]}`**必填**name/role/backstory/arc其余集合默认空)、`CharacterGenResult{cards:list[CharacterCard]=[]}`映射 `characters` name/role/backstory + traits/arc/speech_tics/tags/relations 入各自 JSONBDB `traits`/`arc`/`speech_tics` JSONB dict `tags`/`relations` JSONB list——T5.2 ingest 须把 `list[str]` traits/speech_tics 包进 dict 或调整 DB 列形落库`role` = 角色定位 主角/CP/对手/导师/工具人)。
- **`worldbuilder_spec`**(frozen `AgentSpec`)`name="worldbuilder"`, `tier="writer"`, `output_schema=WorldGenResult`, `reads=["projects"]`, `writes=["world_entities"]`声明式真写库经 T5.2)。独立生成仿 outliner**不进 review **。system_prompt 强调硬规则显式可校验
- **`character_gen_spec`**`name="character-gen"`, `tier="writer"`, `output_schema=CharacterGenResult`, `reads=["world_entities","characters"]`, `writes=["characters"]`system_prompt **注入「已生成卡 + 已有角色」差异化要求M5-a 防雷同)**——已有角色」「已生成卡」「防雷同字样
- 生成节点缝**模块内自有 `GatewayRun` Protocol**不跨模块复用不在 `__init__` 重复导出 gotcha导出公共函数 + 三个 context builder
- `async run_worldbuilder(spec, *, brief, project_context, gateway, user_id, project_id)->WorldGenResult`+ `build_worldbuilder_context(*, brief, project_context)->str`)。
- `async run_character_gen(spec, *, brief, count:int, role:str|None, world_context:str, existing_chars:Sequence[CharacterCard], generated_so_far:Sequence[CharacterCard], gateway, user_id, project_id)->CharacterGenResult`+ `build_character_gen_context(*, brief, count, role, world_context, existing_chars, generated_so_far)->str`——**防雷同上下文**注入已有+已生成卡的 `name(role)traits` 简表要求差异化空时给「(暂无…)」占位不崩)。
- **入库前 continuity 校验缝ARCH §6.5T5.2 **`async precheck_generated_cards(spec, *, cards:Sequence[CharacterCard], world_context:str, characters_context:str, gateway, user_id, project_id)->list[Conflict]`+ `build_precheck_context(*, cards, world_context, characters_context)->str`)。**复用 `continuity_spec`**analyst 只读跑生成卡 vs 世界观/已有角色真相源比对返回 `list[Conflict]`C6 `Conflict` 五类)。**编排器追加的检查 character-gen 直接互调** §5.4 数据流/不变量 #1)。T5.2 入库端点用返回的 conflicts gate有冲突提示作者裁决/调整不静默入库)。
- 三函数均生成用 `run()` `stream()``output_schema=spec.output_schema`system 缓存断点前块cache=True#9);网关失败**直接上抛**端点处理独立生成不做失败隔离parsed 违约抛 `ValueError`仿 run_outline/run_style_extraction**只读不写库**#3)。
- 测试替身复用 `packages/core/tests/fakes_orchestrator.py` `FakeRunGateway`/`SchemaRoutingRunGateway`/`FailingRunGateway` `req.output_schema` 路由 parsed)。
- 消费方**T5.2 端点** `POST /world/generate` `run_worldbuilder``POST /characters/generate` `run_character_gen`批量循环时把已产卡传 `generated_so_far` 防雷同)、`POST /characters`(入库) 前调 `precheck_generated_cards` gateanalyst 网关复用 `build_gateway_for_tier(session,store,"analyst")`writer `"writer"`记账闭环同既有端点末尾 commit)。
## C5 · 记忆服务 `assemble` / `select_relevant_entities` owner @backend 状态: 稳定T1.2, 2026-06-18
- 来源`ARCHITECTURE.md §3.4 / §5.3`确定性选择显式+主角+近况渲染卡片缓存断点)。
- 位置`packages/core/ww_core/memory/`+ `domain/repositories.py`)。
- 输出决策中性文本 `LlmRequest` decisions 2026-06-18
- `AssembledContext{ stable_core:str, volatile:str, selection:SelectionTrace }`
- `stable_core`断点前世界硬规则+定型主角( latest_state)+文风指纹+合并规则已排序/无时间戳/ UUID
- `volatile`断点后注入卡片( latest_state)+伏笔窗口+近况摘要+本章 beats)。
- `stable_core`断点前**作品蓝本(title/logline/premise/theme)**+世界硬规则+定型主角( latest_state)+文风指纹+合并规则已排序/无时间戳/ UUID作品蓝本是书级 spec定型 入缓存前缀首段
- `volatile`断点后**写作指令请创作第 {chapter_no} 章的正文。」始终在场**+注入卡片( latest_state)+伏笔窗口+近况摘要+本章 beats)。
- **空-prompt 保证2026-06-19 bugfix**哪怕世界观/角色/大纲全空只要项目有 premise/title`stable_core`(含作品蓝本) `volatile`(含写作指令) **均非空** writer user message 永不为空 LLM `400 "message at position 0 ... must not be empty"`)。
- `SelectionTrace{ selected:list[SelectedEntity] }``SelectedEntity{ kind:"character"|"world_entity", name:str, reasons:list[SelectionReason] }``SelectionReason="explicit_beat"|"main_character"|"recent_digest"|"foreshadow_window"`
- 入口`async assemble(repos:MemoryRepos, project_id, chapter_no, recent_k=5)->AssembledContext`纯函数 `select_relevant_entities(*, outline, characters, world_entities, recent_digests)->SelectionTrace``render_cards(selection, characters, world_entities)->str``merge_rules(rules)->list[RuleView]`globalgenrestyleproject)。
- 依赖注入`MemoryRepos` 捆绑 7 ProtocolOutline/Character/WorldEntity/Digest/Foreshadow/Style/Rules单测注入内存 fake运行时 `sql_memory_repos(AsyncSession)`**T1.4 注入点**)。
- 依赖注入`MemoryRepos` 捆绑 **8** ProtocolOutline/Character/WorldEntity/Digest/Foreshadow/Style/Rules/**Project**单测注入内存 fake运行时 `sql_memory_repos(AsyncSession)`**T1.4 注入点**)。`ProjectSpecRepo.spec(project_id)->ProjectSpecView|None``ProjectSpecView{title, logline?, premise?, theme?}` project_id `projects` 2026-06-19 新增**消费方须在 `MemoryRepos(...)` `project=` 字段**)。
- 不变量确定性选择(无向量 #6)统一 project_id 过滤3.5)latest_state 严格归 volatile(#9)。
- 消费方**T1.3 write 节点** 构造 `LlmRequest(system=[Block(stable_core, cache=True)], input=volatile)`

View File

@@ -14,6 +14,64 @@
---
## [2026-06-19] Kimi Code OAuthtoken 存储包 + 刷新启发式 + 省略 scope + 后台轮询走 jobs — @backend (K1.3)
- 背景Kimi 订阅 plan 走 device-flow OAuth需定 token 持久化形、何时刷新、device authorization 是否带 scope、后台轮询如何挂。
- 选择:
1. **存储包**`TokenSet{access_token, refresh_token, expires_at}` 序列化为 JSON 串经 Fernet复用 `encrypt_api_key`,工作在 str 上)加密入 `provider_credentials.oauth_enc`C2 扩 K1.1 列)。`expires_at` 存**服务端驱动的绝对 UTC 时刻**(从 `expires_in` 计算入库时刻)。明文 token 绝不进日志/响应/job 结果。
2. **刷新启发式**:建网关时若 access token 剩余寿命 < `MIN_REFRESH_BUFFER_SECONDS=300`300s 缓冲,对 ~15min access 足够),调 `refresh` 换新包并 `upsert_oauth_credential` 持久化(下次复用)。任务文档原提 `max(300, 0.5*expires_in)`——本实现采固定 300s 缓冲(不存原始 expires_inKISS对 15min access 等价于 0.5*expires_in=450s 略激进但安全)。
3. **省略 scope**device authorization 不带 `scope`(匹配研究确认的 kimi-cli v1.41.0+server 拒绝时再加 `scope=kimi-code` 兜底——本期先不带。client_id env `KIMI_CLIENT_ID` 可覆盖默认。
4. **后台轮询走 jobs**`POST .../start` 创建 `jobs(kind="kimi_oauth")` 返 202 + user_code复用 K1.1/M4 `run_job` 在独立 session 上**循环** `poll_token`(每 interval 秒,`authorization_pending` 续轮询、`slow_down` 增大 interval、过期/拒绝抛 AppError 置 job failed成功落加密 token + job done结果只 `{connected, provider}`)。前端轮询 `GET /jobs/{id}`
- 理由 / 取舍:复用既有 jobs/run_job 基建(无新长任务机制);固定 300s 缓冲省去存 expires_inYAGNI。代价refresh-on-build 每次建网关多一次解密 + 可能一次刷新网络调用(仅临近过期时)。
- 影响C3 扩OAuth 3 端点C2 扩 K1.1 的 `StoredCredential` 收口api_key_enc→nullable + auth_type/oauth_enc`_build_provider_adapter` 加 OAuth 分支。K1.4 前端连接流、K1.5 E2E真账号联网验证含封号风险用户自担
## [2026-06-19] Kimi Code OAuthdevice authorization **重新带 `scope=kimi-code`**(实测 401 后修正,超越上条第 3 点)— @backend (K1.3 fix)
- 背景K1.5 真账号联网实测——device-flow 登录成功拿到真 JWT但调 `api.kimi.com/coding/v1` 被拒 `401 Invalid Authentication`。根因device_authorization 省略了 `scope`,拿到的 token 缺 coding entitlement。
- 选择:`start_device_authorization` 的请求体在 `client_id` 之外加 `scope=kimi-code`(模块常量 `KIMI_CODE_SCOPE`)。**修正上条决策第 3 点的「省略 scope」**——`scope=kimi-code` 是 coding-agent 文档化 OAuth scope`ooojustin/opencode-kimi` `constants.ts` 发送之kimi-cli v1.41.0 已不发但服务端仍接受)。
- 范围scope **只**放 device_authorization 请求token 交换(`poll_token`/刷新(`refresh`**不带** scopeOAuth device flow 惯例,参考实现亦然)。
- 影响device-auth 请求体现为 `{"client_id": <id>, "scope": "kimi-code"}`K1.3 单测 `test_start_device_authorization_parses_response_and_sends_scope``data["scope"]=="kimi-code"`。无 OpenAPI 形变,前端无需 re-gen。
## [2026-06-19] Skill registry + 表权限沙箱迁回 `ww_skills` 包(@devops 已纳入 workspace— @backend (M5 R3)
- 背景T5.5 曾因 `packages/skills` 非 uv workspace member 把 `skill_registry`/`skill_permissions` 暂落 `ww_core.domain``ww_skills/__init__` 做再导出门面(见上 T5.5 决策)。@devops 现已把 `packages/skills` 做成真 workspace member`ww_skills` 可 import、有自己的 pyproject、`uv sync` 完成)——前提消除。
- 选择:把两个模块**物理迁入** `packages/skills/ww_skills/{skill_registry,skill_permissions}.py`(内部相对 import 从 `ww_core.domain.skill_permissions` 改为 `ww_skills.skill_permissions``ww_skills/__init__` 改为**直接导出**(不再 re-export ww_core删除 `ww_core.domain` 的两个模块文件 + `__init__` 里的 skill 导出。所有 importer`apps/api` `generation.py`/`project_deps.py` + `test_generation.py`)改 `from ww_skills import …`skill 单测从 `packages/core/tests/` 移到 `packages/skills/tests/`import 也改 `ww_skills`)。
- 理由 / 取舍恢复任务文档原定位skill 逻辑归 skills 包),物理位置与 §5.6 措辞一致ww_core 不再承载 skill 符号(关注点分离)。代价:`packages/skills/pyproject.toml` deps 需校准——`ww_skills` 直接 import `ww_db`/`ww_agents`/`ww_shared`/`ww_llm_gateway`(`Tier`),故去掉不再用的 `ww-core`、补 `ww-llm-gateway` + `sqlalchemy``SqlSkillRepo``select`/`AsyncSession`);改后 `uv sync` 重建 ww-skills。
- 影响C8 实现位置更新contracts.md C8 状态加「M5 R3 迁入 ww_skills 包」);消费方 import 路径从 `ww_core.domain`/`ww_core.domain.skill_registry``ww_skills``ww_core.domain.skill_*` 导入路径**已失效**(凡 grep 到旧路径须改)。`packages/skills/pyproject.toml` 改了 deps@backend 可编辑的 skills 目录内,但属打包元数据)——已 PROGRESS 通知 @devops 复核根锁。
## [2026-06-19] `build_gateway_for_tier` 改用 `build_adapter` 工厂per-provider 适配器)— @backend (M5 R1 #2)
- 背景T5.4 接线只为回退链里的每个 provider 一律建 `OpenAICompatAdapter`——Anthropic/Gemini 即便配进 `tier_routing` 链也因 `_PROVIDER_BASE_URLS` 无 base_url 被跳过、拿不到真实适配器。@llm 加了 `build_adapter(provider, *, api_key, base_url=None)` 工厂C1 扩 follow-up #2)按 provider 选适配器类。
- 选择:`_build_openai_compat_adapter` 改名 `_build_provider_adapter`去掉「base_url 缺失即返 None」分支改调 `build_adapter(provider, api_key=…, base_url=_PROVIDER_BASE_URLS.get(provider))`——OpenAI 兼容 provider 传 base_urlAnthropic/Gemini 传 None 走原生适配器。仅在凭据缺失时返 None回退链跳过。保留既有 503/凭据/单 provider 兼容行为。
- 理由 / 取舍:最小改动让回退链支持非 OpenAI-兼容 provideradapter 选择逻辑收敛到网关包的工厂单一真源apps/api 不再硬编码适配器类。代价:真实 Anthropic/Gemini 仍需 @devops 加 SDK 依赖(适配器懒导入)。
- 影响C3 扩build_gateway_for_tier 接线段更新);`apps/api` 去掉 `AsyncOpenAI`/`OpenAICompatAdapter` 直接 import。T5.7 切 provider E2E 可扩 Anthropic/Gemini 路径。
## [2026-06-19] 设定库 Codex 读端点复用 C5 读侧 + 反向 JSONB 解包 — @backend (M5 R1)
- 背景PROGRESS 余项①——Codex 设定库本应展示 characters/world_entities **读/管理**视图,但后端只有生成/入库写端点,前端只能「生成+本次入库会话内回显」。
- 选择:加 `GET /projects/:id/characters` + `GET /projects/:id/world_entities`**复用 C5 读侧** `SqlCharacterRepo`/`SqlWorldEntityRepo`(经既有 `get_memory_repos` dep不新增 repo、不动 assemble响应做 T5.2 ingest 形变的**逆向**解包(`{"items":[...]}`/`{"text":...}`/`{"rules":[...]}`→裸 list/str复用既有 `_existing_characters` helper。无行返空列表非 404列表语义
- 理由 / 取舍KISS——读端点是写入形变的镜像复用已有读侧 + helper 零新依赖GET 与 POST 同路径靠 method 区分无冲突。代价:读侧 CharacterView 仍是裸 dict端点层做解包与写侧 repo 的包裹对称)。
- 影响C3 扩(两读端点);@frontend `pnpm gen:api` + CodexPage 初始列表拉全量(去掉「会话内」局限,解 gotcha 2026-06-19 @frontend Codex 缺口条)。
## [2026-06-19] 角色入库 continuity gate有冲突→409 CONFLICT_UNRESOLVED作者 acknowledge 后放行 — @backend (T5.2)
- 背景ARCH §6.5 要求生成角色入库前过 continuity 校验(`precheck_generated_cards`但任务文档留了两条路线block / 返回冲突供作者裁决)。要选最简单正确、且守不变量 #3「无 AI 静默写库」。
- 选择:入库端点跑 precheck检出冲突且请求未带 `acknowledge_conflicts=true`**409 `CONFLICT_UNRESOLVED`** + `details{conflicts:[...], conflict_count}`(不写库)。作者在前端查看冲突、裁决后重发带 `acknowledge_conflicts=true` → 放行写库。零冲突 → 直接写库 201。
- 理由 / 取舍:复用既有 `CONFLICT_UNRESOLVED`(409) 码(语义=未决冲突禁写入,与 accept gate 一致),无需新增错误码;显式 `acknowledge` 标志=作者裁决的 HITL gate不静默入库守不变量 #3),比「自动返回冲突 + 另开裁决端点」更省(无新端点、无新状态表,原型 KISS。代价作者确认 = 重发一次请求(带 flag可接受。
- 影响C3 扩POST /characters`partition_writes` 在 gate 后再过写白名单character-gen 只声明 writes=characters。T5.6 前端入库流:预览→点入库→若 409 展示冲突→裁决勾选→带 acknowledge 重发。T5.7 E2E 覆盖两条路径。
## [2026-06-19] 角色卡 schema↔DB 形变落写侧 repolist→{"items"}、arc str→{"text"}、rules→{"rules"})— @backend (T5.2)
- 背景T5.1 gotcha 指出 `CharacterCard.traits`/`speech_tics`(list)/`arc`(str) 与 DB `characters` JSONB **dict** 列不同形;`WorldEntityCard.rules`(list) 与 `world_entities.rules` JSONB dict 列不同形。需定一个固定包裹形。
- 选择:写侧 repo 做转换层——`traits`/`speech_tics` 包成 `{"items":[...]}``arc` 包成 `{"text":...}``rules` 包成 `{"rules":[...]}``tags`/`relations` 是 JSONB list 直落。读回(喂防雷同/precheck反向解包同形`_existing_characters`/`_world_context`)。
- 理由 / 取舍:固定 wrapper key 让 round-trip 确定(写=读互逆),不改 @db 列类型(@db owned。代价assemble/C5 读侧 `CharacterView.traits` 仍是裸 dict本期 assemble 不消费 items 内层,无影响);若后续 assemble 要渲染 traits 文本,按同 wrapper 解包即可。
- 影响C3 扩(写侧 repo 形变契约ww_core.domain 新增 `character_repo`/`world_entity_repo`@db 若日后规整列类型为 list可去 wrapper迁移由届时 owner 执行)。
## [2026-06-19] 顺带补 `GET /projects/:id/style` 读指纹 + 写侧 repo 独立读视图 — @backend (T4.3)
- 背景UX §6.9 要展示完整 16 维指纹 + 证据,但 ARCH §7.2 端点清单**无**读指纹端点(只 `POST /style`。C5 读侧 `SqlStyleRepo.latest` 已存在,但其 `StyleView` **只含 `dimensions`**assemble 只需维度文本,不需证据/版本),扩它会牵动 C5 assemble 契约。
- 选择:(1) **加 `GET /projects/:id/style`** 返最新指纹 `{dimensions, evidence, version}`无指纹→404 `NOT_FOUND`)。(2) 不动 C5 读侧——在写侧 `SqlStyleFingerprintWriteRepo` 上加一个 `latest(project_id)->StyleFingerprintView`(含 evidence_json + version 的更全视图),`GET /style` 用它C5 `SqlStyleRepo.latest`/`StyleView`(只 dimensions供 assemble stable_core保持不变。
- 理由 / 取舍:读写分离 + 各自最小视图(同 `DigestAppendRepo` vs 读侧 `DigestRepo` 先例),不破坏 C5 assemble前端无须靠 job `result` 拼凑摘要、可直接拉完整指纹。
- 影响C3 扩(新增 `GET /style`**待回写规格**ARCH §7.2 端点清单 + PRODUCT_SPEC §6 应补 `GET /projects/:id/style`spec 一致性纪律,@docs 回写。T4.4 前端 FingerprintView 消费此端点。
## [2026-06-19] 学文风 `work` 自建网关+repo不走 FastAPI dep凭据探测前移到请求阶段 — @backend (T4.3)
- 背景:学文风走 jobsM4-c`POST /style` 立即返 202提取经 BackgroundTask `run_job` 在**独立 session** 上跑(请求 session 已关闭。但「无凭据→503」需在请求阶段就让用户知道不能 202 后再静默 fail job
- 选择:(1) `work(session)` 闭包内**自建** analyst 网关(`build_gateway_for_tier(session, SqlCredentialStore(session), "analyst")`+ 写侧 repo`run_job` 传入的独立 session不依赖 FastAPI depdep 绑的是请求 session。(2) 端点仍声明 `get_style_extract_gateway`(analyst) 依赖**仅作凭据探测**——无凭据时 dep 解析阶段抛 503`job_repo.create` 之前拦下;其返回的网关本身不被复用(请求 session 即将关闭)。
- 理由 / 取舍兼顾「BackgroundTask 必须自建 session」gotcha与「无凭据请求阶段即 503」友好、不写注定失败的 job。代价凭据被探测两次请求阶段 + 后台 work原型可接受。
- 影响C3 扩POST /style测试后台路径 monkeypatch `style.build_gateway_for_tier`/`style.SqlStyleFingerprintWriteRepo` + `job_runner.SqlJobRepo`(见 gotcha
## [2026-06-18] 网关结构化输出经可注入 `StructuredClient` 缝instructor— @llm
- 背景:`OpenAICompatAdapter` 需接 instructor 产结构化输出C1 类型预留 `output_schema`/`parsed` 但 M1 未接线),但测试绝不可联网/碰真实 LLM。
- 选择:定义 `StructuredClient` Protocol`create_with_completion(*, messages, response_model, **kw) -> (parsed, raw)`,即 `instructor.AsyncInstructor` 的形adapter 构造可选注入 `structured_client`,未注入时懒构建 `instructor.from_openai(self._client)``run()` 透传 `result.parsed → LlmResponse.parsed`
@@ -78,3 +136,9 @@
- 选择CLAUDE.md 只保留跨文档、易错的**规则**(含 9 条架构不变量);所有枚举型/具体值改为指向 ARCHITECTURE/PRODUCT_SPEC 对应 §(唯一真源)。新增"冲突裁决"段ARCHITECTURE > UX_SPEC/DEV_PLAN > PRODUCT_SPEC矛盾时回写上游。
- 理由 / 取舍:消除文档间漂移面、降低每 session 上下文成本;代价是查具体值需多跳一层到 ARCHITECTURE可接受
- 影响:编辑 CLAUDE.md 的任何 agent 遵循此纪律——契约/枚举值落在 spec 与 `memory/contracts.md`,不在 CLAUDE.md。
## [2026-06-19] T5.5 Skill registry + 表权限沙箱落 `ww_core.domain`,非 `packages/skills/ww_skills` — @backend
- 背景T5.5 任务文档把 registry/沙箱定位在 `packages/skills/ww_skills/`,但该包当前**不是 uv workspace member**(根 `pyproject.toml``[tool.uv.workspace].members` 不含它、mypy_path/ruff src 也不含),无 pyproject → 仅靠 sys.path hack 才能 importapp/测试/mypy 都无法干净消费它。根 pyproject 由 @devops 持有,自己接线会越权 + 阻塞。
- 选择:把实现落在已是 workspace member、且承载「编排/写库 apply 层」的 `ww_core.domain``skill_registry.py` + `skill_permissions.py`,经 `ww_core.domain` 导出)。`ARCHITECTURE §5.6` 明确「强制点在编排/写库层」=ww_core语义上也吻合。`packages/skills/ww_skills/__init__.py` 留**再导出门面**import 自 ww_core保留稳定「skills」导入名。
- 理由 / 取舍非阻塞、门禁可干净覆盖ruff/mypy/pytest 全跑)、不越权改根配置;代价是物理位置与任务文档措辞略偏,但功能/契约C8完整。待 @devops`packages/skills` 纳入 workspace + mypy_path 后,可平滑迁移实现到 `ww_skills` 而不动调用方(只改门面方向)。
- 影响:消费方 import `ww_core.domain``SkillRegistry`/`filter_reads`/`partition_writes`/`validate_declaration`(或经 `ww_skills` 门面)。若 @devops 后续纳入 workspace迁移由届时的 @backend/@llm 执行。

View File

@@ -7,6 +7,33 @@
---
- [2026-06-20] @frontend **页面重访回显已存内容用 RSC 读 helper`lib/api/server.ts`)作初值种入 client 组件404/错误降级、绝不阻塞进页**(大纲/写作重载):先前大纲页 `initialChapters=[]`、工作台 `useState("")` → 重访空白虽然库里有数据。范式:① **大纲**`fetchOutline(projectId)``getJson<OutlineResponse>``chapters??[]`**try/catch 整体降级为 `[]`**(项目存在无大纲后端返 200 空列表,但任何错误也不该让大纲页 500`useOutline(initial)` 已据 `initial.length>0``status="ready"`,传非空即回显,生成流照常覆盖。② **草稿**`fetchDraft` 直接复用既有 `getJsonOrNull`**404→null**,后端无草稿行正是 404页面 `draft?.content ?? ""``Workbench``initialText`。③ **不要把初值塞进 stream 的 reducer**——`Workbench``useState(initialText)` 起步即可:流式那条 `useEffect` 起始 `stream.state.text` 为空、仅当 `phase∈{streaming,done,aborted}` 且文本变化才 `setText`所以初值不会被空流反扑SSE+AbortController+PUT 自动保存全不用动。④ 测纯加载/降级逻辑:`lib/api/server.test.ts``vi.stubGlobal("fetch", …new Response(JSON, {status}))` + `afterEach(vi.unstubAllGlobals)`node 环境够用,`server.ts` 只依赖 `process.env`/`fetch`),断 200 解包、空/错误→`[]`、404→null。
- [2026-06-19] @qa **Kimi OAuth E2E 要 token 真落 pgoverride `get_session_factory`→`e2e_sm`、但**不能** monkeypatch `kimi_oauth.SqlCredentialStore`K1.3 单测那样会让落库走内存 fake验不到真 pg**K1.5):与 K1.3 单测FakeSession/FakeStore/FakeJobRepo验逻辑不同E2E 要验真持久化——让后台 `work` 自建的 `SqlCredentialStore(session)` + `run_job` 默认 `SqlJobRepo` **保持真**,只 override `get_session_factory``e2e_sm`(后台 work 用真 session 写真 pg。仍须monkeypatch **模块级** `routers.kimi_oauth._default_http_client`→同一 scripted fake后台 work 自建 http 非 dep+ `app.dependency_overrides[kimi_oauth._default_http_client]`→同一 fake端点 device-auth 走 depcall[0]=device-auth、call[1..]=token 轮询顺序共享)+ monkeypatch `routers.kimi_oauth.asyncio.sleep`→no-op。断「加密非明文」`_ACCESS_TOKEN.encode() not in row.oauth_enc`Fernet 密文里找不到明文字节)+ `decrypt_oauth_bundle` 回环。`Gateway` 无公开 adapters accessor → 用 `gateway._adapters[provider]``KimiCodeAdapter._client``.base_url`/`.default_headers`/`.api_key`(同 K1.2 单测。refresh-on-build 直测 `_build_provider_adapter`(非经 HTTP更清晰字符串 target monkeypatch `"ww_api.services.project_deps.httpx.AsyncClient"` + `setattr(project_deps,"kimi_refresh",fake)`。清理:删 `provider_credentials`(kimi-code) + `jobs`(kind=kimi_oauth) + `tier_routing`(project_id 为 NULL 的全局行)。ruff E501 按**显示宽度**算(中文字符占 2 列)——含中文注释/docstring 的行别贴边 100。
- [2026-06-19] @frontend **Kimi Code OAuth 三个端点无 params/body → openapi-fetch 用 `api.POST(PATH, {})` / `api.GET(PATH)`;路径用 `as const` 字面量常量复用**K1.4`POST .../oauth/start``.../oauth/disconnect``GET .../oauth/status` 的生成 schema 全是 `path?: never; ...; requestBody?: never`——`api.POST(START, {})`(传空 init objtypecheck 绿)、`api.GET("/settings/providers/kimi-code/oauth/status")`(无第二参)。把 `start`/`disconnect` 路径抽成模块级 `const START = "/settings/...start"``api.POST` 时,**字符串字面量类型仍被 openapi-fetch 推断为合法 path key**(无需 `as const`,但抽常量去重 DRY。② 连接流复用 `useJobPoll`M4零改`connect``data.job_id``poll.poll(job_id)`;轮询终态用 `useEffect([poll.status])` + `startedRef` 守门(同 `useStyleLearn` 先例,`initialPollState.status` 默认 `"polling"` 不能进页即据此显进度)。③ kimi_oauth job 完成态 result 只 `{connected, provider}`**无 token**)——`jobConnected(job)=job.result?.["connected"]===true`**别**期待/解析任何 token 字段。④ user_code a11y`<output tabIndex={0} className="select-all">`(可读、可全选复制、可聚焦),别用纯 `<span>`。⑤ 档位路由原是只读展示K1.4 改成可编辑per-tier `<select>` provider + model input + 保存 PUT `{tier_routing}`);选 OAuth providerkimi-code`applyProviderChange` 自动套 `defaultModel="kimi-for-coding"`API-key provider 清空 model 待填。
- [2026-06-19] @backend **Kimi OAuth 后台轮询 work 自建 http 走模块级 `_default_http_client()`(非 dep——测试须 monkeypatch 它 + `job_runner.SqlJobRepo`,不能只 override dep**K1.3`POST .../start``http` 是 FastAPI dep端点用但后台 `work``run_job` 独立 session 里调**模块级** `routers.kimi_oauth._default_http_client()` 自建 http请求 dep 已失效——E2E/单测必 `monkeypatch.setattr("ww_api.routers.kimi_oauth._default_http_client", lambda: fake_http)`(同一 scripted fake 实例供端点 device-auth call[0] + work poll call[1...] 顺序共享)+ `monkeypatch.setattr(job_runner,"SqlJobRepo", lambda s: fake_job_repo)`run_job 默认 repo_factory 引模块级 SqlJobRepoFakeSession 无 .execute同 T4.3 gotcha+ `monkeypatch.setattr(routers.kimi_oauth,"SqlCredentialStore", lambda s: shared_store)`work 自建 store 落库)+ override `get_session`→FakeSession/`get_session_factory`→FakeSessionFactory + monkeypatch `routers.kimi_oauth.asyncio.sleep`→no-op不真等 interval 秒。TestClient 同步跑完 background task断言稳定。
- [2026-06-19] @backend **`AsyncHttpClient` 最小 Protocol 故意不含 `aclose`(只 `post`router work 关客户端用 `getattr(http,"aclose",None)`**K1.3service 函数start/poll/refresh只需 `.post`,给 Protocol 加 `aclose` 会逼所有测试 fake 实现它mypy red`httpx.AsyncClient``aclose`,故 router `work``finally``aclose = getattr(http,"aclose",None); if aclose: await aclose()`——既关真客户端、又不污染最小 Protocol、fake 无需实现 aclose。**测试模块级属性 monkeypatch`project_deps.httpx`/`kimi_oauth.asyncio`mypy strict 报 `attr-defined`(模块未 re-export——用字符串 target `monkeypatch.setattr("pkg.mod.attr.sub", val)`mypy 不静态校验字符串)规避,别 `setattr(mod.attr, "sub", val)`。**
- [2026-06-19] @llm **Kimi Code 适配器:伪造头走 `AsyncOpenAI(default_headers=…)`、device_id 走 env-或-`uuid5` 确定性缺省绝不随机UA 验证为 `KimiCLI/1.5` 且参考实现未设 X-Msh-***K1.2):① coding 端点 OpenAI 兼容 → `KimiCodeAdapter` **直接子类化 `OpenAICompatAdapter`**(零重复 complete/stream/结构化逻辑),区别只在工厂构造的客户端带 `default_headers` + coding base_url + access_token 当 api_keySDK 自动发 `Authorization: Bearer`)。② `X-Msh-Device-Id` 必须**跨调用稳定**`kimi_device_id()` = env `KIMI_DEVICE_ID` 优先,否则 `uuid5(NAMESPACE_DNS, "ww.kimi-code.device")` 模块级常量——**别用 `uuid4()` 每次新建**(每次随机 device id 会让 Kimi 侧把每次调用当新设备)。③ **校验对照 `picassio/pi-kimi-coder` `extensions/index.ts`**:它对 coding API 只显式设 `User-Agent: "KimiCLI/1.5"`(精确值,本实现采用)、**没有** X-Msh-* 头——与 PROGRESS K1 契约「缺 X-Msh→403」**不符**。本实现按「UA 必需(已验证)+ X-Msh-* 附带(契约要求、真客户端会发、额外标识无害)」处理;**到底缺 X-Msh 会不会 403 须 K1.3/K1.5 对真 api.kimi.com 联网验证**(含封号风险)。④ 单测断「构造出的 `AsyncOpenAI` 携带正确 `base_url`/`default_headers`/`api_key`」即可,零联网;工厂分派测试访问 `adapter._client` 须先 `isinstance(adapter, KimiCodeAdapter)` 收窄(`build_adapter` 返回 `ProviderAdapter` Protocol 无 `_client`,否则 mypy `attr-defined`)。
- [2026-06-19] @db **K1.1 把 `provider_credentials.api_key_enc` 改 nullable 后,跨边界打穿到 @backend `credentials.py` mypy 报错——这是 K1.3 要吸收的K1.1 不越权修**`models.py``api_key_enc: Mapped[bytes | None]`,但 `apps/api/ww_api/services/credentials.py``StoredCredential.api_key_enc: bytes`dataclass+ `SqlCredentialStore.list_credentials/get_credential``r.api_key_enc`(现 `bytes|None`)→ 2 处 `arg-type` 错。@db 只拥有 `packages/db/`,不改 `apps/api/`(目录所有权)。**K1.3 @backend 行动**`StoredCredential.api_key_enc``bytes | None`(顺带加 `auth_type`/`oauth_enc` 字段 + OAuth 读写mypy 即绿。首个新迁移M1M5 全零建表)= K1 新功能预期内。
- [2026-06-19] @db **新迁移文件 autogenerate 后须手修两处再过 ruff**:① autogenerate 模板把 `from sqlalchemy.dialects import postgresql` **写两遍**(重复 importruff F811/格式炸)→删一行;② 模板用单引号 + import 顺序(`from alembic import op``import sqlalchemy as sa` 前)不合本仓 ruff双引号 + isort→照初始迁移 `220ca2e3d53f` 的样式手排(`import sqlalchemy as sa``from alembic import op``from sqlalchemy.dialects import postgresql`双引号docstring 后空行)。改完跑 `uv run ruff format` + `ruff check` 验。
- [2026-06-19] @llm **真 `anthropic`/`google-genai` SDK 装上后,注入客户端 Protocol 与 SDK 具体类型在 mypy strict 下不互通——在边界 `cast` 解决,别硬改 Protocol**R2 follow-up #2):① `instructor.from_anthropic(self._client)` 的重载只收 SDK 具体 `AsyncAnthropic|...` 联合,`AnthropicClient` Protocol 不匹配 → adapter 内 `cast("AsyncAnthropic", self._client)` 选中 AsyncInstructor 重载(`TYPE_CHECKING` 下 import避免运行时硬依赖 SDK。② `factory.build_adapter` 把**真实** `AsyncAnthropic`/`genai.Client` 传给 adapter 时SDK 客户端的 `messages`/`aio`**read-only 属性**,而 Protocol 成员默认 **settable** → mypy 报「expected settable variable, got read-only attribute」adapter 仅**读取**这些属性,故在工厂里 `cast("AnthropicClient"/"GeminiClient", client)` 跨过约束。**测试替身缝完好**adapter 仍声明 Protocol 形参fake 仍可注入。gemini 无 instructor 那条错(它走 `response_schema` 非 instructor只有 factory 的 read-only 那条。
- [2026-06-19] @backend **Codex 读端点缺口已补(解紧邻下面这条 @frontend gap+ skill impl 迁回 `ww_skills` 包**M5 R1/R3`GET /projects/:id/characters` + `GET /projects/:id/world_entities` 已落(复用 C5 读侧 `SqlCharacterRepo`/`SqlWorldEntityRepo`,反向 JSONB 解包 `{"items":[...]}`→list/`{"text":...}`→str/`{"rules":[...]}`→list无行返空列表@frontend `pnpm gen:api` 后 CodexPage 初始列表可拉**跨会话全量**,去掉「会话内回显」局限。② skill registry/沙箱从 `ww_core.domain` **迁到** `packages/skills/ww_skills/`——`from ww_core.domain import SkillRegistry/SqlSkillRepo/SkillRecord/SkillRepo/partition_writes/filter_reads/validate_declaration/KNOWN_TABLES` **已失效**,改 `from ww_skills import …`。③ 改 `packages/skills/pyproject.toml` deps去 ww-core、补 ww-llm-gateway+sqlalchemy后**必须 `uv sync`** 重建 ww-skills否则按旧元数据解析。④ `build_gateway_for_tier` 现用 `ww_llm_gateway.build_adapter(provider, api_key=…, base_url=…)`(去掉 apps/api 直接 `AsyncOpenAI`/`OpenAICompatAdapter` importanthropic/gemini 传 base_url=None 走原生适配器。
- [2026-06-19] @frontend **【RESOLVED 2026-06-19】Codex 读端点缺口已闭合M5 R1 follow-up #1**@backend`GET /projects/:id/characters` / `GET .../world_entities`C3 扩,反向 JSONB 解包成 API 友好字段)后,前端 `gen:api` 纳入 `CharacterListResponse{characters?:[CharacterCardView]}` / `WorldEntityListResponse{world_entities?:[WorldEntityCardView]}`+`lib/api/types` 别名);`lib/api/server``fetchCharacters`/`fetchWorldEntities`(无行→空列表,非 404仿 `fetchForeshadow`codex routeServer Component`Promise.all` 拉项目+人物+世界观全量传初始数据;`CodexPage` 渲染**跨会话持久化真源**(刷新不再空),本会话新入库卡经 `mergeCharacterCards`(纯逻辑,按 name 去重、持久化优先、node 单测)合并补显(不与真源重复)。世界观本期无入库端点,故只渲染真源 + 生成器预览(无 session 合并)。**「会话内回显」局限已去除**。原 T5.3「生成+本次入库会话内回显」管理入口仍保留generate→ingest 后即时见新行)。
- [2026-06-19] @frontend **入库 409 冲突详情形 = accept gate 的 `details.conflicts`ReviewConflict 形),不是 missing_conflict_indices**T5.3`POST /characters` 的 409 CONFLICT_UNRESOLVED 信封 `details{conflicts:[{type,where,refs,suggestion}], conflict_count}`continuity 预检产出C3 扩 T5.2),区别于 accept 的 `details{missing_conflict_indices, conflict_count}`(裁决覆盖缺口)。前端 `extractIngestConflicts``error.code==="CONFLICT_UNRESOLVED"` + 收窄 `details.conflicts`(复用 `lib/review/sse``ReviewConflict` 形 + history.ts 同款安全收窄);裁决流 = 展示冲突 → 作者确认 → 带 `acknowledge_conflicts=true` 重发(非逐条 conflict_index整体放行对齐后端 gate 语义)。错误码同款 503 LLM_UNAVAILABLE 引导去设置(仿 useRefine
- [2026-06-19] @frontend **命令面板⌘K走全局 keydown + RootLayout 挂载,纯过滤/高亮逻辑抽 `lib/command/palette` node-env 单测**T5.6`CommandPaletteMount`client`usePathname` 注入项目上下文 → `projectIdFromPath` 正则抽 `/projects/<id>`;命令清单(导航 + 生成动作)+ `filterCommands`(title/keywords 大小写无关子串) + `moveHighlight`/`clampHighlight`(循环) 全是纯函数、node 单测。组件层只管 ⌘K/Ctrl+K toggle`window.keydown`)、焦点(打开 `inputRef.focus`、Esc/↑↓/Enter、a11yrole=dialog/listbox/option + aria-selected + motion-safe。生成动作生成角色/世界观)= 跳 `codex?gen=character|world`CodexPage 读 searchParams 直开对应 tab无需独立 modal 路由)。
- [2026-06-19] @backend **测 `build_gateway_for_tier` 多 provider 接线要用真 Fernet key不是 `"x"*44`**T5.2):该函数解密凭据建适配器,凭据是 `encrypt_api_key(..., key=CREDENTIAL_ENC_KEY)` 加的密key 必须是合法 Fernet32 字节 url-safe base64否则 `decrypt_api_key``CredentialKeyError`。生成一个:`uv run python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"`,设 `os.environ["CREDENTIAL_ENC_KEY"]=<key>` + `get_settings.cache_clear()`lru_cache。生成/入库**端点**测试不碰真解密override 网关 dep 注 schema-routing fake故那些仍可用 `"x"*44`;只有直接调 `build_gateway_for_tier` 的单测需真 key。
- [2026-06-19] @backend **生成/入库/precheck 端点测试用 schema-routing fake 网关(按 `req.output_schema` 返不同 parsed**T5.2):一次 ingest 请求触发 precheck`output_schema=ContinuityReview`world/character generate 各触发 `WorldGenResult`/`CharacterGenResult`——单一固定-parsed 的 `FakeReviewGateway` 不够。端点测试自带 `_SchemaRoutingGateway({Schema: instance})``run``req.output_schema` 路由,未命中返 None三个网关 depworldbuilder/character_gen/precheckoverride 成同一个。**生成预览端点也要 `commit()`**(网关 ledger add-only → 不 commit 则 usage 静默丢,同 draft 坑)——断言 `session.commits==1` 即便预览不写业务表;冲突 409 路径同样 commit 落 precheck usage。
- [2026-06-19] @llm **回退/熔断要让适配器把瞬时故障翻译成 `TransientProviderError`,否则网关只重试/回退它认得的错**T5.4`Gateway` 的重试谓词 `_is_retryable` 只认 `TransientProviderError``AppError(RATE_LIMITED)`;普通 `Exception`/厂商原生异常**不重试不回退、直接上抛**内容策略拒绝等本就该让作者知情§4.5)。故每个适配器在 `complete`/`stream``except` 里按异常类名(RateLimitError/APITimeoutError/APIConnectionError/InternalServerError/APIStatusError)+`status_code`(429 或 ≥500) 判定瞬时→包成 `TransientProviderError(provider=...)`。**新增适配器必须照做**,否则它的 429/5xx 会穿透回退链变成硬失败。`packages/llm_gateway/tests/fakes_resilience.py``ScriptedAdapter(failures=[...])``TransientProviderError` 模拟可回退失败、用 `AppError(其它码)` 模拟不可回退。
- [2026-06-19] @llm **`Gateway` 构造签名扩了但保留 `resolver=` 兼容——apps/api 的 `build_gateway_for_tier` 无需改即不破,但也因此默认不启用回退链**T5.4`Gateway(adapters, ledger, *, chain_resolver=None, resolver=None, max_retries=2, breaker=None)``resolver`(单路由) 会被自动包成单元素链,故 `project_deps.build_gateway_for_tier` 仍传 `resolver=resolve_route` 照常工作(**未回归**)。但单元素链=无回退——**真正启用回退须 apps/api 改 `build_gateway_for_tier`**:按 DB `tier_routing.fallback`(`StoredRouting.fallback` 已有) 预备多个 provider 适配器进 `adapters` dict + 传 `chain_resolver=lambda tier: chain_from_routing(tier, primary, fallback)`,并可注入跨请求共享的 `CircuitBreaker`(默认每个 Gateway 实例自带一个、进程内不共享,逐请求新建网关则熔断不跨请求累积)。Anthropic/Gemini 适配器真实接线还需 @devops`anthropic`/`google-genai` SDK 依赖(适配器本身懒导入/注入客户端,仅 apps/api 构造真实 client 时需要)。
- [2026-06-19] @llm **character-gen schema 的 `traits`/`speech_tics` 是 `list[str]`,但 DB `characters.traits`/`speech_tics`/`arc` 列是 JSONB **dict**T5.2 ingest 须转形)**T5.1C6 扩 `CharacterCard``traits`/`speech_tics` 设计成 `list[str]`(生成产物天然是列表)、`arc` 设计成 `str`(弧光一句话),但 `packages/db/ww_db/models.py` 的列类型是 `traits: JSONB dict` / `arc: JSONB dict` / `speech_tics: JSONB dict`,而 `tags`/`relations` 才是 JSONB **list**。T5.2 入库映射时list 字段tags/relations直落`traits`/`speech_tics` 须包成 dict`{"items":[...]}` 或按语义拆)或由 @db 调列形;`arc`(str) 同理包 dict。**别假设 schema 字段形 == DB 列形**——schema 贴生成产物、DB 列贴存储ingest 是转换层。`role`/`backstory` 是 Text 列、直落。`WorldEntityCard.rules:list[str]``world_entities.rules` JSONB`{"rules":[...]}` 或裸 list
- [2026-06-19] @llm **入库前 continuity 校验是「编排器追加的一道检查」,复用 `continuity_spec` 而非新建 spec**T5.1ARCH §6.5`precheck_generated_cards` 传入 `continuity_spec`(不是 character-gen 自调、不是新审种),跑生成角色卡 vs 世界观/已有角色真相源、返 `list[Conflict]`。守不变量#1agent 只经 DB/编排器通信、不互调。T5.2 入库端点拿 conflicts 做 gate有冲突→提示作者裁决/调整,**不静默入库**(同 accept 的冲突 gate 精神,但这是生成预览阶段、非 chapter accept。独立生成语义网关失败直接上抛端点处理不做 review 图的失败隔离)。
- [2026-06-19] @qa **学文风 E2E后台 work 自造网关须 monkeypatch `style.build_gateway_for_tier`(不是 override 网关 dep**T4.5`POST /style` 请求阶段的凭据探测走 `get_style_extract_gateway`(可 `dependency_overrides` 注假网关绕过 503但**真正提取**在 `run_job` 自建的独立 session 上跑 `_make_style_learn_work`→其内 `build_gateway_for_tier(session, store, "analyst")` 从凭据建**真 OpenAI 适配器**(与请求 dep 无关。E2E 无真凭据 → 必 `monkeypatch.setattr(ww_api.routers.style, "build_gateway_for_tier", 返真 Gateway 包假适配器)`ledger 绑入参 session=后台真 session。写侧 `SqlStyleFingerprintWriteRepo` + `run_job` 默认 `repo_factory`→真 `SqlJobRepo` **保持不动** → 指纹/job 真落 pg只换 LLM。同 M3override `get_session_factory``e2e_sm`(同测试 engine/loopASGITransport 下 `await client.post` 等 background task 跑完,轮询 `GET /jobs/{id}` 稳定见 done。第四审 E2E只 override `get_review_gateway`analyst即覆盖整个四审图四档位同 deepseek 单适配器),假适配器据 `req.output_schema is StyleDriftReview` 返漂移 parsed回炉假适配器据 `req.output_schema is None` 返改写串writer 纯文本,保证 refined≠original
- [2026-06-19] @frontend **`GET /jobs/{id}` 在 OpenAPI 是裸 `{[k]:unknown}`T0.3 未建命名 schema**T4.4):前端无法直接用生成类型当 JobView须在 `lib/jobs/job.ts` 安全收窄(`narrowJob`status 非法→`queued`、progress 夹取 0..100、result 仅取 object 非数组)。同理 `chapter_reviews.style` 是松散 `{[k]:unknown}|null``normalizeStyleDrift` 收窄成 `{score(缺省100),segments:[{idx,score(缺省100),label:string|null}]}`score 缺省 100 = 后端无指纹降级态)。轮询 reducer/收窄是纯逻辑、node-env 单测hook 只管 setTimeout+fetch。
- [2026-06-19] @frontend **`useJobPoll``initialPollState.status` 默认 `"polling"`,调用方别在挂载即据此显进度**T4.4`useStyleLearn``startedRef` 守门——只在提交过一次 `POST /style` 后才把 `poll.status` 映射进 UI否则文风页一进就误显「提取中…」。轮询「停止」靠 reducer 到 done/failed 终态后不再 `setTimeout`(无 abort 概念,区别于 SSE 的 AbortController。回炉 `useRefine` 503(`error.code==="LLM_UNAVAILABLE"`)→提示去设置,仿 review 流前 503 检出。
- [2026-06-19] @backend **测 `run_job` 后台路径要 monkeypatch `job_runner.SqlJobRepo` 而非 `_default_repo_factory`**T4.3`run_job(..., repo_factory=_default_repo_factory)``repo_factory` 是**默认参数值**,在函数**定义时**绑定——`monkeypatch.setattr(runner_mod, "_default_repo_factory", ...)` 改的是模块属性、改不到已绑定的默认值,无效。`_default_repo_factory` 体内 `return SqlJobRepo(session)` 引用的是**模块全局** `SqlJobRepo`,故 monkeypatch `runner_mod.SqlJobRepo` 才生效(否则后台 `set_running``SqlJobRepo(FakeSession)``.execute` 炸、被 run_job 吞成 job_failed。端点测 BackgroundTask 落库路径override `get_session_factory``FakeSessionFactory` + monkeypatch `style.build_gateway_for_tier`(产 StyleFingerprintResult)/`style.SqlStyleFingerprintWriteRepo`(指共享 fake repo) + `runner.SqlJobRepo`(指 fake job repo)ASGITransport 下 `await client.post` 会等 background task 跑完,断言稳定(同 run_overdue_scan 时序先例)。
- [2026-06-19] @backend **bulk `UPDATE` 取受影响行数要 `cast` 成 `CursorResult`**T4.1 `reap_zombies``await session.execute(update(...))` 静态类型是 `Result[Any]`**无 `.rowcount`** 属性mypy strict 报 `attr-defined`);实际运行时是 `CursorResult`。做法:`from sqlalchemy import CursorResult` + `cast("CursorResult[Any]", result).rowcount``reap_zombies` 用一条 bulk UPDATE非逐行`running→failed` 高效且原子。
- [2026-06-19] @backend **`run_job` 失败置态要开「全新」session**T4.1`work` 抛异常后,原 session 的事务已作废,不能复用它 `fail(job_id, ...)`——`_mark_failed``session_factory()` 再开一个独立 session 写 failed + commit。故 `run_job` 失败路会开 **2 个** session业务一个 + 失败置态一个),单测断言 `len(factory.sessions)==2``run_job` 对 repo 只依赖最小 `JobLifecycleRepo` Protocolset_running/complete/fail——比全 `JobRepo` 窄,单测 fake 不必实现 create/get/reap`run_overdue_scan` 用最小 `OverdueScanRepo` 先例)。
- [2026-06-18] @llm **`SqlAlchemyLedgerSink.record` 改 add-onlyT3.8 修,去 `await flush()`**:原 add+flush 在并行审里 flush 重入炸(见下条)。改为只 `session.add(row)`——`add()` 同步不让步→并行协程不交错;持久化靠端点/事务 `commit()`(自动 flush。draft/review/accept/outline 端点末尾均已 commit记账不丢。**凡新增「并行用网关产 usage」的路径sink 必须保持 add-only不得在并行段 `await flush`。** 旧 gotcha「record 只 flush 不 commit」措辞已过时——现为「add-onlycommit 归调用方」commit 仍会 flush故记账语义不变
- [2026-06-18] @qa/@llm **并行审记账撞 sessionM3 真 bugT3.7 暴露→T3.8 修)**:三审同 LangGraph superstep 并行,各自 `gateway.run()`→共用**请求 session** 的 `SqlAlchemyLedgerSink.record`(`session.add`+`await session.flush()`)。`AsyncSession` 非并发安全→第二/三审 flush 撞 `Session is already flushing`→被 `run_review` 失败隔离吞成 `incomplete`→**foreshadow/pace 静默丢失**SSE 无事件、`chapter_reviews.foreshadow_sug`/`pace` 列空、日志 `review_node_incomplete error='Session is already flushing'`。M2 单审未触发。根因=并行路径里有 `await flush()`(唯一 await 的 DB-IO。修向审稿期记账避免并发 flushadd-only 靠端点 commit / 或缓冲后 collect 串行落 / 或并行审记账用独立 session
- [2026-06-18] @qa **E2E 验「验收后 BackgroundTask 扫描」时序**httpx `ASGITransport``await client.post(...)` 会等 ASGI app 协程(含 Starlette background tasks跑完才返回故 client 上下文退出后断言稳定不 flaky必 override `get_session_factory``e2e_sm`(真 sessionmaker同测试 engine/loop否则默认 `get_sessionmaker()` 另建 engine 绑别的 loop。
@@ -48,3 +75,10 @@
- [2026-06-18] @db mypy strict + 跨包 editable 安装模型里 `from ww_db.base import Base` 会被判成 Any( "cannot subclass Any")需在根 `pyproject.toml` `[tool.mypy] mypy_path=[...各包源码目录...]` + `namespace_packages=true`否则 editable 包解析不到源码
- [2026-06-17] @docs 命名契约后端 Python/Pydantic 一律 **snake_case**字段/schema/JSON前端经 OpenAPI 生成类型消费——别手写 camelCase 共享类型评审里曾因 TS 旧栈遗留 camelCase Pydantic 契约对不上)。
- [2026-06-17] @docs 数据写入只走**验收事务**四审 agent 只读不写任何 AI 产出入库必经 `accept`HITL gate)。别在 agent 节点里直接写库
- [2026-06-19] @qa M5 E2E生成端点的网关`get_worldbuilder_gateway`/`get_character_gen_gateway`/`get_precheck_gateway`**请求 scope FastAPI 依赖**内部虽调 `build_gateway_for_tier`但作为 `Depends` 暴露)→ E2E 直接 `app.dependency_overrides[get_*_gateway]=真Gateway包假适配器` 即可**无需** monkeypatch `build_gateway_for_tier`区别于 M4 学文风那是 `run_job` 后台**自建**网关 monkeypatch 模块级 `style.build_gateway_for_tier`)。
- [2026-06-19] @qa `served_by`(fell_back/degraded) **不出 API**仅观测/记账标注)→ 端到端 HTTP 路径要证回退真发生」,断言 **DB 真源 `usage_ledger.provider` = fallback 名而非 primary**网关记账记实际服务方ARCH §4.5)。HTTP fallback 用真 `Gateway` + `chain_resolver=chain_from_routing(...)` + primary 假适配器每次 `complete` `TransientProviderError` max_retries+1 次失败耗尽重试)→ fallback能力降级路径无支持者经直测 `Gateway.run` `served_by.degraded` 更清晰
- [2026-06-19] @qa `ww_agents.Conflict.type` `ConflictType` **Literal**性格漂移/能力不符/设定违例/地理矛盾/时间线倒错)——构造 precheck 假冲突须用枚举值之一设定违例」),随手写设定冲突 pydantic literal_error
- [2026-06-19] @llm **已被下条推翻**保留作纠错轨迹曾写 Kimi Code coding API 的伪造头只发 `User-Agent: KimiCLI/1.5`不带 X-Msh-*」——该结论源自分歧/错误参考 `picassio/pi-kimi-coder`UA-only导致误把头集裁成 UA-only。**勿采纳本条**见下条
- [2026-06-19] @backend Kimi Code device authorization **必须带 `scope=kimi-code`**`start_device_authorization` 请求体 = `{"client_id": <id>, "scope": "kimi-code"}`。K1.5 联网实测省略 scope 登录能拿到真 JWT但调 `api.kimi.com/coding/v1` `401 Invalid Authentication`——token coding entitlement`scope=kimi-code` coding-agent 文档化 scope`ooojustin/opencode-kimi` `constants.ts` 发送kimi-cli v1.41.0 已不发但服务端仍接受)。scope **只**放 device_authorizationtoken 交换(`poll_token`/刷新(`refresh`**不带** scopedevice flow 惯例)。⚠ 此前 K1.3 单测/decision/contract 省略 scope」(误信 kimi-cli v1.41.0已全部修正
- [2026-06-19] @llm **Kimi Code coding API 伪造头真源 = `ooojustin/opencode-kimi``src/headers.ts`+`src/constants.ts`1:1 镜像 kimi-cli v1.37.0),发完整 7 头、UA `KimiCLI/1.37.0`**K1.2 校正推翻上方 pi-kimi-coder UA-only 那条`picassio/pi-kimi-coder` 是分歧/错误参考只发 UA照它裁成 UA-only 是误修已回退`kimi_code_headers()` `User-Agent=KimiCLI/1.37.0` + `X-Msh-Platform=kimi_cli`字面常量 OS + `X-Msh-Version=1.37.0`==UA 版本+ `X-Msh-Device-Name``socket.gethostname()` ASCII + `X-Msh-Device-Model`macOS=`f"macOS {platform.mac_ver()[0]} {platform.machine()}"``machine()` 原样 `arm64`/`x86_64` 不归一化+ `X-Msh-Os-Version``platform.version()`+ `X-Msh-Device-Id`稳定 32 位无连字符 `uuid4().hex`env `KIMI_DEVICE_ID` 优先否则读/ `~/.kimi/device_id` 复用**绝不每次随机**否则 Kimi 侧每次当新设备)。源码若与此有出入以源码为准本次核对 master 一致)。⚠ HTTP 头值含非 ASCII 会被底层 fetch/httpx host 派生值Device-Name/Model/Os-Version ASCII `asciiHeaderValue` `\x20-\x7e` 之外 + trim空回退 `unknown`)。单测用 env `KIMI_DEVICE_ID` 注入固定 id CI 确定不触真 ~/.kimi)。ruff E501 按显示宽度算中文占 2 含中文 docstring/注释的行别贴边 100
- [2026-06-19] @backend `assemble`C5此前只从 world_entities/characters/style/rules + cards/foreshadow/digests/outline.beats 组装**从不含项目 premise/logline/theme/title也无写章指令** 全新项目只有 premise无世界观/角色/大纲产出 `stable_core=""`+`volatile=""`writer user message 为空LLM `400 "the message at position 0 with role 'user' must not be empty"`修复`MemoryRepos` 加第 8 repo `project:ProjectSpecRepo``spec(project_id)->ProjectSpecView{title,logline?,premise?,theme?}` project_id `projects` owner 过滤——owner 隔离归路由层`assemble` 作品蓝本放进 `stable_core` 首段书级 spec=定型→缓存前缀,不违 #9并让 `volatile` 始终含 `请创作第 {chapter_no} 章的正文。`易变指令)。⚠ 任何手搓 `MemoryRepos(...)` 的地方含测试 fake现在**必须** `project=` 字段否则 dataclass 缺参报错 DDL 变更只读既有 projects 无迁移