feat(b0): 注入透明可控版后端 — PUT override(pin/排除/recent_n) + 选择函数加参 + draft 同读

- selection.select_relevant_entities 加 pinned/excluded frozenset 入参;新理由 author_pin(末位)
- assemble 加 override 关键字参(recent_n 覆盖回看章数 + pin/排除);GET/PUT/draft 同读同一覆盖(不变量 #6 作者兜底)
- 新 domain/injection_repo.py(InjectionOverride/EntityRef/SqlInjectionOverrideRepo,upsert 只 flush)
- 新表 chapter_injection(迁移 ad2c4c663daf,唯一 project_id+chapter_no;复用 outline 行已否决)
- PUT/GET injection 端点 + InjectionOverrideRequest/回显 pinned/excluded;recent_n 1..20 校验 422
- 单测:selection pin/排除/exclude>pin + assemble override + 端点 PUT/404/422;regen TS 客户端
- 门禁绿:ruff/format · mypy 163 · alembic 无漂移 · pytest 476

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Yaojia Wang
2026-06-20 11:56:54 +02:00
parent e8cccf7389
commit 0d473e726e
16 changed files with 681 additions and 41 deletions

View File

@@ -200,6 +200,99 @@ def test_union_dedups_reasons_sorted() -> None:
]
# ---- author override: pin / exclude / recent_n (B0 可控版) ----
def test_pin_forces_inclusion_with_author_pin_reason() -> None:
# 路人甲零自动理由——pin 强制纳入并加 author_pin。
outline = OutlineView(volume=1, chapter_no=1, beats={})
trace = select_relevant_entities(
outline=outline,
characters=[char("路人甲", role="配角")],
world_entities=[],
recent_digests=[],
pinned=frozenset({("character", "路人甲")}),
)
selected = [e for e in trace.selected if e.name == "路人甲"]
assert len(selected) == 1
assert selected[0].reasons == ["author_pin"]
def test_pin_appends_author_pin_to_existing_reasons() -> None:
outline = OutlineView(volume=1, chapter_no=1, beats={})
trace = select_relevant_entities(
outline=outline,
characters=[char("林动", role="主角")],
world_entities=[],
recent_digests=[],
pinned=frozenset({("character", "林动")}),
)
main = next(e for e in trace.selected if e.name == "林动")
assert main.reasons == ["main_character", "author_pin"] # 规范顺序author_pin 末位
def test_exclude_removes_auto_selected_entity() -> None:
# 主角林动本会自动入选——excluded 强制剔除。
outline = OutlineView(volume=1, chapter_no=1, beats={})
trace = select_relevant_entities(
outline=outline,
characters=[char("林动", role="主角")],
world_entities=[],
recent_digests=[],
excluded=frozenset({("character", "林动")}),
)
assert all(e.name != "林动" for e in trace.selected)
def test_exclude_wins_over_pin() -> None:
# 同一实体同时 pin + exclude前端不会但防御排除优先。
outline = OutlineView(volume=1, chapter_no=1, beats={})
trace = select_relevant_entities(
outline=outline,
characters=[char("林动", role="主角")],
world_entities=[],
recent_digests=[],
pinned=frozenset({("character", "林动")}),
excluded=frozenset({("character", "林动")}),
)
assert all(e.name != "林动" for e in trace.selected)
async def test_assemble_override_pins_and_excludes() -> None:
from ww_core.domain.injection_repo import EntityRef, InjectionOverride
repos = build_repos(
characters=[char("林动", role="主角"), char("路人乙", role="龙套")],
spec=ProjectSpecView(title=""),
)
override = InjectionOverride(
pinned=[EntityRef(kind="character", name="路人乙")],
excluded=[EntityRef(kind="character", name="林动")],
)
ctx = await assemble(repos, PROJECT, 1, override=override)
names = {e.name for e in ctx.selection.selected}
assert "路人乙" in names # pin 强制纳入
assert "林动" not in names # exclude 强制剔除
async def test_assemble_override_recent_n_limits_digests() -> None:
from ww_core.domain.injection_repo import InjectionOverride
digests = [DigestView(chapter_no=n, facts={"entities": [f"实体{n}"]}) for n in range(1, 6)]
world = [WorldEntityView(type="设定", name=f"实体{n}") for n in range(1, 6)]
repos = build_repos(
outline={10: OutlineView(volume=1, chapter_no=10, beats={})},
world=world,
digests=digests,
spec=ProjectSpecView(title=""),
)
# recent_n=1 → 只回看最近一章第5章的实体5 命中 recent_digest。
ctx = await assemble(repos, PROJECT, 10, override=InjectionOverride(recent_n=1))
names = {e.name for e in ctx.selection.selected}
assert "实体5" in names
assert "实体1" not in names
# ---- render_cards ----

