feat(ux): B0 注入透明读端点 + F1 写作页右栏真面板

兑现「看到的=写章用的」信任牌(不变量 #6),替换写作页右栏过时假占位
(「M1 暂未接 / M2 开放」)。

后端:
- GET /projects/{id}/chapters/{no}/injection → InjectionResponse(selected
  实体 + 入选理由 + recent_n);调既有 assemble() 回放确定性 SelectionTrace,
  无 LLM / 无 commit / 无 DDL;项目不存在→404,无大纲→selected:[]。
- 新 schemas/injection.py;tests/test_injection.py(3 测)。

前端:
- gen:api 纳入端点;纯逻辑 lib/workbench/injection.ts(理由/类型→中文徽标
  + 4 vitest)+ useInjection 读 hook。
- ChapterAssistant 改 client 组件:列出选中实体 + 理由徽标 + 空/载/错三态,
  四审段指向审稿页;Workbench 传 projectId/chapterNo。

门禁:后端 ruff/format/mypy 159/alembic 无漂移/pytest 454;
前端 lint/typecheck/vitest 167/build。

B0 可控版(PUT override pin/排除/recent_n + selection 加参 + draft 同读
override + 持久化)+ F1 可控控件待后续。
This commit is contained in:
Yaojia Wang
2026-06-20 10:57:03 +02:00
parent 7e63534366
commit 8058cfb11a
12 changed files with 513 additions and 10 deletions

View File

@@ -0,0 +1,169 @@
"""B0 注入透明读端点测试GET /projects/:id/chapters/:no/injection。
读取 assemble 的确定性 SelectionTrace无 LLM、无 commit
- 命中实体 + 入选理由徽标;项目不存在 → 404无大纲 → selected: [](不报错)。
"""
from __future__ import annotations
import uuid
import httpx
import pytest
from fakes_projects import FakeProjectRepo
from ww_api.services.credentials import STUB_OWNER_ID
from ww_core.domain.project_repo import ProjectCreate
from ww_core.domain.repositories import (
CharacterView,
DigestView,
ForeshadowView,
MemoryRepos,
OutlineView,
ProjectSpecView,
RuleView,
StyleView,
WorldEntityView,
)
from ww_shared import ErrorCode
# ---- 内存 fake memory repos无 DB只够断言 SelectionTrace 形状)----
class _OutlineRepo:
def __init__(self, rows: dict[int, OutlineView]) -> None:
self._rows = rows
async def get(self, project_id: uuid.UUID, chapter_no: int) -> OutlineView | None:
return self._rows.get(chapter_no)
async def list_for_project(self, project_id: uuid.UUID) -> list[OutlineView]:
return [self._rows[k] for k in sorted(self._rows)]
class _CharRepo:
def __init__(self, rows: list[CharacterView]) -> None:
self._rows = rows
async def list_for_project(self, project_id: uuid.UUID) -> list[CharacterView]:
return list(self._rows)
class _WorldRepo:
def __init__(self, rows: list[WorldEntityView]) -> None:
self._rows = rows
async def list_for_project(self, project_id: uuid.UUID) -> list[WorldEntityView]:
return list(self._rows)
class _DigestRepo:
async def recent(self, project_id: uuid.UUID, k: int) -> list[DigestView]:
return []
class _ForeshadowRepo:
async def list_for_codes(self, project_id: uuid.UUID, codes: list[str]) -> list[ForeshadowView]:
return []
class _StyleRepo:
async def latest(self, project_id: uuid.UUID) -> StyleView | None:
return None
class _RulesRepo:
async def all_for_project(self, project_id: uuid.UUID) -> list[RuleView]:
return []
class _SpecRepo:
async def spec(self, project_id: uuid.UUID) -> ProjectSpecView | None:
return ProjectSpecView(title="测试作品", premise="测试前提")
def _memory_repos(
*,
outline: dict[int, OutlineView] | None = None,
characters: list[CharacterView] | None = None,
world: list[WorldEntityView] | None = None,
) -> MemoryRepos:
return MemoryRepos(
outline=_OutlineRepo(outline or {}),
character=_CharRepo(characters or []),
world_entity=_WorldRepo(world or []),
digest=_DigestRepo(),
foreshadow=_ForeshadowRepo(),
style=_StyleRepo(),
rules=_RulesRepo(),
project=_SpecRepo(),
)
def _make_client(
*,
project_repo: FakeProjectRepo,
memory_repos: MemoryRepos,
) -> httpx.AsyncClient:
import os
os.environ.setdefault("CREDENTIAL_ENC_KEY", "x" * 44)
from ww_api.main import create_app
from ww_api.services.project_deps import get_memory_repos, get_project_repo
app = create_app()
app.dependency_overrides[get_project_repo] = lambda: project_repo
app.dependency_overrides[get_memory_repos] = lambda: memory_repos
transport = httpx.ASGITransport(app=app)
return httpx.AsyncClient(transport=transport, base_url="http://test")
async def _create_project(repo: FakeProjectRepo, title: str = "注入") -> uuid.UUID:
view = await repo.create(STUB_OWNER_ID, ProjectCreate(title=title))
return uuid.UUID(str(view.id))
@pytest.mark.asyncio
async def test_injection_lists_selected_entities_with_reasons() -> None:
repo = FakeProjectRepo()
pid = await _create_project(repo)
memory = _memory_repos(
outline={3: OutlineView(volume=1, chapter_no=3, beats={"entities": ["青檀"]})},
characters=[
CharacterView(name="林动", role="主角"),
CharacterView(name="青檀", role="配角"),
CharacterView(name="路人甲", role="龙套"),
],
)
client = _make_client(project_repo=repo, memory_repos=memory)
async with client:
resp = await client.get(f"/projects/{pid}/chapters/3/injection")
assert resp.status_code == 200
body = resp.json()
assert body["project_id"] == str(pid)
assert body["chapter_no"] == 3
by_name = {e["name"]: e for e in body["selected"]}
assert "林动" in by_name and "main_character" in by_name["林动"]["reasons"]
assert "青檀" in by_name and "explicit_beat" in by_name["青檀"]["reasons"]
assert "路人甲" not in by_name # 未命中任何理由 → 不注入
assert body["recent_n"] >= 1
@pytest.mark.asyncio
async def test_injection_unknown_project_404() -> None:
repo = FakeProjectRepo()
client = _make_client(project_repo=repo, memory_repos=_memory_repos())
async with client:
resp = await client.get(f"/projects/{uuid.uuid4()}/chapters/1/injection")
assert resp.status_code == 404
assert resp.json()["error"]["code"] == ErrorCode.NOT_FOUND
@pytest.mark.asyncio
async def test_injection_no_outline_returns_empty() -> None:
repo = FakeProjectRepo()
pid = await _create_project(repo)
client = _make_client(project_repo=repo, memory_repos=_memory_repos())
async with client:
resp = await client.get(f"/projects/{pid}/chapters/9/injection")
assert resp.status_code == 200
assert resp.json()["selected"] == []

View File

@@ -26,6 +26,7 @@ from ww_core.domain.project_repo import ProjectCreate, ProjectRepo
from ww_core.domain.repositories import MemoryRepos
from ww_core.domain.review_repo import ReviewRepo
from ww_core.memory import assemble
from ww_core.memory.selection import RECENT_DIGEST_COUNT
from ww_core.orchestrator import (
ChapterState,
SseEvent,
@@ -40,6 +41,7 @@ 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.projects import (
AcceptRequest,
AcceptResponse,
@@ -124,6 +126,38 @@ async def get_project(project_id: uuid.UUID, repo: ProjectRepoDep) -> ProjectRes
return _to_response(view)
@router.get("/{project_id}/chapters/{chapter_no}/injection")
async def get_injection(
project_id: uuid.UUID,
chapter_no: int,
repos: MemoryReposDep,
project_repo: ProjectRepoDep,
) -> InjectionResponse:
"""本章注入透明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
]
log.info(
"injection_read",
project_id=str(project_id),
chapter_no=chapter_no,
selected_count=len(selected),
)
return InjectionResponse(
project_id=project_id,
chapter_no=chapter_no,
selected=selected,
recent_n=RECENT_DIGEST_COUNT,
)
def _encode_sse(event: SseEvent) -> str:
"""把归一事件编码为 text/event-stream 帧:`event: <name>\\ndata: <json>\\n\\n`。"""
payload = json.dumps(event.data, ensure_ascii=False)

View File

@@ -0,0 +1,31 @@
"""本章注入透明B0的响应 schemaC 扩 / ARCH §3.4、§5.3)。
snake_case前端经 OpenAPI 生成 TS 类型消费。改字段 → 前端必须 `pnpm gen:api`。
读取无 LLM、无 commit——仅回放 assemble() 的确定性 SelectionTrace不变量 #6
"""
from __future__ import annotations
import uuid
from pydantic import BaseModel, Field
class InjectionEntity(BaseModel):
"""一个被注入本章上下文的实体 + 入选理由(喂给透明面板的徽标)。"""
kind: str = Field(description="character / world_entity")
name: str
reasons: list[str] = Field(
default_factory=list,
description="explicit_beat / main_character / recent_digest / foreshadow_window",
)
class InjectionResponse(BaseModel):
"""GET /projects/:id/chapters/:no/injection本章确定性注入留痕。"""
project_id: uuid.UUID
chapter_no: int
selected: list[InjectionEntity] = Field(default_factory=list)
recent_n: int = Field(description="近况摘要回看章数(确定性选择的默认参数)")