import { stripQuestionSentences } 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" | "skip_probe" | null; probe_id: string | null; }>; const ANSWER_OPTIONS = new Set(["A", "B", "C", "D", "stop", "skip_probe"]); export function focusSpokenPrompt(schema: Readonly> | 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> | 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; 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; 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 === "skip_probe" ? answer : null, probe_id: typeof row.probe_id === "string" ? row.probe_id : null, }; } export function attachQuestionsToTurns( turns: readonly T[], focuses: readonly ConversationFocus[], ): Array { const byTurn = new Map(); const hanging: ConversationFocus[] = []; for (const focus of focuses) { if (!focus.askedTurnId) { if (focus.status === "active" && turnQuestionKind(focus) === "choice") { hanging.push(focus); } continue; } byTurn.set(focus.askedTurnId, focus); } const lastAssistant = [...turns].reverse().find((turn) => turn.role === "assistant"); if (hanging[0] && lastAssistant && !byTurn.has(lastAssistant.id)) { byTurn.set(lastAssistant.id, hanging[0]); } 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 ? stripQuestionSentences(turn.text, question.prompt) : turn.text; return { ...turn, text, question }; }); } export function interviewQuestionBlocksAdoptOffer( question: TurnQuestion | null | undefined, adopted: boolean, ): boolean { if (adopted) return false; if (!question || questionIsAnswered(question)) return false; return question.kind === "collect_spoken" || question.kind === "choice"; } export type CandidateOfferAnchor = { renderKey: string; role: string; state?: string; text?: string | null; failed?: boolean; question?: TurnQuestion | null; candidateOffer?: Readonly<{ resultId: string }>; }; function isSettledAssistant(message: CandidateOfferAnchor): boolean { if (message.role !== "assistant" || message.failed) return false; if (message.state && message.state !== "settled") return false; return Boolean(message.text); } export function applyLiveCandidateOffer( messages: readonly T[], input: { resultId: string; canOffer: boolean; adopted: boolean }, ): T[] { if (input.adopted) { if (!messages.some((message) => message.candidateOffer)) return [...messages]; return messages.map((message) => ( message.candidateOffer ? { ...message, candidateOffer: { resultId: input.resultId } } : message )); } if (!input.canOffer) return [...messages]; const liveInterview = messages.some((message) => ( interviewQuestionBlocksAdoptOffer(message.question, false) )); if (liveInterview) { return messages.map((message) => ( message.candidateOffer ? { ...message, candidateOffer: undefined } : message )); } const owner = [...messages].reverse().find((message) => ( isSettledAssistant(message) && !interviewQuestionBlocksAdoptOffer(message.question, false) )); if (!owner) { return messages.map((message) => ( message.candidateOffer ? { ...message, candidateOffer: undefined } : message )); } return messages.map((message) => ({ ...message, candidateOffer: message.renderKey === owner.renderKey ? { resultId: input.resultId } : undefined, })); } export function persistedOfferFromTurn( offerResultId: string | null | undefined, previous: Readonly<{ resultId: string }> | undefined, turnHydrated: boolean, ): Readonly<{ resultId: string }> | undefined { if (typeof offerResultId === "string" && offerResultId.length > 0) { return { resultId: offerResultId }; } if (turnHydrated) return undefined; return previous; } export type SelectionCardLock = Readonly<{ resultId: string; key: string }>; /** Keep the offering message once the reader has seen the card for this result. */ export function nextSelectionCardLock( current: SelectionCardLock | null, input: { resultId: string | null | undefined; key: string | undefined }, ): SelectionCardLock | null { if (!input.resultId) return null; if (input.key) { if (current?.resultId === input.resultId && current.key === input.key) return current; return { resultId: input.resultId, key: input.key }; } if (current?.resultId === input.resultId) return current; return null; } /** * After adopt, do not re-anchor onto the follow-up turn. Prefer a live offer, * then the pre-adopt fallback, then the lock from when the card first appeared. */ export function resolveSelectionCardMessageKey(input: { persistedOfferKey: string | undefined; fallbackKey: string | undefined; locked: SelectionCardLock | null; resultId: string | null | undefined; selectedTime: string | null | undefined; canOffer: boolean; }): string | undefined { if (!input.resultId) return undefined; const live = input.persistedOfferKey ?? (input.canOffer && !input.selectedTime ? input.fallbackKey : undefined); if (live) return live; if (input.locked?.resultId === input.resultId) return input.locked.key; return undefined; } function assistantCanOwnAdoptOffer( turn: { role?: string; question?: TurnQuestion | null }, adopted: boolean, ): boolean { return turn.role === "assistant" && !interviewQuestionBlocksAdoptOffer(turn.question, adopted); } export function attachOfferResultToTurns( turns: readonly T[], input: { resultId: string | null | undefined; canAdopt: boolean; acceptedTime: string | null | undefined; }, ): Array { 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 adopted = Boolean(input.acceptedTime); 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 (assistantCanOwnAdoptOffer(turns[index] ?? {}, adopted)) { ownerIndex = index; break; } } if (ownerIndex < 0) { for (let index = turns.length - 1; index >= 0; index -= 1) { if (assistantCanOwnAdoptOffer(turns[index] ?? {}, adopted)) { 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(); }