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)
This commit is contained in:
38
memory/gotchas.md
Normal file
38
memory/gotchas.md
Normal file
@@ -0,0 +1,38 @@
|
||||
# 踩坑与约定(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 长度变化判(同数不同组会漏重置),也不能在 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 节点里直接写库。
|
||||
Reference in New Issue
Block a user