@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.
- package/README.md +39 -4
- package/dist/main.js +419 -37
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -104,6 +104,11 @@ each one must get — and `eval` replays them through the **real grader**, then
|
|
|
104
104
|
what it actually did. This is the one command that both **runs the model** and needs
|
|
105
105
|
you signed in (`novedu-cli login`); everything else about it is local.
|
|
106
106
|
|
|
107
|
+
It checks **both halves** of a grading: your `expect` gates the **verdict**, and an LLM
|
|
108
|
+
**feedback judge** audits the **feedback text** the student would have read — measured
|
|
109
|
+
against the quiz's own grading prompt, so there is nothing extra to author. Flagged
|
|
110
|
+
feedback is **reported, never a failure**.
|
|
111
|
+
|
|
107
112
|
```yaml
|
|
108
113
|
# sorting-quiz.eval.yaml
|
|
109
114
|
# yaml-language-server: $schema=https://raw.githubusercontent.com/Teaching-HTL-Leonding/novedu-chat-mvp/refs/heads/main/activities/evals/eval-yaml.schema.json
|
|
@@ -134,6 +139,13 @@ npx @novedu/cli eval ./sorting-quiz.eval.yaml --repeats 3
|
|
|
134
139
|
npx @novedu/cli eval ./sorting-quiz.eval.yaml \
|
|
135
140
|
--llm-provider "Azure Foundry" --llm-model gpt-5-mini
|
|
136
141
|
|
|
142
|
+
# A strong judge over the quiz's own grader — the recommended pairing
|
|
143
|
+
npx @novedu/cli eval ./sorting-quiz.eval.yaml \
|
|
144
|
+
--judge-llm-provider "Azure Foundry" --judge-llm-model gpt-5.6-terra
|
|
145
|
+
|
|
146
|
+
# Verdicts only: half the LLM calls, for a cheap smoke run
|
|
147
|
+
npx @novedu/cli eval ./sorting-quiz.eval.yaml --no-judge-feedback
|
|
148
|
+
|
|
137
149
|
# Machine-readable, for CI
|
|
138
150
|
npx @novedu/cli eval ./sorting-quiz.eval.yaml --json --out eval-report.json
|
|
139
151
|
|
|
@@ -163,14 +175,37 @@ npx @novedu/cli eval ./sorting-quiz.eval.yaml --report eval-report.md
|
|
|
163
175
|
a broken file instead of aborting the batch. `--json` / `--out` always carry the
|
|
164
176
|
same batch shape `{ files: [...], passed, totals }`, single file or not — `passed`
|
|
165
177
|
is the exit-code verdict, per batch and per file.
|
|
178
|
+
- **The feedback judge.** After each successful grading, an LLM reads the feedback the
|
|
179
|
+
grader wrote and checks it against **that grading's own system prompt** — the course
|
|
180
|
+
rules and the platform frame already in it. It reports four kinds of problem:
|
|
181
|
+
`contradicts_verdict` (praise on a wrong answer, or vice versa), `misstates_facts`,
|
|
182
|
+
`ignores_instructions` (most commonly: not stating the correct answer when the verdict
|
|
183
|
+
is not `correct`, or the wrong language), and `leaks_rubric` (quoting the grading
|
|
184
|
+
criteria at the student). Flags show as **`flagged feedback`** in the terminal report,
|
|
185
|
+
a **Flagged** column plus a **"Flagged feedback"** section in the Markdown report, and
|
|
186
|
+
`totals.feedbackFlagged` / `repeats[].judge.issues` in the JSON. They never change the
|
|
187
|
+
exit code.
|
|
188
|
+
- **Choosing the judge.** By default the judge runs on the same model as the grader.
|
|
189
|
+
`--judge-llm-provider` + `--judge-llm-model` (both or neither) point it at another one,
|
|
190
|
+
which is the **recommended** setup: a strong judge over a smaller grader finds real
|
|
191
|
+
problems, while a small model judging itself mostly produces noise. `--no-judge-feedback`
|
|
192
|
+
turns judging off and halves the LLM calls; combining the two is rejected as
|
|
193
|
+
contradictory. Because judging roughly doubles the cost, the run's scope line says so
|
|
194
|
+
up front: `27 case(s) × 3 repeat(s) = 81 grading + 81 judge call(s)`.
|
|
195
|
+
- **If the judge itself fails**, the run **degrades instead of aborting**: after three
|
|
196
|
+
consecutive judge failures it stops judging (one warning on stderr) and finishes the
|
|
197
|
+
grading normally. Your verdict results are complete; the feedback simply was not
|
|
198
|
+
audited: files that judged nothing show an em dash in the Flagged column rather than a
|
|
199
|
+
`0`, so "unchecked" never reads as "clean".
|
|
166
200
|
- **`--report <file.md>`** additionally writes a readable **Markdown** report — an
|
|
167
201
|
overview table over the files, then the question, the golden answer and the grader's
|
|
168
|
-
feedback for every mismatched, errored or unstable case
|
|
202
|
+
feedback for every mismatched, errored or unstable case, plus the "Flagged feedback"
|
|
203
|
+
section (passing, unflagged cases stay in the
|
|
169
204
|
JSON). It composes with `--json` / `--out` and leaves stdout untouched.
|
|
170
205
|
- **Token totals.** The reports show what a run cost —
|
|
171
|
-
`tokens: 15,420 in (12,300 cached) / 2,810 out` — summed over the grading
|
|
172
|
-
**succeeded**, so it is a lower bound (a retried or failed call reports
|
|
173
|
-
nothing at all is printed when the server reports no usage.
|
|
206
|
+
`tokens: 15,420 in (12,300 cached) / 2,810 out` — summed over the grading **and** judge
|
|
207
|
+
calls that **succeeded**, so it is a lower bound (a retried or failed call reports
|
|
208
|
+
nothing), and nothing at all is printed when the server reports no usage.
|
|
174
209
|
- **Failure handling**: a 5xx or network hiccup is retried (4 attempts, linear
|
|
175
210
|
backoff); any 4xx is terminal; an auth failure aborts the run with one message; and
|
|
176
211
|
three consecutive errored cases trip a circuit breaker so a down server fails fast.
|
package/dist/main.js
CHANGED
|
@@ -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)
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
4498
|
-
|
|
4499
|
-
|
|
4500
|
-
|
|
4501
|
-
|
|
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:
|
|
4742
|
+
message: `Pass --${flag}-provider and --${flag}-model together, or neither.`
|
|
4504
4743
|
};
|
|
4505
|
-
if (!LLM_PROVIDERS.includes(
|
|
4744
|
+
if (!LLM_PROVIDERS.includes(provider)) return {
|
|
4506
4745
|
ok: false,
|
|
4507
|
-
message: `Unknown
|
|
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
|
|
4513
|
-
model
|
|
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 =
|
|
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
|
-
|
|
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
|
|
4661
|
-
|
|
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.
|
|
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": {
|