feat(foreshadow): 写章上下文注入到期/逾期伏笔 + AI 顺势回收指令

- assemble 富渲染伏笔行:补『期望回收 X-Y章』+『(已逾期!)』标记(仍在 volatile 缓存段,守不变量 #9)
- 新增 foreshadow_inject 纯函数:除大纲显式窗口外,按当前章自动纳入
  『回收窗口内 / 已逾期且未 CLOSED』的伏笔(确定性、无向量,守不变量 #6)
- write_craft.md 增克制指令『到点顺势收,别为收而收』;foreshadow.md 描述对齐新注入格式
- MemoryRepos 加可选 foreshadow_ledger(照 review 先例,None 优雅降级)
- 金标准仅 foreshadow spec hash 变更;后端全绿 pytest 1032 passed
This commit is contained in:
Yaojia Wang
2026-07-12 18:13:14 +02:00
parent b810a3fa3c
commit b46afd4f8c
8 changed files with 440 additions and 12 deletions

View File

@@ -9,6 +9,7 @@ import uuid
from dataclasses import dataclass, field
from typing import Any
from ww_core.domain.foreshadow_repo import ForeshadowLedgerRepo, ForeshadowLedgerView
from ww_core.domain.repositories import (
CharacterView,
DigestView,
@@ -30,6 +31,12 @@ from ww_core.memory import (
render_cards,
select_relevant_entities,
)
from ww_core.memory.foreshadow_inject import (
ForeshadowLine,
merge_foreshadow_lines,
render_foreshadow_line,
select_auto_lines,
)
PROJECT = uuid.UUID("00000000-0000-0000-0000-000000000001")
@@ -130,6 +137,44 @@ class FakeReviewRepo:
raise NotImplementedError
@dataclass
class FakeForeshadowLedgerRepo:
"""内存伏笔账本 fake按章自动挑选——只需 `list_by_status` 供 assemble 全量反读。
其余写方法只为满足 `ForeshadowLedgerRepo` Protocol 结构类型assemble 读路径不用。
"""
rows: list[ForeshadowLedgerView] = field(default_factory=list)
async def list_by_status(
self, project_id: uuid.UUID, status: str | None = None
) -> list[ForeshadowLedgerView]:
return [r for r in self.rows if status is None or r.status == status]
async def register(self, *args: Any, **kwargs: Any) -> ForeshadowLedgerView: # pragma: no cover
raise NotImplementedError
async def get(
self, *args: Any, **kwargs: Any
) -> ForeshadowLedgerView | None: # pragma: no cover
raise NotImplementedError
async def transition(
self, *args: Any, **kwargs: Any
) -> ForeshadowLedgerView: # pragma: no cover
raise NotImplementedError
async def record_progress(
self, *args: Any, **kwargs: Any
) -> ForeshadowLedgerView: # pragma: no cover
raise NotImplementedError
async def scan_overdue(
self, *args: Any, **kwargs: Any
) -> list[ForeshadowLedgerView]: # pragma: no cover
raise NotImplementedError
def build_repos(
*,
outline: dict[int, OutlineView] | None = None,
@@ -141,6 +186,7 @@ def build_repos(
rules: list[RuleView] | None = None,
spec: ProjectSpecView | None = None,
review: ReviewRepo | None = None,
foreshadow_ledger: ForeshadowLedgerRepo | None = None,
) -> MemoryRepos:
return MemoryRepos(
outline=FakeOutlineRepo(outline or {}),
@@ -152,6 +198,7 @@ def build_repos(
rules=FakeRulesRepo(rules or []),
project=FakeProjectSpecRepo(spec),
review=review,
foreshadow_ledger=foreshadow_ledger,
)
@@ -659,6 +706,238 @@ async def test_no_prior_conflict_notes_for_first_chapter() -> None:
assert "上一章冲突提醒" not in ctx.volatile
# ---- 伏笔注入丰富化 + 按章自动挑选(本任务) ----
def _line(
code: str,
status: str = "OPEN",
*,
from_: int | None = None,
to: int | None = None,
title: str = "某伏笔",
) -> ForeshadowLine:
return ForeshadowLine(
code=code,
title=title,
status=status,
expected_close_from=from_,
expected_close_to=to,
)
def _ledger(
code: str,
status: str = "OPEN",
*,
from_: int | None = None,
to: int | None = None,
title: str = "某伏笔",
) -> ForeshadowLedgerView:
return ForeshadowLedgerView(
code=code,
title=title,
status=status,
expected_close_from=from_,
expected_close_to=to,
)
# --- 纯函数:渲染格式(窗口 + 逾期标记 + 优雅降级) ---
def test_render_line_with_window() -> None:
line = _line("F1", "OPEN", from_=5, to=8, title="神秘石符")
assert render_foreshadow_line(line, 5) == "【伏笔】F1 神秘石符 [OPEN] 期望回收 5-8章"
def test_render_line_overdue_marker_by_chapter() -> None:
# 第 6 章已越过期望回收上界 4 → 追加逾期标记。
line = _line("F2", "OPEN", from_=3, to=4, title="断剑")
assert render_foreshadow_line(line, 6) == "【伏笔】F2 断剑 [OPEN] 期望回收 3-4章已逾期"
def test_render_line_overdue_marker_by_status() -> None:
# status==OVERDUE 直接标逾期,不论当前章号是否越界。
line = _line("F3", "OVERDUE", from_=3, to=8, title="旧账")
assert render_foreshadow_line(line, 5) == "【伏笔】F3 旧账 [OVERDUE] 期望回收 3-8章已逾期"
def test_render_line_window_missing_gracefully_omitted() -> None:
line = _line("F4", "OPEN", title="无窗伏笔")
out = render_foreshadow_line(line, 5)
assert out == "【伏笔】F4 无窗伏笔 [OPEN]"
assert "期望回收" not in out
def test_render_line_partial_window_omitted() -> None:
# 只有上界、无下界 → 窗口段优雅省略(仍可按上界判逾期)。
line = _line("F5", "OPEN", to=4, title="半窗")
out = render_foreshadow_line(line, 6)
assert "期望回收" not in out
assert "(已逾期!)" in out # 第 6 章 > 上界 4 仍算逾期
# --- 纯函数:按章自动挑选(确定性、无向量、无随机) ---
def test_select_auto_includes_in_window() -> None:
assert [line.code for line in select_auto_lines([_line("A", "OPEN", from_=5, to=8)], 6)] == [
"A"
]
def test_select_auto_includes_boundary_chapters() -> None:
lines = [_line("A", "OPEN", from_=5, to=8)]
assert select_auto_lines(lines, 5) # 下界
assert select_auto_lines(lines, 8) # 上界
def test_select_auto_includes_overdue() -> None:
# 第 10 章远超上界 3 → 逾期且未 CLOSED纳入。
assert [
line.code for line in select_auto_lines([_line("B", "PARTIAL", from_=1, to=3)], 10)
] == ["B"]
def test_select_auto_excludes_closed_in_window() -> None:
assert select_auto_lines([_line("C", "CLOSED", from_=5, to=8)], 6) == []
def test_select_auto_excludes_future_window_not_overdue() -> None:
assert select_auto_lines([_line("D", "OPEN", from_=20, to=30)], 6) == []
def test_select_auto_excludes_windowless_open() -> None:
# 无回收窗口、未逾期 → 不自动纳入(既非窗口内也非逾期)。
assert select_auto_lines([_line("E", "OPEN")], 6) == []
def test_render_unknown_status_defensively_not_overdue() -> None:
# 未知 status非四态枚举→ 保守视为未逾期,不加逾期标记、不崩。
line = _line("F6", "WEIRD", from_=1, to=2, title="怪态")
out = render_foreshadow_line(line, 99)
assert "(已逾期!)" not in out
assert out == "【伏笔】F6 怪态 [WEIRD] 期望回收 1-2章"
def test_merge_dedups_by_code_and_sorts() -> None:
outline = [_line("B", "OPEN", from_=1, to=2)]
auto = [_line("A", "OPEN", from_=5, to=6), _line("B", "OPEN", from_=1, to=2)]
merged = merge_foreshadow_lines(outline, auto)
assert [line.code for line in merged] == ["A", "B"] # 去重 + 按 code 升序
# --- assemble 集成:丰富注入 + 自动挑选 + volatile 边界 ---
async def test_assemble_enriches_outline_foreshadow_window() -> None:
repos = build_repos(
outline={5: OutlineView(volume=1, chapter_no=5, foreshadow_windows=[{"code": "F1"}])},
foreshadows=[
ForeshadowView(
code="F1",
title="神秘石符",
status="OPEN",
expected_close_from=5,
expected_close_to=8,
)
],
spec=ProjectSpecView(title=""),
)
ctx = await assemble(repos, PROJECT, 5)
assert "【伏笔】F1 神秘石符 [OPEN] 期望回收 5-8章" in ctx.volatile
# 不变量 #9伏笔注入含窗口只在 volatile绝不进缓存前缀。
assert "期望回收" not in ctx.stable_core
assert "F1" not in ctx.stable_core
async def test_assemble_auto_injects_in_window_beyond_outline() -> None:
# 大纲未登记 F9但账本里 F9 的回收窗口覆盖本章 → 自动纳入(修「大纲没登记=零注入」)。
ledger = FakeForeshadowLedgerRepo(rows=[_ledger("F9", "OPEN", from_=4, to=7, title="旧誓")])
repos = build_repos(
outline={5: OutlineView(volume=1, chapter_no=5)}, # 无 foreshadow_windows
spec=ProjectSpecView(title=""),
foreshadow_ledger=ledger,
)
ctx = await assemble(repos, PROJECT, 5)
assert "【伏笔】F9 旧誓 [OPEN] 期望回收 4-7章" in ctx.volatile
async def test_assemble_auto_injects_overdue_beyond_outline() -> None:
ledger = FakeForeshadowLedgerRepo(rows=[_ledger("F8", "PARTIAL", from_=1, to=3, title="欠债")])
repos = build_repos(
outline={10: OutlineView(volume=1, chapter_no=10)},
spec=ProjectSpecView(title=""),
foreshadow_ledger=ledger,
)
ctx = await assemble(repos, PROJECT, 10)
assert "【伏笔】F8 欠债 [PARTIAL]" in ctx.volatile
assert "(已逾期!)" in ctx.volatile
async def test_assemble_auto_skips_closed_and_future() -> None:
ledger = FakeForeshadowLedgerRepo(
rows=[
_ledger("CLD", "CLOSED", from_=4, to=7, title="已收"),
_ledger("FUT", "OPEN", from_=20, to=30, title="未来"),
]
)
repos = build_repos(
outline={5: OutlineView(volume=1, chapter_no=5)},
spec=ProjectSpecView(title=""),
foreshadow_ledger=ledger,
)
ctx = await assemble(repos, PROJECT, 5)
assert "CLD" not in ctx.volatile
assert "FUT" not in ctx.volatile
async def test_assemble_merges_outline_and_auto_dedup() -> None:
# F1 同时被大纲窗口显式登记 + 账本回收窗口自动命中 → 合并去重,只出现一次。
ledger = FakeForeshadowLedgerRepo(rows=[_ledger("F1", "OPEN", from_=5, to=8, title="石符")])
repos = build_repos(
outline={5: OutlineView(volume=1, chapter_no=5, foreshadow_windows=[{"code": "F1"}])},
foreshadows=[
ForeshadowView(
code="F1",
title="石符",
status="OPEN",
expected_close_from=5,
expected_close_to=8,
)
],
spec=ProjectSpecView(title=""),
foreshadow_ledger=ledger,
)
ctx = await assemble(repos, PROJECT, 5)
assert ctx.volatile.count("【伏笔】F1") == 1
async def test_assemble_without_ledger_only_outline_foreshadows() -> None:
# 无账本注入(默认)→ 仅大纲登记的伏笔,优雅降级不崩。
repos = build_repos(
outline={5: OutlineView(volume=1, chapter_no=5, foreshadow_windows=[{"code": "F1"}])},
foreshadows=[ForeshadowView(code="F1", title="石符", status="OPEN")],
spec=ProjectSpecView(title=""),
)
ctx = await assemble(repos, PROJECT, 5)
assert "【伏笔】F1 石符 [OPEN]" in ctx.volatile
async def test_assemble_auto_foreshadow_never_enters_stable_core() -> None:
# 不变量 #9按章自动挑选的伏笔每章易变只入 volatile绝不进缓存前缀。
ledger = FakeForeshadowLedgerRepo(rows=[_ledger("F9", "OPEN", from_=4, to=7, title="旧誓")])
repos = build_repos(
outline={5: OutlineView(volume=1, chapter_no=5)},
spec=ProjectSpecView(title="", genre="玄幻"),
foreshadow_ledger=ledger,
)
ctx = await assemble(repos, PROJECT, 5)
assert "F9" not in ctx.stable_core
assert "旧誓" not in ctx.stable_core
# ---- helpers ----