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

@@ -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"]),
}));