Files
writer-work-flow/memory/contracts.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

99 lines
16 KiB
Markdown
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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.

# 契约登记(解耦缝)
> 跨 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。