Files
writer-work-flow/apps/web/lib/review/sse.ts
Yaojia Wang bf39f50b2f 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。
2026-06-25 12:53:03 +02:00

397 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 审稿 SSE 帧归一 + reducer对齐 C3 / C4 扩 / ARCH §7.3)。
// 帧:`section{name,status}` / `conflict{type,where,refs,suggestion}` / `done{length}` / `error{...}`。
// 纯逻辑,便于 node 环境单测(不依赖 DOM
// 五类冲突C6 ConflictType / ARCH §6.1)。
export type ConflictType =
| "性格漂移"
| "能力不符"
| "设定违例"
| "地理矛盾"
| "时间线倒错";
export interface ReviewConflict {
type: string;
where: string;
refs: string[];
suggestion: string;
// 一键采纳补丁对(可缺):可局部修复时审稿提议,前端「采纳改法」据此 find-replace 进终稿。
original: string | null;
replacement: string | null;
}
export type SectionStatus = "started" | "done" | "incomplete";
// 伏笔建议C4 扩 T3.3planted=新埋待确认 / resolved=疑似回收(只读建议)。
export type ForeshadowKind = "planted" | "resolved";
export interface ForeshadowSuggestion {
kind: ForeshadowKind;
code?: string | null;
title: string;
where?: string | null;
note?: string | null;
}
// 节奏报告C4 扩 T3.3):注水段 + 章末钩子 + 爽点节拍图。
export interface PaceIssue {
where: string;
reason: string;
}
export interface PaceReport {
water: PaceIssue[];
hook: boolean;
beat_map: number[];
}
// 文风漂移第四审C4 扩 T4.2):整体相似度 score + 段级漂移。
// `text` = 该漂移段从草稿逐字摘录的原文(内容锚,供前端内容匹配定位回炉目标,
// 不靠位置 idx旧数据/降级可为空串)。
export interface StyleDriftSegment {
idx: number;
text: string;
score: number;
label: string | null;
}
export interface StyleDriftReport {
score: number;
segments: StyleDriftSegment[];
}
export interface SectionEvent {
event: "section";
data: { name: string; status: SectionStatus };
}
export interface ConflictEvent {
event: "conflict";
data: ReviewConflict;
}
export interface ForeshadowEvent {
event: "foreshadow";
data: {
kind: ForeshadowKind;
code?: string | null;
title: string;
where?: string | null;
note?: string | null;
};
}
export interface PaceEvent {
event: "pace";
data: {
water: PaceIssue[];
hook: boolean;
beat_map: number[];
};
}
export interface StyleEvent {
event: "style";
data: {
score: number;
segments: {
idx: number;
text?: string | null;
score: number;
label?: string | null;
}[];
};
}
export interface DoneEvent {
event: "done";
data: { length: number };
}
export interface ErrorEvent {
event: "error";
data: { code: string; message: string; request_id?: string | null };
}
export type ReviewSseEvent =
| SectionEvent
| ConflictEvent
| ForeshadowEvent
| PaceEvent
| StyleEvent
| DoneEvent
| ErrorEvent;
const KNOWN_EVENTS = new Set([
"section",
"conflict",
"foreshadow",
"pace",
"style",
"done",
"error",
]);
// 把一个完整 SSE 块(多行)解析成事件;无法解析则返回 null跳过
export function parseReviewBlock(block: string): ReviewSseEvent | null {
let event = "";
const dataLines: string[] = [];
for (const rawLine of block.split("\n")) {
const line = rawLine.replace(/\r$/, "");
if (line.startsWith(":")) continue; // 注释/心跳
const sep = line.indexOf(":");
if (sep === -1) continue;
const field = line.slice(0, sep);
const value = line.slice(sep + 1).replace(/^ /, "");
if (field === "event") event = value;
else if (field === "data") dataLines.push(value);
}
if (!KNOWN_EVENTS.has(event) || dataLines.length === 0) return null;
let data: unknown;
try {
data = JSON.parse(dataLines.join("\n"));
} catch {
return null;
}
return narrowReviewEvent(event, data);
}
function isRecord(v: unknown): v is Record<string, unknown> {
return typeof v === "object" && v !== null && !Array.isArray(v);
}
function asStringArray(v: unknown): string[] {
return Array.isArray(v) ? v.filter((x): x is string => typeof x === "string") : [];
}
function asNumberArray(v: unknown): number[] {
return Array.isArray(v)
? v.filter((x): x is number => typeof x === "number" && Number.isFinite(x))
: [];
}
function nullableString(v: unknown): string | null {
return typeof v === "string" ? v : null;
}
function asPaceIssues(v: unknown): PaceIssue[] {
if (!Array.isArray(v)) return [];
return v.flatMap((x) =>
isRecord(x) && typeof x.where === "string" && typeof x.reason === "string"
? [{ where: x.where, reason: x.reason }]
: [],
);
}
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,
text: nullableString(x.text),
score: x.score,
label: nullableString(x.label),
},
]
: [],
);
}
// 逐事件类型守卫:按 event 名收窄 data 形状;形状不符返回 null安全跳过
function narrowReviewEvent(event: string, data: unknown): ReviewSseEvent | null {
if (!isRecord(data)) return null;
switch (event) {
case "section":
return typeof data.name === "string" && isSectionStatus(data.status)
? { event: "section", data: { name: data.name, status: data.status } }
: null;
case "conflict":
return typeof data.type === "string" &&
typeof data.where === "string" &&
typeof data.suggestion === "string"
? {
event: "conflict",
data: {
type: data.type,
where: data.where,
refs: asStringArray(data.refs),
suggestion: data.suggestion,
original: nullableString(data.original),
replacement: nullableString(data.replacement),
},
}
: null;
case "foreshadow":
return isForeshadowKind(data.kind) && typeof data.title === "string"
? {
event: "foreshadow",
data: {
kind: data.kind,
code: nullableString(data.code),
title: data.title,
where: nullableString(data.where),
note: nullableString(data.note),
},
}
: null;
case "pace":
return {
event: "pace",
data: {
water: asPaceIssues(data.water),
hook: data.hook === true,
beat_map: asNumberArray(data.beat_map),
},
};
case "style":
return typeof data.score === "number"
? {
event: "style",
data: {
score: data.score,
segments: asStyleSegments(data.segments),
},
}
: null;
case "done":
return typeof data.length === "number"
? { event: "done", data: { length: data.length } }
: null;
case "error":
return typeof data.code === "string" && typeof data.message === "string"
? {
event: "error",
data: {
code: data.code,
message: data.message,
request_id: nullableString(data.request_id),
},
}
: null;
default:
return null;
}
}
function isSectionStatus(v: unknown): v is SectionStatus {
return v === "started" || v === "done" || v === "incomplete";
}
function isForeshadowKind(v: unknown): v is ForeshadowKind {
return v === "planted" || v === "resolved";
}
// 增量缓冲:吃进一段文本,吐出已完成的事件块(空行分隔),保留未完成尾部。
export class ReviewFrameBuffer {
private buf = "";
push(chunk: string): ReviewSseEvent[] {
this.buf += chunk;
const events: ReviewSseEvent[] = [];
let idx: number;
while ((idx = this.findBoundary(this.buf)) !== -1) {
const block = this.buf.slice(0, idx);
this.buf = this.buf.slice(this.boundaryEnd(this.buf, idx));
const evt = parseReviewBlock(block);
if (evt) events.push(evt);
}
return events;
}
private findBoundary(s: string): number {
const a = s.indexOf("\n\n");
const b = s.indexOf("\r\n\r\n");
if (a === -1) return b;
if (b === -1) return a;
return Math.min(a, b);
}
private boundaryEnd(s: string, idx: number): number {
return s.startsWith("\r\n\r\n", idx) ? idx + 4 : idx + 2;
}
}
export type ReviewPhase = "idle" | "reviewing" | "done" | "error" | "aborted";
export interface ReviewSection {
name: string;
status: SectionStatus;
}
export interface ReviewStreamState {
phase: ReviewPhase;
sections: ReviewSection[];
conflicts: ReviewConflict[];
foreshadow: ForeshadowSuggestion[];
pace: PaceReport | null;
style: StyleDriftReport | null;
error: { code: string; message: string; request_id?: string | null } | null;
}
export const initialReviewState: ReviewStreamState = {
phase: "idle",
sections: [],
conflicts: [],
foreshadow: [],
pace: null,
style: null,
error: null,
};
// 纯 reducer把单个事件折叠进状态。section 按 name upsert最后状态生效
export function reduceReview(
state: ReviewStreamState,
event: ReviewSseEvent,
): ReviewStreamState {
switch (event.event) {
case "section":
return {
...state,
phase: "reviewing",
sections: upsertSection(state.sections, event.data),
};
case "conflict":
return {
...state,
phase: "reviewing",
conflicts: [...state.conflicts, event.data],
};
case "foreshadow":
return {
...state,
phase: "reviewing",
foreshadow: [...state.foreshadow, { ...event.data }],
};
case "pace":
return {
...state,
phase: "reviewing",
pace: { ...event.data },
};
case "style":
return {
...state,
phase: "reviewing",
style: {
score: event.data.score,
segments: event.data.segments.map((s) => ({
idx: s.idx,
text: s.text ?? "",
score: s.score,
label: s.label ?? null,
})),
},
};
case "done":
return { ...state, phase: "done" };
case "error":
return { ...state, phase: "error", error: event.data };
default:
return state;
}
}
function upsertSection(
sections: ReviewSection[],
next: ReviewSection,
): ReviewSection[] {
const idx = sections.findIndex((s) => s.name === next.name);
if (idx === -1) return [...sections, next];
return sections.map((s, i) => (i === idx ? next : s));
}