feat(agents,api): 一致性冲突一键采纳补丁对 original/replacement + SSE/契约透传

- 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) <noreply@anthropic.com>
This commit is contained in:
Yaojia Wang
2026-06-22 18:28:04 +02:00
parent 2d77977d8a
commit d028c790c9
8 changed files with 79 additions and 4 deletions

View File

@@ -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"

View File

@@ -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):

View File

@@ -57,10 +57,21 @@ CONTINUITY_SYSTEM_PROMPT = """你是长篇连载小说的「一致性续审」
- 地理矛盾:地点/距离/空间关系与既有设定冲突;
- 时间线倒错:事件先后、时序与既有章节矛盾。
一键采纳补丁original/replacement可选但尽量给
- 若该冲突可由**一处局部改写**修复,额外给出 original 与 replacement\
供作者「采纳改法」一键改入终稿:
- original从本章草稿里**逐字摘录**的精确原文片段\
(含标点,**最小**到能定位的句/短语;不得改写、省略或加省略号);
- replacement把 original 改正后的文本(同等粒度,仅改必要处)。
- 无法定位到单一连续片段(如跨多段、需补全新设定、整体视角问题)\
则 original/replacement 都留空,仅给 suggestion 让作者手改。
纪律:
- 你**只读、只报冲突**,不改稿、不写库、不产章节摘要(摘要在验收时从终稿另提)
- 你**只读、只报冲突**,不改稿、不写库、不产章节摘要(摘要在验收时从终稿另提)
original/replacement 只是**提议**的补丁,是否改入终稿由作者裁决,你不直接改稿。
- 只报有据可依的真冲突;无冲突则返回空列表,不要臆造。
- 引用要具体(章节号、设定项、人物卡条目),便于作者就地裁决。"""
- 引用要具体(章节号、设定项、人物卡条目),便于作者就地裁决。
- original 必须是草稿里真实存在的原文(逐字一致),否则前端无法定位——拿不准就留空。"""
continuity_spec = AgentSpec(

View File

@@ -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}

View File

@@ -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: