2026-08-22 00:19:49 +03:00
/**
2026-08-31 19:51:17 +03:00
* question-core — the question contract + pure grading logic. No framework
* imports, no UI: everything here is deterministic and unit-testable.
2026-08-22 00:19:49 +03:00
*
2026-09-25 23:32:13 +03:00
* The TypeBox schemas below are the tool INPUT contracts (agent/tools
* declares the tools against them); the browser validates, shuffles, and
* grades with these functions, then sends the verdict back as the tool
* output via addToolOutput:
2026-08-22 02:18:27 +03:00
* 1. The teacher model calls the `quiz` tool (question + options +
* correctAnswer + explanation). No server execute → the call lands in
* the UI as a typed tool part in `input-available` state.
* 2. The learner answers in the card; the client grades with this module.
* 3. addToolOutput sends the verdict (selection + correctness + note —
* never the explanation, the model authored that) back to the model.
*
* Because the pending question IS a persisted message part, a session can be
* exited and resumed mid-question for free.
2026-08-22 00:19:49 +03:00
*/
2026-09-25 23:32:13 +03:00
import { Type , type Static } from "typebox" ;
2026-08-31 19:51:17 +03:00
2026-08-22 00:19:49 +03:00
// ─── shared types ───────────────────────────────────────────────────────────
export interface QuizOption {
label : string ;
value : string ;
description? : string ;
}
2026-08-31 19:51:17 +03:00
// ─── tool input schemas (the question contract) ─────────────────────────────
// Parameter names mirror the prompt docs exactly — keep them in sync.
2026-09-26 19:08:39 +03:00
/** One quiz answer option. Owner ruling (2026-09-26): NO `description`
* field — the model reliably used it to leak correctness ("that's not the
* gradient", "close but…"), making quizzes answerable by elimination.
* Neutral elaboration belongs in the question text or the post-answer
* explanation; the option is a bare claim. */
const quizOptionProps = {
label : Type.String ({ description : "Display label for the answer option — a bare claim, no reasoning" }),
2026-09-25 23:32:13 +03:00
value : Type.Optional (
Type . String ({
description : "Optional machine-readable value returned for the option. Defaults to the label." ,
}),
),
2026-09-26 19:08:39 +03:00
};
/** One ask answer option — keeps `description`: ask is ungraded preference
* collection, there is no correctness to leak. */
const askOptionProps = {
... quizOptionProps ,
2026-09-25 23:32:13 +03:00
description : Type.Optional (
Type . String ({ description : "Optional extra detail shown below the option" }),
),
};
2026-08-31 19:51:17 +03:00
2026-09-26 19:08:39 +03:00
/** GLM wobble guard (owner-approved, 2026-09-26): models sometimes emit
* `options` as a JSON-ENCODED STRING instead of an array. Parse it before
* use. Shared by the tools' prepareArguments (server, pre-validation) and
* normalizeOptions (client — the persisted call keeps the raw string, so
* the dock must coerce again). Returns the input untouched when it isn't
* a parseable array — validation then reports honestly. */
export function parseOptionsIfString < T >( options : string | T [] | undefined ) : unknown {
if ( typeof options !== "string" ) return options ;
try {
const parsed : unknown = JSON . parse ( options );
if ( Array . isArray ( parsed )) return parsed ;
} catch {
/* not JSON — fall through untouched */
}
return options ;
}
2026-09-01 01:47:47 +03:00
// ─── cross-field authoring validation (tool-schema refinements) ──────────
// Enforced at the tool-call boundary (see the superRefines below): an invalid
// question NEVER reaches the learner — the SDK returns the validation error
// to the teacher as a tool result and the teacher retries. The rules mirror
// the grading path exactly (normalizeOptions / coerceCorrectAnswer /
// resolveCorrect), so schema-valid ⇔ gradeable.
2026-08-31 19:51:17 +03:00
2026-09-01 01:47:47 +03:00
/** Option sanity shared by quiz + ask: labels non-empty, resolved values
* (trimmed `value`, falling back to the trimmed label — exactly how
* normalizeOptions keys them) distinct. Returns the problems plus the
* resolved keys for the correctAnswer check. */
function optionProblems (
options : Array < { label : string ; value? : string } > ,
) : { problems : string []; keys : string [] } {
const problems : string [] = [];
const keys : string [] = [];
options . forEach (( option , i ) => {
const label = option . label . trim ();
if ( label . length === 0 ) {
problems . push ( `options[ ${ i } ].label is empty — every option needs a label.` );
return ;
}
const key = option . value ? . trim () || label ;
if ( keys . includes ( key )) {
problems . push ( `Two options resolve to the same value " ${ key } " — options must be distinct.` );
}
keys . push ( key );
});
return { problems , keys };
}
/** ask options: the shared sanity, nothing more (ask is not graded). */
export function askOptionProblems ( options : Array < { label : string ; value? : string } > ) : string [] {
return optionProblems ( options ). problems ;
}
/** The full quiz authoring contract: option sanity + correctAnswer must be
* gradeable. Empty result = a quiz the dock can arm and grade faithfully. */
export function quizInputProblems ( input : {
options : Array < { label : string ; value? : string } > ;
correctAnswer : string | string [];
multiSelect? : boolean ;
}) : string [] {
const { problems , keys } = optionProblems ( input . options );
const entries = coerceCorrectAnswer ( input . correctAnswer ). map (( v ) => v . trim ());
const unique = [... new Set ( entries )];
if ( unique . length === 0 || unique . some (( v ) => v . length === 0 )) {
problems . push ( "correctAnswer is required — provide the value (or label) of the correct option(s)." );
return problems ;
}
for ( const v of unique ) {
if ( ! keys . includes ( v )) {
problems . push (
`correctAnswer " ${ v } " does not match any option value. Valid values: ${ keys . map (( k ) => `" ${ k } "` ). join ( ", " ) } . ` +
"Copy the exact value (or label) of the intended option — never a paraphrase or slug." ,
);
}
}
if ( unique . length > 1 && input . multiSelect !== true ) {
problems . push (
`correctAnswer lists ${ unique . length } values but multiSelect is not true — a single-select question with several ` +
"correct answers can never be answered correctly. Set multiSelect: true, or make exactly one option correct." ,
);
}
return problems ;
}
2026-09-11 06:44:20 +03:00
// The SHARED quiz authoring fields — everything except `nodeId`. The probe
// and teach agents use DIFFERENT tools under the same name (docs/probe-teach-
// split.md §1): the prober's quiz has NO nodeId field at all (there is no
// map to position on), the teacher's adds it (the position signal — the
2026-09-25 23:32:13 +03:00
// per-session tree validation runs in the tool's execute, tools.ts).
// Grading rules are identical, single-sourced here. The cross-field
// authoring contract (quizInputProblems, above) is enforced at the
// tool-call boundary in execute — TypeBox carries the structure to the
// model, the problems function carries the refinements.
//
// Argument-order ruling (owner, 2026-09-25): the explanation is REASONING,
// and reasoning happens BEFORE the answer is chosen — models emit tool
// args in schema order, so explanation sits immediately BEFORE correctAnswer.
// (All consumers read by key; the order is authoring guidance, nothing else.)
export const quizInputBase = Type . Object ({
question : Type.String ({
description :
"The single quiz question to ask. ONE question per turn: ask, then end your turn — the answer determines the next question." ,
}),
2026-09-26 19:08:39 +03:00
options : Type.Array ( Type . Object ( quizOptionProps ), {
2026-09-25 23:32:13 +03:00
minItems : 2 ,
description :
2026-09-26 19:08:39 +03:00
"REQUIRED. The answer options (2 or more) as a JSON ARRAY of option objects — never a " +
"JSON-encoded string. Options only — there is no free-text mode. " +
2026-09-25 23:32:13 +03:00
"Give each option a stable `value`; you reference the correct one by that value in correctAnswer. " +
"Never add your own 'I don't know' option — that choice is added for you." ,
}),
multiSelect : Type.Optional (
Type . Boolean ({
description : "Set to true when more than one option is correct and the learner must select all of them." ,
}),
),
explanation : Type.String ({
description :
2026-09-25 03:31:53 +03:00
"REQUIRED. Explanation revealed AFTER the learner answers (shown whether they got it right or wrong). " +
2026-09-25 23:32:13 +03:00
"Use it to reinforce why the correct answer is correct. It is written BEFORE correctAnswer by design: " +
"reason it out first, then commit the answer the reasoning supports." ,
}),
correctAnswer : Type.Union ([ Type . String (), Type . Array ( Type . String ())], {
description :
2026-09-11 06:44:20 +03:00
"REQUIRED. The correct answer as the option value(s) — the `value` field of the option you intend. " +
2026-09-25 23:32:13 +03:00
'Single-select: a single string (e.g. "mercury"). Multi-select: an array of strings (e.g. ["belize", "niue"]); ' +
"the learner is only correct if their selection matches this set exactly. " +
"Always pass the value, not a position number — this is self-checking and prevents miscounting. " +
"A value that matches no option fails validation and the call is returned to you to fix." ,
}),
details : Type.Optional (
Type . String ({ description : "Optional extra context or instructions shown under the question." }),
),
2026-09-11 06:44:20 +03:00
});
// The TEACH-side quiz object: the shared base + the plan-position field.
// `nodeId` means "this question is ABOUT this node" — a position signal,
2026-09-25 23:32:13 +03:00
// never a completion signal (docs/probe-teach-split.md §3). The cross-field
// authoring contract (the Gate-3 hardening: a quiz whose correctAnswer
// can't be graded NEVER reaches the learner — schema-valid ⇔ gradeable, the
// rules exactly what the grading path accepts) runs at the CALL BOUNDARY in
// the tool's execute: quizInputProblems here, the per-session plan-nodeId
// validation in tools.ts (it reads the live chat record, which this core
// never touches).
export const quizInputObject = Type . Object ({
... quizInputBase . properties ,
2026-09-27 02:05:51 +03:00
nodeId : Type.String ({
description :
"REQUIRED plan-tree node id this question is about — must EXACTLY match one of this session's " +
"plan_init node slugs (validated at call time; a mismatched id is rejected back to you). " +
"The lesson's position on the map derives from these tags (the node the latest tagged question is about is " +
"where the map stands; earlier ones count as visited). A node takes as many questions as it deserves: stay on " +
"one node for as many turns as the understanding needs — one reasoning step at a time, never rush forward. " +
"For off-map checks use ask_user_question." ,
}),
2026-09-11 06:44:20 +03:00
});
2026-09-25 23:32:13 +03:00
/** The PROBE-side quiz declaration: the shared authoring contract, no
* nodeId field at all — the session's map doesn't exist yet. Structurally
* identical to quizInputBase (the old superRefine never reached the wire —
* it ran at the call boundary, where quizInputProblems runs now). */
export const probeQuizInputSchema = quizInputBase ;
2026-08-31 19:51:17 +03:00
2026-09-27 08:55:13 +03:00
// ─── the explain tool (free-recall quizzing, owner 2026-09-27) ─────────────
//
// Quiz mechanics WITHOUT options: the learner answers in his own words.
// The SYCOPHANCY MITIGATION is structural — the model commits to the full
// correct answer BEFORE posing (the commitment rides the persisted call
// args, is revealed to the learner on submit, and is restated in the answer
// result the model grades against; the client never grades free text).
2026-09-27 09:13:34 +03:00
// ONE committed answer, reasoning included (owner ruling 2026-09-27: the
// separate explanation field was quiz remnant — collapsed away).
// Mechanics doctrine lives in the TOOL DESCRIPTIONS (pi-native ruling),
// not in the system prompt.
2026-09-27 08:55:13 +03:00
export const explainInputBase = Type . Object ({
question : Type.String ({
description :
"The single question to ask. ONE question per turn: ask, then end your turn — the answer determines the next." ,
}),
correctAnswer : Type.String ({
description :
2026-09-27 09:13:34 +03:00
"REQUIRED. The full correct answer in your own words, reasoning included — COMMITTED before the learner " +
"ever sees the question. The commitment is immutable: when his answer returns, grade it strictly against this " +
"text — never revise the commitment after seeing his words, and never mark a wrong answer right to be kind. " +
"It is shown to him on submit; your next reply grades the nuance of what he wrote." ,
2026-09-27 08:55:13 +03:00
}),
details : Type.Optional (
Type . String ({ description : "Optional extra context or instructions shown under the question." }),
),
});
/** The TEACH-side explain object: base + the plan-position field (same
* semantics as the quiz's nodeId — the seam recognizes BOTH tools). */
export const explainInputObject = Type . Object ({
... explainInputBase . properties ,
nodeId : Type.String ({
description :
"REQUIRED plan-tree node id this question is about — must EXACTLY match one of this session's " +
"plan_init node slugs (validated at call time; a mismatched id is rejected back to you). " +
"The lesson's position on the map derives from these tags (the node the latest tagged question is about is " +
"where the map stands; earlier ones count as visited). A node takes as many questions as it deserves: stay on " +
"one node for as many turns as the understanding needs — one reasoning step at a time, never rush forward. " +
"For off-map checks use ask_user_question." ,
}),
});
/** The explain authoring contract: the commitment must exist. Empty
* result = an explain the dock can arm and the model can grade against. */
export function explainInputProblems ( input : { correctAnswer : string }) : string [] {
if ( input . correctAnswer . trim (). length === 0 ) {
return [ "correctAnswer is required — commit to the full correct answer BEFORE posing the question." ];
}
return [];
}
/** The tool names that carry the quizzing mechanic — the teach seam
* (phases), the plan-position fold, and the pending gate all recognize
* whichever is armed. */
export const QUIZ_TOOL_NAMES = [ "quiz" , "explain" ] as const ;
2026-09-25 23:32:13 +03:00
/** The TEACHER's quiz declaration (base + the position field). */
export const quizInputSchema = quizInputObject ;
export const askInputSchema = Type . Object ({
// No `mode` param — the pi original has none: the mode is inferred from
// whether options are present (the dock does exactly this). One less
// knob for the model to misuse.
question : Type.String ({
description :
"The single question to ask the learner. ONE question per turn: ask, then end your turn — the answer determines the next question." ,
}),
details : Type.Optional (
Type . String ({ description : "Optional extra context or instructions shown under the question." }),
),
options : Type.Optional (
2026-09-26 19:08:39 +03:00
Type . Array ( Type . Object ( askOptionProps ), {
2026-09-25 23:32:13 +03:00
minItems : 2 ,
description :
"Optional multiple-choice options. Omit for free-form text input. " +
"The learner can always choose 'Other' and type a custom answer when options are provided." ,
}),
),
multiSelect : Type.Optional (
Type . Boolean ({ description : "Set to true to allow multiple answers to the same question." }),
),
});
// Ask's option sanity (labels non-empty, values distinct — ask is not
// graded, but duplicate values break the dock's value keying) runs at the
// call boundary in execute (askOptionProblems, above).
export type QuizToolInput = Static < typeof quizInputObject >;
export type AskToolInput = Static < typeof askInputSchema >;
2026-09-27 08:55:13 +03:00
export type ExplainToolInput = Static < typeof explainInputObject >;
export type ProbeExplainToolInput = Static < typeof explainInputBase >;
2026-08-31 19:51:17 +03:00
2026-08-22 00:19:49 +03:00
/** A real selected answer, in the order the options were displayed. */
export interface OptionAnswer {
label : string ;
value : string ;
index : number ; // 1-based display index
}
2026-09-27 08:55:13 +03:00
export type QuestionKind = "quiz" | "ask" | "explain" ;
2026-08-22 00:19:49 +03:00
export type SelectMode = "single-select" | "multi-select" ;
export type QuestionMode = SelectMode | "text" ;
/** Full question data — stored in the `lh_question` custom entry. */
export interface QuestionData {
id : string ;
kind : QuestionKind ;
question : string ;
context? : string ;
mode : QuestionMode ;
options : QuizOption []; // display order (post-shuffle)
// quiz only. NEVER send to the client before answering:
correctIndices? : number [];
2026-09-27 08:55:13 +03:00
// explain only. NEVER send to the client before answering:
committedAnswer? : string ;
2026-09-27 09:13:34 +03:00
// quiz only (the MC "why"). NEVER send to the client before answering:
2026-08-22 00:19:49 +03:00
explanation? : string ;
createdAt : string ;
}
2026-10-02 04:04:04 +03:00
/** The learner's answer — full structured record, stored in `lh_question_answered`.
* NO questionId (owner, tool-result diet 2026-10-02): it was the
* toolCallId restated — the toolResult message already carries it. */
2026-08-22 00:19:49 +03:00
export interface QuestionAnswerData {
kind : QuestionKind ;
questionText : string ;
options ?: { index : number ; label : string }[]; // snapshot for rendering
dontKnow : boolean ;
answers : OptionAnswer [];
other? : string ; // ask: "Other" free text
2026-09-27 08:55:13 +03:00
text? : string ; // ask/explain: text-mode answer
2026-08-22 00:19:49 +03:00
note? : string ;
2026-10-02 00:24:05 +03:00
confidence? : number ; // quiz only: the learner's stated probability (0-100, raw) that his selected answer is correct — never sent on "I don't know" (owner, 2026-10-01)
2026-08-22 00:19:49 +03:00
correct? : boolean ; // quiz only
correctIndices? : number []; // quiz only (rendering)
2026-09-27 08:55:13 +03:00
committed? : string ; // explain only (rendering): the model's committed answer
2026-09-27 09:13:34 +03:00
explanation? : string ; // quiz only (rendering)
2026-08-22 00:19:49 +03:00
dismissed? : boolean ;
answeredAt : string ;
}
/** Client-facing question payload — deliberately omits the answer. */
export interface ClientQuestion {
id : string ;
kind : QuestionKind ;
question : string ;
context? : string ;
mode : QuestionMode ;
options : QuizOption [];
}
/** What the server passes to the bridge when the learner answers. */
export interface AnswerRequest {
dontKnow? : boolean ;
values? : string []; // option values
other? : string ; // ask "Other"
text? : string ; // ask text-mode
note? : string ;
2026-10-02 00:24:05 +03:00
confidence? : number ; // quiz only: 0-100 raw probability — 50 is a real coin flip, always sent (owner, 2026-10-01)
2026-08-22 00:19:49 +03:00
}
2026-10-02 00:24:05 +03:00
/** The confidence slider's snap stops (owner, 2026-10-01): raw probability
* that the selected answer is correct, uneven on purpose — resolution
* concentrates at the top where the calibration signal lives (90 vs 100
* separates knows from mastered). 50 is a genuine coin flip and the
* resting default — untouched and deliberate 50 are the same number. */
export const CONFIDENCE_STOPS : readonly number [] = [ 0 , 30 , 50 , 70 , 90 , 100 ];
export const DEFAULT_CONFIDENCE_INDEX = 2 ;
2026-09-12 04:45:58 +03:00
// ─── dock selection (the client state machine, pure for tests) ─────────────
/** The question dock's selection state (0-based indices in display order). */
export interface DockSelection {
selected : Set < number >;
otherSelected : boolean ;
dontKnow : boolean ;
}
export const emptyDockSelection = () : DockSelection => ({ selected : new Set (), otherSelected : false , dontKnow : false });
/**
* Toggling a listed option. Single-select: the option becomes THE answer and
* displaces "Other". Multi-select: the option toggles alongside its siblings
* AND "Other" — selections are independent (picking options must never clear
* a custom answer, and picking the custom row must never clear them).
*/
export function toggleOption ( mode : SelectMode , sel : DockSelection , idx : number ) : DockSelection {
if ( mode === "multi-select" ) {
const selected = new Set ( sel . selected );
if ( selected . has ( idx )) selected . delete ( idx );
else selected . add ( idx );
return { selected , otherSelected : sel.otherSelected , dontKnow : false };
}
return { selected : new Set ([ idx ]), otherSelected : false , dontKnow : false };
}
/**
* Toggling the ask "Other…" row. Single-select: picking it makes it THE
* answer, displacing the listed options (the typed text always survives).
* Multi-select: it toggles alongside the listed selections and clears
* nothing.
*/
export function toggleOther ( mode : SelectMode , sel : DockSelection ) : DockSelection {
if ( mode === "multi-select" ) return { ... sel , otherSelected : ! sel . otherSelected , dontKnow : false };
return { selected : new Set (), otherSelected : true , dontKnow : false };
}
2026-08-22 00:19:49 +03:00
// ─── options ────────────────────────────────────────────────────────────────
export function normalizeOptions (
2026-09-26 19:08:39 +03:00
options : string | Array < { label : string ; value? : string ; description? : string } > | undefined ,
2026-08-22 00:19:49 +03:00
) : QuizOption [] {
const seen = new Set < string >();
2026-09-26 19:08:39 +03:00
// The persisted tool call can carry GLM's string-encoded options —
// parseOptionsIfString is the same coercion the server's
// prepareArguments runs pre-validation (see parseOptionsIfString).
const coerced = parseOptionsIfString ( options );
const list = Array . isArray ( coerced )
? ( coerced as Array < { label : string ; value? : string ; description? : string } > )
: [];
return list
2026-08-22 00:19:49 +03:00
. map (( option ) => ({
label : option.label.trim (),
value : option.value?.trim () || option . label . trim (),
description : option.description?.trim () || undefined ,
}))
. filter (( option ) => {
if ( option . label . length === 0 ) return false ;
if ( seen . has ( option . value )) throw new Error ( `duplicate option value " ${ option . value } "` );
seen . add ( option . value );
return true ;
});
}
/** Fisher-Yates over a copy. correctAnswer is keyed by value, so display
* order can be shuffled safely before resolving indices. */
2026-08-22 03:23:11 +03:00
// ─── options ────────────────────────────────────────────────────────────────
/** mulberry32 seeded by a string — deterministic shuffle for hydration-safe rendering. */
export function seededRandom ( seed : string ) : () => number {
let h = 2166136261 ;
for ( let i = 0 ; i < seed . length ; i ++ ) {
h ^= seed . charCodeAt ( i );
h = Math . imul ( h , 16777619 );
}
let a = h >>> 0 ;
return () => {
a |= 0 ;
a = ( a + 0x6d2b79f5 ) | 0 ;
let t = Math . imul ( a ^ ( a >>> 15 ), 1 | a );
t = ( t + Math . imul ( t ^ ( t >>> 7 ), 61 | t )) ^ t ;
return (( t ^ ( t >>> 14 )) >>> 0 ) / 4294967296 ;
};
}
/**
* Fisher– Yates with an injectable RNG. Pass `seededRandom(toolCallId)` in
* rendered components: Math.random() shuffles differently on server vs client
* and breaks hydration (React #418). A per-toolCallId seed keeps the order
* stable across reloads while still varying it per question.
*/
export function shuffleOptions ( options : QuizOption [], rand : () => number = Math . random ) : QuizOption [] {
2026-08-22 00:19:49 +03:00
const out = [... options ];
for ( let i = out . length - 1 ; i > 0 ; i -- ) {
2026-08-22 03:23:11 +03:00
const j = Math . floor ( rand () * ( i + 1 ));
2026-08-22 00:19:49 +03:00
const a = out [ i ] ! ;
const b = out [ j ] ! ;
out [ i ] = b ;
out [ j ] = a ;
}
return out ;
}
// ─── quiz grading ───────────────────────────────────────────────────────────
/** The harness sometimes delivers a multi-select correctAnswer array as a
* JSON-stringified string because the schema union lists String first. */
export function coerceCorrectAnswer ( correctAnswer : string | string []) : string [] {
if ( Array . isArray ( correctAnswer )) return correctAnswer ;
const trimmed = correctAnswer . trim ();
if ( trimmed . startsWith ( "[" ) && trimmed . endsWith ( "]" )) {
try {
const parsed = JSON . parse ( trimmed );
if ( Array . isArray ( parsed )) return parsed . map (( v ) => String ( v ));
} catch {
// not valid JSON — treat as a single literal value
}
}
return [ correctAnswer ];
}
/** Resolve author-supplied value(s) to 1-based display indices. Keying by
* value (not position) makes a typo a hard error instead of a silent wrong
* grade. Must run AFTER shuffling. */
export function resolveCorrect (
correctAnswer : string | string [] | undefined ,
options : QuizOption [],
) : { indices : number []; error? : string } {
if ( correctAnswer === undefined ) return { indices : [], error : "correctAnswer is required" };
const arr = coerceCorrectAnswer ( correctAnswer );
if ( arr . length === 0 ) return { indices : [], error : "correctAnswer is required" };
const byValue = new Map ( options . map (( o , i ) => [ o . value , i + 1 ]));
const indices : number [] = [];
for ( const raw of arr ) {
const v = typeof raw === "string" ? raw . trim () : raw ;
const idx = byValue . get ( v );
if ( idx === undefined ) {
const known = options . map (( o ) => `" ${ o . value } "` ). join ( ", " );
return { indices : [], error : `correctAnswer " ${ v } " does not match any option value ( ${ known } )` };
}
indices . push ( idx );
}
return { indices : Array.from ( new Set ( indices )). sort (( a , b ) => a - b ) };
}
/** Exact-set match: same number of selections, same indices. */
export function isCorrect ( selectedIndices : number [], correctIndices : number []) : boolean {
if ( selectedIndices . length !== correctIndices . length ) return false ;
const a = [... selectedIndices ]. sort (( x , y ) => x - y );
const b = [... correctIndices ]. sort (( x , y ) => x - y );
return a . every (( v , i ) => v === b [ i ]);
}
// ─── arming + answering ─────────────────────────────────────────────────────
export function toClientQuestion ( qd : QuestionData ) : ClientQuestion {
return {
id : qd.id ,
kind : qd.kind ,
question : qd.question ,
context : qd.context ,
mode : qd.mode ,
options : qd.options ,
};
}
function formatOptionRef ( options : QuizOption [], index : number ) : string {
const opt = options . find (( o , i ) => i + 1 === index );
return ` ${ index } . ${ opt ? opt . label : "(unknown)" } ` ;
}
/**
* Turn an AnswerRequest into the full answer record + the user message the
* agent will see. Quiz answers are graded here; ask answers are not.
*/
export function buildAnswer ( qd : QuestionData , req : AnswerRequest ) : QuestionAnswerData {
const base : QuestionAnswerData = {
kind : qd.kind ,
questionText : qd.question ,
options : qd.options.map (( o , i ) => ({ index : i + 1 , label : o.label })),
dontKnow : req.dontKnow === true ,
answers : [],
note : req.note?.trim () || undefined ,
answeredAt : new Date (). toISOString (),
};
// resolve selected option values → OptionAnswer (in display order)
if ( req . values && req . values . length > 0 ) {
const byValue = new Map ( qd . options . map (( o , i ) => [ o . value , { ... o , index : i + 1 }]));
for ( const v of req . values ) {
const opt = byValue . get ( v );
if ( ! opt ) continue ; // unknown value — ignore
base . answers . push ({ label : opt.label , value : opt.value , index : opt.index });
}
base . answers . sort (( a , b ) => a . index - b . index );
}
if ( req . other ? . trim ()) base . other = req . other . trim ();
if ( req . text ? . trim ()) base . text = req . text . trim ();
if ( qd . kind === "quiz" ) {
base . correctIndices = qd . correctIndices ?? [];
base . explanation = qd . explanation ;
base . correct = base . dontKnow ? false : isCorrect ( base . answers . map (( a ) => a . index ), qd . correctIndices ?? []);
2026-10-02 00:24:05 +03:00
// The confidence slider rides on real quiz answers only — never on
// "I don't know" (no answer, no probability). Clamped defensively;
// the dock snaps to the grid, the record accepts any integer.
if ( ! base . dontKnow && req . confidence !== undefined ) {
base . confidence = Math . max ( 0 , Math . min ( 100 , Math . round ( req . confidence )));
}
2026-08-22 00:19:49 +03:00
}
2026-09-27 08:55:13 +03:00
if ( qd . kind === "explain" ) {
// No client grading — the model grades in its reply, against the
2026-09-27 09:13:34 +03:00
// commitment. The commitment snapshots for the reveal + rendering.
2026-09-27 08:55:13 +03:00
base . committed = qd . committedAnswer ;
}
2026-08-22 00:19:49 +03:00
return base ;
}
/**
2026-10-02 04:04:04 +03:00
* The MODEL-VISIBLE answer record (owner, tool-result diet 2026-10-02):
* the tool call already carries everything the model itself authored —
* the question, every option, the correct answer, the explanation, the
* explain commitment — so the result returns ONLY what the learner added:
* his selection or words, the graded verdict, his stated confidence, his
* note. The full graded QuestionAnswerData rides `details` for the record
* and the UI; this is the JSON the agent actually reads.
2026-08-22 00:19:49 +03:00
*/
2026-10-02 04:04:04 +03:00
export function compactAnswerForModel ( ad : QuestionAnswerData ) : Record < string , unknown > {
if ( ad . dismissed ) return { kind : ad.kind , dismissed : true };
const out : Record < string , unknown > = { kind : ad.kind };
if ( ad . dontKnow ) out . dontKnow = true ;
// Array.isArray: the seam is called with `unknown` answers (test
// fixtures, foreign records) — degrade to omission, never crash.
if ( Array . isArray ( ad . answers ) && ad . answers . length > 0 )
out . answers = ad . answers . map (( a ) => ({ index : a.index , label : a.label }));
if ( ad . text ) out . text = ad . text ;
if ( ad . other ) out . other = ad . other ;
// The verdict + stated confidence are the learner's PERFORMANCE — new
// information the model needs for its next move. (dontKnow implies
// incorrect and carries no confidence: both stay implicit.)
if ( ad . correct !== undefined && ! ad . dontKnow ) out . correct = ad . correct ;
if ( ad . confidence !== undefined ) out . confidence = ad . confidence ;
if ( ad . note ) out . note = ad . note ;
return out ;
2026-08-22 00:19:49 +03:00
}