Distinguish followups that cannot stamp a probe now go to persistExhaustionCollect instead of repeating the card stem in the assistant body. Choice focuses without asked_turn_id hang on the last assistant turn, and persisted_question with a live choice_card renders the existing card. Representative-time inconsistency is warn-only (BUG-676 investigating).
312 lines
11 KiB
TypeScript
312 lines
11 KiB
TypeScript
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<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 === "skip_probe"
|
|
? 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>();
|
|
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<T extends CandidateOfferAnchor>(
|
|
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<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 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();
|
|
}
|