diff --git a/PROGRESS.md b/PROGRESS.md index be29486..aed5880 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -108,6 +108,7 @@ T0.1 monorepo 骨架 ✅ @devops · T0.2 16 MVP 表迁移(无漂移,users st > 格式:`- [YYYY-MM-DD] @skill 完成/进展 Txx — 一句话结果 + 影响的契约/文件` +- [2026-06-20] UX计划 B0(可控版)+F1(控件) — **✅ 本章注入透明 可控版(分支 `feat/ux-b0-controllable-injection`,提交 `0d473e7` 后端 + `6449c31` 前端,未合 develop)**。在 B0 读端点之上加作者微调,守不变量 #6「确定性选择」的作者兜底。**后端**(@backend/@db):`PUT /projects/{id}/chapters/{no}/injection` ← `InjectionOverrideRequest{pinned,excluded,recent_n(1..20→422)}` → `InjectionResponse`(新增回显 `pinned`/`excluded`,`selected[].reasons` 可含新值 **`author_pin`**);`select_relevant_entities` 加 `pinned/excluded` frozenset 入参(pin 强制纳入加 author_pin / excluded 强制剔除且**优先于 pin**);`assemble` 加 `override` 关键字参(recent_n 覆盖回看章数);**GET injection + PUT + draft 流式端点同读同一覆盖**(看到的=写章用的)。持久化:**新表 `chapter_injection`**(迁移 `ad2c4c663daf`,唯一 `(project_id,chapter_no)`;复用 outline 行污染 beats 已否决)+ 新 `domain/injection_repo.py`(`InjectionOverride`/`EntityRef`/`SqlInjectionOverrideRepo`,upsert 只 flush 端点 commit)。**前端**(@frontend):`gen:api` 纳入 PUT + 新 schema;`lib/workbench/injection.ts` override 纯函数(`currentOverride`/`withPinToggled`/`withExcluded`/`withRestored`/`withRecentN`/`isPinned`,钳 1..20);`useInjection` 加 `saving` 态 + `togglePin/exclude/restore/setRecentN`(PUT 后以服务端确定结果为准、写失败可读文案不改本地态);`ChapterAssistant` 每实体 📌置顶/✕排除 + 「近 N 章」步进器 + 「已排除」恢复区。**全仓门禁绿**:后端 ruff/format/mypy **163**/alembic 无漂移/pytest **476**;前端 lint/tsc/vitest **183**/build。记 contracts C3 再扩。**实景 browse 复验待做。** UX 计划余项:R1/R2/R3 审稿人体学 + Tier4。 - [2026-06-20] UX计划 M1+M2 — **✅ 移动端可用(纯前端,分支 `feat/ux-m1-m2-mobile` 提交 9b893a6)**。救活手机端(390px)写→审主路径。**M1 导航响应式抽屉**:抽出纯逻辑 `lib/nav/items.ts`(`GLOBAL_NAV_ITEMS`/`projectNavItems`/`isNavItemActive` + `ActiveNav` 类型,6 vitest),桌面静态侧栏与移动抽屉共用避免漂移;新增通用 `Drawer`(遮罩点击/Esc 关闭/打开聚焦面板,复用 `CommandPalette` a11y 模式,`lg:hidden`)+ `NavDrawer`(汉堡 `aria-label=打开导航` + 左抽屉)+ `NavItems`;`LeftNav` 改 `hidden lg:block`,`AppShell` 顶栏加汉堡。**M2 工具条不溢出 + 移动可达**:`Workbench` 工具条改 `flex-wrap`(390px 不再横切「尚未保存」);`ChapterList`/`ChapterAssistant` 抽出 `ChapterListContent`/`AssistantContent`,`Workbench` 经左/右 `Drawer` 在 ` None: + self._store: dict[tuple[str, int], InjectionOverride] = {} + + async def get(self, project_id: uuid.UUID, chapter_no: int) -> InjectionOverride | None: + return self._store.get((str(project_id), chapter_no)) + + async def upsert( + self, project_id: uuid.UUID, chapter_no: int, override: InjectionOverride + ) -> InjectionOverride: + self._store[(str(project_id), chapter_no)] = override + return override + + def _memory_repos( *, outline: dict[int, OutlineView] | None = None, @@ -103,16 +120,25 @@ def _make_client( *, project_repo: FakeProjectRepo, memory_repos: MemoryRepos, + injection_repo: _FakeInjectionRepo | None = None, ) -> httpx.AsyncClient: import os os.environ.setdefault("CREDENTIAL_ENC_KEY", "x" * 44) + from fakes_projects import FakeSession from ww_api.main import create_app - from ww_api.services.project_deps import get_memory_repos, get_project_repo + from ww_api.services.project_deps import ( + get_injection_repo, + get_memory_repos, + get_project_repo, + ) + from ww_db import get_session app = create_app() app.dependency_overrides[get_project_repo] = lambda: project_repo app.dependency_overrides[get_memory_repos] = lambda: memory_repos + app.dependency_overrides[get_injection_repo] = lambda: injection_repo or _FakeInjectionRepo() + app.dependency_overrides[get_session] = lambda: FakeSession() transport = httpx.ASGITransport(app=app) return httpx.AsyncClient(transport=transport, base_url="http://test") @@ -167,3 +193,68 @@ async def test_injection_no_outline_returns_empty() -> None: resp = await client.get(f"/projects/{pid}/chapters/9/injection") assert resp.status_code == 200 assert resp.json()["selected"] == [] + + +@pytest.mark.asyncio +async def test_injection_put_pin_excludes_and_reflects() -> None: + repo = FakeProjectRepo() + pid = await _create_project(repo) + memory = _memory_repos( + outline={3: OutlineView(volume=1, chapter_no=3, beats={})}, + characters=[ + CharacterView(name="林动", role="主角"), + CharacterView(name="路人乙", role="龙套"), + ], + ) + injection = _FakeInjectionRepo() + client = _make_client(project_repo=repo, memory_repos=memory, injection_repo=injection) + async with client: + # pin 龙套路人乙(本无理由)、排除主角林动、近况回看设为 2。 + put = await client.put( + f"/projects/{pid}/chapters/3/injection", + json={ + "pinned": [{"kind": "character", "name": "路人乙"}], + "excluded": [{"kind": "character", "name": "林动"}], + "recent_n": 2, + }, + ) + assert put.status_code == 200 + body = put.json() + by_name = {e["name"]: e for e in body["selected"]} + assert "路人乙" in by_name and "author_pin" in by_name["路人乙"]["reasons"] + assert "林动" not in by_name # 排除生效 + assert body["recent_n"] == 2 + assert body["pinned"] == [{"kind": "character", "name": "路人乙"}] + assert body["excluded"] == [{"kind": "character", "name": "林动"}] + + # 覆盖已落 fake repo → GET 回放同一结果(看到的=写章用的)。 + got = await client.get(f"/projects/{pid}/chapters/3/injection") + assert got.status_code == 200 + got_names = {e["name"] for e in got.json()["selected"]} + assert "路人乙" in got_names and "林动" not in got_names + + +@pytest.mark.asyncio +async def test_injection_put_unknown_project_404() -> None: + repo = FakeProjectRepo() + client = _make_client(project_repo=repo, memory_repos=_memory_repos()) + async with client: + resp = await client.put( + f"/projects/{uuid.uuid4()}/chapters/1/injection", + json={"pinned": [], "excluded": [], "recent_n": None}, + ) + assert resp.status_code == 404 + assert resp.json()["error"]["code"] == ErrorCode.NOT_FOUND + + +@pytest.mark.asyncio +async def test_injection_put_rejects_out_of_range_recent_n() -> None: + repo = FakeProjectRepo() + pid = await _create_project(repo) + client = _make_client(project_repo=repo, memory_repos=_memory_repos()) + async with client: + resp = await client.put( + f"/projects/{pid}/chapters/1/injection", + json={"pinned": [], "excluded": [], "recent_n": 999}, + ) + assert resp.status_code == 422 diff --git a/apps/api/tests/test_projects.py b/apps/api/tests/test_projects.py index 836459f..1966272 100644 --- a/apps/api/tests/test_projects.py +++ b/apps/api/tests/test_projects.py @@ -67,6 +67,13 @@ class _StubProjectSpecRepo: return ProjectSpecView(title="测试作品", premise="测试前提") +class _NoInjectionRepo: + """空注入覆盖 stub:draft 端点读它得 None(纯自动选择),不打真 DB。""" + + async def get(self, project_id: uuid.UUID, chapter_no: int) -> None: + return None + + def _empty_memory_repos() -> MemoryRepos: return MemoryRepos( outline=_EmptyOutlineRepo(), @@ -92,6 +99,7 @@ def _make_client( from ww_api.main import create_app from ww_api.services.project_deps import ( get_chapter_repo, + get_injection_repo, get_memory_repos, get_project_repo, get_writer_gateway, @@ -106,6 +114,8 @@ def _make_client( app.dependency_overrides[get_chapter_repo] = lambda: chapter_repo app.dependency_overrides[get_memory_repos] = _empty_memory_repos app.dependency_overrides[get_writer_gateway] = lambda: gateway + # draft 端点现读注入覆盖:注空 stub,避免单测打真 DB(无覆盖 → 纯自动选择)。 + app.dependency_overrides[get_injection_repo] = lambda: _NoInjectionRepo() transport = httpx.ASGITransport(app=app) client = httpx.AsyncClient(transport=transport, base_url="http://test") return client, project_repo, chapter_repo, gateway diff --git a/apps/api/ww_api/routers/projects.py b/apps/api/ww_api/routers/projects.py index 3e26475..e286a93 100644 --- a/apps/api/ww_api/routers/projects.py +++ b/apps/api/ww_api/routers/projects.py @@ -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), diff --git a/apps/api/ww_api/schemas/injection.py b/apps/api/ww_api/schemas/injection.py index 7800320..e9e441f 100644 --- a/apps/api/ww_api/schemas/injection.py +++ b/apps/api/ww_api/schemas/injection.py @@ -1,7 +1,9 @@ -"""本章注入透明(B0)的响应 schema(C 扩 / ARCH §3.4、§5.3)。 +"""本章注入透明(B0)的请求/响应 schema(C 扩 / 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="作者强制剔除的实体(回显,供面板恢复)" + ) diff --git a/apps/api/ww_api/services/project_deps.py b/apps/api/ww_api/services/project_deps.py index ee332fd..1a7523b 100644 --- a/apps/api/ww_api/services/project_deps.py +++ b/apps/api/ww_api/services/project_deps.py @@ -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: + """本章注入覆盖读/写 repo(B0 可控版 GET/PUT injection + draft 同读)。 + + upsert 只 flush,端点提交(与其它写侧一致)。测试经 `app.dependency_overrides` 注 fake。 + """ + return SqlInjectionOverrideRepo(session) + + def get_review_repo( session: Annotated[AsyncSession, Depends(get_session)], ) -> ReviewRepo: diff --git a/apps/web/components/workbench/ChapterAssistant.tsx b/apps/web/components/workbench/ChapterAssistant.tsx index c8d2669..1935ad6 100644 --- a/apps/web/components/workbench/ChapterAssistant.tsx +++ b/apps/web/components/workbench/ChapterAssistant.tsx @@ -3,9 +3,13 @@ import Link from "next/link"; import { + isPinned, kindLabel, reasonLabel, + RECENT_N_MAX, + RECENT_N_MIN, type InjectionEntity, + type InjectionEntityRef, } from "@/lib/workbench/injection"; import { useInjection } from "@/lib/workbench/useInjection"; @@ -15,13 +19,10 @@ interface ChapterAssistantProps { } // 右栏「本章助手」(UX §6.3)。 -// 「本章注入(透明)」读 B0 端点,列出确定性选中的设定/角色 + 入选理由徽标—— -// 这是「看到的=写章用的」的信任牌(不变量 #6)。「四审」指向审稿页入口。 +// 「本章注入(透明)」读 B0 端点,列出确定性选中的设定/角色 + 入选理由徽标, +// 并让作者 📌置顶 / ✕排除 / 调近 N 章(可控版)——「看到的=写章用的」信任牌(不变量 #6)。 // 桌面 aside 包裹;移动端经 Workbench 抽屉复用 AssistantContent。 -export function ChapterAssistant({ - projectId, - chapterNo, -}: ChapterAssistantProps) { +export function ChapterAssistant({ projectId, chapterNo }: ChapterAssistantProps) { return (