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

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