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)
153 lines (141 loc) • 6.2 kB
text/typescript
/**
* Text rendering for the `decide` tool result and the `/decide` command. The tool text is what the
* model reads back, so it stays compact and machine-parseable; the full structured result travels in
* the tool result `details`.
*/
import { BACKEND_IDS, maskKey, type BackendConfig, type DeciderConfig } from "./config.ts";
import type { JevAnswer, JevQuestion } from "./questions.ts";
import type { CompositeResult, GateRow } from "./shapes.ts";
import type { TokenCounts } from "./usage.ts";
export interface AnswerReport {
ids: string[];
questions: Record<string, JevQuestion>;
answers: Record<string, JevAnswer>;
/** Fatal per-question problems: no usable answer. */
issues: Record<string, string>;
/** Non-fatal per-question observations. */
notes: Record<string, string>;
}
/** One answered batch: every question routed to the same backend + model. */
export interface GroupMeta {
backendId: string;
label: string;
kind: "decisions" | "chat";
requestedModel: string;
servedModel?: string;
provider?: string;
endpoint: string;
latencyMs: number;
tokens: TokenCounts;
costUsd?: number;
ids: string[];
notes?: string[];
}
export function formatAnswers(report: AnswerReport, metas: GroupMeta[]): string {
const lines: string[] = [];
for (const meta of metas) {
const header: string[] = [
meta.backendId,
meta.label,
meta.servedModel === undefined || meta.servedModel === meta.requestedModel
? `model ${meta.requestedModel}`
: `model ${meta.requestedModel} → ${meta.servedModel}`,
...(meta.provider === undefined ? [] : [`provider ${meta.provider}`]),
`${meta.latencyMs} ms`,
`tokens ${meta.tokens.input ?? 0} in / ${meta.tokens.output ?? 0} out`,
];
if (meta.costUsd !== undefined) header.push(`cost $${meta.costUsd.toFixed(6)}`);
lines.push(header.join(" · "));
if (meta.kind === "chat") {
lines.push("note: proxy decision by a general chat model, not a calibrated System One model");
}
for (const note of meta.notes ?? []) lines.push(`note: ${note}`);
for (const id of meta.ids) lines.push(...renderAnswer(id, report));
}
for (const [id, note] of Object.entries(report.notes)) lines.push(`note ${id}: ${note}`);
return lines.join("\n");
}
export function formatConfigStatus(cfg: DeciderConfig, selected?: BackendConfig): string {
const rows: Array<[string, string]> = [
["config", cfg.configPath],
["backend", `${cfg.backend} (${cfg.backendSource})${selected === undefined ? "" : ` → ${selected.id}`}`],
];
for (const id of BACKEND_IDS) {
const backend = cfg.backends[id];
const marker = selected?.id === id ? "*" : " ";
rows.push([
`${marker} ${id}`,
`${backend.label} · ${backend.kind}`,
]);
rows.push([
" key",
backend.apiKey === undefined ? `${backend.apiKeySource} (nothing resolved)` : `${backend.apiKeySource} ${maskKey(backend.apiKey)}`,
]);
rows.push([" url", `${backend.baseUrl}${backend.path} (${backend.baseUrlSource})`]);
rows.push([
" model",
backend.model === undefined ? "not set" : `${backend.model} (${backend.modelSource})`,
]);
rows.push([
" limits",
`timeout ${backend.timeoutMs} ms · retries ${backend.maxRetries} · $${backend.costPerMTokInput}/Mtok in · $${backend.costPerMTokOutput}/Mtok out`,
]);
}
rows.push(["models path", cfg.modelsPath]);
const width = Math.max(...rows.map(([label]) => label.length));
const lines = rows.map(([label, value]) => `${label.padEnd(width)} ${value}`);
for (const note of cfg.notes) lines.push(`${"note".padEnd(width)} ${note}`);
return lines.join("\n");
}
export function formatModelList(title: string, names: string[], limit = 40): string {
if (names.length === 0) return `${title}: none reported`;
const shown = names.slice(0, limit);
const suffix = names.length > shown.length ? `\n… ${names.length - shown.length} more` : "";
return `${title} (${names.length}):\n${shown.join("\n")}${suffix}`;
}
/** Confidence-gated routing verdicts. */
export function formatGate(rows: GateRow[], threshold: number): string {
const lines = [`gate: ${rows.filter((row) => row.pass).length}/${rows.length} pass at ${threshold}`];
for (const row of rows) {
const value = row.value === undefined ? "n/a" : String(row.value);
lines.push(` ${row.pass ? "PASS" : "HOLD"} ${row.id} ${row.metric}=${value}`);
}
return lines.join("\n");
}
/** Composite scoring: normalized score × weight, then the weighted mean. */
export function formatComposite(result: CompositeResult): string {
if (result.rows.length === 0) return "composite: no score answers to combine";
const lines = result.rows.map(
(row) =>
` ${row.id} normalized ${round(row.normalized, 4)} × weight ${row.weight} = ${round(row.contribution, 4)}`,
);
lines.push(`composite score ${round(result.total, 4)} (total weight ${result.totalWeight})`);
return lines.join("\n");
}
function round(value: number, digits: number): number {
const factor = 10 ** digits;
return Math.round(value * factor) / factor;
}
function renderAnswer(id: string, report: AnswerReport): string[] {
const answer = report.answers[id];
if (answer === undefined) {
return [`${id} [${report.questions[id].type}] ERROR ${report.issues[id] ?? "no answer"}`];
}
const confidence = "confidence" in answer && answer.confidence !== undefined ? ` · confidence ${answer.confidence}` : "";
if (answer.type === "noul") return [`${id} [noul] ${answer.noul}${confidence}`];
if (answer.type === "choice") {
return [`${id} [choice] ${answer.choice}${confidence}`, ` probabilities: ${renderDistribution(answer.probabilities)}`];
}
return [
`${id} [score] ${answer.score}${confidence}`,
` levels: ${Object.entries(answer.legend)
.map(([index, level]) => `${index} ${level}`)
.join(" · ")}`,
` probabilities: ${renderDistribution(answer.probabilities)}`,
];
}
function renderDistribution(probabilities: Record<string, number>): string {
return Object.entries(probabilities)
.map(([key, value]) => `${key} ${value}`)
.join(" · ");
}