feat(core): continuity 冲突裁决反哺——上一已验收章保留裁决入本章 volatile 前瞻约束

灵感②/D3 窄版 v1:assemble 读上一已验收章的 continuity 冲突作者裁决
(decisions.items 按 conflict_index join 回 conflicts 取正文),仅保留
verdict∈{accept,manual}、去重、按 conflict_index 排序、有界 top-3,
用确定性字符串模板改写成前瞻正向约束,注入本章 volatile「上一章冲突提醒」。

- 绝不调 LLM、绝不反哺 advisory(style/pace/characterization 无作者裁决 oracle)——守不变量 #3/#4。
- 只入 volatile、永不进 stable_core 缓存前缀(依赖上一章裁决、每章易变)——守 #9。
- assemble 仍是确定性纯函数、字节稳定、可缓存——守 #6/#7。
- decisions 非空=已验收(只在验收事务写入);未验收上一章不反哺——守风险#5。
- MemoryRepos 末位加 review: ReviewRepo | None = None(默认 None→优雅降级不反哺),
  docstring 8→9;sql_memory_repos 工厂补 review=SqlReviewRepo(既有构造点靠默认无需感知)——守风险#6。
This commit is contained in:
Yaojia Wang
2026-07-06 16:38:06 +02:00
parent 02d19019f6
commit 2a7a865e98
4 changed files with 278 additions and 3 deletions

View File

