#!/usr/bin/env node
/**
* Grade model outputs against a written rubric using an OpenAI model as judge.
*
* Usage:
* node scripts/llm-judge.mjs OUTPUTS.jsonl RUBRIC.md [--min-score 4] [--out graded.jsonl] [--limit N]
*
* OUTPUTS.jsonl One JSON object per line:
* {"id": "case-1", "input": "what the user asked",
* "output": "what your system answered",
* "reference": "optional known-good answer or facts"}
* RUBRIC.md Plain-language grading criteria and what each score 1-5 means.
* --min-score Lowest score that counts as a pass (default 4).
* --out Write one graded JSON object per line to this file.
* --limit Only grade the first N cases (handy while tuning the rubric).
*
* Environment variables:
* OPENAI_API_KEY required, read by the openai SDK. Never hardcode it.
* OPENAI_JUDGE_MODEL required. A current OpenAI model ID that supports JSON mode
* (response_format json_object). No default, so changes are explicit.
*
* Requirements: Node.js 18+ and the official SDK: npm install openai
*
* Caveats: an LLM judge is a noisy grader. Spot-check its scores against your own
* on a sample before trusting a pass rate, keep the judge model fixed when comparing
* runs, and do not use the same model as both the system under test and the judge
* if you can avoid it.
*
* Exit codes:
* 0 every case scored at or above --min-score
* 1 one or more cases scored below --min-score
* 2 usage or configuration error
* 3 API error (auth, rate limit after retries, server error)
* 4 the judge returned a reply that was not valid score JSON for some case
*/
import { readFileSync, writeFileSync } from "node:fs";
function fail(message, code = 2) {
process.stderr.write(`error: ${message}\n`);
process.exit(code);
}
function parseArgs(argv) {
const opts = { positional: [], minScore: 4, out: null, limit: Infinity };
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i];
const next = () => {
i += 1;
if (argv[i] === undefined) fail(`${arg} needs a value`);
return argv[i];
};
if (arg === "--min-score") opts.minScore = Number(next());
else if (arg === "--out") opts.out = next();
else if (arg === "--limit") opts.limit = Number(next());
else if (arg.startsWith("--")) fail(`unknown option ${arg}`);
else opts.positional.push(arg);
}
if (opts.positional.length !== 2) fail("usage: llm-judge.mjs OUTPUTS.jsonl RUBRIC.md [--min-score N] [--out FILE] [--limit N]");
if (!(opts.minScore >= 1 && opts.minScore <= 5)) fail("--min-score must be between 1 and 5");
if (!(opts.limit > 0)) fail("--limit must be a positive number");
return opts;
}
function loadCases(path) {
let text;
try {
text = readFileSync(path, "utf8");
} catch (err) {
fail(`cannot read ${path}: ${err.code ?? err.message}`);
}
const cases = [];
text.split(/\r?\n/).forEach((line, index) => {
if (!line.trim()) return;
let item;
try {
item = JSON.parse(line);
} catch (err) {
fail(`${path} line ${index + 1}: invalid JSON (${err.message})`);
}
if (!item.id || typeof item.output !== "string") fail(`${path} line ${index + 1}: needs "id" and string "output"`);
cases.push(item);
});
if (!cases.length) fail(`${path} has no cases`);
return cases;
}
const SYSTEM = `You are a strict, consistent grader. You grade one response at a time against the rubric below.
Judge only what is in the response. Do not reward length or confident tone. If a reference is given, treat it as ground truth.
{{RUBRIC}}
Reply with only a JSON object of this shape:
{"score": , "reasons": ""}`;
function userMessage(item) {
const parts = [`\n${item.input ?? ""}\n`, `\n${item.output}\n`];
if (item.reference) parts.push(`\n${item.reference}\n`);
parts.push("Grade the response. Return the JSON object only.");
return parts.join("\n\n");
}
function parseVerdict(text) {
try {
const verdict = JSON.parse(text);
const score = Number(verdict.score);
if (Number.isInteger(score) && score >= 1 && score <= 5) return { score, reasons: String(verdict.reasons ?? "") };
} catch {
// fall through
}
return null;
}
const opts = parseArgs(process.argv.slice(2));
const model = (process.env.OPENAI_JUDGE_MODEL ?? "").trim();
if (!model) fail("set OPENAI_JUDGE_MODEL to the judge model ID");
if (!process.env.OPENAI_API_KEY) fail("OPENAI_API_KEY is not set");
let OpenAI;
try {
({ default: OpenAI } = await import("openai"));
} catch {
fail("the openai package is not installed (npm install openai)");
}
const cases = loadCases(opts.positional[0]).slice(0, opts.limit);
let rubric;
try {
rubric = readFileSync(opts.positional[1], "utf8");
} catch (err) {
fail(`cannot read ${opts.positional[1]}: ${err.code ?? err.message}`);
}
const system = SYSTEM.replace("{{RUBRIC}}", rubric.trim());
const client = new OpenAI({ maxRetries: 3 });
const graded = [];
let below = 0;
let unparsable = 0;
for (const item of cases) {
let reply;
try {
const completion = await client.chat.completions.create({
model,
response_format: { type: "json_object" },
messages: [
{ role: "system", content: system },
{ role: "user", content: userMessage(item) },
],
});
reply = completion.choices[0]?.message?.content ?? "";
} catch (err) {
fail(`API call failed on case ${item.id}: ${err.message}`, 3);
}
const verdict = parseVerdict(reply);
let record;
if (!verdict) {
unparsable += 1;
record = { id: item.id, status: "judge_error", raw: reply };
process.stdout.write(`ERR ${item.id}: judge reply was not valid score JSON\n`);
} else {
const pass = verdict.score >= opts.minScore;
if (!pass) below += 1;
record = { id: item.id, status: pass ? "pass" : "fail", score: verdict.score, reasons: verdict.reasons };
process.stdout.write(`${pass ? "PASS" : "FAIL"} ${item.id}: ${verdict.score}/5 ${verdict.reasons}\n`);
}
graded.push({ ...record, judge_model: model });
}
if (opts.out) writeFileSync(opts.out, graded.map((g) => JSON.stringify(g)).join("\n") + "\n");
const scored = graded.filter((g) => typeof g.score === "number");
const mean = scored.length ? scored.reduce((sum, g) => sum + g.score, 0) / scored.length : 0;
process.stdout.write(`\n${scored.length - below}/${graded.length} passed (min score ${opts.minScore}), mean ${mean.toFixed(2)}, judge ${model}\n`);
if (unparsable) process.exit(4);
process.exit(below ? 1 : 0);