@novedu/cli 0.20.0 → 0.22.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +39 -4
- package/dist/main.js +429 -38
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -104,6 +104,11 @@ each one must get — and `eval` replays them through the **real grader**, then
|
|
|
104
104
|
what it actually did. This is the one command that both **runs the model** and needs
|
|
105
105
|
you signed in (`novedu-cli login`); everything else about it is local.
|
|
106
106
|
|
|
107
|
+
It checks **both halves** of a grading: your `expect` gates the **verdict**, and an LLM
|
|
108
|
+
**feedback judge** audits the **feedback text** the student would have read — measured
|
|
109
|
+
against the quiz's own grading prompt, so there is nothing extra to author. Flagged
|
|
110
|
+
feedback is **reported, never a failure**.
|
|
111
|
+
|
|
107
112
|
```yaml
|
|
108
113
|
# sorting-quiz.eval.yaml
|
|
109
114
|
# yaml-language-server: $schema=https://raw.githubusercontent.com/Teaching-HTL-Leonding/novedu-chat-mvp/refs/heads/main/activities/evals/eval-yaml.schema.json
|
|
@@ -134,6 +139,13 @@ npx @novedu/cli eval ./sorting-quiz.eval.yaml --repeats 3
|
|
|
134
139
|
npx @novedu/cli eval ./sorting-quiz.eval.yaml \
|
|
135
140
|
--llm-provider "Azure Foundry" --llm-model gpt-5-mini
|
|
136
141
|
|
|
142
|
+
# A strong judge over the quiz's own grader — the recommended pairing
|
|
143
|
+
npx @novedu/cli eval ./sorting-quiz.eval.yaml \
|
|
144
|
+
--judge-llm-provider "Azure Foundry" --judge-llm-model gpt-5.6-terra
|
|
145
|
+
|
|
146
|
+
# Verdicts only: half the LLM calls, for a cheap smoke run
|
|
147
|
+
npx @novedu/cli eval ./sorting-quiz.eval.yaml --no-judge-feedback
|
|
148
|
+
|
|
137
149
|
# Machine-readable, for CI
|
|
138
150
|
npx @novedu/cli eval ./sorting-quiz.eval.yaml --json --out eval-report.json
|
|
139
151
|
|
|
@@ -163,14 +175,37 @@ npx @novedu/cli eval ./sorting-quiz.eval.yaml --report eval-report.md
|
|
|
163
175
|
a broken file instead of aborting the batch. `--json` / `--out` always carry the
|
|
164
176
|
same batch shape `{ files: [...], passed, totals }`, single file or not — `passed`
|
|
165
177
|
is the exit-code verdict, per batch and per file.
|
|
178
|
+
- **The feedback judge.** After each successful grading, an LLM reads the feedback the
|
|
179
|
+
grader wrote and checks it against **that grading's own system prompt** — the course
|
|
180
|
+
rules and the platform frame already in it. It reports four kinds of problem:
|
|
181
|
+
`contradicts_verdict` (praise on a wrong answer, or vice versa), `misstates_facts`,
|
|
182
|
+
`ignores_instructions` (most commonly: not stating the correct answer when the verdict
|
|
183
|
+
is not `correct`, or the wrong language), and `leaks_rubric` (quoting the grading
|
|
184
|
+
criteria at the student). Flags show as **`flagged feedback`** in the terminal report,
|
|
185
|
+
a **Flagged** column plus a **"Flagged feedback"** section in the Markdown report, and
|
|
186
|
+
`totals.feedbackFlagged` / `repeats[].judge.issues` in the JSON. They never change the
|
|
187
|
+
exit code.
|
|
188
|
+
- **Choosing the judge.** By default the judge runs on the same model as the grader.
|
|
189
|
+
`--judge-llm-provider` + `--judge-llm-model` (both or neither) point it at another one,
|
|
190
|
+
which is the **recommended** setup: a strong judge over a smaller grader finds real
|
|
191
|
+
problems, while a small model judging itself mostly produces noise. `--no-judge-feedback`
|
|
192
|
+
turns judging off and halves the LLM calls; combining the two is rejected as
|
|
193
|
+
contradictory. Because judging roughly doubles the cost, the run's scope line says so
|
|
194
|
+
up front: `27 case(s) × 3 repeat(s) = 81 grading + 81 judge call(s)`.
|
|
195
|
+
- **If the judge itself fails**, the run **degrades instead of aborting**: after three
|
|
196
|
+
consecutive judge failures it stops judging (one warning on stderr) and finishes the
|
|
197
|
+
grading normally. Your verdict results are complete; the feedback simply was not
|
|
198
|
+
audited: files that judged nothing show an em dash in the Flagged column rather than a
|
|
199
|
+
`0`, so "unchecked" never reads as "clean".
|
|
166
200
|
- **`--report <file.md>`** additionally writes a readable **Markdown** report — an
|
|
167
201
|
overview table over the files, then the question, the golden answer and the grader's
|
|
168
|
-
feedback for every mismatched, errored or unstable case
|
|
202
|
+
feedback for every mismatched, errored or unstable case, plus the "Flagged feedback"
|
|
203
|
+
section (passing, unflagged cases stay in the
|
|
169
204
|
JSON). It composes with `--json` / `--out` and leaves stdout untouched.
|
|
170
205
|
- **Token totals.** The reports show what a run cost —
|
|
171
|
-
`tokens: 15,420 in (12,300 cached) / 2,810 out` — summed over the grading
|
|
172
|
-
**succeeded**, so it is a lower bound (a retried or failed call reports
|
|
173
|
-
nothing at all is printed when the server reports no usage.
|
|
206
|
+
`tokens: 15,420 in (12,300 cached) / 2,810 out` — summed over the grading **and** judge
|
|
207
|
+
calls that **succeeded**, so it is a lower bound (a retried or failed call reports
|
|
208
|
+
nothing), and nothing at all is printed when the server reports no usage.
|
|
174
209
|
- **Failure handling**: a 5xx or network hiccup is retried (4 attempts, linear
|
|
175
210
|
backoff); any 4xx is terminal; an auth failure aborts the run with one message; and
|
|
176
211
|
three consecutive errored cases trip a circuit breaker so a down server fails fast.
|
package/dist/main.js
CHANGED
|
@@ -2591,6 +2591,10 @@ async function loadQuizFrom(url, fetcher, opts = {}) {
|
|
|
2591
2591
|
};
|
|
2592
2592
|
}
|
|
2593
2593
|
}
|
|
2594
|
+
const tutorToolNameSchema = z.enum(["random_number"]).meta({
|
|
2595
|
+
id: "tutorToolName",
|
|
2596
|
+
description: "Name of a built-in tutor tool."
|
|
2597
|
+
});
|
|
2594
2598
|
//#endregion
|
|
2595
2599
|
//#region ../lib/tutors/schemas.ts
|
|
2596
2600
|
/**
|
|
@@ -2626,6 +2630,7 @@ const TutorSchema = z.strictObject({
|
|
|
2626
2630
|
id: "llm",
|
|
2627
2631
|
description: "The model and provider that back this tutor."
|
|
2628
2632
|
}),
|
|
2633
|
+
tools: z.array(tutorToolNameSchema).default([]).meta({ description: "Optional built-in tools the tutor's model may call (e.g. random_number). Off by default; mention enabled tools in tutor_instructions so the model uses them." }),
|
|
2629
2634
|
prompt: z.strictObject({
|
|
2630
2635
|
fragment_files: z.array(FragmentFileRefSchema).default([]).meta({ description: "Optional fragment libraries used by this tutor." }),
|
|
2631
2636
|
text_files: z.array(TextFileRefSchema).default([]).meta({ description: "Optional plain-text files (markdown / source) embedded verbatim via {{file \"alias\"}} markers." }),
|
|
@@ -2666,6 +2671,7 @@ async function loadAndBuildTutorPrompt(url, fetchImpl, opts = {}) {
|
|
|
2666
2671
|
model: tutor.llm.model,
|
|
2667
2672
|
provider: tutor.llm.provider,
|
|
2668
2673
|
imageInput: tutor.llm.imageInput ?? true,
|
|
2674
|
+
tools: tutor.tools,
|
|
2669
2675
|
anonymous: tutor.anonymous ?? true,
|
|
2670
2676
|
title: tutor.title,
|
|
2671
2677
|
description: tutor.description,
|
|
@@ -2837,7 +2843,8 @@ const promptDumpers = {
|
|
|
2837
2843
|
provider: result.provider,
|
|
2838
2844
|
model: result.model
|
|
2839
2845
|
},
|
|
2840
|
-
system: result.prompt
|
|
2846
|
+
system: result.prompt,
|
|
2847
|
+
tools: result.tools
|
|
2841
2848
|
}
|
|
2842
2849
|
};
|
|
2843
2850
|
} },
|
|
@@ -3304,6 +3311,89 @@ async function loadAndCheckEval(url, fetchImpl, opts = {}) {
|
|
|
3304
3311
|
};
|
|
3305
3312
|
}
|
|
3306
3313
|
//#endregion
|
|
3314
|
+
//#region ../lib/quiz-feedback-judge.ts
|
|
3315
|
+
/**
|
|
3316
|
+
* The QUIZ-feedback taxonomy. The judge may only name one of these, and the endpoint
|
|
3317
|
+
* constrains the model to whatever the CALLER sent (see {@link judgmentSchema}) — which
|
|
3318
|
+
* is what keeps the route kind-agnostic for the eval kinds still to come.
|
|
3319
|
+
*
|
|
3320
|
+
* Deliberately NOT documented in code comments: every definition lives in
|
|
3321
|
+
* {@link FEEDBACK_JUDGE_SYSTEM}, where the model actually reads it, so the two can never
|
|
3322
|
+
* drift apart.
|
|
3323
|
+
*/
|
|
3324
|
+
const FEEDBACK_JUDGE_CRITERIA = [
|
|
3325
|
+
"contradicts_verdict",
|
|
3326
|
+
"misstates_facts",
|
|
3327
|
+
"ignores_instructions",
|
|
3328
|
+
"leaks_rubric"
|
|
3329
|
+
];
|
|
3330
|
+
/**
|
|
3331
|
+
* The judge's system prompt. Three properties are load-bearing and were validated
|
|
3332
|
+
* against ~100 real golden answers plus planted violations before shipping:
|
|
3333
|
+
*
|
|
3334
|
+
* * "Do NOT judge the verdict itself" — a different check (the eval's `expect`) owns
|
|
3335
|
+
* that; a judge that re-grades produces noise the report cannot act on.
|
|
3336
|
+
* * "be strict about real violations, but do not invent issues … when in doubt, the
|
|
3337
|
+
* feedback is ok" — without it, weak models flag matters of taste.
|
|
3338
|
+
* * an EMPTY `issues` array is the way to say "acceptable". There is deliberately no
|
|
3339
|
+
* `ok` boolean: weak judges set `ok: false` and then name no issue at all, which is
|
|
3340
|
+
* unreportable. Flagged ⇔ an issue was named.
|
|
3341
|
+
*/
|
|
3342
|
+
const FEEDBACK_JUDGE_SYSTEM = `You are auditing the FEEDBACK a quiz-grading assistant gave to a student.
|
|
3343
|
+
|
|
3344
|
+
You receive:
|
|
3345
|
+
- the complete system prompt the grader was given (it contains shared course
|
|
3346
|
+
rules, the question, and the grading criteria),
|
|
3347
|
+
- the student's answer,
|
|
3348
|
+
- the verdict the grader chose (correct / partial / incorrect),
|
|
3349
|
+
- the feedback text the grader wrote for the student.
|
|
3350
|
+
|
|
3351
|
+
Judge ONLY the feedback text, on these criteria:
|
|
3352
|
+
|
|
3353
|
+
- "contradicts_verdict": the feedback's message disagrees with the verdict —
|
|
3354
|
+
e.g. it celebrates the answer as right although the verdict is incorrect, or
|
|
3355
|
+
corrects an answer whose verdict is correct.
|
|
3356
|
+
- "misstates_facts": the feedback asserts something that the grading criteria
|
|
3357
|
+
in the system prompt contradict — factual errors about the subject matter.
|
|
3358
|
+
- "ignores_instructions": the feedback violates an explicit rule the system
|
|
3359
|
+
prompt states about feedback, e.g. it fails to state the correct answer even
|
|
3360
|
+
though the verdict is not correct and the prompt demands that, is written in
|
|
3361
|
+
a language the prompt does not allow, or is not addressed to the student.
|
|
3362
|
+
- "leaks_rubric": the feedback quotes the grading criteria verbatim, refers to
|
|
3363
|
+
the grading instructions ("my instructions say...", "the rubric requires..."),
|
|
3364
|
+
or reveals verdict boundaries the student is not supposed to see.
|
|
3365
|
+
|
|
3366
|
+
Do NOT judge the verdict itself — a different auditor covers that. Judge the
|
|
3367
|
+
feedback GIVEN the verdict. Be strict about real violations, but do not invent
|
|
3368
|
+
issues: stylistic taste, brevity, or a matter of tone that the system prompt
|
|
3369
|
+
does not regulate are NOT issues. When in doubt, the feedback is ok.
|
|
3370
|
+
|
|
3371
|
+
Return one entry in "issues" per violation you found, and an EMPTY "issues"
|
|
3372
|
+
array when the feedback is acceptable. Answer with the JSON object only.`;
|
|
3373
|
+
/**
|
|
3374
|
+
* The judge's USER message: the four inputs in labeled `===` blocks, the grader's system
|
|
3375
|
+
* prompt first and the feedback under judgment last (so the model reads the standard
|
|
3376
|
+
* before the thing it measures).
|
|
3377
|
+
*
|
|
3378
|
+
* Nothing is escaped — every part is DATA for the judge, not markup, and a course prompt
|
|
3379
|
+
* containing `===` or Markdown must reach the judge exactly as the grader saw it.
|
|
3380
|
+
*/
|
|
3381
|
+
function buildFeedbackJudgeSubject(gradingSystem, answer, verdict, feedback) {
|
|
3382
|
+
return [
|
|
3383
|
+
"=== The system prompt the grader was given ===",
|
|
3384
|
+
gradingSystem,
|
|
3385
|
+
"",
|
|
3386
|
+
"=== The student's answer ===",
|
|
3387
|
+
answer,
|
|
3388
|
+
"",
|
|
3389
|
+
"=== The grader's verdict ===",
|
|
3390
|
+
verdict,
|
|
3391
|
+
"",
|
|
3392
|
+
"=== The grader's feedback (JUDGE THIS) ===",
|
|
3393
|
+
feedback
|
|
3394
|
+
].join("\n");
|
|
3395
|
+
}
|
|
3396
|
+
//#endregion
|
|
3307
3397
|
//#region src/retry.ts
|
|
3308
3398
|
const defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
3309
3399
|
/**
|
|
@@ -3357,6 +3447,14 @@ function addUsage(total, usage) {
|
|
|
3357
3447
|
total.cachedInput += usage.cachedInput;
|
|
3358
3448
|
total.output += usage.output;
|
|
3359
3449
|
}
|
|
3450
|
+
function createJudgeBreaker() {
|
|
3451
|
+
return {
|
|
3452
|
+
consecutiveErrors: 0,
|
|
3453
|
+
stopped: false
|
|
3454
|
+
};
|
|
3455
|
+
}
|
|
3456
|
+
/** Consecutive fully-errored judge calls that mean "stop judging for the rest of the run". */
|
|
3457
|
+
const JUDGE_BREAKER_LIMIT = 3;
|
|
3360
3458
|
/** Consecutive fully-errored cases that mean "the server is down, stop now". */
|
|
3361
3459
|
const CIRCUIT_BREAKER_LIMIT = 3;
|
|
3362
3460
|
/** Flatten questions × answers into cases, each carrying its grading prompt. */
|
|
@@ -3391,6 +3489,12 @@ function majority(graded, expected) {
|
|
|
3391
3489
|
passed: tied.every((verdict) => expected.includes(verdict))
|
|
3392
3490
|
};
|
|
3393
3491
|
}
|
|
3492
|
+
/** The one-line message a failed judge call leaves on its repeat row. */
|
|
3493
|
+
function judgeErrorMessage(error) {
|
|
3494
|
+
if (typeof error === "string") return error;
|
|
3495
|
+
if (typeof error === "object" && error !== null && "message" in error) return String(error.message);
|
|
3496
|
+
return JSON.stringify(error ?? null);
|
|
3497
|
+
}
|
|
3394
3498
|
/** The seam: one runner per eval kind (mirrors `promptDumpers`). */
|
|
3395
3499
|
const evalRunners = { quiz: { async run(checked, options) {
|
|
3396
3500
|
const repeats = Math.max(1, Math.floor(options.repeats ?? 1));
|
|
@@ -3398,9 +3502,53 @@ const evalRunners = { quiz: { async run(checked, options) {
|
|
|
3398
3502
|
const planned = planCases(checked);
|
|
3399
3503
|
const total = planned.length * repeats;
|
|
3400
3504
|
const questionTexts = new Map(checked.quizQuestions.map((q) => [q.id, q.text]));
|
|
3505
|
+
const breaker = options.judgeBreaker ?? createJudgeBreaker();
|
|
3401
3506
|
let done = 0;
|
|
3402
3507
|
let consecutiveErrored = 0;
|
|
3403
3508
|
let aborted;
|
|
3509
|
+
/**
|
|
3510
|
+
* The `judge` field of a repeat that produced NO judgment — spread onto every row
|
|
3511
|
+
* that is not a successful grading. Judging on ⇒ an explicit `null` (so a script
|
|
3512
|
+
* reading `judge === null` catches every unjudged repeat, not only the degraded
|
|
3513
|
+
* ones); judging off ⇒ nothing at all, since the whole run carries no judge fields.
|
|
3514
|
+
*/
|
|
3515
|
+
const unjudged = options.judge ? { judge: null } : {};
|
|
3516
|
+
/**
|
|
3517
|
+
* Judge ONE graded repeat's feedback, as a dependent step of that repeat. Judged
|
|
3518
|
+
* against the repeat's OWN verdict, never the case majority. Returns the fields to
|
|
3519
|
+
* merge onto the row: a judgment, or `judge: null` plus a `judgeError` when the call
|
|
3520
|
+
* failed, or a bare `judge: null` when the breaker had already degraded the run.
|
|
3521
|
+
*/
|
|
3522
|
+
const judgeRepeat = async (system, answer, verdict, feedback) => {
|
|
3523
|
+
const judge = options.judge;
|
|
3524
|
+
if (!judge || breaker.stopped) return { judge: null };
|
|
3525
|
+
const outcome = await withRetry(() => judge({
|
|
3526
|
+
system: FEEDBACK_JUDGE_SYSTEM,
|
|
3527
|
+
subject: buildFeedbackJudgeSubject(system, answer, verdict, feedback),
|
|
3528
|
+
criteria: FEEDBACK_JUDGE_CRITERIA
|
|
3529
|
+
}), {
|
|
3530
|
+
attempts: options.retry?.attempts,
|
|
3531
|
+
baseDelayMs: options.retry?.baseDelayMs,
|
|
3532
|
+
sleep: options.retry?.sleep,
|
|
3533
|
+
shouldRetry: (value) => !value.ok && value.retryable && !breaker.stopped
|
|
3534
|
+
});
|
|
3535
|
+
if (outcome.ok) {
|
|
3536
|
+
breaker.consecutiveErrors = 0;
|
|
3537
|
+
return { judge: {
|
|
3538
|
+
issues: outcome.issues,
|
|
3539
|
+
...outcome.usage ? { usage: outcome.usage } : {}
|
|
3540
|
+
} };
|
|
3541
|
+
}
|
|
3542
|
+
breaker.consecutiveErrors += 1;
|
|
3543
|
+
if (!breaker.stopped && breaker.consecutiveErrors >= JUDGE_BREAKER_LIMIT) {
|
|
3544
|
+
breaker.stopped = true;
|
|
3545
|
+
options.onJudgeDegraded?.();
|
|
3546
|
+
}
|
|
3547
|
+
return {
|
|
3548
|
+
judge: null,
|
|
3549
|
+
judgeError: judgeErrorMessage(outcome.error)
|
|
3550
|
+
};
|
|
3551
|
+
};
|
|
3404
3552
|
const progress = () => {
|
|
3405
3553
|
done += 1;
|
|
3406
3554
|
options.onProgress?.({
|
|
@@ -3415,7 +3563,8 @@ const evalRunners = { quiz: { async run(checked, options) {
|
|
|
3415
3563
|
if (plan.system === void 0) {
|
|
3416
3564
|
rows.push({
|
|
3417
3565
|
repeatIndex,
|
|
3418
|
-
error: { message: `The quiz has no question "${plan.questionId}".` }
|
|
3566
|
+
error: { message: `The quiz has no question "${plan.questionId}".` },
|
|
3567
|
+
...unjudged
|
|
3419
3568
|
});
|
|
3420
3569
|
progress();
|
|
3421
3570
|
continue;
|
|
@@ -3429,19 +3578,23 @@ const evalRunners = { quiz: { async run(checked, options) {
|
|
|
3429
3578
|
sleep: options.retry?.sleep,
|
|
3430
3579
|
shouldRetry: (value) => !value.ok && value.retryable && value.auth !== true
|
|
3431
3580
|
});
|
|
3432
|
-
progress();
|
|
3433
3581
|
if (outcome.ok) {
|
|
3582
|
+
const judged = options.judge ? await judgeRepeat(plan.system, plan.answer, outcome.verdict, outcome.feedback) : {};
|
|
3583
|
+
progress();
|
|
3434
3584
|
rows.push({
|
|
3435
3585
|
repeatIndex,
|
|
3436
3586
|
got: outcome.verdict,
|
|
3437
3587
|
feedback: outcome.feedback,
|
|
3438
|
-
...outcome.usage ? { usage: outcome.usage } : {}
|
|
3588
|
+
...outcome.usage ? { usage: outcome.usage } : {},
|
|
3589
|
+
...judged
|
|
3439
3590
|
});
|
|
3440
3591
|
continue;
|
|
3441
3592
|
}
|
|
3593
|
+
progress();
|
|
3442
3594
|
rows.push({
|
|
3443
3595
|
repeatIndex,
|
|
3444
|
-
error: outcome.error
|
|
3596
|
+
error: outcome.error,
|
|
3597
|
+
...unjudged
|
|
3445
3598
|
});
|
|
3446
3599
|
if (outcome.auth) {
|
|
3447
3600
|
aborted ??= {
|
|
@@ -3469,11 +3622,15 @@ const evalRunners = { quiz: { async run(checked, options) {
|
|
|
3469
3622
|
status,
|
|
3470
3623
|
...winner ? { verdict: winner.verdict } : {},
|
|
3471
3624
|
unstable: new Set(graded).size > 1,
|
|
3625
|
+
feedbackFlagged: rows.some((row) => (row.judge?.issues.length ?? 0) > 0),
|
|
3472
3626
|
repeats: rows
|
|
3473
3627
|
};
|
|
3474
3628
|
});
|
|
3475
3629
|
const usage = { ...ZERO_USAGE };
|
|
3476
|
-
for (const result of results) for (const row of result.repeats)
|
|
3630
|
+
for (const result of results) for (const row of result.repeats) {
|
|
3631
|
+
addUsage(usage, row.usage);
|
|
3632
|
+
addUsage(usage, row.judge?.usage);
|
|
3633
|
+
}
|
|
3477
3634
|
const totals = {
|
|
3478
3635
|
cases: results.length,
|
|
3479
3636
|
passed: results.filter((c) => c.status === "passed").length,
|
|
@@ -3481,6 +3638,8 @@ const evalRunners = { quiz: { async run(checked, options) {
|
|
|
3481
3638
|
errored: results.filter((c) => c.status === "errored").length,
|
|
3482
3639
|
skipped: results.filter((c) => c.status === "skipped").length,
|
|
3483
3640
|
unstable: results.filter((c) => c.unstable).length,
|
|
3641
|
+
feedbackFlagged: results.filter((c) => c.feedbackFlagged).length,
|
|
3642
|
+
judgeErrored: results.reduce((sum, c) => sum + c.repeats.filter((row) => row.judgeError !== void 0).length, 0),
|
|
3484
3643
|
repeats,
|
|
3485
3644
|
calls: total,
|
|
3486
3645
|
usage
|
|
@@ -3505,6 +3664,7 @@ const evalRunners = { quiz: { async run(checked, options) {
|
|
|
3505
3664
|
id: checked.evalFile.id,
|
|
3506
3665
|
target: checked.targetUrl,
|
|
3507
3666
|
llm: options.llm,
|
|
3667
|
+
judging: !options.judge ? "off" : breaker.stopped ? "degraded" : "on",
|
|
3508
3668
|
totals,
|
|
3509
3669
|
questions: [...new Set(checked.evalFile.questions.map((question) => question.question))].map((id) => ({
|
|
3510
3670
|
id,
|
|
@@ -3545,6 +3705,8 @@ function summarizeBatch(files) {
|
|
|
3545
3705
|
errored: 0,
|
|
3546
3706
|
skipped: 0,
|
|
3547
3707
|
unstable: 0,
|
|
3708
|
+
feedbackFlagged: 0,
|
|
3709
|
+
judgeErrored: 0,
|
|
3548
3710
|
usage: { ...ZERO_USAGE }
|
|
3549
3711
|
};
|
|
3550
3712
|
for (const file of files) {
|
|
@@ -3555,6 +3717,8 @@ function summarizeBatch(files) {
|
|
|
3555
3717
|
totals.errored += file.result.totals.errored;
|
|
3556
3718
|
totals.skipped += file.result.totals.skipped;
|
|
3557
3719
|
totals.unstable += file.result.totals.unstable;
|
|
3720
|
+
totals.feedbackFlagged += file.result.totals.feedbackFlagged;
|
|
3721
|
+
totals.judgeErrored += file.result.totals.judgeErrored;
|
|
3558
3722
|
addUsage(totals.usage, file.result.totals.usage);
|
|
3559
3723
|
}
|
|
3560
3724
|
return {
|
|
@@ -3567,9 +3731,22 @@ function summarizeBatch(files) {
|
|
|
3567
3731
|
};
|
|
3568
3732
|
}
|
|
3569
3733
|
/**
|
|
3734
|
+
* Did this file's run produce ANY judgment? The ONE rule every renderer derives its
|
|
3735
|
+
* flagged count's visibility from: a file that judged nothing — judging off, or every
|
|
3736
|
+
* case run after the breaker degraded the run — has NOT been found clean, so its flagged
|
|
3737
|
+
* count renders as "not checked" (an em dash, an omitted segment), never as a `0`.
|
|
3738
|
+
*/
|
|
3739
|
+
function anyJudged(result) {
|
|
3740
|
+
return result.cases.some((evalCase) => evalCase.repeats.some((repeat) => repeat.judge !== void 0 && repeat.judge !== null));
|
|
3741
|
+
}
|
|
3742
|
+
/**
|
|
3570
3743
|
* The CI gate: every file valid, and not a single failed, errored, or skipped CASE —
|
|
3571
3744
|
* an aborted (and therefore incomplete) run must never read as a pass. The single
|
|
3572
3745
|
* source of truth for the exit code AND for `EvalBatchResult.passed`.
|
|
3746
|
+
*
|
|
3747
|
+
* `unstable`, `feedbackFlagged` and `judgeErrored` deliberately do NOT appear here: all
|
|
3748
|
+
* three are reported, none gates. (Gating is per-KIND policy, not a property of judge
|
|
3749
|
+
* results — the quiz kind reports; a kind whose only check is the judge would gate on it.)
|
|
3573
3750
|
*/
|
|
3574
3751
|
function batchPassed(batch) {
|
|
3575
3752
|
return batch.totals.invalid === 0 && batch.totals.failed === 0 && batch.totals.errored === 0 && batch.totals.skipped === 0;
|
|
@@ -3637,6 +3814,7 @@ function formatResult(result, source) {
|
|
|
3637
3814
|
lines.push(` model: ${result.model}`);
|
|
3638
3815
|
lines.push(` system prompt: ${result.prompt.length} chars`);
|
|
3639
3816
|
lines.push(` imageInput: ${result.imageInput} anonymous: ${result.anonymous}` + (result.exampleQuestions.length ? ` exampleQuestions: ${result.exampleQuestions.length}` : ""));
|
|
3817
|
+
if (result.tools.length) lines.push(` tools: ${result.tools.join(", ")}`);
|
|
3640
3818
|
if (result.warnings.length) {
|
|
3641
3819
|
lines.push("");
|
|
3642
3820
|
lines.push(yellow(`${result.warnings.length} warning(s):`));
|
|
@@ -3809,18 +3987,24 @@ function formatEvalReport(result, source) {
|
|
|
3809
3987
|
];
|
|
3810
3988
|
const llm = result.llm.overrides ? `${result.llm.overrides.provider} / ${result.llm.overrides.model} ${yellow("→")} ${result.llm.provider} / ${result.llm.model} ${yellow("(override)")}` : `${result.llm.provider} / ${result.llm.model}`;
|
|
3811
3989
|
lines.push(` llm: ${llm}`);
|
|
3812
|
-
|
|
3990
|
+
const judge = result.llm.judge;
|
|
3991
|
+
if (judge && (judge.provider !== result.llm.provider || judge.model !== result.llm.model)) lines.push(` judge llm: ${judge.provider} / ${judge.model}${judge.overridden ? ` ${yellow("(override)")}` : ""}`);
|
|
3992
|
+
lines.push(` cases: ${totals.cases} × ${totals.repeats} repeat(s) = ${totals.calls} grading call(s)` + (result.judging === "off" ? "" : ` + ${totals.calls} judge call(s)`));
|
|
3813
3993
|
if (result.aborted) {
|
|
3814
3994
|
lines.push("");
|
|
3815
3995
|
lines.push(red(`Run aborted: ${result.aborted.message}`));
|
|
3816
3996
|
}
|
|
3997
|
+
if (result.judging === "degraded") {
|
|
3998
|
+
lines.push("");
|
|
3999
|
+
lines.push(yellow("Feedback judging stopped after repeated judge failures — grading was unaffected."));
|
|
4000
|
+
}
|
|
3817
4001
|
if (result.mismatches.length) {
|
|
3818
4002
|
lines.push("");
|
|
3819
4003
|
lines.push(red(`${result.mismatches.length} mismatch(es):`));
|
|
3820
4004
|
lines.push(...mismatchLines(result));
|
|
3821
4005
|
}
|
|
3822
4006
|
lines.push("");
|
|
3823
|
-
lines.push(` passed: ${totals.passed} failed: ${totals.failed} errored: ${totals.errored}` + (totals.skipped ? red(` skipped: ${totals.skipped} (run aborted)`) : "") + (totals.unstable ? dim(` unstable: ${totals.unstable}`) : ""));
|
|
4007
|
+
lines.push(` passed: ${totals.passed} failed: ${totals.failed} errored: ${totals.errored}` + (totals.skipped ? red(` skipped: ${totals.skipped} (run aborted)`) : "") + (totals.unstable ? dim(` unstable: ${totals.unstable}`) : "") + (!anyJudged(result) ? "" : totals.feedbackFlagged ? yellow(` flagged feedback: ${totals.feedbackFlagged}`) : dim(" flagged feedback: 0")) + (totals.judgeErrored ? dim(` judge errors: ${totals.judgeErrored}`) : ""));
|
|
3824
4008
|
const tokens = formatUsageLine(totals.usage);
|
|
3825
4009
|
if (tokens) lines.push(dim(` ${tokens}`));
|
|
3826
4010
|
if (result.confusion.length) {
|
|
@@ -3850,11 +4034,13 @@ function formatEvalBatchReport(batch) {
|
|
|
3850
4034
|
}
|
|
3851
4035
|
const t = file.result.totals;
|
|
3852
4036
|
const mark = t.failed === 0 && t.errored === 0 && t.skipped === 0 ? green("✔") : red("✗");
|
|
3853
|
-
lines.push(` ${mark} ${name}: ${t.cases} case(s), ${t.passed} passed, ${t.failed} failed, ${t.errored} errored` + (t.skipped ? red(`, ${t.skipped} skipped`) : "") + (t.unstable ? dim(`, ${t.unstable} unstable`) : ""));
|
|
4037
|
+
lines.push(` ${mark} ${name}: ${t.cases} case(s), ${t.passed} passed, ${t.failed} failed, ${t.errored} errored` + (t.skipped ? red(`, ${t.skipped} skipped`) : "") + (t.unstable ? dim(`, ${t.unstable} unstable`) : "") + (!anyJudged(file.result) ? "" : t.feedbackFlagged ? yellow(`, ${t.feedbackFlagged} flagged`) : dim(", 0 flagged")));
|
|
3854
4038
|
}
|
|
3855
4039
|
const g = batch.totals;
|
|
4040
|
+
const judged = batch.files.some((file) => file.result && anyJudged(file.result));
|
|
3856
4041
|
lines.push("");
|
|
3857
|
-
lines.push(` TOTAL: ${g.cases} case(s), ${g.passed} passed, ${g.failed} failed, ${g.errored} errored` + (g.skipped ? red(`, ${g.skipped} skipped`) : "") + (g.invalid ? red(`, ${g.invalid} invalid file(s)`) : ""));
|
|
4042
|
+
lines.push(` TOTAL: ${g.cases} case(s), ${g.passed} passed, ${g.failed} failed, ${g.errored} errored` + (g.skipped ? red(`, ${g.skipped} skipped`) : "") + (judged ? g.feedbackFlagged ? yellow(`, ${g.feedbackFlagged} flagged`) : dim(", 0 flagged") : "") + (g.invalid ? red(`, ${g.invalid} invalid file(s)`) : ""));
|
|
4043
|
+
if (batch.files.some((file) => file.result?.judging === "degraded")) lines.push(yellow(" Feedback judging stopped mid-run after repeated judge failures — files without a flagged count were graded but not judged."));
|
|
3858
4044
|
const tokens = formatUsageLine(g.usage);
|
|
3859
4045
|
if (tokens) lines.push(dim(` ${tokens}`));
|
|
3860
4046
|
for (const file of batch.files) {
|
|
@@ -3889,6 +4075,7 @@ function formatPromptDump(dump, sections, source) {
|
|
|
3889
4075
|
const lines = [green(`✔ Prompts — ${dump.kind}`) + dim(` — ${source}`)];
|
|
3890
4076
|
lines.push(` id: ${dump.id}`);
|
|
3891
4077
|
lines.push(` provider: ${dump.llm.provider} model: ${dump.llm.model}`);
|
|
4078
|
+
if (dump.kind === "tutor" && dump.tools.length > 0) lines.push(` tools: ${dump.tools.join(", ")}`);
|
|
3892
4079
|
lines.push(` prompts: ${sections.length}`);
|
|
3893
4080
|
for (const section of sections) lines.push(` ${section.name}: ${section.text.length} chars`);
|
|
3894
4081
|
lines.push("");
|
|
@@ -3928,6 +4115,17 @@ function llmText(llm) {
|
|
|
3928
4115
|
const effective = `${llm.provider} / ${llm.model}`;
|
|
3929
4116
|
return llm.overrides ? `${llm.overrides.provider} / ${llm.overrides.model} → ${effective} (override)` : effective;
|
|
3930
4117
|
}
|
|
4118
|
+
/**
|
|
4119
|
+
* The judge's pair, but ONLY when it differs from the grading pair — a judge line that
|
|
4120
|
+
* merely repeats the grader would be noise, while a differing one is essential: judge
|
|
4121
|
+
* strictness varies by model, so two reports are comparable only when it matches.
|
|
4122
|
+
*/
|
|
4123
|
+
function judgeLlmText(llm) {
|
|
4124
|
+
const judge = llm.judge;
|
|
4125
|
+
if (!judge) return void 0;
|
|
4126
|
+
if (judge.provider === llm.provider && judge.model === llm.model) return void 0;
|
|
4127
|
+
return `${judge.provider} / ${judge.model}${judge.overridden ? " (override)" : ""}`;
|
|
4128
|
+
}
|
|
3931
4129
|
/** `15,420 / 12,300 / 2,810`, or an em dash when nothing was reported. */
|
|
3932
4130
|
function usageCell(usage) {
|
|
3933
4131
|
if (usage.input === 0 && usage.cachedInput === 0 && usage.output === 0) return "—";
|
|
@@ -3956,6 +4154,7 @@ const OVERVIEW_HEADER = [
|
|
|
3956
4154
|
"Errored",
|
|
3957
4155
|
"Skipped",
|
|
3958
4156
|
"Unstable",
|
|
4157
|
+
"Flagged",
|
|
3959
4158
|
"False-correct",
|
|
3960
4159
|
"Tokens (in / cached / out)"
|
|
3961
4160
|
];
|
|
@@ -3975,6 +4174,7 @@ function overview(batch) {
|
|
|
3975
4174
|
"---:",
|
|
3976
4175
|
"---:",
|
|
3977
4176
|
"---:",
|
|
4177
|
+
"---:",
|
|
3978
4178
|
"---:"
|
|
3979
4179
|
])];
|
|
3980
4180
|
for (const file of batch.files) {
|
|
@@ -3990,6 +4190,7 @@ function overview(batch) {
|
|
|
3990
4190
|
"—",
|
|
3991
4191
|
"—",
|
|
3992
4192
|
"—",
|
|
4193
|
+
"—",
|
|
3993
4194
|
"—"
|
|
3994
4195
|
]));
|
|
3995
4196
|
continue;
|
|
@@ -4004,12 +4205,14 @@ function overview(batch) {
|
|
|
4004
4205
|
count(t.errored),
|
|
4005
4206
|
count(t.skipped),
|
|
4006
4207
|
count(t.unstable),
|
|
4208
|
+
anyJudged(file.result) ? count(t.feedbackFlagged) : "—",
|
|
4007
4209
|
falseCorrectCell(file.result),
|
|
4008
4210
|
usageCell(t.usage)
|
|
4009
4211
|
]));
|
|
4010
4212
|
}
|
|
4011
4213
|
if (batch.files.length > 1) {
|
|
4012
4214
|
const g = batch.totals;
|
|
4215
|
+
const judged = batch.files.some((file) => file.result && anyJudged(file.result));
|
|
4013
4216
|
lines.push(row([
|
|
4014
4217
|
"**TOTAL**",
|
|
4015
4218
|
g.invalid ? `${count(g.invalid)} invalid` : "",
|
|
@@ -4019,6 +4222,7 @@ function overview(batch) {
|
|
|
4019
4222
|
`**${count(g.errored)}**`,
|
|
4020
4223
|
`**${count(g.skipped)}**`,
|
|
4021
4224
|
`**${count(g.unstable)}**`,
|
|
4225
|
+
judged ? `**${count(g.feedbackFlagged)}**` : "—",
|
|
4022
4226
|
"",
|
|
4023
4227
|
`**${usageCell(g.usage)}**`
|
|
4024
4228
|
]));
|
|
@@ -4039,6 +4243,13 @@ function verdictSummary(evalCase) {
|
|
|
4039
4243
|
function needsDetail(evalCase) {
|
|
4040
4244
|
return evalCase.status === "failed" || evalCase.status === "errored" || evalCase.unstable;
|
|
4041
4245
|
}
|
|
4246
|
+
/** The "**Question** / **Golden answer**" intro every case detail section opens with. */
|
|
4247
|
+
function questionAndAnswer(evalCase, questionText) {
|
|
4248
|
+
const lines = [];
|
|
4249
|
+
if (questionText) lines.push("**Question**", "", quote(questionText), "");
|
|
4250
|
+
lines.push("**Golden answer**", "", quote(evalCase.answer), "");
|
|
4251
|
+
return lines;
|
|
4252
|
+
}
|
|
4042
4253
|
/**
|
|
4043
4254
|
* One case's section: the question it belongs to, the golden answer, and what the
|
|
4044
4255
|
* grader said — plus every repeat when they disagreed (the `--repeats` signal is
|
|
@@ -4049,16 +4260,7 @@ function caseSection(evalCase, questionText) {
|
|
|
4049
4260
|
const unstable = evalCase.unstable ? " *(unstable)*" : "";
|
|
4050
4261
|
lines.push(`### \`${evalCase.questionId}\` #${evalCase.answerIndex} — ${verdictSummary(evalCase)}${unstable}`);
|
|
4051
4262
|
lines.push("");
|
|
4052
|
-
|
|
4053
|
-
lines.push("**Question**");
|
|
4054
|
-
lines.push("");
|
|
4055
|
-
lines.push(quote(questionText));
|
|
4056
|
-
lines.push("");
|
|
4057
|
-
}
|
|
4058
|
-
lines.push("**Golden answer**");
|
|
4059
|
-
lines.push("");
|
|
4060
|
-
lines.push(quote(evalCase.answer));
|
|
4061
|
-
lines.push("");
|
|
4263
|
+
lines.push(...questionAndAnswer(evalCase, questionText));
|
|
4062
4264
|
const graded = evalCase.repeats.filter((r) => r.got !== void 0);
|
|
4063
4265
|
const disagreed = new Set(graded.map((r) => r.got)).size > 1;
|
|
4064
4266
|
if (evalCase.repeats.length > 1 && (disagreed || graded.length !== evalCase.repeats.length)) {
|
|
@@ -4088,6 +4290,38 @@ function caseSection(evalCase, questionText) {
|
|
|
4088
4290
|
}
|
|
4089
4291
|
return lines;
|
|
4090
4292
|
}
|
|
4293
|
+
/**
|
|
4294
|
+
* The "Flagged feedback" section: what the LLM judge found wrong with the TEXT the
|
|
4295
|
+
* grader wrote, per case, with each flagged repeat's verdict and feedback quoted verbatim
|
|
4296
|
+
* and the judge's issues as `criterion — note` items.
|
|
4297
|
+
*
|
|
4298
|
+
* Separate from the verdict sections on purpose — these cases usually PASSED (the verdict
|
|
4299
|
+
* was right, the wording was not), and mixing them into the mismatch list would suggest
|
|
4300
|
+
* the run failed on them. Empty when the file has no flags.
|
|
4301
|
+
*/
|
|
4302
|
+
function flaggedSection(result, questionText) {
|
|
4303
|
+
const flagged = result.cases.filter((evalCase) => evalCase.feedbackFlagged);
|
|
4304
|
+
if (flagged.length === 0) return [];
|
|
4305
|
+
const lines = ["### Flagged feedback", ""];
|
|
4306
|
+
lines.push("_An LLM judge audited each feedback text against the very grading prompt it was written under. Reported only — flagged feedback never fails a run._");
|
|
4307
|
+
lines.push("");
|
|
4308
|
+
for (const evalCase of flagged) {
|
|
4309
|
+
lines.push(`#### \`${evalCase.questionId}\` #${evalCase.answerIndex}`);
|
|
4310
|
+
lines.push("");
|
|
4311
|
+
lines.push(...questionAndAnswer(evalCase, questionText.get(evalCase.questionId)));
|
|
4312
|
+
for (const repeat of evalCase.repeats) {
|
|
4313
|
+
const issues = repeat.judge?.issues ?? [];
|
|
4314
|
+
if (issues.length === 0) continue;
|
|
4315
|
+
lines.push(`**Repeat #${repeat.repeatIndex + 1} — \`${repeat.got ?? "?"}\`**`);
|
|
4316
|
+
lines.push("");
|
|
4317
|
+
lines.push(quote(repeat.feedback ?? ""));
|
|
4318
|
+
lines.push("");
|
|
4319
|
+
for (const issue of issues) lines.push(`- \`${cell(issue.criterion)}\` — ${inline(issue.note)}`);
|
|
4320
|
+
lines.push("");
|
|
4321
|
+
}
|
|
4322
|
+
}
|
|
4323
|
+
return lines;
|
|
4324
|
+
}
|
|
4091
4325
|
/** One file's details section, or `[]` when the file has nothing to report. */
|
|
4092
4326
|
function fileDetails(file) {
|
|
4093
4327
|
const name = shortSource(file.source);
|
|
@@ -4105,8 +4339,9 @@ function fileDetails(file) {
|
|
|
4105
4339
|
const result = file.result;
|
|
4106
4340
|
const detailed = result.cases.filter(needsDetail);
|
|
4107
4341
|
const skipped = result.totals.skipped;
|
|
4108
|
-
if (detailed.length === 0 && skipped === 0 && !result.aborted) return [];
|
|
4109
4342
|
const questionText = new Map(result.questions.map((question) => [question.id, question.text]));
|
|
4343
|
+
const flagged = flaggedSection(result, questionText);
|
|
4344
|
+
if (detailed.length === 0 && skipped === 0 && !result.aborted && flagged.length === 0) return [];
|
|
4110
4345
|
const lines = [`## ${cell(name)} — \`${cell(result.id)}\``, ""];
|
|
4111
4346
|
if (result.aborted) {
|
|
4112
4347
|
lines.push("> [!WARNING]");
|
|
@@ -4119,6 +4354,7 @@ function fileDetails(file) {
|
|
|
4119
4354
|
lines.push(`**${count(skipped)} case(s) were never attempted**${reason} — the run is incomplete, so it cannot pass.`);
|
|
4120
4355
|
lines.push("");
|
|
4121
4356
|
}
|
|
4357
|
+
lines.push(...flagged);
|
|
4122
4358
|
return lines;
|
|
4123
4359
|
}
|
|
4124
4360
|
/**
|
|
@@ -4132,6 +4368,8 @@ function renderEvalMarkdownReport(batch, meta) {
|
|
|
4132
4368
|
lines.push(`- **Generated** ${timestamp(meta.generatedAt)} · novedu-cli ${meta.cliVersion}`);
|
|
4133
4369
|
const llms = [...new Set(batch.files.filter((f) => f.result).map((f) => llmText(f.result.llm)))];
|
|
4134
4370
|
for (const llm of llms) lines.push(`- **LLM** ${llm}`);
|
|
4371
|
+
const judges = [...new Set(batch.files.map((file) => file.result ? judgeLlmText(file.result.llm) : void 0).filter((text) => text !== void 0))];
|
|
4372
|
+
for (const judge of judges) lines.push(`- **Feedback judge** ${judge}`);
|
|
4135
4373
|
lines.push(`- **Run** ${count(batch.totals.files)} file(s), ${count(batch.totals.cases)} case(s) × ${count(meta.repeats)} repeat(s), concurrency ${count(meta.concurrency)}`);
|
|
4136
4374
|
const tokens = batch.totals.usage;
|
|
4137
4375
|
if (tokens.input || tokens.cachedInput || tokens.output) lines.push(`- **Tokens** ${count(tokens.input)} in (${count(tokens.cachedInput)} cached) / ${count(tokens.output)} out — successful grading calls only, so a lower bound`);
|
|
@@ -4141,6 +4379,12 @@ function renderEvalMarkdownReport(batch, meta) {
|
|
|
4141
4379
|
lines.push(`> The run was ABORTED — ${count(batch.totals.skipped)} case(s) were never graded, so this report is incomplete.`);
|
|
4142
4380
|
lines.push("");
|
|
4143
4381
|
}
|
|
4382
|
+
const degradedAt = batch.files.find((file) => file.result?.judging === "degraded");
|
|
4383
|
+
if (degradedAt?.result) {
|
|
4384
|
+
lines.push("> [!WARNING]");
|
|
4385
|
+
lines.push(`> Feedback judging STOPPED during \`${cell(shortSource(degradedAt.source))}\` after 3 judge calls failed in a row — everything from there on was graded but NOT judged, and shows an em dash rather than a count in the Flagged column. Grading was unaffected.`);
|
|
4386
|
+
lines.push("");
|
|
4387
|
+
}
|
|
4144
4388
|
lines.push("## Overview");
|
|
4145
4389
|
lines.push("");
|
|
4146
4390
|
lines.push(...overview(batch));
|
|
@@ -4150,7 +4394,7 @@ function renderEvalMarkdownReport(batch, meta) {
|
|
|
4150
4394
|
lines.push("_Nothing else to report — every case matched its expected verdict. The `--json` report carries every case, including the passing ones._");
|
|
4151
4395
|
lines.push("");
|
|
4152
4396
|
} else {
|
|
4153
|
-
lines.push("_Below: only the mismatched, errored and unstable cases. Passing cases live in the `--json` report._");
|
|
4397
|
+
lines.push("_Below: only the mismatched, errored and unstable cases, plus any feedback the judge flagged. Passing cases live in the `--json` report._");
|
|
4154
4398
|
lines.push("");
|
|
4155
4399
|
lines.push(...details);
|
|
4156
4400
|
}
|
|
@@ -4485,23 +4729,27 @@ function expandSources(args) {
|
|
|
4485
4729
|
duplicates
|
|
4486
4730
|
};
|
|
4487
4731
|
}
|
|
4488
|
-
/**
|
|
4489
|
-
|
|
4490
|
-
|
|
4491
|
-
|
|
4492
|
-
|
|
4732
|
+
/**
|
|
4733
|
+
* One provider/model pair from the flags: strictly BOTH-OR-NOTHING (the `effectiveLlm`
|
|
4734
|
+
* rule, docs/ai-models.md) with the provider checked against the known list. Shared by
|
|
4735
|
+
* `--llm-*` (the grading override) and `--judge-llm-*` (the judge's own pair), so the two
|
|
4736
|
+
* can never drift in wording or in strictness.
|
|
4737
|
+
*/
|
|
4738
|
+
function parsePair(flag, provider, model) {
|
|
4739
|
+
if (provider === void 0 && model === void 0) return { ok: true };
|
|
4740
|
+
if (provider === void 0 || model === void 0) return {
|
|
4493
4741
|
ok: false,
|
|
4494
|
-
message:
|
|
4742
|
+
message: `Pass --${flag}-provider and --${flag}-model together, or neither.`
|
|
4495
4743
|
};
|
|
4496
|
-
if (!LLM_PROVIDERS.includes(
|
|
4744
|
+
if (!LLM_PROVIDERS.includes(provider)) return {
|
|
4497
4745
|
ok: false,
|
|
4498
|
-
message: `Unknown
|
|
4746
|
+
message: `Unknown --${flag}-provider "${provider}": expected ${LLM_PROVIDERS.map((p) => `"${p}"`).join(" or ")}.`
|
|
4499
4747
|
};
|
|
4500
4748
|
return {
|
|
4501
4749
|
ok: true,
|
|
4502
4750
|
llm: {
|
|
4503
|
-
provider
|
|
4504
|
-
model
|
|
4751
|
+
provider,
|
|
4752
|
+
model
|
|
4505
4753
|
}
|
|
4506
4754
|
};
|
|
4507
4755
|
}
|
|
@@ -4571,6 +4819,101 @@ function makeGradeFn(server, llm) {
|
|
|
4571
4819
|
};
|
|
4572
4820
|
};
|
|
4573
4821
|
}
|
|
4822
|
+
/**
|
|
4823
|
+
* The HTTP seam for ONE judge call, with the run's judge llm closed in. Mirrors
|
|
4824
|
+
* {@link makeGradeFn}'s failure classification, minus the auth branch: a judge failure
|
|
4825
|
+
* NEVER aborts the run — it degrades judging (see the runner's breaker) while the grading
|
|
4826
|
+
* half finishes untouched.
|
|
4827
|
+
*/
|
|
4828
|
+
function makeJudgeFn(server, llm) {
|
|
4829
|
+
return async ({ system, subject, criteria }) => {
|
|
4830
|
+
const response = await performApiRequest({
|
|
4831
|
+
server,
|
|
4832
|
+
path: "/api/eval/judge",
|
|
4833
|
+
method: "POST",
|
|
4834
|
+
body: {
|
|
4835
|
+
llm,
|
|
4836
|
+
system,
|
|
4837
|
+
subject,
|
|
4838
|
+
criteria: [...criteria]
|
|
4839
|
+
},
|
|
4840
|
+
quiet: true
|
|
4841
|
+
});
|
|
4842
|
+
if (response.ok) {
|
|
4843
|
+
const payload = response.payload;
|
|
4844
|
+
if (Array.isArray(payload?.issues)) {
|
|
4845
|
+
const issues = payload.issues.flatMap((entry) => {
|
|
4846
|
+
const { criterion, note } = entry ?? {};
|
|
4847
|
+
return typeof criterion === "string" ? [{
|
|
4848
|
+
criterion,
|
|
4849
|
+
note: typeof note === "string" ? note : ""
|
|
4850
|
+
}] : [];
|
|
4851
|
+
});
|
|
4852
|
+
const usage = parseUsage(payload?.usage);
|
|
4853
|
+
return {
|
|
4854
|
+
ok: true,
|
|
4855
|
+
issues,
|
|
4856
|
+
...usage ? { usage } : {}
|
|
4857
|
+
};
|
|
4858
|
+
}
|
|
4859
|
+
return {
|
|
4860
|
+
ok: false,
|
|
4861
|
+
retryable: false,
|
|
4862
|
+
error: { message: "The server's response is not a feedback judgment — it may not offer /api/eval/judge at all (does it run a Novedu version with the feedback judge?). Re-run with --no-judge-feedback to grade without judging." }
|
|
4863
|
+
};
|
|
4864
|
+
}
|
|
4865
|
+
return {
|
|
4866
|
+
ok: false,
|
|
4867
|
+
retryable: response.status === void 0 || response.status >= 500,
|
|
4868
|
+
error: response.error
|
|
4869
|
+
};
|
|
4870
|
+
};
|
|
4871
|
+
}
|
|
4872
|
+
/** Budget for the one version probe — a hung check must never hold up a run. */
|
|
4873
|
+
const VERSION_CHECK_TIMEOUT_MS = 5e3;
|
|
4874
|
+
/**
|
|
4875
|
+
* Warn when this CLI was not built from the same commit as the server it is about to
|
|
4876
|
+
* grade against. `eval` assembles every grading system prompt LOCALLY, from the `lib/**`
|
|
4877
|
+
* prompt builders frozen into this published CLI — so a stale binary can certify prompts
|
|
4878
|
+
* the server's activities no longer send. CLI and server live in one repo, which makes
|
|
4879
|
+
* the server's `cliVersion` (from `GET /api/version`, public and unauthenticated) exactly
|
|
4880
|
+
* the CLI release matching its bundled code.
|
|
4881
|
+
*
|
|
4882
|
+
* Deliberately EVAL-ONLY (prompt drift corrupts nothing else) and strictly advisory: one
|
|
4883
|
+
* fetch, no retry, never an abort, never an exit code, and never a byte on stdout — the
|
|
4884
|
+
* JSON output contract owns that stream. Unlike progress it prints off a TTY too: a CI
|
|
4885
|
+
* log is precisely where this warning has to survive. Absence is NOT silently forgiven —
|
|
4886
|
+
* an unreachable, non-JSON, non-2xx or `cliVersion`-less answer says so, because "could
|
|
4887
|
+
* not check" and "checked, fine" must not look the same.
|
|
4888
|
+
*/
|
|
4889
|
+
async function warnOnVersionMismatch(server) {
|
|
4890
|
+
const local = cliVersion();
|
|
4891
|
+
const unverifiable = (reason) => {
|
|
4892
|
+
process.stderr.write(`Warning: could not verify that this CLI (${local}) matches the server's — ${reason}. Locally assembled grading prompts may differ from what that server's activities run.
|
|
4893
|
+
`);
|
|
4894
|
+
};
|
|
4895
|
+
const base = resolveServerUrl(server);
|
|
4896
|
+
let payload;
|
|
4897
|
+
try {
|
|
4898
|
+
const response = await fetch(new URL("/api/version", base), { signal: AbortSignal.timeout(VERSION_CHECK_TIMEOUT_MS) });
|
|
4899
|
+
if (!response.ok) {
|
|
4900
|
+
unverifiable(`${base} answered HTTP ${response.status}`);
|
|
4901
|
+
return;
|
|
4902
|
+
}
|
|
4903
|
+
payload = await response.json();
|
|
4904
|
+
} catch (error) {
|
|
4905
|
+
unverifiable(`${base} did not answer (${error instanceof Error ? error.message : error})`);
|
|
4906
|
+
return;
|
|
4907
|
+
}
|
|
4908
|
+
const remote = payload?.cliVersion;
|
|
4909
|
+
if (typeof remote !== "string" || remote === "") {
|
|
4910
|
+
unverifiable(`${base} reports no CLI version (does it run a Novedu version that has one?)`);
|
|
4911
|
+
return;
|
|
4912
|
+
}
|
|
4913
|
+
if (remote === local) return;
|
|
4914
|
+
process.stderr.write(`Warning: this CLI is ${local} but the server was built with CLI ${remote} — locally assembled grading prompts may differ from what that server's activities run. Update: npm i -g @novedu/cli
|
|
4915
|
+
`);
|
|
4916
|
+
}
|
|
4574
4917
|
/** stderr progress, suppressed off a TTY so CI logs stay readable. */
|
|
4575
4918
|
function progressWriter(prefix) {
|
|
4576
4919
|
if (!process.stderr.isTTY) return void 0;
|
|
@@ -4579,16 +4922,41 @@ function progressWriter(prefix) {
|
|
|
4579
4922
|
};
|
|
4580
4923
|
}
|
|
4581
4924
|
/**
|
|
4925
|
+
* The off-a-TTY replacement for the spinner: ONE newline-terminated line per finished
|
|
4926
|
+
* file. The `\r` counter above is suppressed when stderr is redirected (it would fill a
|
|
4927
|
+
* log with carriage-return noise), which otherwise left a long batch printing nothing at
|
|
4928
|
+
* all between the scope banner and the final report — indistinguishable from a hang, and
|
|
4929
|
+
* an easy way to talk yourself into killing a healthy run. Coarse and greppable is
|
|
4930
|
+
* enough: it proves liveness and says which file the run reached.
|
|
4931
|
+
*
|
|
4932
|
+
* Deliberately no timings — a per-file duration invites extrapolating an ETA that the
|
|
4933
|
+
* model, the provider's load and `--concurrency` make unreliable.
|
|
4934
|
+
*/
|
|
4935
|
+
function writeFileDone(label, result) {
|
|
4936
|
+
const totals = result.totals;
|
|
4937
|
+
process.stderr.write(`${label}: ${totals.cases} case(s), ${totals.passed} passed, ${totals.failed} failed, ${totals.errored} errored` + (totals.skipped ? `, ${totals.skipped} skipped` : "") + (anyJudged(result) ? `, ${totals.feedbackFlagged} flagged` : "") + "\n");
|
|
4938
|
+
}
|
|
4939
|
+
/**
|
|
4582
4940
|
* The command's core, exported for the unit tests. `seams` exists only so tests can
|
|
4583
4941
|
* shrink the retry backoff — the CLI itself never passes it (PoC parity: 4 attempts,
|
|
4584
4942
|
* 5 s linear).
|
|
4585
4943
|
*/
|
|
4586
4944
|
async function runEvalCommand(pathsOrUrls, options, seams = {}) {
|
|
4587
|
-
const override =
|
|
4945
|
+
const override = parsePair("llm", options.llmProvider, options.llmModel);
|
|
4588
4946
|
if (!override.ok) {
|
|
4589
4947
|
failJson({ message: override.message });
|
|
4590
4948
|
return;
|
|
4591
4949
|
}
|
|
4950
|
+
const judgeOverride = parsePair("judge-llm", options.judgeLlmProvider, options.judgeLlmModel);
|
|
4951
|
+
if (!judgeOverride.ok) {
|
|
4952
|
+
failJson({ message: judgeOverride.message });
|
|
4953
|
+
return;
|
|
4954
|
+
}
|
|
4955
|
+
const judging = options.judgeFeedback !== false;
|
|
4956
|
+
if (!judging && judgeOverride.llm) {
|
|
4957
|
+
failJson({ message: "--judge-llm-provider/--judge-llm-model cannot be combined with --no-judge-feedback: the first configures the feedback judge, the second switches it off." });
|
|
4958
|
+
return;
|
|
4959
|
+
}
|
|
4592
4960
|
const expansion = expandSources(pathsOrUrls);
|
|
4593
4961
|
if (!expansion.ok) {
|
|
4594
4962
|
failJson({ message: expansion.message });
|
|
@@ -4632,8 +5000,14 @@ async function runEvalCommand(pathsOrUrls, options, seams = {}) {
|
|
|
4632
5000
|
}
|
|
4633
5001
|
{
|
|
4634
5002
|
const totalCases = [...checked.values()].reduce((sum, file) => sum + file.caseCount, 0);
|
|
4635
|
-
|
|
5003
|
+
const calls = totalCases * repeats;
|
|
5004
|
+
process.stderr.write(`${totalCases} case(s) × ${repeats} repeat(s) = ${calls} grading` + (judging ? ` + ${calls} judge call(s)\n` : " call(s)\n"));
|
|
4636
5005
|
}
|
|
5006
|
+
await warnOnVersionMismatch(options.server);
|
|
5007
|
+
const judgeBreaker = createJudgeBreaker();
|
|
5008
|
+
const onJudgeDegraded = () => {
|
|
5009
|
+
process.stderr.write("Warning: feedback judging was stopped after 3 judge calls failed in a row — the rest of this run is graded but NOT judged. Grading is unaffected.\n");
|
|
5010
|
+
};
|
|
4637
5011
|
let fileIndex = 0;
|
|
4638
5012
|
for (const file of files) {
|
|
4639
5013
|
fileIndex += 1;
|
|
@@ -4644,20 +5018,31 @@ async function runEvalCommand(pathsOrUrls, options, seams = {}) {
|
|
|
4644
5018
|
model: check.quizDump.llm.model
|
|
4645
5019
|
};
|
|
4646
5020
|
const effective = override.llm ?? quizLlm;
|
|
5021
|
+
const judgeLlm = judgeOverride.llm ?? effective;
|
|
4647
5022
|
const llm = {
|
|
4648
5023
|
...effective,
|
|
4649
|
-
...override.llm ? { overrides: quizLlm } : {}
|
|
5024
|
+
...override.llm ? { overrides: quizLlm } : {},
|
|
5025
|
+
...judging ? { judge: {
|
|
5026
|
+
...judgeLlm,
|
|
5027
|
+
overridden: judgeOverride.llm !== void 0
|
|
5028
|
+
} } : {}
|
|
4650
5029
|
};
|
|
4651
|
-
const
|
|
4652
|
-
|
|
5030
|
+
const label = files.length > 1 ? `(${fileIndex}/${files.length}) ${check.evalFile.id}` : check.evalFile.id;
|
|
5031
|
+
const prefix = files.length > 1 ? `${label}: ` : "";
|
|
5032
|
+
const result = await runEval("quiz", check, {
|
|
4653
5033
|
grade: makeGradeFn(options.server, effective),
|
|
5034
|
+
...judging ? { judge: makeJudgeFn(options.server, judgeLlm) } : {},
|
|
5035
|
+
judgeBreaker,
|
|
5036
|
+
onJudgeDegraded,
|
|
4654
5037
|
concurrency,
|
|
4655
5038
|
repeats,
|
|
4656
5039
|
llm,
|
|
4657
5040
|
onProgress: progressWriter(prefix),
|
|
4658
5041
|
...seams.retry ? { retry: seams.retry } : {}
|
|
4659
5042
|
});
|
|
5043
|
+
file.result = result;
|
|
4660
5044
|
if (process.stderr.isTTY) process.stderr.write("\n");
|
|
5045
|
+
else writeFileDone(label, result);
|
|
4661
5046
|
}
|
|
4662
5047
|
const batch = summarizeBatch(files);
|
|
4663
5048
|
const payload = JSON.stringify(batch, null, 2);
|
|
@@ -4684,7 +5069,7 @@ async function runEvalCommand(pathsOrUrls, options, seams = {}) {
|
|
|
4684
5069
|
process.exitCode = batchPassed(batch) ? 0 : 1;
|
|
4685
5070
|
}
|
|
4686
5071
|
function registerEval(program) {
|
|
4687
|
-
program.command("eval").description("Grade a file of golden answers against its quiz's real rubric and report the result").argument("<evalPathOrUrl...>", "one or more eval YAML files (paths, http(s)/file URLs, or a quoted glob pattern)").option("--server <url>", "Novedu server base URL (defaults to the NOVEDU_SERVER env var, then production)").option("--concurrency <n>", "grading calls in flight per file", String(CONCURRENCY_DEFAULT)).option("--repeats <n>", "grade every answer N times and take the majority verdict", "1").option("--llm-provider <provider>", "grade with this provider instead of the quiz's (\"SCCH\" or \"Azure Foundry\"; needs --llm-model)").option("--llm-model <model>", "grade with this model instead of the quiz's (needs --llm-provider)").option("--json", "print the machine-readable batch report on stdout").option("--out <file>", "additionally write the machine-readable batch report to a file").option("--report <file>", "additionally write a readable Markdown report to a file").addHelpText("after", `
|
|
5072
|
+
program.command("eval").description("Grade a file of golden answers against its quiz's real rubric and report the result").argument("<evalPathOrUrl...>", "one or more eval YAML files (paths, http(s)/file URLs, or a quoted glob pattern)").option("--server <url>", "Novedu server base URL (defaults to the NOVEDU_SERVER env var, then production)").option("--concurrency <n>", "grading calls in flight per file", String(CONCURRENCY_DEFAULT)).option("--repeats <n>", "grade every answer N times and take the majority verdict", "1").option("--llm-provider <provider>", "grade with this provider instead of the quiz's (\"SCCH\" or \"Azure Foundry\"; needs --llm-model)").option("--llm-model <model>", "grade with this model instead of the quiz's (needs --llm-provider)").option("--no-judge-feedback", "skip the LLM audit of the grader's feedback (halves the LLM calls)").option("--judge-llm-provider <provider>", "judge the feedback with this provider (\"SCCH\" or \"Azure Foundry\"; needs --judge-llm-model)").option("--judge-llm-model <model>", "judge the feedback with this model instead of the grading one (needs --judge-llm-provider)").option("--json", "print the machine-readable batch report on stdout").option("--out <file>", "additionally write the machine-readable batch report to a file").option("--report <file>", "additionally write a readable Markdown report to a file").addHelpText("after", `
|
|
4688
5073
|
Examples:
|
|
4689
5074
|
# Evaluate one quiz's golden answers
|
|
4690
5075
|
$ novedu-cli eval ./0010-welcome-quiz.eval.yaml
|
|
@@ -4698,6 +5083,12 @@ Examples:
|
|
|
4698
5083
|
# How would this rubric perform on another model? (both flags, always together)
|
|
4699
5084
|
$ novedu-cli eval ./my-quiz.eval.yaml --llm-provider "Azure Foundry" --llm-model gpt-5-mini
|
|
4700
5085
|
|
|
5086
|
+
# A strong judge over the quiz's own grader — the recommended pairing
|
|
5087
|
+
$ novedu-cli eval ./my-quiz.eval.yaml --judge-llm-provider "Azure Foundry" --judge-llm-model gpt-5.6-terra
|
|
5088
|
+
|
|
5089
|
+
# Half the LLM calls: check the verdicts only, skip the feedback audit
|
|
5090
|
+
$ novedu-cli eval ./my-quiz.eval.yaml --no-judge-feedback
|
|
5091
|
+
|
|
4701
5092
|
# Machine-readable, for CI
|
|
4702
5093
|
$ novedu-cli eval ./my-quiz.eval.yaml --json --out eval-report.json
|
|
4703
5094
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@novedu/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.22.0",
|
|
4
4
|
"description": "Command-line companion for the Novedu chat app. Validates tutor, fragment, quiz, writing, coding and eval YAML definitions, dumps the exact LLM prompts an activity produces, and evaluates a quiz's grading rubric against golden answers; signs in with Entra ID and manages codes, app-hosted files and images over the app's API.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"repository": {
|