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

@@ -11,7 +11,7 @@
"de-ai": "9ce3020cdd4b223cc4d13c289babd2811bbfece4b392711dcba4fd0301c87249", "de-ai": "9ce3020cdd4b223cc4d13c289babd2811bbfece4b392711dcba4fd0301c87249",
"expand": "74a5d71c4ffc11b78bf80c7ab3fc3eebfc1192ffff2b0bfa9957ec2c33d9d05e", "expand": "74a5d71c4ffc11b78bf80c7ab3fc3eebfc1192ffff2b0bfa9957ec2c33d9d05e",
"fine-outline": "0ae0b71f4144e82ffa2a7a0059505525857f7d9797e94ced25da3129355a46ed", "fine-outline": "0ae0b71f4144e82ffa2a7a0059505525857f7d9797e94ced25da3129355a46ed",
"foreshadow": "405696461fef445e4609227c40df6c4812737735edea7e1966ee84afe7635645", "foreshadow": "b3f80c86d5f410ac4abe1fbfcacf0ad3c8ef547a0082cb0ea7a63b2edf5f6f24",
"glossary": "54e95d21d10b9209287c10517134f485ca5ce4a3ef08291aac954dcf492d73d0", "glossary": "54e95d21d10b9209287c10517134f485ca5ce4a3ef08291aac954dcf492d73d0",
"golden-finger": "416e8591d99ea1296db550bc428012aa937eed47eea03ef435e92c6843005e21", "golden-finger": "416e8591d99ea1296db550bc428012aa937eed47eea03ef435e92c6843005e21",
"name": "ee759bc967773e3300813f250f233c872fd3a4f0cb00086509ffc3552d67700b", "name": "ee759bc967773e3300813f250f233c872fd3a4f0cb00086509ffc3552d67700b",

View File

@@ -1,7 +1,7 @@
你是长篇连载小说的「伏笔续审」foreshadow-analyst——一位专盯长线叙事「埋设—回收」契约的资深审稿人。你的唯一职责把本章草稿与作品已登记伏笔逐项比对找出本章**新埋的伏笔**与**疑似回收/收束**的伏笔,产出结构化建议清单。你只读、只产建议,不改稿、不写库。 你是长篇连载小说的「伏笔续审」foreshadow-analyst——一位专盯长线叙事「埋设—回收」契约的资深审稿人。你的唯一职责把本章草稿与作品已登记伏笔逐项比对找出本章**新埋的伏笔**与**疑似回收/收束**的伏笔,产出结构化建议清单。你只读、只产建议,不改稿、不写库。
## 比对依据(注入材料) ## 比对依据(注入材料)
- 已登记伏笔foreshadow每条含编码code、标题、当前状态、期望回收窗口 - 已登记伏笔foreshadow每条含编码code、标题、当前状态;登记了回收窗口的还带「期望回收 X-Y章」逾期者标「已逾期」
- 本章草稿正文。 - 本章草稿正文。
材料未提供的内容一律视为「不存在」,不得凭世界观常识或前文记忆脑补。 材料未提供的内容一律视为「不存在」,不得凭世界观常识或前文记忆脑补。

View File

@@ -25,6 +25,11 @@
- 密点厚、疏点薄。**绝不为凑字数注水**:删掉重复的心理复述、冗余的形容词堆叠、无推进的对话与说明。 - 密点厚、疏点薄。**绝不为凑字数注水**:删掉重复的心理复述、冗余的形容词堆叠、无推进的对话与说明。
- 篇幅是结果而非目标——先想清楚这一章每个部分承担什么功能,再决定它值多少笔墨。 - 篇幅是结果而非目标——先想清楚这一章每个部分承担什么功能,再决定它值多少笔墨。
## 伏笔:到点顺势收,别为收而收
- 注入材料里的伏笔可能带「期望回收窗口」或「已逾期」标记。若本章正落在某条伏笔的回收窗口内、或它已被标记逾期,可**顺着当下的情节自然地推进或兑现它**——让它收在剧情本就该走到的地方。
- 回收要**服务当下的冲突与情感**:能自然收束就收;一时收得牵强,就先推进一步、留待更合适的时机,也好过硬来。**切忌为凑一个「回收」而打断节奏、突兀交代**——宁可这一章只把线往前带一带,也不要为收而收、硬凑。
## 文字质感 ## 文字质感
- **句长错落**:长短句交替,避免连续同长度、同结构的句子带来的机械节奏。 - **句长错落**:长短句交替,避免连续同长度、同结构的句子带来的机械节奏。

View File

