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