diff --git a/packages/core/tests/test_memory.py b/packages/core/tests/test_memory.py index 54d072a..6ef304b 100644 --- a/packages/core/tests/test_memory.py +++ b/packages/core/tests/test_memory.py @@ -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 ---- diff --git a/packages/core/ww_core/domain/repositories.py b/packages/core/ww_core/domain/repositories.py index cd88f8a..2423bbd 100644 --- a/packages/core/ww_core/domain/repositories.py +++ b/packages/core/ww_core/domain/repositories.py @@ -15,6 +15,8 @@ from typing import Any, Protocol from pydantic import BaseModel, Field +from ww_core.domain.review_repo import ReviewRepo + # ---- 只读视图(snake_case,frozen 防止意外突变)---- @@ -148,7 +150,11 @@ class ProjectSpecRepo(Protocol): @dataclass(frozen=True) class MemoryRepos: - """记忆服务所需的 8 个 repo 的依赖捆绑(注入点)。""" + """记忆服务所需的 9 个 repo 的依赖捆绑(注入点)。 + + `review` 末位默认 None:只有冲突裁决反哺(灵感②)需要它;不注入时 assemble + 优雅降级(不反哺),故既有构造点无需感知(守风险#6)。 + """ outline: OutlineRepo character: CharacterRepo @@ -158,3 +164,4 @@ class MemoryRepos: style: StyleRepo rules: RulesRepo project: ProjectSpecRepo + review: ReviewRepo | None = None diff --git a/packages/core/ww_core/memory/assemble.py b/packages/core/ww_core/memory/assemble.py index 7d36073..52400c3 100644 --- a/packages/core/ww_core/memory/assemble.py +++ b/packages/core/ww_core/memory/assemble.py @@ -25,6 +25,7 @@ from ww_core.domain.repositories import ( StyleView, WorldEntityView, ) +from ww_core.domain.review_repo import ReviewView from .render import render_cards 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 追加开篇提示(灵感①)。 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: 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/#7:assemble 可缓存的纯函数)。 + - 软批评类(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( chapter_no: int, cards: str, @@ -144,6 +203,7 @@ def _build_volatile( digests: list[DigestView], beats: dict[str, Any], directive: str | None = None, + prior_conflict_notes: str | None = None, ) -> str: fore_lines = [ f"【伏笔】{f.code} {f.title} [{f.status}]" @@ -159,6 +219,8 @@ def _build_volatile( _section("写作指令", f"请创作第 {chapter_no} 章的正文。"), # 开篇黄金三章标记(仅前 N 章)——每章易变,只入 volatile。 _opening_marker(chapter_no), + # 上一章冲突裁决反哺(灵感②)——依赖上一章裁决,每章易变,绝不进缓存前缀(#9)。 + _section("上一章冲突提醒", prior_conflict_notes) if prior_conflict_notes else None, _section("本章注入卡片", cards), _section("相关伏笔窗口", "\n".join(fore_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] + # 冲突裁决反哺(灵感②/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) cards = render_cards(selection, characters, world_entities) 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) diff --git a/packages/core/ww_core/memory/sql_repositories.py b/packages/core/ww_core/memory/sql_repositories.py index 6ce69b9..a216803 100644 --- a/packages/core/ww_core/memory/sql_repositories.py +++ b/packages/core/ww_core/memory/sql_repositories.py @@ -32,6 +32,7 @@ from ww_core.domain.repositories import ( StyleView, WorldEntityView, ) +from ww_core.domain.review_repo import SqlReviewRepo class SqlOutlineRepo: @@ -223,7 +224,10 @@ class SqlProjectSpecRepo: def sql_memory_repos(session: AsyncSession) -> MemoryRepos: - """用一个 AsyncSession 装配全部 8 个 SQLAlchemy repo(T1.4 注入点)。""" + """用一个 AsyncSession 装配全部 9 个 SQLAlchemy repo(T1.4 注入点)。 + + `review=SqlReviewRepo`:供 assemble 反读上一已验收章的 continuity 冲突裁决(灵感②)。 + """ return MemoryRepos( outline=SqlOutlineRepo(session), character=SqlCharacterRepo(session), @@ -233,4 +237,5 @@ def sql_memory_repos(session: AsyncSession) -> MemoryRepos: style=SqlStyleRepo(session), rules=SqlRulesRepo(session), project=SqlProjectSpecRepo(session), + review=SqlReviewRepo(session), )