@novedu/cli 0.21.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.
Files changed (3) hide show
  1. package/README.md +39 -4
  2. package/dist/main.js +419 -37
  3. 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 (passing cases stay in the
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 calls that
172
- **succeeded**, so it is a lower bound (a retried or failed call reports nothing), and
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
@@ -3311,6 +3311,89 @@ async function loadAndCheckEval(url, fetchImpl, opts = {}) {
3311
3311
  };
3312
3312
  }
3313
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
3314
3397
  //#region src/retry.ts
3315
3398
  const defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
3316
3399
  /**
@@ -3364,6 +3447,14 @@ function addUsage(total, usage) {
3364
3447
  total.cachedInput += usage.cachedInput;
3365
3448
  total.output += usage.output;
3366
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;
3367
3458
  /** Consecutive fully-errored cases that mean "the server is down, stop now". */
3368
3459
  const CIRCUIT_BREAKER_LIMIT = 3;
3369
3460
  /** Flatten questions × answers into cases, each carrying its grading prompt. */
@@ -3398,6 +3489,12 @@ function majority(graded, expected) {
3398
3489
  passed: tied.every((verdict) => expected.includes(verdict))
3399
3490
  };
3400
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
+ }
3401
3498
  /** The seam: one runner per eval kind (mirrors `promptDumpers`). */
