Files
writer-work-flow/memory/contracts.md
Yaojia Wang 765dbdfbd4 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>
2026-06-20 10:39:58 +02:00

304 lines
69 KiB
Markdown
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 契约登记(解耦缝)
> 跨 agent 的契约——多 agent 靠它解耦并行。**契约先行**:定义方先在此登记并标 `稳定`,依赖方才动工;改契约必须在此记一笔 + 在 `PROGRESS.md` 通知依赖任务(前端要重生成 TS 客户端、依赖模块要重同步)。
> 状态:`待定义` → `草拟(@skill)` → `稳定` → `已变更(见日志)`。
---
## C1 · LLM 网关接口 owner @llm 状态: 稳定T1.1, 2026-06-18
- 来源:`ARCHITECTURE.md §4.1``LlmRequest` / `LlmResponse` / `Block` / `Usage`**snake_case**)。
- 消费方:编排器、所有 Agent`Gateway.run` / `Gateway.stream`)。
- 关键不变量agent 只传 `tier`writer/analyst/light不传具体 model。
- **已实现(`packages/llm_gateway/ww_llm_gateway`**
- 类型 `types.py``Block(text,cache=False)``Scope(user_id,project_id?)``LlmRequest(tier,input:str|list[Block],system:list[Block],stream,output_schema?,thinking,max_tokens?,scope)``Usage(provider,model,input_tokens,output_tokens,cache_read_tokens,cost_minor,currency)``ServedBy(provider,model,fell_back)``LlmResponse(text,parsed?,usage,served_by)``Delta(text)``Tier`
- `Gateway(adapters:dict[str,ProviderAdapter], ledger:LedgerSink, resolver=resolve_route)``async run(req)->LlmResponse``stream(req)->AsyncIterator[Delta]`。每次调用落 **1 条** `usage_ledger`(经 `LedgerSink`,可注入内存替身)。
- 适配器 `ProviderAdapter`(Protocol)`provider``capabilities()->Capabilities``async complete(req,model)->ProviderResult``stream(req,model)->AsyncIterator[StreamChunk]`。M1 实现 `OpenAICompatAdapter(provider, client:AsyncOpenAI)`DeepSeek注入 client 便于测试)。
- 档位路由 `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` 重生成客户端。**
- **已落T1.7, 2026-06-18snake_case响应仅脱敏 key**
- `GET /settings/providers``ProvidersResponse{ providers:[ProviderView{provider, masked_key}], tier_routing:[TierRoutingView{tier, provider, model, fallback:list[str]}] }`
- `PUT /settings/providers``ProvidersUpsertRequest{ credentials:[ProviderCredentialInput{provider, api_key}], tier_routing:[TierRoutingInput{tier, provider, model, fallback}] }``ProvidersResponse`(脱敏)
- `POST /settings/providers/test``TestConnectionRequest{provider}``TestConnectionResponse{provider, ok, capabilities:CapabilitiesView{structured_output, prefix_cache, thinking}}`
- **明文 key 永不过边界**probe 经可注入 `ProviderProbe`(测试用 fake不联网
- **已落T1.4, 2026-06-18snake_case**
- `POST /projects``ProjectCreateRequest{title, genre?, logline?, premise?, theme?, selling_points:list, structure?}` → 201 `ProjectResponse{id, title, genre?, logline?, premise?, theme?, selling_points, structure?}`
- `GET /projects``ProjectListResponse{projects:[ProjectResponse]}``GET /projects/{id}``ProjectResponse`404 `NOT_FOUND` 信封)
- `POST /projects/{id}/chapters/{no}/draft`**SSE** `text/event-stream`,帧 `event:<token|done|error>\ndata:<json>\n\n``token{text}`/`done{length}`/`error{code,message,request_id}`);无凭据 → 流前 `LLM_UNAVAILABLE`(503) JSON 信封(非帧)。
- `PUT /projects/{id}/chapters/{no}/draft``DraftSaveRequest{text}` → 200 `DraftResponse{project_id, chapter_no, volume, status, version, length}`幂等version 固定 1、status='draft')。
- 网关注入缝 `get_writer_gateway`(从凭据构 `OpenAICompatAdapter`+`SqlAlchemyLedgerSink`);测试经 `app.dependency_overrides` 注 mock 网关(只需 `.stream(req)`)。
- **@frontend 行动**M1 端点已全在 OpenAPI → Wave D 前先 `cd apps/web && pnpm gen:api` 重生成 `lib/api/schema.d.ts`
- **已落T2.4+T2.5, 2026-06-18snake_case全部挂 `projects.router`、已在 OpenAPI— M2 审/裁/验收三端点**
- `POST /projects/{id}/chapters/{no}/review``ReviewRequest{draft?}`(空/缺→回退已存草稿无草稿→404 `NOT_FOUND`)→ **SSE** `text/event-stream`,帧:`section{name,status:"done"|"incomplete"}`(每审一条M2 仅 `continuity`) / `conflict{type,where,refs:list,suggestion}`(每冲突一条,形=C6 `Conflict` 五类) / `done{length=审项数}` / `error{code,message,request_id}`。无凭据→流前 `LLM_UNAVAILABLE`(503 JSON 信封)。续审网关 tier=analyst`get_review_gateway`)。**端点流耗尽后 `session.commit()`**(网关 ledger + collect 均只 flush
- `GET /projects/{id}/chapters/{no}/reviews``ReviewHistoryResponse{reviews:[ReviewHistoryItem{id,project_id,chapter_no,chapter_version?,conflicts:list,foreshadow_sug:list,style?,pace?,health_score?,decisions?}]}`(新→旧)。
- `POST /projects/{id}/chapters/{no}/accept``AcceptRequest{final_text(min1), decisions:[ConflictDecision{conflict_index:int>=0, verdict:"accept"|"ignore"|"manual", note?}]}``AcceptResponse{project_id,chapter_no,accepted_version:int,digest_added:bool,decisions_recorded:int,review_id?:uuid}`。**冲突 gate**:裁决的 `conflict_index` 集合须覆盖 `range(len(最近一条 review.conflicts))`缺判→409 `CONFLICT_UNRESOLVED` + `details.missing_conflict_indices`/`conflict_count`无留痕或零冲突→直通。digest 提炼 tier=light`get_digest_gateway`在事务外R2单事务 promote(R4)+digest.append(#4)+set_decisions 末尾一次 commitR3
- 注入缝:`build_gateway_for_tier(session, store, tier)`(原 `build_writer_gateway` 退化为 writer 特例) + `get_review_gateway`/`get_digest_gateway`/`get_review_repo`/`get_digest_append_repo`;测试经 `app.dependency_overrides` 注 mock。
- **@frontend 行动**`cd apps/web && pnpm gen:api` 重生成客户端含此三端点T2.6)。
- **已落T3.2, 2026-06-18snake_case— 伏笔登记/状态端点 + 验收后到期扫描**(挂 `foreshadow.router`prefix `/projects`tag `foreshadow`,已注册):
- `POST /projects/{id}/foreshadow``ForeshadowRegisterRequest{code(min1),title(min1),planted_at?,content?,expected_close_from?,expected_close_to?,importance?}` → 201 `ForeshadowView{code,title,status,planted_at?,content?,expected_close_from?,expected_close_to?,importance?,links:list,progress:list}`(status=OPEN)。重复 code(DB 唯一 `(project_id,code)`)→ **422** `VALIDATION`+`details{field:"code",code,reason:"duplicate"}`
- `PATCH /projects/{id}/foreshadow/{code}``ForeshadowTransitionRequest{to_status?,progress_entry?}` → 200 `ForeshadowView`。非法转移→422 `VALIDATION`+`details{reason:"invalid_transition"}`二者都缺→422 `empty_update`code 不存在→404 `NOT_FOUND`。先转移后追加进展,端点写后 `commit()`(repo 只 flush)。
- **错误码取舍**:重复/非法转移映射 `VALIDATION`(422) 非 409——现有唯一 409 码 `CONFLICT_UNRESOLVED` 语义专指未决冲突禁验收,不复用;未动 `packages/shared/errors.py` 契约。若 T3.5/前端需真 409 须先加专用码。
- **验收后到期扫描**`POST .../accept` 单事务 commit 成功后经 `BackgroundTasks` 登记 `run_overdue_scan(session_factory, project_id, chapter_no=刚验收章号)`——**自建独立 session**(请求 session 已关闭)、应用 `current_ch>expected_close_to AND status≠CLOSED→OVERDUE`、有变更才 commit日志 `foreshadow_overdue_scan`(带 request_id/overdue_count/codes)失败吞不冒泡。§7.4 持久性局限(进程内/重启丢任务)原型接受、不引 jobs 表。
- **T3.5 续接**`GET /projects/{id}/foreshadow?status=` 看板加到本 `foreshadow.router`(注入 `get_foreshadow_repo``list_by_status`,建议响应包 `{foreshadow:[...]}``POST .../outline` 落**独立** `routers/outline.py`(别混进 foreshadow.py),调 `run_outline`(C6 扩)+`build_gateway_for_tier(...,"analyst")`,逐章 upsert `outline` 表、端点末尾 commit。
- **@frontend 行动**T3.6 前 `pnpm gen:api` 纳入伏笔登记/状态端点(+ T3.5 看板/outline
- **已落T3.5, 2026-06-18snake_case— 伏笔看板 + 大纲生成端点**
- `GET /projects/{id}/foreshadow?status=`tag `foreshadow`query `status?``OPEN|PARTIAL|CLOSED|OVERDUE`(缺省=全部非法→422 `VALIDATION`+`details{field:"status",reason:"invalid_status"}`) → 200 `ForeshadowBoardResponse{foreshadow:list[ForeshadowView]}`(按 code 升序)。
- `POST /projects/{id}/outline`tag `outline`,独立 `routers/outline.py`)← `OutlineGenerateRequest{volume:int=1,ge=1}`M3 简单分卷:整批同卷)→ 200 `OutlineResponse{chapters:list[OutlineChapterView{no,volume,beats:list[str],foreshadow_windows:list[ForeshadowWindowView{code,plant_chapter?,expected_close_from?,expected_close_to?}]}]}`项目不存在→404 `NOT_FOUND`;无凭据/网关失败→503 `LLM_UNAVAILABLE`(流前 JSON 信封)。调 `run_outline`(analyst 网关 `get_outline_gateway`)→逐章 upsert `outline` 表(写侧 `SqlOutlineWriteRepo`,只 flush)→端点末尾 `commit()`(含 1 条 usage_ledger
- **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/`
- 图状态 `ChapterState`(TypedDict, snake_case)`{project_id:UUID, chapter_no:int, user_id:UUID, stable_core:str, volatile:str, draft:str}`——仅控制流+组装上下文+累积草稿(不变量#5resume 从领域表重读。
- 节点缝:
- `build_write_request(*, stable_core, volatile, user_id, project_id) -> LlmRequest`(纯函数;`tier="writer"``system=[Block(stable_core,cache=True)]``input=volatile``stream=True``scope=Scope(user_id,project_id)`)。
- `async stream_chapter_draft(gateway, *, stable_core, volatile, user_id, project_id) -> AsyncIterator[Delta]`——**T1.4 拿来喂 `normalize_deltas` 的底层流缝**。
- `async write_node(state, *, gateway) -> {"draft":str}`——可直接单测(注入 mock 网关)。`GatewayStream` Protocol = 节点对网关最小依赖(只需 `.stream(req)`)。
- **SSE 归一缝T1.4 消费)**`async normalize_deltas(deltas, *, request_id=None) -> AsyncIterator[SseEvent]``SseEvent{event:str, data:dict}`;事件 `token{text}`/`done{length}`/`error{code,message,request_id}`ARCH §7.3 子集M2 加 section/conflict。底层异常→发 `error` 事件后收尾(不上抛);`AppError.code` 透传,未知→`INTERNAL`。HTTP event-stream 编码归 T1.4。
- 图工厂:`build_write_graph(gateway, *, checkpointer=None) -> CompiledStateGraph`START→write→END单测传 `MemorySaver`
- checkpointer setup 入口:`async setup_checkpointer(conn_string) -> None`——**只在 migrations/CI 调**(内部 `AsyncPostgresSaver.from_conn_string` 跑一次 `setup()` DDL懒 import绝不在 app-runtime 跑)。
- 不变量agent 只传 tierDB 唯一 agent 间通道;不可变更新;瞬时重试在网关不在节点。
### C4 扩展T2.2, 2026-06-18· 审稿子图 + review SSE
- 图工厂 `build_review_graph(gateway, review_repo, *, review_specs=(continuity_spec,), checkpointer=None) -> CompiledStateGraph``START →`各 review spec 并行节点`→ collect → END`。M2 默认仅 continuity`review_specs` 可扩M3/M4 加 foreshadow/style/pace。**`build_write_graph` 保留不动**draft 端点仍用它)。
- `ChapterState`(state.py) 新增 `review_context:str``reviews:Annotated[dict, merge_reviews]`(并行分支浅合并 reducer返回新 dictTypedDict 改 `total=False`(按节点逐步填充);仍仅控制流+组装上下文+产物句柄(不变量#5)。
- 节点缝:`run_review(spec, state, *, gateway)->{"reviews":{spec.name:{status,result}}}`(裸函数可单测);`make_review_node(spec, gateway)`(绑 gateway 的公共缝,供 T2.5 跑单审);审稿用 `gateway.run()`(非 stream**只读不写库**(不变量#3);任一审网关失败被隔离为 `{status:"incomplete",result:None}`§5.2),不上抛、不毁图。`GatewayRun` Protocol=节点对网关最小依赖(`.run(req)->LlmResponse`)。
- collect 缝:`collect_reviews(state, *, review_repo)->{}`:抽 continuity 冲突 → `review_repo.record(project_id, chapter_no, chapter_version=None, conflicts=[...])``chapter_reviews` 留痕;**只 flush 不 commit**(提交归端点/T2.4 事务)。`ReviewRecorder` Protocol 形对齐 `domain.review_repo.ReviewRepo.record`
- SSE 新增事件:`section{name,status}`status ∈ started/done/incomplete`conflict{type,where,refs,suggestion}`(对齐 C6 `Conflict`);新归一缝 `normalize_review(reviews, *, request_id=None)->AsyncIterator[SseEvent]`(每审一条 section + 每冲突一条 conflict + `done{length=审项数}`;异常→`error` 不上抛,同 `normalize_deltas` 纪律。HTTP event-stream 编码归 T2.5。
- **accept 不在本图**`interrupt_before=["accept"]`/accept 节点属 T2.4确定性事务代码从领域表重读R3/不变量#5)。
- 消费方行动T2.5 跑 review 子图 + `normalize_review` + 端点**流耗尽后 `await session.commit()`**(网关 ledger + collect 均只 flush不提交则记账/留痕静默丢失,同 M1 draft 坑);`GET .../reviews``review_repo.list_for_chapter`(新→旧)。T2.4 从 `review_repo.list_for_chapter` 读 collect 落的行(`decisions=None`)裁决。
### C4 扩展T3.3, 2026-06-18· 三审齐foreshadow + pace 并入)
- `REVIEW_SPECS = (continuity_spec, foreshadow_spec, pace_spec)``build_review_graph(..., review_specs=REVIEW_SPECS)` 默认三审齐(文风第四审留 M4`run_review`/`make_review_node` 对 spec 泛型(按 `req.output_schema` 产 parsed
- collect 列映射(一次 `review_repo.record(...)` 落齐continuity→`conflicts`(list)foreshadow→`foreshadow_sug`(list`planted`/`resolved` 扁平成单 list、每条加 `kind:"planted"|"resolved"`)pace→`pace`(dict`{water,hook,beat_map}` 入列)style 留 M4。任一审 incomplete→该列空/None不阻塞其余§5.2)。
- `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`断点前**作品蓝本(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` 捆绑 **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)`
## C6 · Agent 声明AgentSpec+ 续审 I/O schema owner @llm 状态: 稳定T2.1, 2026-06-18
- 来源`ARCHITECTURE.md §5.1 / §5.4 / §6.1`位置`packages/agents/ww_agents/``specs.py` / `schemas.py` `ww_agents` 导出)。
- **`AgentSpec`**frozen Pydantic不可变`name:str``tier:Tier`(writer/analyst/light复用 `ww_llm_gateway.types.Tier`——只声明档位不写 model不变量#2)、`system_prompt:str``input_schema:type[BaseModel]|None``output_schema:type[BaseModel]|None`(writer None=纯文本)、`reads:list[str]``writes:list[str]``genre:str|None=None``scope:str="builtin"`
- **`continuity_spec`**`tier="analyst"``reads=["chapter_digests","characters","world_entities"]``writes=[]`(只读不变量#3)、`input_schema=None`(注入材料为序列化文本)、`output_schema=ContinuityReview`
- **`ContinuityReview{ conflicts: list[Conflict] }`**仅冲突digest 不在审稿期产不变量#4)。
- **`Conflict{ type: ConflictType, where:str, refs:list[str]=[], suggestion:str }`**`ConflictType = Literal["性格漂移","能力不符","设定违例","地理矛盾","时间线倒错"]`ARCH §6.1 五类)。
- 续审节点调法`req = LlmRequest(tier="analyst", system=[Block(system_prompt, cache=True)], input=审稿上下文文本, output_schema=ContinuityReview, scope=...)` `resp = await gateway.run(req)` `resp.parsed` `ContinuityReview` 实例 schema 时必非 None)。续审用 `run()` `stream()`只读+产冲突不写库写在 T2.4 验收事务)。
- 消费方编排器(T2.2 续审/collect 节点)、技能运行时(M5)、前端审稿页( C3)。foreshadow/style/pace 三审 spec M3-T3.3/M4 补入本契约
### C6 扩展T3.4, 2026-06-18· outliner Agent + 大纲/伏笔窗口 schema
- 位置`packages/agents/ww_agents/{schemas.py,specs.py}` `ww_agents` 导出节点在 `packages/core/ww_core/orchestrator/outline_node.py` orchestrator 导出)。
- **`outliner_spec`**(frozen `AgentSpec`)`name="outliner"``tier="analyst"``reads=["projects","foreshadow","characters","world_entities"]``writes=["outline"]`(声明式真写库经 T3.5不变量#3)、`input_schema=None``output_schema=OutlineResult`
- **`OutlineResult{chapters:list[OutlineChapter]}`****`OutlineChapter{no:int, beats:list[str], foreshadow_windows:list[ForeshadowWindow]}`****`ForeshadowWindow{code:str, plant_chapter?:int, expected_close_from?:int, expected_close_to?:int}`**(§6.2 关联章与伏笔)。
- 节点缝沿用 review_node `GatewayRun` Protocol `.run(req)->LlmResponse``build_outline_request(spec, *, context, user_id, project_id)->LlmRequest`(`output_schema=OutlineResult`,`stream=False`) + `async run_outline(spec, *, context, gateway, user_id, project_id)->OutlineResult`(`gateway.run().parsed`**只读不写库**大纲独立生成非并行审网关失败**直接上抛**不隔离parsed 违约抛 `ValueError`)。
- 消费方T3.5 端点调 `run_outline` `OutlineResult` 逐章 upsert `outline` `beats`/`foreshadow_windows` JSONB`volume` 由端点分卷逻辑提供schema 不含 volumeanalyst 网关复用 `build_gateway_for_tier(session,store,"analyst")`/`get_review_gateway`记账闭环同 M2端点末尾 `commit()`)。T3.6 前端大纲编辑器消费窗口
### C6 扩展T3.3, 2026-06-18· foreshadow-analyst + pace-checker spec
- `foreshadow_spec`(name="foreshadow", tier="analyst", reads=["foreshadow"], writes=[]只读, output=`ForeshadowReview{planted:[ForeshadowSuggestion],resolved:[ForeshadowSuggestion]}``ForeshadowSuggestion{code?,title,where?,note?}`)。
- `pace_spec`(name="pace", tier="light", reads=["rules"], writes=[]只读, output=`PaceReview{water:[PaceIssue{where,reason}],hook:bool,beat_map:[int]}`)。genre 模板 DSL = pace system_prompt 内描述黄金三章/章末钩子/爽点密度genre 级模板经 review_context 规则合并(`rules`)注入**未新建表/repo**。
- 三审只读留痕经 collectreview_repo写库 commit 归端点)。详细图/SSE 见上C4 扩展T3.3)」。
## C7 · 前端 ↔ 后端类型契约 owner @backend(产出 OpenAPI) / @frontend(生成) 状态: 待定义
- 机制FastAPI OpenAPI `apps/web/lib/api` TS 类型openapi-typescript/orval)。
- 规则后端 schema 任何变更 `gen:api` 重生成不手写共享类型
---
## 契约变更日志append-only
> 格式:`- [date] @skill 改 Cx<改了什么> → 影响 <依赖方/任务>`
- [2026-06-18] @llm C1`Gateway.run()` 现消费 `LlmRequest.output_schema`——`OpenAICompatAdapter.complete` schema 非空时经 **instructor**(`create_with_completion(response_model=...)`) 取已校验 Pydantic 实例并填 `LlmResponse.parsed` schema `parsed is None`纯文本路径不变记账仍 **1 条 usage_ledger**/调用usage raw completion 提取)。`ProviderResult` 新增 `parsed` 字段结构化路径经可注入 `StructuredClient` Protocol fake测试不联网)。→ 影响 T2.2续审节点可直接 `gateway.run(req).parsed`)、未来所有结构化输出 Agent