Files
Jyotisha/frontend/src/lib/rectification-agentic/v9/turn-question.ts
T
jesse-ux 69ffa07d6b
Independent Staging Quality Gate / validate (push) Failing after 20s
Independent Staging Quality Gate / publish (push) Skipped
fix(rectification): stop speaking unstampable distinguish stems (BUG-674/675)
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).
2026-09-14 03:42:48 +08:00

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