@@ -9,6 +9,7 @@ import uuid
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Any from typing import Any
from ww_core.domain.foreshadow_repo import ForeshadowLedgerRepo, ForeshadowLedgerView
from ww_core.domain.repositories import ( from ww_core.domain.repositories import (
CharacterView, CharacterView,
DigestView, DigestView,
@@ -30,6 +31,12 @@ from ww_core.memory import (
render_cards, render_cards,
select_relevant_entities, 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") PROJECT = uuid.UUID("00000000-0000-0000-0000-000000000001")
@@ -130,6 +137,44 @@ class FakeReviewRepo:
raise NotImplementedError 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( def build_repos(
*, *,
outline: dict[int, OutlineView] | None = None, outline: dict[int, OutlineView] | None = None,
@@ -141,6 +186,7 @@ def build_repos(
rules: list[RuleView] | None = None, rules: list[RuleView] | None = None,
spec: ProjectSpecView | None = None, spec: ProjectSpecView | None = None,
review: ReviewRepo | None = None, review: ReviewRepo | None = None,
foreshadow_ledger: ForeshadowLedgerRepo | None = None,
) -> MemoryRepos: ) -> MemoryRepos:
return MemoryRepos( return MemoryRepos(
outline=FakeOutlineRepo(outline or {}), outline=FakeOutlineRepo(outline or {}),
@@ -152,6 +198,7 @@ def build_repos(
rules=FakeRulesRepo(rules or []), rules=FakeRulesRepo(rules or []),
project=FakeProjectSpecRepo(spec), project=FakeProjectSpecRepo(spec),
review=review, 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 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 ---- # ---- helpers ----

View File

@@ -15,6 +15,7 @@ from typing import Any, Protocol
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from ww_core.domain.foreshadow_repo import ForeshadowLedgerRepo
from ww_core.domain.review_repo import ReviewRepo from ww_core.domain.review_repo import ReviewRepo
# ---- 只读视图snake_casefrozen 防止意外突变)---- # ---- 只读视图snake_casefrozen 防止意外突变)----
@@ -156,8 +157,11 @@ class ProjectSpecRepo(Protocol):
class MemoryRepos: class MemoryRepos:
"""记忆服务所需的 9 个 repo 的依赖捆绑(注入点)。 """记忆服务所需的 9 个 repo 的依赖捆绑(注入点)。
`review` 末位默认 None只有冲突裁决反哺灵感②需要它不注入时 assemble `review`/`foreshadow_ledger` 末位默认 None同 optional 注入模式):
优雅降级(不反哺),故既有构造点无需感知(守风险#6 - `review`:只有冲突裁决反哺(灵感②)需要它;
- `foreshadow_ledger`:只有按章自动挑选伏笔(本任务)需要它——账本全量读侧,
供 assemble 在大纲窗口之外自动纳入本章回收窗口内/逾期的伏笔(复用写侧账本 repo
不注入时 assemble 优雅降级(对应能力关闭),故既有构造点无需感知(守风险#6
""" """
outline: OutlineRepo outline: OutlineRepo
@@ -169,3 +173,4 @@ class MemoryRepos:
rules: RulesRepo rules: RulesRepo
project: ProjectSpecRepo project: ProjectSpecRepo
review: ReviewRepo | None = None review: ReviewRepo | None = None
foreshadow_ledger: ForeshadowLedgerRepo | None = None

View File

@@ -17,7 +17,6 @@ from ww_core.domain.injection_repo import InjectionOverride
from ww_core.domain.repositories import ( from ww_core.domain.repositories import (
CharacterView, CharacterView,
DigestView, DigestView,
ForeshadowView,
MemoryRepos, MemoryRepos,
OutlineView, OutlineView,
ProjectSpecView, ProjectSpecView,
@@ -27,6 +26,13 @@ from ww_core.domain.repositories import (
) )
from ww_core.domain.review_repo import ReviewView from ww_core.domain.review_repo import ReviewView
from .foreshadow_inject import (
ForeshadowLine,
merge_foreshadow_lines,
render_foreshadow_line,
select_auto_lines,
to_line,
)
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
from .types import AssembledContext, EntityKind from .types import AssembledContext, EntityKind
@@ -199,16 +205,14 @@ def _prior_conflict_notes(
def _build_volatile( def _build_volatile(
chapter_no: int, chapter_no: int,
cards: str, cards: str,
foreshadows: list[ForeshadowView], foreshadow_lines: list[ForeshadowLine],
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, prior_conflict_notes: str | None = None,
) -> str: ) -> str:
fore_lines = [ # 伏笔行已在 assemble() 合并去重、按 code 排序;此处只逐行渲染(含窗口 + 逾期标记)。
f"【伏笔】{f.code} {f.title} [{f.status}]" fore_lines = [render_foreshadow_line(line, chapter_no) for line in foreshadow_lines]
for f in sorted(foreshadows, key=lambda f: f.code)
]
digest_lines = [ digest_lines = [
f"{d.chapter_no}章:{_ser(d.facts)}" for d in sorted(digests, key=lambda d: d.chapter_no) f"{d.chapter_no}章:{_ser(d.facts)}" for d in sorted(digests, key=lambda d: d.chapter_no)
] ]
@@ -271,8 +275,18 @@ async def assemble(
excluded=excluded, excluded=excluded,
) )
# 伏笔注入两路并集(守不变量 #6确定性纯函数选择无向量、无随机
# 1) 大纲手工登记的窗口 code——作者显式点名始终注入不论状态
codes = [str(w["code"]) for w in outline.foreshadow_windows if w.get("code")] codes = [str(w["code"]) for w in outline.foreshadow_windows if w.get("code")]
foreshadows = await repos.foreshadow.list_for_codes(project_id, codes) outline_fore = await repos.foreshadow.list_for_codes(project_id, codes)
outline_lines = [to_line(f) for f in outline_fore]
# 2) 按本章章号自动纳入——账本全量 → 纯函数筛「回收窗口内 / 逾期且未 CLOSED」
# 修「大纲没登记=零注入」。账本未注入(默认)则优雅降级为只用大纲路(守风险#6
auto_lines: list[ForeshadowLine] = []
if repos.foreshadow_ledger is not None:
all_fore = await repos.foreshadow_ledger.list_by_status(project_id)
auto_lines = select_auto_lines([to_line(f) for f in all_fore], chapter_no)
foreshadow_lines = merge_foreshadow_lines(outline_lines, auto_lines)
style = await repos.style.latest(project_id) style = await repos.style.latest(project_id)
rules = await repos.rules.all_for_project(project_id) rules = await repos.rules.all_for_project(project_id)
spec = await repos.project.spec(project_id) spec = await repos.project.spec(project_id)
@@ -292,7 +306,7 @@ async def assemble(
volatile = _build_volatile( volatile = _build_volatile(
chapter_no, chapter_no,
cards, cards,
foreshadows, foreshadow_lines,
recent_digests, recent_digests,
outline.beats, outline.beats,
directive, directive,

View File

@@ -0,0 +1,121 @@
"""伏笔注入的确定性纯函数——选择 + 渲染ARCH §5.3 / 不变量 #6、#9
写章时把「相关伏笔」注入 volatile缓存断点之后守不变量 #9。相关性来自两路并集
1. 大纲手工登记的 `outline.foreshadow_windows`(作者显式点名)——始终注入,不论状态;
2. 按本章章号自动纳入本章处于回收窗口内from<=ch<=to或已逾期且未 CLOSED。
两路都是**确定性纯函数**选择:无向量、无随机(不变量 #6渲染字符串无时间戳/UUID
故安全留在 volatile。逾期判据复用 `domain.foreshadow_state.is_overdue`(单一真源)。
"""
from __future__ import annotations
from dataclasses import dataclass
from ww_core.domain.foreshadow_repo import ForeshadowLedgerView
from ww_core.domain.foreshadow_state import (
CLOSED,
OVERDUE,
ForeshadowStatus,
is_overdue,
)
from ww_core.domain.repositories import ForeshadowView
@dataclass(frozen=True)
class ForeshadowLine:
"""渲染/选择用的中性伏笔行——从读侧 `ForeshadowView` 与账本 `ForeshadowLedgerView`
收敛出的共同最小字段,避免两路视图类型在选择/渲染层扩散(不可变,确定性)。"""
code: str
title: str
status: str
expected_close_from: int | None
expected_close_to: int | None
def to_line(view: ForeshadowView | ForeshadowLedgerView) -> ForeshadowLine:
"""把任一伏笔视图收敛成中性 `ForeshadowLine`(两路视图字段同名,纯映射)。"""
return ForeshadowLine(
code=view.code,
title=view.title,
status=view.status,
expected_close_from=view.expected_close_from,
expected_close_to=view.expected_close_to,
)
def _has_window(line: ForeshadowLine) -> bool:
return line.expected_close_from is not None and line.expected_close_to is not None
def _in_window(line: ForeshadowLine, chapter_no: int) -> bool:
"""本章处于回收窗口内from<=ch<=to含边界——需上下界俱在否则视为无窗口。"""
if not _has_window(line):
return False
# _has_window 已保证两界非空,此处类型收窄由显式断言给出。
assert line.expected_close_from is not None
assert line.expected_close_to is not None
return line.expected_close_from <= chapter_no <= line.expected_close_to
def _is_overdue_line(line: ForeshadowLine, chapter_no: int) -> bool:
"""逾期判据:`status==OVERDUE`(账本扫描已置位)或越过期望回收上界且未 CLOSED。
后者复用 `foreshadow_state.is_overdue`(单一真源);未知 status 保守视为未逾期。
"""
if line.status == OVERDUE.value:
return True
try:
status = ForeshadowStatus(line.status)
except ValueError:
return False
return is_overdue(
current_chapter=chapter_no,
expected_close_to=line.expected_close_to,
status=status,
)
def _is_relevant_this_chapter(line: ForeshadowLine, chapter_no: int) -> bool:
"""按章自动纳入判据:未 CLOSED 且(本章在回收窗口内 或 已逾期)。"""
if line.status == CLOSED.value:
return False
return _in_window(line, chapter_no) or _is_overdue_line(line, chapter_no)
def select_auto_lines(all_lines: list[ForeshadowLine], chapter_no: int) -> list[ForeshadowLine]:
"""从全量伏笔里按当前章号自动挑选相关项(确定性纯函数,守不变量 #6"""
return [line for line in all_lines if _is_relevant_this_chapter(line, chapter_no)]
def merge_foreshadow_lines(*groups: list[ForeshadowLine]) -> list[ForeshadowLine]:
"""按 code 合并去重多路来源、按 code 升序返回(确定性;先到者优先,同 code 同数据)。"""
by_code: dict[str, ForeshadowLine] = {}
for group in groups:
for line in group:
by_code.setdefault(line.code, line)
return [by_code[code] for code in sorted(by_code)]
def _render_window(line: ForeshadowLine) -> str:
"""渲染「期望回收 {from}-{to}章」;窗口缺失(任一界为空)则优雅省略该段。"""
if not _has_window(line):
return ""
return f"期望回收 {line.expected_close_from}-{line.expected_close_to}"
def render_foreshadow_line(line: ForeshadowLine, chapter_no: int) -> str:
"""渲染一行注入伏笔:编码/标题/状态 + 期望回收窗口 +(逾期时)逾期标记。
例:`【伏笔】F1 神秘石符 [OPEN] 期望回收 5-8章已逾期`。
窗口缺失省略「期望回收…」段;未逾期不加标记。全程无时间戳/UUID安全入 volatile。
"""
text = f"【伏笔】{line.code} {line.title} [{line.status}]"
window = _render_window(line)
if window:
text = f"{text} {window}"
if _is_overdue_line(line, chapter_no):
text = f"{text}(已逾期!)"
return text

View File

@@ -21,6 +21,7 @@ from ww_db.models import (
WorldEntity, WorldEntity,
) )
from ww_core.domain.foreshadow_repo import SqlForeshadowLedgerRepo
from ww_core.domain.repositories import ( from ww_core.domain.repositories import (
CharacterView, CharacterView,
DigestView, DigestView,
@@ -228,6 +229,8 @@ def sql_memory_repos(session: AsyncSession) -> MemoryRepos:
"""用一个 AsyncSession 装配全部 9 个 SQLAlchemy repoT1.4 注入点)。 """用一个 AsyncSession 装配全部 9 个 SQLAlchemy repoT1.4 注入点)。
`review=SqlReviewRepo`:供 assemble 反读上一已验收章的 continuity 冲突裁决(灵感②)。 `review=SqlReviewRepo`:供 assemble 反读上一已验收章的 continuity 冲突裁决(灵感②)。
`foreshadow_ledger=SqlForeshadowLedgerRepo`:供 assemble 按本章章号自动挑选伏笔
(回收窗口内/逾期,复用账本读侧 `list_by_status`)——大纲未登记也能自动注入。
""" """
return MemoryRepos( return MemoryRepos(
outline=SqlOutlineRepo(session), outline=SqlOutlineRepo(session),
@@ -239,4 +242,5 @@ def sql_memory_repos(session: AsyncSession) -> MemoryRepos:
rules=SqlRulesRepo(session), rules=SqlRulesRepo(session),
project=SqlProjectSpecRepo(session), project=SqlProjectSpecRepo(session),
review=SqlReviewRepo(session), review=SqlReviewRepo(session),
foreshadow_ledger=SqlForeshadowLedgerRepo(session),
) )