# 契约登记(解耦缝) > 跨 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` 不直接出 API(review/draft SSE 不回 served_by,settings/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 #2(2026-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=, base_url=None)` 是 **K1.3 接线的缝**——`api_key` 即 OAuth **access token**(OpenAI SDK 自动发 `Authorization: Bearer `),`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 必须 == `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=, 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 ` 即可(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 schema(SQLAlchemy 模型) owner @db 状态: 待定义 - 来源:`ARCHITECTURE.md §3.1` DDL(11 创作表 + 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→nullable;down 逆向。`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/97)mypy 报 `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-18,snake_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-18,snake_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:\ndata:\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-18,snake_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 末尾一次 commit(R3)。 - 注入缝:`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-18,snake_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-18,snake_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`(只 dimensions,assemble 用),故 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`(单 provider,M1 兼容);无任何可用凭据 → 503。单 provider 配置 = 单元素链(行为不变)。 - **`build_adapter` per-provider 接线(M5 R1 follow-up #2,2026-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 兼容 provider(deepseek/kimi/qwen/glm/openai)经 `_PROVIDER_BASE_URLS` 传 base_url;Anthropic/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→API,T5.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_enc,read-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=, 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-19;M5 R3 迁入 ww_skills 包, 2026-06-19) - 来源:`ARCHITECTURE.md §5.6` / `PRODUCT_SPEC §5.5`(声明式 Skill = AgentSpec;「声明式权限 + apply 层白名单」,**非进程沙箱**)。消费方:M5 编排/生成入库层、技能库 UI(T5.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)`(classmethod,async)→ 每条跑 `validate_declaration`(越权声明 → **AppError VALIDATION**,加载中断)→ frozen `dict[name,AgentSpec]`;`get(name)->AgentSpec`(缺 → NOT_FOUND)、`names()->list[str]`、`list_scope(scope)->list[AgentSpec]`。不变量 #2:只带 `tier`,不解析 model(model 解析在网关)。 - **表权限沙箱**(`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=) - `async fail(job_id, error: str) -> JobView`(status=`failed`, error=) - `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` 内自建 gateway(analyst)+ repo(用 `run_job` 传入的独立 session)。注入缝 `get_job_repo`/`get_session_factory` 测试经 `app.dependency_overrides` override。 ## C4 · 编排器接口(LangGraph 写章图) owner @llm 状态: 稳定(M1/T1.3, 2026-06-18;M2 扩四审/验收) - 来源:`ARCHITECTURE.md §5.2`(图)/ §7.3(SSE)。**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}`——仅控制流+组装上下文+累积草稿(不变量#5);resume 从领域表重读。 - 节点缝: - `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 只传 tier;DB 唯一 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,返回新 dict);TypedDict 改 `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]}`(一条)。键按字典序确定性遍历(continuitydict|None`(取 style 审 ok 结果整 dict);`collect_reviews` 末尾 `style=extract_style(reviews)` 传入 `review_repo.record(...)`(`chapter_reviews.style` JSONB 列已存在)。无指纹降级(score=100/空段)仍是 `ok` 结果、照常落库(区别于 incomplete→None)。 - `normalize_review` SSE(向后兼容)新增:`style{score:int, segments:[{idx:int,score:int,label:str|None}]}`(一条)。键按字典序遍历 → continuityLlmRequest`(`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 入 JSONB(DB 列 `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 入各自 JSONB(DB `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.5,T5.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` gate;analyst 网关复用 `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]`(global→genre→style→project)。 - 依赖注入:`MemoryRepos` 捆绑 **8** 个 Protocol(Outline/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 不含 volume);analyst 网关复用 `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**。 - 三审只读,留痕经 collect→review_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-20] @backend 扩 C3:新增 **`GET /projects/{project_id}/chapters/{chapter_no}/injection`**(本章注入透明,B0 读端点)→ `InjectionResponse{project_id, chapter_no, selected:[InjectionEntity{kind,name,reasons[]}], recent_n}`(`schemas/injection.py`,snake_case)。实现仅调既有 `assemble()` 回放确定性 `SelectionTrace`——**无 LLM、无 commit、无 DDL**;项目不存在→404,无大纲→`selected:[]`。reasons 取值同 `SelectionReason`(explicit_beat/main_character/recent_digest/foreshadow_window)。→ 影响 @frontend(已 `pnpm gen:api` + `ChapterAssistant` 消费)。**B0 可控版(PUT override + `select_relevant_entities` 加 pinned/excluded/recent_n + draft 端点同读 override + 持久化)尚未实现,届时再扩本契约。** - [2026-06-20] @backend 再扩 C3(B0 **可控版**,接上条):新增 **`PUT /projects/{project_id}/chapters/{chapter_no}/injection`** ← `InjectionOverrideRequest{pinned:[{kind,name}], excluded:[{kind,name}], recent_n:int|null(1..20)}` → 返回 `InjectionResponse`(同上,**新增回显字段 `pinned`/`excluded`**,`selected[].reasons` 可含新值 **`author_pin`**)。语义:pin 强制纳入并加 `author_pin` 理由 / excluded 强制剔除(**优先于 pin**)/ recent_n 覆盖近况回看章数。GET injection 与 **draft 流式端点**均先读同一覆盖再 `assemble(override=...)`,故「看到的=写章用的」(不变量 #6 作者兜底)。持久化:**新表 `chapter_injection`**(迁移 `ad2c4c663daf`,唯一 `(project_id,chapter_no)`,复用 outline 行已否决);`select_relevant_entities` 加 `pinned/excluded` frozenset 入参、`assemble` 加 `override` 关键字参;新 `domain/injection_repo.py`(`InjectionOverride`/`EntityRef`/`SqlInjectionOverrideRepo`,upsert 只 flush 端点 commit)。→ 影响 @frontend(已 `pnpm gen:api`;F1 加 pin/排除控件 + recent_n 步进器)。 - [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。