@@ -7,6 +7,7 @@ from __future__ import annotations
import uuid import uuid
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Any
from ww_core.domain.repositories import ( from ww_core.domain.repositories import (
CharacterView, CharacterView,
@@ -19,6 +20,7 @@ from ww_core.domain.repositories import (
StyleView, StyleView,
WorldEntityView, WorldEntityView,
) )
from ww_core.domain.review_repo import ReviewRepo, ReviewView
from ww_core.memory import ( from ww_core.memory import (
AssembledContext, AssembledContext,
EntityKind, EntityKind,
@@ -103,6 +105,28 @@ class FakeProjectSpecRepo:
return self.row return self.row
@dataclass
class FakeReviewRepo:
"""内存审稿留痕 fake灵感②冲突裁决反哺——只需 list_for_chapter 供 assemble 反读。
`queried` 记录被查询的章号(验证读的是上一章 chapter_no-1record/set_decisions
只为满足 ReviewRepo Protocol 结构类型assemble 反哺路径不用。
"""
by_chapter: dict[int, list[ReviewView]] = field(default_factory=dict)
queried: list[int] = field(default_factory=list)
async def list_for_chapter(self, project_id: uuid.UUID, chapter_no: int) -> list[ReviewView]:
self.queried.append(chapter_no)
return list(self.by_chapter.get(chapter_no, []))
async def record(self, *args: Any, **kwargs: Any) -> ReviewView: # pragma: no cover
raise NotImplementedError
async def set_decisions(self, *args: Any, **kwargs: Any) -> ReviewView: # pragma: no cover
raise NotImplementedError
def build_repos( def build_repos(
*, *,
outline: dict[int, OutlineView] | None = None, outline: dict[int, OutlineView] | None = None,
@@ -113,6 +137,7 @@ def build_repos(
style: StyleView | None = None, style: StyleView | None = None,
rules: list[RuleView] | None = None, rules: list[RuleView] | None = None,
spec: ProjectSpecView | None = None, spec: ProjectSpecView | None = None,
review: ReviewRepo | None = None,
) -> MemoryRepos: ) -> MemoryRepos:
return MemoryRepos( return MemoryRepos(
outline=FakeOutlineRepo(outline or {}), outline=FakeOutlineRepo(outline or {}),
@@ -123,6 +148,7 @@ def build_repos(
style=FakeStyleRepo(style), style=FakeStyleRepo(style),
rules=FakeRulesRepo(rules or []), rules=FakeRulesRepo(rules or []),
project=FakeProjectSpecRepo(spec), project=FakeProjectSpecRepo(spec),
review=review,
) )
@@ -469,6 +495,167 @@ async def test_no_per_chapter_varying_field_in_cached_blocks() -> None:
assert "第 5 章" not in ch5.stable_core assert "第 5 章" not in ch5.stable_core
# ---- 灵感②/D3上一已验收章 continuity 冲突裁决反哺volatile确定性模板 ----
def _accepted_review(
*,
chapter_no: int,
conflicts: list[dict[str, Any]],
items: list[dict[str, Any]],
) -> ReviewView:
"""构造一行"已验收"审稿留痕decisions 非空(只在验收事务写入 → 视为已验收)。"""
return ReviewView(
id=uuid.UUID(int=chapter_no),
project_id=PROJECT,
chapter_no=chapter_no,
conflicts=conflicts,
decisions={"items": items, "conflict_count": len(conflicts)},
)
def _conflict(type_: str, suggestion: str) -> dict[str, Any]:
return {"type": type_, "where": "某段", "refs": [], "suggestion": suggestion}
def _review_repo_with(reviews: list[ReviewView]) -> FakeReviewRepo:
by_chapter: dict[int, list[ReviewView]] = {}
for r in reviews:
by_chapter.setdefault(r.chapter_no, []).append(r)
return FakeReviewRepo(by_chapter=by_chapter)
async def test_volatile_includes_prior_conflict_notes() -> None:
review = _accepted_review(
chapter_no=1,
conflicts=[_conflict("设定冲突", "主角左臂已断,后文不得再用左手持剑")],
items=[{"conflict_index": 0, "verdict": "accept", "note": None}],
)
repos = build_repos(
spec=ProjectSpecView(title=""),
review=_review_repo_with([review]),
)
ctx = await assemble(repos, PROJECT, 2)
assert "上一章冲突提醒" in ctx.volatile
assert "主角左臂已断,后文不得再用左手持剑" in ctx.volatile
async def test_prior_conflict_notes_never_enter_stable_core() -> None:
# 不变量#9反哺是每章易变输入依赖上一章裁决→ 只入 volatile绝不进缓存前缀。
review = _accepted_review(
chapter_no=1,
conflicts=[_conflict("设定冲突", "灵脉禁止跨界,后文守此规则")],
items=[{"conflict_index": 0, "verdict": "accept", "note": None}],
)
repos = build_repos(spec=ProjectSpecView(title=""), review=_review_repo_with([review]))
ctx = await assemble(repos, PROJECT, 2)
assert "上一章冲突提醒" not in ctx.stable_core
assert "灵脉禁止跨界,后文守此规则" not in ctx.stable_core
async def test_reads_previous_accepted_chapter() -> None:
# 写第 5 章 → 只应读第 4 章chapter_no-1的审稿留痕。
review = _accepted_review(
chapter_no=4,
conflicts=[_conflict("时间线倒错", "事件发生在雨夜,后文保持一致")],
items=[{"conflict_index": 0, "verdict": "manual", "note": "已统一为雨夜"}],
)
fake = _review_repo_with([review])
repos = build_repos(spec=ProjectSpecView(title=""), review=fake)
ctx = await assemble(repos, PROJECT, 5)
assert fake.queried == [4] # 只查上一章
assert "已统一为雨夜" in ctx.volatile
async def test_skips_unaccepted_previous_chapter() -> None:
# 上一章有审稿留痕但 decisions=None尚未验收→ 不反哺(守风险#5不读未定稿
unaccepted = ReviewView(
id=uuid.UUID(int=99),
project_id=PROJECT,
chapter_no=1,
conflicts=[_conflict("设定冲突", "不应注入的建议")],
decisions=None,
)
repos = build_repos(
spec=ProjectSpecView(title=""),
review=_review_repo_with([unaccepted]),
)
ctx = await assemble(repos, PROJECT, 2)
assert "上一章冲突提醒" not in ctx.volatile
assert "不应注入的建议" not in ctx.volatile
async def test_only_retained_conflict_decisions_injected() -> None:
# 只反哺保留项 verdict∈{accept,manual}ignore作者判为非冲突不反哺。
review = _accepted_review(
chapter_no=1,
conflicts=[
_conflict("设定冲突", "采纳项应注入"),
_conflict("时间线倒错", "手改项应注入"),
_conflict("地理矛盾", "忽略项不应注入"),
],
items=[
{"conflict_index": 0, "verdict": "accept", "note": None},
{"conflict_index": 1, "verdict": "manual", "note": None},
{"conflict_index": 2, "verdict": "ignore", "note": None},
],
)
repos = build_repos(spec=ProjectSpecView(title=""), review=_review_repo_with([review]))
ctx = await assemble(repos, PROJECT, 2)
assert "采纳项应注入" in ctx.volatile
assert "手改项应注入" in ctx.volatile
assert "忽略项不应注入" not in ctx.volatile
async def test_prior_conflict_notes_bounded_deduped_sorted_and_no_llm_call() -> None:
# 5 条保留裁决 + 1 条重复 → 去重后有界 top-3两次组装字节完全一致确定性、无 LLM
conflicts = [_conflict("设定冲突", f"约束{i}") for i in range(5)]
conflicts.append(_conflict("设定冲突", "约束0")) # 与 index 0 内容重复
items = [{"conflict_index": i, "verdict": "accept", "note": None} for i in range(6)]
review = _accepted_review(chapter_no=1, conflicts=conflicts, items=items)
repos = build_repos(spec=ProjectSpecView(title=""), review=_review_repo_with([review]))
ctx1 = await assemble(repos, PROJECT, 2)
ctx2 = await assemble(repos, PROJECT, 2)
# 字节稳定(无 LLM 的非确定性——assemble 无网关入参,反哺全程纯字符串模板。
assert ctx1.volatile == ctx2.volatile
section = ctx1.volatile.split("## 上一章冲突提醒\n", 1)[1].split("\n\n", 1)[0]
lines = [ln for ln in section.splitlines() if ln.startswith("- ")]
assert len(lines) == 3 # 有界 top-3
assert len(set(lines)) == len(lines) # 去重
assert "约束0" in section # 去重后仍保留一条约束0
async def test_no_apology_or_selfref_in_prior_conflict_notes() -> None:
review = _accepted_review(
chapter_no=1,
conflicts=[_conflict("设定冲突", "后文保持灵脉守恒")],
items=[{"conflict_index": 0, "verdict": "accept", "note": None}],
)
repos = build_repos(spec=ProjectSpecView(title=""), review=_review_repo_with([review]))
ctx = await assemble(repos, PROJECT, 2)
section = ctx.volatile.split("## 上一章冲突提醒\n", 1)[1].split("\n\n", 1)[0]
# 前瞻正向约束——绝无道歉/自指措辞(守 #3/#4不自评自改、不磨平文风
for banned in ("抱歉", "道歉", "对不起", "如前所述", "更正", "笔误"):
assert banned not in section
async def test_assemble_without_review_repo_gracefully_degrades() -> None:
# review=None默认→ 优雅降级:不反哺、不崩(守风险#6
repos = build_repos(spec=ProjectSpecView(title="")) # review 默认 None
ctx = await assemble(repos, PROJECT, 5)
assert "上一章冲突提醒" not in ctx.volatile
async def test_no_prior_conflict_notes_for_first_chapter() -> None:
# 第 1 章无上一章 → 不查 review repo、不反哺。
fake = FakeReviewRepo()
repos = build_repos(spec=ProjectSpecView(title=""), review=fake)
ctx = await assemble(repos, PROJECT, 1)
assert fake.queried == [] # 未查询(无上一章)
assert "上一章冲突提醒" not in ctx.volatile
# ---- helpers ---- # ---- helpers ----

View File

@@ -15,6 +15,8 @@ from typing import Any, Protocol
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from ww_core.domain.review_repo import ReviewRepo
# ---- 只读视图snake_casefrozen 防止意外突变)---- # ---- 只读视图snake_casefrozen 防止意外突变)----
@@ -148,7 +150,11 @@ class ProjectSpecRepo(Protocol):
@dataclass(frozen=True) @dataclass(frozen=True)
class MemoryRepos: class MemoryRepos:
"""记忆服务所需的 8 个 repo 的依赖捆绑(注入点)。""" """记忆服务所需的 9 个 repo 的依赖捆绑(注入点)。
`review` 末位默认 None只有冲突裁决反哺灵感②需要它不注入时 assemble
优雅降级(不反哺),故既有构造点无需感知(守风险#6
"""
outline: OutlineRepo outline: OutlineRepo
character: CharacterRepo character: CharacterRepo
@@ -158,3 +164,4 @@ class MemoryRepos:
style: StyleRepo style: StyleRepo
rules: RulesRepo rules: RulesRepo
project: ProjectSpecRepo project: ProjectSpecRepo
review: ReviewRepo | None = None

View File

@@ -25,6 +25,7 @@ from ww_core.domain.repositories import (
StyleView, StyleView,
WorldEntityView, WorldEntityView,
) )
from ww_core.domain.review_repo import ReviewView
from .render import render_cards from .render import render_cards
from .selection import MAIN_ROLES, RECENT_DIGEST_COUNT, EntityKey, select_relevant_entities from .selection import MAIN_ROLES, RECENT_DIGEST_COUNT, EntityKey, select_relevant_entities
@@ -36,6 +37,13 @@ _LEVEL_RANK: dict[str, int] = {"global": 0, "genre": 1, "style": 2, "project": 3
# 开篇黄金三章特判上限:前 N 章在 volatile 追加开篇提示(灵感①)。 # 开篇黄金三章特判上限:前 N 章在 volatile 追加开篇提示(灵感①)。
OPENING_CHAPTER_LIMIT = 3 OPENING_CHAPTER_LIMIT = 3
# 冲突裁决反哺(灵感②/D3只反哺"保留项"裁决——作者采纳(accept)或手改(manual)
# ignore作者判为非冲突不反哺。这三个控制值对齐 schemas.projects.Verdict。
_RETAINED_VERDICTS: frozenset[str] = frozenset({"accept", "manual"})
# 上一章冲突提醒有界上限top-N——控作者/模型每章注意力预算(灵感② 校验 M
PRIOR_CONFLICT_NOTE_LIMIT = 3
def _ser(value: Any) -> str: def _ser(value: Any) -> str:
return json.dumps(value, ensure_ascii=False, sort_keys=True) return json.dumps(value, ensure_ascii=False, sort_keys=True)
@@ -137,6 +145,57 @@ def _opening_marker(chapter_no: int) -> str | None:
) )
def _conflict_constraint(conflict: dict[str, Any], note: str | None) -> str | None:
"""把一条已裁决 continuity 冲突改写成本章的前瞻正向约束(确定性模板,绝不调 LLM
优先用作者手改备注(`note`,作者自己的话);否则用审稿改法建议(`suggestion`——
作者点「采纳」即背书之,故仍是外部 oracle 驱动,不违 #3/#4。只做正向「延续设定」
措辞,绝无道歉/自指(守 #3/#4不自评自改、不磨平文风。两者皆空则返回 None。
"""
detail = (note or "").strip() or str(conflict.get("suggestion") or "").strip()
if not detail:
return None
ctype = str(conflict.get("type") or "").strip()
prefix = f"{ctype}" if ctype else ""
return f"{prefix}延续上一章已确认的设定:{detail}"
def _prior_conflict_notes(
latest_review: ReviewView | None,
*,
limit: int = PRIOR_CONFLICT_NOTE_LIMIT,
) -> str | None:
"""上一已验收章的 continuity 冲突作者裁决保留项 → 本章前瞻约束(灵感②/D3
- 仅当上一章已验收(`decisions` 非空——只在验收事务写入)才反哺(守风险#5不读未定稿
- 按 `decisions["items"][].conflict_index` join 回 `conflicts[index]` 取正文,
过滤 `verdict∈{accept,manual}`(保留项)、去重、按 conflict_index 排序、有界 top-N。
- 确定性纯函数、字节稳定、无 LLM 调用(守 #6/#7assemble 可缓存的纯函数)。
- 软批评类style/pace/characterization无作者裁决 oracle → 永不经此反哺(守 #3/#4
"""
if latest_review is None or latest_review.decisions is None:
return None
items = latest_review.decisions.get("items") or []
conflicts = latest_review.conflicts
constraints: list[str] = []
seen: set[str] = set()
for item in sorted(items, key=lambda d: d.get("conflict_index", 0)):
if item.get("verdict") not in _RETAINED_VERDICTS:
continue
idx = item.get("conflict_index")
if not isinstance(idx, int) or not (0 <= idx < len(conflicts)):
continue
line = _conflict_constraint(conflicts[idx], item.get("note"))
if line is None or line in seen:
continue
seen.add(line)
constraints.append(line)
bounded = constraints[:limit]
if not bounded:
return None
return "\n".join(f"- {c}" for c in bounded)
def _build_volatile( def _build_volatile(
chapter_no: int, chapter_no: int,
cards: str, cards: str,
@@ -144,6 +203,7 @@ def _build_volatile(
digests: list[DigestView], digests: list[DigestView],
beats: dict[str, Any], beats: dict[str, Any],
directive: str | None = None, directive: str | None = None,
prior_conflict_notes: str | None = None,
) -> str: ) -> str:
fore_lines = [ fore_lines = [
f"【伏笔】{f.code} {f.title} [{f.status}]" f"【伏笔】{f.code} {f.title} [{f.status}]"
@@ -159,6 +219,8 @@ def _build_volatile(
_section("写作指令", f"请创作第 {chapter_no} 章的正文。"), _section("写作指令", f"请创作第 {chapter_no} 章的正文。"),
# 开篇黄金三章标记(仅前 N 章)——每章易变,只入 volatile。 # 开篇黄金三章标记(仅前 N 章)——每章易变,只入 volatile。
_opening_marker(chapter_no), _opening_marker(chapter_no),
# 上一章冲突裁决反哺(灵感②)——依赖上一章裁决,每章易变,绝不进缓存前缀(#9
_section("上一章冲突提醒", prior_conflict_notes) if prior_conflict_notes else None,
_section("本章注入卡片", cards), _section("本章注入卡片", cards),
_section("相关伏笔窗口", "\n".join(fore_lines)), _section("相关伏笔窗口", "\n".join(fore_lines)),
_section("近况摘要", "\n".join(digest_lines)), _section("近况摘要", "\n".join(digest_lines)),
@@ -217,10 +279,24 @@ async def assemble(
main_characters = [c for c in characters if (c.role or "") in MAIN_ROLES] main_characters = [c for c in characters if (c.role or "") in MAIN_ROLES]
# 冲突裁决反哺(灵感②/D3读上一已验收章的 continuity 冲突作者裁决保留项 →
# 本章 volatile 前瞻约束。review 未注入(默认 None或本章为第 1 章 → 优雅降级不反哺。
prior_conflict_notes: str | None = None
if repos.review is not None and chapter_no > 1:
prior_reviews = await repos.review.list_for_chapter(project_id, chapter_no - 1)
latest_review = prior_reviews[0] if prior_reviews else None # list_for_chapter 新→旧
prior_conflict_notes = _prior_conflict_notes(latest_review)
stable_core = _build_stable(spec, world_entities, main_characters, style, rules) stable_core = _build_stable(spec, world_entities, main_characters, style, rules)
cards = render_cards(selection, characters, world_entities) cards = render_cards(selection, characters, world_entities)
volatile = _build_volatile( volatile = _build_volatile(
chapter_no, cards, foreshadows, recent_digests, outline.beats, directive chapter_no,
cards,
foreshadows,
recent_digests,
outline.beats,
directive,
prior_conflict_notes,
) )
return AssembledContext(stable_core=stable_core, volatile=volatile, selection=selection) return AssembledContext(stable_core=stable_core, volatile=volatile, selection=selection)

View File

@@ -32,6 +32,7 @@ from ww_core.domain.repositories import (
StyleView, StyleView,
WorldEntityView, WorldEntityView,
) )
from ww_core.domain.review_repo import SqlReviewRepo
class SqlOutlineRepo: class SqlOutlineRepo:
@@ -223,7 +224,10 @@ class SqlProjectSpecRepo:
def sql_memory_repos(session: AsyncSession) -> MemoryRepos: def sql_memory_repos(session: AsyncSession) -> MemoryRepos:
"""用一个 AsyncSession 装配全部 8 个 SQLAlchemy repoT1.4 注入点)。""" """用一个 AsyncSession 装配全部 9 个 SQLAlchemy repoT1.4 注入点)。
`review=SqlReviewRepo`:供 assemble 反读上一已验收章的 continuity 冲突裁决(灵感②)。
"""
return MemoryRepos( return MemoryRepos(
outline=SqlOutlineRepo(session), outline=SqlOutlineRepo(session),
character=SqlCharacterRepo(session), character=SqlCharacterRepo(session),
@@ -233,4 +237,5 @@ def sql_memory_repos(session: AsyncSession) -> MemoryRepos:
style=SqlStyleRepo(session), style=SqlStyleRepo(session),
rules=SqlRulesRepo(session), rules=SqlRulesRepo(session),
project=SqlProjectSpecRepo(session), project=SqlProjectSpecRepo(session),
review=SqlReviewRepo(session),
) )