- 续审 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)
99 lines
16 KiB
Markdown
99 lines
16 KiB
Markdown
# 契约登记(解耦缝)
|
||
|
||
> 跨 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 schema(SQLAlchemy 模型) owner @db 状态: 待定义
|
||
- 来源:`ARCHITECTURE.md §3.1` DDL(11 创作表 + 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-18,snake_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-18,snake_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-18,snake_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 末尾一次 commit(R3)。
|
||
- 注入缝:`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-18;M2 扩四审/验收)
|
||
- 来源:`ARCHITECTURE.md §5.2`(图)/ §7.3(SSE)。**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}`——仅控制流+组装上下文+累积草稿(不变量#5);resume 从领域表重读。
|
||
- 节点缝:
|
||
- `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 只传 tier;DB 唯一 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,返回新 dict);TypedDict 改 `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 个 Protocol(Outline/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。
|