View File

@@ -30,6 +30,12 @@ from ww_core.domain.foreshadow_state import (
is_overdue,
transition,
)
from ww_core.domain.injection_repo import (
EntityRef,
InjectionOverride,
InjectionOverrideRepo,
SqlInjectionOverrideRepo,
)
from ww_core.domain.job_repo import (
JobRepo,
JobView,
@@ -79,6 +85,10 @@ __all__ = [
"SqlForeshadowLedgerRepo",
"ForeshadowStatus",
"InvalidTransition",
"EntityRef",
"InjectionOverride",
"InjectionOverrideRepo",
"SqlInjectionOverrideRepo",
"JobRepo",
"JobView",
"SqlJobRepo",

View File

@@ -0,0 +1,123 @@
"""本章注入覆盖B0 可控版)的写/读 RepositoryARCH §3.4 作者手动 pin 兜底)。
作者在「本章注入透明」面板里对确定性自动选择做微调——把某实体强制纳入pin
强制剔除excluded、或覆盖近况回看章数recent_n。这是不变量 #6「确定性选择」的
**作者兜底**override 本身是确定性输入的一部分(同一 (project_id, chapter_no) 存一行),
故「看到的=写章用的」仍成立——draft 端点与注入读端点都读同一覆盖。
**提交边界**`upsert` 只 `flush()` 不 `commit()`——提交交端点(与 outline/rule 等写侧一致,
见 memory/gotchas。唯一约束 `(project_id, chapter_no)`:显式 read-modify-write upsert。
**与 beats 隔离**:单独建 `chapter_injection` 表存覆盖,**不复用 outline 行**——复用会污染
beats/prompt已否决见 memory/decisions
"""
from __future__ import annotations
import uuid
from typing import Protocol
from pydantic import BaseModel, Field
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from ww_db.models import ChapterInjection
class EntityRef(BaseModel):
"""一个被作者点名 pin/排除的实体引用kind + namefrozen 便于入 set 去重)。"""
model_config = {"frozen": True}
kind: str
name: str
class InjectionOverride(BaseModel):
"""作者对本章确定性注入的覆盖pin 强制纳入 / excluded 强制剔除 / recent_n 覆盖回看章数)。
全可选——空覆盖等价于「纯自动选择」。recent_n=None → 用默认 RECENT_DIGEST_COUNT。
"""
model_config = {"frozen": True}
pinned: list[EntityRef] = Field(default_factory=list)
excluded: list[EntityRef] = Field(default_factory=list)
recent_n: int | None = None
class InjectionOverrideRepo(Protocol):
"""注入覆盖读/写接口project_id 隔离;唯一 (project_id, chapter_no);只 flush 不 commit"""
async def get(self, project_id: uuid.UUID, chapter_no: int) -> InjectionOverride | None: ...
async def upsert(
self, project_id: uuid.UUID, chapter_no: int, override: InjectionOverride
) -> InjectionOverride: ...
def _refs(raw: object) -> list[EntityRef]:
"""JSONB list[{kind,name}] → list[EntityRef](容错:跳过形不对的项,不抛)。"""
if not isinstance(raw, list):
return []
out: list[EntityRef] = []
for item in raw:
if isinstance(item, dict) and "kind" in item and "name" in item:
out.append(EntityRef(kind=str(item["kind"]), name=str(item["name"])))
return out
def _to_override(row: ChapterInjection) -> InjectionOverride:
return InjectionOverride(
pinned=_refs(row.pinned),
excluded=_refs(row.excluded),
recent_n=row.recent_n,
)
class SqlInjectionOverrideRepo:
"""SQLAlchemy 实现:显式 read-modify-write upsert只 flush 不 commit"""
def __init__(self, session: AsyncSession) -> None:
self._s = session
async def get(self, project_id: uuid.UUID, chapter_no: int) -> InjectionOverride | None:
row = (
await self._s.execute(
select(ChapterInjection).where(
ChapterInjection.project_id == project_id,
ChapterInjection.chapter_no == chapter_no,
)
)
).scalar_one_or_none()
return _to_override(row) if row is not None else None
async def upsert(
self, project_id: uuid.UUID, chapter_no: int, override: InjectionOverride
) -> InjectionOverride:
# JSONB 写入建新对象重赋值(避免原地 mutate 不被 ORM 侦测,见 gotcha
pinned_payload = [r.model_dump() for r in override.pinned]
excluded_payload = [r.model_dump() for r in override.excluded]
row = (
await self._s.execute(
select(ChapterInjection).where(
ChapterInjection.project_id == project_id,
ChapterInjection.chapter_no == chapter_no,
)
)
).scalar_one_or_none()
if row is None:
row = ChapterInjection(
project_id=project_id,
chapter_no=chapter_no,
pinned=pinned_payload,
excluded=excluded_payload,
recent_n=override.recent_n,
)
self._s.add(row)
else:
row.pinned = pinned_payload
row.excluded = excluded_payload
row.recent_n = override.recent_n
await self._s.flush()
await self._s.refresh(row)
return _to_override(row)

View File

@@ -11,6 +11,7 @@ import json
import uuid
from typing import Any
from ww_core.domain.injection_repo import InjectionOverride
from ww_core.domain.repositories import (
CharacterView,
DigestView,
@@ -24,8 +25,8 @@ from ww_core.domain.repositories import (
)
from .render import render_cards
from .selection import MAIN_ROLES, RECENT_DIGEST_COUNT, select_relevant_entities
from .types import AssembledContext
from .selection import MAIN_ROLES, RECENT_DIGEST_COUNT, EntityKey, select_relevant_entities
from .types import AssembledContext, EntityKind
# 四级规则合并优先级global → genre → style → project越具体越靠后/越优先)
_LEVEL_RANK: dict[str, int] = {"global": 0, "genre": 1, "style": 2, "project": 3}
@@ -35,6 +36,11 @@ def _ser(value: Any) -> str:
return json.dumps(value, ensure_ascii=False, sort_keys=True)
def _kind(raw: str) -> EntityKind:
"""把覆盖里的字符串 kind 收敛成 EntityKind 字面量(未知值按 character 处理,不匹配亦无害)。"""
return "world_entity" if raw == "world_entity" else "character"
def merge_rules(rules: list[RuleView]) -> list[RuleView]:
"""按 global→genre→style→project 排序(同级按内容排序,确定性)。"""
return sorted(rules, key=lambda r: (_LEVEL_RANK.get(r.level, 99), r.level, r.content))
@@ -122,20 +128,37 @@ async def assemble(
project_id: uuid.UUID,
chapter_no: int,
recent_k: int = RECENT_DIGEST_COUNT,
*,
override: InjectionOverride | None = None,
) -> AssembledContext:
"""组装本章 prompt 上下文(确定性、可缓存)。"""
"""组装本章 prompt 上下文(确定性、可缓存)。
`override`B0 可控版作者覆盖也是确定性输入pin 强制纳入 / excluded 强制剔除 /
recent_n 覆盖近况回看章数。draft 与注入读端点传同一覆盖,故「看到的=写章用的」(不变量 #6
"""
# recent_n 覆盖优先于默认 recent_k非正值视为未设守住至少回看若干章
effective_k = override.recent_n if (override and override.recent_n) else recent_k
pinned: frozenset[EntityKey] = (
frozenset((_kind(r.kind), r.name) for r in override.pinned) if override else frozenset()
)
excluded: frozenset[EntityKey] = (
frozenset((_kind(r.kind), r.name) for r in override.excluded) if override else frozenset()
)
outline = await repos.outline.get(project_id, chapter_no) or OutlineView(
volume=0, chapter_no=chapter_no
)
characters = await repos.character.list_for_project(project_id)
world_entities = await repos.world_entity.list_for_project(project_id)
recent_digests = await repos.digest.recent(project_id, recent_k)
recent_digests = await repos.digest.recent(project_id, effective_k)
selection = select_relevant_entities(
outline=outline,
characters=characters,
world_entities=world_entities,
recent_digests=recent_digests,
pinned=pinned,
excluded=excluded,
)
codes = [str(w["code"]) for w in outline.foreshadow_windows if w.get("code")]

View File

@@ -23,8 +23,12 @@ _REASON_RANK: dict[SelectionReason, int] = {
"main_character": 1,
"recent_digest": 2,
"foreshadow_window": 3,
"author_pin": 4,
}
# 实体引用键kind, name——pin/排除集合用,确定性、可 hash。
EntityKey = tuple[EntityKind, str]
def _flatten_strings(obj: Any) -> list[str]:
"""递归收集任意 JSON 结构里的字符串叶子(确定性、用于按名匹配)。"""
@@ -78,8 +82,15 @@ def select_relevant_entities(
characters: list[CharacterView],
world_entities: list[WorldEntityView],
recent_digests: list[DigestView],
pinned: frozenset[EntityKey] = frozenset(),
excluded: frozenset[EntityKey] = frozenset(),
) -> SelectionTrace:
"""确定性并集选择 → SelectionTrace每实体带去重、排序的入选理由"""
"""确定性并集选择 → SelectionTrace每实体带去重、排序的入选理由
作者覆盖B0 可控版):`excluded` 强制剔除(优先于一切,即便自动命中也不注入);
`pinned` 强制纳入并加 `author_pin` 理由即便零自动理由也注入。excluded 优先于 pinned
因为「显式排除」是更强的作者意图(同一实体不应同时出现在两个集合,前端保证)。
"""
beat_listed, beat_text = _explicit_names(outline.beats)
digest_listed, digest_text = _digest_names(recent_digests)
window_names = _window_entity_names(outline)
@@ -96,17 +107,23 @@ def select_relevant_entities(
reasons.append("recent_digest")
if name in window_names:
reasons.append("foreshadow_window")
return sorted(set(reasons), key=lambda r: _REASON_RANK[r])
return reasons
def collect(kind: EntityKind, name: str, *, is_main: bool) -> None:
key: EntityKey = (kind, name)
if key in excluded: # 显式排除优先于一切
return
reasons = reasons_for(name, is_main=is_main)
if key in pinned:
reasons.append("author_pin")
if reasons:
deduped = sorted(set(reasons), key=lambda r: _REASON_RANK[r])
selected.append(SelectedEntity(kind=kind, name=name, reasons=deduped))
for c in characters:
reasons = reasons_for(c.name, is_main=(c.role or "") in MAIN_ROLES)
if reasons:
selected.append(SelectedEntity(kind="character", name=c.name, reasons=reasons))
collect("character", c.name, is_main=(c.role or "") in MAIN_ROLES)
for w in world_entities:
reasons = reasons_for(w.name, is_main=False)
if reasons:
selected.append(SelectedEntity(kind="world_entity", name=w.name, reasons=reasons))
collect("world_entity", w.name, is_main=False)
selected.sort(key=lambda e: (_KIND_RANK[e.kind], e.name))
return SelectionTrace(selected=selected)

View File

@@ -17,14 +17,17 @@ SelectionReason = Literal[
"main_character", # 主角/核心常驻
"recent_digest", # 近 N 章摘要出现
"foreshadow_window", # 命中本章伏笔窗口
"author_pin", # 作者手动置顶B0 可控版 override 强制纳入)
]
# 理由的规范排序(保证 SelectionTrace 确定性)
# 理由的规范排序(保证 SelectionTrace 确定性)。author_pin 排末位:
# 它是作者覆盖的来源,区别于前四个确定性来源(不变量 #6 的作者兜底ARCH §10
REASON_ORDER: tuple[SelectionReason, ...] = (
"explicit_beat",
"main_character",
"recent_digest",
"foreshadow_window",
"author_pin",
)