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

@@ -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无凭据 → 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": {
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 单条澄清反问:问题 + 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
* @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;

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 };
}