Files
Jyotisha/frontend/src/lib/rectification-agentic/v9/turn-question.ts
T
Jesse_ChenandCursor 35e5781e66
Independent Staging Quality Gate / validate (push) Successful in 9m50s
Independent Staging Quality Gate / publish (push) Successful in 1m53s
fix(rectification): gate collect-phase adopt cards and keep post-adopt verify answerable
Public can_adopt follows session_outcome; reverse_verify reuses the persisted question id; offer cards settle on the owning message with a status-bar handoff.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-03 09:29:34 +08:00

181 lines
6.4 KiB
TypeScript

import { detachCollectSpokenAssistantText } from "./collect-prompt";
import { parseAgentChoiceCopy, type ChoiceKey } from "./choice-card";
import type { ConversationFocus } from "./tool-service";
export type TurnQuestionKind = "choice" | "collect_spoken" | "reverse_verify";
export type TurnQuestionOption = Readonly<{
key: ChoiceKey;
label: string;
}>;
export type TurnQuestion = Readonly<{
focus_id: string;
question_id: string;
kind: TurnQuestionKind;
prompt: string;
options: readonly TurnQuestionOption[] | null;
status: string;
answer_option: ChoiceKey | "stop" | null;
probe_id: string | null;
}>;
const ANSWER_OPTIONS = new Set(["A", "B", "C", "D", "stop"]);
export function focusSpokenPrompt(schema: Readonly<Record<string, unknown>> | null | undefined): string | null {
if (!schema) return null;
const top = typeof schema.prompt === "string" ? schema.prompt.trim() : "";
if (top.length >= 8 && top.length <= 120) return top;
const copy = parseAgentChoiceCopy(schema);
const fromChoice = copy?.prompt?.trim() ?? "";
return fromChoice || null;
}
export function turnQuestionKind(focus: {
intent?: string | null;
expectedAnswerSchema?: Readonly<Record<string, unknown>> | null;
}): TurnQuestionKind {
const intent = focus.intent ?? "";
if (intent === "reverse_verify" || intent === "out_of_sample_check") return "reverse_verify";
if (parseAgentChoiceCopy(focus.expectedAnswerSchema)) return "choice";
return "collect_spoken";
}
export function turnQuestionFromFocus(focus: ConversationFocus): TurnQuestion | null {
const prompt = focusSpokenPrompt(focus.expectedAnswerSchema);
if (!prompt || !focus.id || !focus.questionId) return null;
const copy = parseAgentChoiceCopy(focus.expectedAnswerSchema);
const probeId = typeof focus.expectedAnswerSchema.probe_id === "string"
? focus.expectedAnswerSchema.probe_id
: null;
const answer = focus.answerOption;
return {
focus_id: focus.id,
question_id: focus.questionId,
kind: turnQuestionKind(focus),
prompt,
options: copy
? copy.options.map((option) => ({ key: option.key, label: option.label }))
: null,
status: focus.status,
answer_option: answer && ANSWER_OPTIONS.has(answer) ? answer : null,
probe_id: probeId,
};
}
export function parseTurnQuestion(value: unknown): TurnQuestion | null {
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
const row = value as Record<string, unknown>;
if (typeof row.focus_id !== "string" || typeof row.question_id !== "string") return null;
if (row.kind !== "choice" && row.kind !== "collect_spoken" && row.kind !== "reverse_verify") {
return null;
}
const prompt = typeof row.prompt === "string" ? row.prompt.trim() : "";
if (!prompt) return null;
const answer = row.answer_option;
const options = Array.isArray(row.options)
? row.options.flatMap((item): TurnQuestionOption[] => {
if (!item || typeof item !== "object") return [];
const option = item as Record<string, unknown>;
if (option.key !== "A" && option.key !== "B" && option.key !== "C" && option.key !== "D") {
return [];
}
if (typeof option.label !== "string" || !option.label.trim()) return [];
return [{ key: option.key, label: option.label.trim() }];
})
: [];
return {
focus_id: row.focus_id,
question_id: row.question_id,
kind: row.kind,
prompt,
options: options.length === 4 ? options : (row.kind === "choice" || row.kind === "reverse_verify" ? options : null),
status: typeof row.status === "string" ? row.status : "active",
answer_option: answer === "A" || answer === "B" || answer === "C" || answer === "D" || answer === "stop"
? answer
: null,
probe_id: typeof row.probe_id === "string" ? row.probe_id : null,
};
}
export function attachQuestionsToTurns<T extends { id: string; role: string; text: string | null }>(
turns: readonly T[],
focuses: readonly ConversationFocus[],
): Array<T & { question: TurnQuestion | null; text: string | null }> {
const byTurn = new Map<string, ConversationFocus>();
for (const focus of focuses) {
if (!focus.askedTurnId) continue;
byTurn.set(focus.askedTurnId, focus);
}
return turns.map((turn) => {
if (turn.role !== "assistant") return { ...turn, question: null };
const focus = byTurn.get(turn.id);
if (!focus) return { ...turn, question: null };
const question = turnQuestionFromFocus(focus);
const text = question && turn.text
? detachCollectSpokenAssistantText(turn.text, question.prompt)
: turn.text;
return { ...turn, text, question };
});
}
export function attachOfferResultToTurns<T extends {
id: string;
role: string;
question?: TurnQuestion | null;
}>(
turns: readonly T[],
input: {
resultId: string | null | undefined;
canAdopt: boolean;
acceptedTime: string | null | undefined;
},
): Array<T & { offer_result_id: string | null }> {
const resultId = input.resultId ?? null;
const offered = Boolean(resultId && (input.canAdopt || input.acceptedTime));
if (!offered || !resultId) {
return turns.map((turn) => ({ ...turn, offer_result_id: null }));
}
const firstVerify = turns.findIndex((turn) => (
turn.role === "assistant" && turn.question?.kind === "reverse_verify"
));
const searchUntil = firstVerify >= 0 ? firstVerify : turns.length;
let ownerIndex = -1;
for (let index = searchUntil - 1; index >= 0; index -= 1) {
if (turns[index]?.role === "assistant") {
ownerIndex = index;
break;
}
}
if (ownerIndex < 0) {
for (let index = turns.length - 1; index >= 0; index -= 1) {
if (turns[index]?.role === "assistant") {
ownerIndex = index;
break;
}
}
}
return turns.map((turn, index) => ({
...turn,
offer_result_id: index === ownerIndex ? resultId : null,
}));
}
export function questionIsAnswered(question: TurnQuestion): boolean {
return question.status !== "active";
}
export function copyTextForMessage(body: string, question: TurnQuestion | null | undefined): string {
const spoken = body.trim();
if (!question?.prompt) return spoken;
const lines = [spoken, "", question.prompt];
if (question.options?.length) {
lines.push("");
for (const option of question.options) {
const mark = question.answer_option === option.key ? " ✓" : "";
lines.push(`${option.key}. ${option.label}${mark}`);
}
}
return lines.join("\n").trim();
}