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:
@@ -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` 不直接出 API(review/draft SSE 不回 served_by,settings/providers 也不含它)→ 经核 **本次无 OpenAPI 形变,前端无需 re-gen**;若后续把 `served_by` 暴露到响应体,再触发 gen:api。
|
||||
- **`Gateway` 构造扩**(关键字,全可选):`Gateway(adapters, ledger, *, chain_resolver: Callable[[Tier], list[Route]] | None=None, resolver: Callable[[Tier], Route] | None=None, max_retries=2, breaker: CircuitBreaker | None=None)`。`chain_resolver`(回退链)优先;`resolver`(单路由,M1 兼容)被自动包成单元素链;皆缺省回退到默认 `resolve_chain`。`run`/`stream` 行为:沿链逐 provider→瞬时失败退避重试 R 次→仍失败/熔断打开/无适配器则切下一个;首个成功者服务,非链首即 `served_by.fell_back=True`;链耗尽抛 `AppError(LLM_UNAVAILABLE)`。记账记**实际服务方** provider/model(回退后不记主模型)。流式仅在首块产出前可切(产出后中途失败上抛,不静默重连,§4.5)。
|
||||
- **回退链解析(routing.py)**:`resolve_chain(tier)->list[Route]`(默认仅全局 `tier_defaults`,单元素);`chain_from_routing(tier, primary:str, fallback:list[str])->list[Route]`(据 DB `tier_routing` 行去重保序构链——供 apps/api 包成 `ChainResolver` 注入网关,**apps/api 待接线**:`build_gateway_for_tier` 目前仍只注主 provider 适配器 + `resolver=resolve_route`,要启用回退须按 fallback 预备多个适配器 + 传 `chain_resolver`)。`ChainResolver = Callable[[Tier], list[Route]]` 类型别名导出。
|
||||
- **熔断器**:`CircuitBreaker(*, threshold=5, reset_seconds=30.0, clock=time.monotonic)`:`is_open(provider)`/`record_failure`/`record_success`;连续失败超阈值→短时熔断(网关直接跳过该 provider 走回退);成功清零;冷却窗口过半开放行。**默认每个 Gateway 实例自带一个 breaker**(进程内、非跨实例共享)——跨请求持久熔断需调用方注入共享 breaker。
|
||||
- **瞬时错误契约**:适配器把厂商 429/超时/5xx/连接错误翻译为 `ww_llm_gateway.errors.TransientProviderError`(OpenAI 兼容/Anthropic/Gemini 适配器均已包装,按异常类名 + status_code 判定);网关另把 `AppError(RATE_LIMITED)` 也视作可重试/可回退。非瞬时错误(内容策略拒绝等)原样上抛、不重试。
|
||||
- **新适配器**(均经注入的客户端 Protocol,**测试不联网、不硬 import 厂商 SDK**):
|
||||
- `AnthropicAdapter(provider, client: AnthropicClient, *, structured_client?)`:`capabilities()=(structured_output=True, prefix_cache=True, thinking=True)`;缓存断点经 system 块 `cache_control:{type:ephemeral}`(§4.6);结构化经 `instructor.from_anthropic`(懒构建)。
|
||||
- `GeminiAdapter(provider, client: GeminiClient)`:`capabilities()=(structured_output=True, prefix_cache=False, thinking=True)`;结构化经 `config.response_mime_type=application/json + response_schema`,文本回 `model_validate_json`。
|
||||
- `OpenAICompatAdapter` 不变(新增瞬时错误包装,参与回退链)。
|
||||
- **依赖**:`packages/llm_gateway/pyproject.toml` 加 `tenacity>=8.2`(已 `uv sync`;原为 instructor 传递依赖)。**`anthropic`/`google-genai` SDK 已由 @devops 加(已 `uv sync`)**——适配器仍懒导入/注入客户端,单测零依赖。
|
||||
|
||||
#### C1 扩 follow-up #2(2026-06-19)· `build_adapter` provider→适配器工厂 owner @llm 状态: 稳定
|
||||
- 位置:`packages/llm_gateway/ww_llm_gateway/factory.py`,经包 `__init__` 导出 `build_adapter`。
|
||||
- **签名(稳定,apps/api `build_gateway_for_tier` 据此逐 provider 建适配器进 `adapters` dict)**:
|
||||
`def build_adapter(provider: str, *, api_key: str, base_url: str | None = None) -> ProviderAdapter`。
|
||||
- **分派**:`provider=="anthropic"` → `AnthropicAdapter`(懒 import `AsyncAnthropic(api_key=, base_url?)`);`provider in {"gemini","google"}` → `GeminiAdapter`(懒 import `genai.Client(api_key=)`,**忽略 base_url**——SDK 无该形参);其余(deepseek/kimi/qwen/glm/openai…)→ `OpenAICompatAdapter(provider, AsyncOpenAI(api_key=, base_url=))`。
|
||||
- 工厂从 api_key 构**真实**客户端(适配器内部仍是注入客户端 Protocol,测试零联网);故单测只断**按 provider 名选对适配器类 + provider 字段透传**(`tests/test_build_adapter_factory.py`,6 测),不联网。
|
||||
- **@backend 行动**:`build_gateway_for_tier` 现可调 `build_adapter(provider, api_key=…, base_url=…)` 替换「一律 `OpenAICompatAdapter`」,使回退链中的 anthropic/gemini 走对应专用适配器。**无 OpenAPI 形变**(served_by 不出 API),前端无需 re-gen。
|
||||
|
||||
#### C1 扩(K1.2, 2026-06-19)· Kimi Code 订阅 plan 适配器(伪造头 + OAuth bearer) owner @llm 状态: 稳定
|
||||
- 位置:`packages/llm_gateway/ww_llm_gateway/adapters/kimi_code.py`,经包 `__init__` 导出 `KimiCodeAdapter`/`build_kimi_code_client`/`kimi_code_headers`/`kimi_device_id`/`kimi_device_model`/`KIMI_CODE_BASE_URL`/`KIMI_CODE_USER_AGENT`/`KIMI_CLI_VERSION`/`KIMI_CODE_PLATFORM`/`KIMI_CODE_PROVIDER`。
|
||||
- **provider 名**:`kimi-code`(与 API-key provider `kimi` 分离,便于档位切换)。`build_adapter("kimi-code", api_key=<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` 重生成客户端。**
|
||||
@@ -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`(只 dimensions,assemble 用),故 assemble/C5 行为不变。
|
||||
- `POST /projects/{id}/chapters/{no}/refine` ← `RefineRequest{segment:str(min_length=1), instruction:str|None=None}` → 200 `RefineResponse{original:str, refined:str}`。同步回炉:writer 网关(`get_refine_gateway`)`run(refiner_spec)`,`output_schema=None` → 取 `resp.text` 为 refined;输入 = `【待重写段落】{segment}` + 可选 `【改写指令】{instruction}`。**不写库**(不变量 #3,作者采纳经既有 draft 自动保存合入);**末尾 `session.commit()`**(网关 ledger add-only,否则 usage_ledger 静默丢失,同 draft/review 纪律)。项目不存在→404;无凭据→503。
|
||||
- 注入缝(`services/project_deps.py`):`get_style_write_repo`(`SqlStyleFingerprintWriteRepo`,服务 `GET /style` 读侧)、`get_style_extract_gateway`(analyst,学文风的凭据探测缝)、`get_refine_gateway`(writer)。测试经 `app.dependency_overrides` override;学文风后台路径 monkeypatch `style.build_gateway_for_tier`/`style.SqlStyleFingerprintWriteRepo` + `job_runner.SqlJobRepo`(`run_job` 自建 repo,不走 dep)。
|
||||
- 写侧 repo(`packages/core/ww_core/domain/style_repo.py`,经 `ww_core.domain` 导出):`StyleFingerprintWriteRepo`/`SqlStyleFingerprintWriteRepo`(加 `Write` 避撞 C5 读侧 `SqlStyleRepo`,仿 `DigestAppendRepo` 先例):`append(project_id, *, dimensions_json, evidence_json)->int`(version=当前 max+1,首次 1;**只 flush 不 commit**)+ `latest(project_id)->StyleFingerprintView|None`(含 dimensions/evidence/version,供 `GET /style`)。
|
||||
- **@frontend 行动**:T4.4 前 `pnpm gen:api` 纳入 style/refine/GET style + jobs 轮询类型。
|
||||
|
||||
### C3 扩展(T5.5, 2026-06-19)· 规则端点 `POST /projects/:id/rules`(独立 `routers/rules.py`,已注册)
|
||||
- snake_case;改字段 → **@frontend 必须 `cd apps/web && pnpm gen:api`**(T5.6 前置)。
|
||||
- `POST /projects/{project_id}/rules` ← `RuleCreateRequest{level:Literal["global","genre","style","project"], content:str(min_length=1)}` → **201** `RuleView{level:str, content:str}`。非法 `level` / 空 `content` → **FastAPI 422**(Pydantic Literal/min_length 校验,非 AppError 信封)。
|
||||
- 加规则是**作者显式动作**(不变量 #3:不经 AI 静默写库)。写一行 `rules`(绑 `project_id`),喂给 assemble 的四级合并 `merge_rules`(global→genre→style→project)。
|
||||
- 提交边界:`RuleWriteRepo.create` 只 `flush()`,端点写后 `await session.commit()`(仿 foreshadow/outline 写侧)。
|
||||
- 写侧 repo(`packages/core/ww_core/domain/rule_repo.py`,经 `ww_core.domain` 导出):`RuleWriteRepo`(Protocol)/`SqlRuleWriteRepo`/`RuleWriteView{project_id?,level,content}`(加 `Write` 避撞 C5 读侧 `domain.repositories.RuleView{level,content}`,仿 `OutlineWriteRepo` 先例)。
|
||||
- 注入缝(`services/project_deps.py`):`get_rule_write_repo`(`SqlRuleWriteRepo`)。测试经 `app.dependency_overrides` override 注 fake repo + fake session(断 commit 计数)。
|
||||
- **@frontend 行动**:T5.6 前 `pnpm gen:api` 纳入 `POST /rules`(规则页用)。
|
||||
|
||||
### C3 扩展(T5.2, 2026-06-19)· 生成/入库 + 读端点(独立 `routers/generation.py`,已注册) owner @backend 状态: 稳定
|
||||
- snake_case;改字段 → **@frontend 必须 `cd apps/web && pnpm gen:api`**(T5.6 前置)。生成走**即时返回**(预览→作者确认→入库,M5-d,非 jobs)。
|
||||
- `POST /projects/{project_id}/world/generate` ← `WorldGenerateRequest{brief:str(min1)}` → **200** `WorldGenPreviewResponse{entities:[WorldEntityCardView{type:str,name:str,rules:list[str]}]}`。**预览不入库**(worldbuilder writer 网关 `run_worldbuilder`);末尾 `commit()` 仅落网关 ledger。项目不存在→404;无凭据→**503** `LLM_UNAVAILABLE`(`get_worldbuilder_gateway` dep 解析阶段拦下)。
|
||||
- `POST /projects/{project_id}/characters/generate` ← `CharacterGenerateRequest{brief:str(min1), count:int=1(ge1,le12), role:str|None}` → **200** `CharacterGenPreviewResponse{cards:[CharacterCardView{name,role,traits:list[str],backstory,arc:str,speech_tics:list[str],tags:list[str],relations:[CharacterRelationView{name,kind,note?}]}]}`。**预览不入库**(character-gen writer 网关,注入已有角色防雷同,generated_so_far=[]);末尾 commit 落 ledger。404/503 同上。
|
||||
- `POST /projects/{project_id}/characters`(入库)← `CharacterIngestRequest{cards:[CharacterCardView](min1), acknowledge_conflicts:bool=false}` → **201** `CharacterIngestResponse{created:list[str], rejected_tables:list[str]}`。**入库 gate**:① `precheck_generated_cards`(continuity 预检, analyst 网关) 比对卡 vs 世界观/已有角色真相源——有冲突且 `acknowledge_conflicts=false` → **409 `CONFLICT_UNRESOLVED`** + `details{conflicts:[{type,where,refs,suggestion}], conflict_count}`(仿 accept gate,不静默入库,不变量#3;`acknowledge_conflicts=true` 即作者裁决放行)。② `partition_writes(character_gen_spec, {"characters":cards})` 白名单过滤(越权表→`rejected_tables` 审计 log+丢弃,character-gen 只声明 writes=characters 故正常为空)。③ 写 `characters` 行(schema list/str → DB JSONB **dict** 形变,见写侧 repo)。404/503 同上。提交边界:precheck 网关 ledger + 角色写侧均只 flush → 端点末尾一次 `commit()`(含冲突 409 路径也 commit 落 precheck usage)。
|
||||
- `GET /projects/{project_id}/rules` → **200** `RuleListResponse{rules:[RuleView{level,content}]}`(规则页;复用 C5 读侧 `SqlRulesRepo.all_for_project`,不动 assemble)。
|
||||
- `GET /skills`(独立 `skills_router`,prefix `/skills`)→ **200** `SkillListResponse{skills:[SkillView{name,scope,tier,reads:list[str],writes:list[str],genre?}]}`(技能库 UI;经 `get_skill_registry` 读 registry,按 name 升序)。
|
||||
- 写侧 repo(`packages/core/ww_core/domain/`,经 `ww_core.domain` 导出):`CharacterWriteRepo`/`SqlCharacterWriteRepo`/`CharacterWriteView{id,name,role}`(`character_repo.py`)+ `WorldEntityWriteRepo`/`SqlWorldEntityWriteRepo`/`WorldEntityWriteView{id,type,name}`(`world_entity_repo.py`,预留对称入库)。**形变**:`traits`/`speech_tics`(list)→`{"items":[...]}`、`arc`(str)→`{"text":...}`、`rules`(list)→`{"rules":[...]}`;`tags`/`relations`→直落 JSONB list(仅 character 入库端点已落地;world ingest 端点本期未建,仅 worldbuilder 走预览)。
|
||||
- 注入缝(`services/project_deps.py`):`get_worldbuilder_gateway`(writer)/`get_character_gen_gateway`(writer)/`get_precheck_gateway`(analyst)/`get_character_write_repo`/`get_world_entity_write_repo`/`get_rules_read_repo`。测试经 `app.dependency_overrides` 注 fake repo + schema-routing fake 网关(按 `req.output_schema` 返 WorldGenResult/CharacterGenResult/ContinuityReview)+ fake session(断 commit 计数)。
|
||||
- **`build_gateway_for_tier` 多 provider 接线(T5.4 follow-up,已完成)**:据 DB `tier_routing` 取该 tier 的 `provider:model` + `fallback`,为每个可建适配器的 provider 预备适配器,注入 `chain_resolver=chain_from_routing(...)` 启用回退链。无 DB 路由行 → 退回全局 `resolve_route`(单 provider,M1 兼容);无任何可用凭据 → 503。单 provider 配置 = 单元素链(行为不变)。
|
||||
- **`build_adapter` per-provider 接线(M5 R1 follow-up #2,2026-06-19,已完成)**:`_build_provider_adapter`(原 `_build_openai_compat_adapter`)现调 `ww_llm_gateway.build_adapter(provider, api_key=…, base_url=…)`(C1 扩 follow-up #2 工厂)替代「一律 `OpenAICompatAdapter`」——OpenAI 兼容 provider(deepseek/kimi/qwen/glm/openai)经 `_PROVIDER_BASE_URLS` 传 base_url;Anthropic/Gemini 传 `base_url=None` 走原生适配器。这样 `tier_routing` 链里配置的 Anthropic/Gemini provider 也能拿到**真实适配器**参与回退(不再被 base_url 缺失跳过)。未配凭据的 provider 返 None(回退链跳过)。**无 OpenAPI 形变**(served_by 不出 API)。真实 Anthropic/Gemini 跑通仍需 @devops 加 `anthropic`/`google-genai` SDK 依赖(适配器懒导入/注入客户端)。
|
||||
- **@frontend 行动**:T5.6 前 `pnpm gen:api` 纳入 world/characters generate + characters ingest + GET rules + GET skills。
|
||||
|
||||
### C3 扩展(M5 R1 follow-up, 2026-06-19)· 设定库 Codex 读端点(GET characters / world_entities) owner @backend 状态: 稳定
|
||||
- 补 PROGRESS「Codex 读端点缺口」余项:设定库 Codex 现可展示跨会话**全量**已入库角色/世界观(前端先前只能「生成+本次入库会话内回显」)。snake_case;新增端点 → **@frontend 必须 `cd apps/web && pnpm gen:api`** 重生成 TS 客户端 + CodexPage 初始列表去掉「会话内」局限(见 gotcha 2026-06-19 @frontend Codex 缺口条)。
|
||||
- `GET /projects/{project_id}/characters` → **200** `CharacterListResponse{characters:[CharacterCardView]}`(已入库角色全量;复用 C5 读侧 `SqlCharacterRepo`,经 `get_memory_repos` 的 `memory.character.list_for_project`,**不动 assemble**)。无行 → 空列表(非 404)。
|
||||
- `GET /projects/{project_id}/world_entities` → **200** `WorldEntityListResponse{world_entities:[WorldEntityCardView]}`(已入库世界观实体全量;复用 C5 读侧 `SqlWorldEntityRepo`)。
|
||||
- **形变(DB JSONB→API,T5.2 ingest 形变的逆向)**:`characters.traits`/`speech_tics` dict `{"items":[...]}`→裸 list、`arc` dict `{"text":...}`→str、`tags`/`relations`→直落 list(复用既有 `_existing_characters` helper);`world_entities.rules` dict `{"rules":[...]}`→裸 list。
|
||||
- **路由**:挂既有 `generation.router`(prefix `/projects`);GET `/{project_id}/characters` 与 POST 同路径,FastAPI 按 method 区分,无冲突。注入缝复用 `get_memory_repos`(无新 dep)。测试经 `app.dependency_overrides[get_memory_repos]` 注 fake `MemoryRepos`。
|
||||
- **@frontend 行动**:`pnpm gen:api` 纳入 `GET /projects/:id/characters` + `GET /projects/:id/world_entities`;`lib/api/server` 加 `fetchCharacters`/`fetchWorldEntities` + CodexPage 初始列表拉全量。
|
||||
|
||||
### C3 扩展(大纲读端点 follow-up, 2026-06-20)· `GET /projects/{id}/outline`(挂既有 `routers/outline.py`,已注册) owner @backend 状态: 稳定
|
||||
- 补缺口:大纲页先前**只有** `POST .../outline`(生成+持久化),无读端点 → 重访页面已落库的大纲不回显,看似「未保存」。仿 Codex 读端点(GET characters/world_entities)补对称读侧。
|
||||
- `GET /projects/{project_id}/outline`(tag `outline`)→ **200** `OutlineResponse{chapters:list[OutlineChapterView{no,volume,beats:list[str],foreshadow_windows:list[ForeshadowWindowView]}]}`(与 `POST .../outline` 响应**同形**,前端类型对齐),按 `chapter_no` 升序。项目存在但无大纲 → **200 空列表**(非 404);项目不存在 → **404 `NOT_FOUND`**(仿 POST 的项目存在性检查)。**只读不写库**。
|
||||
- **复用 C5 读侧**:扩 `OutlineRepo`(domain protocol) + `SqlOutlineRepo`(C5 assemble 读侧 repo) 加 `list_for_project(project_id)->list[OutlineView]`(仿 `CharacterRepo`/`WorldEntityRepo` 命名,order_by chapter_no);既有 `get(project_id, chapter_no)` 不动,assemble 行为不变。新注入缝 `get_outline_read_repo`(`services/project_deps.py`,仿 `get_rules_read_repo`)。
|
||||
- **beats 形变**:DB `outline.beats` JSONB dict `{"beats":[...]}` → 出参 `OutlineChapterView.beats` 解包成裸 `list[str]`(同 POST 路径 / `OutlineWriteView._to_view`)。
|
||||
- **@frontend 行动**:`pnpm gen:api` 纳入 `GET /projects/:id/outline`;大纲页初次加载拉已持久化大纲(去掉「生成后才显示」局限)。
|
||||
|
||||
### C3 扩展(草稿读端点 follow-up, 2026-06-20)· `GET /projects/{id}/chapters/{no}/draft`(挂既有 `routers/projects.py`,已注册) owner @backend 状态: 稳定
|
||||
- 补缺口:写作工作台先前**只有** `POST .../draft`(SSE 流写) + `PUT .../draft`(自动保存) 无读端点 → 重访工作台时编辑器空白、看似「未保存」(其实 `chapters` 行在库)。仿 `GET .../outline` 补对称读侧。
|
||||
- `GET /projects/{project_id}/chapters/{chapter_no}/draft` → **200** `DraftView{project_id, chapter_no, volume, status, version, content, length}`(**比 PUT 的 `DraftResponse` 多 `content`**——读侧需正文重建编辑器;`length`=content 字符数派生)。无草稿行**或正文空白** → **404 `NOT_FOUND`**(工作台据此呈现空编辑器,与其它「缺资源」端点一致)。**只读不写库**。
|
||||
- **复用读 seam**:直接调既有 `chapter_repo.get_draft(project_id, chapter_no)->ChapterDraftView|None`(同续审 `_resolve_review_draft` 的读缝);`ChapterDraftView` 已含全部字段,无需新 repo 方法/新 view。多版本时固定返草稿版次(`DRAFT_VERSION=1`)那条可编辑工作副本,与 PUT 保存的同一行。
|
||||
- **@frontend 行动**:`pnpm gen:api` 纳入 `GET /projects/:id/chapters/:no/draft`(新 schema `DraftView`);工作台初次加载拉已保存草稿回灌编辑器(去掉「重访看似未保存」局限)。
|
||||
|
||||
### C3 扩展(K1.3, 2026-06-19)· Kimi Code OAuth device-flow 端点(独立 `routers/kimi_oauth.py`,已注册) owner @backend 状态: 稳定
|
||||
- snake_case;新增 3 端点 + 3 schema → **@frontend 必须 `cd apps/web && pnpm gen:api`**(K1.4 前置)。**响应/job 结果/日志绝不含 access/refresh token**(只 user_code/connected/expires_at 等非密信息);token 仅以 Fernet 密文存 `provider_credentials.oauth_enc`。
|
||||
- `POST /settings/providers/kimi-code/oauth/start` → **202** `OAuthStartResponse{job_id:uuid, user_code:str, verification_uri:str, verification_uri_complete:str|None, expires_in:int, interval:int}`。流程:`start_device_authorization(http)` → `job_repo.create(None,"kimi_oauth")` + `session.commit()`(202 前持久化供轮询)→ `background_tasks.add_task(run_job, session_factory, job.id, _make_poll_work(device))`。后台 `work` 在 `run_job` 自建独立 session 上**循环 `poll_token`**(`authorization_pending`→续轮询、`slow_down`→增大 interval、`expired`/`denied`→抛 AppError 置 job failed);成功 → `upsert_oauth_credential`(加密包)+ job done(结果只 `{connected:true, provider}`)。前端展示 user_code + 打开 verification_uri + 轮询 `GET /jobs/{id}`。
|
||||
- `POST /settings/providers/kimi-code/oauth/disconnect` → **200** `OAuthDisconnectResponse{disconnected:bool}`(`delete_credential` 清 OAuth 凭据行)。
|
||||
- `GET /settings/providers/kimi-code/oauth/status` → **200** `OAuthStatusResponse{connected:bool, expires_at:str|None}`(解密 `oauth_enc` 取过期时刻;**无 token 本体**;解密失败/无凭据→`connected:false`)。
|
||||
- **store OAuth 读写**(`services/credentials.py`,C2 扩 K1.1 收口):`StoredCredential` 加 `auth_type:str`/`oauth_enc:bytes|None`,`api_key_enc` 改 `bytes|None`(K1.1 的 2 处 mypy red 已消)。新方法:`upsert_oauth_credential(owner,provider,oauth_enc)`(auth_type="oauth"+清 api_key_enc,read-modify-write 守可空 project_id 约束)、`delete_credential(owner,provider)->bool`。`upsert_credential`(api_key 路径)现显式 set auth_type="api_key"+清 oauth_enc。
|
||||
- **`_build_provider_adapter` Kimi Code OAuth 接线**(`services/project_deps.py`):provider `kimi-code` 或 `auth_type="oauth"` → `_resolve_kimi_code_token`:解密 `oauth_enc` → `needs_refresh`(剩余寿命 < 300s)则经临时 `httpx.AsyncClient` 调 `kimi_oauth.refresh` + `upsert_oauth_credential` 持久化新包 → 用(刷新后的)access token 调 `build_adapter("kimi-code", api_key=<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-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/`。
|
||||
@@ -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` 产 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`:断点前(世界硬规则+定型主角(无 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]`(global→genre→style→project)。
|
||||
- 依赖注入:`MemoryRepos` 捆绑 7 个 Protocol(Outline/Character/WorldEntity/Digest/Foreshadow/Style/Rules);单测注入内存 fake,运行时 `sql_memory_repos(AsyncSession)`(**T1.4 注入点**)。
|
||||
- 依赖注入:`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)`。
|
||||
|
||||
|
||||
@@ -14,6 +14,64 @@
|
||||
|
||||
---
|
||||
|
||||
## [2026-06-19] Kimi Code OAuth:token 存储包 + 刷新启发式 + 省略 scope + 后台轮询走 jobs — @backend (K1.3)
|
||||
- 背景:Kimi 订阅 plan 走 device-flow OAuth;需定 token 持久化形、何时刷新、device authorization 是否带 scope、后台轮询如何挂。
|
||||
- 选择:
|
||||
1. **存储包**:`TokenSet{access_token, refresh_token, expires_at}` 序列化为 JSON 串经 Fernet(复用 `encrypt_api_key`,工作在 str 上)加密入 `provider_credentials.oauth_enc`(C2 扩 K1.1 列)。`expires_at` 存**服务端驱动的绝对 UTC 时刻**(从 `expires_in` 计算入库时刻)。明文 token 绝不进日志/响应/job 结果。
|
||||
2. **刷新启发式**:建网关时若 access token 剩余寿命 < `MIN_REFRESH_BUFFER_SECONDS=300`(300s 缓冲,对 ~15min access 足够),调 `refresh` 换新包并 `upsert_oauth_credential` 持久化(下次复用)。任务文档原提 `max(300, 0.5*expires_in)`——本实现采固定 300s 缓冲(不存原始 expires_in,KISS;对 15min access 等价于 0.5*expires_in=450s 略激进但安全)。
|
||||
3. **省略 scope**:device authorization 不带 `scope`(匹配研究确认的 kimi-cli v1.41.0+;server 拒绝时再加 `scope=kimi-code` 兜底——本期先不带)。client_id env `KIMI_CLIENT_ID` 可覆盖默认。
|
||||
4. **后台轮询走 jobs**:`POST .../start` 创建 `jobs(kind="kimi_oauth")` 返 202 + user_code,复用 K1.1/M4 `run_job` 在独立 session 上**循环** `poll_token`(每 interval 秒,`authorization_pending` 续轮询、`slow_down` 增大 interval、过期/拒绝抛 AppError 置 job failed),成功落加密 token + job done(结果只 `{connected, provider}`)。前端轮询 `GET /jobs/{id}`。
|
||||
- 理由 / 取舍:复用既有 jobs/run_job 基建(无新长任务机制);固定 300s 缓冲省去存 expires_in(YAGNI)。代价:refresh-on-build 每次建网关多一次解密 + 可能一次刷新网络调用(仅临近过期时)。
|
||||
- 影响:C3 扩(OAuth 3 端点);C2 扩 K1.1 的 `StoredCredential` 收口(api_key_enc→nullable + auth_type/oauth_enc);`_build_provider_adapter` 加 OAuth 分支。K1.4 前端连接流、K1.5 E2E(真账号联网验证含封号风险,用户自担)。
|
||||
|
||||
## [2026-06-19] Kimi Code OAuth:device authorization **重新带 `scope=kimi-code`**(实测 401 后修正,超越上条第 3 点)— @backend (K1.3 fix)
|
||||
- 背景:K1.5 真账号联网实测——device-flow 登录成功拿到真 JWT,但调 `api.kimi.com/coding/v1` 被拒 `401 Invalid Authentication`。根因:device_authorization 省略了 `scope`,拿到的 token 缺 coding entitlement。
|
||||
- 选择:`start_device_authorization` 的请求体在 `client_id` 之外加 `scope=kimi-code`(模块常量 `KIMI_CODE_SCOPE`)。**修正上条决策第 3 点的「省略 scope」**——`scope=kimi-code` 是 coding-agent 文档化 OAuth scope(`ooojustin/opencode-kimi` `constants.ts` 发送之;kimi-cli v1.41.0 已不发但服务端仍接受)。
|
||||
- 范围:scope **只**放 device_authorization 请求;token 交换(`poll_token`)/刷新(`refresh`)**不带** scope(OAuth device flow 惯例,参考实现亦然)。
|
||||
- 影响:device-auth 请求体现为 `{"client_id": <id>, "scope": "kimi-code"}`;K1.3 单测 `test_start_device_authorization_parses_response_and_sends_scope` 断 `data["scope"]=="kimi-code"`。无 OpenAPI 形变,前端无需 re-gen。
|
||||
|
||||
## [2026-06-19] Skill registry + 表权限沙箱迁回 `ww_skills` 包(@devops 已纳入 workspace)— @backend (M5 R3)
|
||||
- 背景:T5.5 曾因 `packages/skills` 非 uv workspace member 把 `skill_registry`/`skill_permissions` 暂落 `ww_core.domain`、`ww_skills/__init__` 做再导出门面(见上 T5.5 决策)。@devops 现已把 `packages/skills` 做成真 workspace member(`ww_skills` 可 import、有自己的 pyproject、`uv sync` 完成)——前提消除。
|
||||
- 选择:把两个模块**物理迁入** `packages/skills/ww_skills/{skill_registry,skill_permissions}.py`(内部相对 import 从 `ww_core.domain.skill_permissions` 改为 `ww_skills.skill_permissions`),`ww_skills/__init__` 改为**直接导出**(不再 re-export ww_core);删除 `ww_core.domain` 的两个模块文件 + `__init__` 里的 skill 导出。所有 importer(`apps/api` `generation.py`/`project_deps.py` + `test_generation.py`)改 `from ww_skills import …`;skill 单测从 `packages/core/tests/` 移到 `packages/skills/tests/`(import 也改 `ww_skills`)。
|
||||
- 理由 / 取舍:恢复任务文档原定位(skill 逻辑归 skills 包),物理位置与 §5.6 措辞一致;ww_core 不再承载 skill 符号(关注点分离)。代价:`packages/skills/pyproject.toml` deps 需校准——`ww_skills` 直接 import `ww_db`/`ww_agents`/`ww_shared`/`ww_llm_gateway`(`Tier`),故去掉不再用的 `ww-core`、补 `ww-llm-gateway` + `sqlalchemy`(`SqlSkillRepo` 用 `select`/`AsyncSession`);改后 `uv sync` 重建 ww-skills。
|
||||
- 影响:C8 实现位置更新(contracts.md C8 状态加「M5 R3 迁入 ww_skills 包」);消费方 import 路径从 `ww_core.domain`/`ww_core.domain.skill_registry` → `ww_skills`。`ww_core.domain.skill_*` 导入路径**已失效**(凡 grep 到旧路径须改)。`packages/skills/pyproject.toml` 改了 deps(在 @backend 可编辑的 skills 目录内,但属打包元数据)——已 PROGRESS 通知 @devops 复核根锁。
|
||||
|
||||
## [2026-06-19] `build_gateway_for_tier` 改用 `build_adapter` 工厂(per-provider 适配器)— @backend (M5 R1 #2)
|
||||
- 背景:T5.4 接线只为回退链里的每个 provider 一律建 `OpenAICompatAdapter`——Anthropic/Gemini 即便配进 `tier_routing` 链也因 `_PROVIDER_BASE_URLS` 无 base_url 被跳过、拿不到真实适配器。@llm 加了 `build_adapter(provider, *, api_key, base_url=None)` 工厂(C1 扩 follow-up #2)按 provider 选适配器类。
|
||||
- 选择:`_build_openai_compat_adapter` 改名 `_build_provider_adapter`,去掉「base_url 缺失即返 None」分支,改调 `build_adapter(provider, api_key=…, base_url=_PROVIDER_BASE_URLS.get(provider))`——OpenAI 兼容 provider 传 base_url,Anthropic/Gemini 传 None 走原生适配器。仅在凭据缺失时返 None(回退链跳过)。保留既有 503/凭据/单 provider 兼容行为。
|
||||
- 理由 / 取舍:最小改动让回退链支持非 OpenAI-兼容 provider;adapter 选择逻辑收敛到网关包的工厂(单一真源),apps/api 不再硬编码适配器类。代价:真实 Anthropic/Gemini 仍需 @devops 加 SDK 依赖(适配器懒导入)。
|
||||
- 影响:C3 扩(build_gateway_for_tier 接线段更新);`apps/api` 去掉 `AsyncOpenAI`/`OpenAICompatAdapter` 直接 import。T5.7 切 provider E2E 可扩 Anthropic/Gemini 路径。
|
||||
|
||||
## [2026-06-19] 设定库 Codex 读端点复用 C5 读侧 + 反向 JSONB 解包 — @backend (M5 R1)
|
||||
- 背景:PROGRESS 余项①——Codex 设定库本应展示 characters/world_entities **读/管理**视图,但后端只有生成/入库写端点,前端只能「生成+本次入库会话内回显」。
|
||||
- 选择:加 `GET /projects/:id/characters` + `GET /projects/:id/world_entities`,**复用 C5 读侧** `SqlCharacterRepo`/`SqlWorldEntityRepo`(经既有 `get_memory_repos` dep,不新增 repo、不动 assemble);响应做 T5.2 ingest 形变的**逆向**解包(`{"items":[...]}`/`{"text":...}`/`{"rules":[...]}`→裸 list/str),复用既有 `_existing_characters` helper。无行返空列表(非 404,列表语义)。
|
||||
- 理由 / 取舍:KISS——读端点是写入形变的镜像,复用已有读侧 + helper 零新依赖;GET 与 POST 同路径靠 method 区分无冲突。代价:读侧 CharacterView 仍是裸 dict,端点层做解包(与写侧 repo 的包裹对称)。
|
||||
- 影响:C3 扩(两读端点);@frontend `pnpm gen:api` + CodexPage 初始列表拉全量(去掉「会话内」局限,解 gotcha 2026-06-19 @frontend Codex 缺口条)。
|
||||
|
||||
## [2026-06-19] 角色入库 continuity gate:有冲突→409 CONFLICT_UNRESOLVED,作者 acknowledge 后放行 — @backend (T5.2)
|
||||
- 背景:ARCH §6.5 要求生成角色入库前过 continuity 校验(`precheck_generated_cards`),但任务文档留了两条路线(block / 返回冲突供作者裁决)。要选最简单正确、且守不变量 #3「无 AI 静默写库」。
|
||||
- 选择:入库端点跑 precheck;检出冲突且请求未带 `acknowledge_conflicts=true` → **409 `CONFLICT_UNRESOLVED`** + `details{conflicts:[...], conflict_count}`(不写库)。作者在前端查看冲突、裁决后重发带 `acknowledge_conflicts=true` → 放行写库。零冲突 → 直接写库 201。
|
||||
- 理由 / 取舍:复用既有 `CONFLICT_UNRESOLVED`(409) 码(语义=未决冲突禁写入,与 accept gate 一致),无需新增错误码;显式 `acknowledge` 标志=作者裁决的 HITL gate(不静默入库,守不变量 #3),比「自动返回冲突 + 另开裁决端点」更省(无新端点、无新状态表,原型 KISS)。代价:作者确认 = 重发一次请求(带 flag),可接受。
|
||||
- 影响:C3 扩(POST /characters);`partition_writes` 在 gate 后再过写白名单(character-gen 只声明 writes=characters)。T5.6 前端入库流:预览→点入库→若 409 展示冲突→裁决勾选→带 acknowledge 重发。T5.7 E2E 覆盖两条路径。
|
||||
|
||||
## [2026-06-19] 角色卡 schema↔DB 形变落写侧 repo(list→{"items"}、arc str→{"text"}、rules→{"rules"})— @backend (T5.2)
|
||||
- 背景:T5.1 gotcha 指出 `CharacterCard.traits`/`speech_tics`(list)/`arc`(str) 与 DB `characters` JSONB **dict** 列不同形;`WorldEntityCard.rules`(list) 与 `world_entities.rules` JSONB dict 列不同形。需定一个固定包裹形。
|
||||
- 选择:写侧 repo 做转换层——`traits`/`speech_tics` 包成 `{"items":[...]}`、`arc` 包成 `{"text":...}`、`rules` 包成 `{"rules":[...]}`;`tags`/`relations` 是 JSONB list 直落。读回(喂防雷同/precheck)反向解包同形(`_existing_characters`/`_world_context`)。
|
||||
- 理由 / 取舍:固定 wrapper key 让 round-trip 确定(写=读互逆),不改 @db 列类型(@db owned)。代价:assemble/C5 读侧 `CharacterView.traits` 仍是裸 dict(本期 assemble 不消费 items 内层,无影响);若后续 assemble 要渲染 traits 文本,按同 wrapper 解包即可。
|
||||
- 影响:C3 扩(写侧 repo 形变契约);ww_core.domain 新增 `character_repo`/`world_entity_repo`。@db 若日后规整列类型为 list,可去 wrapper(迁移由届时 owner 执行)。
|
||||
|
||||
## [2026-06-19] 顺带补 `GET /projects/:id/style` 读指纹 + 写侧 repo 独立读视图 — @backend (T4.3)
|
||||
- 背景:UX §6.9 要展示完整 16 维指纹 + 证据,但 ARCH §7.2 端点清单**无**读指纹端点(只 `POST /style`)。C5 读侧 `SqlStyleRepo.latest` 已存在,但其 `StyleView` **只含 `dimensions`**(assemble 只需维度文本,不需证据/版本),扩它会牵动 C5 assemble 契约。
|
||||
- 选择:(1) **加 `GET /projects/:id/style`** 返最新指纹 `{dimensions, evidence, version}`(无指纹→404 `NOT_FOUND`)。(2) 不动 C5 读侧——在写侧 `SqlStyleFingerprintWriteRepo` 上加一个 `latest(project_id)->StyleFingerprintView`(含 evidence_json + version 的更全视图),`GET /style` 用它;C5 `SqlStyleRepo.latest`/`StyleView`(只 dimensions,供 assemble stable_core)保持不变。
|
||||
- 理由 / 取舍:读写分离 + 各自最小视图(同 `DigestAppendRepo` vs 读侧 `DigestRepo` 先例),不破坏 C5 assemble;前端无须靠 job `result` 拼凑摘要、可直接拉完整指纹。
|
||||
- 影响:C3 扩(新增 `GET /style`);**待回写规格**:ARCH §7.2 端点清单 + PRODUCT_SPEC §6 应补 `GET /projects/:id/style`(spec 一致性纪律,@docs 回写)。T4.4 前端 FingerprintView 消费此端点。
|
||||
|
||||
## [2026-06-19] 学文风 `work` 自建网关+repo(不走 FastAPI dep),凭据探测前移到请求阶段 — @backend (T4.3)
|
||||
- 背景:学文风走 jobs(M4-c):`POST /style` 立即返 202,提取经 BackgroundTask `run_job` 在**独立 session** 上跑(请求 session 已关闭)。但「无凭据→503」需在请求阶段就让用户知道(不能 202 后再静默 fail job)。
|
||||
- 选择:(1) `work(session)` 闭包内**自建** analyst 网关(`build_gateway_for_tier(session, SqlCredentialStore(session), "analyst")`)+ 写侧 repo(用 `run_job` 传入的独立 session),不依赖 FastAPI dep(dep 绑的是请求 session)。(2) 端点仍声明 `get_style_extract_gateway`(analyst) 依赖**仅作凭据探测**——无凭据时 dep 解析阶段抛 503,在 `job_repo.create` 之前拦下;其返回的网关本身不被复用(请求 session 即将关闭)。
|
||||
- 理由 / 取舍:兼顾「BackgroundTask 必须自建 session」(gotcha)与「无凭据请求阶段即 503」(友好、不写注定失败的 job)。代价:凭据被探测两次(请求阶段 + 后台 work),原型可接受。
|
||||
- 影响:C3 扩(POST /style);测试后台路径 monkeypatch `style.build_gateway_for_tier`/`style.SqlStyleFingerprintWriteRepo` + `job_runner.SqlJobRepo`(见 gotcha)。
|
||||
|
||||
## [2026-06-18] 网关结构化输出经可注入 `StructuredClient` 缝(instructor)— @llm
|
||||
- 背景:`OpenAICompatAdapter` 需接 instructor 产结构化输出(C1 类型预留 `output_schema`/`parsed` 但 M1 未接线),但测试绝不可联网/碰真实 LLM。
|
||||
- 选择:定义 `StructuredClient` Protocol(`create_with_completion(*, messages, response_model, **kw) -> (parsed, raw)`,即 `instructor.AsyncInstructor` 的形);adapter 构造可选注入 `structured_client`,未注入时懒构建 `instructor.from_openai(self._client)`。`run()` 透传 `result.parsed → LlmResponse.parsed`。
|
||||
@@ -78,3 +136,9 @@
|
||||
- 选择:CLAUDE.md 只保留跨文档、易错的**规则**(含 9 条架构不变量);所有枚举型/具体值改为指向 ARCHITECTURE/PRODUCT_SPEC 对应 §(唯一真源)。新增"冲突裁决"段:ARCHITECTURE > UX_SPEC/DEV_PLAN > PRODUCT_SPEC,矛盾时回写上游。
|
||||
- 理由 / 取舍:消除文档间漂移面、降低每 session 上下文成本;代价是查具体值需多跳一层到 ARCHITECTURE(可接受)。
|
||||
- 影响:编辑 CLAUDE.md 的任何 agent 遵循此纪律——契约/枚举值落在 spec 与 `memory/contracts.md`,不在 CLAUDE.md。
|
||||
|
||||
## [2026-06-19] T5.5 Skill registry + 表权限沙箱落 `ww_core.domain`,非 `packages/skills/ww_skills` — @backend
|
||||
- 背景:T5.5 任务文档把 registry/沙箱定位在 `packages/skills/ww_skills/`,但该包当前**不是 uv workspace member**(根 `pyproject.toml` 的 `[tool.uv.workspace].members` 不含它、mypy_path/ruff src 也不含),无 pyproject → 仅靠 sys.path hack 才能 import,app/测试/mypy 都无法干净消费它。根 pyproject 由 @devops 持有,自己接线会越权 + 阻塞。
|
||||
- 选择:把实现落在已是 workspace member、且承载「编排/写库 apply 层」的 `ww_core.domain`(`skill_registry.py` + `skill_permissions.py`,经 `ww_core.domain` 导出)。`ARCHITECTURE §5.6` 明确「强制点在编排/写库层」=ww_core,语义上也吻合。`packages/skills/ww_skills/__init__.py` 留**再导出门面**(import 自 ww_core),保留稳定「skills」导入名。
|
||||
- 理由 / 取舍:非阻塞、门禁可干净覆盖(ruff/mypy/pytest 全跑)、不越权改根配置;代价是物理位置与任务文档措辞略偏,但功能/契约(C8)完整。待 @devops 把 `packages/skills` 纳入 workspace + mypy_path 后,可平滑迁移实现到 `ww_skills` 而不动调用方(只改门面方向)。
|
||||
- 影响:消费方 import `ww_core.domain` 的 `SkillRegistry`/`filter_reads`/`partition_writes`/`validate_declaration`(或经 `ww_skills` 门面)。若 @devops 后续纳入 workspace,迁移由届时的 @backend/@llm 执行。
|
||||
|
||||
@@ -7,6 +7,33 @@
|
||||
|
||||
---
|
||||
|
||||
- [2026-06-20] @frontend **页面重访回显已存内容用 RSC 读 helper(`lib/api/server.ts`)作初值种入 client 组件,404/错误降级、绝不阻塞进页**(大纲/写作重载):先前大纲页 `initialChapters=[]`、工作台 `useState("")` → 重访空白虽然库里有数据。范式:① **大纲**:`fetchOutline(projectId)` 包 `getJson<OutlineResponse>` 取 `chapters??[]`,**try/catch 整体降级为 `[]`**(项目存在无大纲后端返 200 空列表,但任何错误也不该让大纲页 500);`useOutline(initial)` 已据 `initial.length>0` 置 `status="ready"`,传非空即回显,生成流照常覆盖。② **草稿**:`fetchDraft` 直接复用既有 `getJsonOrNull`(**404→null**,后端无草稿行正是 404),页面 `draft?.content ?? ""` 作 `Workbench` 的 `initialText`。③ **不要把初值塞进 stream 的 reducer**——`Workbench` 用 `useState(initialText)` 起步即可:流式那条 `useEffect` 起始 `stream.state.text` 为空、仅当 `phase∈{streaming,done,aborted}` 且文本变化才 `setText`,所以初值不会被空流反扑,SSE+AbortController+PUT 自动保存全不用动。④ 测纯加载/降级逻辑:`lib/api/server.test.ts` 用 `vi.stubGlobal("fetch", …new Response(JSON, {status}))` + `afterEach(vi.unstubAllGlobals)`(node 环境够用,`server.ts` 只依赖 `process.env`/`fetch`),断 200 解包、空/错误→`[]`、404→null。
|
||||
|
||||
- [2026-06-19] @qa **Kimi OAuth E2E 要 token 真落 pg:override `get_session_factory`→`e2e_sm`、但**不能** monkeypatch `kimi_oauth.SqlCredentialStore`(K1.3 单测那样会让落库走内存 fake,验不到真 pg)**(K1.5):与 K1.3 单测(FakeSession/FakeStore/FakeJobRepo,验逻辑)不同,E2E 要验真持久化——让后台 `work` 自建的 `SqlCredentialStore(session)` + `run_job` 默认 `SqlJobRepo` **保持真**,只 override `get_session_factory`→`e2e_sm`(后台 work 用真 session 写真 pg)。仍须:monkeypatch **模块级** `routers.kimi_oauth._default_http_client`→同一 scripted fake(后台 work 自建 http 非 dep)+ `app.dependency_overrides[kimi_oauth._default_http_client]`→同一 fake(端点 device-auth 走 dep;call[0]=device-auth、call[1..]=token 轮询顺序共享)+ monkeypatch `routers.kimi_oauth.asyncio.sleep`→no-op。断「加密非明文」:`_ACCESS_TOKEN.encode() not in row.oauth_enc`(Fernet 密文里找不到明文字节)+ `decrypt_oauth_bundle` 回环。`Gateway` 无公开 adapters accessor → 用 `gateway._adapters[provider]`;`KimiCodeAdapter._client` 读 `.base_url`/`.default_headers`/`.api_key`(同 K1.2 单测)。refresh-on-build 直测 `_build_provider_adapter`(非经 HTTP,更清晰):字符串 target monkeypatch `"ww_api.services.project_deps.httpx.AsyncClient"` + `setattr(project_deps,"kimi_refresh",fake)`。清理:删 `provider_credentials`(kimi-code) + `jobs`(kind=kimi_oauth) + `tier_routing`(project_id 为 NULL 的全局行)。ruff E501 按**显示宽度**算(中文字符占 2 列)——含中文注释/docstring 的行别贴边 100。
|
||||
|
||||
- [2026-06-19] @frontend **Kimi Code OAuth 三个端点无 params/body → openapi-fetch 用 `api.POST(PATH, {})` / `api.GET(PATH)`;路径用 `as const` 字面量常量复用**(K1.4):`POST .../oauth/start`、`.../oauth/disconnect`、`GET .../oauth/status` 的生成 schema 全是 `path?: never; ...; requestBody?: never`——`api.POST(START, {})`(传空 init obj,typecheck 绿)、`api.GET("/settings/providers/kimi-code/oauth/status")`(无第二参)。把 `start`/`disconnect` 路径抽成模块级 `const START = "/settings/...start"` 给 `api.POST` 时,**字符串字面量类型仍被 openapi-fetch 推断为合法 path key**(无需 `as const`,但抽常量去重 DRY)。② 连接流复用 `useJobPoll`(M4)零改:`connect` 拿 `data.job_id` 调 `poll.poll(job_id)`;轮询终态用 `useEffect([poll.status])` + `startedRef` 守门(同 `useStyleLearn` 先例,`initialPollState.status` 默认 `"polling"` 不能进页即据此显进度)。③ kimi_oauth job 完成态 result 只 `{connected, provider}`(**无 token**)——`jobConnected(job)=job.result?.["connected"]===true`,**别**期待/解析任何 token 字段。④ user_code a11y:用 `<output tabIndex={0} className="select-all">`(可读、可全选复制、可聚焦),别用纯 `<span>`。⑤ 档位路由原是只读展示,K1.4 改成可编辑(per-tier `<select>` provider + model input + 保存 PUT `{tier_routing}`);选 OAuth provider(kimi-code)经 `applyProviderChange` 自动套 `defaultModel="kimi-for-coding"`,API-key provider 清空 model 待填。
|
||||
- [2026-06-19] @backend **Kimi OAuth 后台轮询 work 自建 http 走模块级 `_default_http_client()`(非 dep)——测试须 monkeypatch 它 + `job_runner.SqlJobRepo`,不能只 override dep**(K1.3):`POST .../start` 的 `http` 是 FastAPI dep(端点用),但后台 `work` 在 `run_job` 独立 session 里调**模块级** `routers.kimi_oauth._default_http_client()` 自建 http(请求 dep 已失效)——E2E/单测必 `monkeypatch.setattr("ww_api.routers.kimi_oauth._default_http_client", lambda: fake_http)`(同一 scripted fake 实例供端点 device-auth call[0] + work poll call[1...] 顺序共享)+ `monkeypatch.setattr(job_runner,"SqlJobRepo", lambda s: fake_job_repo)`(run_job 默认 repo_factory 引模块级 SqlJobRepo,FakeSession 无 .execute;同 T4.3 gotcha)+ `monkeypatch.setattr(routers.kimi_oauth,"SqlCredentialStore", lambda s: shared_store)`(work 自建 store 落库)+ override `get_session`→FakeSession/`get_session_factory`→FakeSessionFactory + monkeypatch `routers.kimi_oauth.asyncio.sleep`→no-op(不真等 interval 秒)。TestClient 同步跑完 background task,断言稳定。
|
||||
- [2026-06-19] @backend **`AsyncHttpClient` 最小 Protocol 故意不含 `aclose`(只 `post`);router work 关客户端用 `getattr(http,"aclose",None)`**(K1.3):service 函数(start/poll/refresh)只需 `.post`,给 Protocol 加 `aclose` 会逼所有测试 fake 实现它(mypy red)。`httpx.AsyncClient` 有 `aclose`,故 router `work` 的 `finally` 用 `aclose = getattr(http,"aclose",None); if aclose: await aclose()`——既关真客户端、又不污染最小 Protocol、fake 无需实现 aclose。**测试模块级属性 monkeypatch(`project_deps.httpx`/`kimi_oauth.asyncio`)mypy strict 报 `attr-defined`(模块未 re-export)——用字符串 target `monkeypatch.setattr("pkg.mod.attr.sub", val)`(mypy 不静态校验字符串)规避,别 `setattr(mod.attr, "sub", val)`。**
|
||||
- [2026-06-19] @llm **Kimi Code 适配器:伪造头走 `AsyncOpenAI(default_headers=…)`、device_id 走 env-或-`uuid5` 确定性缺省(绝不随机);UA 验证为 `KimiCLI/1.5` 且参考实现未设 X-Msh-***(K1.2):① coding 端点 OpenAI 兼容 → `KimiCodeAdapter` **直接子类化 `OpenAICompatAdapter`**(零重复 complete/stream/结构化逻辑),区别只在工厂构造的客户端带 `default_headers` + coding base_url + access_token 当 api_key(SDK 自动发 `Authorization: Bearer`)。② `X-Msh-Device-Id` 必须**跨调用稳定**:`kimi_device_id()` = env `KIMI_DEVICE_ID` 优先,否则 `uuid5(NAMESPACE_DNS, "ww.kimi-code.device")` 模块级常量——**别用 `uuid4()` 每次新建**(每次随机 device id 会让 Kimi 侧把每次调用当新设备)。③ **校验对照 `picassio/pi-kimi-coder` `extensions/index.ts`**:它对 coding API 只显式设 `User-Agent: "KimiCLI/1.5"`(精确值,本实现采用)、**没有** X-Msh-* 头——与 PROGRESS K1 契约「缺 X-Msh→403」**不符**。本实现按「UA 必需(已验证)+ X-Msh-* 附带(契约要求、真客户端会发、额外标识无害)」处理;**到底缺 X-Msh 会不会 403 须 K1.3/K1.5 对真 api.kimi.com 联网验证**(含封号风险)。④ 单测断「构造出的 `AsyncOpenAI` 携带正确 `base_url`/`default_headers`/`api_key`」即可,零联网;工厂分派测试访问 `adapter._client` 须先 `isinstance(adapter, KimiCodeAdapter)` 收窄(`build_adapter` 返回 `ProviderAdapter` Protocol 无 `_client`,否则 mypy `attr-defined`)。
|
||||
- [2026-06-19] @db **K1.1 把 `provider_credentials.api_key_enc` 改 nullable 后,跨边界打穿到 @backend `credentials.py` mypy 报错——这是 K1.3 要吸收的,K1.1 不越权修** — `models.py` 列 `api_key_enc: Mapped[bytes | None]`,但 `apps/api/ww_api/services/credentials.py` 的 `StoredCredential.api_key_enc: bytes`(dataclass)+ `SqlCredentialStore.list_credentials/get_credential` 拿 `r.api_key_enc`(现 `bytes|None`)→ 2 处 `arg-type` 错。@db 只拥有 `packages/db/`,不改 `apps/api/`(目录所有权)。**K1.3 @backend 行动**:`StoredCredential.api_key_enc` 改 `bytes | None`(顺带加 `auth_type`/`oauth_enc` 字段 + OAuth 读写),mypy 即绿。首个新迁移(M1–M5 全零建表)= K1 新功能预期内。
|
||||
- [2026-06-19] @db **新迁移文件 autogenerate 后须手修两处再过 ruff**:① autogenerate 模板把 `from sqlalchemy.dialects import postgresql` **写两遍**(重复 import,ruff F811/格式炸)→删一行;② 模板用单引号 + import 顺序(`from alembic import op` 在 `import sqlalchemy as sa` 前)不合本仓 ruff(双引号 + isort)→照初始迁移 `220ca2e3d53f` 的样式手排(`import sqlalchemy as sa` → `from alembic import op` → `from sqlalchemy.dialects import postgresql`,双引号,docstring 后空行)。改完跑 `uv run ruff format` + `ruff check` 验。
|
||||
- [2026-06-19] @llm **真 `anthropic`/`google-genai` SDK 装上后,注入客户端 Protocol 与 SDK 具体类型在 mypy strict 下不互通——在边界 `cast` 解决,别硬改 Protocol**(R2 follow-up #2):① `instructor.from_anthropic(self._client)` 的重载只收 SDK 具体 `AsyncAnthropic|...` 联合,`AnthropicClient` Protocol 不匹配 → adapter 内 `cast("AsyncAnthropic", self._client)` 选中 AsyncInstructor 重载(`TYPE_CHECKING` 下 import,避免运行时硬依赖 SDK)。② `factory.build_adapter` 把**真实** `AsyncAnthropic`/`genai.Client` 传给 adapter 时,SDK 客户端的 `messages`/`aio` 是 **read-only 属性**,而 Protocol 成员默认 **settable** → mypy 报「expected settable variable, got read-only attribute」;adapter 仅**读取**这些属性,故在工厂里 `cast("AnthropicClient"/"GeminiClient", client)` 跨过约束。**测试替身缝完好**(adapter 仍声明 Protocol 形参,fake 仍可注入)。gemini 无 instructor 那条错(它走 `response_schema` 非 instructor),只有 factory 的 read-only 那条。
|
||||
- [2026-06-19] @backend **Codex 读端点缺口已补(解紧邻下面这条 @frontend gap)+ skill impl 迁回 `ww_skills` 包**(M5 R1/R3):① `GET /projects/:id/characters` + `GET /projects/:id/world_entities` 已落(复用 C5 读侧 `SqlCharacterRepo`/`SqlWorldEntityRepo`,反向 JSONB 解包 `{"items":[...]}`→list/`{"text":...}`→str/`{"rules":[...]}`→list,无行返空列表)→ @frontend `pnpm gen:api` 后 CodexPage 初始列表可拉**跨会话全量**,去掉「会话内回显」局限。② skill registry/沙箱从 `ww_core.domain` **迁到** `packages/skills/ww_skills/`——`from ww_core.domain import SkillRegistry/SqlSkillRepo/SkillRecord/SkillRepo/partition_writes/filter_reads/validate_declaration/KNOWN_TABLES` **已失效**,改 `from ww_skills import …`。③ 改 `packages/skills/pyproject.toml` deps(去 ww-core、补 ww-llm-gateway+sqlalchemy)后**必须 `uv sync`** 重建 ww-skills,否则按旧元数据解析。④ `build_gateway_for_tier` 现用 `ww_llm_gateway.build_adapter(provider, api_key=…, base_url=…)`(去掉 apps/api 直接 `AsyncOpenAI`/`OpenAICompatAdapter` import);anthropic/gemini 传 base_url=None 走原生适配器。
|
||||
- [2026-06-19] @frontend **【RESOLVED 2026-06-19】Codex 读端点缺口已闭合(M5 R1 follow-up #1)**:@backend 补 `GET /projects/:id/characters` / `GET .../world_entities`(C3 扩,反向 JSONB 解包成 API 友好字段)后,前端 `gen:api` 纳入 `CharacterListResponse{characters?:[CharacterCardView]}` / `WorldEntityListResponse{world_entities?:[WorldEntityCardView]}`(+`lib/api/types` 别名);`lib/api/server` 加 `fetchCharacters`/`fetchWorldEntities`(无行→空列表,非 404,仿 `fetchForeshadow`);codex route(Server Component)`Promise.all` 拉项目+人物+世界观全量传初始数据;`CodexPage` 渲染**跨会话持久化真源**(刷新不再空),本会话新入库卡经 `mergeCharacterCards`(纯逻辑,按 name 去重、持久化优先、node 单测)合并补显(不与真源重复)。世界观本期无入库端点,故只渲染真源 + 生成器预览(无 session 合并)。**「会话内回显」局限已去除**。原 T5.3「生成+本次入库会话内回显」管理入口仍保留(generate→ingest 后即时见新行)。
|
||||
- [2026-06-19] @frontend **入库 409 冲突详情形 = accept gate 的 `details.conflicts`(ReviewConflict 形),不是 missing_conflict_indices**(T5.3):`POST /characters` 的 409 CONFLICT_UNRESOLVED 信封 `details{conflicts:[{type,where,refs,suggestion}], conflict_count}`(continuity 预检产出,C3 扩 T5.2),区别于 accept 的 `details{missing_conflict_indices, conflict_count}`(裁决覆盖缺口)。前端 `extractIngestConflicts` 按 `error.code==="CONFLICT_UNRESOLVED"` + 收窄 `details.conflicts`(复用 `lib/review/sse` 的 `ReviewConflict` 形 + history.ts 同款安全收窄);裁决流 = 展示冲突 → 作者确认 → 带 `acknowledge_conflicts=true` 重发(非逐条 conflict_index,整体放行,对齐后端 gate 语义)。错误码同款 503 LLM_UNAVAILABLE 引导去设置(仿 useRefine)。
|
||||
- [2026-06-19] @frontend **命令面板(⌘K)走全局 keydown + RootLayout 挂载,纯过滤/高亮逻辑抽 `lib/command/palette` node-env 单测**(T5.6):`CommandPaletteMount`(client)读 `usePathname` 注入项目上下文 → `projectIdFromPath` 正则抽 `/projects/<id>`;命令清单(导航 + 生成动作)+ `filterCommands`(title/keywords 大小写无关子串) + `moveHighlight`/`clampHighlight`(循环) 全是纯函数、node 单测。组件层只管 ⌘K/Ctrl+K toggle(`window.keydown`)、焦点(打开 `inputRef.focus`)、Esc/↑↓/Enter、a11y(role=dialog/listbox/option + aria-selected + motion-safe)。生成动作(生成角色/世界观)= 跳 `codex?gen=character|world`,CodexPage 读 searchParams 直开对应 tab(无需独立 modal 路由)。
|
||||
- [2026-06-19] @backend **测 `build_gateway_for_tier` 多 provider 接线要用真 Fernet key(不是 `"x"*44`)**(T5.2):该函数解密凭据建适配器,凭据是 `encrypt_api_key(..., key=CREDENTIAL_ENC_KEY)` 加的密,key 必须是合法 Fernet(32 字节 url-safe base64),否则 `decrypt_api_key` 抛 `CredentialKeyError`。生成一个:`uv run python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"`,设 `os.environ["CREDENTIAL_ENC_KEY"]=<key>` + `get_settings.cache_clear()`(lru_cache)。生成/入库**端点**测试不碰真解密(override 网关 dep 注 schema-routing fake),故那些仍可用 `"x"*44`;只有直接调 `build_gateway_for_tier` 的单测需真 key。
|
||||
- [2026-06-19] @backend **生成/入库/precheck 端点测试用 schema-routing fake 网关(按 `req.output_schema` 返不同 parsed)**(T5.2):一次 ingest 请求触发 precheck(`output_schema=ContinuityReview`),world/character generate 各触发 `WorldGenResult`/`CharacterGenResult`——单一固定-parsed 的 `FakeReviewGateway` 不够。端点测试自带 `_SchemaRoutingGateway({Schema: instance})`(`run` 据 `req.output_schema` 路由,未命中返 None),三个网关 dep(worldbuilder/character_gen/precheck)override 成同一个。**生成预览端点也要 `commit()`**(网关 ledger add-only → 不 commit 则 usage 静默丢,同 draft 坑)——断言 `session.commits==1` 即便预览不写业务表;冲突 409 路径同样 commit 落 precheck usage。
|
||||
- [2026-06-19] @llm **回退/熔断要让适配器把瞬时故障翻译成 `TransientProviderError`,否则网关只重试/回退它认得的错**(T5.4):`Gateway` 的重试谓词 `_is_retryable` 只认 `TransientProviderError` 和 `AppError(RATE_LIMITED)`;普通 `Exception`/厂商原生异常**不重试不回退、直接上抛**(内容策略拒绝等本就该让作者知情,§4.5)。故每个适配器在 `complete`/`stream` 的 `except` 里按异常类名(RateLimitError/APITimeoutError/APIConnectionError/InternalServerError/APIStatusError)+`status_code`(429 或 ≥500) 判定瞬时→包成 `TransientProviderError(provider=...)`。**新增适配器必须照做**,否则它的 429/5xx 会穿透回退链变成硬失败。`packages/llm_gateway/tests/fakes_resilience.py` 的 `ScriptedAdapter(failures=[...])` 用 `TransientProviderError` 模拟可回退失败、用 `AppError(其它码)` 模拟不可回退。
|
||||
- [2026-06-19] @llm **`Gateway` 构造签名扩了但保留 `resolver=` 兼容——apps/api 的 `build_gateway_for_tier` 无需改即不破,但也因此默认不启用回退链**(T5.4):`Gateway(adapters, ledger, *, chain_resolver=None, resolver=None, max_retries=2, breaker=None)`。`resolver`(单路由) 会被自动包成单元素链,故 `project_deps.build_gateway_for_tier` 仍传 `resolver=resolve_route` 照常工作(**未回归**)。但单元素链=无回退——**真正启用回退须 apps/api 改 `build_gateway_for_tier`**:按 DB `tier_routing.fallback`(`StoredRouting.fallback` 已有) 预备多个 provider 适配器进 `adapters` dict + 传 `chain_resolver=lambda tier: chain_from_routing(tier, primary, fallback)`,并可注入跨请求共享的 `CircuitBreaker`(默认每个 Gateway 实例自带一个、进程内不共享,逐请求新建网关则熔断不跨请求累积)。Anthropic/Gemini 适配器真实接线还需 @devops 加 `anthropic`/`google-genai` SDK 依赖(适配器本身懒导入/注入客户端,仅 apps/api 构造真实 client 时需要)。
|
||||
- [2026-06-19] @llm **character-gen schema 的 `traits`/`speech_tics` 是 `list[str]`,但 DB `characters.traits`/`speech_tics`/`arc` 列是 JSONB **dict**(T5.2 ingest 须转形)**(T5.1):C6 扩 `CharacterCard` 把 `traits`/`speech_tics` 设计成 `list[str]`(生成产物天然是列表)、`arc` 设计成 `str`(弧光一句话),但 `packages/db/ww_db/models.py` 的列类型是 `traits: JSONB dict` / `arc: JSONB dict` / `speech_tics: JSONB dict`,而 `tags`/`relations` 才是 JSONB **list**。T5.2 入库映射时:list 字段(tags/relations)直落;`traits`/`speech_tics` 须包成 dict(如 `{"items":[...]}` 或按语义拆)或由 @db 调列形;`arc`(str) 同理包 dict。**别假设 schema 字段形 == DB 列形**——schema 贴生成产物、DB 列贴存储,ingest 是转换层。`role`/`backstory` 是 Text 列、直落。`WorldEntityCard.rules:list[str]` ↔ `world_entities.rules` JSONB(包 `{"rules":[...]}` 或裸 list)。
|
||||
- [2026-06-19] @llm **入库前 continuity 校验是「编排器追加的一道检查」,复用 `continuity_spec` 而非新建 spec**(T5.1,ARCH §6.5):`precheck_generated_cards` 传入 `continuity_spec`(不是 character-gen 自调、不是新审种),跑生成角色卡 vs 世界观/已有角色真相源、返 `list[Conflict]`。守不变量#1(agent 只经 DB/编排器通信、不互调)。T5.2 入库端点拿 conflicts 做 gate:有冲突→提示作者裁决/调整,**不静默入库**(同 accept 的冲突 gate 精神,但这是生成预览阶段、非 chapter accept)。独立生成语义:网关失败直接上抛端点处理(不做 review 图的失败隔离)。
|
||||
- [2026-06-19] @qa **学文风 E2E:后台 work 自造网关须 monkeypatch `style.build_gateway_for_tier`(不是 override 网关 dep)**(T4.5):`POST /style` 请求阶段的凭据探测走 `get_style_extract_gateway`(可 `dependency_overrides` 注假网关绕过 503),但**真正提取**在 `run_job` 自建的独立 session 上跑 `_make_style_learn_work`→其内 `build_gateway_for_tier(session, store, "analyst")` 从凭据建**真 OpenAI 适配器**(与请求 dep 无关)。E2E 无真凭据 → 必 `monkeypatch.setattr(ww_api.routers.style, "build_gateway_for_tier", 返真 Gateway 包假适配器)`(ledger 绑入参 session=后台真 session)。写侧 `SqlStyleFingerprintWriteRepo` + `run_job` 默认 `repo_factory`→真 `SqlJobRepo` **保持不动** → 指纹/job 真落 pg(只换 LLM)。同 M3:override `get_session_factory`→`e2e_sm`(同测试 engine/loop),ASGITransport 下 `await client.post` 等 background task 跑完,轮询 `GET /jobs/{id}` 稳定见 done。第四审 E2E:只 override `get_review_gateway`(analyst)即覆盖整个四审图(四档位同 deepseek 单适配器),假适配器据 `req.output_schema is StyleDriftReview` 返漂移 parsed;回炉假适配器据 `req.output_schema is None` 返改写串(writer 纯文本,保证 refined≠original)。
|
||||
- [2026-06-19] @frontend **`GET /jobs/{id}` 在 OpenAPI 是裸 `{[k]:unknown}`(T0.3 未建命名 schema)**(T4.4):前端无法直接用生成类型当 JobView,须在 `lib/jobs/job.ts` 安全收窄(`narrowJob`:status 非法→`queued`、progress 夹取 0..100、result 仅取 object 非数组)。同理 `chapter_reviews.style` 是松散 `{[k]:unknown}|null`→`normalizeStyleDrift` 收窄成 `{score(缺省100),segments:[{idx,score(缺省100),label:string|null}]}`(score 缺省 100 = 后端无指纹降级态)。轮询 reducer/收窄是纯逻辑、node-env 单测;hook 只管 setTimeout+fetch。
|
||||
- [2026-06-19] @frontend **`useJobPoll` 的 `initialPollState.status` 默认 `"polling"`,调用方别在挂载即据此显进度**(T4.4):`useStyleLearn` 用 `startedRef` 守门——只在提交过一次 `POST /style` 后才把 `poll.status` 映射进 UI,否则文风页一进就误显「提取中…」。轮询「停止」靠 reducer 到 done/failed 终态后不再 `setTimeout`(无 abort 概念,区别于 SSE 的 AbortController)。回炉 `useRefine` 503(`error.code==="LLM_UNAVAILABLE"`)→提示去设置,仿 review 流前 503 检出。
|
||||
- [2026-06-19] @backend **测 `run_job` 后台路径要 monkeypatch `job_runner.SqlJobRepo` 而非 `_default_repo_factory`**(T4.3):`run_job(..., repo_factory=_default_repo_factory)` 的 `repo_factory` 是**默认参数值**,在函数**定义时**绑定——`monkeypatch.setattr(runner_mod, "_default_repo_factory", ...)` 改的是模块属性、改不到已绑定的默认值,无效。`_default_repo_factory` 体内 `return SqlJobRepo(session)` 引用的是**模块全局** `SqlJobRepo`,故 monkeypatch `runner_mod.SqlJobRepo` 才生效(否则后台 `set_running` 用 `SqlJobRepo(FakeSession)` 触 `.execute` 炸、被 run_job 吞成 job_failed)。端点测 BackgroundTask 落库路径:override `get_session_factory`→`FakeSessionFactory` + monkeypatch `style.build_gateway_for_tier`(产 StyleFingerprintResult)/`style.SqlStyleFingerprintWriteRepo`(指共享 fake repo) + `runner.SqlJobRepo`(指 fake job repo);ASGITransport 下 `await client.post` 会等 background task 跑完,断言稳定(同 run_overdue_scan 时序先例)。
|
||||
- [2026-06-19] @backend **bulk `UPDATE` 取受影响行数要 `cast` 成 `CursorResult`**(T4.1 `reap_zombies`):`await session.execute(update(...))` 静态类型是 `Result[Any]`,**无 `.rowcount`** 属性(mypy strict 报 `attr-defined`);实际运行时是 `CursorResult`。做法:`from sqlalchemy import CursorResult` + `cast("CursorResult[Any]", result).rowcount`。`reap_zombies` 用一条 bulk UPDATE(非逐行)标 `running→failed` 高效且原子。
|
||||
- [2026-06-19] @backend **`run_job` 失败置态要开「全新」session**(T4.1):`work` 抛异常后,原 session 的事务已作废,不能复用它 `fail(job_id, ...)`——`_mark_failed` 经 `session_factory()` 再开一个独立 session 写 failed + commit。故 `run_job` 失败路会开 **2 个** session(业务一个 + 失败置态一个),单测断言 `len(factory.sessions)==2`。`run_job` 对 repo 只依赖最小 `JobLifecycleRepo` Protocol(set_running/complete/fail)——比全 `JobRepo` 窄,单测 fake 不必实现 create/get/reap(同 `run_overdue_scan` 用最小 `OverdueScanRepo` 先例)。
|
||||
- [2026-06-18] @llm **`SqlAlchemyLedgerSink.record` 改 add-only(T3.8 修,去 `await flush()`)**:原 add+flush 在并行审里 flush 重入炸(见下条)。改为只 `session.add(row)`——`add()` 同步不让步→并行协程不交错;持久化靠端点/事务 `commit()`(自动 flush)。draft/review/accept/outline 端点末尾均已 commit,记账不丢。**凡新增「并行用网关产 usage」的路径,sink 必须保持 add-only,不得在并行段 `await flush`。** 旧 gotcha「record 只 flush 不 commit」措辞已过时——现为「add-only,commit 归调用方」(commit 仍会 flush,故记账语义不变)。
|
||||
- [2026-06-18] @qa/@llm **并行审记账撞 session(M3 真 bug,T3.7 暴露→T3.8 修)**:三审同 LangGraph superstep 并行,各自 `gateway.run()`→共用**请求 session** 的 `SqlAlchemyLedgerSink.record`(`session.add`+`await session.flush()`)。`AsyncSession` 非并发安全→第二/三审 flush 撞 `Session is already flushing`→被 `run_review` 失败隔离吞成 `incomplete`→**foreshadow/pace 静默丢失**(SSE 无事件、`chapter_reviews.foreshadow_sug`/`pace` 列空、日志 `review_node_incomplete error='Session is already flushing'`)。M2 单审未触发。根因=并行路径里有 `await flush()`(唯一 await 的 DB-IO)。修向:审稿期记账避免并发 flush(add-only 靠端点 commit / 或缓冲后 collect 串行落 / 或并行审记账用独立 session)。
|
||||
- [2026-06-18] @qa **E2E 验「验收后 BackgroundTask 扫描」时序**:httpx `ASGITransport` 下 `await client.post(...)` 会等 ASGI app 协程(含 Starlette background tasks)跑完才返回,故 client 上下文退出后断言稳定不 flaky;必 override `get_session_factory`→`e2e_sm`(真 sessionmaker,同测试 engine/loop),否则默认 `get_sessionmaker()` 另建 engine 绑别的 loop。
|
||||
@@ -48,3 +75,10 @@
|
||||
- [2026-06-18] @db mypy strict + 跨包 editable 安装:模型里 `from ww_db.base import Base` 会被判成 Any(报 "cannot subclass Any"),需在根 `pyproject.toml` 设 `[tool.mypy] mypy_path=[...各包源码目录...]` + `namespace_packages=true`,否则 editable 包解析不到源码。
|
||||
- [2026-06-17] @docs 命名契约:后端 Python/Pydantic 一律 **snake_case**(字段/schema/JSON),前端经 OpenAPI 生成类型消费——别手写 camelCase 共享类型(评审里曾因 TS 旧栈遗留 camelCase 与 Pydantic 契约对不上)。
|
||||
- [2026-06-17] @docs 数据写入只走**验收事务**:四审 agent 只读不写;任何 AI 产出入库必经 `accept`(HITL gate)。别在 agent 节点里直接写库。
|
||||
- [2026-06-19] @qa M5 E2E:生成端点的网关(`get_worldbuilder_gateway`/`get_character_gen_gateway`/`get_precheck_gateway`)是**请求 scope FastAPI 依赖**(内部虽调 `build_gateway_for_tier`,但作为 `Depends` 暴露)→ E2E 直接 `app.dependency_overrides[get_*_gateway]=真Gateway包假适配器` 即可,**无需** monkeypatch `build_gateway_for_tier`(区别于 M4 学文风:那是 `run_job` 后台**自建**网关,须 monkeypatch 模块级 `style.build_gateway_for_tier`)。
|
||||
- [2026-06-19] @qa `served_by`(fell_back/degraded) **不出 API**(仅观测/记账标注)→ 端到端 HTTP 路径要证「回退真发生」,断言 **DB 真源 `usage_ledger.provider` = fallback 名而非 primary**(网关记账记实际服务方,ARCH §4.5)。HTTP fallback 用真 `Gateway` + `chain_resolver=chain_from_routing(...)` + primary 假适配器每次 `complete` 抛 `TransientProviderError`(备 ≥max_retries+1 次失败耗尽重试)→ 切 fallback;能力降级路径(无支持者)经直测 `Gateway.run` 验 `served_by.degraded` 更清晰。
|
||||
- [2026-06-19] @qa `ww_agents.Conflict.type` 是 `ConflictType` **Literal**(性格漂移/能力不符/设定违例/地理矛盾/时间线倒错)——构造 precheck 假冲突须用枚举值之一(如「设定违例」),随手写「设定冲突」会 pydantic literal_error。
|
||||
- [2026-06-19] @llm ⚠️**已被下条推翻**(保留作纠错轨迹):曾写 Kimi Code coding API 的伪造头「只发 `User-Agent: KimiCLI/1.5`、不带 X-Msh-*」——该结论源自分歧/错误参考 `picassio/pi-kimi-coder`(UA-only),导致误把头集裁成 UA-only。**勿采纳本条**,见下条。
|
||||
- [2026-06-19] @backend Kimi Code device authorization **必须带 `scope=kimi-code`**(`start_device_authorization` 请求体 = `{"client_id": <id>, "scope": "kimi-code"}`)。K1.5 联网实测:省略 scope 登录能拿到真 JWT,但调 `api.kimi.com/coding/v1` 回 `401 Invalid Authentication`——token 缺 coding entitlement。`scope=kimi-code` 是 coding-agent 文档化 scope(`ooojustin/opencode-kimi` `constants.ts` 发送;kimi-cli v1.41.0 已不发但服务端仍接受)。scope **只**放 device_authorization;token 交换(`poll_token`)/刷新(`refresh`)**不带** scope(device flow 惯例)。⚠️ 此前 K1.3 单测/decision/contract 写「省略 scope」(误信 kimi-cli v1.41.0),已全部修正。
|
||||
- [2026-06-19] @llm **Kimi Code coding API 伪造头真源 = `ooojustin/opencode-kimi`(`src/headers.ts`+`src/constants.ts`,1:1 镜像 kimi-cli v1.37.0),发完整 7 头、UA `KimiCLI/1.37.0`**(K1.2 校正,推翻上方 pi-kimi-coder UA-only 那条):`picassio/pi-kimi-coder` 是分歧/错误参考(只发 UA),照它裁成 UA-only 是误修,已回退。`kimi_code_headers()` 发 `User-Agent=KimiCLI/1.37.0` + `X-Msh-Platform=kimi_cli`(字面常量,非 OS 名)+ `X-Msh-Version=1.37.0`(==UA 版本)+ `X-Msh-Device-Name`(`socket.gethostname()` ASCII 化)+ `X-Msh-Device-Model`(macOS=`f"macOS {platform.mac_ver()[0]} {platform.machine()}"`,`machine()` 原样 `arm64`/`x86_64` 不归一化)+ `X-Msh-Os-Version`(`platform.version()`)+ `X-Msh-Device-Id`(稳定 32 位无连字符 `uuid4().hex`:env `KIMI_DEVICE_ID` 优先→否则读/建 `~/.kimi/device_id` 复用,**绝不每次随机**,否则 Kimi 侧每次当新设备)。源码若与此有出入以源码为准(本次核对 master 一致)。⚠️ HTTP 头值含非 ASCII 会被底层 fetch/httpx 拒,host 派生值(Device-Name/Model/Os-Version)须 ASCII 化(`asciiHeaderValue`:裁 `\x20-\x7e` 之外 + trim,空回退 `unknown`)。单测用 env `KIMI_DEVICE_ID` 注入固定 id 保 CI 确定(不触真 ~/.kimi)。ruff E501 按显示宽度算(中文占 2 列),含中文 docstring/注释的行别贴边 100。
|
||||
- [2026-06-19] @backend `assemble`(C5)此前只从 world_entities/characters/style/rules + cards/foreshadow/digests/outline.beats 组装,**从不含项目 premise/logline/theme/title,也无「写章」指令** → 全新项目(只有 premise、无世界观/角色/大纲)产出 `stable_core=""`+`volatile=""`,writer 的 user message 为空,LLM 回 `400 "the message at position 0 with role 'user' must not be empty"`。修复:`MemoryRepos` 加第 8 个 repo `project:ProjectSpecRepo`(`spec(project_id)->ProjectSpecView{title,logline?,premise?,theme?}`,按 project_id 读 `projects`,无 owner 过滤——owner 隔离归路由层);`assemble` 把「作品蓝本」放进 `stable_core` 首段(书级 spec=定型→缓存前缀,不违 #9),并让 `volatile` 始终含 `请创作第 {chapter_no} 章的正文。`(易变指令)。⚠️ 任何手搓 `MemoryRepos(...)` 的地方(含测试 fake)现在**必须**补 `project=` 字段,否则 dataclass 缺参报错。无 DDL 变更(只读既有 projects 列),无迁移。
|
||||
|
||||
Reference in New Issue
Block a user