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>
181 lines
6.4 KiB
TypeScript
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();
|
|
}
|