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>
85 lines
47 KiB
Markdown
85 lines
47 KiB
Markdown
# 踩坑与约定(append-only)
|
||
|
||
> 实现中发现的坑、易错点、约定俗成——让兄弟 agent 不重复踩。一条一项,最新在最上。
|
||
> 只记**非显而易见**的;规格/CLAUDE.md 已写的别重复。
|
||
|
||
格式:`- [date] @skill <坑/约定> — 缘由 + 怎么做`
|
||
|
||
---
|
||
|
||
- [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。
|
||
- [2026-06-18] @frontend **审稿 seed 扩成三态**:`useReviewStream.seed` 入参由 `ReviewConflict[]` 改 `ReviewSeed{conflicts,foreshadow,pace}`(进页同时种伏笔建议/节奏留痕,免重审即可看)。`ReviewStreamState` 加 `foreshadow:ForeshadowSuggestion[]`(累加) / `pace:PaceReport|null`(**替换非累加**,对齐后端 collect「pace 整 dict 入列」)。
|
||
- [2026-06-18] @frontend **伏笔 transition 乐观更新只改 status、回滚存快照**:`useForeshadow.transition` 本地先改 status,PATCH 失败 `setItems(snapshot)` 回滚 + 读 `error.details.reason`(duplicate/invalid_transition/empty_update) 映射文案。register **不乐观**(要服务端 code 唯一校验):成功才追加返回行,重复 code→422 友好提示不改 items。大纲页**无 GET 端点**:进页 `initialChapters=[]`,靠 `POST .../outline` 生成填充(无凭据→503 引导去设置)。
|
||
- [2026-06-18] @backend **端点级测「无凭据→LLM_UNAVAILABLE」不要靠真实 `get_*_gateway` dep 解析**——`FakeSession` 不支持 `.execute`,`SqlCredentialStore` 会炸成 500。做法:override 网关 dep 注一个 `raise AppError(LLM_UNAVAILABLE)` 的 async 函数(等价无凭据行为),与 review/accept/outline 测试一致。
|
||
- [2026-06-18] @backend **BackgroundTask 必须自建独立 session**:FastAPI BackgroundTasks 在 response 发回、请求 session 关闭后才跑——复用 `Depends(get_session)` 的 session 已关闭会炸。验收后到期扫描经可注入 `SessionFactory`(=`get_sessionmaker()`),`run_overdue_scan` 内 `async with factory()` 开新 session 自己 commit;再加 `repo_factory` 缝便于单测注 fake、纯函数 await 不起后台线程。**accept 端点新增 `get_session_factory` 依赖→所有 accept 测试 client 需 override 它**(否则 dep 解析会建真 engine)。
|
||
- [2026-06-18] @backend **伏笔登记重复 code / 非法转移 → `VALIDATION`(422) 非 409**:现有唯一 409 码 `CONFLICT_UNRESOLVED` 专指未决冲突禁验收,不复用;`details.reason` ∈ `duplicate`/`invalid_transition`/`empty_update` 供前端区分。register 捕 SQLAlchemy `IntegrityError`→rollback→`AppError(VALIDATION)`;transition 捕 `InvalidTransition`/`LookupError`(404)。
|
||
- [2026-06-18] @llm **三审列类型不齐**:`chapter_reviews.foreshadow_sug` 是 JSONB **list**、`pace` 是 JSONB **dict**——collect 把 `ForeshadowReview{planted,resolved}`(dict) **扁平成单 list**、每条加 `kind:"planted"|"resolved"`;`PaceReview` 整体入 dict 列。贴合既有 DB 列类型、不改 @db。新审种加入须同步:collect 列映射 + `sse._section_result_events` 分支 + `normalize_review` 键名。`sse.py` import `.collect` 的 spec.name 常量做 section 分流(无循环:collect 不 import sse)。
|
||
- [2026-06-18] @llm **三审并行图测试按 `req.output_schema` 路由 parsed**(`SchemaRoutingRunGateway`)——单 `FakeRunGateway` 对所有审返同一 parsed 会让三审拿错 schema。验失败隔离:某 schema 不登记→网关抛 KeyError→`run_review` 隔离为 `incomplete`,无需改 gateway。
|
||
- [2026-06-18] @backend **伏笔 `record_progress` append JSONB 必须新建 list 重赋值**(`row.progress = [*old, entry]`),不可原地 `.append()`——SQLAlchemy 默认不侦测可变 JSONB 原地突变,原地改不脏标记→flush 丢失。`scan_overdue` 仅在有变更时 flush(空扫描零写)。状态机:`transition` 同态(current==to)幂等放行、CLOSED 为终态(离开 CLOSED 全非法抛 `InvalidTransition`);`is_overdue` 严格大于(current==expected_close_to 仍在窗口内不逾期)、无 expected_close_to 永不逾期。
|
||
- [2026-06-18] @llm **orchestrator 内每模块各自声明 `GatewayRun` Protocol**(`review_node.py` 与 `outline_node.py` 各一份,按模块最小依赖)——**不跨模块复用、不在 orchestrator `__init__` 重复导出**(`__init__` 只导出 review_node 那个,避免 re-export 名冲突);outline 的为模块内部用。
|
||
- [2026-06-18] @qa **M2 E2E 多档位假适配器**:`config.tier_defaults` writer/analyst/light 默认同 provider(deepseek)→单个假适配器(`provider="deepseek"`)即覆盖三档位;据 `req.output_schema is ContinuityReview`(续审)/否则 digest facts schema 分支返回 `parsed`;三档位用不同 `input_tokens` 区分以断言各自落 `usage_ledger`。三端点记账闭环 = review 端点流末 commit + accept 验收事务末 commit 都把网关 ledger flush 真正提交(M1 ledger bug 在 M2 无复发)。
|
||
- [2026-06-18] @qa **E2E 验证「digest 从终稿非草稿」(#4) 手法**:final_text 注入草稿没有的标记串,假 light 适配器把它放进 digest facts 的 `summary`,断言 `chapter_digests.facts["summary"]==标记` 且 `标记 not in draft_text`。accept 409 gate 经 ASGITransport 正常返回(`AppError` 不上抛),断言 `resp.json()["error"]["details"]["missing_conflict_indices"]`(`ErrorCode` StrEnum → `"CONFLICT_UNRESOLVED"`)。
|
||
- [2026-06-18] @frontend **审稿历史 `conflicts` 在 OpenAPI 被标松散 `{[k]:unknown}[]`**(后端用 dict/JSONB 列)→ 前端 `lib/review/history.ts` 安全收窄成 `ReviewConflict{type,where,refs,suggestion}`,缺字段给默认、**保序**(顺序=冲突 gate 的 `conflict_index` 身份,不可重排,否则裁决错位)。
|
||
- [2026-06-18] @frontend **审稿页重审完成重置裁决草稿用 streaming→done 边沿判定**(`wasReviewingRef`):不能用 conflicts 长度变化判(同数不同组会漏重置),也不能在 seed(phase=idle,进页种历史留痕)时误触发。
|
||
- [2026-06-18] @backend **冲突 gate 判据(accept)**:冲突身份 = 「最近一条 `chapter_reviews.conflicts` 列表的下标」;裁决 `ConflictDecision{conflict_index, verdict:accept|ignore|manual, note?}`;gate 通过 = 裁决的 `conflict_index` 集合**覆盖** `range(len(conflicts))`,缺判→409 `CONFLICT_UNRESOLVED` + `details.missing_conflict_indices`/`conflict_count`;无审稿留痕或零冲突→直通验收。
|
||
- [2026-06-18] @backend/@qa **ASGITransport 默认 `raise_app_exceptions=True`**:accept 事务回滚测试里,repo 抛的非-`AppError`(如 `RuntimeError`)会**上抛到 client 调用方**而非返回 500——测试用 `pytest.raises(RuntimeError)` 包住请求调用、再断言 `session.commits == 0`(证明未部分提交)。DB 级原子回滚由 T2.7 真 pg 覆盖。
|
||
- [2026-06-18] @llm **langgraph 并行节点同写一个 state key 必须配 reducer**:并行四审同 superstep 各写 `{spec.name: ...}` 到 `reviews`,无 reducer → LangGraph 抛 `InvalidUpdateError`。用 `Annotated[dict, merge_reviews]`(浅合并、返回新 dict、不可变)。`ChapterState` 因逐节点填充改 `total=False`。
|
||
- [2026-06-18] @llm **langgraph `add_node` 重载拒收显式 `Callable` 类型别名**:`make_review_node` 返回的具名 `BoundReviewNode` 别名会让 mypy 报 incompatible arg-type。图工厂里改用 **inline `async def` 闭包**(spec 经默认参 `_spec=spec` 绑定,避开循环晚绑定),mypy 才推出精确函数类型匹配重载。`make_review_node` 仍作公共缝(供 T2.5 跑单审)单独保留+单测。
|
||
- [2026-06-18] @llm **审稿失败隔离两层**:审级失败在 `run_review` 内标 `incomplete`(§5.2 任一审不阻塞其余);归一级意外(畸形 entry)→ `normalize_review` 发一条 `error` 事件后收尾(同 `normalize_deltas` 纪律)。
|
||
- [2026-06-18] @llm **instructor 1.15.3 结构化输出接线**:用 `AsyncInstructor.create_with_completion(messages=..., response_model=..., model=..., max_tokens=...)` 同时拿 `(parsed, raw_completion)`——`raw.usage` 用于记账,避免结构化路径丢 usage;usage 提取统一走 `_usage_from(raw_usage)`(文本/结构化/流共用)。adapter 经可注入 `StructuredClient` Protocol 注 fake(测试不联网)。结构化路径 `ProviderResult.text = parsed.model_dump_json()`(日志/留痕),消费走 `parsed`。**带 `output_schema` 时 `gateway.run(req).parsed` 必非 None**。
|
||
- [2026-06-18] @backend **frozen View 的运行时不可变断言因类型分两种**:dataclass frozen(`ChapterView`)→赋值抛 `FrozenInstanceError` 且 **mypy 会静态报错**(测试里故意赋值需 `# type: ignore[misc]`);Pydantic frozen(`DigestView`/`ReviewView`)→抛 `ValidationError` 但 **mypy 不静态校验**(**勿加** `# type: ignore`,否则被判 unused-ignore,ruff/mypy 红)。
|
||
- [2026-06-18] @backend **M2 验收-side repos 只 flush 不 commit**:`promote_to_accepted`/`digest.append`/`review.record`/`set_decisions` 均只 `flush()`,交由 T2.4 验收事务单次 `commit()`(对齐「写库副作用在事务/编排层」);唯独 draft `save_draft` 仍自 commit(M1 自动保存语义)。`(project_id,chapter_no,version)` 唯一性是 **DB 级**(T0.2 models 定义),纯 fake 单测不断言它(不引 pg 依赖以免 pytest 门禁需起库)→ 由 T2.7 E2E 真实 DB 覆盖。
|
||
- [2026-06-18] @backend/@llm **网关 ledger 只 flush、调用方必须 commit**(不变量「写库副作用在编排层不在网关」的代价):`SqlAlchemyLedgerSink.record` 只 `flush()` 不 `commit()`;`get_session` 退出时不提交 → 隐式回滚。draft SSE 端点曾因此把 `usage_ledger` 行丢掉(T1.9 暴露)。修复:端点在 SSE 流**耗尽后** `await session.commit()`(FastAPI 缓存 `Depends(get_session)`,网关 ledger 与端点同一 session)。**M2 起凡用网关产 usage 的路径(四审/accept)都要确保所在事务最终提交**,否则记账静默丢失。
|
||
- [2026-06-18] @frontend **Next 里消费 SSE 用 `fetch`+`ReadableStream` reader,不用 `EventSource`**(EventSource 不能 POST、不能干净 abort)。"停"=`AbortController.abort()`,吞掉 `AbortError`、已收 token 留在 state 并被自动保存。流前错误(无凭据→503 `LLM_UNAVAILABLE` 是 **JSON 信封非帧**)经 `!res.ok` 检出、从 `{error:{code,message}}` 解析。
|
||
- [2026-06-18] @frontend apps/web 测试环境坑:`server-only` 包未装→别 import(Server Component 仅靠约定);vitest 是 **2.x**(无 `toHaveBeenCalledExactlyOnceWith`,用 `toHaveBeenCalledTimes`+`toHaveBeenCalledWith`);**未装 jsdom/testing-library**→单测走 node env 测纯逻辑(SSE reducer/帧缓冲、debounce、向导状态机),组件 DOM 渲染留给 T1.9 Playwright。
|
||
- [2026-06-18] @backend **stub user 未 seed → FK 风险**:`projects.owner_id` / `usage_ledger.owner_id` / `provider_credentials.owner_id` 全 FK→`users.id`,但仓库无 seeded stub user。约定 `STUB_OWNER_ID = uuid.UUID(int=1)`(对齐网关 `Scope.user_id` stub)。任何写这些表的路径(写章记账、立项、存凭据)跑前必须存在该 user 行——T1.4 幂等 seed(startup/lifespan);auth 落地后替换为真实 principal。
|
||
- [2026-06-18] @backend 含 nullable 列的唯一约束别用 PG `ON CONFLICT`:`provider_credentials(owner_id,project_id,provider)` / `tier_routing(project_id,tier)` 的 `project_id` 可空,PG 默认 NULLS DISTINCT → 全局行(`project_id=NULL`)的 `ON CONFLICT` 不去重、会插重复。T1.7 用显式 read-modify-write(`project_id IS NULL`)。若 @db 后续给约束加 `NULLS NOT DISTINCT` 可改回原生 upsert。
|
||
- [2026-06-18] @llm langgraph **1.2.5** 实装(pyproject 写 `>=0.2.40` 但装了 1.x,用 1.x API):`from langgraph.checkpoint.postgres.aio import AsyncPostgresSaver` → `AsyncPostgresSaver.from_conn_string(...)`(async ctx) → `await saver.setup()`(只在 migrations/CI)。mypy strict 下:`add_node` 不收 `functools.partial`(用 `async def` 闭包绑定依赖);`StateGraph[...]`/`CompiledStateGraph[...]`/`BaseCheckpointSaver[Any]` 需写全类型参;测试 state dict 标注 `: ChapterState`、`ainvoke` config 标注 `: RunnableConfig`。
|
||
- [2026-06-18] @orchestrator **跨包测试同名碰撞**:每包 `tests/` 无 __init__(避免与顶层 `tests` 包撞),但多包并存时 ① pytest 全跑:同名顶层模块 `fakes.py` 撞("import file mismatch")→ 测试替身用**全局唯一**名(`fakes_gateway`/`fakes_providers`/`fakes_orchestrator`);② 聚合 `mypy packages apps`:多个 rootless `conftest.py` 撞成同名模块 → root pyproject `[tool.mypy] exclude=["(^|/)conftest\\.py$"]`(conftest 仅 fixtures,按包仍受检)。test_*.py 保持全局唯一名。
|
||
- [2026-06-18] @backend Fernet 凭据 key 取 `settings.credential_enc_key`(env `CREDENTIAL_ENC_KEY`);`get_settings` 是 lru_cache,测试改 env 后须 `get_settings.cache_clear()`(在 client fixture 里)。list/GET 不解密(只回脱敏占位),仅 probe 按需解密——缩小明文暴露面。
|
||
- [2026-06-18] @orchestrator 后台 fork 子代理会因 "stream idle timeout" 早夭(T1.1 网关 fork 跑了 8.5 分钟、0 产出、0 文件)。坑:别盲等/盲重启重型 fork。做法:fork 完成后先核对**产物文件 + 自跑门禁**再认其结果;早夭则编排者内联实现该任务(已有完整上下文),不再二次 fork 同一关键路径任务。
|
||
- [2026-06-18] @backend 记忆选择走**确定性子串名匹配**(在 flatten+sorted 的 beats/facts 文本上 + 显式 entities 列表),非 pg_trgm/向量;§3.4 的 pg_trgm/作者 pin 兜底属后续。`selection` 与 `render_cards` 均按 `(kind, name)` 排序→输出与 repo 返回顺序无关;CJK 名按 **codepoint** 排(乙 U+4E59 < 甲 U+7532),测试断言按 codepoint 而非甲乙丙语义。`latest_state` 只进 `volatile`(卡片),`stable_core` 故意不含它以保缓存前缀字节稳定。
|
||
- [2026-06-18] @qa/@llm 包内单测放 `packages/<pkg>/tests/`(**无 __init__.py**,避免与顶层 `tests` 包同名冲突);测试替身放独立 `fakes.py` 用绝对导入 `from fakes import ...`(不能 `from .conftest import`,相对导入在无包目录下报 no known parent package);`conftest.py` 只放 fixtures。门禁按包跑:`uv run {ruff check|mypy|pytest} packages/<pkg>`。
|
||
- [2026-06-18] @frontend pnpm 11 配置已迁出 package.json/.npmrc → 只读 `apps/web/pnpm-workspace.yaml`。坑:`pnpm run <script>` 前会跑 verifyDepsBeforeRun 触发隐式 install,遇 `ERR_PNPM_IGNORED_BUILDS`(esbuild/sharp/unrs-resolver 被默认拦截)直接整条命令失败。做法:在 `pnpm-workspace.yaml` 写 `onlyBuiltDependencies:` 白名单 + `verifyDepsBeforeRun: false`。
|
||
- [2026-06-18] @frontend gen:api 离线管线:`scripts/gen-api.mjs` 用 `execFileSync(uv, [...])` 取后端 OpenAPI(不靠运行中的服务) + 直接调 `node_modules/.bin/openapi-typescript`(别用 `pnpm exec`,会再触发 deps 检查)。改后端 schema 后跑 `pnpm gen:api` 重生成 `lib/api/schema.d.ts`。
|
||
- [2026-06-18] @backend async engine 跨事件循环坑:`get_sessionmaker` 用 `lru_cache`,engine 绑定首个 loop;pytest-asyncio 每测试新 loop → 复用会报 `Connection._cancel never awaited`/连接失败。测试里每个 DB 测试 `get_sessionmaker.cache_clear()` 在当前 loop 重建并 `dispose()`。
|
||
- [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 列),无迁移。
|