@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.
Files changed (3) hide show
  1. package/README.md +39 -4
  2. package/dist/main.js +429 -38
  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
@@ -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) 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
+ }
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
- 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)`));
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
- if (questionText) {
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
- /** The `--llm-provider`/`--llm-model` pair: strictly both-or-nothing, provider checked. */
4489
- function parseOverride(options) {
4490
- const { llmProvider, llmModel } = options;
4491
- if (llmProvider === void 0 && llmModel === void 0) return { ok: true };
4492
- 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 {
4493
4741
  ok: false,
4494
- message: "Pass --llm-provider and --llm-model together, or neither."
4742
+ message: `Pass --${flag}-provider and --${flag}-model together, or neither.`
4495
4743
  };
4496
- if (!LLM_PROVIDERS.includes(llmProvider)) return {
4744
+ if (!LLM_PROVIDERS.includes(provider)) return {
4497
4745
  ok: false,
4498
- 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 ")}.`
4499
4747
  };
4500
4748
  return {
4501
4749
  ok: true,
4502
4750
  llm: {
4503
- provider: llmProvider,
4504
- model: llmModel
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 = parseOverride(options);
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
- 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"));
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 prefix = files.length > 1 ? `(${fileIndex}/${files.length}) ${check.evalFile.id}: ` : "";
4652
- 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, {
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.20.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": {