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:
46
apps/web/lib/style/locateSegment.test.ts
Normal file
46
apps/web/lib/style/locateSegment.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
26
apps/web/lib/style/locateSegment.ts
Normal file
26
apps/web/lib/style/locateSegment.ts
Normal 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;
|
||||
}
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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"]),
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user