UNPKG

pi-decider

Version:

Decision backends for pi and omp — TypeSafe Jev, OpenRouter's Decisions API, or an OpenAI-compatible chat proxy — exposed as one typed tool (noul / choice / score)

420 lines (388 loc) • 16.6 kB
/** * Question normalization and answer conformance. * * Both engines (native Jev and the LLM proxy) go through this module, so the tool's * input and output shapes are fixed by the plugin rather than by whichever backend * answered: questions are validated against the documented TypeSafe shapes, and every * answer is conformed into the same typed result (or reported as a per-question issue). */ import { JevError } from "./errors.ts"; export type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue }; export interface NoulQuestion { type: "noul"; instructions: JsonValue; criteria?: { true?: string; false?: string }; } export interface ChoiceQuestion { type: "choice"; instructions: JsonValue; criteria: Record<string, string | null>; } export interface ScoreQuestion { type: "score"; instructions: JsonValue; /** Ordered level descriptions: index 0 is the lowest level. */ criteria: string[]; } export type JevQuestion = NoulQuestion | ChoiceQuestion | ScoreQuestion; export interface NoulAnswer { type: "noul"; noul: number; } export interface ChoiceAnswer { type: "choice"; choice: string; probabilities: Record<string, number>; /** Absent when the engine did not report one (the LLM proxy only relays what the model returned). */ confidence?: number; } export interface ScoreAnswer { type: "score"; score: number; legend: Record<string, string>; probabilities: Record<string, number>; confidence?: number; } export type JevAnswer = NoulAnswer | ChoiceAnswer | ScoreAnswer; export interface NormalizedQuestions { ids: string[]; questions: Record<string, JevQuestion>; /** Per-question backend/model overrides, stripped from the request payload sent to the service. */ routes: Record<string, QuestionRoute>; } /** A single question can be answered by a different backend or model than the rest of the call. */ export interface QuestionRoute { backend?: string; model?: string; } export interface ConformResult { answers: Record<string, JevAnswer>; /** Fatal per-question problems: that question has no usable answer. */ issues: Record<string, string>; /** Non-fatal per-question observations, e.g. probabilities that had to be renormalized. */ notes: Record<string, string>; } const TYPE_ALIASES: Record<string, JevQuestion["type"]> = { noul: "noul", yes_no: "noul", yesno: "noul", boolean: "noul", bool: "noul", choice: "choice", classify: "choice", classification: "choice", select: "choice", score: "score", rating: "score", rate: "score", scale: "score", grade: "score", }; /** Validate and canonicalize the tool's `questions` array into the request map TypeSafe expects. */ export function normalizeQuestions(raw: unknown): NormalizedQuestions { if (!Array.isArray(raw) || raw.length === 0) { throw new JevError("questions must be a non-empty array of question objects."); } const ids: string[] = []; const questions: Record<string, JevQuestion> = {}; const routes: Record<string, QuestionRoute> = {}; raw.forEach((entry, index) => { const where = `questions[${index}]`; if (entry === null || typeof entry !== "object" || Array.isArray(entry)) { throw new JevError(`${where} must be an object.`); } const input = entry as Record<string, unknown>; const id = readRequiredString(input.id, `${where}.id`); if (questions[id] !== undefined) throw new JevError(`${where}.id "${id}" is used more than once.`); const rawType = readRequiredString(input.type, `${where}.type`).toLowerCase().replace(/[\s-]+/g, "_"); const type = TYPE_ALIASES[rawType]; if (type === undefined) { throw new JevError(`${where}.type must be noul, choice, or score (got "${rawType}").`); } const instructions = readInstructions( input.instructions ?? input.question ?? input.prompt ?? input.instruction, `${where}.instructions`, ); const route: QuestionRoute = {}; const backendOverride = input.backend ?? input.provider; if (backendOverride !== undefined) route.backend = readRequiredString(backendOverride, `${where}.backend`); if (input.model !== undefined) route.model = readRequiredString(input.model, `${where}.model`); if (route.backend !== undefined || route.model !== undefined) routes[id] = route; questions[id] = type === "noul" ? { type, instructions, ...readNoulCriteria(input.criteria, `${where}.criteria`) } : type === "choice" ? { type, instructions, criteria: readChoiceOptions(input.criteria ?? input.options, `${where}.criteria`) } : { type, instructions, criteria: readScoreLevels(input.criteria ?? input.levels, `${where}.criteria`) }; ids.push(id); }); return { ids, questions, routes }; } /** * Conform raw answers (native JSON or parsed model output) into the canonical answer shapes. * Answers that cannot be conformed are reported as issues instead of being silently repaired. */ export function conformAnswers(questions: Record<string, JevQuestion>, raw: unknown): ConformResult { const answers: Record<string, JevAnswer> = {}; const issues: Record<string, string> = {}; const notes: Record<string, string> = {}; if (raw === null || typeof raw !== "object" || Array.isArray(raw)) { for (const id of Object.keys(questions)) issues[id] = "engine returned no answer object"; return { answers, issues, notes }; } const source = raw as Record<string, unknown>; for (const [id, question] of Object.entries(questions)) { const entry = source[id]; if (entry === undefined || entry === null) { issues[id] = "engine returned no answer for this question"; continue; } if (question.type === "noul") { const value = readNumberField(entry, ["noul", "probability", "p", "yes", "value"]); if (value === undefined || value < 0 || value > 1) { issues[id] = `noul answer must be a number in [0,1], got ${describeValue(entry)}`; continue; } answers[id] = { type: "noul", noul: value }; continue; } if (question.type === "choice") { const options = Object.keys(question.criteria); const rawChoice = readStringField(entry, ["choice", "selected", "option", "label", "answer", "value"]); const choice = rawChoice === undefined ? undefined : matchOption(options, rawChoice); if (choice === undefined) { issues[id] = rawChoice === undefined ? `choice answer is missing a "choice" field` : `choice "${rawChoice}" is not one of the criteria options: ${options.join(", ")}`; continue; } const distribution = readDistribution(options, readProbabilityField(entry)); if (distribution === undefined) { issues[id] = `choice probabilities must cover every option (${options.join(", ")}) with numbers >= 0`; continue; } if (distribution.sum <= 0) { issues[id] = "choice probabilities sum to 0"; continue; } if (Math.abs(distribution.sum - 1) > 1e-6) { notes[id] = `probabilities renormalized (sum was ${round(distribution.sum, 6)})`; } answers[id] = { type: "choice", choice, probabilities: scale(distribution.values, distribution.sum), ...readConfidence(entry, notes, id), }; continue; } const levels = question.criteria; const value = readNumberField(entry, ["score", "value", "rating", "level"]); if (value === undefined || value < 0 || value > levels.length - 1) { issues[id] = `score answer must be a number between 0 and ${levels.length - 1}, got ${describeValue(entry)}`; continue; } const keys = levels.map((_, index) => String(index)); const distribution = readDistribution(keys, readProbabilityField(entry)); if (distribution === undefined) { issues[id] = `score probabilities must cover every level index (0..${levels.length - 1}) with numbers >= 0`; continue; } if (distribution.sum <= 0) { issues[id] = "score probabilities sum to 0"; continue; } if (Math.abs(distribution.sum - 1) > 1e-6) { notes[id] = `probabilities renormalized (sum was ${round(distribution.sum, 6)})`; } const legend: Record<string, string> = {}; levels.forEach((level, index) => { legend[String(index)] = level; }); answers[id] = { type: "score", score: value, legend, probabilities: scale(distribution.values, distribution.sum), ...readConfidence(entry, notes, id), }; } return { answers, issues, notes }; } function readConfidence( entry: unknown, notes: Record<string, string>, id: string, ): { confidence?: number } { const value = readNumberField(entry, ["confidence"]); if (value === undefined) return {}; if (value < 0 || value > 1) { notes[id] = `${notes[id] ? `${notes[id]}; ` : ""}dropped confidence ${value} (outside [0,1])`; return {}; } return { confidence: value }; } function readRequiredString(raw: unknown, where: string): string { if (typeof raw !== "string" || raw.trim() === "") throw new JevError(`${where} must be a non-empty string.`); return raw.trim(); } function readInstructions(raw: unknown, where: string): JsonValue { if (typeof raw === "string") { const trimmed = raw.trim(); if (trimmed === "") throw new JevError(`${where} must not be empty.`); return trimmed; } if (Array.isArray(raw)) { if (raw.length === 0) throw new JevError(`${where} must not be an empty array.`); return raw as JsonValue; } if (raw !== null && typeof raw === "object" && Object.keys(raw as Record<string, unknown>).length > 0) { return raw as JsonValue; } throw new JevError(`${where} must be a non-empty string, object, or array.`); } function readNoulCriteria(raw: unknown, where: string): { criteria?: { true?: string; false?: string } } { if (raw === undefined || raw === null) return {}; if (typeof raw !== "object" || Array.isArray(raw)) { throw new JevError(`${where} must be an object with optional "true" and "false" descriptions.`); } const input = raw as Record<string, unknown>; const criteria: { true?: string; false?: string } = {}; const yes = input.true ?? input.yes; const no = input.false ?? input.no; if (typeof yes === "string" && yes.trim() !== "") criteria.true = yes.trim(); if (typeof no === "string" && no.trim() !== "") criteria.false = no.trim(); return Object.keys(criteria).length === 0 ? {} : { criteria }; } function readChoiceOptions(raw: unknown, where: string): Record<string, string | null> { const options: Record<string, string | null> = {}; if (Array.isArray(raw)) { for (const entry of raw) { if (typeof entry === "string" && entry.trim() !== "") { options[entry.trim()] ??= null; continue; } if (entry !== null && typeof entry === "object" && !Array.isArray(entry)) { const label = readStringField(entry, ["label", "option", "name", "value", "key"]); if (label === undefined) throw new JevError(`${where} entries need a label/option/name field.`); const description = readStringField(entry, ["description", "rubric", "detail"]); options[label] = description ?? null; continue; } throw new JevError(`${where} must contain option strings or objects.`); } } else if (raw !== null && typeof raw === "object") { for (const [key, value] of Object.entries(raw as Record<string, unknown>)) { if (key.trim() === "") throw new JevError(`${where} has an empty option key.`); if (value === null || value === undefined) { options[key] = null; continue; } if (typeof value !== "string") { throw new JevError(`${where}.${key} must be a description string or null.`); } options[key] = value.trim() === "" ? null : value.trim(); } } else { throw new JevError(`${where} must be an object of option -> description, or an array of options.`); } if (Object.keys(options).length < 2) { throw new JevError(`${where} needs at least two options; a single-option choice cannot discriminate.`); } return options; } function readScoreLevels(raw: unknown, where: string): string[] { let levels: string[]; if (Array.isArray(raw)) { levels = raw.map((entry) => readLevelText(entry, where)); } else if (raw !== null && typeof raw === "object") { const entries = Object.entries(raw as Record<string, unknown>); const numericKeys = entries.length > 0 && entries.every(([key]) => /^\d+$/.test(key.trim())); levels = entries.map(([key, value]) => readLevelText(numericKeys ? value : key, where)); } else { throw new JevError(`${where} must be an ordered array of level descriptions.`); } if (levels.length < 2) { throw new JevError(`${where} needs at least two levels.`); } return levels; } function readLevelText(entry: unknown, where: string): string { if (typeof entry === "string" && entry.trim() !== "") return entry.trim(); if (entry !== null && typeof entry === "object" && !Array.isArray(entry)) { const text = readStringField(entry, ["description", "label", "text", "name", "value"]); if (text !== undefined) return text; } throw new JevError(`${where} level descriptions must be non-empty strings.`); } function readNumberField(source: unknown, keys: string[]): number | undefined { if (typeof source === "number" && Number.isFinite(source)) return source; if (source === null || typeof source !== "object" || Array.isArray(source)) return undefined; const record = source as Record<string, unknown>; for (const key of keys) { const value = record[key]; if (typeof value === "number" && Number.isFinite(value)) return value; } return undefined; } function readStringField(source: unknown, keys: string[]): string | undefined { if (source === null || typeof source !== "object" || Array.isArray(source)) return undefined; const record = source as Record<string, unknown>; for (const key of keys) { const value = record[key]; if (typeof value === "string" && value.trim() !== "") return value.trim(); } return undefined; } function readProbabilityField(entry: unknown): unknown { if (entry === null || typeof entry !== "object" || Array.isArray(entry)) return undefined; const record = entry as Record<string, unknown>; return record.probabilities ?? record.probability ?? record.distribution ?? record.scores; } function matchOption(options: string[], value: string): string | undefined { const exact = options.find((option) => option === value); if (exact !== undefined) return exact; const needle = value.trim().toLowerCase(); return options.find((option) => option.trim().toLowerCase() === needle); } /** Read `{ key: number }` (or an array aligned with `keys`) and report the raw sum. */ function readDistribution(keys: string[], raw: unknown): { values: Record<string, number>; sum: number } | undefined { if (Array.isArray(raw)) { if (raw.length !== keys.length) return undefined; const values: Record<string, number> = {}; let sum = 0; keys.forEach((key, index) => { const value = raw[index]; if (typeof value !== "number" || !Number.isFinite(value) || value < 0) return; values[key] = value; sum += value; }); return Object.keys(values).length === keys.length ? { values, sum } : undefined; } if (raw === null || typeof raw !== "object") return undefined; const source = raw as Record<string, unknown>; const values: Record<string, number> = {}; let sum = 0; for (const key of keys) { const value = source[key]; if (typeof value !== "number" || !Number.isFinite(value) || value < 0) return undefined; values[key] = value; sum += value; } return { values, sum }; } function scale(values: Record<string, number>, sum: number): Record<string, number> { const scaled: Record<string, number> = {}; for (const [key, value] of Object.entries(values)) scaled[key] = round(value / sum, 6); return scaled; } function round(value: number, digits: number): number { const factor = 10 ** digits; return Math.round(value * factor) / factor; } function describeValue(value: unknown): string { if (typeof value === "string") return JSON.stringify(value); if (typeof value === "number" || typeof value === "boolean") return String(value); return value === null ? "null" : JSON.stringify(value).slice(0, 200); }