feat(frontend): 整章重写接入 AI 反问澄清——useRewriteClarify + ChapterRewritePanel 门控(WFW-9 M2)
This commit is contained in:
115
apps/web/lib/workbench/useRewriteClarify.test.ts
Normal file
115
apps/web/lib/workbench/useRewriteClarify.test.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
// @vitest-environment jsdom
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { useRewriteClarify } from "./useRewriteClarify";
|
||||
import type { ClarifyDecisionVM } from "./clarify";
|
||||
|
||||
const post = vi.fn();
|
||||
vi.mock("@/lib/api/client", () => ({
|
||||
api: { POST: (...a: unknown[]) => post(...a) },
|
||||
}));
|
||||
|
||||
const CLARIFY_PATH = "/projects/{project_id}/chapters/{chapter_no}/rewrite/clarify";
|
||||
|
||||
describe("useRewriteClarify", () => {
|
||||
beforeEach(() => post.mockReset());
|
||||
afterEach(() => vi.clearAllMocks());
|
||||
|
||||
it("初始 idle、无决策", () => {
|
||||
const { result } = renderHook(() => useRewriteClarify());
|
||||
expect(result.current.status).toBe("idle");
|
||||
expect(result.current.decision).toBeNull();
|
||||
});
|
||||
|
||||
it("need_clarification=true:POST rewrite/clarify 带 feedback+prior_draft,映射 VM,status=asking", async () => {
|
||||
post.mockResolvedValue({
|
||||
data: {
|
||||
need_clarification: true,
|
||||
questions: [
|
||||
{
|
||||
question: "高潮想更爽还是更虐?",
|
||||
options: [
|
||||
{ label: "更爽", value: "把高潮写得更爽快" },
|
||||
{ label: "更虐", value: "把高潮写得更虐" },
|
||||
],
|
||||
allow_free_text: true,
|
||||
},
|
||||
],
|
||||
verification: null,
|
||||
},
|
||||
error: null,
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useRewriteClarify());
|
||||
let vm: ClarifyDecisionVM | undefined;
|
||||
await act(async () => {
|
||||
vm = await result.current.check("p1", 3, "改改", "整章草稿正文");
|
||||
});
|
||||
|
||||
expect(post).toHaveBeenCalledWith(CLARIFY_PATH, {
|
||||
params: { path: { project_id: "p1", chapter_no: 3 } },
|
||||
body: { feedback: "改改", prior_draft: "整章草稿正文" },
|
||||
});
|
||||
expect(vm).toEqual({
|
||||
needClarification: true,
|
||||
questions: [
|
||||
{
|
||||
question: "高潮想更爽还是更虐?",
|
||||
options: [
|
||||
{ label: "更爽", value: "把高潮写得更爽快" },
|
||||
{ label: "更虐", value: "把高潮写得更虐" },
|
||||
],
|
||||
allowFreeText: true,
|
||||
},
|
||||
],
|
||||
verification: undefined,
|
||||
});
|
||||
expect(result.current.status).toBe("asking");
|
||||
expect(result.current.decision?.needClarification).toBe(true);
|
||||
});
|
||||
|
||||
it("need_clarification=false:status=proceed,decision 清空", async () => {
|
||||
post.mockResolvedValue({
|
||||
data: { need_clarification: false, questions: [], verification: "我按你说的拉满高潮" },
|
||||
error: null,
|
||||
});
|
||||
const { result } = renderHook(() => useRewriteClarify());
|
||||
let vm: ClarifyDecisionVM | undefined;
|
||||
await act(async () => {
|
||||
vm = await result.current.check("p1", 1, "把第 3 段的高潮再拉满,删掉闪回", "草稿");
|
||||
});
|
||||
expect(vm?.needClarification).toBe(false);
|
||||
expect(result.current.status).toBe("proceed");
|
||||
expect(result.current.decision).toBeNull();
|
||||
});
|
||||
|
||||
it("后端 error:视为放行(proceed,needClarification=false),不阻塞重写", async () => {
|
||||
post.mockResolvedValue({ data: null, error: { detail: "boom" } });
|
||||
const { result } = renderHook(() => useRewriteClarify());
|
||||
let vm: ClarifyDecisionVM | undefined;
|
||||
await act(async () => {
|
||||
vm = await result.current.check("p1", 1, "改", "草稿");
|
||||
});
|
||||
expect(vm?.needClarification).toBe(false);
|
||||
expect(result.current.status).toBe("proceed");
|
||||
});
|
||||
|
||||
// 注:hook 的 try/catch 对「fetch 抛异常」的放行分支与上面「后端 error 信封」放行分支同构、
|
||||
// 行为一致(均 status=proceed、needClarification=false、返回 PROCEED);此处不单测 throw 路径,
|
||||
// 因 mock 抛错 + React act 冲刷会触发 vitest 未处理错误误报(与被测逻辑无关,同 M1 useClarify.test.ts)。
|
||||
|
||||
it("reset 清回 idle", async () => {
|
||||
post.mockResolvedValue({
|
||||
data: { need_clarification: true, questions: [{ question: "q", options: [], allow_free_text: true }] },
|
||||
error: null,
|
||||
});
|
||||
const { result } = renderHook(() => useRewriteClarify());
|
||||
await act(async () => {
|
||||
await result.current.check("p1", 1, "改", "草稿");
|
||||
});
|
||||
act(() => result.current.reset());
|
||||
expect(result.current.status).toBe("idle");
|
||||
expect(result.current.decision).toBeNull();
|
||||
});
|
||||
});
|
||||
70
apps/web/lib/workbench/useRewriteClarify.ts
Normal file
70
apps/web/lib/workbench/useRewriteClarify.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
|
||||
import { api } from "@/lib/api/client";
|
||||
import { PROCEED, toVM, type ClarifyDecisionVM } from "./clarify";
|
||||
|
||||
export type ClarifyStatus = "idle" | "checking" | "asking" | "proceed" | "error";
|
||||
|
||||
export interface UseRewriteClarify {
|
||||
status: ClarifyStatus;
|
||||
// status=asking 时的待答问题(含选项);其它时为 null。
|
||||
decision: ClarifyDecisionVM | null;
|
||||
// 预检:调 rewrite/clarify 端点,返回 VM 供调用方同步分支。失败=放行(needClarification=false),不阻塞整章重写。
|
||||
check: (
|
||||
projectId: string,
|
||||
chapterNo: number,
|
||||
feedback: string,
|
||||
priorDraft: string,
|
||||
) => Promise<ClarifyDecisionVM>;
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
// 整章重写侧 AI 反问澄清预检(WFW-9 M2,路线A 两阶段之「问题」阶段——refine/clarify 的章级孪生)。
|
||||
// 「再沟通/重写」意见含糊/极短时先调独立非流式端点让 AI 反问;needClarification=false 或失败=放行去既有 rewrite SSE。
|
||||
export function useRewriteClarify(): UseRewriteClarify {
|
||||
const [status, setStatus] = useState<ClarifyStatus>("idle");
|
||||
const [decision, setDecision] = useState<ClarifyDecisionVM | null>(null);
|
||||
|
||||
const check = useCallback<UseRewriteClarify["check"]>(
|
||||
async (projectId, chapterNo, feedback, priorDraft) => {
|
||||
setStatus("checking");
|
||||
try {
|
||||
const { data, error } = await api.POST(
|
||||
"/projects/{project_id}/chapters/{chapter_no}/rewrite/clarify",
|
||||
{
|
||||
params: { path: { project_id: projectId, chapter_no: chapterNo } },
|
||||
body: { feedback, prior_draft: priorDraft },
|
||||
},
|
||||
);
|
||||
if (error || !data) {
|
||||
setStatus("proceed");
|
||||
setDecision(null);
|
||||
return PROCEED;
|
||||
}
|
||||
const vm = toVM(data);
|
||||
if (vm.needClarification && vm.questions.length > 0) {
|
||||
setStatus("asking");
|
||||
setDecision(vm);
|
||||
} else {
|
||||
setStatus("proceed");
|
||||
setDecision(null);
|
||||
}
|
||||
return vm;
|
||||
} catch {
|
||||
setStatus("proceed");
|
||||
setDecision(null);
|
||||
return PROCEED;
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const reset = useCallback((): void => {
|
||||
setStatus("idle");
|
||||
setDecision(null);
|
||||
}, []);
|
||||
|
||||
return { status, decision, check, reset };
|
||||
}
|
||||
Reference in New Issue
Block a user