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:
@@ -7,6 +7,7 @@ from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
from ww_core.domain.repositories import (
|
||||
CharacterView,
|
||||
@@ -19,6 +20,7 @@ from ww_core.domain.repositories import (
|
||||
StyleView,
|
||||
WorldEntityView,
|
||||
)
|
||||
from ww_core.domain.review_repo import ReviewRepo, ReviewView
|
||||
from ww_core.memory import (
|
||||
AssembledContext,
|
||||
EntityKind,
|
||||
@@ -103,6 +105,28 @@ class FakeProjectSpecRepo:
|
||||
return self.row
|
||||
|
||||
|
||||
@dataclass
|
||||
class FakeReviewRepo:
|
||||
"""内存审稿留痕 fake(灵感②冲突裁决反哺)——只需 list_for_chapter 供 assemble 反读。
|
||||
|
||||
`queried` 记录被查询的章号(验证读的是上一章 chapter_no-1);record/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(
|
||||
*,
|
||||
outline: dict[int, OutlineView] | None = None,
|
||||
@@ -113,6 +137,7 @@ def build_repos(
|
||||
style: StyleView | None = None,
|
||||
rules: list[RuleView] | None = None,
|
||||
spec: ProjectSpecView | None = None,
|
||||
review: ReviewRepo | None = None,
|
||||
) -> MemoryRepos:
|
||||
return MemoryRepos(
|
||||
outline=FakeOutlineRepo(outline or {}),
|
||||
@@ -123,6 +148,7 @@ def build_repos(
|
||||
style=FakeStyleRepo(style),
|
||||
rules=FakeRulesRepo(rules or []),
|
||||
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
|
||||
|
||||
|
||||
# ---- 灵感②/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 ----
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user