Files
writer-work-flow/apps/web/lib/review/useReviewStream.ts
Yaojia Wang 72b3c81146 feat(review): 人物塑造 advisory 审(第五审,仅建议不阻断验收)
写→审链新增第五审「人物塑造」(灵感③/D2):advisory 单维——仅建议、不阻断
验收(不进 conflicts、不碰 assert_conflicts_resolved、不入 REVIEW_RESERVED_NAMES);
只做人物塑造、不做氛围。

- SPEC characterization_spec(analyst,reads=("characters",),writes=())+
  prompts/characterization.md(只读、显式忽略文本长度与辞藻华丽度、引用逐字片段+置信度)
- schema CharacterizationReview{issues[...]}(全字段默认值守解析韧性)+ 注册 catalog
- 计数三处 22→23 + regen prompt_hashes.json(仅加一条)
- DB chapter_reviews 加 characterization JSONB nullable 列(迁移 f6a7b8c9d0e1)
- 接线:graph REVIEW_SPECS 追加 · chain ReviewRecordRepo · collect(extract/record) ·
  review_repo(Protocol/Sql/_to_view/ReviewView) · sse(event/factory/section) · normalize 自动纳入
- 契约 ReviewHistoryItem 加 characterization(gen:api 已重生成)
- 前端 sse/history/useReviewStream + CharacterizationPanel(只展示无裁决 UI、置信度降序+低置信折叠)+ ReviewReport 挂面板
- 测试 test_characterization_does_not_block_accept + 计数/golden + collect/normalize + 前端 reducer/normalize/parse

守不变量 #2/#3/#9;依赖 ⑧(motive/appearance 作客观锚点)。
2026-07-06 17:08:38 +02:00

155 lines
4.7 KiB
TypeScript
Raw Permalink 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.

"use client";
import { useCallback, useReducer, useRef } from "react";
import { API_BASE_PUBLIC } from "@/lib/api/config";
import {
ReviewFrameBuffer,
initialReviewState,
reduceReview,
type CharacterizationReport,
type ForeshadowSuggestion,
type PaceReport,
type ReviewConflict,
type ReviewStreamState,
type StyleDriftReport,
} from "./sse";
export interface ReviewSeed {
conflicts: ReviewConflict[];
foreshadow: ForeshadowSuggestion[];
pace: PaceReport | null;
style: StyleDriftReport | null;
characterization: CharacterizationReport | null;
}
type Action =
| { type: "start" }
| { type: "events"; events: ReturnType<ReviewFrameBuffer["push"]> }
| { type: "abort" }
| { type: "fail"; code: string; message: string }
| { type: "seed"; seed: ReviewSeed };
function reducer(state: ReviewStreamState, action: Action): ReviewStreamState {
switch (action.type) {
case "start":
return { ...initialReviewState, phase: "reviewing" };
case "events":
return action.events.reduce(reduceReview, state);
case "abort":
return { ...state, phase: "aborted" };
case "fail":
return {
...state,
phase: "error",
error: { code: action.code, message: action.message },
};
case "seed":
return {
...initialReviewState,
conflicts: action.seed.conflicts,
foreshadow: action.seed.foreshadow,
pace: action.seed.pace,
style: action.seed.style,
characterization: action.seed.characterization,
};
default:
return state;
}
}
export interface UseReviewStream {
state: ReviewStreamState;
isReviewing: boolean;
// 用当前编辑器草稿重新审稿。
start: (projectId: string, chapterNo: number, draft: string) => Promise<void>;
stop: () => void;
// 进页用历史留痕(冲突 + 伏笔建议 + 节奏)种入(无需重审即可裁决/查看)。
seed: (seed: ReviewSeed) => void;
}
// 消费 POST .../review 的 SSE 流fetch+ReadableStreamEventSource 不支持 POST
// "停" = abort已收 section/conflict 留在 state裁决草稿不丢
// 流前 503 LLM_UNAVAILABLE 是 JSON 信封(非帧)→ 经 !res.ok 检出。
export function useReviewStream(): UseReviewStream {
const [state, dispatch] = useReducer(reducer, initialReviewState);
const controllerRef = useRef<AbortController | null>(null);
const stop = useCallback(() => {
controllerRef.current?.abort();
controllerRef.current = null;
dispatch({ type: "abort" });
}, []);
const seed = useCallback((seedData: ReviewSeed) => {
dispatch({ type: "seed", seed: seedData });
}, []);
const start = useCallback(
async (
projectId: string,
chapterNo: number,
draft: string,
): Promise<void> => {
const controller = new AbortController();
controllerRef.current = controller;
dispatch({ type: "start" });
try {
const res = await fetch(
`${API_BASE_PUBLIC}/projects/${projectId}/chapters/${chapterNo}/review`,
{
method: "POST",
headers: {
Accept: "text/event-stream",
"Content-Type": "application/json",
},
body: JSON.stringify({ draft }),
signal: controller.signal,
},
);
if (!res.ok || !res.body) {
let code = "REVIEW_FAILED";
let message = `审稿请求失败(${res.status}`;
try {
const body = (await res.json()) as {
error?: { code?: string; message?: string };
};
if (body.error?.code) code = body.error.code;
if (body.error?.message) message = body.error.message;
} catch {
// 非 JSON 信封,沿用默认文案。
}
dispatch({ type: "fail", code, message });
return;
}
const reader = res.body.getReader();
const decoder = new TextDecoder();
const buffer = new ReviewFrameBuffer();
for (;;) {
const { value, done } = await reader.read();
if (done) break;
const events = buffer.push(decoder.decode(value, { stream: true }));
if (events.length > 0) dispatch({ type: "events", events });
}
} catch (err: unknown) {
if (err instanceof DOMException && err.name === "AbortError") {
return; // 用户主动停止state 已置 aborted。
}
const message = err instanceof Error ? err.message : "未知网络错误";
dispatch({ type: "fail", code: "NETWORK", message });
} finally {
controllerRef.current = null;
}
},
[],
);
return {
state,
isReviewing: state.phase === "reviewing",
start,
stop,
seed,
};
}