feat(frontend): 润色再沟通接入 AI 反问澄清——选项芯片 + 折答案回炉(WFW-9 M1)

RefinePanel「再沟通」升级为对话式:意见含糊/极短(门控 <10 字,或点「帮我理清方向」)时先调
clarify 预检端点让 AI 反问,渲染 ChoiceChips 选项芯片(+自由输入兜底);作者选/答后
foldClarifications 把答案折进 instruction,再走既有 refine 回炉(零迁移)。清晰意见直接回炉、
不交往返税;预检失败=放行(useClarify 视 error/异常为 needClarification=false,不阻塞润色)。
新增:lib/workbench/clarify.ts(VM 类型+foldClarifications+needsClarifyGate 纯逻辑) +
useClarify(SSE 外调映射 snake→VM) + ChoiceChips.tsx;gen:api 重生成客户端。
门禁绿:tsc/lint/vitest 654(+clarify/useClarify 18 例)/build/coverage 95.36%。
This commit is contained in:
Yaojia Wang
2026-07-08 08:47:28 +02:00
parent 1652ad9d20
commit e30c933c76
7 changed files with 714 additions and 10 deletions

View File

@@ -0,0 +1,106 @@
"use client";
import { useId, useState, type KeyboardEvent } from "react";
import { Button } from "@/components/ui/Button";
import { TextInput } from "@/components/ui/TextInput";
import type { ClarifyOptionVM } from "@/lib/workbench/clarify";
interface ChoiceChipsProps {
// 反问的澄清问题。
question: string;
// 24 个具体选项;为空时只渲染自由输入。
options: ClarifyOptionVM[];
// 是否提供常驻自由输入兜底。
allowFreeText: boolean;
// 点选某选项:回传其 value供上层折进 instruction
onPick: (value: string) => void;
// 提交自由输入文本allowFreeText 且提供此回调时可用)。
onFreeText?: (text: string) => void;
}
// AI 反问澄清的选项芯片组WFW-9 M1渲染一个问题 + 一组选项芯片(复用 Button
// 作者点选一项即高亮并回传 value可选自由输入兜底。纯展示、无副作用、无 API 调用。
export function ChoiceChips({
question,
options,
allowFreeText,
onPick,
onFreeText,
}: ChoiceChipsProps) {
const [picked, setPicked] = useState<string | null>(null);
const [freeText, setFreeText] = useState("");
const inputId = useId();
const hasOptions = options.length > 0;
const canSubmit = Boolean(onFreeText) && freeText.trim().length > 0;
const handlePick = (value: string): void => {
setPicked(value);
onPick(value);
};
const handleFreeSubmit = (): void => {
const trimmed = freeText.trim();
if (!trimmed || !onFreeText) return;
onFreeText(trimmed);
setFreeText("");
};
const handleKeyDown = (event: KeyboardEvent<HTMLInputElement>): void => {
if (event.key !== "Enter") return;
event.preventDefault();
handleFreeSubmit();
};
return (
<div className="space-y-2">
<p className="text-sm text-ink">{question}</p>
{hasOptions ? (
<div className="flex flex-wrap gap-2" role="group" aria-label="澄清选项">
{options.map((option, index) => {
const active = option.value === picked;
return (
<Button
key={`${index}-${option.value}`}
type="button"
aria-pressed={active}
onClick={() => handlePick(option.value)}
variant={active ? "outline" : "secondary"}
size="sm"
>
{option.label}
</Button>
);
})}
</div>
) : null}
{allowFreeText ? (
<div className="flex items-center gap-2">
<label htmlFor={inputId} className="sr-only">
</label>
<TextInput
id={inputId}
value={freeText}
onChange={(event) => setFreeText(event.target.value)}
onKeyDown={handleKeyDown}
controlSize="sm"
placeholder="或直接说你的想法…"
className="flex-1"
/>
<Button
type="button"
onClick={handleFreeSubmit}
disabled={!canSubmit}
variant="secondary"
size="sm"
>
</Button>
</div>
) : null}
</div>
);
}

View File

