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:
@@ -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_case,frozen 防止意外突变)----
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
121
packages/core/ww_core/memory/foreshadow_inject.py
Normal file
121
packages/core/ww_core/memory/foreshadow_inject.py
Normal 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
|
||||
@@ -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 repo(T1.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),
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user