Files
writer-work-flow/memory/gotchas.md
Yaojia Wang 68f194a043 feat: M2 — 写→审(一致性)→裁决→验收(事务);未决冲突禁验收
- 续审 Agent 声明(AgentSpec) + 结构化输出契约(ContinuityReview/Conflict 五类)
- LangGraph 并行审子图(可扩四审) + collect 落 chapter_reviews 留痕 + review SSE(section/conflict)
- 验收-side Repository:章节 accepted 版本晋升 + digest append-only + 审稿留痕/裁决
- API:review(SSE) + reviews 历史 + accept(单原子事务:晋升 version + 终稿 digest + 裁决留痕)
- 冲突 gate:未决裁决拦截(CONFLICT_UNRESOLVED);digest 从终稿提炼(不变量#4)
- 前端:审稿报告页 + 冲突就地标注 + 裁决(采纳/忽略/手改) + 未决禁验收 + 「本次将更新」清单
- M2 E2E:真实 DB + 多档位 mock 网关零 token 走通 写→审→裁决→验收→摘要入库
- 多 agent 协同台账(PROGRESS.md) + 共享记忆(memory/contracts·decisions·gotchas)
2026-06-18 11:38:28 +02:00

39 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 踩坑与约定append-only
> 实现中发现的坑、易错点、约定俗成——让兄弟 agent 不重复踩。一条一项,最新在最上。
> 只记**非显而易见**的;规格/CLAUDE.md 已写的别重复。
格式:`- [date] @skill <坑/约定> — 缘由 + 怎么做`
---
- [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 长度变化判(同数不同组会漏重置),也不能在 seedphase=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` 用于记账,避免结构化路径丢 usageusage 提取统一走 `_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-ignoreruff/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` 仍自 commitM1 自动保存语义)。`(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` 包未装→别 importServer 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 幂等 seedstartup/lifespanauth 落地后替换为真实 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 绑定首个 looppytest-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 节点里直接写库