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

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

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

View File

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