import { appendFileSync, existsSync, mkdirSync } from "node:fs"; import { join } from "node:path"; import { complete } from "@earendil-works/pi-ai/compat"; import { StringEnum, type Model, type ToolCall } from "@earendil-works/pi-ai"; import { Type } from "typebox"; import type { ModelAuth } from "./model.ts"; import { CONFIG_DIR } from "./settings.ts"; import type { TaxonomyCategory } from "./taxonomy.ts"; const ERRORS_LOG_PATH = join(CONFIG_DIR, "errors.log"); const CLASSIFY_TOOL_NAME = "report_errors"; const DEFAULT_TIMEOUT_MS = 10_000; export interface ClassifiedError { category: string; original: string; corrected: string; note: string; severity: string; } export type CorrectResult = { ok: true; errors: ClassifiedError[] } | { ok: false; failureClass: string; error: string }; const CLASSIFY_SYSTEM_PROMPT = [ "You are a strict but fast English grammar and usage checker.", "Call report_errors with every grammar, usage, or phrasing error you find in the user's message.", "If the message has no errors, call report_errors with an empty errors array.", "Do not comment on style, tone, or content. Only flag actual English errors.", ].join("\n"); function buildTool(taxonomy: TaxonomyCategory[]) { const keys = taxonomy.map((t) => t.key) as [string, ...string[]]; return { name: CLASSIFY_TOOL_NAME, description: "Report grammar, usage, or phrasing errors found in the message.", parameters: Type.Object({ errors: Type.Array( Type.Object({ category: StringEnum(keys, { description: "The error category" }), original: Type.String({ description: "The exact erroneous text from the message" }), corrected: Type.String({ description: "The corrected form of that text" }), note: Type.String({ description: "A one-sentence human-readable explanation" }), severity: StringEnum(["minor", "moderate", "major"] as const), }), ), }), }; } function isClassifiedError(value: unknown, validKeys: Set): value is ClassifiedError { if (!value || typeof value !== "object") return false; const r = value as Record; return ( typeof r.category === "string" && validKeys.has(r.category) && typeof r.original === "string" && typeof r.corrected === "string" && typeof r.note === "string" && typeof r.severity === "string" ); } function withTimeout(timeoutMs: number, parentSignal?: AbortSignal) { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(new Error("Correction call timed out")), timeoutMs); const onParentAbort = () => controller.abort(parentSignal?.reason); parentSignal?.addEventListener("abort", onParentAbort, { once: true }); return { signal: controller.signal, cancel: () => { clearTimeout(timer); parentSignal?.removeEventListener("abort", onParentAbort); }, }; } /** Appends a failure class to the extension's own diagnostics log. Never throws. */ export function logCorrectionFailure(failureClass: string, message: string): void { try { if (!existsSync(CONFIG_DIR)) mkdirSync(CONFIG_DIR, { recursive: true }); const line = JSON.stringify({ ts: new Date().toISOString(), failureClass, message }); appendFileSync(ERRORS_LOG_PATH, `${line}\n`, "utf8"); } catch { // The tutor's own diagnostics must never be able to throw into the caller. } } /** * Classifies a message against the frozen taxonomy. The category is constrained at the * schema level via a forced tool call rather than requested in the prompt: a response * naming a category outside the enum is impossible to represent, and a response with an * unparseable tool call is treated as a failed classification rather than silently coerced. */ export async function classifyMessage( model: Model, auth: ModelAuth, taxonomy: TaxonomyCategory[], text: string, options: { timeoutMs?: number; signal?: AbortSignal } = {}, ): Promise { const { signal, cancel } = withTimeout(options.timeoutMs ?? DEFAULT_TIMEOUT_MS, options.signal); try { const response = await complete( model, { systemPrompt: CLASSIFY_SYSTEM_PROMPT, messages: [{ role: "user", content: [{ type: "text", text }], timestamp: Date.now() }], tools: [buildTool(taxonomy)], }, { apiKey: auth.apiKey, headers: auth.headers, env: auth.env, signal }, ); if (response.stopReason === "aborted") { return { ok: false, failureClass: "timeout", error: "Correction call timed out" }; } if (response.stopReason === "error") { return { ok: false, failureClass: "provider_error", error: response.errorMessage ?? "Unknown provider error" }; } const toolCall = response.content.find((c): c is ToolCall => c.type === "toolCall" && c.name === CLASSIFY_TOOL_NAME); if (!toolCall) { return { ok: true, errors: [] }; } const rawErrors = (toolCall.arguments as Record | undefined)?.errors; if (!Array.isArray(rawErrors)) { return { ok: false, failureClass: "schema_invalid", error: "Tool call is missing an errors array" }; } const validKeys = new Set(taxonomy.map((t) => t.key)); const errors: ClassifiedError[] = []; for (const raw of rawErrors) { if (!isClassifiedError(raw, validKeys)) { return { ok: false, failureClass: "schema_invalid", error: `Unrecognized category in response: ${JSON.stringify(raw)}` }; } errors.push(raw); } return { ok: true, errors }; } catch (err) { const failureClass = signal.aborted ? "timeout" : "network_error"; return { ok: false, failureClass, error: err instanceof Error ? err.message : String(err) }; } finally { cancel(); } }