378 lines
98 KiB
Markdown
378 lines
98 KiB
Markdown
# 契约登记(解耦缝)
|
||
|
||
> 跨 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?,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 扩(CR-H4, 2026-07-08)· `LlmRequest` 加可选 `request_id` owner @llm 状态: 稳定
|
||
- **`LlmRequest` 加字段**(C1 形变,**仅加可选、默认 `None`,最后一位,向后兼容**):`request_id: str | None = None`。调用方(apps/api/编排器)设置后,网关把它**条件透传**到每条 `llm_call` / `llm_provider_failed` 日志(`req.request_id is not None` 才发——避免 None 覆盖 `merge_contextvars` 在 sync/SSE 路径供的 id),贯通端到端追踪(ARCH §9.3)。
|
||
- **无 OpenAPI 形变、无需 gen:api**:`LlmRequest` 是网关内部类型,不出现在 `packages/shared` / `schema.d.ts`;前端无感。旧调用点全部无 request_id → 默认 `None`,行为不变。
|
||
|
||
#### 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=<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 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:<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`。
|
||
|
||
### C3 扩展(项目列表元数据, 2026-06-28)· `ProjectResponse` 增加最近编辑与待审稿统计 owner @backend 状态: 稳定
|
||
- `ProjectResponse` 追加字段:`updated_at: datetime | null`、`pending_review_count: int >= 0`。创建、列表、详情响应共用同一 schema;前端已 `pnpm gen:api`。
|
||
- `updated_at` 口径:取项目自身 `projects.updated_at` 与该项目下章节 `chapters.updated_at`、审稿留痕 `chapter_reviews.created_at` 的最大值,用于作品库“最近编辑”排序。为支持草稿重复保存后的真实编辑时间,`chapters` 表新增 `updated_at` 列(迁移 `8c1d2e3f4a5b`,既有行以 `created_at` 回填)。
|
||
- `pending_review_count` 口径:按章节草稿行计数;当草稿 `updated_at` 晚于该章最近 accepted 版本和最近审稿留痕时,视为待审稿。已验收且草稿未再更新的章节不计入。
|
||
- **@frontend 已消费**:作品库增加“待审稿”筛选、“最近编辑”排序,作品卡显示最近编辑时间和待审稿徽标。
|
||
- **已落(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, role_mix:dict[str,int]|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 同上。**⑦ 群像按定位配比**:`role_mix`({定位:数量})在场时 `count` 取各值之和(忽略客户端 count),每项≥1、总和≤12(`model_validator` 归一化);缺省退回单一 `role`+`count`。
|
||
- `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 扩展(T4-b, 2026-06-20)· `POST .../draft` 接收可选本章指令 `directive`(挂既有 `routers/projects.py`) owner @backend 状态: 稳定
|
||
- **仅加可选 body,向后兼容**:`POST /projects/:id/chapters/:no/draft` 现接收可选 JSON body `DraftStreamRequest{directive: str|None=None}`(无 body 的旧调用方不变,仍按大纲生成)。`directive`=作者本章指令,**临时输入、不持久化、无迁移**。
|
||
- 直通 `assemble(..., directive=…)`→`_build_volatile` 以「本章指令」段领衔 volatile(断点后),**绝不入 stable_core**(守缓存前缀不变量 #9)。`draft_stream_start` 日志加 `directive_len`(不记原文,脱敏)。
|
||
- **@frontend 行动**:`pnpm gen:api` 纳入 `DraftStreamRequest`;`useDraftStream.start(projectId, chapterNo, directive?)` 非空时 POST JSON body 传 directive。
|
||
|
||
### 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=<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`。
|
||
|
||
### C3 扩展(T6.2/T6.3, 2026-06-22)· 创作工具箱通用端点(独立 `routers/toolbox.py`,已注册) owner @backend 状态: 稳定
|
||
- 通用生成器框架:声明驱动、一条执行路径——「加一个生成器」= 在 `ww_skills.TOOLBOX` 加一份 `GeneratorTool` 声明(descriptor 在代码、**无 DB 迁移**),无需新端点。snake_case;**新增 3 端点 + 新 schema → @frontend 必须 `cd apps/web && pnpm gen:api`**(T6 前端前置)。
|
||
- `GET /skills/toolbox` → **200** `ToolboxListResponse{tools:[ToolDescriptorView{key,title,subtitle,genre?,is_legacy:bool,ingestable:bool,input_fields:[ToolInputFieldView{name,label,type,required,default?,help?}],legacy_route?:str}]}`(legacy 3 + 新 8,按 key 升序)。legacy 工具(worldbuilding/character/outline,`is_legacy=true`)携 `legacy_route`(`/projects/{id}/codex?gen=world` · `?gen=character` · `/projects/{id}/outline`)供前端跳现有页面,不回归既测。
|
||
- `POST /projects/{project_id}/skills/{tool_key}/generate` ← `ToolGenerateRequest{brief:str="", chapter_no:int|None(ge1), count:int|None(1..12), kind:str|None}` → **200** `ToolGeneratePreviewResponse{tool_key, output_kind:str(产物 schema 名), preview:dict(各工具 output_schema 的 model_dump)}`。**预览不入库**(按 `spec.tier` 经 `get_tier_gateway_builder` 建网关 → `run_generator`);末尾 `commit()` 仅落 ledger。context 派发(PURE `services/toolbox_context.build_toolbox_context`):brief_only/with_project→`build_brief_context`、with_world→ + world_entities 硬规则卡、with_outline_chapter→ + 指定章 outline beats(**缺章/缺大纲→空节拍不报错**)。未知 tool_key→404;legacy 工具(spec=None)→**404**(前端走 legacy_route);项目不存在→404;无凭据→**503** `LLM_UNAVAILABLE`。
|
||
- `POST /projects/{project_id}/skills/{tool_key}/ingest` ← `ToolIngestRequest{world_entities:[WorldEntityCardView], chapter_no:int|None, scenes:[OutlineSceneIngestView{idx,beat,purpose,conflict,hook}], acknowledge_conflicts:bool=false}` → **201** `ToolIngestResponse{table:str, created:list[str], rejected_tables:list[str]}`。**仅 `ingest!=None` 的工具**(golden-finger/glossary→world_entities,fine-outline→outline);其余→**422 VALIDATION**。入库 gate(仿 `POST /characters`):world_entities 走 **continuity 预检(analyst 网关)** 比对待入库实体 vs 既有世界观真相源——有冲突且 `acknowledge_conflicts=false`→**409 `CONFLICT_UNRESOLVED`** + `details{conflicts:[{type,where,refs,suggestion}],conflict_count}`;`partition_writes(spec,{table:items})` 白名单过滤(越权→`rejected_tables` 审计);schema→DB JSONB 形变写库(rules→`{"rules":[...]}`;细纲场景按 idx 升序拼成该章 beats,`outline_write_repo.upsert_chapter`)。outline(细纲)不做 continuity 预检(场景是粗节拍展开,非设定/角色比对),无 chapter_no→422。未知/legacy→404。提交边界:ledger + 写侧只 flush → 端点末尾一次 `commit()`(含 409 路径也 commit 落 precheck usage)。
|
||
- **注册表**(`packages/skills/ww_skills/toolbox_registry.py`,经 `ww_skills` 导出):`TOOLBOX:dict[str,GeneratorTool]`(11 条)+ `get_tool(key)->GeneratorTool|None`。新工具的 `key==spec.name`、`output_schema is spec.output_schema`。
|
||
- **注入缝**(`services/project_deps.py`):`get_tier_gateway_builder`→`TierGatewayBuilder=Callable[[Tier],Awaitable[Gateway]]`(按工具 tier 动态建网关);测试经 `app.dependency_overrides[get_tier_gateway_builder]` 注返 mock 网关的 builder + `get_world_entity_write_repo`/`get_outline_write_repo`/`get_memory_repos`/`get_project_repo` 注 fake(schema-routing fake 网关按 `req.output_schema` 返 IdeaListResult/ContinuityReview/…)。
|
||
- **守不变量**:#2(spec 只声明 tier)/#3(预览不写库;入库经 continuity gate + 白名单)/#9(system_prompt 进缓存前块、注入材料进 input)。**无新迁移**(descriptor 在代码,复用 world_entities/outline 表)。
|
||
- **@frontend 行动**:`pnpm gen:api` 纳入 `GET /skills/toolbox` + `POST .../skills/{tool_key}/generate` + `POST .../skills/{tool_key}/ingest`(新 schema `ToolboxListResponse`/`ToolDescriptorView`/`ToolInputFieldView`/`ToolGenerateRequest`/`ToolGeneratePreviewResponse`/`ToolIngestRequest`/`ToolIngestResponse`/`OutlineSceneIngestView`);工具箱落地页据 `GET /skills/toolbox` 渲染卡片栅格(legacy 走 `legacy_route` 跳页,新工具据 `input_fields` 声明驱动表单 → generate 预览 → 可入库者 ingest + 复用 409 冲突裁决)。
|
||
|
||
## 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=<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` 内自建 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。
|
||
- 一键采纳改法(方案1):`conflict` SSE 事件 + `ReviewConflictView`(留痕)+ C6 `Conflict` schema 新增可选 `original?:str|null`/`replacement?:str|null`(一键采纳补丁对,最小句段;审稿可局部修复时提议,前端「采纳改法」把 original→replacement find-replace 进终稿,找不到则不动正文提示手改)。**向后兼容**:缺省 null;审稿仍只读(只提议不改稿,不变量#3)。
|
||
- **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` 产 parsed,style 天然套循环)。
|
||
- 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` 结果、照常落库(区别于 incomplete→None)。
|
||
- `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.4(StylePanel ◔ 相似度 + 漂移段一键回炉,`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 入 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` 重生成;不手写共享类型。
|
||
|
||
---
|
||
|
||
## C-Chain · 多章工作流链端点 + 服务层 owner @backend / @llm(图) 状态: 稳定(C2, 2026-06-23)
|
||
> 设计真源:`docs/design/chain-workflow.md`。C1(@llm) 图签名 §3.3;C2(@backend) 端点/schema/服务接线。
|
||
- **3 端点**(`routers/chain.py`,挂 `/projects` 前缀):
|
||
- `POST /projects/{pid}/chains/{chain_key}/run` ← `ChainRunRequest{start_chapter_no:int>=1, count:int 1..50}` → 202 `ChainRunAccepted{job_id, chain_key, start_chapter_no, count}`。写一行 `jobs(queued, kind="chain")` 返 202,链经 BackgroundTask `run_chain_job` 跑(自建独立 session)。未知 chain_key→404(仅 `draft_volume`);count 越界→422(schema Field);项目不存在→404;无凭据→503。
|
||
- `GET /jobs/{job_id}`(**复用**现有):轮询进度/awaiting。链 job `result = {chain_key, written:[int], completed:bool, awaiting_chapter:int|null}`;interrupt 命中时 `status="awaiting_input"` + `result.awaiting_chapter`。
|
||
- `POST /projects/{pid}/chains/runs/{job_id}/resume` ← `ChainResumeRequest{decisions:[ConflictDecision]}` → 202 `ChainRunAccepted`。仅当 job=`awaiting_input`;非该态→**409 `CONFLICT`**;job 不存在→404。带裁决 `Command(resume=...)` 续跑。
|
||
- **schema**(`schemas/chain.py`):`ChainRunRequest`/`ChainRunAccepted`/`ChainResumeRequest`;`ConflictDecision` 复用 `schemas/projects.py`(conflict_index/verdict/note)。
|
||
- **零迁移**(§7):复用 `jobs`(kind/status/progress/result 全已存);新增 `status` 值 `"awaiting_input"`(jobs.status 自由 Text 列,无 DDL);awaiting 章经 `result.awaiting_chapter` 表达。`JobRepo` 加 `set_awaiting(job_id, result)`。**唯一新增 DDL = langgraph 检查点表(C3 迁移建,本任务未引入)。**
|
||
- **新增错误码**(`ww_shared/errors.py`):`ErrorCode.CONFLICT`→409(资源状态冲突,如对非 awaiting 链 job 续跑;与 `CONFLICT_UNRESOLVED` 区分)。
|
||
- **新网关缝**(`project_deps.py`):`get_chain_gateway`(`build_chain_gateway`:按请求 tier writer/analyst/light 分派、union 三档适配器+回退链——单档网关 chain_resolver 恒返该档会错路由 review/digest,故链需多档分派);`get_digest_gateway_builder`(accept 节点自建短事务建 light 档 digest 网关)。`get_checkpointer_factory`(`services/chain_deps.py`:运行时 `AsyncPostgresSaver.from_conn_string(database_url_sync)` 上下文;测试注 MemorySaver 工厂)。
|
||
- **token 纪律**:job result/status/日志只记章号/计数/标志,绝不含 prompt/正文/token(§5,已断言)。
|
||
- → 影响 @frontend(链发起页/进度/裁决续跑面板,**非目标本期不做**,契约稳定后 follow-up `pnpm gen:api`);@db/@devops C3(检查点建表迁移 + 可选 jobs 注释)。
|
||
|
||
## C6-ext · Prompt 外置 + SpecResolver owner @llm(SPECS/loader/catalog) + @backend(resolver/守卫) 状态: 稳定(方案A,2026-06-24)
|
||
> 设计真源:`docs/design/prompt-management.md`。纯重构、零功能/schema 变更、零迁移(前端无需 `pnpm gen:api`)。
|
||
- **`load_prompt(name) -> str`**(`packages/agents/ww_agents/prompt_loader.py`,@llm):按 `prompts/<spec.name>.md` import 期读盘 → 去 BOM(`utf-8-sig`)/CRLF·CR→LF/NFC/`rstrip('\n')` → 内存缓存 → 返回完整 UTF-8 文本;缺文件 `PromptNotFoundError`(fail-fast,不 fallback、不插值)。
|
||
- **`SPECS: Final[dict[name, AgentSpec]]`**(`specs.py`,@llm):21 内置 spec 集中名册,`assert len(SPECS)==21`;`system_prompt=load_prompt(name)`、`output_schema=SCHEMA_CATALOG[name]`。**不变量**:`SPECS[name] is *_spec`(兼容期同一实例,旧 `from ww_agents import *_spec` 不破);`prompts/<name>.md`/`SPECS[name]`/`SCHEMA_CATALOG[name]` 三集合按 name 恒等。
|
||
- **`SCHEMA_CATALOG: Final[dict[name, type[BaseModel]|None]]`**(`schema_catalog.py`,@llm):name→output type **唯一真相源**(`refiner=None`);`output_schema_for(name)`。input 全 None,本波不建 input 槽(YAGNI)。
|
||
- **`REVIEW_RESERVED_NAMES: Final[frozenset]={continuity,foreshadow,style,pace}`**(@llm):四审受信名显式白名单(非派生集合),安全边界锚此。
|
||
- **`SpecResolver`**(`packages/skills/ww_skills/spec_resolver.py`,@backend):`build(skills)` 纯合并 `dict(SPECS)`+`SkillRegistry`(读路径无冲突校验);`get(name)` 先查内置 `SPECS`(**纯内存、零 DB**)未命中才查 registry,都无→`NOT_FOUND`;`output_schema_for`/`names`/`list_scope`。name **精确字符串相等**(无大小写/连字符归一)。
|
||
- **守卫前移**:用户 skill 同名内置(`REVIEW_RESERVED_NAMES ∪ set(SPECS)`)在 **`SkillRegistry` 入库/加载校验期**即拒(`AppError(VALIDATION)`,与 `validate_declaration` 同处),**不在 resolver 读路径**——内置 `get` 确定性、零运行时分叉,守不变量 #3。
|
||
- **打包契约**(@devops):`.gitattributes` 锁 `packages/agents/ww_agents/prompts/*.md text eol=lf`;`packages/agents/pyproject.toml` hatchling `artifacts` 纳入 `prompts/*.md` 随 wheel/sdist 分发;CI `agents-wheel-smoke`(build→裸装→`import ww_agents; assert ww_agents.SPECS`)防 `.md` 漏带(源码树 pytest 测不出)。`packages/{llm_gateway,core}` 补声明 `structlog`(原直接 import 未声明,靠 apps/api 传递)。
|
||
- 消费方:`toolbox_registry.GeneratorTool.spec` 走 `SPECS["<name>"]`(不再直接 import 生成器 `*_spec`)。**本波不改**编排器(`REVIEW_SPECS`/节点/§6.5 precheck/chain)与 apps/api 3 路由(`toolbox/outline/style`)——兼容期续用 `*_spec`(因同一实例不变量无回归),切 resolver 列后续波次。
|
||
|
||
## 契约变更日志(append-only)
|
||
> 格式:`- [date] @skill 改 Cx:<改了什么> → 影响 <依赖方/任务>`
|
||
|
||
- [2026-07-06] @llm/@db/@backend/@frontend 改 C3+C4(③ 人物塑造 advisory 审):新增第五审 **人物塑造**(advisory 单维——仅建议、**不阻断验收**:不进 conflicts、不碰 `assert_conflicts_resolved`、**不入 `REVIEW_RESERVED_NAMES`**,D2)。**DB**:`chapter_reviews` 加 1 列 `characterization`(**JSONB nullable**,迁移 `f6a7b8c9d0e1`,独立于恒 None 的 health_score,nullable 免 backfill)。**契约变更**:`ReviewHistoryItem`(`schemas/projects.py`)+ `ReviewView`(`domain/review_repo.py`,末位默认 None 优雅降级)各加 `characterization: dict|None`;`list_reviews` 路由透传。新 SPEC `characterization_spec`(`tier="analyst"` 不写 model,`reads=("characters",)` 以人物卡 motive/appearance 为客观锚点,`writes=()` 只读,`prompts/characterization.md` **显式忽略文本长度与辞藻华丽度**、引用逐字片段+置信度)+ schema `CharacterizationReview{issues:[CharacterizationIssue{character,aspect,where,quote,diagnosis,suggestion,confidence}]}`(全字段默认值守解析韧性)+ 注册 `SCHEMA_CATALOG["characterization"]`;**计数三处 +1(22→23)**:`specs.py` assert、`test_prompt_loader.py` len 断言、`schema_catalog.py` docstring;**regen `prompt_hashes.json`**(仅加 characterization 一条)。接线链:`graph.py REVIEW_SPECS` 末位追加(并行扇出自动汇入 collect);`chain/nodes.py ReviewRecordRepo.record` + `collect.py {CHARACTERIZATION 常量, extract_characterization, ReviewRecorder Protocol, collect_reviews}` + `review_repo.py {Protocol.record, SqlReviewRepo.record, _to_view}` 均加 `characterization` kwarg/字段;`sse.py {EVENT_CHARACTERIZATION, characterization_event, _section_result_events elif}`(normalize 按 sorted 键自动纳入——字典序 characterization 排最前,不打乱既有四审断言)。前端 `lib/review/sse.ts`(`CharacterizationIssue`/`CharacterizationReport`/`CharacterizationEvent` + KNOWN_EVENTS + `asCharacterizationIssues` 守卫 + reducer + state)+ `lib/review/history.ts normalizeCharacterization` + `useReviewStream ReviewSeed` + 新 `CharacterizationPanel.tsx`(advisory 只展示、**无裁决 UI**、按置信度降序 + 低置信度折叠控注意力预算)+ `ReviewReport.tsx` 挂面板 + 双 seed 点。**codegen**:`characterization` `= None` → openapi-typescript 渲染为可选。**测试**:`test_characterization_does_not_block_accept`(零 continuity 冲突 + 有 advisory 问题 → 验收直通)+ 计数/golden + collect 映射 + normalize 事件 + 前端 reducer/normalize/parse 纯函数。→ 影响 @frontend(已 `pnpm gen:api`)。依赖 ⑧(motive/appearance 已入 CharacterCard 契约,作客观锚点)。
|
||
|
||
- [2026-07-06] @llm/@backend/@frontend 立 C3 新端点(⑤ AI 立项方案生成):**`POST /skills/project-plan/generate`**(**不带 project 前缀**——立项前 project 未建,绕开 toolbox 的 `project_repo.get→404`)← `ProjectPlanGenerateRequest{genre?,logline?,title?,premise?,theme?,structure?,tone?,ending_type?,narrative_pov?,selling_points[],brief?}`(全字段 `max_length` 上界 CR-H9;`brief` 用 `str|None` 令 codegen 渲染为可选)→ `ProjectPlanView{title_candidates[str], setting, narrative_structure, story_core, ending_design, tone}`(全字段默认值守解析韧性)。**种子门控**:缺 genre 或 logline(strip 后空)→ **422 VALIDATION**(空向导不出方案,防 slop);无凭据→503。新 SPEC `project_plan_spec`(`tier="analyst"` 不写 model,`reads=()`/`writes=()` 纯预览,`prompts/project-plan.md`)+ schema `ProjectPlanResult`(`ww_agents.schemas`)+ 注册 `SCHEMA_CATALOG["project-plan"]`;**计数三处 +1(21→22)**:`specs.py` assert、`test_prompt_loader.py` len 断言、`schema_catalog.py` docstring;**regen `prompt_hashes.json`**(仅加 project-plan 一条)。端点复用 `build_brief_context`+`run_generator`(**`run_generator`/`_build_request` 的 `project_id` 放宽为 `uuid.UUID|None`**——向导阶段无 project,`usage_ledger.project_id` 本就 nullable);新网关缝 `get_project_plan_gateway`(analyst 档,`project_deps.py`)。**零迁移**(纯预览不写业务表,仅落 usage ledger)。前端 `wizard.ts` 加 `mapPlanResultToWizardForm`(书名候选→title、叙事结构→structure、基调→tone;时空背景/结局设计无独立向导字段→折进 premise 带标签**不静默丢**;结局取向/叙事视角预设枚举不臆造)+ `applyPlanPatch`(**仅回填空字段、保护已编辑**)+ `canGeneratePlan`/`toPlanSeedRequest` + `useProjectPlan` hook(renderHook 测试)+ `ProjectWizard.tsx` 第 2 步 `PlanAssistant`(生成→预览→按字段回填)。→ 影响 @frontend(已 `pnpm gen:api`)。依赖 ④(tone/ending_type/narrative_pov 已入 project 契约与向导)。
|
||
|
||
- [2026-07-06] @db/@backend/@frontend 改 C2+C3(④ 立项基调/结局/视角):`projects` 表加 3 列 `tone`/`ending_type`/`narrative_pov`(**Text nullable、无 CHECK**,同 genre/structure;迁移 `e5f6a7b8c9d0`,nullable 免 backfill)。`ProjectCreateRequest`+`ProjectResponse`(`schemas/projects.py`)各加 3 字段 `str|None`(**无 Literal**,Literal 只留 Verdict);domain `ProjectCreate`/`ProjectView`/`_to_view`/`SqlProjectRepo.create` + `FakeProjectRepo` 同步透传。完整 stable_core 链路:`ProjectSpecView`(`repositories.py`,frozen)加 3 字段 + `SqlProjectSpecRepo.spec` 查询构造带上 + `_build_spec_section`(`assemble.py`)渲染进「作品蓝本」块(**书级常量→缓存前缀,字节稳定守 #9**,绝不入 volatile)。前端 `wizard.ts` `WizardForm`/`emptyWizardForm`/`toCreateRequest` + 预设数组 `TONES`/`ENDING_TYPES`/`NARRATIVE_POVS`(仿 GENRES/STRUCTURES)+ `ProjectWizard.tsx` 第 3 步控件(tone→Select,pov/ending→SegmentedControl)+ 确认页回显。**codegen**:3 字段皆 `= None` 无默认工厂 → openapi-typescript 渲染为 `?: string | null` 可选。→ 影响 @frontend(已 `pnpm gen:api`);未来 ⑤ AI 立项方案回填这 3 字段。
|
||
|
||
- [2026-07-06] @llm/@backend 改 C3(⑧ appearance/motive 穿线):`CharacterCardView`(`schemas/generation.py`)+ `ww_agents.CharacterCard` 各加 `appearance:str=""` + `motive:str=""`(**均给默认值守解析韧性**,DB 列 `characters.appearance/motive` 早已在初始迁移、此前一直 NULL——**无新迁移**)。写侧 `CharacterWriteRepo.create`/`SqlCharacterWriteRepo`(create+**update backfill 分支**)/`CharacterWriteFields` 同步加两列;`routers/generation.py` `_card_to_view`/`_view_to_card`/`_existing_characters` 双向形变带上两字段。同批**重切 motive/traits/arc 口径**:动机唯一落 `motive`,`traits[]` 只留核心/表层/阴影三层,`arc` 引用 motive(改 `character-gen.md` → 已 regen `prompt_hashes.json` 金标准)。**codegen 注意**:因带 `default`,openapi-typescript 把两字段渲染为**必填**(响应恒有值),前端 `CharacterCardView` 字面量构造点须显式给两字段。→ 影响 @frontend(已 `pnpm gen:api` + `CharacterCardItem` 展示/编辑两字段);未来 ③ 人物塑造审查以 motive/appearance 为客观锚点。
|
||
|
||
- [2026-07-06] @backend/@llm/@frontend 改 C3(⑦ 群像按定位配比·缩减版):`CharacterGenerateRequest`(`schemas/generation.py`)加 `role_mix:dict[str,int]|None`——`model_validator(mode="after")` 在场时校验(非空、每项≥1、总和≤`MAX_GENERATE_COUNT`=12)并把 `count` 归一为各值之和(构造期归一化,忽略客户端 count);缺省退回单一 `role`+`count`。**归属纠偏**:`build_character_gen_context`+`run_character_gen`(`ww_core/orchestrator/generation_node.py`)=**@llm 域**,加末位默认 `role_mix=None` 关键字参,在场时注入「角色定位配比」块(保插入序、确定性、无时间戳,守 #6/#7;覆盖单一 role 行);`routers/generation.py` 穿 `role_mix=body.role_mix`。`character-gen.md` 加「按配比分配 role」纪律 → 已 regen `prompt_hashes.json`(仅 character-gen 哈希变,**不进 SPECS/不 bump 计数**)。前端 `cards.ts` 加 `sanitizeRoleMix`/`roleMixTotal` + `buildCharacterGenerateRequest` 第 4 参 roleMix(在场时 count=Σ、发 role_mix、丢 role);`useCharacterGen` `CharacterGenInput.roleMix` 穿参;新 `RoleMixEditor.tsx` + `CharacterGenerator` SegmentedControl 单一/配比双模。**codegen**:`role_mix` 渲染 `?: {[k:string]:number} | null` 可选。→ 影响 @frontend(已 `pnpm gen:api`)。**无迁移**。
|
||
|
||
- [2026-06-24] @llm/@backend 立 C6-ext(Prompt 外置方案A):21 prompt 散文外置 `prompts/<name>.md` + `load_prompt`/`SPECS`/`SCHEMA_CATALOG`/`REVIEW_RESERVED_NAMES`(@llm)+ `SpecResolver` + SkillRegistry 入库守卫前移 + toolbox 桥接 `SPECS`(@backend)+ `.gitattributes`/wheel package-data/CI 冒烟(@devops)。随后全量重写 21 prompt 内容(任务对齐,schema 契约/不变量保持,金标准 fixture 重生成)。门禁绿:ruff/format/mypy(209)/pytest(744)。→ 影响:后续波次可将编排器 + apps/api 路由切 `SpecResolver` 并删 `*_spec` 导出;前端零影响(GeneratorTool 描述符字段不变)。
|
||
|
||
- [2026-06-23] @backend 立 C-Chain(C2):3 端点 + `schemas/chain.py` + `services/{chain_runner,chain_deps}.py` + `jobs` 零迁移复用(加 `status="awaiting_input"` + `JobRepo.set_awaiting`)+ 新 `ErrorCode.CONFLICT`(409) + `project_deps` 加 `build_chain_gateway`/`get_chain_gateway`/`get_digest_gateway_builder`/`get_checkpointer_factory`。OpenAPI 含 2 新 POST(GET /jobs 复用)。门禁绿:ruff/format/mypy(193) 干净 + pytest 600 passed + alembic 无漂移。→ 影响 @frontend(follow-up `pnpm gen:api`)、@db/@devops C3(检查点迁移)。
|
||
|
||
- [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。
|
||
|
||
- [2026-07-06] @frontend 前端依赖新增(灵感⑥ 关系图谱缩减版 / D4,**无后端契约变更、无 gen:api、无迁移**):`apps/web/package.json` 加 `@xyflow/react@^12.11.2`(React Flow,MIT,peer react>=17 兼容 R19)+ `@dagrejs/dagre@^3.0.0`(一次性初始布局,自带 TS 类型)。`pnpm install` 后 `pnpm-workspace.yaml` 被 pnpm 自动追加 `minimumReleaseAgeExclude`(@xyflow/react/@xyflow/system 供应链豁免)+ `pnpm-lock.yaml` 更新。新增 `lib/characters/graphLayout.ts`(纯逻辑:角色+relations→节点/边+dagre 布局,仅用现有 `CharacterCardView.relations={name,kind,note}`——**边按 name-join、kind 作中文标签、节点按 role 上色,无 closeness/阵营**,悬空边跳过并计数)+ `components/characters/RelationshipGraph.tsx`(`'use client'`,经 CodexPage `dynamic(ssr:false)` 加载,显式容器高度 + import RF CSS + prefers-reduced-motion 关 fitView 动画 + 纸感配色)。消费方:无(叶子特性,挂在设定库人物 tab)。
|
||
|
||
- [2026-07-07] @backend/@llm/@frontend 立 C-Rewrite(WFW-8 整章再沟通/重写):新增 **`POST /projects/{project_id}/chapters/{chapter_no}/rewrite`**(SSE,text/event-stream,复用 token/done/error 帧契约)← `RewriteStreamRequest{feedback:str(1..4000), prior_draft:str(1..200000)}` → 流式重写整章一版。实现:`prompts/rewrite.md` 教条(非 SPEC,仿 write_craft 经 `load_prompt` 读盘,`test_prompt_loader` doctrine 排除集加 `rewrite`)+ `orchestrator/rewrite_node.py`(`build_rewrite_request` 纯函数 system=[rewrite教条,stable_core] cache 前缀 / input=近况+当前草稿+作者意见 断点后,守不变量 #9;`stream_chapter_rewrite`)+ 端点复用 `assemble()` 记忆注入 + `normalize_deltas`。**只读不写库**(HITL:新版停前端,接受才落,不变量 #3)、**工具非写节点**(不破坏 章=f(outline,state) 纯函数,不变量 #7)、项目不存在触网关前 404。**无 DDL/无迁移**。门禁绿:ruff/mypy(224)/pytest(+test_rewrite_node 5 例)/alembic 无漂移。→ 影响 @frontend(已 `pnpm gen:api`;`useChapterRewrite` SSE hook + `ChapterRewritePanel` 版本栈 + Workbench「整章重写」入口)。
|
||
|
||
- [2026-07-08] @backend/@llm/@frontend 立 C-Clarify(WFW-9 M1 refine 侧 AI 反问澄清,路线A 两阶段):新增 **`POST /projects/{project_id}/chapters/{chapter_no}/refine/clarify`**(**非流式** JSON 预检)← `RefineClarifyRequest{segment:str(1..20000), instruction:str(默认"",0..2000)}` → **`ClarifyDecision`**{need_clarification:bool, questions:[ClarifyQuestion{question, options:[ClarifyOption{label,value}](0..4), allow_free_text:bool}](0或1,v1硬上限1问), verification:str|null}。tier=**analyst**,结构化输出(instructor),**只读不写库**(末尾 commit 仅记 usage_ledger,不变量 #3);判别/校验失败**确定性回退 need_clarification=false**(放行)。`ClarifyDecision` 定义在 `ww_agents`(供 producer+端点共用),注册 `clarify_refine_spec`(SPECS #24)+SCHEMA_CATALOG+金标准。**既有 `POST .../refine`(RefineRequest/RefineResponse)完全未改**。→ 影响 @frontend(已 gen:api;`useClarify` 映射 snake→VM + `ChoiceChips` 选项芯片 + `RefinePanel` 门控预检:意见<10字或点按钮→预检→needClarification 则渲染选项→答案 foldClarifications 折进 instruction→走既有 refine)。**无 DDL/无迁移**。门禁绿:后端 ruff/mypy227/pytest900/alembic;前端 tsc/lint/vitest654/build/cov95.36%。M2(rewrite 侧两阶段)/M3(UX+E2E) 待办。
|