fix(qa): 实施 4 组设计型 QA 项——规则删除/codex 角色/大纲卷过滤/文风回炉锚点
#5 规则 DELETE + id:暴露 RuleView.id(PK);新增 DELETE /projects/{id}/rules/{rule_id} (项目/规则不存在→404,成功→204,按 (id,project_id) 限定);rule_repo 加 list_for_project/delete;RulesPage 每条加删除(乐观删+回滚+toast)。assemble 侧 RuleView(缓存前缀)不动,列表另立 RuleListItemView。 #7 codex 角色 relations:写侧本已持久化、读端点 _existing_characters 硬编码 []。 加 _relations_from_jsonb 解析 {name,kind,note},CodexPage 渲染关系 chip。 #8 角色入库幂等:SqlCharacterWriteRepo.create 改 (project_id,name) app 层 upsert—— 重复入库改更新而非插入;不加 UNIQUE/迁移(线上已有重复行会让约束迁移失败)。 #1 大纲卷过滤:GET /outline 支持可选 ?volume(无参=全部,向后兼容);OutlineEditor 加「查看:全部/卷N」筛选,与生成目标卷解耦。 H3/#9 文风回炉锚点:StyleDriftSegment 加 text(逐字命中段),style.md 指示审稿输出; 前端按内容锚点定位回炉目标(idx 仅排序),命中失败 → 提示「无法定位该段」而非 静默 no-op。style golden fixture 已重生成。 契约变更已 pnpm gen:api(RuleView.id / DELETE rules / outline ?volume)。无迁移 (alembic 无漂移)。门禁绿:ruff/mypy(210)/alembic/pytest 760 · 前端 tsc/lint/vitest 329。
This commit is contained in:
@@ -30,7 +30,7 @@ from ww_agents import (
|
||||
)
|
||||
from ww_core.domain.character_repo import CharacterWriteView
|
||||
from ww_core.domain.project_repo import ProjectCreate
|
||||
from ww_core.domain.repositories import RuleView
|
||||
from ww_core.domain.rule_repo import RuleListItemView
|
||||
from ww_llm_gateway.types import LlmRequest, LlmResponse, ServedBy, Usage
|
||||
from ww_shared import AppError, ErrorCode
|
||||
from ww_skills import SkillRegistry
|
||||
@@ -81,8 +81,27 @@ class _FakeCharacterWriteRepo:
|
||||
tags: list[Any],
|
||||
relations: list[dict[str, Any]],
|
||||
) -> CharacterWriteView:
|
||||
# 幂等:按 (project_id, name) upsert——同名更新既有行而非插重复(镜像 Sql 实现)。
|
||||
existing = next(
|
||||
(r for r in self.rows if r["project_id"] == project_id and r["name"] == name),
|
||||
None,
|
||||
)
|
||||
if existing is not None:
|
||||
existing.update(
|
||||
{
|
||||
"role": role,
|
||||
"traits": list(traits),
|
||||
"arc": arc,
|
||||
"speech_tics": list(speech_tics),
|
||||
"tags": list(tags),
|
||||
"relations": [dict(r) for r in relations],
|
||||
}
|
||||
)
|
||||
return CharacterWriteView(id=existing["id"], name=name, role=role)
|
||||
row_id = uuid.uuid4()
|
||||
self.rows.append(
|
||||
{
|
||||
"id": row_id,
|
||||
"project_id": project_id,
|
||||
"name": name,
|
||||
"role": role,
|
||||
@@ -93,14 +112,16 @@ class _FakeCharacterWriteRepo:
|
||||
"relations": [dict(r) for r in relations],
|
||||
}
|
||||
)
|
||||
return CharacterWriteView(id=uuid.uuid4(), name=name, role=role)
|
||||
return CharacterWriteView(id=row_id, name=name, role=role)
|
||||
|
||||
|
||||
class _FakeRulesReadRepo:
|
||||
def __init__(self, rules: list[RuleView] | None = None) -> None:
|
||||
"""实现规则 repo 的读侧 `list_for_project`(带 id,供 list_rules 端点)。"""
|
||||
|
||||
def __init__(self, rules: list[RuleListItemView] | None = None) -> None:
|
||||
self._rules = rules or []
|
||||
|
||||
async def all_for_project(self, project_id: uuid.UUID) -> list[RuleView]:
|
||||
async def list_for_project(self, project_id: uuid.UUID) -> list[RuleListItemView]:
|
||||
return list(self._rules)
|
||||
|
||||
|
||||
@@ -170,7 +191,7 @@ def _make_app(
|
||||
get_memory_repos,
|
||||
get_precheck_gateway,
|
||||
get_project_repo,
|
||||
get_rules_read_repo,
|
||||
get_rule_write_repo,
|
||||
get_skill_registry,
|
||||
get_worldbuilder_gateway,
|
||||
)
|
||||
@@ -190,7 +211,7 @@ def _make_app(
|
||||
(lambda: memory) if memory is not None else _empty_memory_repos
|
||||
)
|
||||
app.dependency_overrides[get_character_write_repo] = lambda: char_repo
|
||||
app.dependency_overrides[get_rules_read_repo] = lambda: rules_repo
|
||||
app.dependency_overrides[get_rule_write_repo] = lambda: rules_repo
|
||||
app.dependency_overrides[get_skill_registry] = lambda: registry
|
||||
app.dependency_overrides[get_session] = lambda: session
|
||||
gw = _raise_no_creds if no_creds else (lambda: gateway)
|
||||
@@ -370,6 +391,118 @@ async def test_ingest_characters_acknowledged_conflict_writes() -> None:
|
||||
assert len(char_repo.rows) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ingest_same_name_twice_updates_not_duplicates() -> None:
|
||||
# Arrange:同一项目、同名角色入库两次,第二次字段不同。
|
||||
repo = FakeProjectRepo()
|
||||
pid = await _seed_project(repo)
|
||||
gateway = _SchemaRoutingGateway({ContinuityReview: _no_conflicts()})
|
||||
char_repo = _FakeCharacterWriteRepo()
|
||||
app, _ = _make_app(project_repo=repo, gateway=gateway, char_repo=char_repo)
|
||||
|
||||
def _payload(role: str, trait: str) -> dict[str, Any]:
|
||||
return {
|
||||
"cards": [
|
||||
{
|
||||
"name": "叶寒",
|
||||
"role": role,
|
||||
"traits": [trait],
|
||||
"backstory": "孤儿",
|
||||
"arc": "成长",
|
||||
"speech_tics": [],
|
||||
"tags": [],
|
||||
"relations": [],
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
async with _client(app) as client:
|
||||
# Act:先入主角/腹黑,再以同名入对手/隐忍。
|
||||
first = await client.post(f"/projects/{pid}/characters", json=_payload("主角", "腹黑"))
|
||||
second = await client.post(f"/projects/{pid}/characters", json=_payload("对手", "隐忍"))
|
||||
|
||||
# Assert:仍是一行(按 name upsert),字段被更新而非新增重复。
|
||||
assert first.status_code == 201
|
||||
assert second.status_code == 201
|
||||
assert len(char_repo.rows) == 1
|
||||
assert char_repo.rows[0]["role"] == "对手"
|
||||
assert char_repo.rows[0]["traits"] == ["隐忍"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ingest_relations_persist_on_write() -> None:
|
||||
# Arrange:入库带关系网的角色卡。
|
||||
repo = FakeProjectRepo()
|
||||
pid = await _seed_project(repo)
|
||||
gateway = _SchemaRoutingGateway({ContinuityReview: _no_conflicts()})
|
||||
char_repo = _FakeCharacterWriteRepo()
|
||||
app, _ = _make_app(project_repo=repo, gateway=gateway, char_repo=char_repo)
|
||||
|
||||
relations = [{"name": "苏离", "kind": "宿敌", "note": "灭门之仇"}]
|
||||
async with _client(app) as client:
|
||||
resp = await client.post(
|
||||
f"/projects/{pid}/characters",
|
||||
json={
|
||||
"cards": [
|
||||
{
|
||||
"name": "叶寒",
|
||||
"role": "主角",
|
||||
"traits": ["腹黑"],
|
||||
"backstory": "孤儿",
|
||||
"arc": "成长",
|
||||
"speech_tics": [],
|
||||
"tags": [],
|
||||
"relations": relations,
|
||||
}
|
||||
]
|
||||
},
|
||||
)
|
||||
|
||||
# Assert:relations 持久化(写侧未丢)。
|
||||
assert resp.status_code == 201
|
||||
assert char_repo.rows[0]["relations"] == relations
|
||||
|
||||
|
||||
def test_relations_from_jsonb_parses_and_skips_dirty() -> None:
|
||||
# Arrange:混入脏条目(非 dict / 缺 name / 缺 kind)。
|
||||
from ww_api.routers.generation import _relations_from_jsonb
|
||||
|
||||
raw = [
|
||||
{"name": "苏离", "kind": "宿敌", "note": "灭门之仇"},
|
||||
{"name": "无类型"}, # 缺 kind → 跳过
|
||||
{"kind": "无名"}, # 缺 name → 跳过
|
||||
"not-a-dict", # 非 dict → 跳过
|
||||
{"name": "墨白", "kind": "师徒"}, # note 可缺
|
||||
]
|
||||
# Act
|
||||
out = _relations_from_jsonb(raw)
|
||||
# Assert:只保留有效两条,note 缺为 None。
|
||||
assert [(r.name, r.kind, r.note) for r in out] == [
|
||||
("苏离", "宿敌", "灭门之仇"),
|
||||
("墨白", "师徒", None),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_characters_includes_relations() -> None:
|
||||
# Arrange:读侧 memory view 带 relations(JSONB list)。
|
||||
repo = FakeProjectRepo()
|
||||
pid = await _seed_project(repo)
|
||||
app, _ = _make_app(
|
||||
project_repo=repo,
|
||||
gateway=_SchemaRoutingGateway({}),
|
||||
memory=_codex_memory_with_relations(),
|
||||
)
|
||||
|
||||
async with _client(app) as client:
|
||||
resp = await client.get(f"/projects/{pid}/characters")
|
||||
|
||||
# Assert:读端点不再丢 relations(修 #7)。
|
||||
assert resp.status_code == 200
|
||||
card = resp.json()["characters"][0]
|
||||
assert card["relations"] == [{"name": "苏离", "kind": "宿敌", "note": "灭门之仇"}]
|
||||
|
||||
|
||||
# ---- 读端点 ----
|
||||
|
||||
|
||||
@@ -377,10 +510,11 @@ async def test_ingest_characters_acknowledged_conflict_writes() -> None:
|
||||
async def test_list_rules_returns_rules() -> None:
|
||||
repo = FakeProjectRepo()
|
||||
pid = await _seed_project(repo)
|
||||
rid1, rid2 = uuid.uuid4(), uuid.uuid4()
|
||||
rules_repo = _FakeRulesReadRepo(
|
||||
[
|
||||
RuleView(level="project", content="主角不复活"),
|
||||
RuleView(level="global", content="无脏话"),
|
||||
RuleListItemView(id=rid1, level="project", content="主角不复活"),
|
||||
RuleListItemView(id=rid2, level="global", content="无脏话"),
|
||||
]
|
||||
)
|
||||
gateway = _SchemaRoutingGateway({})
|
||||
@@ -392,6 +526,8 @@ async def test_list_rules_returns_rules() -> None:
|
||||
assert resp.status_code == 200
|
||||
rules = resp.json()["rules"]
|
||||
assert [r["content"] for r in rules] == ["主角不复活", "无脏话"]
|
||||
# 列表带稳定 id(前端删除 handle)。
|
||||
assert [r["id"] for r in rules] == [str(rid1), str(rid2)]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -466,6 +602,53 @@ def _codex_memory() -> Any:
|
||||
)
|
||||
|
||||
|
||||
def _codex_memory_with_relations() -> Any:
|
||||
"""MemoryRepos:角色行携 relations(JSONB list),验读端点还原关系网(修 #7)。"""
|
||||
from test_projects import (
|
||||
_EmptyDigestRepo,
|
||||
_EmptyForeshadowRepo,
|
||||
_EmptyOutlineRepo,
|
||||
_EmptyRulesRepo,
|
||||
_EmptyStyleRepo,
|
||||
_StubProjectSpecRepo,
|
||||
)
|
||||
from ww_core.domain.repositories import (
|
||||
CharacterView,
|
||||
MemoryRepos,
|
||||
WorldEntityView,
|
||||
)
|
||||
|
||||
class _CharRepo:
|
||||
async def list_for_project(self, project_id: uuid.UUID) -> list[CharacterView]:
|
||||
return [
|
||||
CharacterView(
|
||||
name="叶寒",
|
||||
role="主角",
|
||||
traits={"items": ["腹黑"]},
|
||||
backstory="孤儿出身",
|
||||
arc={"text": "成长"},
|
||||
speech_tics={"items": []},
|
||||
tags=[],
|
||||
relations=[{"name": "苏离", "kind": "宿敌", "note": "灭门之仇"}],
|
||||
)
|
||||
]
|
||||
|
||||
class _WorldRepo:
|
||||
async def list_for_project(self, project_id: uuid.UUID) -> list[WorldEntityView]:
|
||||
return []
|
||||
|
||||
return MemoryRepos(
|
||||
outline=_EmptyOutlineRepo(),
|
||||
character=_CharRepo(),
|
||||
world_entity=_WorldRepo(),
|
||||
digest=_EmptyDigestRepo(),
|
||||
foreshadow=_EmptyForeshadowRepo(),
|
||||
style=_EmptyStyleRepo(),
|
||||
rules=_EmptyRulesRepo(),
|
||||
project=_StubProjectSpecRepo(),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_characters_unpacks_jsonb_to_api_shape() -> None:
|
||||
repo = FakeProjectRepo()
|
||||
|
||||
@@ -239,6 +239,31 @@ async def test_get_outline_returns_persisted_chapters_in_order_with_unpacked_bea
|
||||
assert body["chapters"][1]["beats"] == ["冲突升级"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_outline_with_volume_filters_to_that_volume() -> None:
|
||||
# ?volume=N 只返回该卷章节;无该参数返回全部(向后兼容)。
|
||||
project_repo = FakeProjectRepo()
|
||||
pid = await _seed_project(project_repo)
|
||||
read_repo = FakeOutlineReadRepo()
|
||||
read_repo.add_chapter(pid, volume=1, chapter_no=1, beats=["卷一·开篇"])
|
||||
read_repo.add_chapter(pid, volume=1, chapter_no=2, beats=["卷一·冲突"])
|
||||
read_repo.add_chapter(pid, volume=2, chapter_no=3, beats=["卷二·新篇"])
|
||||
client = _make_read_client(project_repo=project_repo, outline_read_repo=read_repo)
|
||||
|
||||
async with client:
|
||||
all_resp = await client.get(f"/projects/{pid}/outline")
|
||||
vol2_resp = await client.get(f"/projects/{pid}/outline?volume=2")
|
||||
|
||||
# 不带 volume → 全部三章。
|
||||
assert all_resp.status_code == 200
|
||||
assert [c["no"] for c in all_resp.json()["chapters"]] == [1, 2, 3]
|
||||
# ?volume=2 → 仅卷二的第 3 章。
|
||||
assert vol2_resp.status_code == 200
|
||||
vol2_chapters = vol2_resp.json()["chapters"]
|
||||
assert [c["no"] for c in vol2_chapters] == [3]
|
||||
assert all(c["volume"] == 2 for c in vol2_chapters)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_outline_returns_empty_list_when_no_outline() -> None:
|
||||
project_repo = FakeProjectRepo()
|
||||
|
||||
@@ -17,7 +17,7 @@ from cryptography.fernet import Fernet
|
||||
from fakes_projects import FakeProjectRepo, FakeSession
|
||||
from ww_api.services.credentials import STUB_OWNER_ID
|
||||
from ww_core.domain.project_repo import ProjectView
|
||||
from ww_core.domain.rule_repo import RuleWriteView
|
||||
from ww_core.domain.rule_repo import RuleListItemView, RuleWriteView
|
||||
|
||||
|
||||
class _FakeRuleWriteRepo:
|
||||
@@ -25,10 +25,22 @@ class _FakeRuleWriteRepo:
|
||||
self.rows: list[RuleWriteView] = []
|
||||
|
||||
async def create(self, project_id: uuid.UUID, *, level: str, content: str) -> RuleWriteView:
|
||||
view = RuleWriteView(project_id=project_id, level=level, content=content)
|
||||
view = RuleWriteView(id=uuid.uuid4(), project_id=project_id, level=level, content=content)
|
||||
self.rows.append(view)
|
||||
return view
|
||||
|
||||
async def list_for_project(self, project_id: uuid.UUID) -> list[RuleListItemView]:
|
||||
return [
|
||||
RuleListItemView(id=r.id, level=r.level, content=r.content)
|
||||
for r in self.rows
|
||||
if r.project_id in (project_id, None)
|
||||
]
|
||||
|
||||
async def delete(self, project_id: uuid.UUID, rule_id: uuid.UUID) -> bool:
|
||||
before = len(self.rows)
|
||||
self.rows = [r for r in self.rows if not (r.id == rule_id and r.project_id == project_id)]
|
||||
return len(self.rows) < before
|
||||
|
||||
|
||||
def _make_client() -> tuple[
|
||||
httpx.AsyncClient, _FakeRuleWriteRepo, FakeSession, FakeProjectRepo, uuid.UUID
|
||||
@@ -72,6 +84,8 @@ async def test_create_rule_returns_201_and_commits() -> None:
|
||||
body = resp.json()
|
||||
assert body["level"] == "project"
|
||||
assert body["content"] == "主角不许中途复活"
|
||||
# 创建回显带 id(前端删除 handle)。
|
||||
assert body["id"] == str(repo.rows[0].id)
|
||||
assert session.commits == 1
|
||||
assert len(repo.rows) == 1
|
||||
assert repo.rows[0].project_id == pid
|
||||
@@ -124,3 +138,37 @@ async def test_create_rule_unknown_project_returns_404() -> None:
|
||||
assert resp.status_code == 404
|
||||
assert resp.json()["error"]["code"] == "NOT_FOUND"
|
||||
assert len(repo.rows) == 0 # 未触达写库
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_rule_returns_204_and_commits() -> None:
|
||||
# 作者显式删一条本作品规则(不变量 #3):204 + 端点提交 + 行被移除。
|
||||
client, repo, session, _project_repo, pid = _make_client()
|
||||
seeded = await repo.create(pid, level="project", content="待删规则")
|
||||
async with client:
|
||||
resp = await client.delete(f"/projects/{pid}/rules/{seeded.id}")
|
||||
assert resp.status_code == 204
|
||||
assert session.commits == 1
|
||||
assert len(repo.rows) == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_rule_unknown_rule_returns_404() -> None:
|
||||
# 未知 rule_id → 404;不提交(删不到行)。
|
||||
client, repo, session, _project_repo, pid = _make_client()
|
||||
async with client:
|
||||
resp = await client.delete(f"/projects/{pid}/rules/{uuid.uuid4()}")
|
||||
assert resp.status_code == 404
|
||||
assert resp.json()["error"]["code"] == "NOT_FOUND"
|
||||
assert session.commits == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_rule_unknown_project_returns_404() -> None:
|
||||
# 项目不存在 → 404(先于 rule 查校验),不触达删除。
|
||||
client, repo, session, _project_repo, _pid = _make_client()
|
||||
async with client:
|
||||
resp = await client.delete(f"/projects/{uuid.uuid4()}/rules/{uuid.uuid4()}")
|
||||
assert resp.status_code == 404
|
||||
assert resp.json()["error"]["code"] == "NOT_FOUND"
|
||||
assert session.commits == 0
|
||||
|
||||
@@ -103,7 +103,7 @@ class _FakeRuleWriteRepo:
|
||||
|
||||
async def create(self, project_id: uuid.UUID, *, level: str, content: str) -> RuleWriteView:
|
||||
self.rows.append({"project_id": project_id, "level": level, "content": content})
|
||||
return RuleWriteView(project_id=project_id, level=level, content=content)
|
||||
return RuleWriteView(id=uuid.uuid4(), project_id=project_id, level=level, content=content)
|
||||
|
||||
|
||||
class _FakeOutlineReadRepo:
|
||||
|
||||
@@ -19,7 +19,7 @@ ledger,否则 usage 静默丢失,同 draft/review 纪律)。无凭据 →
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from typing import Annotated
|
||||
from typing import Annotated, Any
|
||||
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
@@ -33,8 +33,9 @@ from ww_agents import (
|
||||
from ww_core.domain import (
|
||||
CharacterWriteRepo,
|
||||
ProjectRepo,
|
||||
RuleWriteRepo,
|
||||
)
|
||||
from ww_core.domain.repositories import MemoryRepos, RulesRepo
|
||||
from ww_core.domain.repositories import MemoryRepos
|
||||
from ww_core.orchestrator import (
|
||||
precheck_generated_cards,
|
||||
run_character_gen,
|
||||
@@ -71,7 +72,7 @@ from ww_api.services.project_deps import (
|
||||
get_memory_repos,
|
||||
get_precheck_gateway,
|
||||
get_project_repo,
|
||||
get_rules_read_repo,
|
||||
get_rule_write_repo,
|
||||
get_skill_registry,
|
||||
get_worldbuilder_gateway,
|
||||
)
|
||||
@@ -84,7 +85,7 @@ skills_router = APIRouter(prefix="/skills", tags=["skills"])
|
||||
ProjectRepoDep = Annotated[ProjectRepo, Depends(get_project_repo)]
|
||||
MemoryReposDep = Annotated[MemoryRepos, Depends(get_memory_repos)]
|
||||
CharacterWriteRepoDep = Annotated[CharacterWriteRepo, Depends(get_character_write_repo)]
|
||||
RulesReadRepoDep = Annotated[RulesRepo, Depends(get_rules_read_repo)]
|
||||
RuleRepoDep = Annotated[RuleWriteRepo, Depends(get_rule_write_repo)]
|
||||
SkillRegistryDep = Annotated[SkillRegistry, Depends(get_skill_registry)]
|
||||
WorldGatewayDep = Annotated[Gateway, Depends(get_worldbuilder_gateway)]
|
||||
CharacterGatewayDep = Annotated[Gateway, Depends(get_character_gen_gateway)]
|
||||
@@ -111,10 +112,28 @@ def _render_characters_context(cards: list[CharacterCard]) -> str:
|
||||
return "\n".join(f"- {c.name}({c.role}):{'、'.join(c.traits) or '(未列)'}" for c in cards)
|
||||
|
||||
|
||||
def _relations_from_jsonb(raw: list[Any]) -> list[CharacterRelation]:
|
||||
"""`characters.relations` JSONB list(每条 {name, kind, note?})→ schema CharacterRelation。
|
||||
|
||||
跳过缺 name/kind 的脏条目(历史/外部数据不可信,守输入校验边界)。
|
||||
"""
|
||||
out: list[CharacterRelation] = []
|
||||
for item in raw or []:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
name = item.get("name")
|
||||
kind = item.get("kind")
|
||||
if not name or not kind:
|
||||
continue
|
||||
out.append(CharacterRelation(name=str(name), kind=str(kind), note=item.get("note")))
|
||||
return out
|
||||
|
||||
|
||||
async def _existing_characters(memory: MemoryRepos, project_id: uuid.UUID) -> list[CharacterCard]:
|
||||
"""把已有角色读侧视图转成 `CharacterCard`(喂防雷同 / precheck)。
|
||||
"""把已有角色读侧视图转成 `CharacterCard`(喂防雷同 / precheck / 设定库读端点)。
|
||||
|
||||
DB JSONB dict 列 → schema list/str 反向解包(与写侧形变互逆;缺则空/占位)。
|
||||
`relations` 从 JSONB list 还原(设定库 Codex 需展示关系网;precheck 不读此字段,无害)。
|
||||
"""
|
||||
views = await memory.character.list_for_project(project_id)
|
||||
cards: list[CharacterCard] = []
|
||||
@@ -133,7 +152,7 @@ async def _existing_characters(memory: MemoryRepos, project_id: uuid.UUID) -> li
|
||||
arc=arc or "",
|
||||
speech_tics=tics,
|
||||
tags=list(v.tags or []),
|
||||
relations=[],
|
||||
relations=_relations_from_jsonb(list(v.relations or [])),
|
||||
)
|
||||
)
|
||||
return cards
|
||||
@@ -377,14 +396,16 @@ async def list_world_entities(
|
||||
@router.get("/{project_id}/rules")
|
||||
async def list_rules(
|
||||
project_id: uuid.UUID,
|
||||
repo: RulesReadRepoDep,
|
||||
repo: RuleRepoDep,
|
||||
project_repo: ProjectRepoDep,
|
||||
) -> RuleListResponse:
|
||||
"""规则列表(按读侧顺序)。规则页用。项目不存在 → 404(QA MEDIUM:此前返误导性空 200)。"""
|
||||
"""规则列表(带 id,供前端删除 handle)。项目不存在 → 404(QA MEDIUM:此前返误导性空 200)。"""
|
||||
if await project_repo.get(STUB_OWNER_ID, project_id) is None:
|
||||
raise AppError(ErrorCode.NOT_FOUND, f"project not found: {project_id}")
|
||||
rules = await repo.all_for_project(project_id)
|
||||
return RuleListResponse(rules=[RuleView(level=r.level, content=r.content) for r in rules])
|
||||
rules = await repo.list_for_project(project_id)
|
||||
return RuleListResponse(
|
||||
rules=[RuleView(id=r.id, level=r.level, content=r.content) for r in rules]
|
||||
)
|
||||
|
||||
|
||||
@skills_router.get("")
|
||||
|
||||
@@ -143,10 +143,12 @@ async def get_outline(
|
||||
request: Request,
|
||||
project_repo: ProjectRepoDep,
|
||||
outline_repo: OutlineReadRepoDep,
|
||||
volume: int | None = None,
|
||||
) -> OutlineResponse:
|
||||
"""读取已持久化的大纲(逐章,按 chapter_no 升序)。
|
||||
|
||||
项目不存在 → 404;项目存在但尚无大纲 → 200 空列表(非 404,页面初次访问的常态)。
|
||||
可选 `?volume=N`:只返回该卷的章节(前端按卷切换);不传 → 全部章节(向后兼容)。
|
||||
项目不存在 → 404;项目存在但尚无大纲(或该卷无章节)→ 200 空列表(非 404)。
|
||||
DB `outline.beats` 是 JSONB `{"beats": [...]}` → 解包成裸 `list[str]`(与 POST 响应同形,
|
||||
前端 OpenAPI 类型对齐)。读侧复用 C5 assemble 的 `OutlineRepo`,不写库。
|
||||
"""
|
||||
@@ -157,6 +159,8 @@ async def get_outline(
|
||||
raise AppError(ErrorCode.NOT_FOUND, f"project not found: {project_id}")
|
||||
|
||||
views = await outline_repo.list_for_project(project_id)
|
||||
if volume is not None:
|
||||
views = [v for v in views if v.volume == volume]
|
||||
chapters = [
|
||||
OutlineChapterView(
|
||||
no=view.chapter_no,
|
||||
@@ -171,6 +175,7 @@ async def get_outline(
|
||||
"outline_read",
|
||||
project_id=str(project_id),
|
||||
request_id=request_id,
|
||||
volume=volume,
|
||||
chapter_count=len(chapters),
|
||||
)
|
||||
return OutlineResponse(chapters=chapters)
|
||||
|
||||
@@ -15,7 +15,7 @@ from __future__ import annotations
|
||||
import uuid
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from fastapi import APIRouter, Depends, Request, Response
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from ww_core.domain import RuleWriteRepo
|
||||
from ww_core.domain.project_repo import ProjectRepo
|
||||
@@ -60,4 +60,34 @@ async def create_rule(
|
||||
request_id=request_id,
|
||||
level=body.level,
|
||||
)
|
||||
return RuleView(level=view.level, content=view.content)
|
||||
return RuleView(id=view.id, level=view.level, content=view.content)
|
||||
|
||||
|
||||
@router.delete("/{project_id}/rules/{rule_id}", status_code=204)
|
||||
async def delete_rule(
|
||||
project_id: uuid.UUID,
|
||||
rule_id: uuid.UUID,
|
||||
request: Request,
|
||||
repo: RuleWriteRepoDep,
|
||||
project_repo: ProjectRepoDep,
|
||||
session: SessionDep,
|
||||
) -> Response:
|
||||
"""删除一条本作品规则(204)。项目不存在 → 404;规则不存在 / 不属于该项目 → 404。
|
||||
|
||||
删规则是**作者显式动作**(不变量 #3:规则增删不经 AI 静默写库)。repo.delete 只 flush,
|
||||
端点提交;删不到行(未知 id / 跨项目)→ 不提交、抛 404。
|
||||
"""
|
||||
request_id = getattr(request.state, "request_id", None)
|
||||
if await project_repo.get(STUB_OWNER_ID, project_id) is None:
|
||||
raise AppError(ErrorCode.NOT_FOUND, f"project not found: {project_id}")
|
||||
deleted = await repo.delete(project_id, rule_id)
|
||||
if not deleted:
|
||||
raise AppError(ErrorCode.NOT_FOUND, f"rule not found: {rule_id}")
|
||||
await session.commit()
|
||||
log.info(
|
||||
"rule_deleted",
|
||||
project_id=str(project_id),
|
||||
rule_id=str(rule_id),
|
||||
request_id=request_id,
|
||||
)
|
||||
return Response(status_code=204)
|
||||
|
||||
@@ -7,6 +7,7 @@ snake_case;前端经 OpenAPI 生成 TS 类型消费。改字段 → 前端必
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from typing import Annotated, Literal
|
||||
|
||||
from pydantic import BaseModel, Field, StringConstraints
|
||||
@@ -25,7 +26,11 @@ class RuleCreateRequest(BaseModel):
|
||||
|
||||
|
||||
class RuleView(BaseModel):
|
||||
"""规则视图(创建后回显;snake_case)。"""
|
||||
"""规则视图(创建后回显 + 列表项;snake_case)。
|
||||
|
||||
`id` 是该规则行的稳定主键——前端规则页用它作删除 handle(DELETE /rules/{rule_id})。
|
||||
"""
|
||||
|
||||
id: uuid.UUID
|
||||
level: str
|
||||
content: str
|
||||
|
||||
@@ -87,13 +87,28 @@ export function CodexPage({
|
||||
已入库人物({characters.length})
|
||||
</h3>
|
||||
{characters.length > 0 ? (
|
||||
<ul className="flex flex-wrap gap-2">
|
||||
<ul className="flex flex-col gap-2">
|
||||
{characters.map((c, i) => (
|
||||
<li
|
||||
key={`${c.name}-${i}`}
|
||||
className="rounded bg-bg px-2 py-1 text-xs text-ink-soft"
|
||||
className="rounded bg-bg px-2 py-1.5 text-xs text-ink-soft"
|
||||
>
|
||||
{c.name}({c.role})
|
||||
<span className="text-ink">
|
||||
{c.name}({c.role})
|
||||
</span>
|
||||
{c.relations && c.relations.length > 0 ? (
|
||||
<ul className="mt-1 flex flex-wrap gap-1">
|
||||
{c.relations.map((r, j) => (
|
||||
<li
|
||||
key={`${r.name}-${r.kind}-${j}`}
|
||||
className="rounded bg-panel px-1.5 py-0.5 text-[11px]"
|
||||
title={r.note ?? undefined}
|
||||
>
|
||||
{r.kind} · {r.name}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
@@ -4,7 +4,12 @@ import { useMemo, useState } from "react";
|
||||
|
||||
import { AppShell } from "@/components/AppShell";
|
||||
import type { OutlineChapterView, ProjectResponse } from "@/lib/api/types";
|
||||
import { groupByVolume } from "@/lib/outline/outline";
|
||||
import {
|
||||
distinctVolumes,
|
||||
filterByViewVolume,
|
||||
groupByVolume,
|
||||
type ViewVolume,
|
||||
} from "@/lib/outline/outline";
|
||||
import { useOutline } from "@/lib/outline/useOutline";
|
||||
import { OutlineChapterRow } from "./OutlineChapterRow";
|
||||
|
||||
@@ -21,7 +26,13 @@ export function OutlineEditor({
|
||||
}: OutlineEditorProps) {
|
||||
const { chapters, status, error, generate } = useOutline(initialChapters);
|
||||
const [volume, setVolume] = useState(1);
|
||||
const volumes = useMemo(() => groupByVolume(chapters), [chapters]);
|
||||
// 视图卷过滤("全部" 或某卷)——只影响展示,与生成的目标卷 `volume` 解耦。
|
||||
const [viewVolume, setViewVolume] = useState<ViewVolume>("all");
|
||||
const availableVolumes = useMemo(() => distinctVolumes(chapters), [chapters]);
|
||||
const volumes = useMemo(
|
||||
() => groupByVolume(filterByViewVolume(chapters, viewVolume)),
|
||||
[chapters, viewVolume],
|
||||
);
|
||||
const generating = status === "generating";
|
||||
|
||||
return (
|
||||
@@ -34,7 +45,32 @@ export function OutlineEditor({
|
||||
<div className="flex h-[calc(100vh-var(--chrome,4rem))] flex-col p-6">
|
||||
<div className="mb-4 flex items-center gap-3">
|
||||
<h1 className="font-serif text-lg text-ink">大纲</h1>
|
||||
<label htmlFor="vol" className="ml-auto text-xs text-ink-soft">
|
||||
{availableVolumes.length > 0 ? (
|
||||
<label htmlFor="view-vol" className="ml-auto text-xs text-ink-soft">
|
||||
查看
|
||||
<select
|
||||
id="view-vol"
|
||||
value={viewVolume === "all" ? "all" : String(viewVolume)}
|
||||
onChange={(e) =>
|
||||
setViewVolume(
|
||||
e.target.value === "all" ? "all" : Number(e.target.value),
|
||||
)
|
||||
}
|
||||
className="ml-1 rounded border border-line bg-bg px-2 py-1 text-sm text-ink focus:border-cinnabar focus:outline-none"
|
||||
>
|
||||
<option value="all">全部</option>
|
||||
{availableVolumes.map((v) => (
|
||||
<option key={v} value={String(v)}>
|
||||
卷 {v}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
) : null}
|
||||
<label
|
||||
htmlFor="vol"
|
||||
className={`text-xs text-ink-soft${availableVolumes.length > 0 ? "" : " ml-auto"}`}
|
||||
>
|
||||
卷号
|
||||
</label>
|
||||
<input
|
||||
@@ -74,7 +110,9 @@ export function OutlineEditor({
|
||||
<div className="min-h-0 flex-1 overflow-auto">
|
||||
{volumes.length === 0 ? (
|
||||
<p className="rounded border border-dashed border-line p-6 text-sm text-ink-soft">
|
||||
暂无大纲。点「✦ AI 排大纲」生成逐章节拍与伏笔窗口。
|
||||
{chapters.length > 0 && viewVolume !== "all"
|
||||
? `卷 ${viewVolume} 暂无大纲。切到「全部」查看其它卷,或点「✦ AI 排大纲」为本卷生成。`
|
||||
: "暂无大纲。点「✦ AI 排大纲」生成逐章节拍与伏笔窗口。"}
|
||||
</p>
|
||||
) : (
|
||||
volumes.map((group) => (
|
||||
|
||||
@@ -36,6 +36,7 @@ import { useRefine } from "@/lib/style/useRefine";
|
||||
import { useReviewStream } from "@/lib/review/useReviewStream";
|
||||
import type { ReviewConflict, StyleDriftSegment } from "@/lib/review/sse";
|
||||
import { normalizeStyleDrift } from "@/lib/style/style";
|
||||
import { locateDriftSegment } from "@/lib/style/locateSegment";
|
||||
import { useToast } from "@/components/Toast";
|
||||
import { ThinkingIndicator } from "@/components/ThinkingIndicator";
|
||||
import { AcceptPanel } from "./AcceptPanel";
|
||||
@@ -80,10 +81,8 @@ export function ReviewReport({
|
||||
const [rewriting, setRewriting] = useState<Set<number>>(new Set());
|
||||
// 可编辑终稿 textarea 引用:供「跳转」/采纳后在正文里选中定位问题区域。
|
||||
const editorRef = useRef<HTMLTextAreaElement>(null);
|
||||
// 回炉中的漂移段(null=未打开 RefineView)。
|
||||
const [refineSegment, setRefineSegment] = useState<StyleDriftSegment | null>(
|
||||
null,
|
||||
);
|
||||
// 回炉中漂移段在终稿里定位到的原文(null=未打开 RefineView)。内容锚定位,非位置 idx。
|
||||
const [refineText, setRefineText] = useState<string | null>(null);
|
||||
const seededRef = useRef(false);
|
||||
|
||||
// 进页一次性把历史留痕(冲突 + 伏笔建议 + 节奏)种入流状态(无需重审即可裁决/查看)。
|
||||
@@ -342,8 +341,17 @@ export function ReviewReport({
|
||||
|
||||
// 终稿按空行切段(大文本:仅在 finalText 变化时重算,不在每次 render split)。
|
||||
const finalParas = useMemo(() => finalText.split(/\n{2,}/), [finalText]);
|
||||
// 漂移段 idx → 终稿对应段正文(越界则空串)。
|
||||
const segmentText = (idx: number): string => finalParas[idx]?.trim() ?? "";
|
||||
|
||||
// 一键回炉:用漂移段自带原文在终稿里做内容匹配定位(内容锚),定位不到则就 #9
|
||||
// 给出明确提示而非静默 no-op;定位到才打开 RefineView。
|
||||
const onRefineSegment = (segment: StyleDriftSegment): void => {
|
||||
const located = locateDriftSegment(finalParas, segment);
|
||||
if (located === null) {
|
||||
toast("无法定位该段(原文可能已改动),请手动选择要回炉的段落。", "error");
|
||||
return;
|
||||
}
|
||||
setRefineText(located);
|
||||
};
|
||||
|
||||
// 采纳:把重写段替换终稿中的原段(首个匹配),合入终稿(经既有 draft 路径)。
|
||||
const onAdopt = (original: string, refined: string): void => {
|
||||
@@ -355,7 +363,7 @@ export function ReviewReport({
|
||||
}
|
||||
return prev.slice(0, idx) + refined + prev.slice(idx + original.length);
|
||||
});
|
||||
setRefineSegment(null);
|
||||
setRefineText(null);
|
||||
toast("已合入终稿,记得验收时复核。", "success");
|
||||
};
|
||||
|
||||
@@ -523,15 +531,15 @@ export function ReviewReport({
|
||||
<StylePanel
|
||||
style={style}
|
||||
incomplete={sectionStatus("style") === "incomplete"}
|
||||
onRefine={setRefineSegment}
|
||||
onRefine={onRefineSegment}
|
||||
/>
|
||||
{refineSegment !== null ? (
|
||||
{refineText !== null ? (
|
||||
<RefineView
|
||||
projectId={project.id}
|
||||
chapterNo={chapterNo}
|
||||
segment={segmentText(refineSegment.idx)}
|
||||
segment={refineText}
|
||||
onAdopt={onAdopt}
|
||||
onClose={() => setRefineSegment(null)}
|
||||
onClose={() => setRefineText(null)}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -19,7 +19,7 @@ interface RulesPageProps {
|
||||
|
||||
// 规则页(UX §7):四级规则列表 + 新增(乐观 + 回滚)。
|
||||
export function RulesPage({ project, initialRules }: RulesPageProps) {
|
||||
const { items, busy, add } = useRules(initialRules);
|
||||
const { items, busy, add, remove } = useRules(initialRules);
|
||||
const [level, setLevel] = useState<RuleLevel>("project");
|
||||
const [content, setContent] = useState("");
|
||||
const groups = useMemo(() => groupByLevel(items), [items]);
|
||||
@@ -88,12 +88,23 @@ export function RulesPage({ project, initialRules }: RulesPageProps) {
|
||||
<p className="text-xs text-ink-soft">(暂无)</p>
|
||||
) : (
|
||||
<ul className="flex flex-col gap-2">
|
||||
{groups[lv].map((rule, i) => (
|
||||
{groups[lv].map((rule) => (
|
||||
<li
|
||||
key={i}
|
||||
className="rounded border border-line bg-panel px-3 py-2 text-sm text-ink"
|
||||
key={rule.id}
|
||||
className="flex items-start justify-between gap-3 rounded border border-line bg-panel px-3 py-2 text-sm text-ink"
|
||||
>
|
||||
{rule.content}
|
||||
<span className="min-w-0 flex-1 break-words">
|
||||
{rule.content}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() => void remove(project.id, rule.id)}
|
||||
aria-label="删除规则"
|
||||
className="shrink-0 text-xs text-ink-soft hover:text-cinnabar disabled:opacity-50"
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
87
apps/web/lib/api/schema.d.ts
vendored
87
apps/web/lib/api/schema.d.ts
vendored
@@ -146,6 +146,9 @@ export interface paths {
|
||||
*
|
||||
* `body.directive`(可选,T4-b)是临时本章指令,直通 assemble→volatile(不持久化);
|
||||
* 无 body 的旧调用方仍可用(向后兼容)。
|
||||
*
|
||||
* 项目不存在 → 404(在触网关前 fail-fast):否则非法 project_id 会静默烧一次付费/限流的
|
||||
* LLM 调用并返回 200(QA C1)。
|
||||
*/
|
||||
post: operations["stream_draft_projects__project_id__chapters__chapter_no__draft_post"];
|
||||
delete?: never;
|
||||
@@ -286,7 +289,8 @@ export interface paths {
|
||||
* Get Outline
|
||||
* @description 读取已持久化的大纲(逐章,按 chapter_no 升序)。
|
||||
*
|
||||
* 项目不存在 → 404;项目存在但尚无大纲 → 200 空列表(非 404,页面初次访问的常态)。
|
||||
* 可选 `?volume=N`:只返回该卷的章节(前端按卷切换);不传 → 全部章节(向后兼容)。
|
||||
* 项目不存在 → 404;项目存在但尚无大纲(或该卷无章节)→ 200 空列表(非 404)。
|
||||
* DB `outline.beats` 是 JSONB `{"beats": [...]}` → 解包成裸 `list[str]`(与 POST 响应同形,
|
||||
* 前端 OpenAPI 类型对齐)。读侧复用 C5 assemble 的 `OutlineRepo`,不写库。
|
||||
*/
|
||||
@@ -312,13 +316,15 @@ export interface paths {
|
||||
};
|
||||
/**
|
||||
* List Rules
|
||||
* @description 规则列表(按读侧顺序)。规则页用。
|
||||
* @description 规则列表(带 id,供前端删除 handle)。项目不存在 → 404(QA MEDIUM:此前返误导性空 200)。
|
||||
*/
|
||||
get: operations["list_rules_projects__project_id__rules_get"];
|
||||
put?: never;
|
||||
/**
|
||||
* Create Rule
|
||||
* @description 新增一条规则(201)。非法 level / 空 content → FastAPI 422。
|
||||
* @description 新增一条规则(201)。非法 level / 空 content → FastAPI 422;项目不存在 → 404。
|
||||
*
|
||||
* 项目存在性须在 insert 前校验:否则 FK 违例会逃逸成 500(QA H2),而非干净的 404。
|
||||
*/
|
||||
post: operations["create_rule_projects__project_id__rules_post"];
|
||||
delete?: never;
|
||||
@@ -327,6 +333,29 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/projects/{project_id}/rules/{rule_id}": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
post?: never;
|
||||
/**
|
||||
* Delete Rule
|
||||
* @description 删除一条本作品规则(204)。项目不存在 → 404;规则不存在 / 不属于该项目 → 404。
|
||||
*
|
||||
* 删规则是**作者显式动作**(不变量 #3:规则增删不经 AI 静默写库)。repo.delete 只 flush,
|
||||
* 端点提交;删不到行(未知 id / 跨项目)→ 不提交、抛 404。
|
||||
*/
|
||||
delete: operations["delete_rule_projects__project_id__rules__rule_id__delete"];
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/projects/{project_id}/style": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -1671,7 +1700,7 @@ export interface components {
|
||||
level: "global" | "genre" | "style" | "project";
|
||||
/**
|
||||
* Content
|
||||
* @description 规则正文
|
||||
* @description 规则正文(首尾空白会被裁剪,不可全空白)
|
||||
*/
|
||||
content: string;
|
||||
};
|
||||
@@ -1685,9 +1714,16 @@ export interface components {
|
||||
};
|
||||
/**
|
||||
* RuleView
|
||||
* @description 规则视图(创建后回显;snake_case)。
|
||||
* @description 规则视图(创建后回显 + 列表项;snake_case)。
|
||||
*
|
||||
* `id` 是该规则行的稳定主键——前端规则页用它作删除 handle(DELETE /rules/{rule_id})。
|
||||
*/
|
||||
RuleView: {
|
||||
/**
|
||||
* Id
|
||||
* Format: uuid
|
||||
*/
|
||||
id: string;
|
||||
/** Level */
|
||||
level: string;
|
||||
/** Content */
|
||||
@@ -1859,8 +1895,11 @@ export interface components {
|
||||
* @description 单条档位路由写入。
|
||||
*/
|
||||
TierRoutingInput: {
|
||||
/** Tier */
|
||||
tier: string;
|
||||
/**
|
||||
* Tier
|
||||
* @enum {string}
|
||||
*/
|
||||
tier: "writer" | "analyst" | "light";
|
||||
/** Provider */
|
||||
provider: string;
|
||||
/** Model */
|
||||
@@ -2744,7 +2783,9 @@ export interface operations {
|
||||
};
|
||||
get_outline_projects__project_id__outline_get: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
query?: {
|
||||
volume?: number | null;
|
||||
};
|
||||
header?: never;
|
||||
path: {
|
||||
project_id: string;
|
||||
@@ -2874,6 +2915,36 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
delete_rule_projects__project_id__rules__rule_id__delete: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
project_id: string;
|
||||
rule_id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
204: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
/** @description Validation Error */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["HTTPValidationError"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
get_style_projects__project_id__style_get: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
||||
@@ -184,6 +184,19 @@ describe("mergeCharacterCards", () => {
|
||||
const persisted = [card({ name: "甲" })];
|
||||
expect(mergeCharacterCards(persisted, [])).toEqual(persisted);
|
||||
});
|
||||
|
||||
it("preserves relations on the merged cards (Codex relation display)", () => {
|
||||
const persisted = [
|
||||
card({
|
||||
name: "甲",
|
||||
relations: [{ name: "乙", kind: "宿敌", note: "灭门之仇" }],
|
||||
}),
|
||||
];
|
||||
const out = mergeCharacterCards(persisted, []);
|
||||
expect(out[0]?.relations).toEqual([
|
||||
{ name: "乙", kind: "宿敌", note: "灭门之仇" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("mergeWorldEntities", () => {
|
||||
|
||||
@@ -5,6 +5,8 @@ import type {
|
||||
OutlineChapterView,
|
||||
} from "@/lib/api/types";
|
||||
import {
|
||||
distinctVolumes,
|
||||
filterByViewVolume,
|
||||
groupByVolume,
|
||||
isCloseWindow,
|
||||
windowBadgeLabel,
|
||||
@@ -38,6 +40,48 @@ describe("groupByVolume", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("distinctVolumes", () => {
|
||||
it("returns sorted unique volume numbers", () => {
|
||||
expect(
|
||||
distinctVolumes([
|
||||
ch({ no: 1, volume: 2 }),
|
||||
ch({ no: 2, volume: 1 }),
|
||||
ch({ no: 3, volume: 2 }),
|
||||
]),
|
||||
).toEqual([1, 2]);
|
||||
});
|
||||
|
||||
it("handles undefined", () => {
|
||||
expect(distinctVolumes(undefined)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("filterByViewVolume", () => {
|
||||
const chapters = [
|
||||
ch({ no: 1, volume: 1 }),
|
||||
ch({ no: 2, volume: 1 }),
|
||||
ch({ no: 3, volume: 2 }),
|
||||
];
|
||||
|
||||
it("returns all chapters when view is 'all'", () => {
|
||||
expect(filterByViewVolume(chapters, "all").map((c) => c.no)).toEqual([
|
||||
1, 2, 3,
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps only the selected volume", () => {
|
||||
expect(filterByViewVolume(chapters, 2).map((c) => c.no)).toEqual([3]);
|
||||
});
|
||||
|
||||
it("returns empty for a volume with no chapters", () => {
|
||||
expect(filterByViewVolume(chapters, 9)).toEqual([]);
|
||||
});
|
||||
|
||||
it("handles undefined", () => {
|
||||
expect(filterByViewVolume(undefined, "all")).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("windowBadgeLabel", () => {
|
||||
it("renders a range, half-open, or bare badge", () => {
|
||||
expect(
|
||||
|
||||
@@ -25,6 +25,28 @@ export function groupByVolume(
|
||||
}));
|
||||
}
|
||||
|
||||
// 视图卷过滤值:"all" = 全部卷;具体数字 = 只看该卷(对齐后端 GET ?volume)。
|
||||
export type ViewVolume = number | "all";
|
||||
|
||||
// 已存大纲里出现过的卷号(升序、去重)——驱动「全部 / 卷 N」选择器选项。
|
||||
export function distinctVolumes(
|
||||
chapters: readonly OutlineChapterView[] | undefined,
|
||||
): number[] {
|
||||
const seen = new Set<number>();
|
||||
for (const ch of chapters ?? []) seen.add(ch.volume);
|
||||
return [...seen].sort((a, b) => a - b);
|
||||
}
|
||||
|
||||
// 按视图卷过滤:选「全部」返回全部,选具体卷只留该卷(客户端过滤,避免重复请求)。
|
||||
export function filterByViewVolume(
|
||||
chapters: readonly OutlineChapterView[] | undefined,
|
||||
view: ViewVolume,
|
||||
): OutlineChapterView[] {
|
||||
const all = [...(chapters ?? [])];
|
||||
if (view === "all") return all;
|
||||
return all.filter((ch) => ch.volume === view);
|
||||
}
|
||||
|
||||
// 伏笔窗口徽标文案:`⚑ F-012 (40-60)` / `⚑ F-012 (≤60)` / `⚑ F-012`。
|
||||
export function windowBadgeLabel(window: ForeshadowWindowView): string {
|
||||
const from = window.expected_close_from;
|
||||
|
||||
@@ -46,8 +46,11 @@ export interface PaceReport {
|
||||
}
|
||||
|
||||
// 文风漂移(第四审,C4 扩 T4.2):整体相似度 score + 段级漂移。
|
||||
// `text` = 该漂移段从草稿逐字摘录的原文(内容锚,供前端内容匹配定位回炉目标,
|
||||
// 不靠位置 idx;旧数据/降级可为空串)。
|
||||
export interface StyleDriftSegment {
|
||||
idx: number;
|
||||
text: string;
|
||||
score: number;
|
||||
label: string | null;
|
||||
}
|
||||
@@ -89,6 +92,7 @@ export interface StyleEvent {
|
||||
score: number;
|
||||
segments: {
|
||||
idx: number;
|
||||
text?: string | null;
|
||||
score: number;
|
||||
label?: string | null;
|
||||
}[];
|
||||
@@ -176,7 +180,14 @@ function asStyleSegments(v: unknown): StyleEvent["data"]["segments"] {
|
||||
if (!Array.isArray(v)) return [];
|
||||
return v.flatMap((x) =>
|
||||
isRecord(x) && typeof x.idx === "number" && typeof x.score === "number"
|
||||
? [{ idx: x.idx, score: x.score, label: nullableString(x.label) }]
|
||||
? [
|
||||
{
|
||||
idx: x.idx,
|
||||
text: nullableString(x.text),
|
||||
score: x.score,
|
||||
label: nullableString(x.label),
|
||||
},
|
||||
]
|
||||
: [],
|
||||
);
|
||||
}
|
||||
@@ -360,6 +371,7 @@ export function reduceReview(
|
||||
score: event.data.score,
|
||||
segments: event.data.segments.map((s) => ({
|
||||
idx: s.idx,
|
||||
text: s.text ?? "",
|
||||
score: s.score,
|
||||
label: s.label ?? null,
|
||||
})),
|
||||
|
||||
@@ -8,15 +8,28 @@ import {
|
||||
} from "./sse";
|
||||
|
||||
describe("parseReviewBlock — style (C4 扩 T4.2)", () => {
|
||||
it("parses a style frame", () => {
|
||||
it("parses a style frame carrying the text anchor", () => {
|
||||
const evt = parseReviewBlock(
|
||||
'event:style\ndata:{"score":87,"segments":[{"idx":3,"score":60,"label":"口语化"}]}',
|
||||
'event:style\ndata:{"score":87,"segments":[{"idx":3,"text":"原文锚","score":60,"label":"口语化"}]}',
|
||||
);
|
||||
expect(evt).toEqual({
|
||||
event: "style",
|
||||
data: {
|
||||
score: 87,
|
||||
segments: [{ idx: 3, score: 60, label: "口语化" }],
|
||||
segments: [{ idx: 3, text: "原文锚", score: 60, label: "口语化" }],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("defaults missing segment text to null when parsing", () => {
|
||||
const evt = parseReviewBlock(
|
||||
'event:style\ndata:{"score":87,"segments":[{"idx":3,"score":60}]}',
|
||||
);
|
||||
expect(evt).toEqual({
|
||||
event: "style",
|
||||
data: {
|
||||
score: 87,
|
||||
segments: [{ idx: 3, text: null, score: 60, label: null }],
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -32,17 +45,20 @@ describe("reduceReview — style (replace-style like pace)", () => {
|
||||
it("sets style report and marks reviewing", () => {
|
||||
const evt: ReviewSseEvent = {
|
||||
event: "style",
|
||||
data: { score: 80, segments: [{ idx: 1, score: 50, label: "突兀" }] },
|
||||
data: {
|
||||
score: 80,
|
||||
segments: [{ idx: 1, text: "原文锚", score: 50, label: "突兀" }],
|
||||
},
|
||||
};
|
||||
const out = reduceReview(initialReviewState, evt);
|
||||
expect(out.phase).toBe("reviewing");
|
||||
expect(out.style).toEqual({
|
||||
score: 80,
|
||||
segments: [{ idx: 1, score: 50, label: "突兀" }],
|
||||
segments: [{ idx: 1, text: "原文锚", score: 50, label: "突兀" }],
|
||||
});
|
||||
});
|
||||
|
||||
it("replaces (not accumulates) the style report and defaults label null", () => {
|
||||
it("replaces (not accumulates) the style report and defaults text/label", () => {
|
||||
const first = reduceReview(initialReviewState, {
|
||||
event: "style",
|
||||
data: { score: 70, segments: [{ idx: 0, score: 40 }] },
|
||||
@@ -51,7 +67,12 @@ describe("reduceReview — style (replace-style like pace)", () => {
|
||||
event: "style",
|
||||
data: { score: 95, segments: [] },
|
||||
});
|
||||
expect(first.style?.segments[0]).toEqual({ idx: 0, score: 40, label: null });
|
||||
expect(first.style?.segments[0]).toEqual({
|
||||
idx: 0,
|
||||
text: "",
|
||||
score: 40,
|
||||
label: null,
|
||||
});
|
||||
expect(second.style).toEqual({ score: 95, segments: [] });
|
||||
});
|
||||
|
||||
|
||||
@@ -19,15 +19,15 @@ describe("isRuleLevel", () => {
|
||||
describe("groupByLevel", () => {
|
||||
it("groups rules by level, unknown level falls to project", () => {
|
||||
const rules: RuleView[] = [
|
||||
{ level: "global", content: "g1" },
|
||||
{ level: "project", content: "p1" },
|
||||
{ level: "weird", content: "x1" },
|
||||
{ id: "r1", level: "global", content: "g1" },
|
||||
{ id: "r2", level: "project", content: "p1" },
|
||||
{ id: "r3", level: "weird", content: "x1" },
|
||||
];
|
||||
const groups = groupByLevel(rules);
|
||||
expect(groups.global).toEqual([{ level: "global", content: "g1" }]);
|
||||
expect(groups.global).toEqual([{ id: "r1", level: "global", content: "g1" }]);
|
||||
expect(groups.project).toEqual([
|
||||
{ level: "project", content: "p1" },
|
||||
{ level: "weird", content: "x1" },
|
||||
{ id: "r2", level: "project", content: "p1" },
|
||||
{ id: "r3", level: "weird", content: "x1" },
|
||||
]);
|
||||
expect(groups.genre).toEqual([]);
|
||||
});
|
||||
|
||||
@@ -16,6 +16,8 @@ export interface UseRules {
|
||||
level: RuleLevel,
|
||||
content: string,
|
||||
) => Promise<boolean>;
|
||||
// 删除一条规则(乐观移除 + 失败回滚 + Toast)。
|
||||
remove: (projectId: string, ruleId: string) => Promise<boolean>;
|
||||
}
|
||||
|
||||
// 规则页(UX §7):列出 + 新增规则。乐观追加,失败回滚(仿 useForeshadow)。
|
||||
@@ -31,7 +33,12 @@ export function useRules(initial: RuleView[]): UseRules {
|
||||
return false;
|
||||
}
|
||||
const snapshot = items;
|
||||
const optimistic: RuleView = { level, content: content.trim() };
|
||||
// 乐观项需一个临时 id(删除 handle 之前);服务端返回后被权威行替换。
|
||||
const optimistic: RuleView = {
|
||||
id: crypto.randomUUID(),
|
||||
level,
|
||||
content: content.trim(),
|
||||
};
|
||||
setItems((prev) => [...prev, optimistic]);
|
||||
setBusy(true);
|
||||
try {
|
||||
@@ -58,5 +65,29 @@ export function useRules(initial: RuleView[]): UseRules {
|
||||
[items, toast],
|
||||
);
|
||||
|
||||
return { items, busy, add };
|
||||
const remove = useCallback<UseRules["remove"]>(
|
||||
async (projectId, ruleId) => {
|
||||
const snapshot = items;
|
||||
setItems((prev) => prev.filter((r) => r.id !== ruleId));
|
||||
setBusy(true);
|
||||
try {
|
||||
const { error } = await api.DELETE(
|
||||
"/projects/{project_id}/rules/{rule_id}",
|
||||
{ params: { path: { project_id: projectId, rule_id: ruleId } } },
|
||||
);
|
||||
if (error) {
|
||||
setItems(snapshot); // 回滚
|
||||
toast("删除规则失败,请稍后重试。", "error");
|
||||
return false;
|
||||
}
|
||||
toast("已删除规则", "success");
|
||||
return true;
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
},
|
||||
[items, toast],
|
||||
);
|
||||
|
||||
return { items, busy, add, remove };
|
||||
}
|
||||
|
||||
@@ -59,7 +59,7 @@ export function defaultModelFor(providerId: string): string {
|
||||
|
||||
// 单条档位路由的可编辑草稿(纯数据)。
|
||||
export interface RoutingDraft {
|
||||
tier: string;
|
||||
tier: Tier;
|
||||
provider: string;
|
||||
model: string;
|
||||
}
|
||||
@@ -96,7 +96,7 @@ export function applyProviderChange(
|
||||
// 草稿 → PUT body 的 tier_routing:只取选了 provider+model 的行(不可变)。
|
||||
export function draftsToRoutingInput(
|
||||
drafts: RoutingDraft[],
|
||||
): { tier: string; provider: string; model: string }[] {
|
||||
): { tier: Tier; provider: string; model: string }[] {
|
||||
return drafts
|
||||
.filter((d) => d.provider.trim() !== "" && d.model.trim() !== "")
|
||||
.map((d) => ({ tier: d.tier, provider: d.provider, model: d.model }));
|
||||
|
||||
46
apps/web/lib/style/locateSegment.test.ts
Normal file
46
apps/web/lib/style/locateSegment.test.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { locateDriftSegment } from "./locateSegment";
|
||||
|
||||
const DRAFT = "第一段没问题。\n\n他乘坐迈巴赫扬长而去,留下一地尘土。\n\n结尾。";
|
||||
const PARAS = DRAFT.split(/\n{2,}/);
|
||||
|
||||
describe("locateDriftSegment", () => {
|
||||
it("locates by content anchor, ignoring a mismatched idx", () => {
|
||||
// idx 指向第 0 段,但 text 是第 2 段——内容锚优先,定位到真实原文。
|
||||
const located = locateDriftSegment(PARAS, {
|
||||
idx: 0,
|
||||
text: "他乘坐迈巴赫扬长而去,留下一地尘土。",
|
||||
});
|
||||
expect(located).toBe("他乘坐迈巴赫扬长而去,留下一地尘土。");
|
||||
});
|
||||
|
||||
it("trims the anchor before matching", () => {
|
||||
const located = locateDriftSegment(PARAS, {
|
||||
idx: 99,
|
||||
text: " 他乘坐迈巴赫扬长而去,留下一地尘土。 ",
|
||||
});
|
||||
expect(located).toBe("他乘坐迈巴赫扬长而去,留下一地尘土。");
|
||||
});
|
||||
|
||||
it("returns null when the anchor text is not found (#9 guard — no silent no-op)", () => {
|
||||
// 自带原文但终稿里已被改动——不回退到可能错位的 idx,判定为定位失败。
|
||||
expect(
|
||||
locateDriftSegment(PARAS, { idx: 1, text: "他乘坐保时捷离开了。" }),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("falls back to positional idx when no text anchor (legacy data)", () => {
|
||||
expect(locateDriftSegment(PARAS, { idx: 1, text: "" })).toBe(
|
||||
"他乘坐迈巴赫扬长而去,留下一地尘土。",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns null when no anchor and idx is out of range (#9 guard)", () => {
|
||||
expect(locateDriftSegment(PARAS, { idx: 9, text: "" })).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when no anchor and the positional paragraph is blank", () => {
|
||||
expect(locateDriftSegment(["", " "], { idx: 0, text: "" })).toBeNull();
|
||||
});
|
||||
});
|
||||
26
apps/web/lib/style/locateSegment.ts
Normal file
26
apps/web/lib/style/locateSegment.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
// 文风漂移段的「内容锚」定位(QA H3 / #9):用漂移段携带的原文 `text` 在终稿里做
|
||||
// 内容匹配定位回炉目标,而不是用位置索引 idx——因为审稿端的分段方式与前端按空行切段
|
||||
// 不一定一致,靠 idx 取段会命中错段、越界则静默落空。纯逻辑,便于 node 环境单测。
|
||||
|
||||
import type { StyleDriftSegment } from "@/lib/review/sse";
|
||||
|
||||
// 在终稿里定位漂移段原文:
|
||||
// 1) 段自带 text 且能在终稿里逐字搜到 → 返回该原文(最可靠,内容锚)。
|
||||
// 2) text 为空/搜不到(旧数据、或作者已手改导致原文不在)→ 回退到位置 idx 取段;
|
||||
// idx 越界或取到空段 → 返回 null(由调用方就 #9 给出「无法定位」提示,不静默 no-op)。
|
||||
export function locateDriftSegment(
|
||||
finalParas: readonly string[],
|
||||
segment: Pick<StyleDriftSegment, "idx" | "text">,
|
||||
): string | null {
|
||||
const anchor = segment.text.trim();
|
||||
if (anchor.length > 0) {
|
||||
// 内容匹配:原文需在某一段里出现(终稿整体含该原文即视为可定位)。
|
||||
const found = finalParas.some((p) => p.includes(anchor));
|
||||
if (found) return anchor;
|
||||
// 自带原文但终稿里搜不到(已被手改)→ 不回退到可能错位的 idx,直接判定为定位失败。
|
||||
return null;
|
||||
}
|
||||
// 无内容锚(旧数据)→ 退回位置 idx 取段(兼容旧留痕)。
|
||||
const para = finalParas[segment.idx]?.trim() ?? "";
|
||||
return para.length > 0 ? para : null;
|
||||
}
|
||||
@@ -59,13 +59,13 @@ describe("normalizeFingerprint", () => {
|
||||
});
|
||||
|
||||
describe("normalizeStyleDrift", () => {
|
||||
it("tightens style dict into report, filtering bad segments", () => {
|
||||
it("tightens style dict into report, carrying text anchor, filtering bad segments", () => {
|
||||
const out = normalizeStyleDrift(
|
||||
reviewItem({
|
||||
style: {
|
||||
score: 87,
|
||||
segments: [
|
||||
{ idx: 3, score: 60, label: "口语化" },
|
||||
{ idx: 3, text: "他乘坐迈巴赫扬长而去。", score: 60, label: "口语化" },
|
||||
{ idx: 5, score: 72 },
|
||||
"junk",
|
||||
],
|
||||
@@ -75,12 +75,24 @@ describe("normalizeStyleDrift", () => {
|
||||
expect(out).toEqual({
|
||||
score: 87,
|
||||
segments: [
|
||||
{ idx: 3, score: 60, label: "口语化" },
|
||||
{ idx: 5, score: 72, label: null },
|
||||
{ idx: 3, text: "他乘坐迈巴赫扬长而去。", score: 60, label: "口语化" },
|
||||
{ idx: 5, text: "", score: 72, label: null },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("defaults missing segment text to empty string", () => {
|
||||
const out = normalizeStyleDrift(
|
||||
reviewItem({ style: { score: 80, segments: [{ idx: 0, score: 50 }] } }),
|
||||
);
|
||||
expect(out?.segments[0]).toEqual({
|
||||
idx: 0,
|
||||
text: "",
|
||||
score: 50,
|
||||
label: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("defaults score to 100 (degrade态) and returns null when missing", () => {
|
||||
expect(normalizeStyleDrift(reviewItem({ style: {} }))).toEqual({
|
||||
score: 100,
|
||||
@@ -92,10 +104,22 @@ describe("normalizeStyleDrift", () => {
|
||||
});
|
||||
|
||||
describe("narrowStyleEvent", () => {
|
||||
it("narrows SSE style event data with label fallback", () => {
|
||||
it("narrows SSE style event data with text anchor + label fallback", () => {
|
||||
expect(
|
||||
narrowStyleEvent({
|
||||
score: 90,
|
||||
segments: [{ idx: 1, text: "原文锚", score: 50 }],
|
||||
}),
|
||||
).toEqual({
|
||||
score: 90,
|
||||
segments: [{ idx: 1, text: "原文锚", score: 50, label: null }],
|
||||
});
|
||||
expect(
|
||||
narrowStyleEvent({ score: 90, segments: [{ idx: 1, score: 50 }] }),
|
||||
).toEqual({ score: 90, segments: [{ idx: 1, score: 50, label: null }] });
|
||||
).toEqual({
|
||||
score: 90,
|
||||
segments: [{ idx: 1, text: "", score: 50, label: null }],
|
||||
});
|
||||
});
|
||||
|
||||
it("returns degrade态 for non-object data", () => {
|
||||
|
||||
@@ -52,6 +52,10 @@ function asOptionalString(v: unknown): string | null {
|
||||
return typeof v === "string" ? v : null;
|
||||
}
|
||||
|
||||
function asString(v: unknown): string {
|
||||
return typeof v === "string" ? v : "";
|
||||
}
|
||||
|
||||
// 把松散 style dict 收窄成漂移报告;缺失/非 dict → null(不渲染)。
|
||||
export function normalizeStyleDrift(
|
||||
item: ReviewHistoryItem | undefined,
|
||||
@@ -64,6 +68,7 @@ export function normalizeStyleDrift(
|
||||
.filter((s): s is Record<string, unknown> => typeof s === "object" && s !== null)
|
||||
.map((s) => ({
|
||||
idx: asInt(s["idx"]),
|
||||
text: asString(s["text"]),
|
||||
score: asInt(s["score"], 100),
|
||||
label: asOptionalString(s["label"]),
|
||||
}));
|
||||
@@ -82,6 +87,7 @@ export function narrowStyleEvent(data: unknown): StyleDriftReport {
|
||||
.filter((s): s is Record<string, unknown> => typeof s === "object" && s !== null)
|
||||
.map((s) => ({
|
||||
idx: asInt(s["idx"]),
|
||||
text: asString(s["text"]),
|
||||
score: asInt(s["score"], 100),
|
||||
label: asOptionalString(s["label"]),
|
||||
}));
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -16,7 +16,7 @@
|
||||
"outliner": "3086ba81fe8028687bf079db2c2fb227ba5a162b7fc09210914e6f4ebd9c0d2e",
|
||||
"pace": "c6a023cb93fde4879a0fb93cd28e0227694a4cf867e64e768319ee3135d82746",
|
||||
"refiner": "65a4baa298bedce4592829c02d6a16c15a19ef6fdbb2098278f9decc8ffeb813",
|
||||
"style": "728d9ad2379d7923e1927c9de2b7b512ea8b39faef122a60005215aa069823f1",
|
||||
"style": "3a7b2c078b62e4e08f62af91ecfae8d9a27adc598ca60f59927c3bcd136393c5",
|
||||
"style_extract": "998c30ea0d0eab3936e1d6b319e832645eefaa7f7dd4a1c86d5c1ec9467e8b8c",
|
||||
"teardown": "4fb7335c3e79e19ef276f199011a5de888330fc7f91a53dd483a6531b94b973e",
|
||||
"worldbuilder": "3bf578c2df3018e0f969949c760bb8d214639df4aa17aa904d3ce803c6ddcc3e"
|
||||
|
||||
@@ -39,7 +39,8 @@
|
||||
|
||||
- `score`:整数,整章相对文风指纹的**整体相似度**(0–100,越高越贴合)。无指纹降级时为 `100`。
|
||||
- `segments`:漂移段清单(数组)。无漂移段(含无指纹降级)则为**空数组** `[]`。每个元素:
|
||||
- `idx`:整数,该段在本章的段索引(**0 起**,必须与正文切分顺序严格一致,便于前端朱砂标注与一键回炉对齐);
|
||||
- `idx`:整数,该段在本章的段索引(**0 起**,仅供前端展示排序);
|
||||
- `text`:字符串,**从本章草稿里逐字摘录**的该漂移段原文(含标点,**一字不改、不要改写或省略**)。前端靠这段原文在终稿里做内容匹配来定位回炉目标,所以它必须是草稿中真实存在、可被原样搜到的连续片段;
|
||||
- `score`:整数,该段相对文风指纹的相似度(0–100,**越低越偏离**);
|
||||
- `label`:字符串,漂移类型说明(如「机翻腔」「叙述拖沓」),可缺省(无合适标签时省略)。
|
||||
|
||||
@@ -47,4 +48,4 @@
|
||||
|
||||
- 只读、只报漂移诊断,**不改稿、不写库**(不变量 #3)。
|
||||
- 依据指纹判定,**不臆造**;无明显漂移段则 `segments` 为空列表。
|
||||
- `idx` 必须与正文段切分顺序一致;段级 `score` 越低代表越偏离。
|
||||
- 每个漂移段的 `text` 必须是草稿里**逐字可搜到**的连续原文(含标点、不改写、不省略),否则前端无法定位、回炉会落空;段级 `score` 越低代表越偏离。
|
||||
|
||||
@@ -194,13 +194,19 @@ class StyleFingerprintResult(BaseModel):
|
||||
|
||||
|
||||
class StyleDriftSegment(BaseModel):
|
||||
"""单个漂移段:段索引 + 相似度分 + 可选标签(ARCH §5.4 打分轨)。
|
||||
"""单个漂移段:原文锚 + 段索引 + 相似度分 + 可选标签(ARCH §5.4 打分轨)。
|
||||
|
||||
`idx` 是本章段索引(0 起),`score` 是该段相对文风指纹的相似度(0–100,越低越偏);
|
||||
`label` 可缺(如「机翻腔」「叙述拖沓」等漂移类型说明)。
|
||||
`text` 是从本章草稿**逐字摘录**的该漂移段原文(含标点,不得改写)——前端据此用
|
||||
内容匹配在终稿里定位回炉目标(**内容锚**,不靠位置 idx,避免分段方式不一致导致定位
|
||||
到错段 / 越界静默失败)。`idx` 仅供前端展示排序(0 起);`score` 是该段相对文风指纹
|
||||
的相似度(0–100,越低越偏);`label` 可缺(如「机翻腔」「叙述拖沓」等漂移类型说明)。
|
||||
"""
|
||||
|
||||
idx: int = Field(description="本章段索引(0 起)")
|
||||
idx: int = Field(description="本章段索引(0 起,仅供展示排序)")
|
||||
text: str = Field(
|
||||
default="",
|
||||
description="该漂移段从本章草稿**逐字摘录**的原文(含标点,不得改写);供前端内容锚定位回炉目标",
|
||||
)
|
||||
score: int = Field(description="该段相对文风指纹的相似度(0–100,越低越偏离)")
|
||||
label: str | None = Field(default=None, description="漂移类型标签(如「机翻腔」);可缺")
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ from dataclasses import dataclass, field
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
from ww_core.domain.rule_repo import RuleWriteRepo, RuleWriteView
|
||||
from ww_core.domain.rule_repo import RuleListItemView, RuleWriteRepo, RuleWriteView
|
||||
|
||||
PROJECT = uuid.UUID("00000000-0000-0000-0000-000000000001")
|
||||
|
||||
@@ -23,11 +23,25 @@ class _FakeRuleWriteRepo:
|
||||
flushed: int = 0
|
||||
|
||||
async def create(self, project_id: uuid.UUID, *, level: str, content: str) -> RuleWriteView:
|
||||
view = RuleWriteView(project_id=project_id, level=level, content=content)
|
||||
view = RuleWriteView(id=uuid.uuid4(), project_id=project_id, level=level, content=content)
|
||||
self.rows.append(view)
|
||||
self.flushed += 1
|
||||
return view
|
||||
|
||||
async def list_for_project(self, project_id: uuid.UUID) -> list[RuleListItemView]:
|
||||
return [
|
||||
RuleListItemView(id=r.id, level=r.level, content=r.content)
|
||||
for r in self.rows
|
||||
if r.project_id == project_id
|
||||
]
|
||||
|
||||
async def delete(self, project_id: uuid.UUID, rule_id: uuid.UUID) -> bool:
|
||||
for r in self.rows:
|
||||
if r.id == rule_id and r.project_id == project_id:
|
||||
self.rows.remove(r)
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_returns_view() -> None:
|
||||
@@ -41,6 +55,6 @@ async def test_create_returns_view() -> None:
|
||||
|
||||
|
||||
def test_rule_view_is_frozen() -> None:
|
||||
view = RuleWriteView(project_id=PROJECT, level="global", content="x")
|
||||
view = RuleWriteView(id=uuid.uuid4(), project_id=PROJECT, level="global", content="x")
|
||||
with pytest.raises(ValidationError):
|
||||
view.content = "y"
|
||||
|
||||
@@ -54,7 +54,12 @@ from ww_core.domain.project_repo import (
|
||||
)
|
||||
from ww_core.domain.repositories import DigestView, MemoryRepos
|
||||
from ww_core.domain.review_repo import ReviewRepo, ReviewView, SqlReviewRepo
|
||||
from ww_core.domain.rule_repo import RuleWriteRepo, RuleWriteView, SqlRuleWriteRepo
|
||||
from ww_core.domain.rule_repo import (
|
||||
RuleListItemView,
|
||||
RuleWriteRepo,
|
||||
RuleWriteView,
|
||||
SqlRuleWriteRepo,
|
||||
)
|
||||
from ww_core.domain.style_repo import (
|
||||
SqlStyleFingerprintWriteRepo,
|
||||
StyleFingerprintView,
|
||||
@@ -116,6 +121,7 @@ __all__ = [
|
||||
"ReviewRepo",
|
||||
"ReviewView",
|
||||
"SqlReviewRepo",
|
||||
"RuleListItemView",
|
||||
"RuleWriteView",
|
||||
"RuleWriteRepo",
|
||||
"SqlRuleWriteRepo",
|
||||
|
||||
@@ -24,6 +24,7 @@ import uuid
|
||||
from typing import Any, Protocol
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from ww_db.models import Character
|
||||
|
||||
@@ -52,6 +53,10 @@ class CharacterWriteRepo(Protocol):
|
||||
"""角色写侧接口(按 project_id 隔离;只 flush 不 commit)。
|
||||
|
||||
入参贴 `ww_agents.CharacterCard`(生成产物形);实现负责 schema → DB 列形变。
|
||||
|
||||
**幂等(按 (project_id, name) upsert)**:同名重复入库 → 更新既有卡而非插重复行。
|
||||
不加 DB UNIQUE 约束(线上库已存历史重复行,约束迁移会失败);改在 app 层
|
||||
get-by-(project_id, name) → update else insert(QA #8)。
|
||||
"""
|
||||
|
||||
async def create(
|
||||
@@ -70,7 +75,11 @@ class CharacterWriteRepo(Protocol):
|
||||
|
||||
|
||||
class SqlCharacterWriteRepo:
|
||||
"""SQLAlchemy 实现:插一行 `characters`(schema→DB 形变;只 flush 不 commit)。"""
|
||||
"""SQLAlchemy 实现:upsert 一行 `characters`(schema→DB 形变;只 flush 不 commit)。
|
||||
|
||||
幂等:先按 (project_id, name) 查既有行——命中则原地更新该行字段,否则插新行。
|
||||
避免同名角色重复入库产生重复卡(QA #8;不加 DB 约束,纯 app 层)。
|
||||
"""
|
||||
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self._s = session
|
||||
@@ -88,6 +97,22 @@ class SqlCharacterWriteRepo:
|
||||
tags: list[Any],
|
||||
relations: list[dict[str, Any]],
|
||||
) -> CharacterWriteView:
|
||||
existing = (
|
||||
await self._s.execute(
|
||||
select(Character).where(Character.project_id == project_id, Character.name == name)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if existing is not None:
|
||||
existing.role = role
|
||||
existing.traits = _traits_to_jsonb(traits)
|
||||
existing.backstory = backstory
|
||||
existing.arc = _arc_to_jsonb(arc)
|
||||
existing.speech_tics = _traits_to_jsonb(speech_tics)
|
||||
existing.tags = list(tags)
|
||||
existing.relations = [dict(r) for r in relations]
|
||||
await self._s.flush()
|
||||
await self._s.refresh(existing)
|
||||
return CharacterWriteView(id=existing.id, name=existing.name, role=existing.role)
|
||||
row = Character(
|
||||
project_id=project_id,
|
||||
name=name,
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
"""规则**写侧** Repository(C3 扩 / PRODUCT_SPEC §7 `POST /projects/:id/rules`)。
|
||||
|
||||
读侧(`all_for_project`,供 assemble 注入 + `merge_rules` 四级合并)已在
|
||||
`ww_core.memory.sql_repositories.SqlRulesRepo` 提供(C5,不动)。本模块加**写**能力,
|
||||
命名加 `Write` 前缀避免歧义(同 `OutlineWriteRepo`/`DigestAppendRepo` 先例)。
|
||||
`ww_core.memory.sql_repositories.SqlRulesRepo` 提供(C5,不动;其 `RuleView` 不带 id,
|
||||
专供缓存前缀拼装)。本模块加**写**能力 + **带 id 的列表 / 删除**——前端规则页需要稳定
|
||||
handle(id)来删除某条规则(不变量 #3:删规则是作者显式动作,不经 AI)。命名加 `Write`
|
||||
前缀避免与读侧 repo 歧义(同 `OutlineWriteRepo`/`DigestAppendRepo` 先例)。
|
||||
|
||||
`level` ∈ global/genre/style/project(合法性由端点/schema 校验,repo 只写)。
|
||||
**提交边界**:`create` 只 `flush()` 不 `commit()`——提交交端点事务(与项目其它写侧
|
||||
repo 一致,见 memory/gotchas)。
|
||||
**提交边界**:`create`/`delete` 只 `flush()` 不 `commit()`——提交交端点事务(与项目其它
|
||||
写侧 repo 一致,见 memory/gotchas)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -15,6 +17,7 @@ import uuid
|
||||
from typing import Protocol
|
||||
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from ww_db.models import Rule
|
||||
|
||||
@@ -24,19 +27,34 @@ class RuleWriteView(BaseModel):
|
||||
|
||||
model_config = {"frozen": True}
|
||||
|
||||
id: uuid.UUID
|
||||
project_id: uuid.UUID | None
|
||||
level: str
|
||||
content: str
|
||||
|
||||
|
||||
class RuleListItemView(BaseModel):
|
||||
"""规则列表项(带 id,供前端规则页删除用;snake_case,frozen)。"""
|
||||
|
||||
model_config = {"frozen": True}
|
||||
|
||||
id: uuid.UUID
|
||||
level: str
|
||||
content: str
|
||||
|
||||
|
||||
class RuleWriteRepo(Protocol):
|
||||
"""规则写侧接口(绑 project_id;只 flush 不 commit)。"""
|
||||
|
||||
async def create(self, project_id: uuid.UUID, *, level: str, content: str) -> RuleWriteView: ...
|
||||
|
||||
async def list_for_project(self, project_id: uuid.UUID) -> list[RuleListItemView]: ...
|
||||
|
||||
async def delete(self, project_id: uuid.UUID, rule_id: uuid.UUID) -> bool: ...
|
||||
|
||||
|
||||
class SqlRuleWriteRepo:
|
||||
"""SQLAlchemy 实现:插一行 `rules`(只 flush 不 commit)。"""
|
||||
"""SQLAlchemy 实现:插一行 `rules` / 列表(带 id)/ 删一行(均只 flush 不 commit)。"""
|
||||
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self._s = session
|
||||
@@ -46,4 +64,37 @@ class SqlRuleWriteRepo:
|
||||
self._s.add(row)
|
||||
await self._s.flush()
|
||||
await self._s.refresh(row)
|
||||
return RuleWriteView(project_id=row.project_id, level=row.level, content=row.content)
|
||||
return RuleWriteView(
|
||||
id=row.id, project_id=row.project_id, level=row.level, content=row.content
|
||||
)
|
||||
|
||||
async def list_for_project(self, project_id: uuid.UUID) -> list[RuleListItemView]:
|
||||
"""本作品规则 + 全局规则(project_id 为空),带 id(前端删除 handle)。
|
||||
|
||||
与读侧 `SqlRulesRepo.all_for_project` 同范围,但回传 id;排序确定性
|
||||
(level 优先级在 assemble.merge_rules 处理,这里只保证稳定返回)。
|
||||
"""
|
||||
rows = (
|
||||
await self._s.execute(
|
||||
select(Rule).where((Rule.project_id == project_id) | (Rule.project_id.is_(None)))
|
||||
)
|
||||
).scalars()
|
||||
return [RuleListItemView(id=r.id, level=r.level, content=r.content) for r in rows]
|
||||
|
||||
async def delete(self, project_id: uuid.UUID, rule_id: uuid.UUID) -> bool:
|
||||
"""删除属于该项目的某条规则;删到行返回 True,无匹配返回 False(→ 端点 404)。
|
||||
|
||||
以 `(id, project_id)` 双条件定位——既防误删它项目规则,也使删全局规则(project_id
|
||||
为空)经此端点天然不可达(项目维度规则页只删本作品规则,不变量 #3 作者显式动作)。
|
||||
先取行再 `session.delete`(避免 bulk-delete 的 rowcount 类型坑),只 flush,提交交端点。
|
||||
"""
|
||||
row = (
|
||||
await self._s.execute(
|
||||
select(Rule).where(Rule.id == rule_id, Rule.project_id == project_id)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if row is None:
|
||||
return False
|
||||
await self._s.delete(row)
|
||||
await self._s.flush()
|
||||
return True
|
||||
|
||||
Reference in New Issue
Block a user