From d028c790c9ac93b22538955b7895f5c5a6c90fff Mon Sep 17 00:00:00 2001 From: Yaojia Wang Date: Mon, 22 Jun 2026 18:28:04 +0200 Subject: [PATCH] =?UTF-8?q?feat(agents,api):=20=E4=B8=80=E8=87=B4=E6=80=A7?= =?UTF-8?q?=E5=86=B2=E7=AA=81=E4=B8=80=E9=94=AE=E9=87=87=E7=BA=B3=E8=A1=A5?= =?UTF-8?q?=E4=B8=81=E5=AF=B9=20original/replacement=20+=20SSE/=E5=A5=91?= =?UTF-8?q?=E7=BA=A6=E9=80=8F=E4=BC=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Conflict schema 增 original/replacement(可缺):可局部修复时审稿提议精确补丁 - 续审 prompt 指示填逐字 original + 改后 replacement,不可定位则留空 - orchestrator SSE conflict 事件 + apps/api ReviewConflictView 透传两字段 - 重生成 TS 客户端 schema.d.ts;memory/contracts.md 记录契约变更(向后兼容) Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/api/ww_api/schemas/projects.py | 3 +++ apps/web/lib/api/schema.d.ts | 4 ++++ memory/contracts.md | 1 + packages/agents/tests/test_specs.py | 25 +++++++++++++++++++++++ packages/agents/ww_agents/schemas.py | 11 ++++++++++ packages/agents/ww_agents/specs.py | 15 ++++++++++++-- packages/core/tests/test_review.py | 5 +++++ packages/core/ww_core/orchestrator/sse.py | 19 +++++++++++++++-- 8 files changed, 79 insertions(+), 4 deletions(-) diff --git a/apps/api/ww_api/schemas/projects.py b/apps/api/ww_api/schemas/projects.py index 85c55f0..da92ec1 100644 --- a/apps/api/ww_api/schemas/projects.py +++ b/apps/api/ww_api/schemas/projects.py @@ -107,6 +107,9 @@ class ReviewConflictView(BaseModel): where: str = "" refs: list[str] = Field(default_factory=list) suggestion: str = "" + # 一键采纳补丁对(可缺):留痕里带则前端「采纳改法」可把 original→replacement 改入终稿。 + original: str | None = None + replacement: str | None = None class ReviewHistoryItem(BaseModel): diff --git a/apps/web/lib/api/schema.d.ts b/apps/web/lib/api/schema.d.ts index 842791f..91090cb 100644 --- a/apps/web/lib/api/schema.d.ts +++ b/apps/web/lib/api/schema.d.ts @@ -1353,6 +1353,10 @@ export interface components { * @default */ suggestion: string; + /** Original */ + original?: string | null; + /** Replacement */ + replacement?: string | null; }; /** * ReviewHistoryItem diff --git a/memory/contracts.md b/memory/contracts.md index 32ff3af..46c5788 100644 --- a/memory/contracts.md +++ b/memory/contracts.md @@ -221,6 +221,7 @@ - 节点缝:`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。 +- 一键采纳改法(方案1):`conflict` SSE 事件 + `ReviewConflictView`(留痕)+ C6 `Conflict` schema 新增可选 `original?:str|null`/`replacement?:str|null`(一键采纳补丁对,最小句段;审稿可局部修复时提议,前端「采纳改法」把 original→replacement find-replace 进终稿,找不到则不动正文提示手改)。**向后兼容**:缺省 null;审稿仍只读(只提议不改稿,不变量#3)。 - **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`)裁决。 diff --git a/packages/agents/tests/test_specs.py b/packages/agents/tests/test_specs.py index ddfbe37..86ce03d 100644 --- a/packages/agents/tests/test_specs.py +++ b/packages/agents/tests/test_specs.py @@ -61,6 +61,31 @@ def test_conflict_refs_default_empty() -> None: assert conflict.refs == [] +def test_conflict_patch_fields_default_none() -> None: + # 一键采纳补丁字段(original/replacement)可缺:不可定位的冲突两者留空。 + conflict = Conflict.model_validate( + {"type": "设定违例", "where": "战斗场景", "suggestion": "删去违例能力"} + ) + assert conflict.original is None + assert conflict.replacement is None + + +def test_conflict_carries_patch_pair_when_localizable() -> None: + # 可局部修复:original 逐字原文最小句段 + replacement 改后文本,供前端一键 find-replace。 + conflict = Conflict.model_validate( + { + "type": "设定违例", + "where": "第1段", + "refs": [], + "suggestion": "迈巴赫改为奔驰", + "original": "迈巴赫扬长而去的尾灯", + "replacement": "奔驰扬长而去的尾灯", + } + ) + assert conflict.original == "迈巴赫扬长而去的尾灯" + assert conflict.replacement == "奔驰扬长而去的尾灯" + + def test_continuity_spec_is_analyst_tier() -> None: assert continuity_spec.tier == "analyst" assert continuity_spec.name == "continuity" diff --git a/packages/agents/ww_agents/schemas.py b/packages/agents/ww_agents/schemas.py index cd34681..8ece3f7 100644 --- a/packages/agents/ww_agents/schemas.py +++ b/packages/agents/ww_agents/schemas.py @@ -32,6 +32,17 @@ class Conflict(BaseModel): description="冲突来源引用:相关章节号、设定项或人物卡条目", ) suggestion: str = Field(description="改法建议") + # 一键采纳补丁对(可缺):可由局部改写修复时给出,供前端把 original→replacement + # find-replace 进终稿(作者点「采纳改法」触发,仍只读——审稿只提议、不改稿)。 + # 不可定位到单一连续片段(跨段/缺设定等)则两者留空,回退到作者手改。 + original: str | None = Field( + default=None, + description="可一键采纳时:从本章草稿**逐字摘录**的精确原文最小句段(含标点,不得改写);不可定位则留空", + ) + replacement: str | None = Field( + default=None, + description="可一键采纳时:对应 original 的改后文本(同粒度最小句段);不可定位则留空", + ) class ContinuityReview(BaseModel): diff --git a/packages/agents/ww_agents/specs.py b/packages/agents/ww_agents/specs.py index 253d28a..839bc01 100644 --- a/packages/agents/ww_agents/specs.py +++ b/packages/agents/ww_agents/specs.py @@ -57,10 +57,21 @@ CONTINUITY_SYSTEM_PROMPT = """你是长篇连载小说的「一致性续审」 - 地理矛盾:地点/距离/空间关系与既有设定冲突; - 时间线倒错:事件先后、时序与既有章节矛盾。 +一键采纳补丁(original/replacement,可选但尽量给): +- 若该冲突可由**一处局部改写**修复,额外给出 original 与 replacement,\ +供作者「采纳改法」一键改入终稿: + - original:从本章草稿里**逐字摘录**的精确原文片段\ +(含标点,**最小**到能定位的句/短语;不得改写、省略或加省略号); + - replacement:把 original 改正后的文本(同等粒度,仅改必要处)。 +- 无法定位到单一连续片段(如跨多段、需补全新设定、整体视角问题)\ +则 original/replacement 都留空,仅给 suggestion 让作者手改。 + 纪律: -- 你**只读、只报冲突**,不改稿、不写库、不产章节摘要(摘要在验收时从终稿另提)。 +- 你**只读、只报冲突**,不改稿、不写库、不产章节摘要(摘要在验收时从终稿另提); + original/replacement 只是**提议**的补丁,是否改入终稿由作者裁决,你不直接改稿。 - 只报有据可依的真冲突;无冲突则返回空列表,不要臆造。 -- 引用要具体(章节号、设定项、人物卡条目),便于作者就地裁决。""" +- 引用要具体(章节号、设定项、人物卡条目),便于作者就地裁决。 +- original 必须是草稿里真实存在的原文(逐字一致),否则前端无法定位——拿不准就留空。""" continuity_spec = AgentSpec( diff --git a/packages/core/tests/test_review.py b/packages/core/tests/test_review.py index 391ffa5..7198c8d 100644 --- a/packages/core/tests/test_review.py +++ b/packages/core/tests/test_review.py @@ -68,6 +68,8 @@ _CONFLICT = Conflict( where="第3段「一拳轰碎山岳」", refs=["人物卡:主角:力量上限=裂石"], suggestion="改为震裂巨石", + original="他一拳轰碎山岳。", + replacement="他一拳震裂巨石。", ) @@ -238,6 +240,9 @@ async def test_normalize_review_emits_section_conflict_done() -> None: assert events[0].data == {"name": CONTINUITY, "status": SECTION_DONE} assert events[1].data["type"] == "能力不符" assert events[1].data["refs"] == ["人物卡:主角:力量上限=裂石"] + # 一键采纳补丁对透传到 conflict 事件(前端 find-replace 用)。 + assert events[1].data["original"] == "他一拳轰碎山岳。" + assert events[1].data["replacement"] == "他一拳震裂巨石。" assert events[-1].data == {"length": 1} diff --git a/packages/core/ww_core/orchestrator/sse.py b/packages/core/ww_core/orchestrator/sse.py index 85ee444..32a2c1b 100644 --- a/packages/core/ww_core/orchestrator/sse.py +++ b/packages/core/ww_core/orchestrator/sse.py @@ -68,11 +68,24 @@ def conflict_event( where: str, refs: list[str], suggestion: str, + original: str | None = None, + replacement: str | None = None, ) -> SseEvent: - """冲突命中事件(§7.3 `conflict`)——形对齐 C6 `Conflict`,供正文就地朱砂标注。""" + """冲突命中事件(§7.3 `conflict`)——形对齐 C6 `Conflict`,供正文就地朱砂标注。 + + `original`/`replacement` 为可选一键采纳补丁对:可局部修复时由审稿提议, + 前端「采纳改法」据此把 original→replacement find-replace 进终稿(不可定位则为 None)。 + """ return SseEvent( event=EVENT_CONFLICT, - data={"type": type, "where": where, "refs": refs, "suggestion": suggestion}, + data={ + "type": type, + "where": where, + "refs": refs, + "suggestion": suggestion, + "original": original, + "replacement": replacement, + }, ) @@ -180,6 +193,8 @@ def _section_result_events(name: str, result: dict[str, Any]) -> list[SseEvent]: where=conflict.get("where", ""), refs=list(conflict.get("refs") or []), suggestion=conflict.get("suggestion", ""), + original=conflict.get("original"), + replacement=conflict.get("replacement"), ) ) elif name == FORESHADOW: