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

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

View File

@@ -184,6 +184,19 @@ describe("mergeCharacterCards", () => {
const persisted = [card({ name: "甲" })];
expect(mergeCharacterCards(persisted, [])).toEqual(persisted);
});
it("preserves relations on the merged cards (Codex relation display)", () => {
const persisted = [
card({
name: "甲",
relations: [{ name: "乙", kind: "宿敌", note: "灭门之仇" }],
}),
];
const out = mergeCharacterCards(persisted, []);
expect(out[0]?.relations).toEqual([
{ name: "乙", kind: "宿敌", note: "灭门之仇" },
]);
});
});
describe("mergeWorldEntities", () => {

View File

@@ -5,6 +5,8 @@ import type {
OutlineChapterView,
} from "@/lib/api/types";
import {
distinctVolumes,
filterByViewVolume,
groupByVolume,
isCloseWindow,
windowBadgeLabel,
@@ -38,6 +40,48 @@ describe("groupByVolume", () => {
});
});
describe("distinctVolumes", () => {
it("returns sorted unique volume numbers", () => {
expect(
distinctVolumes([
ch({ no: 1, volume: 2 }),
ch({ no: 2, volume: 1 }),
ch({ no: 3, volume: 2 }),
]),
).toEqual([1, 2]);
});
it("handles undefined", () => {
expect(distinctVolumes(undefined)).toEqual([]);
});
});
describe("filterByViewVolume", () => {
const chapters = [
ch({ no: 1, volume: 1 }),
ch({ no: 2, volume: 1 }),
ch({ no: 3, volume: 2 }),
];
it("returns all chapters when view is 'all'", () => {
expect(filterByViewVolume(chapters, "all").map((c) => c.no)).toEqual([
1, 2, 3,
]);
});
it("keeps only the selected volume", () => {
expect(filterByViewVolume(chapters, 2).map((c) => c.no)).toEqual([3]);
});
it("returns empty for a volume with no chapters", () => {
expect(filterByViewVolume(chapters, 9)).toEqual([]);
});
it("handles undefined", () => {
expect(filterByViewVolume(undefined, "all")).toEqual([]);
});
});
describe("windowBadgeLabel", () => {
it("renders a range, half-open, or bare badge", () => {
expect(

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`。
export function windowBadgeLabel(window: ForeshadowWindowView): string {
const from = window.expected_close_from;

View File

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

View File

@@ -8,15 +8,28 @@ import {
} from "./sse";
describe("parseReviewBlock — style (C4 扩 T4.2)", () => {
it("parses a style frame", () => {
it("parses a style frame carrying the text anchor", () => {
const evt = parseReviewBlock(
'event:style\ndata:{"score":87,"segments":[{"idx":3,"score":60,"label":"口语化"}]}',
'event:style\ndata:{"score":87,"segments":[{"idx":3,"text":"原文锚","score":60,"label":"口语化"}]}',
);
expect(evt).toEqual({
event: "style",
data: {
score: 87,
segments: [{ idx: 3, score: 60, label: "口语化" }],
segments: [{ idx: 3, text: "原文锚", score: 60, label: "口语化" }],
},
});
});
it("defaults missing segment text to null when parsing", () => {
const evt = parseReviewBlock(
'event:style\ndata:{"score":87,"segments":[{"idx":3,"score":60}]}',
);
expect(evt).toEqual({
event: "style",
data: {
score: 87,
segments: [{ idx: 3, text: null, score: 60, label: null }],
},
});
});
@@ -32,17 +45,20 @@ describe("reduceReview — style (replace-style like pace)", () => {
it("sets style report and marks reviewing", () => {
const evt: ReviewSseEvent = {
event: "style",
data: { score: 80, segments: [{ idx: 1, score: 50, label: "突兀" }] },
data: {
score: 80,
segments: [{ idx: 1, text: "原文锚", score: 50, label: "突兀" }],
},
};
const out = reduceReview(initialReviewState, evt);
expect(out.phase).toBe("reviewing");
expect(out.style).toEqual({
score: 80,
segments: [{ idx: 1, score: 50, label: "突兀" }],
segments: [{ idx: 1, text: "原文锚", score: 50, label: "突兀" }],
});
});
it("replaces (not accumulates) the style report and defaults label null", () => {
it("replaces (not accumulates) the style report and defaults text/label", () => {
const first = reduceReview(initialReviewState, {
event: "style",
data: { score: 70, segments: [{ idx: 0, score: 40 }] },
@@ -51,7 +67,12 @@ describe("reduceReview — style (replace-style like pace)", () => {
event: "style",
data: { score: 95, segments: [] },
});
expect(first.style?.segments[0]).toEqual({ idx: 0, score: 40, label: null });
expect(first.style?.segments[0]).toEqual({
idx: 0,
text: "",
score: 40,
label: null,
});
expect(second.style).toEqual({ score: 95, segments: [] });
});

View File

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

View File

@@ -16,6 +16,8 @@ export interface UseRules {
level: RuleLevel,
content: string,
) => Promise<boolean>;
// 删除一条规则(乐观移除 + 失败回滚 + Toast
remove: (projectId: string, ruleId: string) => Promise<boolean>;
}
// 规则页UX §7列出 + 新增规则。乐观追加,失败回滚(仿 useForeshadow
@@ -31,7 +33,12 @@ export function useRules(initial: RuleView[]): UseRules {
return false;
}
const snapshot = items;
const optimistic: RuleView = { level, content: content.trim() };
// 乐观项需一个临时 id删除 handle 之前);服务端返回后被权威行替换。
const optimistic: RuleView = {
id: crypto.randomUUID(),
level,
content: content.trim(),
};
setItems((prev) => [...prev, optimistic]);
setBusy(true);
try {
@@ -58,5 +65,29 @@ export function useRules(initial: RuleView[]): UseRules {
[items, toast],
);
return { items, busy, add };
const remove = useCallback<UseRules["remove"]>(
async (projectId, ruleId) => {
const snapshot = items;
setItems((prev) => prev.filter((r) => r.id !== ruleId));
setBusy(true);
try {
const { error } = await api.DELETE(
"/projects/{project_id}/rules/{rule_id}",
{ params: { path: { project_id: projectId, rule_id: ruleId } } },
);
if (error) {
setItems(snapshot); // 回滚
toast("删除规则失败,请稍后重试。", "error");
return false;
}
toast("已删除规则", "success");
return true;
} finally {
setBusy(false);
}
},
[items, toast],
);
return { items, busy, add, remove };
}

View File

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

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", () => {
it("tightens style dict into report, filtering bad segments", () => {
it("tightens style dict into report, carrying text anchor, filtering bad segments", () => {
const out = normalizeStyleDrift(
reviewItem({
style: {
score: 87,
segments: [
{ idx: 3, score: 60, label: "口语化" },
{ idx: 3, text: "他乘坐迈巴赫扬长而去。", score: 60, label: "口语化" },
{ idx: 5, score: 72 },
"junk",
],
@@ -75,12 +75,24 @@ describe("normalizeStyleDrift", () => {
expect(out).toEqual({
score: 87,
segments: [
{ idx: 3, score: 60, label: "口语化" },
{ idx: 5, score: 72, label: null },
{ idx: 3, text: "他乘坐迈巴赫扬长而去。", score: 60, label: "口语化" },
{ idx: 5, text: "", score: 72, label: null },
],
});
});
it("defaults missing segment text to empty string", () => {
const out = normalizeStyleDrift(
reviewItem({ style: { score: 80, segments: [{ idx: 0, score: 50 }] } }),
);
expect(out?.segments[0]).toEqual({
idx: 0,
text: "",
score: 50,
label: null,
});
});
it("defaults score to 100 (degrade态) and returns null when missing", () => {
expect(normalizeStyleDrift(reviewItem({ style: {} }))).toEqual({
score: 100,
@@ -92,10 +104,22 @@ describe("normalizeStyleDrift", () => {
});
describe("narrowStyleEvent", () => {
it("narrows SSE style event data with label fallback", () => {
it("narrows SSE style event data with text anchor + label fallback", () => {
expect(
narrowStyleEvent({
score: 90,
segments: [{ idx: 1, text: "原文锚", score: 50 }],
}),
).toEqual({
score: 90,
segments: [{ idx: 1, text: "原文锚", score: 50, label: null }],
});
expect(
narrowStyleEvent({ score: 90, segments: [{ idx: 1, score: 50 }] }),
).toEqual({ score: 90, segments: [{ idx: 1, score: 50, label: null }] });
).toEqual({
score: 90,
segments: [{ idx: 1, text: "", score: 50, label: null }],
});
});
it("returns degrade态 for non-object data", () => {

View File

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