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

@@ -15,6 +15,7 @@ from typing import Any, Protocol
from pydantic import BaseModel, Field
from ww_core.domain.foreshadow_repo import ForeshadowLedgerRepo
from ww_core.domain.review_repo import ReviewRepo
# ---- 只读视图snake_casefrozen 防止意外突变)----
@@ -156,8 +157,11 @@ class ProjectSpecRepo(Protocol):
class MemoryRepos:
"""记忆服务所需的 9 个 repo 的依赖捆绑(注入点)。
`review` 末位默认 None只有冲突裁决反哺灵感②需要它不注入时 assemble
优雅降级(不反哺),故既有构造点无需感知(守风险#6
`review`/`foreshadow_ledger` 末位默认 None同 optional 注入模式):
- `review`:只有冲突裁决反哺(灵感②)需要它;
- `foreshadow_ledger`:只有按章自动挑选伏笔(本任务)需要它——账本全量读侧,
供 assemble 在大纲窗口之外自动纳入本章回收窗口内/逾期的伏笔(复用写侧账本 repo
不注入时 assemble 优雅降级(对应能力关闭),故既有构造点无需感知(守风险#6
"""
outline: OutlineRepo
@@ -169,3 +173,4 @@ class MemoryRepos:
rules: RulesRepo
project: ProjectSpecRepo
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 (
CharacterView,
DigestView,
ForeshadowView,
MemoryRepos,
OutlineView,
ProjectSpecView,
@@ -27,6 +26,13 @@ from ww_core.domain.repositories import (
)
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 .selection import MAIN_ROLES, RECENT_DIGEST_COUNT, EntityKey, select_relevant_entities
from .types import AssembledContext, EntityKind
@@ -199,16 +205,14 @@ def _prior_conflict_notes(
def _build_volatile(
chapter_no: int,
cards: str,
foreshadows: list[ForeshadowView],
foreshadow_lines: list[ForeshadowLine],
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}]"
for f in sorted(foreshadows, key=lambda f: f.code)
]
# 伏笔行已在 assemble() 合并去重、按 code 排序;此处只逐行渲染(含窗口 + 逾期标记)。
fore_lines = [render_foreshadow_line(line, chapter_no) for line in foreshadow_lines]
digest_lines = [
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,
)
# 伏笔注入两路并集(守不变量 #6确定性纯函数选择无向量、无随机
# 1) 大纲手工登记的窗口 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)
rules = await repos.rules.all_for_project(project_id)
spec = await repos.project.spec(project_id)
@@ -292,7 +306,7 @@ async def assemble(
volatile = _build_volatile(
chapter_no,
cards,
foreshadows,
foreshadow_lines,
recent_digests,
outline.beats,
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,
)
from ww_core.domain.foreshadow_repo import SqlForeshadowLedgerRepo
from ww_core.domain.repositories import (
CharacterView,
DigestView,
@@ -228,6 +229,8 @@ def sql_memory_repos(session: AsyncSession) -> MemoryRepos:
"""用一个 AsyncSession 装配全部 9 个 SQLAlchemy repoT1.4 注入点)。
`review=SqlReviewRepo`:供 assemble 反读上一已验收章的 continuity 冲突裁决(灵感②)。
`foreshadow_ledger=SqlForeshadowLedgerRepo`:供 assemble 按本章章号自动挑选伏笔
(回收窗口内/逾期,复用账本读侧 `list_by_status`)——大纲未登记也能自动注入。
"""
return MemoryRepos(
outline=SqlOutlineRepo(session),
@@ -239,4 +242,5 @@ def sql_memory_repos(session: AsyncSession) -> MemoryRepos:
rules=SqlRulesRepo(session),
project=SqlProjectSpecRepo(session),
review=SqlReviewRepo(session),
foreshadow_ledger=SqlForeshadowLedgerRepo(session),
)