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:
106
apps/web/components/workbench/ChoiceChips.tsx
Normal file
106
apps/web/components/workbench/ChoiceChips.tsx
Normal 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;
|
||||
// 2–4 个具体选项;为空时只渲染自由输入。
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
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 { Button } from "@/components/ui/Button";
|
||||
@@ -9,6 +9,12 @@ import { SectionHeader } from "@/components/ui/SectionHeader";
|
||||
import { StatusNote } from "@/components/ui/StatusNote";
|
||||
import { TextArea } from "@/components/ui/TextArea";
|
||||
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 {
|
||||
projectId: string;
|
||||
@@ -30,7 +36,9 @@ export function RefinePanel({
|
||||
onClose,
|
||||
}: RefinePanelProps) {
|
||||
const { status, versions, latest, refine, recommunicate } = useRefine();
|
||||
const clarify = useClarify();
|
||||
const [instruction, setInstruction] = useState("");
|
||||
const [pendingInstruction, setPendingInstruction] = useState<string | null>(null);
|
||||
const ranRef = useRef(false);
|
||||
|
||||
// 打开即对选段回炉一次(仅一次,避免重复请求)。
|
||||
@@ -41,12 +49,38 @@ export function RefinePanel({
|
||||
}, [projectId, chapterNo, original, refine]);
|
||||
|
||||
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();
|
||||
if (!trimmed || busy) return;
|
||||
// 折入澄清答案(或原意见)后回炉,并清理澄清态。
|
||||
const runRecommunicate = (finalInstruction: string): void => {
|
||||
clarify.reset();
|
||||
setPendingInstruction(null);
|
||||
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 (
|
||||
@@ -101,19 +135,41 @@ export function RefinePanel({
|
||||
value={instruction}
|
||||
onChange={(e) => setInstruction(e.target.value)}
|
||||
rows={2}
|
||||
placeholder="再沟通:想怎么改?(如「再收紧节奏」「保留这句比喻」)"
|
||||
placeholder="再沟通:想怎么改?(意见太笼统时 AI 会先反问、给方向选项)"
|
||||
/>
|
||||
</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">
|
||||
<Button
|
||||
onClick={onRecommunicate}
|
||||
disabled={busy || instruction.trim().length === 0}
|
||||
onClick={() => void onRecommunicate(false)}
|
||||
disabled={busy || clarifying || instruction.trim().length === 0}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
>
|
||||
<MessageSquarePlus className="h-4 w-4" aria-hidden="true" />
|
||||
再改一版
|
||||
</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
|
||||
onClick={() => latest && onAccept(latest.refined)}
|
||||
disabled={busy || !latest}
|
||||
@@ -123,8 +179,11 @@ export function RefinePanel({
|
||||
<Check className="h-4 w-4" aria-hidden="true" />
|
||||
接受回填
|
||||
</Button>
|
||||
{busy ? (
|
||||
<ThinkingIndicator label="生成中" className="text-xs text-cinnabar" />
|
||||
{busy || clarifying ? (
|
||||
<ThinkingIndicator
|
||||
label={clarifying ? "琢磨要不要反问" : "生成中"}
|
||||
className="text-xs text-cinnabar"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
173
apps/web/lib/api/schema.d.ts
vendored
173
apps/web/lib/api/schema.d.ts
vendored
@@ -429,6 +429,33 @@ export interface paths {
|
||||
patch?: 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;无凭据 → 503(dep 解析阶段拦下)。**只读不写库**(不变量 #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": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -1109,6 +1136,81 @@ export interface components {
|
||||
/** Note */
|
||||
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 单条澄清反问:问题 + 2–4 个锚定选项 + 自由输入兜底。
|
||||
*
|
||||
* `options` 常规 2–4 个(锚定本段/本章的具体走法);凑不出具体可区分选项时留空列表,
|
||||
* 退化为纯自由输入。`allow_free_text` 常驻 True——自由输入永远兜底(作者可不选任一项)。
|
||||
*/
|
||||
ClarifyQuestion: {
|
||||
/**
|
||||
* Question
|
||||
* @description 反问的澄清问题(一句话,指向本段的分歧点)
|
||||
*/
|
||||
question: string;
|
||||
/**
|
||||
* Options
|
||||
* @description 2–4 个锚定本段/本章的具体走法选项;凑不出具体选项时为空列表
|
||||
*/
|
||||
options?: components["schemas"]["ClarifyOption"][];
|
||||
/**
|
||||
* Allow Free Text
|
||||
* @description 是否允许自由输入(常驻兜底)
|
||||
* @default true
|
||||
*/
|
||||
allow_free_text: boolean;
|
||||
};
|
||||
/**
|
||||
* ConflictDecision
|
||||
* @description 对最近一次审稿留痕里**某个冲突**(按其在 conflicts 列表的下标定位)的裁决。
|
||||
@@ -1780,6 +1882,23 @@ export interface components {
|
||||
/** Tier Routing */
|
||||
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
|
||||
* @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: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
||||
90
apps/web/lib/workbench/clarify.test.ts
Normal file
90
apps/web/lib/workbench/clarify.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
64
apps/web/lib/workbench/clarify.ts
Normal file
64
apps/web/lib/workbench/clarify.ts
Normal 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;
|
||||
}
|
||||
|
||||
// 一个澄清问题:反问 + 2–4 个具体选项(可为空退化为纯自由输入)+ 常驻自由输入兜底。
|
||||
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;
|
||||
}
|
||||
115
apps/web/lib/workbench/useClarify.test.ts
Normal file
115
apps/web/lib/workbench/useClarify.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 { 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→VM,status=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=false:status=proceed,decision 清空", 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:视为放行(proceed,needClarification=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();
|
||||
});
|
||||
});
|
||||
97
apps/web/lib/workbench/useClarify.ts
Normal file
97
apps/web/lib/workbench/useClarify.ts
Normal 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 };
|
||||
}
|
||||
Reference in New Issue
Block a user