3402
3499
  const evalRunners = { quiz: { async run(checked, options) {
3403
3500
  const repeats = Math.max(1, Math.floor(options.repeats ?? 1));
@@ -3405,9 +3502,53 @@ const evalRunners = { quiz: { async run(checked, options) {
3405
3502
  const planned = planCases(checked);
3406
3503
  const total = planned.length * repeats;
3407
3504
  const questionTexts = new Map(checked.quizQuestions.map((q) => [q.id, q.text]));
3505
+ const breaker = options.judgeBreaker ?? createJudgeBreaker();
3408
3506
  let done = 0;
3409
3507
  let consecutiveErrored = 0;
3410
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
+ };
3411
3552
  const progress = () => {
3412
3553
  done += 1;
3413
3554
  options.onProgress?.({
@@ -3422,7 +3563,8 @@ const evalRunners = { quiz: { async run(checked, options) {
3422
3563
  if (plan.system === void 0) {
3423
3564
  rows.push({
3424
3565
  repeatIndex,
3425
- error: { message: `The quiz has no question "${plan.questionId}".` }
3566
+ error: { message: `The quiz has no question "${plan.questionId}".` },
3567
+ ...unjudged
3426
3568
  });
3427
3569
  progress();
3428
3570
  continue;
@@ -3436,19 +3578,23 @@ const evalRunners = { quiz: { async run(checked, options) {
3436
3578
  sleep: options.retry?.sleep,
3437
3579
  shouldRetry: (value) => !value.ok && value.retryable && value.auth !== true
3438
3580
  });
3439
- progress();
3440
3581
  if (outcome.ok) {
3582
+ const judged = options.judge ? await judgeRepeat(plan.system, plan.answer, outcome.verdict, outcome.feedback) : {};
3583
+ progress();
3441
3584
  rows.push({
3442
3585
  repeatIndex,
3443
3586
  got: outcome.verdict,
3444
3587
  feedback: outcome.feedback,
3445
- ...outcome.usage ? { usage: outcome.usage } : {}
3588
+ ...outcome.usage ? { usage: outcome.usage } : {},
3589
+ ...judged
3446
3590
  });
3447
3591
  continue;
3448
3592
  }
3593
+ progress();
3449
3594
  rows.push({
3450
3595
  repeatIndex,
3451
- error: outcome.error
3596
+ error: outcome.error,
3597
+ ...unjudged
3452
3598
  });
3453
3599
  if (outcome.auth) {
3454
3600
  aborted ??= {
@@ -3476,11 +3622,15 @@ const evalRunners = { quiz: { async run(checked, options) {
3476
3622
  status,
3477
3623
  ...winner ? { verdict: winner.verdict } : {},
3478
3624
  unstable: new Set(graded).size > 1,
3625
+ feedbackFlagged: rows.some((row) => (row.judge?.issues.length ?? 0) > 0),
3479
3626
  repeats: rows
3480
3627
  };
3481
3628
  });
3482
3629
  const usage = { ...ZERO_USAGE };
3483
- for (const result of results) for (const row of result.repeats) addUsage(usage, row.usage);
3630
+ for (const result of results) for (const row of result.repeats) {
3631
+ addUsage(usage, row.usage);
3632
+ addUsage(usage, row.judge?.usage);
3633
+ }
3484
3634
  const totals = {
3485
3635
  cases: results.length,
3486
3636
  passed: results.filter((c) => c.status === "passed").length,
@@ -3488,6 +3638,8 @@ const evalRunners = { quiz: { async run(checked, options) {
3488
3638
  errored: results.filter((c) => c.status === "errored").length,
3489
3639
  skipped: results.filter((c) => c.status === "skipped").length,
3490
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),
3491
3643
  repeats,
3492
3644
  calls: total,
3493
3645
  usage
@@ -3512,6 +3664,7 @@ const evalRunners = { quiz: { async run(checked, options) {
3512
3664
  id: checked.evalFile.id,
3513
3665
  target: checked.targetUrl,
3514
3666
  llm: options.llm,
3667
+ judging: !options.judge ? "off" : breaker.stopped ? "degraded" : "on",
3515
3668
  totals,
3516
3669
  questions: [...new Set(checked.evalFile.questions.map((question) => question.question))].map((id) => ({
3517
3670
  id,
@@ -3552,6 +3705,8 @@ function summarizeBatch(files) {
3552
3705
  errored: 0,
3553
3706
  skipped: 0,
3554
3707
  unstable: 0,
3708
+ feedbackFlagged: 0,
3709
+ judgeErrored: 0,
3555
3710
  usage: { ...ZERO_USAGE }
3556
3711
  };
3557
3712
  for (const file of files) {
@@ -3562,6 +3717,8 @@ function summarizeBatch(files) {
3562
3717
  totals.errored += file.result.totals.errored;
3563
3718
  totals.skipped += file.result.totals.skipped;
3564
3719
  totals.unstable += file.result.totals.unstable;
3720
+ totals.feedbackFlagged += file.result.totals.feedbackFlagged;
3721
+ totals.judgeErrored += file.result.totals.judgeErrored;
3565
3722
  addUsage(totals.usage, file.result.totals.usage);
3566
3723
  }
3567
3724
  return {
@@ -3574,9 +3731,22 @@ function summarizeBatch(files) {
3574
3731
  };
3575
3732
  }
3576
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
+ /**
3577
3743
  * The CI gate: every file valid, and not a single failed, errored, or skipped CASE —
3578
3744
  * an aborted (and therefore incomplete) run must never read as a pass. The single
3579
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.)
3580
3750
  */
3581
3751
  function batchPassed(batch) {
3582
3752
  return batch.totals.invalid === 0 && batch.totals.failed === 0 && batch.totals.errored === 0 && batch.totals.skipped === 0;
@@ -3817,18 +3987,24 @@ function formatEvalReport(result, source) {
3817
3987
  ];
3818
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}`;
3819
3989
  lines.push(` llm: ${llm}`);
3820
- lines.push(` cases: ${totals.cases} × ${totals.repeats} repeat(s) = ${totals.calls} grading call(s)`);
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)`));
3821
3993
  if (result.aborted) {
3822
3994
  lines.push("");
3823
3995
  lines.push(red(`Run aborted: ${result.aborted.message}`));
3824
3996
  }
3997
+ if (result.judging === "degraded") {
3998
+ lines.push("");
3999
+ lines.push(yellow("Feedback judging stopped after repeated judge failures — grading was unaffected."));
4000
+ }
3825
4001
  if (result.mismatches.length) {
3826
4002
  lines.push("");
3827
4003
  lines.push(red(`${result.mismatches.length} mismatch(es):`));
3828
4004
  lines.push(...mismatchLines(result));
3829
4005
  }
3830
4006
  lines.push("");
3831
- 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}`) : ""));
3832
4008
  const tokens = formatUsageLine(totals.usage);
3833
4009
  if (tokens) lines.push(dim(` ${tokens}`));
3834
4010
  if (result.confusion.length) {
@@ -3858,11 +4034,13 @@ function formatEvalBatchReport(batch) {
3858
4034
  }
3859
4035
  const t = file.result.totals;
3860
4036
  const mark = t.failed === 0 && t.errored === 0 && t.skipped === 0 ? green("✔") : red("✗");
3861
- 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")));
3862
4038
  }
3863
4039
  const g = batch.totals;
4040
+ const judged = batch.files.some((file) => file.result && anyJudged(file.result));
3864
4041
  lines.push("");
3865
- 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."));
3866
4044
  const tokens = formatUsageLine(g.usage);
3867
4045
  if (tokens) lines.push(dim(` ${tokens}`));
3868
4046
  for (const file of batch.files) {
@@ -3937,6 +4115,17 @@ function llmText(llm) {
3937
4115
  const effective = `${llm.provider} / ${llm.model}`;
3938
4116
  return llm.overrides ? `${llm.overrides.provider} / ${llm.overrides.model} → ${effective} (override)` : effective;
3939
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
+ }
3940
4129
  /** `15,420 / 12,300 / 2,810`, or an em dash when nothing was reported. */
3941
4130
  function usageCell(usage) {
3942
4131
  if (usage.input === 0 && usage.cachedInput === 0 && usage.output === 0) return "—";
@@ -3965,6 +4154,7 @@ const OVERVIEW_HEADER = [
3965
4154
  "Errored",
3966
4155
  "Skipped",
3967
4156
  "Unstable",
4157
+ "Flagged",
3968
4158
  "False-correct",
3969
4159
  "Tokens (in / cached / out)"
3970
4160
  ];
@@ -3984,6 +4174,7 @@ function overview(batch) {
3984
4174
  "---:",
3985
4175
  "---:",
3986
4176
  "---:",
4177
+ "---:",
3987
4178
  "---:"
3988
4179
  ])];
3989
4180
  for (const file of batch.files) {
@@ -3999,6 +4190,7 @@ function overview(batch) {
3999
4190
  "—",
4000
4191
  "—",
4001
4192
  "—",
4193
+ "—",
4002
4194
  "—"
4003
4195
  ]));
4004
4196
  continue;
@@ -4013,12 +4205,14 @@ function overview(batch) {
4013
4205
  count(t.errored),
4014
4206
  count(t.skipped),
4015
4207
  count(t.unstable),
4208
+ anyJudged(file.result) ? count(t.feedbackFlagged) : "—",
4016
4209
  falseCorrectCell(file.result),
4017
4210
  usageCell(t.usage)
4018
4211
  ]));
4019
4212
  }
4020
4213
  if (batch.files.length > 1) {
4021
4214
  const g = batch.totals;
4215
+ const judged = batch.files.some((file) => file.result && anyJudged(file.result));
4022
4216
  lines.push(row([
4023
4217
  "**TOTAL**",
4024
4218
  g.invalid ? `${count(g.invalid)} invalid` : "",
@@ -4028,6 +4222,7 @@ function overview(batch) {
4028
4222
  `**${count(g.errored)}**`,
4029
4223
  `**${count(g.skipped)}**`,
4030
4224
  `**${count(g.unstable)}**`,
4225
+ judged ? `**${count(g.feedbackFlagged)}**` : "—",
4031
4226
  "",
4032
4227
  `**${usageCell(g.usage)}**`
4033
4228
  ]));
@@ -4048,6 +4243,13 @@ function verdictSummary(evalCase) {
4048
4243
  function needsDetail(evalCase) {
4049
4244
  return evalCase.status === "failed" || evalCase.status === "errored" || evalCase.unstable;
4050
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
+ }
4051
4253
  /**
4052
4254
  * One case's section: the question it belongs to, the golden answer, and what the
4053
4255
  * grader said — plus every repeat when they disagreed (the `--repeats` signal is
@@ -4058,16 +4260,7 @@ function caseSection(evalCase, questionText) {
4058
4260
  const unstable = evalCase.unstable ? " *(unstable)*" : "";
4059
4261
  lines.push(`### \`${evalCase.questionId}\` #${evalCase.answerIndex} — ${verdictSummary(evalCase)}${unstable}`);
4060
4262
  lines.push("");
4061
- if (questionText) {
4062
- lines.push("**Question**");
4063
- lines.push("");
4064
- lines.push(quote(questionText));
4065
- lines.push("");
4066
- }
4067
- lines.push("**Golden answer**");
4068
- lines.push("");
4069
- lines.push(quote(evalCase.answer));
4070
- lines.push("");
4263
+ lines.push(...questionAndAnswer(evalCase, questionText));
4071
4264
  const graded = evalCase.repeats.filter((r) => r.got !== void 0);
4072
4265
  const disagreed = new Set(graded.map((r) => r.got)).size > 1;
4073
4266
  if (evalCase.repeats.length > 1 && (disagreed || graded.length !== evalCase.repeats.length)) {
@@ -4097,6 +4290,38 @@ function caseSection(evalCase, questionText) {
4097
4290
  }
4098
4291
  return lines;
4099
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
+ }
4100
4325
  /** One file's details section, or `[]` when the file has nothing to report. */
4101
4326
  function fileDetails(file) {
4102
4327
  const name = shortSource(file.source);
@@ -4114,8 +4339,9 @@ function fileDetails(file) {
4114
4339
  const result = file.result;
4115
4340
  const detailed = result.cases.filter(needsDetail);
4116
4341
  const skipped = result.totals.skipped;
4117
- if (detailed.length === 0 && skipped === 0 && !result.aborted) return [];
4118
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 [];
4119
4345
  const lines = [`## ${cell(name)} — \`${cell(result.id)}\``, ""];
4120
4346
  if (result.aborted) {
4121
4347
  lines.push("> [!WARNING]");
@@ -4128,6 +4354,7 @@ function fileDetails(file) {
4128
4354
  lines.push(`**${count(skipped)} case(s) were never attempted**${reason} — the run is incomplete, so it cannot pass.`);
4129
4355
  lines.push("");
4130
4356
  }
4357
+ lines.push(...flagged);
4131
4358
  return lines;
4132
4359
  }
4133
4360
  /**
@@ -4141,6 +4368,8 @@ function renderEvalMarkdownReport(batch, meta) {
4141
4368
  lines.push(`- **Generated** ${timestamp(meta.generatedAt)} · novedu-cli ${meta.cliVersion}`);
4142
4369
  const llms = [...new Set(batch.files.filter((f) => f.result).map((f) => llmText(f.result.llm)))];
4143
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}`);
4144
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)}`);
4145
4374
  const tokens = batch.totals.usage;
4146
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`);
@@ -4150,6 +4379,12 @@ function renderEvalMarkdownReport(batch, meta) {
4150
4379
  lines.push(`> The run was ABORTED — ${count(batch.totals.skipped)} case(s) were never graded, so this report is incomplete.`);
4151
4380
  lines.push("");
4152
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
+ }
4153
4388
  lines.push("## Overview");
4154
4389
  lines.push("");
4155
4390
  lines.push(...overview(batch));
@@ -4159,7 +4394,7 @@ function renderEvalMarkdownReport(batch, meta) {
4159
4394
  lines.push("_Nothing else to report — every case matched its expected verdict. The `--json` report carries every case, including the passing ones._");
4160
4395
  lines.push("");
4161
4396
  } else {
4162
- 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._");
4163
4398
  lines.push("");
4164
4399
  lines.push(...details);
4165
4400
  }
@@ -4494,23 +4729,27 @@ function expandSources(args) {
4494
4729
  duplicates
4495
4730
  };
4496
4731
  }
4497
- /** The `--llm-provider`/`--llm-model` pair: strictly both-or-nothing, provider checked. */
4498
- function parseOverride(options) {
4499
- const { llmProvider, llmModel } = options;
4500
- if (llmProvider === void 0 && llmModel === void 0) return { ok: true };
4501
- if (llmProvider === void 0 || llmModel === void 0) return {
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 {
4502
4741
  ok: false,
4503
- message: "Pass --llm-provider and --llm-model together, or neither."
4742
+ message: `Pass --${flag}-provider and --${flag}-model together, or neither.`
4504
4743
  };
4505
- if (!LLM_PROVIDERS.includes(llmProvider)) return {
4744
+ if (!LLM_PROVIDERS.includes(provider)) return {
4506
4745
  ok: false,
4507
- message: `Unknown --llm-provider "${llmProvider}": expected ${LLM_PROVIDERS.map((p) => `"${p}"`).join(" or ")}.`
4746
+ message: `Unknown --${flag}-provider "${provider}": expected ${LLM_PROVIDERS.map((p) => `"${p}"`).join(" or ")}.`
4508
4747
  };
4509
4748
  return {
4510
4749
  ok: true,
4511
4750
  llm: {
4512
- provider: llmProvider,
4513
- model: llmModel
4751
+ provider,
4752
+ model
4514
4753
  }
4515
4754
  };
4516
4755
  }
@@ -4580,6 +4819,101 @@ function makeGradeFn(server, llm) {
4580
4819
  };
4581
4820
  };
4582
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
+ }
4583
4917
  /** stderr progress, suppressed off a TTY so CI logs stay readable. */
4584
4918
  function progressWriter(prefix) {
4585
4919
  if (!process.stderr.isTTY) return void 0;
@@ -4588,16 +4922,41 @@ function progressWriter(prefix) {
4588
4922
  };
4589
4923
  }
4590
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
+ /**
4591
4940
  * The command's core, exported for the unit tests. `seams` exists only so tests can
4592
4941
  * shrink the retry backoff — the CLI itself never passes it (PoC parity: 4 attempts,
4593
4942
  * 5 s linear).
4594
4943
  */
4595
4944
  async function runEvalCommand(pathsOrUrls, options, seams = {}) {
4596
- const override = parseOverride(options);
4945
+ const override = parsePair("llm", options.llmProvider, options.llmModel);
4597
4946
  if (!override.ok) {
4598
4947
  failJson({ message: override.message });
4599
4948
  return;
4600
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
+ }
4601
4960
  const expansion = expandSources(pathsOrUrls);
4602
4961
  if (!expansion.ok) {
4603
4962
  failJson({ message: expansion.message });
@@ -4641,8 +5000,14 @@ async function runEvalCommand(pathsOrUrls, options, seams = {}) {
4641
5000
  }
4642
5001
  {
4643
5002
  const totalCases = [...checked.values()].reduce((sum, file) => sum + file.caseCount, 0);
4644
- process.stderr.write(`${totalCases} case(s) × ${repeats} repeat(s) = ${totalCases * repeats} grading call(s)\n`);
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"));
4645
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
+ };
4646
5011
  let fileIndex = 0;
4647
5012
  for (const file of files) {
4648
5013
  fileIndex += 1;
@@ -4653,20 +5018,31 @@ async function runEvalCommand(pathsOrUrls, options, seams = {}) {
4653
5018
  model: check.quizDump.llm.model
4654
5019
  };
4655
5020
  const effective = override.llm ?? quizLlm;
5021
+ const judgeLlm = judgeOverride.llm ?? effective;
4656
5022
  const llm = {
4657
5023
  ...effective,
4658
- ...override.llm ? { overrides: quizLlm } : {}
5024
+ ...override.llm ? { overrides: quizLlm } : {},
5025
+ ...judging ? { judge: {
5026
+ ...judgeLlm,
5027
+ overridden: judgeOverride.llm !== void 0
5028
+ } } : {}
4659
5029
  };
4660
- const prefix = files.length > 1 ? `(${fileIndex}/${files.length}) ${check.evalFile.id}: ` : "";
4661
- file.result = await runEval("quiz", check, {
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, {
4662
5033
  grade: makeGradeFn(options.server, effective),
5034
+ ...judging ? { judge: makeJudgeFn(options.server, judgeLlm) } : {},
5035
+ judgeBreaker,
5036
+ onJudgeDegraded,
4663
5037
  concurrency,
4664
5038
  repeats,
4665
5039
  llm,
4666
5040
  onProgress: progressWriter(prefix),
4667
5041
  ...seams.retry ? { retry: seams.retry } : {}
4668
5042
  });
5043
+ file.result = result;
4669
5044
  if (process.stderr.isTTY) process.stderr.write("\n");
5045
+ else writeFileDone(label, result);
4670
5046
  }
4671
5047
  const batch = summarizeBatch(files);
4672
5048
  const payload = JSON.stringify(batch, null, 2);
@@ -4693,7 +5069,7 @@ async function runEvalCommand(pathsOrUrls, options, seams = {}) {
4693
5069
  process.exitCode = batchPassed(batch) ? 0 : 1;
4694
5070
  }
4695
5071
  function registerEval(program) {
4696
- 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", `
4697
5073
  Examples:
4698
5074
  # Evaluate one quiz's golden answers
4699
5075
  $ novedu-cli eval ./0010-welcome-quiz.eval.yaml
@@ -4707,6 +5083,12 @@ Examples:
4707
5083
  # How would this rubric perform on another model? (both flags, always together)
4708
5084
  $ novedu-cli eval ./my-quiz.eval.yaml --llm-provider "Azure Foundry" --llm-model gpt-5-mini
4709
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
+
4710
5092
  # Machine-readable, for CI
4711
5093
  $ novedu-cli eval ./my-quiz.eval.yaml --json --out eval-report.json
4712
5094
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@novedu/cli",
3
- "version": "0.21.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": {