merge: 实施 4 组设计型 QA 项(规则删除/codex 角色/大纲卷过滤/文风回炉锚点)

This commit is contained in:
Yaojia Wang
2026-06-25 12:53:03 +02:00
33 changed files with 907 additions and 99 deletions

View File

@@ -30,7 +30,7 @@ from ww_agents import (
) )
from ww_core.domain.character_repo import CharacterWriteView from ww_core.domain.character_repo import CharacterWriteView
from ww_core.domain.project_repo import ProjectCreate 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_llm_gateway.types import LlmRequest, LlmResponse, ServedBy, Usage
from ww_shared import AppError, ErrorCode from ww_shared import AppError, ErrorCode
from ww_skills import SkillRegistry from ww_skills import SkillRegistry
@@ -81,8 +81,27 @@ class _FakeCharacterWriteRepo:
tags: list[Any], tags: list[Any],
relations: list[dict[str, Any]], relations: list[dict[str, Any]],
) -> CharacterWriteView: ) -> 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( self.rows.append(
{ {
"id": row_id,
"project_id": project_id, "project_id": project_id,
"name": name, "name": name,
"role": role, "role": role,
@@ -93,14 +112,16 @@ class _FakeCharacterWriteRepo:
"relations": [dict(r) for r in relations], "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: 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 [] 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) return list(self._rules)
@@ -170,7 +191,7 @@ def _make_app(
get_memory_repos, get_memory_repos,
get_precheck_gateway, get_precheck_gateway,
get_project_repo, get_project_repo,
get_rules_read_repo, get_rule_write_repo,
get_skill_registry, get_skill_registry,
get_worldbuilder_gateway, get_worldbuilder_gateway,
) )
@@ -190,7 +211,7 @@ def _make_app(
(lambda: memory) if memory is not None else _empty_memory_repos (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_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_skill_registry] = lambda: registry
app.dependency_overrides[get_session] = lambda: session app.dependency_overrides[get_session] = lambda: session
gw = _raise_no_creds if no_creds else (lambda: gateway) 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 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,
}
]
},
)
# Assertrelations 持久化(写侧未丢)。
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 带 relationsJSONB 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: async def test_list_rules_returns_rules() -> None:
repo = FakeProjectRepo() repo = FakeProjectRepo()
pid = await _seed_project(repo) pid = await _seed_project(repo)
rid1, rid2 = uuid.uuid4(), uuid.uuid4()
rules_repo = _FakeRulesReadRepo( rules_repo = _FakeRulesReadRepo(
[ [
RuleView(level="project", content="主角不复活"), RuleListItemView(id=rid1, level="project", content="主角不复活"),
RuleView(level="global", content="无脏话"), RuleListItemView(id=rid2, level="global", content="无脏话"),
] ]
) )
gateway = _SchemaRoutingGateway({}) gateway = _SchemaRoutingGateway({})
@@ -392,6 +526,8 @@ async def test_list_rules_returns_rules() -> None:
assert resp.status_code == 200 assert resp.status_code == 200
rules = resp.json()["rules"] rules = resp.json()["rules"]
assert [r["content"] for r in rules] == ["主角不复活", "无脏话"] assert [r["content"] for r in rules] == ["主角不复活", "无脏话"]
# 列表带稳定 id前端删除 handle
assert [r["id"] for r in rules] == [str(rid1), str(rid2)]
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -466,6 +602,53 @@ def _codex_memory() -> Any:
) )
def _codex_memory_with_relations() -> Any:
"""MemoryRepos角色行携 relationsJSONB 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 @pytest.mark.asyncio
async def test_list_characters_unpacks_jsonb_to_api_shape() -> None: async def test_list_characters_unpacks_jsonb_to_api_shape() -> None:
repo = FakeProjectRepo() repo = FakeProjectRepo()

View File

@@ -239,6 +239,31 @@ async def test_get_outline_returns_persisted_chapters_in_order_with_unpacked_bea
assert body["chapters"][1]["beats"] == ["冲突升级"] 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 @pytest.mark.asyncio
async def test_get_outline_returns_empty_list_when_no_outline() -> None: async def test_get_outline_returns_empty_list_when_no_outline() -> None:
project_repo = FakeProjectRepo() project_repo = FakeProjectRepo()

View File

@@ -17,7 +17,7 @@ from cryptography.fernet import Fernet
from fakes_projects import FakeProjectRepo, FakeSession from fakes_projects import FakeProjectRepo, FakeSession
from ww_api.services.credentials import STUB_OWNER_ID from ww_api.services.credentials import STUB_OWNER_ID
from ww_core.domain.project_repo import ProjectView 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: class _FakeRuleWriteRepo:
@@ -25,10 +25,22 @@ class _FakeRuleWriteRepo:
self.rows: list[RuleWriteView] = [] self.rows: list[RuleWriteView] = []
async def create(self, project_id: uuid.UUID, *, level: str, content: str) -> 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) self.rows.append(view)
return 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[ def _make_client() -> tuple[
httpx.AsyncClient, _FakeRuleWriteRepo, FakeSession, FakeProjectRepo, uuid.UUID httpx.AsyncClient, _FakeRuleWriteRepo, FakeSession, FakeProjectRepo, uuid.UUID
@@ -72,6 +84,8 @@ async def test_create_rule_returns_201_and_commits() -> None:
body = resp.json() body = resp.json()
assert body["level"] == "project" assert body["level"] == "project"
assert body["content"] == "主角不许中途复活" assert body["content"] == "主角不许中途复活"
# 创建回显带 id前端删除 handle
assert body["id"] == str(repo.rows[0].id)
assert session.commits == 1 assert session.commits == 1
assert len(repo.rows) == 1 assert len(repo.rows) == 1
assert repo.rows[0].project_id == pid 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.status_code == 404
assert resp.json()["error"]["code"] == "NOT_FOUND" assert resp.json()["error"]["code"] == "NOT_FOUND"
assert len(repo.rows) == 0 # 未触达写库 assert len(repo.rows) == 0 # 未触达写库
@pytest.mark.asyncio
async def test_delete_rule_returns_204_and_commits() -> None:
# 作者显式删一条本作品规则(不变量 #3204 + 端点提交 + 行被移除。
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

View File

@@ -103,7 +103,7 @@ class _FakeRuleWriteRepo:
async def create(self, project_id: uuid.UUID, *, level: str, content: str) -> RuleWriteView: async def create(self, project_id: uuid.UUID, *, level: str, content: str) -> RuleWriteView:
self.rows.append({"project_id": project_id, "level": level, "content": content}) 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: class _FakeOutlineReadRepo:

View File

@@ -19,7 +19,7 @@ ledger否则 usage 静默丢失,同 draft/review 纪律)。无凭据 →
from __future__ import annotations from __future__ import annotations
import uuid import uuid
from typing import Annotated from typing import Annotated, Any
from fastapi import APIRouter, Depends, Request from fastapi import APIRouter, Depends, Request
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@@ -33,8 +33,9 @@ from ww_agents import (
from ww_core.domain import ( from ww_core.domain import (
CharacterWriteRepo, CharacterWriteRepo,
ProjectRepo, ProjectRepo,
RuleWriteRepo,
) )
from ww_core.domain.repositories import MemoryRepos, RulesRepo from ww_core.domain.repositories import MemoryRepos
from ww_core.orchestrator import ( from ww_core.orchestrator import (
precheck_generated_cards, precheck_generated_cards,
run_character_gen, run_character_gen,
@@ -71,7 +72,7 @@ from ww_api.services.project_deps import (
get_memory_repos, get_memory_repos,
get_precheck_gateway, get_precheck_gateway,
get_project_repo, get_project_repo,
get_rules_read_repo, get_rule_write_repo,
get_skill_registry, get_skill_registry,
get_worldbuilder_gateway, get_worldbuilder_gateway,
) )
@@ -84,7 +85,7 @@ skills_router = APIRouter(prefix="/skills", tags=["skills"])
ProjectRepoDep = Annotated[ProjectRepo, Depends(get_project_repo)] ProjectRepoDep = Annotated[ProjectRepo, Depends(get_project_repo)]
MemoryReposDep = Annotated[MemoryRepos, Depends(get_memory_repos)] MemoryReposDep = Annotated[MemoryRepos, Depends(get_memory_repos)]
CharacterWriteRepoDep = Annotated[CharacterWriteRepo, Depends(get_character_write_repo)] 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)] SkillRegistryDep = Annotated[SkillRegistry, Depends(get_skill_registry)]
WorldGatewayDep = Annotated[Gateway, Depends(get_worldbuilder_gateway)] WorldGatewayDep = Annotated[Gateway, Depends(get_worldbuilder_gateway)]
CharacterGatewayDep = Annotated[Gateway, Depends(get_character_gen_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) 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]: async def _existing_characters(memory: MemoryRepos, project_id: uuid.UUID) -> list[CharacterCard]:
"""把已有角色读侧视图转成 `CharacterCard`(喂防雷同 / precheck """把已有角色读侧视图转成 `CharacterCard`(喂防雷同 / precheck / 设定库读端点)。
DB JSONB dict 列 → schema list/str 反向解包(与写侧形变互逆;缺则空/占位)。 DB JSONB dict 列 → schema list/str 反向解包(与写侧形变互逆;缺则空/占位)。
`relations` 从 JSONB list 还原(设定库 Codex 需展示关系网precheck 不读此字段,无害)。
""" """
views = await memory.character.list_for_project(project_id) views = await memory.character.list_for_project(project_id)
cards: list[CharacterCard] = [] cards: list[CharacterCard] = []
@@ -133,7 +152,7 @@ async def _existing_characters(memory: MemoryRepos, project_id: uuid.UUID) -> li
arc=arc or "", arc=arc or "",
speech_tics=tics, speech_tics=tics,
tags=list(v.tags or []), tags=list(v.tags or []),
relations=[], relations=_relations_from_jsonb(list(v.relations or [])),
) )
) )
return cards return cards
@@ -377,14 +396,16 @@ async def list_world_entities(
@router.get("/{project_id}/rules") @router.get("/{project_id}/rules")
async def list_rules( async def list_rules(
project_id: uuid.UUID, project_id: uuid.UUID,
repo: RulesReadRepoDep, repo: RuleRepoDep,
project_repo: ProjectRepoDep, project_repo: ProjectRepoDep,
) -> RuleListResponse: ) -> RuleListResponse:
"""规则列表(按读侧顺序)。规则页用。项目不存在 → 404QA MEDIUM此前返误导性空 200""" """规则列表(带 id供前端删除 handle。项目不存在 → 404QA MEDIUM此前返误导性空 200"""
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 not found: {project_id}") raise AppError(ErrorCode.NOT_FOUND, f"project not found: {project_id}")
rules = await repo.all_for_project(project_id) rules = await repo.list_for_project(project_id)
return RuleListResponse(rules=[RuleView(level=r.level, content=r.content) for r in rules]) return RuleListResponse(
rules=[RuleView(id=r.id, level=r.level, content=r.content) for r in rules]
)
@skills_router.get("") @skills_router.get("")

View File

@@ -143,10 +143,12 @@ async def get_outline(
request: Request, request: Request,
project_repo: ProjectRepoDep, project_repo: ProjectRepoDep,
outline_repo: OutlineReadRepoDep, outline_repo: OutlineReadRepoDep,
volume: int | None = None,
) -> OutlineResponse: ) -> OutlineResponse:
"""读取已持久化的大纲(逐章,按 chapter_no 升序)。 """读取已持久化的大纲(逐章,按 chapter_no 升序)。
项目不存在 → 404项目存在但尚无大纲 → 200 空列表(非 404页面初次访问的常态)。 可选 `?volume=N`:只返回该卷的章节(前端按卷切换);不传 → 全部章节(向后兼容)。
项目不存在 → 404项目存在但尚无大纲或该卷无章节→ 200 空列表(非 404
DB `outline.beats` 是 JSONB `{"beats": [...]}` → 解包成裸 `list[str]`(与 POST 响应同形, DB `outline.beats` 是 JSONB `{"beats": [...]}` → 解包成裸 `list[str]`(与 POST 响应同形,
前端 OpenAPI 类型对齐)。读侧复用 C5 assemble 的 `OutlineRepo`,不写库。 前端 OpenAPI 类型对齐)。读侧复用 C5 assemble 的 `OutlineRepo`,不写库。
""" """
@@ -157,6 +159,8 @@ async def get_outline(
raise AppError(ErrorCode.NOT_FOUND, f"project not found: {project_id}") raise AppError(ErrorCode.NOT_FOUND, f"project not found: {project_id}")
views = await outline_repo.list_for_project(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 = [ chapters = [
OutlineChapterView( OutlineChapterView(
no=view.chapter_no, no=view.chapter_no,
@@ -171,6 +175,7 @@ async def get_outline(
"outline_read", "outline_read",
project_id=str(project_id), project_id=str(project_id),
request_id=request_id, request_id=request_id,
volume=volume,
chapter_count=len(chapters), chapter_count=len(chapters),
) )
return OutlineResponse(chapters=chapters) return OutlineResponse(chapters=chapters)

View File

@@ -15,7 +15,7 @@ from __future__ import annotations
import uuid import uuid
from typing import Annotated from typing import Annotated
from fastapi import APIRouter, Depends, Request from fastapi import APIRouter, Depends, Request, Response
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from ww_core.domain import RuleWriteRepo from ww_core.domain import RuleWriteRepo
from ww_core.domain.project_repo import ProjectRepo from ww_core.domain.project_repo import ProjectRepo
@@ -60,4 +60,34 @@ async def create_rule(
request_id=request_id, request_id=request_id,
level=body.level, 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)

View File

@@ -7,6 +7,7 @@ snake_case前端经 OpenAPI 生成 TS 类型消费。改字段 → 前端必
from __future__ import annotations from __future__ import annotations
import uuid
from typing import Annotated, Literal from typing import Annotated, Literal
from pydantic import BaseModel, Field, StringConstraints from pydantic import BaseModel, Field, StringConstraints
@@ -25,7 +26,11 @@ class RuleCreateRequest(BaseModel):
class RuleView(BaseModel): class RuleView(BaseModel):
"""规则视图创建后回显snake_case""" """规则视图(创建后回显 + 列表项snake_case
`id` 是该规则行的稳定主键——前端规则页用它作删除 handleDELETE /rules/{rule_id})。
"""
id: uuid.UUID
level: str level: str
content: str content: str

View File

@@ -87,13 +87,28 @@ export function CodexPage({
{characters.length} {characters.length}
</h3> </h3>
{characters.length > 0 ? ( {characters.length > 0 ? (
<ul className="flex flex-wrap gap-2"> <ul className="flex flex-col gap-2">
{characters.map((c, i) => ( {characters.map((c, i) => (
<li <li
key={`${c.name}-${i}`} 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"
> >
<span className="text-ink">
{c.name}{c.role} {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> </li>
))} ))}
</ul> </ul>

View File

@@ -4,7 +4,12 @@ import { useMemo, useState } from "react";
import { AppShell } from "@/components/AppShell"; import { AppShell } from "@/components/AppShell";
import type { OutlineChapterView, ProjectResponse } from "@/lib/api/types"; 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 { useOutline } from "@/lib/outline/useOutline";
import { OutlineChapterRow } from "./OutlineChapterRow"; import { OutlineChapterRow } from "./OutlineChapterRow";
@@ -21,7 +26,13 @@ export function OutlineEditor({
}: OutlineEditorProps) { }: OutlineEditorProps) {
const { chapters, status, error, generate } = useOutline(initialChapters); const { chapters, status, error, generate } = useOutline(initialChapters);
const [volume, setVolume] = useState(1); 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"; const generating = status === "generating";
return ( return (
@@ -34,7 +45,32 @@ export function OutlineEditor({
<div className="flex h-[calc(100vh-var(--chrome,4rem))] flex-col p-6"> <div className="flex h-[calc(100vh-var(--chrome,4rem))] flex-col p-6">
<div className="mb-4 flex items-center gap-3"> <div className="mb-4 flex items-center gap-3">
<h1 className="font-serif text-lg text-ink"></h1> <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> </label>
<input <input
@@ -74,7 +110,9 @@ export function OutlineEditor({
<div className="min-h-0 flex-1 overflow-auto"> <div className="min-h-0 flex-1 overflow-auto">
{volumes.length === 0 ? ( {volumes.length === 0 ? (
<p className="rounded border border-dashed border-line p-6 text-sm text-ink-soft"> <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> </p>
) : ( ) : (
volumes.map((group) => ( volumes.map((group) => (

View File

@@ -36,6 +36,7 @@ import { useRefine } from "@/lib/style/useRefine";
import { useReviewStream } from "@/lib/review/useReviewStream"; import { useReviewStream } from "@/lib/review/useReviewStream";
import type { ReviewConflict, StyleDriftSegment } from "@/lib/review/sse"; import type { ReviewConflict, StyleDriftSegment } from "@/lib/review/sse";
import { normalizeStyleDrift } from "@/lib/style/style"; import { normalizeStyleDrift } from "@/lib/style/style";
import { locateDriftSegment } from "@/lib/style/locateSegment";
import { useToast } from "@/components/Toast"; import { useToast } from "@/components/Toast";
import { ThinkingIndicator } from "@/components/ThinkingIndicator"; import { ThinkingIndicator } from "@/components/ThinkingIndicator";
import { AcceptPanel } from "./AcceptPanel"; import { AcceptPanel } from "./AcceptPanel";
@@ -80,10 +81,8 @@ export function ReviewReport({
const [rewriting, setRewriting] = useState<Set<number>>(new Set()); const [rewriting, setRewriting] = useState<Set<number>>(new Set());
// 可编辑终稿 textarea 引用:供「跳转」/采纳后在正文里选中定位问题区域。 // 可编辑终稿 textarea 引用:供「跳转」/采纳后在正文里选中定位问题区域。
const editorRef = useRef<HTMLTextAreaElement>(null); const editorRef = useRef<HTMLTextAreaElement>(null);
// 回炉中漂移段null=未打开 RefineView // 回炉中漂移段在终稿里定位到的原文null=未打开 RefineView内容锚定位,非位置 idx。
const [refineSegment, setRefineSegment] = useState<StyleDriftSegment | null>( const [refineText, setRefineText] = useState<string | null>(null);
null,
);
const seededRef = useRef(false); const seededRef = useRef(false);
// 进页一次性把历史留痕(冲突 + 伏笔建议 + 节奏)种入流状态(无需重审即可裁决/查看)。 // 进页一次性把历史留痕(冲突 + 伏笔建议 + 节奏)种入流状态(无需重审即可裁决/查看)。
@@ -342,8 +341,17 @@ export function ReviewReport({
// 终稿按空行切段(大文本:仅在 finalText 变化时重算,不在每次 render split // 终稿按空行切段(大文本:仅在 finalText 变化时重算,不在每次 render split
const finalParas = useMemo(() => finalText.split(/\n{2,}/), [finalText]); 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 路径)。 // 采纳:把重写段替换终稿中的原段(首个匹配),合入终稿(经既有 draft 路径)。
const onAdopt = (original: string, refined: string): void => { 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); return prev.slice(0, idx) + refined + prev.slice(idx + original.length);
}); });
setRefineSegment(null); setRefineText(null);
toast("已合入终稿,记得验收时复核。", "success"); toast("已合入终稿,记得验收时复核。", "success");
}; };
@@ -523,15 +531,15 @@ export function ReviewReport({
<StylePanel <StylePanel
style={style} style={style}
incomplete={sectionStatus("style") === "incomplete"} incomplete={sectionStatus("style") === "incomplete"}
onRefine={setRefineSegment} onRefine={onRefineSegment}
/> />
{refineSegment !== null ? ( {refineText !== null ? (
<RefineView <RefineView
projectId={project.id} projectId={project.id}
chapterNo={chapterNo} chapterNo={chapterNo}
segment={segmentText(refineSegment.idx)} segment={refineText}
onAdopt={onAdopt} onAdopt={onAdopt}
onClose={() => setRefineSegment(null)} onClose={() => setRefineText(null)}
/> />
) : null} ) : null}
</div> </div>

View File

@@ -19,7 +19,7 @@ interface RulesPageProps {
// 规则页UX §7四级规则列表 + 新增(乐观 + 回滚)。 // 规则页UX §7四级规则列表 + 新增(乐观 + 回滚)。
export function RulesPage({ project, initialRules }: RulesPageProps) { 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 [level, setLevel] = useState<RuleLevel>("project");
const [content, setContent] = useState(""); const [content, setContent] = useState("");
const groups = useMemo(() => groupByLevel(items), [items]); const groups = useMemo(() => groupByLevel(items), [items]);
@@ -88,12 +88,23 @@ export function RulesPage({ project, initialRules }: RulesPageProps) {
<p className="text-xs text-ink-soft"></p> <p className="text-xs text-ink-soft"></p>
) : ( ) : (
<ul className="flex flex-col gap-2"> <ul className="flex flex-col gap-2">
{groups[lv].map((rule, i) => ( {groups[lv].map((rule) => (
<li <li
key={i} key={rule.id}
className="rounded border border-line bg-panel px-3 py-2 text-sm text-ink" className="flex items-start justify-between gap-3 rounded border border-line bg-panel px-3 py-2 text-sm text-ink"
> >
<span className="min-w-0 flex-1 break-words">
{rule.content} {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> </li>
))} ))}
</ul> </ul>

View File

@@ -146,6 +146,9 @@ export interface paths {
* *
* `body.directive`可选T4-b是临时本章指令直通 assemble→volatile不持久化 * `body.directive`可选T4-b是临时本章指令直通 assemble→volatile不持久化
* 无 body 的旧调用方仍可用(向后兼容)。 * 无 body 的旧调用方仍可用(向后兼容)。
*
* 项目不存在 → 404在触网关前 fail-fast否则非法 project_id 会静默烧一次付费/限流的
* LLM 调用并返回 200QA C1
*/ */
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;
@@ -286,7 +289,8 @@ export interface paths {
* Get Outline * Get Outline
* @description 读取已持久化的大纲(逐章,按 chapter_no 升序)。 * @description 读取已持久化的大纲(逐章,按 chapter_no 升序)。
* *
* 项目不存在 → 404项目存在但尚无大纲 → 200 空列表(非 404页面初次访问的常态)。 * 可选 `?volume=N`:只返回该卷的章节(前端按卷切换);不传 → 全部章节(向后兼容)。
* 项目不存在 → 404项目存在但尚无大纲或该卷无章节→ 200 空列表(非 404
* DB `outline.beats` 是 JSONB `{"beats": [...]}` → 解包成裸 `list[str]`(与 POST 响应同形, * DB `outline.beats` 是 JSONB `{"beats": [...]}` → 解包成裸 `list[str]`(与 POST 响应同形,
* 前端 OpenAPI 类型对齐)。读侧复用 C5 assemble 的 `OutlineRepo`,不写库。 * 前端 OpenAPI 类型对齐)。读侧复用 C5 assemble 的 `OutlineRepo`,不写库。
*/ */
@@ -312,13 +316,15 @@ export interface paths {
}; };
/** /**
* List Rules * List Rules
* @description 规则列表(按读侧顺序)。规则页用 * @description 规则列表(带 id供前端删除 handle。项目不存在 → 404QA MEDIUM此前返误导性空 200
*/ */
get: operations["list_rules_projects__project_id__rules_get"]; get: operations["list_rules_projects__project_id__rules_get"];
put?: never; put?: never;
/** /**
* Create Rule * Create Rule
* @description 新增一条规则201。非法 level / 空 content → FastAPI 422。 * @description 新增一条规则201。非法 level / 空 content → FastAPI 422;项目不存在 → 404
*
* 项目存在性须在 insert 前校验:否则 FK 违例会逃逸成 500QA H2而非干净的 404。
*/ */
post: operations["create_rule_projects__project_id__rules_post"]; post: operations["create_rule_projects__project_id__rules_post"];
delete?: never; delete?: never;
@@ -327,6 +333,29 @@ export interface paths {
patch?: never; patch?: never;
trace?: 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": { "/projects/{project_id}/style": {
parameters: { parameters: {
query?: never; query?: never;
@@ -1671,7 +1700,7 @@ export interface components {
level: "global" | "genre" | "style" | "project"; level: "global" | "genre" | "style" | "project";
/** /**
* Content * Content
* @description 规则正文 * @description 规则正文(首尾空白会被裁剪,不可全空白)
*/ */
content: string; content: string;
}; };
@@ -1685,9 +1714,16 @@ export interface components {
}; };
/** /**
* RuleView * RuleView
* @description 规则视图创建后回显snake_case * @description 规则视图(创建后回显 + 列表项snake_case
*
* `id` 是该规则行的稳定主键——前端规则页用它作删除 handleDELETE /rules/{rule_id})。
*/ */
RuleView: { RuleView: {
/**
* Id
* Format: uuid
*/
id: string;
/** Level */ /** Level */
level: string; level: string;
/** Content */ /** Content */
@@ -1859,8 +1895,11 @@ export interface components {
* @description 单条档位路由写入。 * @description 单条档位路由写入。
*/ */
TierRoutingInput: { TierRoutingInput: {
/** Tier */ /**
tier: string; * Tier
* @enum {string}
*/
tier: "writer" | "analyst" | "light";
/** Provider */ /** Provider */
provider: string; provider: string;
/** Model */ /** Model */
@@ -2744,7 +2783,9 @@ export interface operations {
}; };
get_outline_projects__project_id__outline_get: { get_outline_projects__project_id__outline_get: {
parameters: { parameters: {
query?: never; query?: {
volume?: number | null;
};
header?: never; header?: never;
path: { path: {
project_id: string; 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: { get_style_projects__project_id__style_get: {
parameters: { parameters: {
query?: never; query?: never;

View File

@@ -184,6 +184,19 @@ describe("mergeCharacterCards", () => {
const persisted = [card({ name: "甲" })]; const persisted = [card({ name: "甲" })];
expect(mergeCharacterCards(persisted, [])).toEqual(persisted); 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", () => { describe("mergeWorldEntities", () => {

View File

@@ -5,6 +5,8 @@ import type {
OutlineChapterView, OutlineChapterView,
} from "@/lib/api/types"; } from "@/lib/api/types";
import { import {
distinctVolumes,
filterByViewVolume,
groupByVolume, groupByVolume,
isCloseWindow, isCloseWindow,
windowBadgeLabel, 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", () => { describe("windowBadgeLabel", () => {
it("renders a range, half-open, or bare badge", () => { it("renders a range, half-open, or bare badge", () => {
expect( expect(

View File

@@ -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`。 // 伏笔窗口徽标文案:`⚑ F-012 (40-60)` / `⚑ F-012 (≤60)` / `⚑ F-012`。
export function windowBadgeLabel(window: ForeshadowWindowView): string { export function windowBadgeLabel(window: ForeshadowWindowView): string {
const from = window.expected_close_from; const from = window.expected_close_from;

View File

@@ -46,8 +46,11 @@ export interface PaceReport {
} }
// 文风漂移第四审C4 扩 T4.2):整体相似度 score + 段级漂移。 // 文风漂移第四审C4 扩 T4.2):整体相似度 score + 段级漂移。
// `text` = 该漂移段从草稿逐字摘录的原文(内容锚,供前端内容匹配定位回炉目标,
// 不靠位置 idx旧数据/降级可为空串)。
export interface StyleDriftSegment { export interface StyleDriftSegment {
idx: number; idx: number;
text: string;
score: number; score: number;
label: string | null; label: string | null;
} }
@@ -89,6 +92,7 @@ export interface StyleEvent {
score: number; score: number;
segments: { segments: {
idx: number; idx: number;
text?: string | null;
score: number; score: number;
label?: string | null; label?: string | null;
}[]; }[];
@@ -176,7 +180,14 @@ function asStyleSegments(v: unknown): StyleEvent["data"]["segments"] {
if (!Array.isArray(v)) return []; if (!Array.isArray(v)) return [];
return v.flatMap((x) => return v.flatMap((x) =>
isRecord(x) && typeof x.idx === "number" && typeof x.score === "number" 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, score: event.data.score,
segments: event.data.segments.map((s) => ({ segments: event.data.segments.map((s) => ({
idx: s.idx, idx: s.idx,
text: s.text ?? "",
score: s.score, score: s.score,
label: s.label ?? null, label: s.label ?? null,
})), })),

View File

@@ -8,15 +8,28 @@ import {
} from "./sse"; } from "./sse";
describe("parseReviewBlock — style (C4 扩 T4.2)", () => { describe("parseReviewBlock — style (C4 扩 T4.2)", () => {
it("parses a style frame", () => { it("parses a style frame carrying the text anchor", () => {
const evt = parseReviewBlock( 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({ expect(evt).toEqual({
event: "style", event: "style",
data: { data: {
score: 87, 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", () => { it("sets style report and marks reviewing", () => {
const evt: ReviewSseEvent = { const evt: ReviewSseEvent = {
event: "style", 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); const out = reduceReview(initialReviewState, evt);
expect(out.phase).toBe("reviewing"); expect(out.phase).toBe("reviewing");
expect(out.style).toEqual({ expect(out.style).toEqual({
score: 80, 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, { const first = reduceReview(initialReviewState, {
event: "style", event: "style",
data: { score: 70, segments: [{ idx: 0, score: 40 }] }, data: { score: 70, segments: [{ idx: 0, score: 40 }] },
@@ -51,7 +67,12 @@ describe("reduceReview — style (replace-style like pace)", () => {
event: "style", event: "style",
data: { score: 95, segments: [] }, 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: [] }); expect(second.style).toEqual({ score: 95, segments: [] });
}); });

View File

@@ -19,15 +19,15 @@ describe("isRuleLevel", () => {
describe("groupByLevel", () => { describe("groupByLevel", () => {
it("groups rules by level, unknown level falls to project", () => { it("groups rules by level, unknown level falls to project", () => {
const rules: RuleView[] = [ const rules: RuleView[] = [
{ level: "global", content: "g1" }, { id: "r1", level: "global", content: "g1" },
{ level: "project", content: "p1" }, { id: "r2", level: "project", content: "p1" },
{ level: "weird", content: "x1" }, { id: "r3", level: "weird", content: "x1" },
]; ];
const groups = groupByLevel(rules); 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([ expect(groups.project).toEqual([
{ level: "project", content: "p1" }, { id: "r2", level: "project", content: "p1" },
{ level: "weird", content: "x1" }, { id: "r3", level: "weird", content: "x1" },
]); ]);
expect(groups.genre).toEqual([]); expect(groups.genre).toEqual([]);
}); });

View File

@@ -16,6 +16,8 @@ export interface UseRules {
level: RuleLevel, level: RuleLevel,
content: string, content: string,
) => Promise<boolean>; ) => Promise<boolean>;
// 删除一条规则(乐观移除 + 失败回滚 + Toast
remove: (projectId: string, ruleId: string) => Promise<boolean>;
} }
// 规则页UX §7列出 + 新增规则。乐观追加,失败回滚(仿 useForeshadow // 规则页UX §7列出 + 新增规则。乐观追加,失败回滚(仿 useForeshadow
@@ -31,7 +33,12 @@ export function useRules(initial: RuleView[]): UseRules {
return false; return false;
} }
const snapshot = items; 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]); setItems((prev) => [...prev, optimistic]);
setBusy(true); setBusy(true);
try { try {
@@ -58,5 +65,29 @@ export function useRules(initial: RuleView[]): UseRules {
[items, toast], [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 };
} }

View File

@@ -59,7 +59,7 @@ export function defaultModelFor(providerId: string): string {
// 单条档位路由的可编辑草稿(纯数据)。 // 单条档位路由的可编辑草稿(纯数据)。
export interface RoutingDraft { export interface RoutingDraft {
tier: string; tier: Tier;
provider: string; provider: string;
model: string; model: string;
} }
@@ -96,7 +96,7 @@ export function applyProviderChange(
// 草稿 → PUT body 的 tier_routing只取选了 provider+model 的行(不可变)。 // 草稿 → PUT body 的 tier_routing只取选了 provider+model 的行(不可变)。
export function draftsToRoutingInput( export function draftsToRoutingInput(
drafts: RoutingDraft[], drafts: RoutingDraft[],
): { tier: string; provider: string; model: string }[] { ): { tier: Tier; provider: string; model: string }[] {
return drafts return drafts
.filter((d) => d.provider.trim() !== "" && d.model.trim() !== "") .filter((d) => d.provider.trim() !== "" && d.model.trim() !== "")
.map((d) => ({ tier: d.tier, provider: d.provider, model: d.model })); .map((d) => ({ tier: d.tier, provider: d.provider, model: d.model }));

View 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();
});
});

View 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;
}

View File

@@ -59,13 +59,13 @@ describe("normalizeFingerprint", () => {
}); });
describe("normalizeStyleDrift", () => { 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( const out = normalizeStyleDrift(
reviewItem({ reviewItem({
style: { style: {
score: 87, score: 87,
segments: [ segments: [
{ idx: 3, score: 60, label: "口语化" }, { idx: 3, text: "他乘坐迈巴赫扬长而去。", score: 60, label: "口语化" },
{ idx: 5, score: 72 }, { idx: 5, score: 72 },
"junk", "junk",
], ],
@@ -75,12 +75,24 @@ describe("normalizeStyleDrift", () => {
expect(out).toEqual({ expect(out).toEqual({
score: 87, score: 87,
segments: [ segments: [
{ idx: 3, score: 60, label: "口语化" }, { idx: 3, text: "他乘坐迈巴赫扬长而去。", score: 60, label: "口语化" },
{ idx: 5, score: 72, label: null }, { 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", () => { it("defaults score to 100 (degrade态) and returns null when missing", () => {
expect(normalizeStyleDrift(reviewItem({ style: {} }))).toEqual({ expect(normalizeStyleDrift(reviewItem({ style: {} }))).toEqual({
score: 100, score: 100,
@@ -92,10 +104,22 @@ describe("normalizeStyleDrift", () => {
}); });
describe("narrowStyleEvent", () => { 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( expect(
narrowStyleEvent({ score: 90, segments: [{ idx: 1, score: 50 }] }), 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", () => { it("returns degrade态 for non-object data", () => {

View File

@@ -52,6 +52,10 @@ function asOptionalString(v: unknown): string | null {
return typeof v === "string" ? v : null; return typeof v === "string" ? v : null;
} }
function asString(v: unknown): string {
return typeof v === "string" ? v : "";
}
// 把松散 style dict 收窄成漂移报告;缺失/非 dict → null不渲染 // 把松散 style dict 收窄成漂移报告;缺失/非 dict → null不渲染
export function normalizeStyleDrift( export function normalizeStyleDrift(
item: ReviewHistoryItem | undefined, item: ReviewHistoryItem | undefined,
@@ -64,6 +68,7 @@ export function normalizeStyleDrift(
.filter((s): s is Record<string, unknown> => typeof s === "object" && s !== null) .filter((s): s is Record<string, unknown> => typeof s === "object" && s !== null)
.map((s) => ({ .map((s) => ({
idx: asInt(s["idx"]), idx: asInt(s["idx"]),
text: asString(s["text"]),
score: asInt(s["score"], 100), score: asInt(s["score"], 100),
label: asOptionalString(s["label"]), 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) .filter((s): s is Record<string, unknown> => typeof s === "object" && s !== null)
.map((s) => ({ .map((s) => ({
idx: asInt(s["idx"]), idx: asInt(s["idx"]),
text: asString(s["text"]),
score: asInt(s["score"], 100), score: asInt(s["score"], 100),
label: asOptionalString(s["label"]), label: asOptionalString(s["label"]),
})); }));

File diff suppressed because one or more lines are too long

View File

@@ -16,7 +16,7 @@
"outliner": "3086ba81fe8028687bf079db2c2fb227ba5a162b7fc09210914e6f4ebd9c0d2e", "outliner": "3086ba81fe8028687bf079db2c2fb227ba5a162b7fc09210914e6f4ebd9c0d2e",
"pace": "c6a023cb93fde4879a0fb93cd28e0227694a4cf867e64e768319ee3135d82746", "pace": "c6a023cb93fde4879a0fb93cd28e0227694a4cf867e64e768319ee3135d82746",
"refiner": "65a4baa298bedce4592829c02d6a16c15a19ef6fdbb2098278f9decc8ffeb813", "refiner": "65a4baa298bedce4592829c02d6a16c15a19ef6fdbb2098278f9decc8ffeb813",
"style": "728d9ad2379d7923e1927c9de2b7b512ea8b39faef122a60005215aa069823f1", "style": "3a7b2c078b62e4e08f62af91ecfae8d9a27adc598ca60f59927c3bcd136393c5",
"style_extract": "998c30ea0d0eab3936e1d6b319e832645eefaa7f7dd4a1c86d5c1ec9467e8b8c", "style_extract": "998c30ea0d0eab3936e1d6b319e832645eefaa7f7dd4a1c86d5c1ec9467e8b8c",
"teardown": "4fb7335c3e79e19ef276f199011a5de888330fc7f91a53dd483a6531b94b973e", "teardown": "4fb7335c3e79e19ef276f199011a5de888330fc7f91a53dd483a6531b94b973e",
"worldbuilder": "3bf578c2df3018e0f969949c760bb8d214639df4aa17aa904d3ce803c6ddcc3e" "worldbuilder": "3bf578c2df3018e0f969949c760bb8d214639df4aa17aa904d3ce803c6ddcc3e"

View File

@@ -39,7 +39,8 @@
- `score`:整数,整章相对文风指纹的**整体相似度**0100越高越贴合。无指纹降级时为 `100` - `score`:整数,整章相对文风指纹的**整体相似度**0100越高越贴合。无指纹降级时为 `100`
- `segments`:漂移段清单(数组)。无漂移段(含无指纹降级)则为**空数组** `[]`。每个元素: - `segments`:漂移段清单(数组)。无漂移段(含无指纹降级)则为**空数组** `[]`。每个元素:
- `idx`:整数,该段在本章的段索引(**0 起**必须与正文切分顺序严格一致,便于前端朱砂标注与一键回炉对齐 - `idx`:整数,该段在本章的段索引(**0 起**仅供前端展示排序
- `text`:字符串,**从本章草稿里逐字摘录**的该漂移段原文(含标点,**一字不改、不要改写或省略**)。前端靠这段原文在终稿里做内容匹配来定位回炉目标,所以它必须是草稿中真实存在、可被原样搜到的连续片段;
- `score`整数该段相对文风指纹的相似度0100**越低越偏离** - `score`整数该段相对文风指纹的相似度0100**越低越偏离**
- `label`:字符串,漂移类型说明(如「机翻腔」「叙述拖沓」),可缺省(无合适标签时省略)。 - `label`:字符串,漂移类型说明(如「机翻腔」「叙述拖沓」),可缺省(无合适标签时省略)。
@@ -47,4 +48,4 @@
- 只读、只报漂移诊断,**不改稿、不写库**(不变量 #3)。 - 只读、只报漂移诊断,**不改稿、不写库**(不变量 #3)。
- 依据指纹判定,**不臆造**;无明显漂移段则 `segments` 为空列表。 - 依据指纹判定,**不臆造**;无明显漂移段则 `segments` 为空列表。
- `idx` 必须与正文段切分顺序一致;段级 `score` 越低代表越偏离。 - 每个漂移段的 `text` 必须是草稿里**逐字可搜到**的连续原文(含标点、不改写、不省略),否则前端无法定位、回炉会落空;段级 `score` 越低代表越偏离。

View File

@@ -194,13 +194,19 @@ class StyleFingerprintResult(BaseModel):
class StyleDriftSegment(BaseModel): class StyleDriftSegment(BaseModel):
"""单个漂移段:段索引 + 相似度分 + 可选标签ARCH §5.4 打分轨)。 """单个漂移段:原文锚 + 段索引 + 相似度分 + 可选标签ARCH §5.4 打分轨)。
`idx` 是本章段索引0 起),`score` 是该段相对文风指纹的相似度0100越低越偏 `text` 是本章草稿**逐字摘录**的该漂移段原文(含标点,不得改写)——前端据此用
`label` 可缺(如「机翻腔」「叙述拖沓」等漂移类型说明)。 内容匹配在终稿里定位回炉目标(**内容锚**,不靠位置 idx避免分段方式不一致导致定位
到错段 / 越界静默失败)。`idx` 仅供前端展示排序0 起);`score` 是该段相对文风指纹
的相似度0100越低越偏`label` 可缺(如「机翻腔」「叙述拖沓」等漂移类型说明)。
""" """
idx: int = Field(description="本章段索引0 起)") idx: int = Field(description="本章段索引0 起,仅供展示排序")
text: str = Field(
default="",
description="该漂移段从本章草稿**逐字摘录**的原文(含标点,不得改写);供前端内容锚定位回炉目标",
)
score: int = Field(description="该段相对文风指纹的相似度0100越低越偏离") score: int = Field(description="该段相对文风指纹的相似度0100越低越偏离")
label: str | None = Field(default=None, description="漂移类型标签(如「机翻腔」);可缺") label: str | None = Field(default=None, description="漂移类型标签(如「机翻腔」);可缺")

View File

@@ -12,7 +12,7 @@ from dataclasses import dataclass, field
import pytest import pytest
from pydantic import ValidationError 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") PROJECT = uuid.UUID("00000000-0000-0000-0000-000000000001")
@@ -23,11 +23,25 @@ class _FakeRuleWriteRepo:
flushed: int = 0 flushed: int = 0
async def create(self, project_id: uuid.UUID, *, level: str, content: str) -> 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) self.rows.append(view)
self.flushed += 1 self.flushed += 1
return 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 == 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 @pytest.mark.asyncio
async def test_create_returns_view() -> None: 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: 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): with pytest.raises(ValidationError):
view.content = "y" view.content = "y"

View File

@@ -54,7 +54,12 @@ from ww_core.domain.project_repo import (
) )
from ww_core.domain.repositories import DigestView, MemoryRepos from ww_core.domain.repositories import DigestView, MemoryRepos
from ww_core.domain.review_repo import ReviewRepo, ReviewView, SqlReviewRepo 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 ( from ww_core.domain.style_repo import (
SqlStyleFingerprintWriteRepo, SqlStyleFingerprintWriteRepo,
StyleFingerprintView, StyleFingerprintView,
@@ -116,6 +121,7 @@ __all__ = [
"ReviewRepo", "ReviewRepo",
"ReviewView", "ReviewView",
"SqlReviewRepo", "SqlReviewRepo",
"RuleListItemView",
"RuleWriteView", "RuleWriteView",
"RuleWriteRepo", "RuleWriteRepo",
"SqlRuleWriteRepo", "SqlRuleWriteRepo",

View File

@@ -24,6 +24,7 @@ import uuid
from typing import Any, Protocol from typing import Any, Protocol
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from ww_db.models import Character from ww_db.models import Character
@@ -52,6 +53,10 @@ class CharacterWriteRepo(Protocol):
"""角色写侧接口(按 project_id 隔离;只 flush 不 commit """角色写侧接口(按 project_id 隔离;只 flush 不 commit
入参贴 `ww_agents.CharacterCard`(生成产物形);实现负责 schema → DB 列形变。 入参贴 `ww_agents.CharacterCard`(生成产物形);实现负责 schema → DB 列形变。
**幂等(按 (project_id, name) upsert**:同名重复入库 → 更新既有卡而非插重复行。
不加 DB UNIQUE 约束(线上库已存历史重复行,约束迁移会失败);改在 app 层
get-by-(project_id, name) → update else insertQA #8
""" """
async def create( async def create(
@@ -70,7 +75,11 @@ class CharacterWriteRepo(Protocol):
class SqlCharacterWriteRepo: 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: def __init__(self, session: AsyncSession) -> None:
self._s = session self._s = session
@@ -88,6 +97,22 @@ class SqlCharacterWriteRepo:
tags: list[Any], tags: list[Any],
relations: list[dict[str, Any]], relations: list[dict[str, Any]],
) -> CharacterWriteView: ) -> 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( row = Character(
project_id=project_id, project_id=project_id,
name=name, name=name,

View File

@@ -1,12 +1,14 @@
"""规则**写侧** RepositoryC3 扩 / PRODUCT_SPEC §7 `POST /projects/:id/rules`)。 """规则**写侧** RepositoryC3 扩 / PRODUCT_SPEC §7 `POST /projects/:id/rules`)。
读侧(`all_for_project`,供 assemble 注入 + `merge_rules` 四级合并)已在 读侧(`all_for_project`,供 assemble 注入 + `merge_rules` 四级合并)已在
`ww_core.memory.sql_repositories.SqlRulesRepo` 提供C5不动)。本模块加**写**能力 `ww_core.memory.sql_repositories.SqlRulesRepo` 提供C5不动;其 `RuleView` 不带 id
命名加 `Write` 前缀避免歧义(同 `OutlineWriteRepo`/`DigestAppendRepo` 先例)。 专供缓存前缀拼装)。本模块加**写**能力 + **带 id 的列表 / 删除**——前端规则页需要稳定
handleid来删除某条规则不变量 #3删规则是作者显式动作不经 AI。命名加 `Write`
前缀避免与读侧 repo 歧义(同 `OutlineWriteRepo`/`DigestAppendRepo` 先例)。
`level` ∈ global/genre/style/project合法性由端点/schema 校验repo 只写)。 `level` ∈ global/genre/style/project合法性由端点/schema 校验repo 只写)。
**提交边界**`create` 只 `flush()` 不 `commit()`——提交交端点事务(与项目其它写侧 **提交边界**`create`/`delete` 只 `flush()` 不 `commit()`——提交交端点事务(与项目其它
repo 一致,见 memory/gotchas 写侧 repo 一致,见 memory/gotchas
""" """
from __future__ import annotations from __future__ import annotations
@@ -15,6 +17,7 @@ import uuid
from typing import Protocol from typing import Protocol
from pydantic import BaseModel from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from ww_db.models import Rule from ww_db.models import Rule
@@ -24,19 +27,34 @@ class RuleWriteView(BaseModel):
model_config = {"frozen": True} model_config = {"frozen": True}
id: uuid.UUID
project_id: uuid.UUID | None project_id: uuid.UUID | None
level: str level: str
content: str content: str
class RuleListItemView(BaseModel):
"""规则列表项(带 id供前端规则页删除用snake_casefrozen"""
model_config = {"frozen": True}
id: uuid.UUID
level: str
content: str
class RuleWriteRepo(Protocol): class RuleWriteRepo(Protocol):
"""规则写侧接口(绑 project_id只 flush 不 commit""" """规则写侧接口(绑 project_id只 flush 不 commit"""
async def create(self, project_id: uuid.UUID, *, level: str, content: str) -> RuleWriteView: ... 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: class SqlRuleWriteRepo:
"""SQLAlchemy 实现:插一行 `rules`只 flush 不 commit""" """SQLAlchemy 实现:插一行 `rules` / 列表(带 id/ 删一行(均只 flush 不 commit"""
def __init__(self, session: AsyncSession) -> None: def __init__(self, session: AsyncSession) -> None:
self._s = session self._s = session
@@ -46,4 +64,37 @@ class SqlRuleWriteRepo:
self._s.add(row) self._s.add(row)
await self._s.flush() await self._s.flush()
await self._s.refresh(row) 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