merge: B0 注入透明可控版 + F1 控件(pin/排除/recent_n override)
This commit is contained in:
@@ -108,6 +108,7 @@ T0.1 monorepo 骨架 ✅ @devops · T0.2 16 MVP 表迁移(无漂移,users st
|
|||||||
|
|
||||||
> 格式:`- [YYYY-MM-DD] @skill 完成/进展 Txx — 一句话结果 + 影响的契约/文件`
|
> 格式:`- [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` 在 `<lg` 触达目录/助手。**实景 390px 复验(browse)**:汉堡显示 + 静态导航 `display:none`、抽屉开合(聚焦 + Esc 关闭)、工具条两行无横向溢出、助手抽屉真注入面板。前端门禁绿:lint/typecheck 干净 · vitest **177**(171→+6) · build OK。无后端/契约变更。文件:`lib/nav/{items,items.test}.ts`(新) · `components/{Drawer,NavDrawer,NavItems}.tsx`(新) · `components/{AppShell,LeftNav}.tsx` · `components/workbench/{Workbench,ChapterList,ChapterAssistant}.tsx`。**UX 计划余项**:B0 可控版(PUT override) + F1 控件 + 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` 在 `<lg` 触达目录/助手。**实景 390px 复验(browse)**:汉堡显示 + 静态导航 `display:none`、抽屉开合(聚焦 + Esc 关闭)、工具条两行无横向溢出、助手抽屉真注入面板。前端门禁绿:lint/typecheck 干净 · vitest **177**(171→+6) · build OK。无后端/契约变更。文件:`lib/nav/{items,items.test}.ts`(新) · `components/{Drawer,NavDrawer,NavItems}.tsx`(新) · `components/{AppShell,LeftNav}.tsx` · `components/workbench/{Workbench,ChapterList,ChapterAssistant}.tsx`。**UX 计划余项**:B0 可控版(PUT override) + F1 控件 + R1/R2/R3 审稿人体学 + Tier4。
|
||||||
- [2026-06-20] UX计划 F3+F4 — **✅ 流式进度感 + 友好错误文案(纯前端)**。F4:新 `apps/web/lib/errors/messages.ts`(`friendlyError(code,fallback)`→中文文案 + 可选「去设置提供商」动作;覆盖后端 `ErrorCode` + 前端 `NETWORK/STREAM_FAILED`;未知码回退原 message;+4 vitest);写作页 `Workbench` 与审稿页 `ReviewReport` 的错误块改用之,不再直接暴露 raw error code。F3:`Workbench` 工具条字数计数器流式时变「生成中… N 字」+ 朱砂 `motion-safe:animate-pulse`(尊重 prefers-reduced-motion),停后回「字数 N」;纯 `text.length`,无后端改动。前端门禁绿:lint/typecheck/vitest **171**/build。文件:`lib/errors/{messages,messages.test}.ts` · `components/workbench/Workbench.tsx` · `components/review/ReviewReport.tsx`。无契约变更。
|
- [2026-06-20] UX计划 F3+F4 — **✅ 流式进度感 + 友好错误文案(纯前端)**。F4:新 `apps/web/lib/errors/messages.ts`(`friendlyError(code,fallback)`→中文文案 + 可选「去设置提供商」动作;覆盖后端 `ErrorCode` + 前端 `NETWORK/STREAM_FAILED`;未知码回退原 message;+4 vitest);写作页 `Workbench` 与审稿页 `ReviewReport` 的错误块改用之,不再直接暴露 raw error code。F3:`Workbench` 工具条字数计数器流式时变「生成中… N 字」+ 朱砂 `motion-safe:animate-pulse`(尊重 prefers-reduced-motion),停后回「字数 N」;纯 `text.length`,无后端改动。前端门禁绿:lint/typecheck/vitest **171**/build。文件:`lib/errors/{messages,messages.test}.ts` · `components/workbench/Workbench.tsx` · `components/review/ReviewReport.tsx`。无契约变更。
|
||||||
- [2026-06-20] UX计划 B0(读)+F1 — **✅ 本章注入透明(读端点 + 右栏真面板)**。兑现差异化信任牌「看到的=写章用的」(不变量 #6):把先前写作页右栏「M1 暂未接 / M2 开放」的过时假占位换成真数据。**后端**(@backend):`GET /projects/{id}/chapters/{no}/injection` → `InjectionResponse{project_id,chapter_no,selected:[{kind,name,reasons[]}],recent_n}`,调既有 `assemble()` 回放确定性 `SelectionTrace`(**无 LLM、无 commit、无 DDL**);项目不存在→404,无大纲→`selected:[]`。文件:`schemas/injection.py`(新) · `routers/projects.py`(+GET+2 import) · `tests/test_injection.py`(新,3 测)。**前端**(@frontend):`gen:api` 纳入端点;纯逻辑 `lib/workbench/injection.ts`(理由/类型→中文徽标 + 4 vitest) + `useInjection` 读 hook;`ChapterAssistant` 改 client 组件列出选中实体 + 入选理由徽标(本章点名/主角常驻/近章出现/伏笔窗口)+ 空/载/错三态,「四审」段改指向审稿页;`Workbench` 传 `projectId/chapterNo`。**全仓门禁绿**:后端 ruff/format/mypy **159 Success**/alembic 无漂移/pytest **454 passed**;前端 lint/typecheck/vitest **167 passed**/build。记 contracts C3扩。**B0 余项(可控版:PUT override pin/排除/recent_n + selection 加参 + draft 同读 override + 持久化)+ F1 可控控件待后续。**
|
- [2026-06-20] UX计划 B0(读)+F1 — **✅ 本章注入透明(读端点 + 右栏真面板)**。兑现差异化信任牌「看到的=写章用的」(不变量 #6):把先前写作页右栏「M1 暂未接 / M2 开放」的过时假占位换成真数据。**后端**(@backend):`GET /projects/{id}/chapters/{no}/injection` → `InjectionResponse{project_id,chapter_no,selected:[{kind,name,reasons[]}],recent_n}`,调既有 `assemble()` 回放确定性 `SelectionTrace`(**无 LLM、无 commit、无 DDL**);项目不存在→404,无大纲→`selected:[]`。文件:`schemas/injection.py`(新) · `routers/projects.py`(+GET+2 import) · `tests/test_injection.py`(新,3 测)。**前端**(@frontend):`gen:api` 纳入端点;纯逻辑 `lib/workbench/injection.ts`(理由/类型→中文徽标 + 4 vitest) + `useInjection` 读 hook;`ChapterAssistant` 改 client 组件列出选中实体 + 入选理由徽标(本章点名/主角常驻/近章出现/伏笔窗口)+ 空/载/错三态,「四审」段改指向审稿页;`Workbench` 传 `projectId/chapterNo`。**全仓门禁绿**:后端 ruff/format/mypy **159 Success**/alembic 无漂移/pytest **454 passed**;前端 lint/typecheck/vitest **167 passed**/build。记 contracts C3扩。**B0 余项(可控版:PUT override pin/排除/recent_n + selection 加参 + draft 同读 override + 持久化)+ F1 可控控件待后续。**
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import httpx
|
|||||||
import pytest
|
import pytest
|
||||||
from fakes_projects import FakeProjectRepo
|
from fakes_projects import FakeProjectRepo
|
||||||
from ww_api.services.credentials import STUB_OWNER_ID
|
from ww_api.services.credentials import STUB_OWNER_ID
|
||||||
|
from ww_core.domain.injection_repo import InjectionOverride
|
||||||
from ww_core.domain.project_repo import ProjectCreate
|
from ww_core.domain.project_repo import ProjectCreate
|
||||||
from ww_core.domain.repositories import (
|
from ww_core.domain.repositories import (
|
||||||
CharacterView,
|
CharacterView,
|
||||||
@@ -81,6 +82,22 @@ class _SpecRepo:
|
|||||||
return ProjectSpecView(title="测试作品", premise="测试前提")
|
return ProjectSpecView(title="测试作品", premise="测试前提")
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeInjectionRepo:
|
||||||
|
"""内存注入覆盖 repo(按 (project_id, chapter_no) 存最后一次 upsert)。"""
|
||||||
|
|
||||||
|
def __init__(self) -> 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(
|
def _memory_repos(
|
||||||
*,
|
*,
|
||||||
outline: dict[int, OutlineView] | None = None,
|
outline: dict[int, OutlineView] | None = None,
|
||||||
@@ -103,16 +120,25 @@ def _make_client(
|
|||||||
*,
|
*,
|
||||||
project_repo: FakeProjectRepo,
|
project_repo: FakeProjectRepo,
|
||||||
memory_repos: MemoryRepos,
|
memory_repos: MemoryRepos,
|
||||||
|
injection_repo: _FakeInjectionRepo | None = None,
|
||||||
) -> httpx.AsyncClient:
|
) -> httpx.AsyncClient:
|
||||||
import os
|
import os
|
||||||
|
|
||||||
os.environ.setdefault("CREDENTIAL_ENC_KEY", "x" * 44)
|
os.environ.setdefault("CREDENTIAL_ENC_KEY", "x" * 44)
|
||||||
|
from fakes_projects import FakeSession
|
||||||
from ww_api.main import create_app
|
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 = create_app()
|
||||||
app.dependency_overrides[get_project_repo] = lambda: project_repo
|
app.dependency_overrides[get_project_repo] = lambda: project_repo
|
||||||
app.dependency_overrides[get_memory_repos] = lambda: memory_repos
|
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)
|
transport = httpx.ASGITransport(app=app)
|
||||||
return httpx.AsyncClient(transport=transport, base_url="http://test")
|
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")
|
resp = await client.get(f"/projects/{pid}/chapters/9/injection")
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
assert resp.json()["selected"] == []
|
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
|
||||||
|
|||||||
@@ -67,6 +67,13 @@ class _StubProjectSpecRepo:
|
|||||||
return ProjectSpecView(title="测试作品", premise="测试前提")
|
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:
|
def _empty_memory_repos() -> MemoryRepos:
|
||||||
return MemoryRepos(
|
return MemoryRepos(
|
||||||
outline=_EmptyOutlineRepo(),
|
outline=_EmptyOutlineRepo(),
|
||||||
@@ -92,6 +99,7 @@ def _make_client(
|
|||||||
from ww_api.main import create_app
|
from ww_api.main import create_app
|
||||||
from ww_api.services.project_deps import (
|
from ww_api.services.project_deps import (
|
||||||
get_chapter_repo,
|
get_chapter_repo,
|
||||||
|
get_injection_repo,
|
||||||
get_memory_repos,
|
get_memory_repos,
|
||||||
get_project_repo,
|
get_project_repo,
|
||||||
get_writer_gateway,
|
get_writer_gateway,
|
||||||
@@ -106,6 +114,8 @@ def _make_client(
|
|||||||
app.dependency_overrides[get_chapter_repo] = lambda: chapter_repo
|
app.dependency_overrides[get_chapter_repo] = lambda: chapter_repo
|
||||||
app.dependency_overrides[get_memory_repos] = _empty_memory_repos
|
app.dependency_overrides[get_memory_repos] = _empty_memory_repos
|
||||||
app.dependency_overrides[get_writer_gateway] = lambda: gateway
|
app.dependency_overrides[get_writer_gateway] = lambda: gateway
|
||||||
|
# draft 端点现读注入覆盖:注空 stub,避免单测打真 DB(无覆盖 → 纯自动选择)。
|
||||||
|
app.dependency_overrides[get_injection_repo] = lambda: _NoInjectionRepo()
|
||||||
transport = httpx.ASGITransport(app=app)
|
transport = httpx.ASGITransport(app=app)
|
||||||
client = httpx.AsyncClient(transport=transport, base_url="http://test")
|
client = httpx.AsyncClient(transport=transport, base_url="http://test")
|
||||||
return client, project_repo, chapter_repo, gateway
|
return client, project_repo, chapter_repo, gateway
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ from fastapi.responses import StreamingResponse
|
|||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from ww_core.domain.chapter_repo import ChapterRepo
|
from ww_core.domain.chapter_repo import ChapterRepo
|
||||||
from ww_core.domain.digest_repo import DigestAppendRepo
|
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.project_repo import ProjectCreate, ProjectRepo
|
||||||
from ww_core.domain.repositories import MemoryRepos
|
from ww_core.domain.repositories import MemoryRepos
|
||||||
from ww_core.domain.review_repo import ReviewRepo
|
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_shared import AppError, ErrorCode
|
||||||
|
|
||||||
from ww_api.logging_config import get_logger
|
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 (
|
from ww_api.schemas.projects import (
|
||||||
AcceptRequest,
|
AcceptRequest,
|
||||||
AcceptResponse,
|
AcceptResponse,
|
||||||
@@ -67,6 +73,7 @@ from ww_api.services.project_deps import (
|
|||||||
get_chapter_repo,
|
get_chapter_repo,
|
||||||
get_digest_append_repo,
|
get_digest_append_repo,
|
||||||
get_digest_gateway,
|
get_digest_gateway,
|
||||||
|
get_injection_repo,
|
||||||
get_memory_repos,
|
get_memory_repos,
|
||||||
get_project_repo,
|
get_project_repo,
|
||||||
get_review_gateway,
|
get_review_gateway,
|
||||||
@@ -85,6 +92,7 @@ GatewayDep = Annotated[Gateway, Depends(get_writer_gateway)]
|
|||||||
ReviewGatewayDep = Annotated[Gateway, Depends(get_review_gateway)]
|
ReviewGatewayDep = Annotated[Gateway, Depends(get_review_gateway)]
|
||||||
DigestGatewayDep = Annotated[Gateway, Depends(get_digest_gateway)]
|
DigestGatewayDep = Annotated[Gateway, Depends(get_digest_gateway)]
|
||||||
MemoryReposDep = Annotated[MemoryRepos, Depends(get_memory_repos)]
|
MemoryReposDep = Annotated[MemoryRepos, Depends(get_memory_repos)]
|
||||||
|
InjectionRepoDep = Annotated[InjectionOverrideRepo, Depends(get_injection_repo)]
|
||||||
ReviewRepoDep = Annotated[ReviewRepo, Depends(get_review_repo)]
|
ReviewRepoDep = Annotated[ReviewRepo, Depends(get_review_repo)]
|
||||||
DigestRepoDep = Annotated[DigestAppendRepo, Depends(get_digest_append_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)
|
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")
|
@router.get("/{project_id}/chapters/{chapter_no}/injection")
|
||||||
async def get_injection(
|
async def get_injection(
|
||||||
project_id: uuid.UUID,
|
project_id: uuid.UUID,
|
||||||
chapter_no: int,
|
chapter_no: int,
|
||||||
repos: MemoryReposDep,
|
repos: MemoryReposDep,
|
||||||
project_repo: ProjectRepoDep,
|
project_repo: ProjectRepoDep,
|
||||||
|
injection_repo: InjectionRepoDep,
|
||||||
) -> InjectionResponse:
|
) -> InjectionResponse:
|
||||||
"""本章注入透明(B0,读端点):回放确定性 SelectionTrace。无 LLM、无 commit。
|
"""本章注入透明(B0,读端点):回放确定性 SelectionTrace + 作者覆盖。无 LLM、无 commit。
|
||||||
|
|
||||||
项目不存在 → 404;无大纲 → selected: [](不报错)。看到的=写章用的(不变量 #6)。
|
项目不存在 → 404;无大纲 → selected: [](不报错)。看到的=写章用的(不变量 #6)。
|
||||||
"""
|
"""
|
||||||
if await project_repo.get(STUB_OWNER_ID, project_id) is None:
|
if await project_repo.get(STUB_OWNER_ID, project_id) is None:
|
||||||
raise AppError(ErrorCode.NOT_FOUND, f"project {project_id} not found")
|
raise AppError(ErrorCode.NOT_FOUND, f"project {project_id} not found")
|
||||||
context = await assemble(repos, project_id, chapter_no)
|
override = await injection_repo.get(project_id, chapter_no)
|
||||||
selected = [
|
resp = await _injection_response(repos, project_id, chapter_no, override)
|
||||||
InjectionEntity(kind=e.kind, name=e.name, reasons=list(e.reasons))
|
|
||||||
for e in context.selection.selected
|
|
||||||
]
|
|
||||||
log.info(
|
log.info(
|
||||||
"injection_read",
|
"injection_read",
|
||||||
project_id=str(project_id),
|
project_id=str(project_id),
|
||||||
chapter_no=chapter_no,
|
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(
|
return resp
|
||||||
project_id=project_id,
|
|
||||||
|
|
||||||
|
@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,
|
chapter_no=chapter_no,
|
||||||
selected=selected,
|
pinned_count=len(saved.pinned),
|
||||||
recent_n=RECENT_DIGEST_COUNT,
|
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:
|
def _encode_sse(event: SseEvent) -> str:
|
||||||
@@ -171,11 +238,13 @@ async def stream_draft(
|
|||||||
request: Request,
|
request: Request,
|
||||||
repos: MemoryReposDep,
|
repos: MemoryReposDep,
|
||||||
gateway: GatewayDep,
|
gateway: GatewayDep,
|
||||||
|
injection_repo: InjectionRepoDep,
|
||||||
session: Annotated[AsyncSession, Depends(get_session)],
|
session: Annotated[AsyncSession, Depends(get_session)],
|
||||||
) -> StreamingResponse:
|
) -> StreamingResponse:
|
||||||
"""流式写章草稿:组装记忆 → 网关流 → 归一为 SSE 事件 → text/event-stream。"""
|
"""流式写章草稿:组装记忆(含作者注入覆盖)→ 网关流 → 归一为 SSE 事件 → text/event-stream。"""
|
||||||
request_id = getattr(request.state, "request_id", None)
|
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(
|
log.info(
|
||||||
"draft_stream_start",
|
"draft_stream_start",
|
||||||
project_id=str(project_id),
|
project_id=str(project_id),
|
||||||
|
|||||||
@@ -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`。
|
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
|
from __future__ import annotations
|
||||||
@@ -10,6 +12,10 @@ import uuid
|
|||||||
|
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
# recent_n 合法区间(回看近况摘要章数)——下限 1,上限防作者误填爆 token。
|
||||||
|
RECENT_N_MIN = 1
|
||||||
|
RECENT_N_MAX = 20
|
||||||
|
|
||||||
|
|
||||||
class InjectionEntity(BaseModel):
|
class InjectionEntity(BaseModel):
|
||||||
"""一个被注入本章上下文的实体 + 入选理由(喂给透明面板的徽标)。"""
|
"""一个被注入本章上下文的实体 + 入选理由(喂给透明面板的徽标)。"""
|
||||||
@@ -18,14 +24,39 @@ class InjectionEntity(BaseModel):
|
|||||||
name: str
|
name: str
|
||||||
reasons: list[str] = Field(
|
reasons: list[str] = Field(
|
||||||
default_factory=list,
|
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):
|
class InjectionResponse(BaseModel):
|
||||||
"""GET /projects/:id/chapters/:no/injection:本章确定性注入留痕。"""
|
"""GET/PUT /projects/:id/chapters/:no/injection:本章注入留痕 + 作者覆盖回显。"""
|
||||||
|
|
||||||
project_id: uuid.UUID
|
project_id: uuid.UUID
|
||||||
chapter_no: int
|
chapter_no: int
|
||||||
selected: list[InjectionEntity] = Field(default_factory=list)
|
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="作者强制剔除的实体(回显,供面板恢复)"
|
||||||
|
)
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ from ww_core.domain import ForeshadowLedgerRepo, SqlForeshadowLedgerRepo
|
|||||||
from ww_core.domain.chapter_repo import ChapterRepo, SqlChapterRepo
|
from ww_core.domain.chapter_repo import ChapterRepo, SqlChapterRepo
|
||||||
from ww_core.domain.character_repo import CharacterWriteRepo, SqlCharacterWriteRepo
|
from ww_core.domain.character_repo import CharacterWriteRepo, SqlCharacterWriteRepo
|
||||||
from ww_core.domain.digest_repo import DigestAppendRepo, SqlDigestAppendRepo
|
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.job_repo import JobRepo, SqlJobRepo
|
||||||
from ww_core.domain.outline_write_repo import OutlineWriteRepo, SqlOutlineWriteRepo
|
from ww_core.domain.outline_write_repo import OutlineWriteRepo, SqlOutlineWriteRepo
|
||||||
from ww_core.domain.project_repo import ProjectRepo, SqlProjectRepo
|
from ww_core.domain.project_repo import ProjectRepo, SqlProjectRepo
|
||||||
@@ -102,6 +103,16 @@ def get_memory_repos(
|
|||||||
return sql_memory_repos(session)
|
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(
|
def get_review_repo(
|
||||||
session: Annotated[AsyncSession, Depends(get_session)],
|
session: Annotated[AsyncSession, Depends(get_session)],
|
||||||
) -> ReviewRepo:
|
) -> ReviewRepo:
|
||||||
|
|||||||
@@ -3,9 +3,13 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
isPinned,
|
||||||
kindLabel,
|
kindLabel,
|
||||||
reasonLabel,
|
reasonLabel,
|
||||||
|
RECENT_N_MAX,
|
||||||
|
RECENT_N_MIN,
|
||||||
type InjectionEntity,
|
type InjectionEntity,
|
||||||
|
type InjectionEntityRef,
|
||||||
} from "@/lib/workbench/injection";
|
} from "@/lib/workbench/injection";
|
||||||
import { useInjection } from "@/lib/workbench/useInjection";
|
import { useInjection } from "@/lib/workbench/useInjection";
|
||||||
|
|
||||||
@@ -15,13 +19,10 @@ interface ChapterAssistantProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 右栏「本章助手」(UX §6.3)。
|
// 右栏「本章助手」(UX §6.3)。
|
||||||
// 「本章注入(透明)」读 B0 端点,列出确定性选中的设定/角色 + 入选理由徽标——
|
// 「本章注入(透明)」读 B0 端点,列出确定性选中的设定/角色 + 入选理由徽标,
|
||||||
// 这是「看到的=写章用的」的信任牌(不变量 #6)。「四审」指向审稿页入口。
|
// 并让作者 📌置顶 / ✕排除 / 调近 N 章(可控版)——「看到的=写章用的」信任牌(不变量 #6)。
|
||||||
// 桌面 aside 包裹;移动端经 Workbench 抽屉复用 AssistantContent。
|
// 桌面 aside 包裹;移动端经 Workbench 抽屉复用 AssistantContent。
|
||||||
export function ChapterAssistant({
|
export function ChapterAssistant({ projectId, chapterNo }: ChapterAssistantProps) {
|
||||||
projectId,
|
|
||||||
chapterNo,
|
|
||||||
}: ChapterAssistantProps) {
|
|
||||||
return (
|
return (
|
||||||
<aside className="hidden border-l border-line bg-panel py-4 lg:block">
|
<aside className="hidden border-l border-line bg-panel py-4 lg:block">
|
||||||
<AssistantContent projectId={projectId} chapterNo={chapterNo} />
|
<AssistantContent projectId={projectId} chapterNo={chapterNo} />
|
||||||
@@ -29,21 +30,34 @@ export function ChapterAssistant({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AssistantContent({
|
export function AssistantContent({ projectId, chapterNo }: ChapterAssistantProps) {
|
||||||
projectId,
|
|
||||||
chapterNo,
|
|
||||||
}: ChapterAssistantProps) {
|
|
||||||
const injection = useInjection(projectId, chapterNo);
|
const injection = useInjection(projectId, chapterNo);
|
||||||
const selected = injection.data?.selected ?? [];
|
const data = injection.data;
|
||||||
|
const selected = data?.selected ?? [];
|
||||||
|
const excluded = data?.excluded ?? [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="px-4">
|
<div className="px-4">
|
||||||
<h2 className="text-sm font-semibold text-ink">本章助手</h2>
|
<div className="flex items-center justify-between">
|
||||||
|
<h2 className="text-sm font-semibold text-ink">本章助手</h2>
|
||||||
|
{injection.saving ? (
|
||||||
|
<span className="text-[10px] text-ink-soft">保存中…</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
<section className="mt-4">
|
<section className="mt-4">
|
||||||
<h3 className="text-xs font-semibold uppercase tracking-wide text-ink-soft">
|
<div className="flex items-center justify-between">
|
||||||
本章注入(透明)
|
<h3 className="text-xs font-semibold uppercase tracking-wide text-ink-soft">
|
||||||
</h3>
|
本章注入(透明)
|
||||||
|
</h3>
|
||||||
|
{data ? (
|
||||||
|
<RecentStepper
|
||||||
|
value={data.recent_n}
|
||||||
|
disabled={injection.saving}
|
||||||
|
onChange={injection.setRecentN}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
{injection.loading ? (
|
{injection.loading ? (
|
||||||
<p className="mt-2 text-xs text-ink-soft">加载注入信息…</p>
|
<p className="mt-2 text-xs text-ink-soft">加载注入信息…</p>
|
||||||
@@ -53,15 +67,43 @@ export function AssistantContent({
|
|||||||
</p>
|
</p>
|
||||||
) : selected.length === 0 ? (
|
) : selected.length === 0 ? (
|
||||||
<p className="mt-2 rounded border border-dashed border-line bg-bg p-3 text-xs text-ink-soft">
|
<p className="mt-2 rounded border border-dashed border-line bg-bg p-3 text-xs text-ink-soft">
|
||||||
本章暂无注入实体(先在大纲点名设定/角色)。
|
本章暂无注入实体(先在大纲点名设定/角色,或从下方排除项恢复)。
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<ul className="mt-2 space-y-2">
|
<ul className="mt-2 space-y-2">
|
||||||
{selected.map((entity) => (
|
{selected.map((entity) => (
|
||||||
<EntityRow key={`${entity.kind}:${entity.name}`} entity={entity} />
|
<EntityRow
|
||||||
|
key={`${entity.kind}:${entity.name}`}
|
||||||
|
entity={entity}
|
||||||
|
pinned={isPinned(
|
||||||
|
{ pinned: data?.pinned, excluded: data?.excluded, recent_n: data?.recent_n },
|
||||||
|
entity,
|
||||||
|
)}
|
||||||
|
disabled={injection.saving}
|
||||||
|
onTogglePin={injection.togglePin}
|
||||||
|
onExclude={injection.exclude}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{excluded.length > 0 ? (
|
||||||
|
<div className="mt-3">
|
||||||
|
<h4 className="text-[10px] uppercase tracking-wide text-ink-soft">
|
||||||
|
已排除(不注入)
|
||||||
|
</h4>
|
||||||
|
<ul className="mt-1 space-y-1">
|
||||||
|
{excluded.map((ref) => (
|
||||||
|
<ExcludedRow
|
||||||
|
key={`${ref.kind}:${ref.name}`}
|
||||||
|
refItem={ref}
|
||||||
|
disabled={injection.saving}
|
||||||
|
onRestore={injection.restore}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="mt-6">
|
<section className="mt-6">
|
||||||
@@ -82,7 +124,16 @@ export function AssistantContent({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function EntityRow({ entity }: { entity: InjectionEntity }) {
|
interface EntityRowProps {
|
||||||
|
entity: InjectionEntity;
|
||||||
|
pinned: boolean;
|
||||||
|
disabled: boolean;
|
||||||
|
onTogglePin: (ref: InjectionEntityRef) => void;
|
||||||
|
onExclude: (ref: InjectionEntityRef) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function EntityRow({ entity, pinned, disabled, onTogglePin, onExclude }: EntityRowProps) {
|
||||||
|
const ref: InjectionEntityRef = { kind: entity.kind, name: entity.name };
|
||||||
return (
|
return (
|
||||||
<li className="rounded border border-line bg-bg p-2">
|
<li className="rounded border border-line bg-bg p-2">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -90,6 +141,32 @@ function EntityRow({ entity }: { entity: InjectionEntity }) {
|
|||||||
{kindLabel(entity.kind)}
|
{kindLabel(entity.kind)}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-sm text-ink">{entity.name}</span>
|
<span className="text-sm text-ink">{entity.name}</span>
|
||||||
|
<div className="ml-auto flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={disabled}
|
||||||
|
aria-pressed={pinned}
|
||||||
|
title={pinned ? "取消置顶" : "置顶(强制注入)"}
|
||||||
|
onClick={() => onTogglePin(ref)}
|
||||||
|
className={`rounded px-1.5 py-0.5 text-[11px] disabled:opacity-50 ${
|
||||||
|
pinned
|
||||||
|
? "bg-[var(--color-cinnabar-wash)] text-cinnabar"
|
||||||
|
: "text-ink-soft hover:bg-panel"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
📌
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={disabled}
|
||||||
|
title="排除(不注入本章)"
|
||||||
|
aria-label={`排除 ${entity.name}`}
|
||||||
|
onClick={() => onExclude(ref)}
|
||||||
|
className="rounded px-1.5 py-0.5 text-[11px] text-ink-soft hover:bg-panel disabled:opacity-50"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-1.5 flex flex-wrap gap-1">
|
<div className="mt-1.5 flex flex-wrap gap-1">
|
||||||
{(entity.reasons ?? []).map((reason) => (
|
{(entity.reasons ?? []).map((reason) => (
|
||||||
@@ -104,3 +181,62 @@ function EntityRow({ entity }: { entity: InjectionEntity }) {
|
|||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ExcludedRowProps {
|
||||||
|
refItem: InjectionEntityRef;
|
||||||
|
disabled: boolean;
|
||||||
|
onRestore: (ref: InjectionEntityRef) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ExcludedRow({ refItem, disabled, onRestore }: ExcludedRowProps) {
|
||||||
|
return (
|
||||||
|
<li className="flex items-center gap-2 rounded border border-dashed border-line px-2 py-1">
|
||||||
|
<span className="text-[10px] text-ink-soft">{kindLabel(refItem.kind)}</span>
|
||||||
|
<span className="text-xs text-ink-soft line-through">{refItem.name}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={disabled}
|
||||||
|
title="恢复(回到自动判定)"
|
||||||
|
onClick={() => onRestore(refItem)}
|
||||||
|
className="ml-auto rounded px-1.5 py-0.5 text-[11px] text-cinnabar hover:bg-[var(--color-cinnabar-wash)] disabled:opacity-50"
|
||||||
|
>
|
||||||
|
↩ 恢复
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RecentStepperProps {
|
||||||
|
value: number;
|
||||||
|
disabled: boolean;
|
||||||
|
onChange: (n: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 「关联近 N 章」步进器:覆盖近况摘要回看章数(钳在 [MIN, MAX])。
|
||||||
|
function RecentStepper({ value, disabled, onChange }: RecentStepperProps) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-1 text-[11px] text-ink-soft">
|
||||||
|
<span>近</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={disabled || value <= RECENT_N_MIN}
|
||||||
|
aria-label="减少关联章数"
|
||||||
|
onClick={() => onChange(value - 1)}
|
||||||
|
className="rounded border border-line px-1.5 leading-tight hover:bg-panel disabled:opacity-40"
|
||||||
|
>
|
||||||
|
−
|
||||||
|
</button>
|
||||||
|
<span className="w-4 text-center tabular-nums text-ink">{value}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={disabled || value >= RECENT_N_MAX}
|
||||||
|
aria-label="增加关联章数"
|
||||||
|
onClick={() => onChange(value + 1)}
|
||||||
|
className="rounded border border-line px-1.5 leading-tight hover:bg-panel disabled:opacity-40"
|
||||||
|
>
|
||||||
|
+
|
||||||
|
</button>
|
||||||
|
<span>章</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
93
apps/web/lib/api/schema.d.ts
vendored
93
apps/web/lib/api/schema.d.ts
vendored
@@ -99,12 +99,19 @@ export interface paths {
|
|||||||
};
|
};
|
||||||
/**
|
/**
|
||||||
* Get Injection
|
* Get Injection
|
||||||
* @description 本章注入透明(B0,读端点):回放确定性 SelectionTrace。无 LLM、无 commit。
|
* @description 本章注入透明(B0,读端点):回放确定性 SelectionTrace + 作者覆盖。无 LLM、无 commit。
|
||||||
*
|
*
|
||||||
* 项目不存在 → 404;无大纲 → selected: [](不报错)。看到的=写章用的(不变量 #6)。
|
* 项目不存在 → 404;无大纲 → selected: [](不报错)。看到的=写章用的(不变量 #6)。
|
||||||
*/
|
*/
|
||||||
get: operations["get_injection_projects__project_id__chapters__chapter_no__injection_get"];
|
get: operations["get_injection_projects__project_id__chapters__chapter_no__injection_get"];
|
||||||
put?: never;
|
/**
|
||||||
|
* Save Injection
|
||||||
|
* @description 本章注入覆盖(B0 可控版,写端点):存 pin/排除/recent_n → 回放新的确定性 selected。
|
||||||
|
*
|
||||||
|
* 项目不存在 → 404。upsert 只 flush,端点末尾 commit。覆盖是确定性输入,draft 端点同读,
|
||||||
|
* 故「看到的=写章用的」仍成立(不变量 #6 的作者兜底)。
|
||||||
|
*/
|
||||||
|
put: operations["save_injection_projects__project_id__chapters__chapter_no__injection_put"];
|
||||||
post?: never;
|
post?: never;
|
||||||
delete?: never;
|
delete?: never;
|
||||||
options?: never;
|
options?: never;
|
||||||
@@ -135,7 +142,7 @@ export interface paths {
|
|||||||
put: operations["save_draft_projects__project_id__chapters__chapter_no__draft_put"];
|
put: operations["save_draft_projects__project_id__chapters__chapter_no__draft_put"];
|
||||||
/**
|
/**
|
||||||
* Stream Draft
|
* Stream Draft
|
||||||
* @description 流式写章草稿:组装记忆 → 网关流 → 归一为 SSE 事件 → text/event-stream。
|
* @description 流式写章草稿:组装记忆(含作者注入覆盖)→ 网关流 → 归一为 SSE 事件 → text/event-stream。
|
||||||
*/
|
*/
|
||||||
post: operations["stream_draft_projects__project_id__chapters__chapter_no__draft_post"];
|
post: operations["stream_draft_projects__project_id__chapters__chapter_no__draft_post"];
|
||||||
delete?: never;
|
delete?: never;
|
||||||
@@ -994,13 +1001,41 @@ export interface components {
|
|||||||
name: string;
|
name: string;
|
||||||
/**
|
/**
|
||||||
* Reasons
|
* Reasons
|
||||||
* @description explicit_beat / main_character / recent_digest / foreshadow_window
|
* @description explicit_beat/main_character/recent_digest/foreshadow_window/author_pin
|
||||||
*/
|
*/
|
||||||
reasons?: string[];
|
reasons?: string[];
|
||||||
};
|
};
|
||||||
|
/**
|
||||||
|
* InjectionEntityRef
|
||||||
|
* @description 作者 pin/排除点名的实体引用(kind + name)。
|
||||||
|
*/
|
||||||
|
InjectionEntityRef: {
|
||||||
|
/**
|
||||||
|
* Kind
|
||||||
|
* @description character / world_entity
|
||||||
|
*/
|
||||||
|
kind: string;
|
||||||
|
/** Name */
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* InjectionOverrideRequest
|
||||||
|
* @description PUT /projects/:id/chapters/:no/injection:作者对本章注入的覆盖。
|
||||||
|
*
|
||||||
|
* pinned 强制纳入(加 author_pin 理由);excluded 强制剔除;recent_n 覆盖近况回看章数
|
||||||
|
* (None=用默认)。空覆盖 = 纯自动选择。
|
||||||
|
*/
|
||||||
|
InjectionOverrideRequest: {
|
||||||
|
/** Pinned */
|
||||||
|
pinned?: components["schemas"]["InjectionEntityRef"][];
|
||||||
|
/** Excluded */
|
||||||
|
excluded?: components["schemas"]["InjectionEntityRef"][];
|
||||||
|
/** Recent N */
|
||||||
|
recent_n?: number | null;
|
||||||
|
};
|
||||||
/**
|
/**
|
||||||
* InjectionResponse
|
* InjectionResponse
|
||||||
* @description GET /projects/:id/chapters/:no/injection:本章确定性注入留痕。
|
* @description GET/PUT /projects/:id/chapters/:no/injection:本章注入留痕 + 作者覆盖回显。
|
||||||
*/
|
*/
|
||||||
InjectionResponse: {
|
InjectionResponse: {
|
||||||
/**
|
/**
|
||||||
@@ -1014,9 +1049,19 @@ export interface components {
|
|||||||
selected?: components["schemas"]["InjectionEntity"][];
|
selected?: components["schemas"]["InjectionEntity"][];
|
||||||
/**
|
/**
|
||||||
* Recent N
|
* Recent N
|
||||||
* @description 近况摘要回看章数(确定性选择的默认参数)
|
* @description 生效的近况回看章数(默认或被 override 覆盖后的值)
|
||||||
*/
|
*/
|
||||||
recent_n: number;
|
recent_n: number;
|
||||||
|
/**
|
||||||
|
* Pinned
|
||||||
|
* @description 作者强制纳入的实体(回显)
|
||||||
|
*/
|
||||||
|
pinned?: components["schemas"]["InjectionEntityRef"][];
|
||||||
|
/**
|
||||||
|
* Excluded
|
||||||
|
* @description 作者强制剔除的实体(回显,供面板恢复)
|
||||||
|
*/
|
||||||
|
excluded?: components["schemas"]["InjectionEntityRef"][];
|
||||||
};
|
};
|
||||||
/**
|
/**
|
||||||
* OAuthDisconnectResponse
|
* OAuthDisconnectResponse
|
||||||
@@ -1689,6 +1734,42 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
save_injection_projects__project_id__chapters__chapter_no__injection_put: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
project_id: string;
|
||||||
|
chapter_no: number;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["InjectionOverrideRequest"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
/** @description Successful Response */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["InjectionResponse"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Validation Error */
|
||||||
|
422: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["HTTPValidationError"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
get_draft_projects__project_id__chapters__chapter_no__draft_get: {
|
get_draft_projects__project_id__chapters__chapter_no__draft_get: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
|
|||||||
@@ -1,6 +1,25 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
import { kindLabel, reasonLabel } from "./injection";
|
import {
|
||||||
|
currentOverride,
|
||||||
|
isPinned,
|
||||||
|
kindLabel,
|
||||||
|
reasonLabel,
|
||||||
|
withExcluded,
|
||||||
|
withPinToggled,
|
||||||
|
withRecentN,
|
||||||
|
withRestored,
|
||||||
|
type InjectionOverrideRequest,
|
||||||
|
type InjectionResponse,
|
||||||
|
} from "./injection";
|
||||||
|
|
||||||
|
const REF = { kind: "character", name: "林动" } as const;
|
||||||
|
|
||||||
|
const emptyOverride = (): InjectionOverrideRequest => ({
|
||||||
|
pinned: [],
|
||||||
|
excluded: [],
|
||||||
|
recent_n: 5,
|
||||||
|
});
|
||||||
|
|
||||||
describe("reasonLabel", () => {
|
describe("reasonLabel", () => {
|
||||||
it("maps each known selection reason to a Chinese badge", () => {
|
it("maps each known selection reason to a Chinese badge", () => {
|
||||||
@@ -8,6 +27,7 @@ describe("reasonLabel", () => {
|
|||||||
expect(reasonLabel("main_character")).toBe("主角常驻");
|
expect(reasonLabel("main_character")).toBe("主角常驻");
|
||||||
expect(reasonLabel("recent_digest")).toBe("近章出现");
|
expect(reasonLabel("recent_digest")).toBe("近章出现");
|
||||||
expect(reasonLabel("foreshadow_window")).toBe("伏笔窗口");
|
expect(reasonLabel("foreshadow_window")).toBe("伏笔窗口");
|
||||||
|
expect(reasonLabel("author_pin")).toBe("作者置顶");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("falls back to the raw code for an unknown reason", () => {
|
it("falls back to the raw code for an unknown reason", () => {
|
||||||
@@ -25,3 +45,59 @@ describe("kindLabel", () => {
|
|||||||
expect(kindLabel("mystery")).toBe("mystery");
|
expect(kindLabel("mystery")).toBe("mystery");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("currentOverride", () => {
|
||||||
|
it("extracts pinned/excluded/recent_n from a response", () => {
|
||||||
|
const data = {
|
||||||
|
project_id: "p",
|
||||||
|
chapter_no: 1,
|
||||||
|
selected: [],
|
||||||
|
recent_n: 3,
|
||||||
|
pinned: [REF],
|
||||||
|
excluded: [{ kind: "world_entity", name: "玄铁" }],
|
||||||
|
} as unknown as InjectionResponse;
|
||||||
|
expect(currentOverride(data)).toEqual({
|
||||||
|
pinned: [REF],
|
||||||
|
excluded: [{ kind: "world_entity", name: "玄铁" }],
|
||||||
|
recent_n: 3,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("withPinToggled", () => {
|
||||||
|
it("pins an unpinned ref and removes it from excluded", () => {
|
||||||
|
const next = withPinToggled(withExcluded(emptyOverride(), REF), REF);
|
||||||
|
expect(isPinned(next, REF)).toBe(true);
|
||||||
|
expect(next.excluded).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("unpins an already-pinned ref", () => {
|
||||||
|
const pinned = withPinToggled(emptyOverride(), REF);
|
||||||
|
const next = withPinToggled(pinned, REF);
|
||||||
|
expect(isPinned(next, REF)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("withExcluded / withRestored", () => {
|
||||||
|
it("excludes a ref, removing it from pinned, then restores it", () => {
|
||||||
|
const pinnedThenExcluded = withExcluded(withPinToggled(emptyOverride(), REF), REF);
|
||||||
|
expect(isPinned(pinnedThenExcluded, REF)).toBe(false);
|
||||||
|
expect(pinnedThenExcluded.excluded).toEqual([REF]);
|
||||||
|
const restored = withRestored(pinnedThenExcluded, REF);
|
||||||
|
expect(restored.excluded).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is idempotent when excluding twice", () => {
|
||||||
|
const once = withExcluded(emptyOverride(), REF);
|
||||||
|
const twice = withExcluded(once, REF);
|
||||||
|
expect(twice.excluded).toEqual([REF]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("withRecentN", () => {
|
||||||
|
it("clamps below the minimum and above the maximum", () => {
|
||||||
|
expect(withRecentN(emptyOverride(), 0).recent_n).toBe(1);
|
||||||
|
expect(withRecentN(emptyOverride(), 999).recent_n).toBe(20);
|
||||||
|
expect(withRecentN(emptyOverride(), 7).recent_n).toBe(7);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -5,13 +5,21 @@ import type { components } from "@/lib/api/schema";
|
|||||||
|
|
||||||
export type InjectionResponse = components["schemas"]["InjectionResponse"];
|
export type InjectionResponse = components["schemas"]["InjectionResponse"];
|
||||||
export type InjectionEntity = components["schemas"]["InjectionEntity"];
|
export type InjectionEntity = components["schemas"]["InjectionEntity"];
|
||||||
|
export type InjectionEntityRef = components["schemas"]["InjectionEntityRef"];
|
||||||
|
export type InjectionOverrideRequest =
|
||||||
|
components["schemas"]["InjectionOverrideRequest"];
|
||||||
|
|
||||||
// SelectionReason → 作者可读徽标(对齐 ARCH §3.4 的四来源)。
|
// recent_n 合法区间(与后端 RECENT_N_MIN/MAX 对齐;越界后端 422)。
|
||||||
|
export const RECENT_N_MIN = 1;
|
||||||
|
export const RECENT_N_MAX = 20;
|
||||||
|
|
||||||
|
// SelectionReason → 作者可读徽标(对齐 ARCH §3.4 的四来源 + 作者置顶)。
|
||||||
export const REASON_LABELS: Record<string, string> = {
|
export const REASON_LABELS: Record<string, string> = {
|
||||||
explicit_beat: "本章点名",
|
explicit_beat: "本章点名",
|
||||||
main_character: "主角常驻",
|
main_character: "主角常驻",
|
||||||
recent_digest: "近章出现",
|
recent_digest: "近章出现",
|
||||||
foreshadow_window: "伏笔窗口",
|
foreshadow_window: "伏笔窗口",
|
||||||
|
author_pin: "作者置顶",
|
||||||
};
|
};
|
||||||
|
|
||||||
// EntityKind → 中文。
|
// EntityKind → 中文。
|
||||||
@@ -28,3 +36,81 @@ export function reasonLabel(reason: string): string {
|
|||||||
export function kindLabel(kind: string): string {
|
export function kindLabel(kind: string): string {
|
||||||
return KIND_LABELS[kind] ?? kind;
|
return KIND_LABELS[kind] ?? kind;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- 作者覆盖(B0 可控版)纯函数:从响应推导当前覆盖 + 不可变地施加单个改动 ----
|
||||||
|
|
||||||
|
const refKey = (r: InjectionEntityRef): string => `${r.kind}:${r.name}`;
|
||||||
|
const sameRef = (a: InjectionEntityRef, b: InjectionEntityRef): boolean =>
|
||||||
|
a.kind === b.kind && a.name === b.name;
|
||||||
|
|
||||||
|
// 从注入响应回显字段抽出当前覆盖(PUT 回放服务端确定结果后驱动 UI)。
|
||||||
|
export function currentOverride(
|
||||||
|
data: InjectionResponse,
|
||||||
|
): InjectionOverrideRequest {
|
||||||
|
return {
|
||||||
|
pinned: [...(data.pinned ?? [])],
|
||||||
|
excluded: [...(data.excluded ?? [])],
|
||||||
|
recent_n: data.recent_n,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isPinned(
|
||||||
|
override: InjectionOverrideRequest,
|
||||||
|
ref: InjectionEntityRef,
|
||||||
|
): boolean {
|
||||||
|
return (override.pinned ?? []).some((r) => sameRef(r, ref));
|
||||||
|
}
|
||||||
|
|
||||||
|
const without = (
|
||||||
|
list: InjectionEntityRef[] | undefined,
|
||||||
|
ref: InjectionEntityRef,
|
||||||
|
): InjectionEntityRef[] => (list ?? []).filter((r) => !sameRef(r, ref));
|
||||||
|
|
||||||
|
// 切换 pin:已置顶 → 取消;否则置顶并从排除列表移除(一个实体不同时 pin+排除)。
|
||||||
|
export function withPinToggled(
|
||||||
|
override: InjectionOverrideRequest,
|
||||||
|
ref: InjectionEntityRef,
|
||||||
|
): InjectionOverrideRequest {
|
||||||
|
const pinnedNow = isPinned(override, ref);
|
||||||
|
return {
|
||||||
|
...override,
|
||||||
|
pinned: pinnedNow
|
||||||
|
? without(override.pinned, ref)
|
||||||
|
: [...(override.pinned ?? []), { kind: ref.kind, name: ref.name }],
|
||||||
|
excluded: without(override.excluded, ref),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 排除:加入排除列表,并从置顶列表移除。重复排除幂等(先去重再加)。
|
||||||
|
export function withExcluded(
|
||||||
|
override: InjectionOverrideRequest,
|
||||||
|
ref: InjectionEntityRef,
|
||||||
|
): InjectionOverrideRequest {
|
||||||
|
return {
|
||||||
|
...override,
|
||||||
|
pinned: without(override.pinned, ref),
|
||||||
|
excluded: [
|
||||||
|
...without(override.excluded, ref),
|
||||||
|
{ kind: ref.kind, name: ref.name },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 恢复被排除的实体(回到纯自动选择判定)。
|
||||||
|
export function withRestored(
|
||||||
|
override: InjectionOverrideRequest,
|
||||||
|
ref: InjectionEntityRef,
|
||||||
|
): InjectionOverrideRequest {
|
||||||
|
return { ...override, excluded: without(override.excluded, ref) };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置近况回看章数(钳到合法区间,避免后端 422)。
|
||||||
|
export function withRecentN(
|
||||||
|
override: InjectionOverrideRequest,
|
||||||
|
n: number,
|
||||||
|
): InjectionOverrideRequest {
|
||||||
|
const clamped = Math.max(RECENT_N_MIN, Math.min(RECENT_N_MAX, Math.round(n)));
|
||||||
|
return { ...override, recent_n: clamped };
|
||||||
|
}
|
||||||
|
|
||||||
|
export { refKey };
|
||||||
|
|||||||
@@ -1,24 +1,40 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
|
||||||
import { api } from "@/lib/api/client";
|
import { api } from "@/lib/api/client";
|
||||||
import type { InjectionResponse } from "./injection";
|
import {
|
||||||
|
currentOverride,
|
||||||
|
withExcluded,
|
||||||
|
withPinToggled,
|
||||||
|
withRecentN,
|
||||||
|
withRestored,
|
||||||
|
type InjectionEntityRef,
|
||||||
|
type InjectionOverrideRequest,
|
||||||
|
type InjectionResponse,
|
||||||
|
} from "./injection";
|
||||||
|
|
||||||
export interface UseInjection {
|
export interface UseInjection {
|
||||||
data: InjectionResponse | null;
|
data: InjectionResponse | null;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
|
saving: boolean;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
|
togglePin: (ref: InjectionEntityRef) => Promise<void>;
|
||||||
|
exclude: (ref: InjectionEntityRef) => Promise<void>;
|
||||||
|
restore: (ref: InjectionEntityRef) => Promise<void>;
|
||||||
|
setRecentN: (n: number) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 本章注入透明(B0 读端点):挂载时拉一次确定性 SelectionTrace。
|
const INJECTION_PATH =
|
||||||
// 只读、无 LLM、无 commit——展示「选了谁/为什么」。失败给可读文案不抛。
|
"/projects/{project_id}/chapters/{chapter_no}/injection" as const;
|
||||||
export function useInjection(
|
|
||||||
projectId: string,
|
// 本章注入透明(B0 可控版):读确定性 SelectionTrace + 作者覆盖;
|
||||||
chapterNo: number,
|
// pin/排除/recent_n 经 PUT 覆盖 → 服务端回放新的确定结果(看到的=写章用的)。
|
||||||
): UseInjection {
|
// 写失败给可读文案、不改本地状态(天然回滚),不抛。
|
||||||
|
export function useInjection(projectId: string, chapterNo: number): UseInjection {
|
||||||
const [data, setData] = useState<InjectionResponse | null>(null);
|
const [data, setData] = useState<InjectionResponse | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -27,10 +43,9 @@ export function useInjection(
|
|||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
void (async () => {
|
void (async () => {
|
||||||
const { data: body, error: err } = await api.GET(
|
const { data: body, error: err } = await api.GET(INJECTION_PATH, {
|
||||||
"/projects/{project_id}/chapters/{chapter_no}/injection",
|
params: { path: { project_id: projectId, chapter_no: chapterNo } },
|
||||||
{ params: { path: { project_id: projectId, chapter_no: chapterNo } } },
|
});
|
||||||
);
|
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
if (err || !body) {
|
if (err || !body) {
|
||||||
setError("注入信息暂不可用");
|
setError("注入信息暂不可用");
|
||||||
@@ -46,5 +61,49 @@ export function useInjection(
|
|||||||
};
|
};
|
||||||
}, [projectId, chapterNo]);
|
}, [projectId, chapterNo]);
|
||||||
|
|
||||||
return { data, loading, error };
|
const save = useCallback(
|
||||||
|
async (override: InjectionOverrideRequest) => {
|
||||||
|
setSaving(true);
|
||||||
|
setError(null);
|
||||||
|
const { data: body, error: err } = await api.PUT(INJECTION_PATH, {
|
||||||
|
params: { path: { project_id: projectId, chapter_no: chapterNo } },
|
||||||
|
body: override,
|
||||||
|
});
|
||||||
|
if (err || !body) {
|
||||||
|
setError("保存注入设置失败,请重试");
|
||||||
|
} else {
|
||||||
|
setData(body); // 以服务端确定结果为准(不变量 #6)。
|
||||||
|
}
|
||||||
|
setSaving(false);
|
||||||
|
},
|
||||||
|
[projectId, chapterNo],
|
||||||
|
);
|
||||||
|
|
||||||
|
const mutate = useCallback(
|
||||||
|
(fn: (o: InjectionOverrideRequest) => InjectionOverrideRequest) =>
|
||||||
|
async (): Promise<void> => {
|
||||||
|
if (!data) return;
|
||||||
|
await save(fn(currentOverride(data)));
|
||||||
|
},
|
||||||
|
[data, save],
|
||||||
|
);
|
||||||
|
|
||||||
|
const togglePin = useCallback(
|
||||||
|
(ref: InjectionEntityRef) => mutate((o) => withPinToggled(o, ref))(),
|
||||||
|
[mutate],
|
||||||
|
);
|
||||||
|
const exclude = useCallback(
|
||||||
|
(ref: InjectionEntityRef) => mutate((o) => withExcluded(o, ref))(),
|
||||||
|
[mutate],
|
||||||
|
);
|
||||||
|
const restore = useCallback(
|
||||||
|
(ref: InjectionEntityRef) => mutate((o) => withRestored(o, ref))(),
|
||||||
|
[mutate],
|
||||||
|
);
|
||||||
|
const setRecentN = useCallback(
|
||||||
|
(n: number) => mutate((o) => withRecentN(o, n))(),
|
||||||
|
[mutate],
|
||||||
|
);
|
||||||
|
|
||||||
|
return { data, loading, saving, error, togglePin, exclude, restore, setRecentN };
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -302,4 +302,6 @@
|
|||||||
|
|
||||||
- [2026-06-20] @backend 扩 C3:新增 **`GET /projects/{project_id}/chapters/{chapter_no}/injection`**(本章注入透明,B0 读端点)→ `InjectionResponse{project_id, chapter_no, selected:[InjectionEntity{kind,name,reasons[]}], recent_n}`(`schemas/injection.py`,snake_case)。实现仅调既有 `assemble()` 回放确定性 `SelectionTrace`——**无 LLM、无 commit、无 DDL**;项目不存在→404,无大纲→`selected:[]`。reasons 取值同 `SelectionReason`(explicit_beat/main_character/recent_digest/foreshadow_window)。→ 影响 @frontend(已 `pnpm gen:api` + `ChapterAssistant` 消费)。**B0 可控版(PUT override + `select_relevant_entities` 加 pinned/excluded/recent_n + draft 端点同读 override + 持久化)尚未实现,届时再扩本契约。**
|
- [2026-06-20] @backend 扩 C3:新增 **`GET /projects/{project_id}/chapters/{chapter_no}/injection`**(本章注入透明,B0 读端点)→ `InjectionResponse{project_id, chapter_no, selected:[InjectionEntity{kind,name,reasons[]}], recent_n}`(`schemas/injection.py`,snake_case)。实现仅调既有 `assemble()` 回放确定性 `SelectionTrace`——**无 LLM、无 commit、无 DDL**;项目不存在→404,无大纲→`selected:[]`。reasons 取值同 `SelectionReason`(explicit_beat/main_character/recent_digest/foreshadow_window)。→ 影响 @frontend(已 `pnpm gen:api` + `ChapterAssistant` 消费)。**B0 可控版(PUT override + `select_relevant_entities` 加 pinned/excluded/recent_n + draft 端点同读 override + 持久化)尚未实现,届时再扩本契约。**
|
||||||
|
|
||||||
|
- [2026-06-20] @backend 再扩 C3(B0 **可控版**,接上条):新增 **`PUT /projects/{project_id}/chapters/{chapter_no}/injection`** ← `InjectionOverrideRequest{pinned:[{kind,name}], excluded:[{kind,name}], recent_n:int|null(1..20)}` → 返回 `InjectionResponse`(同上,**新增回显字段 `pinned`/`excluded`**,`selected[].reasons` 可含新值 **`author_pin`**)。语义:pin 强制纳入并加 `author_pin` 理由 / excluded 强制剔除(**优先于 pin**)/ recent_n 覆盖近况回看章数。GET injection 与 **draft 流式端点**均先读同一覆盖再 `assemble(override=...)`,故「看到的=写章用的」(不变量 #6 作者兜底)。持久化:**新表 `chapter_injection`**(迁移 `ad2c4c663daf`,唯一 `(project_id,chapter_no)`,复用 outline 行已否决);`select_relevant_entities` 加 `pinned/excluded` frozenset 入参、`assemble` 加 `override` 关键字参;新 `domain/injection_repo.py`(`InjectionOverride`/`EntityRef`/`SqlInjectionOverrideRepo`,upsert 只 flush 端点 commit)。→ 影响 @frontend(已 `pnpm gen:api`;F1 加 pin/排除控件 + recent_n 步进器)。
|
||||||
|
|
||||||
- [2026-06-18] @llm 改 C1:`Gateway.run()` 现消费 `LlmRequest.output_schema`——`OpenAICompatAdapter.complete` 在 schema 非空时经 **instructor**(`create_with_completion(response_model=...)`) 取已校验 Pydantic 实例并填 `LlmResponse.parsed`;无 schema 时 `parsed is None`、纯文本路径不变;记账仍 **1 条 usage_ledger**/调用(usage 从 raw completion 提取)。`ProviderResult` 新增 `parsed` 字段。结构化路径经可注入 `StructuredClient` Protocol 注 fake(测试不联网)。→ 影响 T2.2(续审节点可直接 `gateway.run(req).parsed`)、未来所有结构化输出 Agent。
|
- [2026-06-18] @llm 改 C1:`Gateway.run()` 现消费 `LlmRequest.output_schema`——`OpenAICompatAdapter.complete` 在 schema 非空时经 **instructor**(`create_with_completion(response_model=...)`) 取已校验 Pydantic 实例并填 `LlmResponse.parsed`;无 schema 时 `parsed is None`、纯文本路径不变;记账仍 **1 条 usage_ledger**/调用(usage 从 raw completion 提取)。`ProviderResult` 新增 `parsed` 字段。结构化路径经可注入 `StructuredClient` Protocol 注 fake(测试不联网)。→ 影响 T2.2(续审节点可直接 `gateway.run(req).parsed`)、未来所有结构化输出 Agent。
|
||||||
|
|||||||
@@ -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 ----
|
# ---- render_cards ----
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -30,6 +30,12 @@ from ww_core.domain.foreshadow_state import (
|
|||||||
is_overdue,
|
is_overdue,
|
||||||
transition,
|
transition,
|
||||||
)
|
)
|
||||||
|
from ww_core.domain.injection_repo import (
|
||||||
|
EntityRef,
|
||||||
|
InjectionOverride,
|
||||||
|
InjectionOverrideRepo,
|
||||||
|
SqlInjectionOverrideRepo,
|
||||||
|
)
|
||||||
from ww_core.domain.job_repo import (
|
from ww_core.domain.job_repo import (
|
||||||
JobRepo,
|
JobRepo,
|
||||||
JobView,
|
JobView,
|
||||||
@@ -79,6 +85,10 @@ __all__ = [
|
|||||||
"SqlForeshadowLedgerRepo",
|
"SqlForeshadowLedgerRepo",
|
||||||
"ForeshadowStatus",
|
"ForeshadowStatus",
|
||||||
"InvalidTransition",
|
"InvalidTransition",
|
||||||
|
"EntityRef",
|
||||||
|
"InjectionOverride",
|
||||||
|
"InjectionOverrideRepo",
|
||||||
|
"SqlInjectionOverrideRepo",
|
||||||
"JobRepo",
|
"JobRepo",
|
||||||
"JobView",
|
"JobView",
|
||||||
"SqlJobRepo",
|
"SqlJobRepo",
|
||||||
|
|||||||
123
packages/core/ww_core/domain/injection_repo.py
Normal file
123
packages/core/ww_core/domain/injection_repo.py
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
"""本章注入覆盖(B0 可控版)的写/读 Repository(ARCH §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 + name;frozen 便于入 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)
|
||||||
@@ -11,6 +11,7 @@ import json
|
|||||||
import uuid
|
import uuid
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
from ww_core.domain.injection_repo import InjectionOverride
|
||||||
from ww_core.domain.repositories import (
|
from ww_core.domain.repositories import (
|
||||||
CharacterView,
|
CharacterView,
|
||||||
DigestView,
|
DigestView,
|
||||||
@@ -24,8 +25,8 @@ from ww_core.domain.repositories import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
from .render import render_cards
|
from .render import render_cards
|
||||||
from .selection import MAIN_ROLES, RECENT_DIGEST_COUNT, select_relevant_entities
|
from .selection import MAIN_ROLES, RECENT_DIGEST_COUNT, EntityKey, select_relevant_entities
|
||||||
from .types import AssembledContext
|
from .types import AssembledContext, EntityKind
|
||||||
|
|
||||||
# 四级规则合并优先级:global → genre → style → project(越具体越靠后/越优先)
|
# 四级规则合并优先级:global → genre → style → project(越具体越靠后/越优先)
|
||||||
_LEVEL_RANK: dict[str, int] = {"global": 0, "genre": 1, "style": 2, "project": 3}
|
_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)
|
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]:
|
def merge_rules(rules: list[RuleView]) -> list[RuleView]:
|
||||||
"""按 global→genre→style→project 排序(同级按内容排序,确定性)。"""
|
"""按 global→genre→style→project 排序(同级按内容排序,确定性)。"""
|
||||||
return sorted(rules, key=lambda r: (_LEVEL_RANK.get(r.level, 99), r.level, r.content))
|
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,
|
project_id: uuid.UUID,
|
||||||
chapter_no: int,
|
chapter_no: int,
|
||||||
recent_k: int = RECENT_DIGEST_COUNT,
|
recent_k: int = RECENT_DIGEST_COUNT,
|
||||||
|
*,
|
||||||
|
override: InjectionOverride | None = None,
|
||||||
) -> AssembledContext:
|
) -> 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(
|
outline = await repos.outline.get(project_id, chapter_no) or OutlineView(
|
||||||
volume=0, chapter_no=chapter_no
|
volume=0, chapter_no=chapter_no
|
||||||
)
|
)
|
||||||
characters = await repos.character.list_for_project(project_id)
|
characters = await repos.character.list_for_project(project_id)
|
||||||
world_entities = await repos.world_entity.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(
|
selection = select_relevant_entities(
|
||||||
outline=outline,
|
outline=outline,
|
||||||
characters=characters,
|
characters=characters,
|
||||||
world_entities=world_entities,
|
world_entities=world_entities,
|
||||||
recent_digests=recent_digests,
|
recent_digests=recent_digests,
|
||||||
|
pinned=pinned,
|
||||||
|
excluded=excluded,
|
||||||
)
|
)
|
||||||
|
|
||||||
codes = [str(w["code"]) for w in outline.foreshadow_windows if w.get("code")]
|
codes = [str(w["code"]) for w in outline.foreshadow_windows if w.get("code")]
|
||||||
|
|||||||
@@ -23,8 +23,12 @@ _REASON_RANK: dict[SelectionReason, int] = {
|
|||||||
"main_character": 1,
|
"main_character": 1,
|
||||||
"recent_digest": 2,
|
"recent_digest": 2,
|
||||||
"foreshadow_window": 3,
|
"foreshadow_window": 3,
|
||||||
|
"author_pin": 4,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# 实体引用键(kind, name)——pin/排除集合用,确定性、可 hash。
|
||||||
|
EntityKey = tuple[EntityKind, str]
|
||||||
|
|
||||||
|
|
||||||
def _flatten_strings(obj: Any) -> list[str]:
|
def _flatten_strings(obj: Any) -> list[str]:
|
||||||
"""递归收集任意 JSON 结构里的字符串叶子(确定性、用于按名匹配)。"""
|
"""递归收集任意 JSON 结构里的字符串叶子(确定性、用于按名匹配)。"""
|
||||||
@@ -78,8 +82,15 @@ def select_relevant_entities(
|
|||||||
characters: list[CharacterView],
|
characters: list[CharacterView],
|
||||||
world_entities: list[WorldEntityView],
|
world_entities: list[WorldEntityView],
|
||||||
recent_digests: list[DigestView],
|
recent_digests: list[DigestView],
|
||||||
|
pinned: frozenset[EntityKey] = frozenset(),
|
||||||
|
excluded: frozenset[EntityKey] = frozenset(),
|
||||||
) -> SelectionTrace:
|
) -> SelectionTrace:
|
||||||
"""确定性并集选择 → SelectionTrace(每实体带去重、排序的入选理由)。"""
|
"""确定性并集选择 → SelectionTrace(每实体带去重、排序的入选理由)。
|
||||||
|
|
||||||
|
作者覆盖(B0 可控版):`excluded` 强制剔除(优先于一切,即便自动命中也不注入);
|
||||||
|
`pinned` 强制纳入并加 `author_pin` 理由(即便零自动理由也注入)。excluded 优先于 pinned,
|
||||||
|
因为「显式排除」是更强的作者意图(同一实体不应同时出现在两个集合,前端保证)。
|
||||||
|
"""
|
||||||
beat_listed, beat_text = _explicit_names(outline.beats)
|
beat_listed, beat_text = _explicit_names(outline.beats)
|
||||||
digest_listed, digest_text = _digest_names(recent_digests)
|
digest_listed, digest_text = _digest_names(recent_digests)
|
||||||
window_names = _window_entity_names(outline)
|
window_names = _window_entity_names(outline)
|
||||||
@@ -96,17 +107,23 @@ def select_relevant_entities(
|
|||||||
reasons.append("recent_digest")
|
reasons.append("recent_digest")
|
||||||
if name in window_names:
|
if name in window_names:
|
||||||
reasons.append("foreshadow_window")
|
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:
|
for c in characters:
|
||||||
reasons = reasons_for(c.name, is_main=(c.role or "") in MAIN_ROLES)
|
collect("character", c.name, is_main=(c.role or "") in MAIN_ROLES)
|
||||||
if reasons:
|
|
||||||
selected.append(SelectedEntity(kind="character", name=c.name, reasons=reasons))
|
|
||||||
|
|
||||||
for w in world_entities:
|
for w in world_entities:
|
||||||
reasons = reasons_for(w.name, is_main=False)
|
collect("world_entity", w.name, is_main=False)
|
||||||
if reasons:
|
|
||||||
selected.append(SelectedEntity(kind="world_entity", name=w.name, reasons=reasons))
|
|
||||||
|
|
||||||
selected.sort(key=lambda e: (_KIND_RANK[e.kind], e.name))
|
selected.sort(key=lambda e: (_KIND_RANK[e.kind], e.name))
|
||||||
return SelectionTrace(selected=selected)
|
return SelectionTrace(selected=selected)
|
||||||
|
|||||||
@@ -17,14 +17,17 @@ SelectionReason = Literal[
|
|||||||
"main_character", # 主角/核心常驻
|
"main_character", # 主角/核心常驻
|
||||||
"recent_digest", # 近 N 章摘要出现
|
"recent_digest", # 近 N 章摘要出现
|
||||||
"foreshadow_window", # 命中本章伏笔窗口
|
"foreshadow_window", # 命中本章伏笔窗口
|
||||||
|
"author_pin", # 作者手动置顶(B0 可控版 override 强制纳入)
|
||||||
]
|
]
|
||||||
|
|
||||||
# 理由的规范排序(保证 SelectionTrace 确定性)
|
# 理由的规范排序(保证 SelectionTrace 确定性)。author_pin 排末位:
|
||||||
|
# 它是作者覆盖的来源,区别于前四个确定性来源(不变量 #6 的作者兜底,ARCH §10)。
|
||||||
REASON_ORDER: tuple[SelectionReason, ...] = (
|
REASON_ORDER: tuple[SelectionReason, ...] = (
|
||||||
"explicit_beat",
|
"explicit_beat",
|
||||||
"main_character",
|
"main_character",
|
||||||
"recent_digest",
|
"recent_digest",
|
||||||
"foreshadow_window",
|
"foreshadow_window",
|
||||||
|
"author_pin",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
"""T_B0 chapter_injection override table
|
||||||
|
|
||||||
|
Revision ID: ad2c4c663daf
|
||||||
|
Revises: 1f011c42bd4d
|
||||||
|
Create Date: 2026-06-20 11:51:00.494169
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
revision: str = "ad2c4c663daf"
|
||||||
|
down_revision: str | None = "1f011c42bd4d"
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.create_table(
|
||||||
|
"chapter_injection",
|
||||||
|
sa.Column("project_id", sa.Uuid(), nullable=False),
|
||||||
|
sa.Column("chapter_no", sa.Integer(), nullable=False),
|
||||||
|
sa.Column(
|
||||||
|
"pinned",
|
||||||
|
postgresql.JSONB(astext_type=sa.Text()),
|
||||||
|
server_default=sa.text("'[]'"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"excluded",
|
||||||
|
postgresql.JSONB(astext_type=sa.Text()),
|
||||||
|
server_default=sa.text("'[]'"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column("recent_n", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("id", sa.Uuid(), server_default=sa.text("gen_random_uuid()"), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
|
||||||
|
sa.Column("created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(["project_id"], ["projects.id"], ondelete="CASCADE"),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
sa.UniqueConstraint("project_id", "chapter_no"),
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
op.f("ix_chapter_injection_project_id"), "chapter_injection", ["project_id"], unique=False
|
||||||
|
)
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_index(op.f("ix_chapter_injection_project_id"), table_name="chapter_injection")
|
||||||
|
op.drop_table("chapter_injection")
|
||||||
|
# ### end Alembic commands ###
|
||||||
@@ -152,6 +152,24 @@ class Chapter(UuidPk, CreatedAt, Base):
|
|||||||
version: Mapped[int] = mapped_column(Integer, nullable=False, server_default=text("1"))
|
version: Mapped[int] = mapped_column(Integer, nullable=False, server_default=text("1"))
|
||||||
|
|
||||||
|
|
||||||
|
class ChapterInjection(UuidPk, TimestampedMixin, Base):
|
||||||
|
"""本章注入覆盖(B0 可控版):作者对确定性自动选择的微调,每章一行。
|
||||||
|
|
||||||
|
与 `outline.beats` 分表存放(复用 outline 行会污染 beats/prompt,已否决)。
|
||||||
|
pinned/excluded 为 JSONB list[{kind,name}];recent_n 覆盖近况回看章数(NULL=用默认)。
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "chapter_injection"
|
||||||
|
__table_args__ = (UniqueConstraint("project_id", "chapter_no"),)
|
||||||
|
project_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
ForeignKey("projects.id", ondelete="CASCADE"), nullable=False, index=True
|
||||||
|
)
|
||||||
|
chapter_no: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||||
|
pinned: Mapped[list[Any]] = mapped_column(JSONB, server_default=text("'[]'"))
|
||||||
|
excluded: Mapped[list[Any]] = mapped_column(JSONB, server_default=text("'[]'"))
|
||||||
|
recent_n: Mapped[int | None] = mapped_column(Integer)
|
||||||
|
|
||||||
|
|
||||||
class ChapterReview(UuidPk, CreatedAt, Base):
|
class ChapterReview(UuidPk, CreatedAt, Base):
|
||||||
__tablename__ = "chapter_reviews"
|
__tablename__ = "chapter_reviews"
|
||||||
project_id: Mapped[uuid.UUID] = mapped_column(
|
project_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
|||||||
Reference in New Issue
Block a user