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:
Yaojia Wang
2026-06-25 12:53:03 +02:00
parent e60eff7aa1
commit bf39f50b2f
33 changed files with 907 additions and 99 deletions

View File

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

View File

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

View File

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

View File

@@ -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"

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.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",

View File

@@ -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 insertQA #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,

View File

@@ -1,12 +1,14 @@
"""规则**写侧** RepositoryC3 扩 / 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 的列表 / 删除**——前端规则页需要稳定
handleid来删除某条规则不变量 #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_casefrozen"""
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