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:
Yaojia Wang
2026-06-18 11:38:28 +02:00
parent b523b4fd21
commit 68f194a043
36 changed files with 3881 additions and 0 deletions

98
memory/contracts.md Normal file
View File

@@ -0,0 +1,98 @@
# 契约登记(解耦缝)
> 跨 agent 的契约——多 agent 靠它解耦并行。**契约先行**:定义方先在此登记并标 `稳定`,依赖方才动工;改契约必须在此记一笔 + 在 `PROGRESS.md` 通知依赖任务(前端要重生成 TS 客户端、依赖模块要重同步)。
> 状态:`待定义` → `草拟(@skill)` → `稳定` → `已变更(见日志)`。
---
## C1 · LLM 网关接口 owner @llm 状态: 稳定T1.1, 2026-06-18
- 来源:`ARCHITECTURE.md §4.1``LlmRequest` / `LlmResponse` / `Block` / `Usage`**snake_case**)。
- 消费方:编排器、所有 Agent`Gateway.run` / `Gateway.stream`)。
- 关键不变量agent 只传 `tier`writer/analyst/light不传具体 model。
- **已实现(`packages/llm_gateway/ww_llm_gateway`**
- 类型 `types.py``Block(text,cache=False)``Scope(user_id,project_id?)``LlmRequest(tier,input:str|list[Block],system:list[Block],stream,output_schema?,thinking,max_tokens?,scope)``Usage(provider,model,input_tokens,output_tokens,cache_read_tokens,cost_minor,currency)``ServedBy(provider,model,fell_back)``LlmResponse(text,parsed?,usage,served_by)``Delta(text)``Tier`
- `Gateway(adapters:dict[str,ProviderAdapter], ledger:LedgerSink, resolver=resolve_route)``async run(req)->LlmResponse``stream(req)->AsyncIterator[Delta]`。每次调用落 **1 条** `usage_ledger`(经 `LedgerSink`,可注入内存替身)。
- 适配器 `ProviderAdapter`(Protocol)`provider``capabilities()->Capabilities``async complete(req,model)->ProviderResult``stream(req,model)->AsyncIterator[StreamChunk]`。M1 实现 `OpenAICompatAdapter(provider, client:AsyncOpenAI)`DeepSeek注入 client 便于测试)。
- 档位路由 `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
## C2 · DB schemaSQLAlchemy 模型 owner @db 状态: 待定义
- 来源:`ARCHITECTURE.md §3.1` DDL11 创作表 + chapter_reviews + 运营表无向量列users stub
- 消费方:`@backend`(Repository/记忆服务/验收)、`@llm`(Agent reads/writes)。
## 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` 重生成客户端。**
- **已落T1.7, 2026-06-18snake_case响应仅脱敏 key**
- `GET /settings/providers``ProvidersResponse{ providers:[ProviderView{provider, masked_key}], tier_routing:[TierRoutingView{tier, provider, model, fallback:list[str]}] }`
- `PUT /settings/providers``ProvidersUpsertRequest{ credentials:[ProviderCredentialInput{provider, api_key}], tier_routing:[TierRoutingInput{tier, provider, model, fallback}] }``ProvidersResponse`(脱敏)
- `POST /settings/providers/test``TestConnectionRequest{provider}``TestConnectionResponse{provider, ok, capabilities:CapabilitiesView{structured_output, prefix_cache, thinking}}`
- **明文 key 永不过边界**probe 经可注入 `ProviderProbe`(测试用 fake不联网
- **已落T1.4, 2026-06-18snake_case**
- `POST /projects``ProjectCreateRequest{title, genre?, logline?, premise?, theme?, selling_points:list, structure?}` → 201 `ProjectResponse{id, title, genre?, logline?, premise?, theme?, selling_points, structure?}`
- `GET /projects``ProjectListResponse{projects:[ProjectResponse]}``GET /projects/{id}``ProjectResponse`404 `NOT_FOUND` 信封)
- `POST /projects/{id}/chapters/{no}/draft`**SSE** `text/event-stream`,帧 `event:<token|done|error>\ndata:<json>\n\n``token{text}`/`done{length}`/`error{code,message,request_id}`);无凭据 → 流前 `LLM_UNAVAILABLE`(503) JSON 信封(非帧)。
- `PUT /projects/{id}/chapters/{no}/draft``DraftSaveRequest{text}` → 200 `DraftResponse{project_id, chapter_no, volume, status, version, length}`幂等version 固定 1、status='draft')。
- 网关注入缝 `get_writer_gateway`(从凭据构 `OpenAICompatAdapter`+`SqlAlchemyLedgerSink`);测试经 `app.dependency_overrides` 注 mock 网关(只需 `.stream(req)`)。
- **@frontend 行动**M1 端点已全在 OpenAPI → Wave D 前先 `cd apps/web && pnpm gen:api` 重生成 `lib/api/schema.d.ts`
- **已落T2.4+T2.5, 2026-06-18snake_case全部挂 `projects.router`、已在 OpenAPI— M2 审/裁/验收三端点**
- `POST /projects/{id}/chapters/{no}/review``ReviewRequest{draft?}`(空/缺→回退已存草稿无草稿→404 `NOT_FOUND`)→ **SSE** `text/event-stream`,帧:`section{name,status:"done"|"incomplete"}`(每审一条M2 仅 `continuity`) / `conflict{type,where,refs:list,suggestion}`(每冲突一条,形=C6 `Conflict` 五类) / `done{length=审项数}` / `error{code,message,request_id}`。无凭据→流前 `LLM_UNAVAILABLE`(503 JSON 信封)。续审网关 tier=analyst`get_review_gateway`)。**端点流耗尽后 `session.commit()`**(网关 ledger + collect 均只 flush
- `GET /projects/{id}/chapters/{no}/reviews``ReviewHistoryResponse{reviews:[ReviewHistoryItem{id,project_id,chapter_no,chapter_version?,conflicts:list,foreshadow_sug:list,style?,pace?,health_score?,decisions?}]}`(新→旧)。
- `POST /projects/{id}/chapters/{no}/accept``AcceptRequest{final_text(min1), decisions:[ConflictDecision{conflict_index:int>=0, verdict:"accept"|"ignore"|"manual", note?}]}``AcceptResponse{project_id,chapter_no,accepted_version:int,digest_added:bool,decisions_recorded:int,review_id?:uuid}`。**冲突 gate**:裁决的 `conflict_index` 集合须覆盖 `range(len(最近一条 review.conflicts))`缺判→409 `CONFLICT_UNRESOLVED` + `details.missing_conflict_indices`/`conflict_count`无留痕或零冲突→直通。digest 提炼 tier=light`get_digest_gateway`在事务外R2单事务 promote(R4)+digest.append(#4)+set_decisions 末尾一次 commitR3
- 注入缝:`build_gateway_for_tier(session, store, tier)`(原 `build_writer_gateway` 退化为 writer 特例) + `get_review_gateway`/`get_digest_gateway`/`get_review_repo`/`get_digest_append_repo`;测试经 `app.dependency_overrides` 注 mock。
- **@frontend 行动**`cd apps/web && pnpm gen:api` 重生成客户端含此三端点T2.6)。
## C4 · 编排器接口LangGraph 写章图 owner @llm 状态: 稳定M1/T1.3, 2026-06-18M2 扩四审/验收)
- 来源:`ARCHITECTURE.md §5.2`(图)/ §7.3SSE。**M1 仅单 `write` 节点**;并行四审/collect/interrupt(accept) 属 M2。
- 位置:`packages/core/ww_core/orchestrator/`
- 图状态 `ChapterState`(TypedDict, snake_case)`{project_id:UUID, chapter_no:int, user_id:UUID, stable_core:str, volatile:str, draft:str}`——仅控制流+组装上下文+累积草稿(不变量#5resume 从领域表重读。
- 节点缝:
- `build_write_request(*, stable_core, volatile, user_id, project_id) -> LlmRequest`(纯函数;`tier="writer"``system=[Block(stable_core,cache=True)]``input=volatile``stream=True``scope=Scope(user_id,project_id)`)。
- `async stream_chapter_draft(gateway, *, stable_core, volatile, user_id, project_id) -> AsyncIterator[Delta]`——**T1.4 拿来喂 `normalize_deltas` 的底层流缝**。
- `async write_node(state, *, gateway) -> {"draft":str}`——可直接单测(注入 mock 网关)。`GatewayStream` Protocol = 节点对网关最小依赖(只需 `.stream(req)`)。
- **SSE 归一缝T1.4 消费)**`async normalize_deltas(deltas, *, request_id=None) -> AsyncIterator[SseEvent]``SseEvent{event:str, data:dict}`;事件 `token{text}`/`done{length}`/`error{code,message,request_id}`ARCH §7.3 子集M2 加 section/conflict。底层异常→发 `error` 事件后收尾(不上抛);`AppError.code` 透传,未知→`INTERNAL`。HTTP event-stream 编码归 T1.4。
- 图工厂:`build_write_graph(gateway, *, checkpointer=None) -> CompiledStateGraph`START→write→END单测传 `MemorySaver`
- checkpointer setup 入口:`async setup_checkpointer(conn_string) -> None`——**只在 migrations/CI 调**(内部 `AsyncPostgresSaver.from_conn_string` 跑一次 `setup()` DDL懒 import绝不在 app-runtime 跑)。
- 不变量agent 只传 tierDB 唯一 agent 间通道;不可变更新;瞬时重试在网关不在节点。
### C4 扩展T2.2, 2026-06-18· 审稿子图 + review SSE
- 图工厂 `build_review_graph(gateway, review_repo, *, review_specs=(continuity_spec,), checkpointer=None) -> CompiledStateGraph``START →`各 review spec 并行节点`→ collect → END`。M2 默认仅 continuity`review_specs` 可扩M3/M4 加 foreshadow/style/pace。**`build_write_graph` 保留不动**draft 端点仍用它)。
- `ChapterState`(state.py) 新增 `review_context:str``reviews:Annotated[dict, merge_reviews]`(并行分支浅合并 reducer返回新 dictTypedDict 改 `total=False`(按节点逐步填充);仍仅控制流+组装上下文+产物句柄(不变量#5)。
- 节点缝:`run_review(spec, state, *, gateway)->{"reviews":{spec.name:{status,result}}}`(裸函数可单测);`make_review_node(spec, gateway)`(绑 gateway 的公共缝,供 T2.5 跑单审);审稿用 `gateway.run()`(非 stream**只读不写库**(不变量#3);任一审网关失败被隔离为 `{status:"incomplete",result:None}`§5.2),不上抛、不毁图。`GatewayRun` Protocol=节点对网关最小依赖(`.run(req)->LlmResponse`)。
- collect 缝:`collect_reviews(state, *, review_repo)->{}`:抽 continuity 冲突 → `review_repo.record(project_id, chapter_no, chapter_version=None, conflicts=[...])``chapter_reviews` 留痕;**只 flush 不 commit**(提交归端点/T2.4 事务)。`ReviewRecorder` Protocol 形对齐 `domain.review_repo.ReviewRepo.record`
- SSE 新增事件:`section{name,status}`status ∈ started/done/incomplete`conflict{type,where,refs,suggestion}`(对齐 C6 `Conflict`);新归一缝 `normalize_review(reviews, *, request_id=None)->AsyncIterator[SseEvent]`(每审一条 section + 每冲突一条 conflict + `done{length=审项数}`;异常→`error` 不上抛,同 `normalize_deltas` 纪律。HTTP event-stream 编码归 T2.5。
- **accept 不在本图**`interrupt_before=["accept"]`/accept 节点属 T2.4确定性事务代码从领域表重读R3/不变量#5)。
- 消费方行动T2.5 跑 review 子图 + `normalize_review` + 端点**流耗尽后 `await session.commit()`**(网关 ledger + collect 均只 flush不提交则记账/留痕静默丢失,同 M1 draft 坑);`GET .../reviews``review_repo.list_for_chapter`(新→旧)。T2.4 从 `review_repo.list_for_chapter` 读 collect 落的行(`decisions=None`)裁决。
## 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
- `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 个 ProtocolOutline/Character/WorldEntity/Digest/Foreshadow/Style/Rules单测注入内存 fake运行时 `sql_memory_repos(AsyncSession)`**T1.4 注入点**)。
- 不变量:确定性选择(无向量 #6);统一 project_id 过滤(§3.5)latest_state 严格归 volatile(#9)。
- 消费方:**T1.3 write 节点** → 构造 `LlmRequest(system=[Block(stable_core, cache=True)], input=volatile)`
## C6 · Agent 声明AgentSpec+ 续审 I/O schema owner @llm 状态: 稳定T2.1, 2026-06-18
- 来源:`ARCHITECTURE.md §5.1 / §5.4 / §6.1`。位置:`packages/agents/ww_agents/``specs.py` / `schemas.py`,经 `ww_agents` 导出)。
- **`AgentSpec`**frozen Pydantic不可变`name:str``tier:Tier`(writer/analyst/light复用 `ww_llm_gateway.types.Tier`——只声明档位不写 model不变量#2)、`system_prompt:str``input_schema:type[BaseModel]|None``output_schema:type[BaseModel]|None`(writer 为 None=纯文本)、`reads:list[str]``writes:list[str]``genre:str|None=None``scope:str="builtin"`
- **`continuity_spec`**`tier="analyst"``reads=["chapter_digests","characters","world_entities"]``writes=[]`(只读,不变量#3)、`input_schema=None`(注入材料为序列化文本)、`output_schema=ContinuityReview`
- **`ContinuityReview{ conflicts: list[Conflict] }`**仅冲突digest 不在审稿期产,不变量#4)。
- **`Conflict{ type: ConflictType, where:str, refs:list[str]=[], suggestion:str }`**`ConflictType = Literal["性格漂移","能力不符","设定违例","地理矛盾","时间线倒错"]`ARCH §6.1 五类)。
- 续审节点调法:`req = LlmRequest(tier="analyst", system=[Block(system_prompt, cache=True)], input=审稿上下文文本, output_schema=ContinuityReview, scope=...)``resp = await gateway.run(req)``resp.parsed``ContinuityReview` 实例(带 schema 时必非 None。续审用 `run()``stream()`;只读+产冲突,不写库(写在 T2.4 验收事务)。
- 消费方:编排器(T2.2 续审/collect 节点)、技能运行时(M5)、前端审稿页(经 C3)。foreshadow/style/pace 三审 spec 待 M3/M4 补入本契约。
## C7 · 前端 ↔ 后端类型契约 owner @backend(产出 OpenAPI) / @frontend(生成) 状态: 待定义
- 机制FastAPI OpenAPI → `apps/web/lib/api` TS 类型openapi-typescript/orval
- 规则:后端 schema 任何变更 → 跑 `gen:api` 重生成;不手写共享类型。
---
## 契约变更日志append-only
> 格式:`- [date] @skill 改 Cx<改了什么> → 影响 <依赖方/任务>`
- [2026-06-18] @llm 改 C1`Gateway.run()` 现消费 `LlmRequest.output_schema`——`OpenAICompatAdapter.complete` 在 schema 非空时经 **instructor**(`create_with_completion(response_model=...)`) 取已校验 Pydantic 实例并填 `LlmResponse.parsed`;无 schema 时 `parsed is None`、纯文本路径不变;记账仍 **1 条 usage_ledger**/调用usage 从 raw completion 提取)。`ProviderResult` 新增 `parsed` 字段。结构化路径经可注入 `StructuredClient` Protocol 注 fake测试不联网。→ 影响 T2.2(续审节点可直接 `gateway.run(req).parsed`)、未来所有结构化输出 Agent。

49
memory/decisions.md Normal file
View File

@@ -0,0 +1,49 @@
# 实现决策记录append-only
> 实现期做出、而规格未覆盖的决策 + 理由。一条一决策,最新在最上。
> 与规格冲突的不要写这里——去改规格(见 CLAUDE.md「Conventions」。重大架构选择见 `ARCHITECTURE.md §1.2 ADR`。
格式:
```
## [YYYY-MM-DD] <决策标题> — @skill
- 背景:为什么要决策
- 选择:选了什么
- 理由 / 取舍:
- 影响:动到哪些契约/模块/任务
```
---
## [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`
- 理由 / 取舍:保留既有「注入 client 便于测试」风格,结构化路径同样可注 fake。`ProviderResult.text` 对结构化路径置为 `parsed.model_dump_json()`(便于日志/留痕),程序消费走 `parsed`
- 影响:契约 C1`run()``parsed`;见 contracts 变更日志)、`ProviderResult``parsed` 字段T2.2 续审节点据此消费。
## [2026-06-18] 审稿页终稿来源:页内可编辑 textarea无 GET draft 端点)— @frontend (T2.6)
- 背景:审稿/验收都需「终稿」文本,但当前无 `GET .../draft` 端点拉取已存草稿。
- 选择:审稿页用可折叠 textarea 编辑终稿(`initialDraft=''`),重审传它为 `{draft}`(空→后端回退已存草稿),验收 `final_text`=它。
- 理由 / 取舍:对齐不变量#4(摘要从作者裁决/改稿后的终稿提炼作者可在审稿页改稿。代价刷新页不自动回填草稿正文M2 可接受,后续可加 GET draft
- 影响仅前端T2.7 E2E 用 `#final-text` 注入终稿。
## [2026-06-18] 验收事务边界digest 提炼在事务外、单事务三写一次提交 — @backend (T2.4)
- 背景§5.5 要求验收「单事务」含「从终稿提炼 digestLLM 调用)」,但不能在持开 DB 事务里跨网络调 LLM占锁
- 选择digest 提炼 `extract_digest_facts`(tier=light`ChapterDigestFacts` schema经网关 `run().parsed`) 在 `run_accept_transaction` **之前**调用R2结果作 `digest_facts:dict` 传入;事务内只三次纯 DB 写(`promote_to_accepted``digest.append``set_decisions`+ 末尾一次 `session.commit()`,任一步失败整体回滚。
- 理由 / 取舍兼顾「digest 从终稿(不变量#4)」与「事务不跨网络」;提炼那次网关调用的 usage 随事务提交落 1 条 usage_ledger。digest 用 light、续审用 analyst 档位,统一经新 `build_gateway_for_tier(session, store, tier)``build_writer_gateway` 退化为 writer 特例。§5.5 步骤 3人物 latest_state/伏笔更新)留 `TODO(M3)` 占位,不引入 M3 表逻辑。
- 影响:契约 C3accept/review/reviews 三端点T2.6 消费 `AcceptResponse`/`CONFLICT_UNRESOLVED`T2.7 E2E 验证事务原子性+digest 来源+ledger。
## [2026-06-18] 验收-side 写侧 Repository 命名与提交边界 — @backend (T2.3)
- 背景:`chapter_digests` 已有读侧 repo`memory/``DigestRepo.recent`,供 assembleM2 需写侧 append。同名易混。
- 选择:写侧命名 `DigestAppendRepo`/`SqlDigestAppendRepo`,落 `domain/digest_repo.py`**共用 `DigestView`**;读侧仍在 `memory/`。新增 `review_repo.py`(`record`/`list_for_chapter` 新→旧/`set_decisions`) 与 `chapter_repo``max_version`/`promote_to_accepted`/`latest_accepted`
- 理由 / 取舍:读写分离避免同名歧义;写侧三 repo 的写方法**只 `flush()``commit()`**,提交归 T2.4 验收事务单次完成(对齐「写库副作用在编排/事务层」不变量);`save_draft` 仍自 commitM1 自动保存语义)。
- 影响T2.4 在单事务里组合 `promote_to_accepted``digest.append``review.set_decisions``commit()`R2/R3/R4/R5 决策落在这些接口点。
- 背景ARCH §5.3 伪码里 `assemble` 直接返回 `LlmRequest`,会让 `packages/core/memory` 反向依赖 `packages/llm_gateway` 的类型,且使 M1 的 T1.2 强依赖 T1.1(无法并行)。
- 选择:`assemble(project_id, chapter_no) -> AssembledContext(stable_core: str, volatile: str, selection: SelectionTrace)`。稳定内核/易变各为**已确定性排序、无时间戳/UUID 的字符串**write 节点(T1.3)再据此构造 `LlmRequest(system=[Block(text=stable_core, cache=True)], input=volatile)``SelectionTrace` 记录每个实体的入选理由,供 UX 注入透明面板(T1.6)。
- 理由 / 取舍:记忆服务只经 DB 通信、不应知道网关请求类型(对齐不变量①②);解耦后 T1.1‖T1.2 可并行。§5.3 伪码视为示意,真正的缝是「确定性选择 → 序列化的 stable/volatile 文本」。
- 影响:契约 C5 输出形 = `AssembledContext`(非 `LlmRequest`C1 的 `Block`/`LlmRequest` 仅 gateway+orchestrator 使用。回写 ARCH §5.3 待 M1 落地后由 @docs 注一句。
## [2026-06-18] CLAUDE.md 不复制 spec 的枚举值,只重述跨文档规则 — @all
- 背景CLAUDE.md 原先内联了 SSE 事件名、LLM 调用日志字段、错误码/envelope 形状等"会变的具体值"ARCHITECTURE 一改即静默过期,使 CLAUDE.md 自身成为最大漂移源。
- 选择CLAUDE.md 只保留跨文档、易错的**规则**(含 9 条架构不变量);所有枚举型/具体值改为指向 ARCHITECTURE/PRODUCT_SPEC 对应 §(唯一真源)。新增"冲突裁决"段ARCHITECTURE > UX_SPEC/DEV_PLAN > PRODUCT_SPEC矛盾时回写上游。
- 理由 / 取舍:消除文档间漂移面、降低每 session 上下文成本;代价是查具体值需多跳一层到 ARCHITECTURE可接受
- 影响:编辑 CLAUDE.md 的任何 agent 遵循此纪律——契约/枚举值落在 spec 与 `memory/contracts.md`,不在 CLAUDE.md。

38
memory/gotchas.md Normal file
View 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 长度变化判(同数不同组会漏重置),也不能在 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 节点里直接写库