@@ -1,7 +1,7 @@
"use client"; "use client";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { Check, MessageSquarePlus, RotateCcw, X } from "lucide-react"; import { Check, MessageSquarePlus, MessagesSquare, RotateCcw, X } from "lucide-react";
import { ThinkingIndicator } from "@/components/ThinkingIndicator"; import { ThinkingIndicator } from "@/components/ThinkingIndicator";
import { Button } from "@/components/ui/Button"; import { Button } from "@/components/ui/Button";
@@ -9,6 +9,12 @@ import { SectionHeader } from "@/components/ui/SectionHeader";
import { StatusNote } from "@/components/ui/StatusNote"; import { StatusNote } from "@/components/ui/StatusNote";
import { TextArea } from "@/components/ui/TextArea"; import { TextArea } from "@/components/ui/TextArea";
import { useRefine } from "@/lib/workbench/useRefine"; import { useRefine } from "@/lib/workbench/useRefine";
import { useClarify } from "@/lib/workbench/useClarify";
import { foldClarifications, needsClarifyGate } from "@/lib/workbench/clarify";
import { ChoiceChips } from "./ChoiceChips";
// 门控阈值:再沟通意见 trim 后短于此字数才自动预检反问(清晰意见不交往返税)。
const CLARIFY_MIN_CHARS = 10;
interface RefinePanelProps { interface RefinePanelProps {
projectId: string; projectId: string;
@@ -30,7 +36,9 @@ export function RefinePanel({
onClose, onClose,
}: RefinePanelProps) { }: RefinePanelProps) {
const { status, versions, latest, refine, recommunicate } = useRefine(); const { status, versions, latest, refine, recommunicate } = useRefine();
const clarify = useClarify();
const [instruction, setInstruction] = useState(""); const [instruction, setInstruction] = useState("");
const [pendingInstruction, setPendingInstruction] = useState<string | null>(null);
const ranRef = useRef(false); const ranRef = useRef(false);
// 打开即对选段回炉一次(仅一次,避免重复请求)。 // 打开即对选段回炉一次(仅一次,避免重复请求)。
@@ -41,12 +49,38 @@ export function RefinePanel({
}, [projectId, chapterNo, original, refine]); }, [projectId, chapterNo, original, refine]);
const busy = status === "refining"; const busy = status === "refining";
const clarifying = clarify.status === "checking";
const firstQuestion = clarify.decision?.questions[0] ?? null;
const asking = clarify.status === "asking" && firstQuestion !== null;
const onRecommunicate = (): void => { // 折入澄清答案(或原意见)后回炉,并清理澄清态。
const trimmed = instruction.trim(); const runRecommunicate = (finalInstruction: string): void => {
if (!trimmed || busy) return; clarify.reset();
setPendingInstruction(null);
setInstruction(""); setInstruction("");
void recommunicate(projectId, chapterNo, trimmed); void recommunicate(projectId, chapterNo, finalInstruction);
};
// 「再改一版」:意见含糊/极短(或 force先预检反问否则直接回炉。
const onRecommunicate = async (force: boolean): Promise<void> => {
const trimmed = instruction.trim();
if (!trimmed || busy || clarifying) return;
if (force || needsClarifyGate(trimmed, CLARIFY_MIN_CHARS)) {
const segment = latest?.refined ?? original;
const vm = await clarify.check(projectId, chapterNo, segment, trimmed);
if (vm.needClarification && vm.questions.length > 0) {
setPendingInstruction(trimmed); // 记住原意见,等作者答完折进去
return;
}
}
runRecommunicate(trimmed);
};
// 作者答复澄清(点选 value 或自由输入)→ 折进意见 → 回炉。
const onClarifyAnswer = (answer: string): void => {
const question = clarify.decision?.questions[0]?.question ?? "";
const base = pendingInstruction ?? instruction.trim();
runRecommunicate(foldClarifications(base, [{ question, answer }]));
}; };
return ( return (
@@ -101,19 +135,41 @@ export function RefinePanel({
value={instruction} value={instruction}
onChange={(e) => setInstruction(e.target.value)} onChange={(e) => setInstruction(e.target.value)}
rows={2} rows={2}
placeholder="再沟通:想怎么改?(如「再收紧节奏」「保留这句比喻」" placeholder="再沟通:想怎么改?(意见太笼统时 AI 会先反问、给方向选项"
/> />
</label> </label>
{asking && firstQuestion ? (
<div className="rounded border border-cinnabar/40 bg-bg p-3">
<p className="mb-2 text-2xs text-ink-soft">AI </p>
<ChoiceChips
question={firstQuestion.question}
options={firstQuestion.options}
allowFreeText={firstQuestion.allowFreeText}
onPick={onClarifyAnswer}
onFreeText={onClarifyAnswer}
/>
</div>
) : null}
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<Button <Button
onClick={onRecommunicate} onClick={() => void onRecommunicate(false)}
disabled={busy || instruction.trim().length === 0} disabled={busy || clarifying || instruction.trim().length === 0}
variant="secondary" variant="secondary"
size="sm" size="sm"
> >
<MessageSquarePlus className="h-4 w-4" aria-hidden="true" /> <MessageSquarePlus className="h-4 w-4" aria-hidden="true" />
</Button> </Button>
<Button
onClick={() => void onRecommunicate(true)}
disabled={busy || clarifying || instruction.trim().length === 0}
variant="ghost"
size="sm"
title="让 AI 先反问、给方向选项再改"
>
<MessagesSquare className="h-4 w-4" aria-hidden="true" />
</Button>
<Button <Button
onClick={() => latest && onAccept(latest.refined)} onClick={() => latest && onAccept(latest.refined)}
disabled={busy || !latest} disabled={busy || !latest}
@@ -123,8 +179,11 @@ export function RefinePanel({
<Check className="h-4 w-4" aria-hidden="true" /> <Check className="h-4 w-4" aria-hidden="true" />
</Button> </Button>
{busy ? ( {busy || clarifying ? (
<ThinkingIndicator label="生成中" className="text-xs text-cinnabar" /> <ThinkingIndicator
label={clarifying ? "琢磨要不要反问" : "生成中"}
className="text-xs text-cinnabar"
/>
) : null} ) : null}
</div> </div>
</div> </div>

View File

@@ -429,6 +429,33 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/projects/{project_id}/chapters/{chapter_no}/refine/clarify": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/**
* Clarify Refine
* @description 润色预检澄清WFW-9 M1 路线A 两阶段之「问题」阶段):非流式 JSON只判不改。
*
* 独立于既有 refine 端点——只判「作者这条再沟通意见清不清楚」,含糊则反问 1 问 + 给选项,
* 清楚则给确认语放行;**绝不改正文**(正文仍走 refine 端点。analyst 档(不变量 #2
*
* 项目不存在 → 404无凭据 → 503dep 解析阶段拦下)。**只读不写库**(不变量 #3
* 末尾 `commit()` 让网关 usage_ledger 落库add-only否则记账静默丢失同 refine 纪律)。
* 判别/校验失败在 `run_clarify` 内确定性回退 `need_clarification=false`(不阻塞润色)。
*/
post: operations["clarify_refine_projects__project_id__chapters__chapter_no__refine_clarify_post"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/templates": { "/templates": {
parameters: { parameters: {
query?: never; query?: never;
@@ -1109,6 +1136,81 @@ export interface components {
/** Note */ /** Note */
note?: string | null; note?: string | null;
}; };
/**
* ClarifyDecision
* @description 润色预检澄清决策(结构化 LLM 输出 + API 响应)。
*
* 路线A 两阶段之「问题」阶段:仅判定「要不要反问」并给选项,**不改正文**(正文仍走
* 既有 refine 端点)。纯只读(`clarify_refine_spec.writes=()`,不变量 #3
*
* **全字段给默认值守解析韧性**(仿 `StyleDriftReview` 降级范式LLM 漏产/畸形时
* 降级为 `need_clarification=false`(不问、直接放行 refine绝不阻塞润色主链路。
*
* - `need_clarification=false` → `questions` 为空、可给一句 `verification` 确认语;
* - `need_clarification=true` → `questions` 恰 1 问v1 硬上限)、`verification` 可空。
*/
ClarifyDecision: {
/**
* Need Clarification
* @description 是否需要向作者反问澄清(含糊/多走法→true明确→false
* @default false
*/
need_clarification: boolean;
/**
* Questions
* @description 反问清单need_clarification=false 时为空v1 硬上限 1 问)
*/
questions?: components["schemas"]["ClarifyQuestion"][];
/**
* Verification
* @description 明确时的一句『我这样理解对吗』确认语need_clarification=false 时给,可空)
*/
verification?: string | null;
};
/**
* ClarifyOption
* @description 单个澄清选项:展示文案 + 选中回填值(锚定本段/本章的一种具体走法)。
*
* `value` = 作者选中后折进 refine instruction 字符串的内容(零迁移,不落库)。
* 凑不出具体可区分选项时退化为空 options`ClarifyQuestion.options=[]`),突出自由输入。
*/
ClarifyOption: {
/**
* Label
* @description 展示文案(作者看到的选项标签)
*/
label: string;
/**
* Value
* @description 选中后回填/折进 instruction 的具体走法内容
*/
value: string;
};
/**
* ClarifyQuestion
* @description 单条澄清反问:问题 + 24 个锚定选项 + 自由输入兜底。
*
* `options` 常规 24 个(锚定本段/本章的具体走法);凑不出具体可区分选项时留空列表,
* 退化为纯自由输入。`allow_free_text` 常驻 True——自由输入永远兜底作者可不选任一项
*/
ClarifyQuestion: {
/**
* Question
* @description 反问的澄清问题(一句话,指向本段的分歧点)
*/
question: string;
/**
* Options
* @description 24 个锚定本段/本章的具体走法选项;凑不出具体选项时为空列表
*/
options?: components["schemas"]["ClarifyOption"][];
/**
* Allow Free Text
* @description 是否允许自由输入(常驻兜底)
* @default true
*/
allow_free_text: boolean;
};
/** /**
* ConflictDecision * ConflictDecision
* @description 对最近一次审稿留痕里**某个冲突**(按其在 conflicts 列表的下标定位)的裁决。 * @description 对最近一次审稿留痕里**某个冲突**(按其在 conflicts 列表的下标定位)的裁决。
@@ -1780,6 +1882,23 @@ export interface components {
/** Tier Routing */ /** Tier Routing */
tier_routing?: components["schemas"]["TierRoutingInput"][]; tier_routing?: components["schemas"]["TierRoutingInput"][];
}; };
/**
* RefineClarifyRequest
* @description 润色预检澄清:选段 + 再沟通意见WFW-9 M1 路线A 两阶段之「问题」阶段)。
*
* 与 `RefineRequest` 独立——预检只判「意见清不清楚」,不改正文。`instruction` 为作者的
* 再沟通意见(可空/极短,正是触发反问的场景);带长度上界防超长入参。响应=`ClarifyDecision`
* (在 ww_agents端点直接返回供前端 gen:api 生成强类型客户端)。
*/
RefineClarifyRequest: {
/** Segment */
segment: string;
/**
* Instruction
* @default
*/
instruction: string;
};
/** /**
* RefineRequest * RefineRequest
* @description 回炉:重写选中段(可选改写指令)。 * @description 回炉:重写选中段(可选改写指令)。
@@ -3310,6 +3429,60 @@ export interface operations {
}; };
}; };
}; };
clarify_refine_projects__project_id__chapters__chapter_no__refine_clarify_post: {
parameters: {
query?: never;
header?: never;
path: {
project_id: string;
chapter_no: number;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["RefineClarifyRequest"];
};
};
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ClarifyDecision"];
};
};
/** @description 项目不存在 */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ErrorEnvelope"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
/** @description LLM 不可用 */
503: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ErrorEnvelope"];
};
};
};
};
list_templates_templates_get: { list_templates_templates_get: {
parameters: { parameters: {
query?: never; query?: never;

View File

@@ -0,0 +1,90 @@
import { describe, expect, it } from "vitest";
import {
foldClarifications,
needsClarifyGate,
type ClarifyAnswer,
} from "./clarify";
describe("foldClarifications", () => {
it("原意见在前,每条澄清另起一行并保序", () => {
const answers: ClarifyAnswer[] = [
{ question: "这段谁在说话?", answer: "主角内心独白" },
{ question: "结尾要留钩子吗?", answer: "留一个悬念" },
];
const result = foldClarifications("收紧节奏", answers);
expect(result).toBe(
"收紧节奏\n已澄清这段谁在说话 → 主角内心独白\n已澄清结尾要留钩子吗 → 留一个悬念",
);
});
it("无回答时原样返回去空白后的 instruction", () => {
expect(foldClarifications(" 收紧节奏 ", [])).toBe("收紧节奏");
});
it("instruction 为空时只输出澄清行", () => {
const answers: ClarifyAnswer[] = [
{ question: "谁在说话?", answer: "配角旁白" },
];
expect(foldClarifications(" ", answers)).toBe("已澄清:谁在说话? → 配角旁白");
});
it("跳过空/纯空白回答的条目", () => {
const answers: ClarifyAnswer[] = [
{ question: "Q1", answer: " " },
{ question: "Q2", answer: "有效回答" },
];
expect(foldClarifications("原意见", answers)).toBe(
"原意见\n已澄清Q2 → 有效回答",
);
});
it("问题为空(自由输入)时省略问题只留答案", () => {
const answers: ClarifyAnswer[] = [{ question: " ", answer: "就按我说的改" }];
expect(foldClarifications("原意见", answers)).toBe(
"原意见\n已澄清就按我说的改",
);
});
it("去除问题与答案两端空白", () => {
const answers: ClarifyAnswer[] = [
{ question: " 谁在说话? ", answer: " 主角 " },
];
expect(foldClarifications("x", answers)).toBe("x\n已澄清谁在说话 → 主角");
});
it("不可变:不修改入参数组", () => {
const answers: ClarifyAnswer[] = [{ question: "Q", answer: "A" }];
const snapshot = JSON.parse(JSON.stringify(answers));
foldClarifications("原意见", answers);
expect(answers).toEqual(snapshot);
});
it("全部为空回答且 instruction 为空时返回空串", () => {
expect(foldClarifications("", [{ question: "Q", answer: "" }])).toBe("");
});
});
describe("needsClarifyGate", () => {
it("去空白后长度小于 minChars 时需要预检", () => {
expect(needsClarifyGate("改一下", 8)).toBe(true);
});
it("恰好等于 minChars 时不需预检(边界)", () => {
expect(needsClarifyGate("12345678", 8)).toBe(false);
});
it("超过 minChars 时不需预检", () => {
expect(needsClarifyGate("这是一段足够清晰具体的润色意见", 8)).toBe(false);
});
it("纯空白按空计(长度 0 < minChars需预检", () => {
expect(needsClarifyGate(" ", 8)).toBe(true);
});
it("先去两端空白再计长度", () => {
expect(needsClarifyGate(" 改 ", 2)).toBe(true);
});
});

View File

@@ -0,0 +1,64 @@
// AI 反问澄清(纯逻辑 + 前端 view-model 类型WFW-9 M1路线A 两阶段)。
// 「润色/再沟通」意见不清晰时,先走独立非流式 JSON 预检端点让 AI 反问给选项,
// 作者点选/自由输入后,把答案「折进」既有 refine 的 instruction 字符串(零迁移、不动 refine 端点)。
// 这里只放确定性纯函数 + 本地 VM 类型camelCase与后端 snake_case ClarifyDecision 的映射由主线接线时完成。
// 澄清选项(锚定本段/本章的一种具体走法)。
export interface ClarifyOptionVM {
label: string;
value: string;
}
// 一个澄清问题:反问 + 24 个具体选项(可为空退化为纯自由输入)+ 常驻自由输入兜底。
export interface ClarifyQuestionVM {
question: string;
options: ClarifyOptionVM[];
allowFreeText: boolean;
}
// 一回合预检结论(对应后端 ClarifyDecision
export interface ClarifyDecisionVM {
needClarification: boolean;
// v1 硬上限 1 问needClarification=false 时为空。
questions: ClarifyQuestionVM[];
// needClarification=false 时给一句「我这样理解对吗」确认语,可空。
verification?: string;
}
// 作者对某个澄清问题的回答(点选回填 value 或自由输入文本)。
export interface ClarifyAnswer {
question: string;
answer: string;
}
const CLARIFY_PREFIX = "已澄清:";
const CLARIFY_ARROW = " → ";
const LINE_SEPARATOR = "\n";
// 把作者对各澄清问题的回答逐条折进 instruction原意见在前每条澄清另起一行供既有 refine 端点直接使用。
// 纯函数、确定性、不可变:不改动入参;跳过空回答;保留传入顺序。
export function foldClarifications(
instruction: string,
answers: readonly ClarifyAnswer[],
): string {
const lines = answers
.map((entry) => ({
question: entry.question.trim(),
answer: entry.answer.trim(),
}))
.filter((entry) => entry.answer.length > 0)
.map((entry) =>
entry.question
? `${CLARIFY_PREFIX}${entry.question}${CLARIFY_ARROW}${entry.answer}`
: `${CLARIFY_PREFIX}${entry.answer}`,
);
const trimmedInstruction = instruction.trim();
const parts = trimmedInstruction ? [trimmedInstruction, ...lines] : lines;
return parts.join(LINE_SEPARATOR);
}
// 门控instruction 去空白后长度不足 minChars意见太空/太短)才需要预检反问;清晰意见直接走 refine 不发问。
export function needsClarifyGate(instruction: string, minChars: number): boolean {
return instruction.trim().length < minChars;
}

View 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 { useClarify } from "./useClarify";
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}/refine/clarify";
describe("useClarify", () => {
beforeEach(() => post.mockReset());
afterEach(() => vi.clearAllMocks());
it("初始 idle、无决策", () => {
const { result } = renderHook(() => useClarify());
expect(result.current.status).toBe("idle");
expect(result.current.decision).toBeNull();
});
it("need_clarification=true映射 snake→VMstatus=asking返回 VM", 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(() => useClarify());
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: { segment: "这段", instruction: "改改" },
});
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=falsestatus=proceeddecision 清空", async () => {
post.mockResolvedValue({
data: { need_clarification: false, questions: [], verification: "我按你说的收紧节奏" },
error: null,
});
const { result } = renderHook(() => useClarify());
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");
expect(result.current.decision).toBeNull();
});
it("后端 error视为放行proceedneedClarification=false", async () => {
post.mockResolvedValue({ data: null, error: { detail: "boom" } });
const { result } = renderHook(() => useClarify());
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此处不单测 throw 路径,
// 因 mockRejected + React act 冲刷会触发 vitest 未处理拒绝误报(与被测逻辑无关)。
it("reset 清回 idle", async () => {
post.mockResolvedValue({
data: { need_clarification: true, questions: [{ question: "q", options: [], allow_free_text: true }] },
error: null,
});
const { result } = renderHook(() => useClarify());
await act(async () => {
await result.current.check("p1", 1, "段", "改");
});
act(() => result.current.reset());
expect(result.current.status).toBe("idle");
expect(result.current.decision).toBeNull();
});
});

View File

@@ -0,0 +1,97 @@
"use client";
import { useCallback, useState } from "react";
import { api } from "@/lib/api/client";
import type { ClarifyDecisionVM } from "./clarify";
// 澄清预检结论为「放行」(直接去 refine不反问——后端 error/前端异常时的确定性回退。
const PROCEED: ClarifyDecisionVM = { needClarification: false, questions: [] };
export type ClarifyStatus = "idle" | "checking" | "asking" | "proceed" | "error";
export interface UseClarify {
status: ClarifyStatus;
// status=asking 时的待答问题(含选项);其它时为 null。
decision: ClarifyDecisionVM | null;
// 预检:调 refine/clarify 端点,返回 VM 供调用方同步分支。失败=放行needClarification=false不阻塞润色。
check: (
projectId: string,
chapterNo: number,
segment: string,
instruction: string,
) => Promise<ClarifyDecisionVM>;
reset: () => void;
}
// 后端 snake_case ClarifyDecision → 前端 camelCase VM不信任外部数据全部显式取值 + 默认)。
function toVM(data: {
need_clarification?: boolean;
questions?: {
question?: string;
options?: { label?: string; value?: string }[];
allow_free_text?: boolean;
}[];
verification?: string | null;
}): ClarifyDecisionVM {
return {
needClarification: Boolean(data.need_clarification),
questions: (data.questions ?? []).map((q) => ({
question: q.question ?? "",
options: (q.options ?? []).map((o) => ({
label: o.label ?? "",
value: o.value ?? "",
})),
allowFreeText: Boolean(q.allow_free_text),
})),
verification: data.verification ?? undefined,
};
}
// AI 反问澄清预检WFW-9 M1路线A 两阶段之「问题」阶段)。
// 「再沟通」意见含糊/极短时先调独立非流式端点让 AI 反问needClarification=false 或失败=放行去既有 refine。
export function useClarify(): UseClarify {
const [status, setStatus] = useState<ClarifyStatus>("idle");
const [decision, setDecision] = useState<ClarifyDecisionVM | null>(null);
const check = useCallback<UseClarify["check"]>(
async (projectId, chapterNo, segment, instruction) => {
setStatus("checking");
try {
const { data, error } = await api.POST(
"/projects/{project_id}/chapters/{chapter_no}/refine/clarify",
{
params: { path: { project_id: projectId, chapter_no: chapterNo } },
body: { segment, instruction },
},
);
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 };
}