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

@@ -22,6 +22,7 @@ from fastapi.responses import StreamingResponse
from sqlalchemy.ext.asyncio import AsyncSession
from ww_core.domain.chapter_repo import ChapterRepo
from ww_core.domain.digest_repo import DigestAppendRepo
from ww_core.domain.injection_repo import EntityRef, InjectionOverride, InjectionOverrideRepo
from ww_core.domain.project_repo import ProjectCreate, ProjectRepo
from ww_core.domain.repositories import MemoryRepos
from ww_core.domain.review_repo import ReviewRepo
@@ -41,7 +42,12 @@ from ww_llm_gateway import Gateway
from ww_shared import AppError, ErrorCode
from ww_api.logging_config import get_logger
from ww_api.schemas.injection import InjectionEntity, InjectionResponse
from ww_api.schemas.injection import (
InjectionEntity,
InjectionEntityRef,
InjectionOverrideRequest,
InjectionResponse,
)
from ww_api.schemas.projects import (
AcceptRequest,
AcceptResponse,
@@ -67,6 +73,7 @@ from ww_api.services.project_deps import (
get_chapter_repo,
get_digest_append_repo,
get_digest_gateway,
get_injection_repo,
get_memory_repos,
get_project_repo,
get_review_gateway,
@@ -85,6 +92,7 @@ GatewayDep = Annotated[Gateway, Depends(get_writer_gateway)]
ReviewGatewayDep = Annotated[Gateway, Depends(get_review_gateway)]
DigestGatewayDep = Annotated[Gateway, Depends(get_digest_gateway)]
MemoryReposDep = Annotated[MemoryRepos, Depends(get_memory_repos)]
InjectionRepoDep = Annotated[InjectionOverrideRepo, Depends(get_injection_repo)]
ReviewRepoDep = Annotated[ReviewRepo, Depends(get_review_repo)]
DigestRepoDep = Annotated[DigestAppendRepo, Depends(get_digest_append_repo)]
@@ -126,36 +134,95 @@ async def get_project(project_id: uuid.UUID, repo: ProjectRepoDep) -> ProjectRes
return _to_response(view)
async def _injection_response(
repos: MemoryRepos,
project_id: uuid.UUID,
chapter_no: int,
override: InjectionOverride | None,
) -> InjectionResponse:
"""组装注入响应:以作者覆盖跑确定性 assemble → selected + override 回显GET/PUT 共用)。"""
context = await assemble(repos, project_id, chapter_no, override=override)
selected = [
InjectionEntity(kind=e.kind, name=e.name, reasons=list(e.reasons))
for e in context.selection.selected
]
effective_n = override.recent_n if (override and override.recent_n) else RECENT_DIGEST_COUNT
return InjectionResponse(
project_id=project_id,
chapter_no=chapter_no,
selected=selected,
recent_n=effective_n,
pinned=[
InjectionEntityRef(kind=r.kind, name=r.name)
for r in (override.pinned if override else [])
],
excluded=[
InjectionEntityRef(kind=r.kind, name=r.name)
for r in (override.excluded if override else [])
],
)
@router.get("/{project_id}/chapters/{chapter_no}/injection")
async def get_injection(
project_id: uuid.UUID,
chapter_no: int,
repos: MemoryReposDep,
project_repo: ProjectRepoDep,
injection_repo: InjectionRepoDep,
) -> InjectionResponse:
"""本章注入透明B0读端点回放确定性 SelectionTrace。无 LLM、无 commit。
"""本章注入透明B0读端点回放确定性 SelectionTrace + 作者覆盖。无 LLM、无 commit。
项目不存在 → 404无大纲 → selected: [](不报错)。看到的=写章用的(不变量 #6
"""
if await project_repo.get(STUB_OWNER_ID, project_id) is None:
raise AppError(ErrorCode.NOT_FOUND, f"project {project_id} not found")
context = await assemble(repos, project_id, chapter_no)
selected = [
InjectionEntity(kind=e.kind, name=e.name, reasons=list(e.reasons))
for e in context.selection.selected
]
override = await injection_repo.get(project_id, chapter_no)
resp = await _injection_response(repos, project_id, chapter_no, override)
log.info(
"injection_read",
project_id=str(project_id),
chapter_no=chapter_no,
selected_count=len(selected),
selected_count=len(resp.selected),
pinned_count=len(resp.pinned),
excluded_count=len(resp.excluded),
)
return InjectionResponse(
project_id=project_id,
return resp
@router.put("/{project_id}/chapters/{chapter_no}/injection")
async def save_injection(
project_id: uuid.UUID,
chapter_no: int,
body: InjectionOverrideRequest,
repos: MemoryReposDep,
project_repo: ProjectRepoDep,
injection_repo: InjectionRepoDep,
session: Annotated[AsyncSession, Depends(get_session)],
) -> InjectionResponse:
"""本章注入覆盖B0 可控版,写端点):存 pin/排除/recent_n → 回放新的确定性 selected。
项目不存在 → 404。upsert 只 flush端点末尾 commit。覆盖是确定性输入draft 端点同读,
故「看到的=写章用的」仍成立(不变量 #6 的作者兜底)。
"""
if await project_repo.get(STUB_OWNER_ID, project_id) is None:
raise AppError(ErrorCode.NOT_FOUND, f"project {project_id} not found")
override = InjectionOverride(
pinned=[EntityRef(kind=r.kind, name=r.name) for r in body.pinned],
excluded=[EntityRef(kind=r.kind, name=r.name) for r in body.excluded],
recent_n=body.recent_n,
)
saved = await injection_repo.upsert(project_id, chapter_no, override)
await session.commit()
log.info(
"injection_saved",
project_id=str(project_id),
chapter_no=chapter_no,
selected=selected,
recent_n=RECENT_DIGEST_COUNT,
pinned_count=len(saved.pinned),
excluded_count=len(saved.excluded),
recent_n=saved.recent_n,
)
return await _injection_response(repos, project_id, chapter_no, saved)
def _encode_sse(event: SseEvent) -> str:
@@ -171,11 +238,13 @@ async def stream_draft(
request: Request,
repos: MemoryReposDep,
gateway: GatewayDep,
injection_repo: InjectionRepoDep,
session: Annotated[AsyncSession, Depends(get_session)],
) -> StreamingResponse:
"""流式写章草稿:组装记忆 → 网关流 → 归一为 SSE 事件 → text/event-stream。"""
"""流式写章草稿:组装记忆(含作者注入覆盖)→ 网关流 → 归一为 SSE 事件 → text/event-stream。"""
request_id = getattr(request.state, "request_id", None)
context = await assemble(repos, project_id, chapter_no)
override = await injection_repo.get(project_id, chapter_no)
context = await assemble(repos, project_id, chapter_no, override=override)
log.info(
"draft_stream_start",
project_id=str(project_id),

View File

@@ -1,7 +1,9 @@
"""本章注入透明B0的响应 schemaC 扩 / ARCH §3.4、§5.3)。
"""本章注入透明B0请求/响应 schemaC 扩 / ARCH §3.4、§5.3)。
snake_case前端经 OpenAPI 生成 TS 类型消费。改字段 → 前端必须 `pnpm gen:api`。
读取无 LLM、无 commit——回放 assemble() 的确定性 SelectionTrace不变量 #6
读取无 LLM、无 commit——回放 assemble() 的确定性 SelectionTrace不变量 #6
可控版PUT存作者覆盖pin 强制纳入 / excluded 强制剔除 / recent_n 覆盖回看章数,
本身仍是确定性输入,故「看到的=写章用的」成立。
"""
from __future__ import annotations
@@ -10,6 +12,10 @@ import uuid
from pydantic import BaseModel, Field
# recent_n 合法区间(回看近况摘要章数)——下限 1上限防作者误填爆 token。
RECENT_N_MIN = 1
RECENT_N_MAX = 20
class InjectionEntity(BaseModel):
"""一个被注入本章上下文的实体 + 入选理由(喂给透明面板的徽标)。"""
@@ -18,14 +24,39 @@ class InjectionEntity(BaseModel):
name: str
reasons: list[str] = Field(
default_factory=list,
description="explicit_beat / main_character / recent_digest / foreshadow_window",
description="explicit_beat/main_character/recent_digest/foreshadow_window/author_pin",
)
class InjectionEntityRef(BaseModel):
"""作者 pin/排除点名的实体引用kind + name"""
kind: str = Field(description="character / world_entity")
name: str
class InjectionOverrideRequest(BaseModel):
"""PUT /projects/:id/chapters/:no/injection作者对本章注入的覆盖。
pinned 强制纳入(加 author_pin 理由excluded 强制剔除recent_n 覆盖近况回看章数
None=用默认)。空覆盖 = 纯自动选择。
"""
pinned: list[InjectionEntityRef] = Field(default_factory=list)
excluded: list[InjectionEntityRef] = Field(default_factory=list)
recent_n: int | None = Field(default=None, ge=RECENT_N_MIN, le=RECENT_N_MAX)
class InjectionResponse(BaseModel):
"""GET /projects/:id/chapters/:no/injection本章确定性注入留痕。"""
"""GET/PUT /projects/:id/chapters/:no/injection本章注入留痕 + 作者覆盖回显"""
project_id: uuid.UUID
chapter_no: int
selected: list[InjectionEntity] = Field(default_factory=list)
recent_n: int = Field(description="近况摘要回看章数(确定性选择的默认参数")
recent_n: int = Field(description="生效的近况回看章数(默认或被 override 覆盖后的值")
pinned: list[InjectionEntityRef] = Field(
default_factory=list, description="作者强制纳入的实体(回显)"
)
excluded: list[InjectionEntityRef] = Field(
default_factory=list, description="作者强制剔除的实体(回显,供面板恢复)"
)

View File

@@ -20,6 +20,7 @@ from ww_core.domain import ForeshadowLedgerRepo, SqlForeshadowLedgerRepo
from ww_core.domain.chapter_repo import ChapterRepo, SqlChapterRepo
from ww_core.domain.character_repo import CharacterWriteRepo, SqlCharacterWriteRepo
from ww_core.domain.digest_repo import DigestAppendRepo, SqlDigestAppendRepo
from ww_core.domain.injection_repo import InjectionOverrideRepo, SqlInjectionOverrideRepo
from ww_core.domain.job_repo import JobRepo, SqlJobRepo
from ww_core.domain.outline_write_repo import OutlineWriteRepo, SqlOutlineWriteRepo
from ww_core.domain.project_repo import ProjectRepo, SqlProjectRepo
@@ -102,6 +103,16 @@ def get_memory_repos(
return sql_memory_repos(session)
def get_injection_repo(
session: Annotated[AsyncSession, Depends(get_session)],
) -> InjectionOverrideRepo:
"""本章注入覆盖读/写 repoB0 可控版 GET/PUT injection + draft 同读)。
upsert 只 flush端点提交与其它写侧一致。测试经 `app.dependency_overrides` 注 fake。
"""
return SqlInjectionOverrideRepo(session)
def get_review_repo(
session: Annotated[AsyncSession, Depends(get_session)],
) -> ReviewRepo: