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

@@ -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 真落 pgoverride `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 走 depcall[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 objtypecheck 绿)、`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 providerkimi-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 引模块级 SqlJobRepoFakeSession 无 .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.3service 函数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_keySDK 自动发 `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 即绿。首个新迁移M1M5 全零建表)= K1 新功能预期内。
- [2026-06-19] @db **新迁移文件 autogenerate 后须手修两处再过 ruff**:① autogenerate 模板把 `from sqlalchemy.dialects import postgresql` **写两遍**(重复 importruff 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` importanthropic/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 routeServer 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、a11yrole=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 必须是合法 Fernet32 字节 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三个网关 depworldbuilder/character_gen/precheckoverride 成同一个。**生成预览端点也要 `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.1C6 扩 `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.1ARCH §6.5`precheck_generated_cards` 传入 `continuity_spec`(不是 character-gen 自调、不是新审种),跑生成角色卡 vs 世界观/已有角色真相源、返 `list[Conflict]`。守不变量#1agent 只经 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。同 M3override `get_session_factory``e2e_sm`(同测试 engine/loopASGITransport 下 `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` Protocolset_running/complete/fail——比全 `JobRepo` 窄,单测 fake 不必实现 create/get/reap`run_overdue_scan` 用最小 `OverdueScanRepo` 先例)。
- [2026-06-18] @llm **`SqlAlchemyLedgerSink.record` 改 add-onlyT3.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-onlycommit 归调用方」commit 仍会 flush故记账语义不变
- [2026-06-18] @qa/@llm **并行审记账撞 sessionM3 真 bugT3.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。修向审稿期记账避免并发 flushadd-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_authorizationtoken 交换(`poll_token`/刷新(`refresh`**不带** scopedevice 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 无迁移