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:
@@ -87,13 +87,28 @@ export function CodexPage({
|
||||
已入库人物({characters.length})
|
||||
</h3>
|
||||
{characters.length > 0 ? (
|
||||
<ul className="flex flex-wrap gap-2">
|
||||
<ul className="flex flex-col gap-2">
|
||||
{characters.map((c, i) => (
|
||||
<li
|
||||
key={`${c.name}-${i}`}
|
||||
className="rounded bg-bg px-2 py-1 text-xs text-ink-soft"
|
||||
className="rounded bg-bg px-2 py-1.5 text-xs text-ink-soft"
|
||||
>
|
||||
{c.name}({c.role})
|
||||
<span className="text-ink">
|
||||
{c.name}({c.role})
|
||||
</span>
|
||||
{c.relations && c.relations.length > 0 ? (
|
||||
<ul className="mt-1 flex flex-wrap gap-1">
|
||||
{c.relations.map((r, j) => (
|
||||
<li
|
||||
key={`${r.name}-${r.kind}-${j}`}
|
||||
className="rounded bg-panel px-1.5 py-0.5 text-[11px]"
|
||||
title={r.note ?? undefined}
|
||||
>
|
||||
{r.kind} · {r.name}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
@@ -4,7 +4,12 @@ import { useMemo, useState } from "react";
|
||||
|
||||
import { AppShell } from "@/components/AppShell";
|
||||
import type { OutlineChapterView, ProjectResponse } from "@/lib/api/types";
|
||||
import { groupByVolume } from "@/lib/outline/outline";
|
||||
import {
|
||||
distinctVolumes,
|
||||
filterByViewVolume,
|
||||
groupByVolume,
|
||||
type ViewVolume,
|
||||
} from "@/lib/outline/outline";
|
||||
import { useOutline } from "@/lib/outline/useOutline";
|
||||
import { OutlineChapterRow } from "./OutlineChapterRow";
|
||||
|
||||
@@ -21,7 +26,13 @@ export function OutlineEditor({
|
||||
}: OutlineEditorProps) {
|
||||
const { chapters, status, error, generate } = useOutline(initialChapters);
|
||||
const [volume, setVolume] = useState(1);
|
||||
const volumes = useMemo(() => groupByVolume(chapters), [chapters]);
|
||||
// 视图卷过滤("全部" 或某卷)——只影响展示,与生成的目标卷 `volume` 解耦。
|
||||
const [viewVolume, setViewVolume] = useState<ViewVolume>("all");
|
||||
const availableVolumes = useMemo(() => distinctVolumes(chapters), [chapters]);
|
||||
const volumes = useMemo(
|
||||
() => groupByVolume(filterByViewVolume(chapters, viewVolume)),
|
||||
[chapters, viewVolume],
|
||||
);
|
||||
const generating = status === "generating";
|
||||
|
||||
return (
|
||||
@@ -34,7 +45,32 @@ export function OutlineEditor({
|
||||
<div className="flex h-[calc(100vh-var(--chrome,4rem))] flex-col p-6">
|
||||
<div className="mb-4 flex items-center gap-3">
|
||||
<h1 className="font-serif text-lg text-ink">大纲</h1>
|
||||
<label htmlFor="vol" className="ml-auto text-xs text-ink-soft">
|
||||
{availableVolumes.length > 0 ? (
|
||||
<label htmlFor="view-vol" className="ml-auto text-xs text-ink-soft">
|
||||
查看
|
||||
<select
|
||||
id="view-vol"
|
||||
value={viewVolume === "all" ? "all" : String(viewVolume)}
|
||||
onChange={(e) =>
|
||||
setViewVolume(
|
||||
e.target.value === "all" ? "all" : Number(e.target.value),
|
||||
)
|
||||
}
|
||||
className="ml-1 rounded border border-line bg-bg px-2 py-1 text-sm text-ink focus:border-cinnabar focus:outline-none"
|
||||
>
|
||||
<option value="all">全部</option>
|
||||
{availableVolumes.map((v) => (
|
||||
<option key={v} value={String(v)}>
|
||||
卷 {v}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
) : null}
|
||||
<label
|
||||
htmlFor="vol"
|
||||
className={`text-xs text-ink-soft${availableVolumes.length > 0 ? "" : " ml-auto"}`}
|
||||
>
|
||||
卷号
|
||||
</label>
|
||||
<input
|
||||
@@ -74,7 +110,9 @@ export function OutlineEditor({
|
||||
<div className="min-h-0 flex-1 overflow-auto">
|
||||
{volumes.length === 0 ? (
|
||||
<p className="rounded border border-dashed border-line p-6 text-sm text-ink-soft">
|
||||
暂无大纲。点「✦ AI 排大纲」生成逐章节拍与伏笔窗口。
|
||||
{chapters.length > 0 && viewVolume !== "all"
|
||||
? `卷 ${viewVolume} 暂无大纲。切到「全部」查看其它卷,或点「✦ AI 排大纲」为本卷生成。`
|
||||
: "暂无大纲。点「✦ AI 排大纲」生成逐章节拍与伏笔窗口。"}
|
||||
</p>
|
||||
) : (
|
||||
volumes.map((group) => (
|
||||
|
||||
@@ -36,6 +36,7 @@ import { useRefine } from "@/lib/style/useRefine";
|
||||
import { useReviewStream } from "@/lib/review/useReviewStream";
|
||||
import type { ReviewConflict, StyleDriftSegment } from "@/lib/review/sse";
|
||||
import { normalizeStyleDrift } from "@/lib/style/style";
|
||||
import { locateDriftSegment } from "@/lib/style/locateSegment";
|
||||
import { useToast } from "@/components/Toast";
|
||||
import { ThinkingIndicator } from "@/components/ThinkingIndicator";
|
||||
import { AcceptPanel } from "./AcceptPanel";
|
||||
@@ -80,10 +81,8 @@ export function ReviewReport({
|
||||
const [rewriting, setRewriting] = useState<Set<number>>(new Set());
|
||||
// 可编辑终稿 textarea 引用:供「跳转」/采纳后在正文里选中定位问题区域。
|
||||
const editorRef = useRef<HTMLTextAreaElement>(null);
|
||||
// 回炉中的漂移段(null=未打开 RefineView)。
|
||||
const [refineSegment, setRefineSegment] = useState<StyleDriftSegment | null>(
|
||||
null,
|
||||
);
|
||||
// 回炉中漂移段在终稿里定位到的原文(null=未打开 RefineView)。内容锚定位,非位置 idx。
|
||||
const [refineText, setRefineText] = useState<string | null>(null);
|
||||
const seededRef = useRef(false);
|
||||
|
||||
// 进页一次性把历史留痕(冲突 + 伏笔建议 + 节奏)种入流状态(无需重审即可裁决/查看)。
|
||||
@@ -342,8 +341,17 @@ export function ReviewReport({
|
||||
|
||||
// 终稿按空行切段(大文本:仅在 finalText 变化时重算,不在每次 render split)。
|
||||
const finalParas = useMemo(() => finalText.split(/\n{2,}/), [finalText]);
|
||||
// 漂移段 idx → 终稿对应段正文(越界则空串)。
|
||||
const segmentText = (idx: number): string => finalParas[idx]?.trim() ?? "";
|
||||
|
||||
// 一键回炉:用漂移段自带原文在终稿里做内容匹配定位(内容锚),定位不到则就 #9
|
||||
// 给出明确提示而非静默 no-op;定位到才打开 RefineView。
|
||||
const onRefineSegment = (segment: StyleDriftSegment): void => {
|
||||
const located = locateDriftSegment(finalParas, segment);
|
||||
if (located === null) {
|
||||
toast("无法定位该段(原文可能已改动),请手动选择要回炉的段落。", "error");
|
||||
return;
|
||||
}
|
||||
setRefineText(located);
|
||||
};
|
||||
|
||||
// 采纳:把重写段替换终稿中的原段(首个匹配),合入终稿(经既有 draft 路径)。
|
||||
const onAdopt = (original: string, refined: string): void => {
|
||||
@@ -355,7 +363,7 @@ export function ReviewReport({
|
||||
}
|
||||
return prev.slice(0, idx) + refined + prev.slice(idx + original.length);
|
||||
});
|
||||
setRefineSegment(null);
|
||||
setRefineText(null);
|
||||
toast("已合入终稿,记得验收时复核。", "success");
|
||||
};
|
||||
|
||||
@@ -523,15 +531,15 @@ export function ReviewReport({
|
||||
<StylePanel
|
||||
style={style}
|
||||
incomplete={sectionStatus("style") === "incomplete"}
|
||||
onRefine={setRefineSegment}
|
||||
onRefine={onRefineSegment}
|
||||
/>
|
||||
{refineSegment !== null ? (
|
||||
{refineText !== null ? (
|
||||
<RefineView
|
||||
projectId={project.id}
|
||||
chapterNo={chapterNo}
|
||||
segment={segmentText(refineSegment.idx)}
|
||||
segment={refineText}
|
||||
onAdopt={onAdopt}
|
||||
onClose={() => setRefineSegment(null)}
|
||||
onClose={() => setRefineText(null)}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -19,7 +19,7 @@ interface RulesPageProps {
|
||||
|
||||
// 规则页(UX §7):四级规则列表 + 新增(乐观 + 回滚)。
|
||||
export function RulesPage({ project, initialRules }: RulesPageProps) {
|
||||
const { items, busy, add } = useRules(initialRules);
|
||||
const { items, busy, add, remove } = useRules(initialRules);
|
||||
const [level, setLevel] = useState<RuleLevel>("project");
|
||||
const [content, setContent] = useState("");
|
||||
const groups = useMemo(() => groupByLevel(items), [items]);
|
||||
@@ -88,12 +88,23 @@ export function RulesPage({ project, initialRules }: RulesPageProps) {
|
||||
<p className="text-xs text-ink-soft">(暂无)</p>
|
||||
) : (
|
||||
<ul className="flex flex-col gap-2">
|
||||
{groups[lv].map((rule, i) => (
|
||||
{groups[lv].map((rule) => (
|
||||
<li
|
||||
key={i}
|
||||
className="rounded border border-line bg-panel px-3 py-2 text-sm text-ink"
|
||||
key={rule.id}
|
||||
className="flex items-start justify-between gap-3 rounded border border-line bg-panel px-3 py-2 text-sm text-ink"
|
||||
>
|
||||
{rule.content}
|
||||
<span className="min-w-0 flex-1 break-words">
|
||||
{rule.content}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() => void remove(project.id, rule.id)}
|
||||
aria-label="删除规则"
|
||||
className="shrink-0 text-xs text-ink-soft hover:text-cinnabar disabled:opacity-50"
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
87
apps/web/lib/api/schema.d.ts
vendored
87
apps/web/lib/api/schema.d.ts
vendored
@@ -146,6 +146,9 @@ export interface paths {
|
||||
*
|
||||
* `body.directive`(可选,T4-b)是临时本章指令,直通 assemble→volatile(不持久化);
|
||||
* 无 body 的旧调用方仍可用(向后兼容)。
|
||||
*
|
||||
* 项目不存在 → 404(在触网关前 fail-fast):否则非法 project_id 会静默烧一次付费/限流的
|
||||
* LLM 调用并返回 200(QA 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)。项目不存在 → 404(QA 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 违例会逃逸成 500(QA 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` 是该规则行的稳定主键——前端规则页用它作删除 handle(DELETE /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;
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
})),
|
||||
|
||||
@@ -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: [] });
|
||||
});
|
||||
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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 }));
|
||||
|
||||
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"]),
|
||||
}));
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user