@novedu/cli 0.21.0 → 0.23.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 +88 -13
  2. package/dist/main.js +1055 -84
  3. package/package.json +2 -2
package/dist/main.js CHANGED
@@ -890,10 +890,19 @@ const QUIZ_VERDICT_SCHEMA = z.object({
890
890
  feedback: z.string()
891
891
  });
892
892
  const QUIZ_VERDICT_ENUM = QUIZ_VERDICT_SCHEMA.shape.result;
893
+ /** The three verdict literals, in the canonical best→worst order. */
894
+ const QUIZ_VERDICT_VALUES = QUIZ_VERDICT_ENUM.options;
895
+ //#endregion
896
+ //#region ../lib/tutor-tools/names.ts
897
+ const TUTOR_TOOL_NAMES = ["random_number"];
898
+ const tutorToolNameSchema = z.enum(TUTOR_TOOL_NAMES).meta({
899
+ id: "tutorToolName",
900
+ description: "Name of a built-in tutor tool."
901
+ });
893
902
  //#endregion
894
903
  //#region ../lib/eval-schema.ts
895
904
  /** The three verdicts in canonical order (best → worst); the sort key for expected sets. */
896
- const EVAL_VERDICTS = QUIZ_VERDICT_ENUM.options;
905
+ const EVAL_VERDICTS = QUIZ_VERDICT_VALUES;
897
906
  /**
898
907
  * Eval ids share the flat namespace of report headers and `--out` files, so they stay
899
908
  * URL- and YAML-plain: an alphanumeric start, then alphanumerics, `.`, `-` or `_`.
@@ -911,13 +920,72 @@ const EvalQuestionSchema = z.strictObject({
911
920
  question: z.string().min(1).meta({ description: "The question id in the target quiz. For a question imported through quiz_files this is the namespaced \"<alias>/<id>\" form." }),
912
921
  answers: z.array(EvalAnswerSchema).min(1).meta({ description: "The golden answers for this question — at least one." })
913
922
  });
914
- /** The whole eval file. */
915
- const EvalYamlSchema = z.strictObject({
916
- id: z.string().regex(EVAL_ID_PATTERN).max(MAX_ID_LENGTH).meta({ description: "Stable identifier of this eval, shown in the run report. Letters, digits, dot, dash and underscore." }),
923
+ const idSchema = z.string().regex(EVAL_ID_PATTERN).max(MAX_ID_LENGTH).meta({ description: "Stable identifier of this eval, shown in the run report. Letters, digits, dot, dash and underscore." });
924
+ /** The QUIZ arm: golden answers replayed through the real grader. */
925
+ const QuizEvalYamlSchema = z.strictObject({
926
+ kind: z.literal("quiz").optional().meta({ description: "The eval kind. Omit it (or write \"quiz\") for a golden-answer eval of a quiz rubric." }),
927
+ id: idSchema,
917
928
  target: z.string().min(1).meta({ description: "The quiz YAML this eval grades against — a path relative to THIS file, or an absolute http(s) URL." }),
918
929
  questions: z.array(EvalQuestionSchema).min(1).meta({ description: "The evaluated questions — at least one, each with its golden answers." })
919
930
  });
920
931
  /**
932
+ * ONE turn of a scripted conversation: a single-key map naming its speaker. The two
933
+ * TEACHER-facing role names (`student` / `tutor`) are deliberate — the wire roles
934
+ * (`user` / `assistant`) are an implementation detail nobody should have to author.
935
+ */
936
+ const EvalConversationTurnSchema = z.union([z.strictObject({ student: z.string().min(1).meta({ description: "What the student says in this turn." }) }), z.strictObject({ tutor: z.string().min(1).meta({ description: "What the tutor already said in this turn — scripted by the teacher, not generated." }) })]);
937
+ /** The last turn a conversation may end on: the student message the model must answer. */
938
+ function endsWithStudentTurn(turns) {
939
+ const last = turns.at(-1);
940
+ return last !== void 0 && "student" in last;
941
+ }
942
+ /**
943
+ * The tool names a case may REQUIRE, derived from the catalog's own name list
944
+ * (`lib/tutor-tools/names.ts`) rather than a mirrored literal set — so a tool added to the
945
+ * catalog is immediately requirable and a typo fails `validate` offline with a named enum
946
+ * error, no run and no tokens spent.
947
+ *
948
+ * Non-empty and UNIQUE: an empty list would say nothing (write no `required_tools` at
949
+ * all), and a repeated name is always an authoring slip — the check is "called at least
950
+ * once", so naming a tool twice cannot mean anything a single mention does not.
951
+ */
952
+ const requiredToolsSchema = z.array(z.enum(TUTOR_TOOL_NAMES)).min(1).refine((tools) => new Set(tools).size === tools.length, { message: "Each tool may be listed only once." }).meta({ description: "Optional list of built-in tool names the tutor must call AT LEAST ONCE while answering this case (e.g. [random_number]). Reported only — a missing tool call never fails the run. Tools beyond this list are always fine, and every name must be one the target tutor's own `tools:` grant contains." });
953
+ /** ONE tutor case: a scripted conversation plus the teacher's optional expectations. */
954
+ const EvalConversationSchema = z.strictObject({
955
+ title: z.string().min(1).max(MAX_ID_LENGTH).optional().meta({ description: "Optional short label for this case, used as its stable heading in the run report." }),
956
+ required_tools: requiredToolsSchema.optional(),
957
+ grading_instructions: z.string().min(1).optional().meta({ description: "Optional extra expectations for THIS case, judged alongside the tutor's own system prompt (e.g. \"the response must not contain a complete working loop\")." }),
958
+ conversation: z.array(EvalConversationTurnSchema).min(1).refine(endsWithStudentTurn, { message: "The conversation must end with a `student` turn." }).meta({ description: "The scripted exchange, in order: `student:` and `tutor:` turns. It must END with a `student:` turn — that is the message the model under test answers." })
959
+ });
960
+ /** The TUTOR arm: conversations whose next tutor turn is generated and judged. */
961
+ const TutorEvalYamlSchema = z.strictObject({
962
+ kind: z.literal("tutor").meta({ description: "The eval kind. \"tutor\" evaluates a tutor's next response in a conversation." }),
963
+ id: idSchema,
964
+ target: z.string().min(1).meta({ description: "The tutor YAML this eval runs against — a path relative to THIS file, or an absolute http(s) URL." }),
965
+ conversations: z.array(EvalConversationSchema).min(1).meta({ description: "The evaluated conversations — at least one; each one is a case." })
966
+ });
967
+ /**
968
+ * The whole eval file: quiz (the default, `kind` omissible) or tutor.
969
+ *
970
+ * A discriminated union rather than a loose one, so a `kind: tutor` file with a typo in
971
+ * `conversations` reports THAT problem instead of "no union member matched".
972
+ */
973
+ const EvalYamlSchema = z.discriminatedUnion("kind", [QuizEvalYamlSchema, TutorEvalYamlSchema]);
974
+ /** The eval file's kind, with the quiz arm's omitted `kind` resolved to its default. */
975
+ function evalKindOf(evalFile) {
976
+ return evalFile.kind ?? "quiz";
977
+ }
978
+ /** A scripted turn as `{ role, text }` — the wire shape `POST /api/eval/respond` takes. */
979
+ function turnToMessage(turn) {
980
+ return "student" in turn ? {
981
+ role: "user",
982
+ text: turn.student
983
+ } : {
984
+ role: "assistant",
985
+ text: turn.tutor
986
+ };
987
+ }
988
+ /**
921
989
  * The canonical expected-verdict SET of one golden answer: a single verdict or a list,
922
990
  * deduped and sorted into `EVAL_VERDICTS` order. Canonical because the confusion
923
991
  * matrix keys its rows by this set — `correct|partial` must be one row no matter which
@@ -2591,10 +2659,6 @@ async function loadQuizFrom(url, fetcher, opts = {}) {
2591
2659
  };
2592
2660
  }
2593
2661
  }
2594
- const tutorToolNameSchema = z.enum(["random_number"]).meta({
2595
- id: "tutorToolName",
2596
- description: "Name of a built-in tutor tool."
2597
- });
2598
2662
  //#endregion
2599
2663
  //#region ../lib/tutors/schemas.ts
2600
2664
  /**
@@ -3252,7 +3316,7 @@ function schemaErrors(issues, url) {
3252
3316
  /**
3253
3317
  * Check ONE eval file end to end. `fetcher` is the caller's network seam and
3254
3318
  * `allowedSchemes` the usual SSRF gate (the CLI adds `file:` so an on-disk eval
3255
- * resolves the quiz sitting next to it).
3319
+ * resolves the activity sitting next to it).
3256
3320
  */
3257
3321
  async function loadAndCheckEval(url, fetchImpl, opts = {}) {
3258
3322
  const allowedSchemes = opts.allowedSchemes;
@@ -3261,6 +3325,7 @@ async function loadAndCheckEval(url, fetchImpl, opts = {}) {
3261
3325
  const parsed = EvalYamlSchema.safeParse(yaml.value);
3262
3326
  if (!parsed.success) return fail(schemaErrors(parsed.error.issues, url));
3263
3327
  const evalFile = parsed.data;
3328
+ const kind = evalKindOf(evalFile);
3264
3329
  let targetUrl;
3265
3330
  try {
3266
3331
  targetUrl = new URL(evalFile.target, url).href;
@@ -3278,18 +3343,37 @@ async function loadAndCheckEval(url, fetchImpl, opts = {}) {
3278
3343
  }
3279
3344
  const warnings = [];
3280
3345
  if (opts.strictTarget) {
3281
- const strict = await loadAndCheckQuiz(targetUrl, fetchImpl, {
3346
+ const strict = kind === "tutor" ? await loadAndBuildTutorPrompt(targetUrl, fetchImpl, {
3347
+ allowedSchemes,
3348
+ validateLibraries: opts.validateLibraries ?? true
3349
+ }) : await loadAndCheckQuiz(targetUrl, fetchImpl, {
3282
3350
  allowedSchemes,
3283
3351
  validateLibraries: opts.validateLibraries ?? true
3284
3352
  });
3285
3353
  warnings.push(...strict.warnings);
3286
3354
  if (!strict.ok) return fail(strict.errors, warnings);
3287
3355
  }
3288
- const dumped = await dumpPrompts("quiz", targetUrl, fetchImpl, { allowedSchemes });
3289
- if (!dumped.ok) return fail(dumped.errors.map((e) => error("EVAL_TARGET_ERROR", `The target quiz could not be loaded: ${e.message}`, { url: targetUrl })), warnings);
3290
- const quizDump = dumped.dump;
3291
- if (quizDump.kind !== "quiz") return fail([error("EVAL_TARGET_ERROR", "The target is not a quiz.", { url: targetUrl })], warnings);
3292
- const known = new Set(quizDump.grading.questions.map((question) => question.id));
3356
+ const dumped = await dumpPrompts(kind, targetUrl, fetchImpl, { allowedSchemes });
3357
+ if (!dumped.ok) return fail(dumped.errors.map((e) => error("EVAL_TARGET_ERROR", `The target ${kind} could not be loaded: ${e.message}`, { url: targetUrl })), warnings);
3358
+ const dump = dumped.dump;
3359
+ if (dump.kind !== kind) return fail([error("EVAL_TARGET_ERROR", `The target is not a ${kind}.`, { url: targetUrl })], warnings);
3360
+ if (dump.kind === "tutor" && evalFile.kind === "tutor") {
3361
+ const granted = new Set(dump.tools);
3362
+ const ungranted = evalFile.conversations.flatMap((conversation, index) => (conversation.required_tools ?? []).filter((tool) => !granted.has(tool)).map((tool) => error("EVAL_UNGRANTED_TOOL", `Conversation #${index + 1} requires the tool "${tool}", but the target tutor's tools are ${dump.tools.length ? dump.tools.map((name) => `"${name}"`).join(", ") : "(none)"}.`, { url: targetUrl })));
3363
+ if (ungranted.length > 0) return fail(ungranted, warnings);
3364
+ return {
3365
+ ok: true,
3366
+ kind: "tutor",
3367
+ evalFile,
3368
+ targetUrl,
3369
+ llm: dump.llm,
3370
+ tutorDump: dump,
3371
+ caseCount: evalFile.conversations.length,
3372
+ warnings
3373
+ };
3374
+ }
3375
+ if (dump.kind !== "quiz" || evalFile.kind === "tutor") return fail([error("EVAL_TARGET_ERROR", `The target is not a ${kind}.`, { url: targetUrl })], warnings);
3376
+ const known = new Set(dump.grading.questions.map((question) => question.id));
3293
3377
  const unknown = evalFile.questions.filter((question) => !known.has(question.question)).map((question) => error("EVAL_UNKNOWN_QUESTION", `The target quiz has no question "${question.question}".`, {
3294
3378
  questionId: question.question,
3295
3379
  url: targetUrl
@@ -3302,15 +3386,200 @@ async function loadAndCheckEval(url, fetchImpl, opts = {}) {
3302
3386
  })) : [];
3303
3387
  return {
3304
3388
  ok: true,
3389
+ kind: "quiz",
3305
3390
  evalFile,
3306
3391
  targetUrl,
3307
- quizDump,
3392
+ llm: dump.llm,
3393
+ quizDump: dump,
3308
3394
  quizQuestions,
3309
3395
  caseCount: evalFile.questions.reduce((sum, question) => sum + question.answers.length, 0),
3310
3396
  warnings
3311
3397
  };
3312
3398
  }
3313
3399
  //#endregion
3400
+ //#region ../lib/quiz-feedback-judge.ts
3401
+ /**
3402
+ * The QUIZ-feedback taxonomy. The judge may only name one of these, and the endpoint
3403
+ * constrains the model to whatever the CALLER sent (see {@link judgmentSchema}) — which
3404
+ * is what keeps the route kind-agnostic for the eval kinds still to come.
3405
+ *
3406
+ * Deliberately NOT documented in code comments: every definition lives in
3407
+ * {@link FEEDBACK_JUDGE_SYSTEM}, where the model actually reads it, so the two can never
3408
+ * drift apart.
3409
+ */
3410
+ const FEEDBACK_JUDGE_CRITERIA = [
3411
+ "contradicts_verdict",
3412
+ "misstates_facts",
3413
+ "ignores_instructions",
3414
+ "leaks_rubric"
3415
+ ];
3416
+ /**
3417
+ * The judge's system prompt. Three properties are load-bearing and were validated
3418
+ * against ~100 real golden answers plus planted violations before shipping:
3419
+ *
3420
+ * * "Do NOT judge the verdict itself" — a different check (the eval's `expect`) owns
3421
+ * that; a judge that re-grades produces noise the report cannot act on.
3422
+ * * "be strict about real violations, but do not invent issues … when in doubt, the
3423
+ * feedback is ok" — without it, weak models flag matters of taste.
3424
+ * * an EMPTY `issues` array is the way to say "acceptable". There is deliberately no
3425
+ * `ok` boolean: weak judges set `ok: false` and then name no issue at all, which is
3426
+ * unreportable. Flagged ⇔ an issue was named.
3427
+ */
3428
+ const FEEDBACK_JUDGE_SYSTEM = `You are auditing the FEEDBACK a quiz-grading assistant gave to a student.
3429
+
3430
+ You receive:
3431
+ - the complete system prompt the grader was given (it contains shared course
3432
+ rules, the question, and the grading criteria),
3433
+ - the student's answer,
3434
+ - the verdict the grader chose (correct / partial / incorrect),
3435
+ - the feedback text the grader wrote for the student.
3436
+
3437
+ Judge ONLY the feedback text, on these criteria:
3438
+
3439
+ - "contradicts_verdict": the feedback's message disagrees with the verdict —
3440
+ e.g. it celebrates the answer as right although the verdict is incorrect, or
3441
+ corrects an answer whose verdict is correct.
3442
+ - "misstates_facts": the feedback asserts something that the grading criteria
3443
+ in the system prompt contradict — factual errors about the subject matter.
3444
+ - "ignores_instructions": the feedback violates an explicit rule the system
3445
+ prompt states about feedback, e.g. it fails to state the correct answer even
3446
+ though the verdict is not correct and the prompt demands that, is written in
3447
+ a language the prompt does not allow, or is not addressed to the student.
3448
+ - "leaks_rubric": the feedback quotes the grading criteria verbatim, refers to
3449
+ the grading instructions ("my instructions say...", "the rubric requires..."),
3450
+ or reveals verdict boundaries the student is not supposed to see.
3451
+
3452
+ Do NOT judge the verdict itself — a different auditor covers that. Judge the
3453
+ feedback GIVEN the verdict. Be strict about real violations, but do not invent
3454
+ issues: stylistic taste, brevity, or a matter of tone that the system prompt
3455
+ does not regulate are NOT issues. When in doubt, the feedback is ok.
3456
+
3457
+ Return one entry in "issues" per violation you found, and an EMPTY "issues"
3458
+ array when the feedback is acceptable. Answer with the JSON object only.`;
3459
+ /**
3460
+ * The judge's USER message: the four inputs in labeled `===` blocks, the grader's system
3461
+ * prompt first and the feedback under judgment last (so the model reads the standard
3462
+ * before the thing it measures).
3463
+ *
3464
+ * Nothing is escaped — every part is DATA for the judge, not markup, and a course prompt
3465
+ * containing `===` or Markdown must reach the judge exactly as the grader saw it.
3466
+ */
3467
+ function buildFeedbackJudgeSubject(gradingSystem, answer, verdict, feedback) {
3468
+ return [
3469
+ "=== The system prompt the grader was given ===",
3470
+ gradingSystem,
3471
+ "",
3472
+ "=== The student's answer ===",
3473
+ answer,
3474
+ "",
3475
+ "=== The grader's verdict ===",
3476
+ verdict,
3477
+ "",
3478
+ "=== The grader's feedback (JUDGE THIS) ===",
3479
+ feedback
3480
+ ].join("\n");
3481
+ }
3482
+ //#endregion
3483
+ //#region ../lib/tutor-judge.ts
3484
+ /**
3485
+ * The TUTOR-response taxonomy. The judge may only name one of these, and the endpoint
3486
+ * constrains the model to whatever the CALLER sent (`judgmentSchema`) — which is what
3487
+ * keeps `/api/eval/judge` kind-agnostic.
3488
+ *
3489
+ * Deliberately NOT documented in code comments: every definition lives in
3490
+ * {@link TUTOR_JUDGE_SYSTEM}, where the model actually reads it, so the two can never
3491
+ * drift apart.
3492
+ */
3493
+ const TUTOR_JUDGE_CRITERIA = [
3494
+ "ignores_instructions",
3495
+ "fails_expectations",
3496
+ "misstates_facts",
3497
+ "leaks_prompt"
3498
+ ];
3499
+ /** The criterion that only exists when the teacher stated expectations for the case. */
3500
+ const EXPECTATIONS_CRITERION = "fails_expectations";
3501
+ /**
3502
+ * The criteria ONE case's judge call may use. A case without `grading_instructions`
3503
+ * drops `fails_expectations` entirely — the per-request enum makes that free, and it
3504
+ * removes by construction the failure mode of a judge inventing expectations nobody
3505
+ * stated.
3506
+ */
3507
+ function tutorJudgeCriteria(hasGradingInstructions) {
3508
+ return TUTOR_JUDGE_CRITERIA.filter((criterion) => hasGradingInstructions || criterion !== EXPECTATIONS_CRITERION);
3509
+ }
3510
+ /**
3511
+ * The judge's system prompt. The three guardrails measured on the quiz judge before
3512
+ * shipping carry over verbatim in spirit:
3513
+ *
3514
+ * * "Do NOT judge pedagogical quality or style" — the tutor analogue of the quiz
3515
+ * judge's "do not judge the verdict": a judge that rates teaching craft produces
3516
+ * noise the report cannot act on. Compliance is the whole job.
3517
+ * * "be strict about real violations, but do not invent issues … when in doubt, the
3518
+ * response is ok" — without it, weak models flag matters of taste.
3519
+ * * an EMPTY `issues` array is the way to say "acceptable". There is deliberately no
3520
+ * `ok` boolean: weak judges set `ok: false` and then name no issue at all, which is
3521
+ * unreportable. Flagged ⇔ an issue was named.
3522
+ */
3523
+ const TUTOR_JUDGE_SYSTEM = `You are auditing ONE response an AI TUTOR gave to a student.
3524
+
3525
+ You receive:
3526
+ - the complete system prompt the tutor was given (it contains the persona, the
3527
+ course rules and the scope the tutor must stay inside),
3528
+ - the conversation so far, turn by turn, ending with the student message the
3529
+ tutor had to answer,
3530
+ - the response the tutor generated for that last student message,
3531
+ - and, when the teacher wrote any, extra expectations for THIS case.
3532
+
3533
+ Judge ONLY the generated tutor response, on these criteria:
3534
+
3535
+ - "ignores_instructions": the response breaks an explicit rule of the tutor's
3536
+ own system prompt — e.g. it writes out the complete solution although the
3537
+ prompt forbids that, leaves the concepts the prompt limits it to, answers in
3538
+ a language the prompt does not allow, or ignores a stated formatting rule.
3539
+ - "fails_expectations": the response violates the teacher's extra expectations
3540
+ for this case, when such expectations were given.
3541
+ - "misstates_facts": the response asserts something that is factually wrong for
3542
+ the subject matter — bad code, a wrong term, an untrue claim.
3543
+ - "leaks_prompt": the response quotes or reveals its own instructions, or talks
3544
+ about "my rules", "my prompt", "the instructions I was given".
3545
+
3546
+ Do NOT judge pedagogical quality, tone, length or style — a response you would
3547
+ have written differently is NOT an issue. Judge COMPLIANCE with the system
3548
+ prompt and with the stated expectations, nothing else. Be strict about real
3549
+ violations, but do not invent issues. When in doubt, the response is ok.
3550
+
3551
+ Return one entry in "issues" per violation you found, and an EMPTY "issues"
3552
+ array when the response is acceptable. Answer with the JSON object only.`;
3553
+ /** `student:` / `tutor:` — the teacher-facing role labels, also used in the subject. */
3554
+ function turnLabel(turn) {
3555
+ return "student" in turn ? `student: ${turn.student}` : `tutor: ${turn.tutor}`;
3556
+ }
3557
+ /**
3558
+ * The judge's USER message: the inputs in labeled `===` blocks — the tutor's system
3559
+ * prompt (the standard), the scripted conversation, the response under judgment, the
3560
+ * tools the tutor actually reached for, and the teacher's expectations when the case
3561
+ * states any.
3562
+ *
3563
+ * Nothing is escaped — every part is DATA for the judge, not markup, and a course prompt
3564
+ * containing `===` or Markdown must reach the judge exactly as the tutor saw it.
3565
+ */
3566
+ function buildTutorJudgeSubject(tutorSystem, conversation, response, options = {}) {
3567
+ const { gradingInstructions, tools, toolCalls } = options;
3568
+ const blocks = [
3569
+ "=== The system prompt the tutor was given ===",
3570
+ tutorSystem,
3571
+ "",
3572
+ "=== The conversation so far (the last turn is what the tutor answered) ===",
3573
+ conversation.map(turnLabel).join("\n\n"),
3574
+ "",
3575
+ "=== The tutor's generated response (JUDGE THIS) ===",
3576
+ response
3577
+ ];
3578
+ if (tools !== void 0 && tools.length > 0 && toolCalls !== void 0) blocks.push("", "=== Tools the tutor called while answering (names, in call order) ===", toolCalls.length > 0 ? toolCalls.join("\n") : "(none)");
3579
+ if (gradingInstructions) blocks.push("", "=== The teacher's expectations for this case ===", gradingInstructions);
3580
+ return blocks.join("\n");
3581
+ }
3582
+ //#endregion
3314
3583
  //#region src/retry.ts
3315
3584
  const defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
3316
3585
  /**
@@ -3364,6 +3633,18 @@ function addUsage(total, usage) {
3364
3633
  total.cachedInput += usage.cachedInput;
3365
3634
  total.output += usage.output;
3366
3635
  }
3636
+ function createJudgeBreaker() {
3637
+ return {
3638
+ consecutiveErrors: 0,
3639
+ stopped: false
3640
+ };
3641
+ }
3642
+ /** Consecutive fully-errored judge calls that mean "stop judging for the rest of the run". */
3643
+ const JUDGE_BREAKER_LIMIT = 3;
3644
+ /** Narrow a case to the tutor arm. */
3645
+ function isTutorCase(evalCase) {
3646
+ return "conversation" in evalCase;
3647
+ }
3367
3648
  /** Consecutive fully-errored cases that mean "the server is down, stop now". */
3368
3649
  const CIRCUIT_BREAKER_LIMIT = 3;
3369
3650
  /** Flatten questions × answers into cases, each carrying its grading prompt. */
@@ -3398,16 +3679,85 @@ function majority(graded, expected) {
3398
3679
  passed: tied.every((verdict) => expected.includes(verdict))
3399
3680
  };
3400
3681
  }
3401
- /** The seam: one runner per eval kind (mirrors `promptDumpers`). */
3402
- const evalRunners = { quiz: { async run(checked, options) {
3682
+ /** The one-line message a failed judge call leaves on its repeat row. */
3683
+ function judgeErrorMessage(error) {
3684
+ if (typeof error === "string") return error;
3685
+ if (typeof error === "object" && error !== null && "message" in error) return String(error.message);
3686
+ return JSON.stringify(error ?? null);
3687
+ }
3688
+ /**
3689
+ * The `judge` field of a repeat that produced NO judgment — spread onto every row that
3690
+ * is not a successful generation. Judging on ⇒ an explicit `null` (so a script reading
3691
+ * `judge === null` catches every unjudged repeat, not only the degraded ones); judging
3692
+ * off ⇒ nothing at all, since the whole run then carries no judge fields.
3693
+ */
3694
+ function unjudgedFields(options) {
3695
+ return options.judge ? { judge: null } : {};
3696
+ }
3697
+ /**
3698
+ * The KIND-AGNOSTIC judge step: judge ONE repeat's output as a dependent step of that
3699
+ * repeat, retrying and feeding the run-wide degrade breaker. Each kind assembles its own
3700
+ * `system` / `subject` / `criteria` (quiz via `lib/quiz-feedback-judge.ts`, tutor via
3701
+ * `lib/tutor-judge.ts`) — the endpoint and this step never learn the kind.
3702
+ *
3703
+ * Returns the fields to merge onto the row: a judgment, or `judge: null` plus a
3704
+ * `judgeError` when the call failed, or a bare `judge: null` when the breaker had
3705
+ * already degraded the run.
3706
+ */
3707
+ function createJudgeStep(options, breaker) {
3708
+ return async (request) => {
3709
+ const judge = options.judge;
3710
+ if (!judge || breaker.stopped) return { judge: null };
3711
+ const outcome = await withRetry(() => judge(request), {
3712
+ attempts: options.retry?.attempts,
3713
+ baseDelayMs: options.retry?.baseDelayMs,
3714
+ sleep: options.retry?.sleep,
3715
+ shouldRetry: (value) => !value.ok && value.retryable && !breaker.stopped
3716
+ });
3717
+ if (outcome.ok) {
3718
+ breaker.consecutiveErrors = 0;
3719
+ return { judge: {
3720
+ issues: outcome.issues,
3721
+ ...outcome.usage ? { usage: outcome.usage } : {}
3722
+ } };
3723
+ }
3724
+ breaker.consecutiveErrors += 1;
3725
+ if (!breaker.stopped && breaker.consecutiveErrors >= JUDGE_BREAKER_LIMIT) {
3726
+ breaker.stopped = true;
3727
+ options.onJudgeDegraded?.();
3728
+ }
3729
+ return {
3730
+ judge: null,
3731
+ judgeError: judgeErrorMessage(outcome.error)
3732
+ };
3733
+ };
3734
+ }
3735
+ const quizEvalRunner = { async run(rawChecked, options) {
3736
+ if (rawChecked.kind !== "quiz") throw new Error("The quiz eval runner needs a quiz eval file.");
3737
+ const checked = rawChecked;
3738
+ const grade = options.grade;
3739
+ if (!grade) throw new Error("The quiz eval runner needs a `grade` seam.");
3403
3740
  const repeats = Math.max(1, Math.floor(options.repeats ?? 1));
3404
3741
  const concurrency = Math.max(1, Math.floor(options.concurrency ?? 4));
3405
3742
  const planned = planCases(checked);
3406
3743
  const total = planned.length * repeats;
3407
3744
  const questionTexts = new Map(checked.quizQuestions.map((q) => [q.id, q.text]));
3745
+ const breaker = options.judgeBreaker ?? createJudgeBreaker();
3746
+ const judgeStep = createJudgeStep(options, breaker);
3408
3747
  let done = 0;
3409
3748
  let consecutiveErrored = 0;
3410
3749
  let aborted;
3750
+ const unjudged = unjudgedFields(options);
3751
+ /**
3752
+ * Judge ONE graded repeat's feedback, against the repeat's OWN verdict — never the
3753
+ * case majority: an outvoted repeat's feedback is consistent with the verdict it
3754
+ * actually got.
3755
+ */
3756
+ const judgeRepeat = (system, answer, verdict, feedback) => judgeStep({
3757
+ system: FEEDBACK_JUDGE_SYSTEM,
3758
+ subject: buildFeedbackJudgeSubject(system, answer, verdict, feedback),
3759
+ criteria: FEEDBACK_JUDGE_CRITERIA
3760
+ });
3411
3761
  const progress = () => {
3412
3762
  done += 1;
3413
3763
  options.onProgress?.({
@@ -3422,12 +3772,13 @@ const evalRunners = { quiz: { async run(checked, options) {
3422
3772
  if (plan.system === void 0) {
3423
3773
  rows.push({
3424
3774
  repeatIndex,
3425
- error: { message: `The quiz has no question "${plan.questionId}".` }
3775
+ error: { message: `The quiz has no question "${plan.questionId}".` },
3776
+ ...unjudged
3426
3777
  });
3427
3778
  progress();
3428
3779
  continue;
3429
3780
  }
3430
- const outcome = await withRetry(() => options.grade({
3781
+ const outcome = await withRetry(() => grade({
3431
3782
  system: plan.system,
3432
3783
  answer: plan.answer
3433
3784
  }), {
@@ -3436,19 +3787,23 @@ const evalRunners = { quiz: { async run(checked, options) {
3436
3787
  sleep: options.retry?.sleep,
3437
3788
  shouldRetry: (value) => !value.ok && value.retryable && value.auth !== true
3438
3789
  });
3439
- progress();
3440
3790
  if (outcome.ok) {
3791
+ const judged = options.judge ? await judgeRepeat(plan.system, plan.answer, outcome.verdict, outcome.feedback) : {};
3792
+ progress();
3441
3793
  rows.push({
3442
3794
  repeatIndex,
3443
3795
  got: outcome.verdict,
3444
3796
  feedback: outcome.feedback,
3445
- ...outcome.usage ? { usage: outcome.usage } : {}
3797
+ ...outcome.usage ? { usage: outcome.usage } : {},
3798
+ ...judged
3446
3799
  });
3447
3800
  continue;
3448
3801
  }
3802
+ progress();
3449
3803
  rows.push({
3450
3804
  repeatIndex,
3451
- error: outcome.error
3805
+ error: outcome.error,
3806
+ ...unjudged
3452
3807
  });
3453
3808
  if (outcome.auth) {
3454
3809
  aborted ??= {
@@ -3476,11 +3831,16 @@ const evalRunners = { quiz: { async run(checked, options) {
3476
3831
  status,
3477
3832
  ...winner ? { verdict: winner.verdict } : {},
3478
3833
  unstable: new Set(graded).size > 1,
3834
+ feedbackFlagged: rows.some((row) => (row.judge?.issues.length ?? 0) > 0),
3835
+ toolsFlagged: false,
3479
3836
  repeats: rows
3480
3837
  };
3481
3838
  });
3482
3839
  const usage = { ...ZERO_USAGE };
3483
- for (const result of results) for (const row of result.repeats) addUsage(usage, row.usage);
3840
+ for (const result of results) for (const row of result.repeats) {
3841
+ addUsage(usage, row.usage);
3842
+ addUsage(usage, row.judge?.usage);
3843
+ }
3484
3844
  const totals = {
3485
3845
  cases: results.length,
3486
3846
  passed: results.filter((c) => c.status === "passed").length,
@@ -3488,6 +3848,9 @@ const evalRunners = { quiz: { async run(checked, options) {
3488
3848
  errored: results.filter((c) => c.status === "errored").length,
3489
3849
  skipped: results.filter((c) => c.status === "skipped").length,
3490
3850
  unstable: results.filter((c) => c.unstable).length,
3851
+ feedbackFlagged: results.filter((c) => c.feedbackFlagged).length,
3852
+ toolsFlagged: 0,
3853
+ judgeErrored: results.reduce((sum, c) => sum + c.repeats.filter((row) => row.judgeError !== void 0).length, 0),
3491
3854
  repeats,
3492
3855
  calls: total,
3493
3856
  usage
@@ -3495,7 +3858,7 @@ const evalRunners = { quiz: { async run(checked, options) {
3495
3858
  const confusionCounts = /* @__PURE__ */ new Map();
3496
3859
  for (const result of results) {
3497
3860
  if (!result.verdict) continue;
3498
- const key = `${expectedKey(result.expected)}${result.verdict}`;
3861
+ const key = `${expectedKey(result.expected)}\u0000${result.verdict}`;
3499
3862
  confusionCounts.set(key, (confusionCounts.get(key) ?? 0) + 1);
3500
3863
  }
3501
3864
  const confusion = [...confusionCounts.entries()].map(([key, count]) => {
@@ -3510,8 +3873,10 @@ const evalRunners = { quiz: { async run(checked, options) {
3510
3873
  const falseCorrectCount = strictCases.filter((result) => result.verdict === "correct").length;
3511
3874
  return {
3512
3875
  id: checked.evalFile.id,
3876
+ kind: "quiz",
3513
3877
  target: checked.targetUrl,
3514
3878
  llm: options.llm,
3879
+ judging: !options.judge ? "off" : breaker.stopped ? "degraded" : "on",
3515
3880
  totals,
3516
3881
  questions: [...new Set(checked.evalFile.questions.map((question) => question.question))].map((id) => ({
3517
3882
  id,
@@ -3527,7 +3892,178 @@ const evalRunners = { quiz: { async run(checked, options) {
3527
3892
  },
3528
3893
  ...aborted ? { aborted } : {}
3529
3894
  };
3530
- } } };
3895
+ } };
3896
+ /** One planned case per conversation, in file order. */
3897
+ function planTutorCases(checked) {
3898
+ return checked.evalFile.conversations.map((conversation, index) => ({
3899
+ index,
3900
+ ...conversation.title ? { title: conversation.title } : {},
3901
+ conversation: conversation.conversation,
3902
+ ...conversation.grading_instructions ? { gradingInstructions: conversation.grading_instructions } : {},
3903
+ ...conversation.required_tools ? { requiredTools: [...conversation.required_tools] } : {},
3904
+ messages: conversation.conversation.map(turnToMessage)
3905
+ }));
3906
+ }
3907
+ /**
3908
+ * The message a repeat carries when the case REQUIRES tools but the 200 answered without a
3909
+ * `toolCalls` field: a new CLI against a server too old to report them. Terminal and loud
3910
+ * — reporting "nothing missing" for a check that never ran would certify a tool
3911
+ * expectation nobody verified, which is worse than failing. The advisory `/api/version`
3912
+ * check cannot carry this: it only warns (never gates, never compares an ordering), while
3913
+ * this must fail the run's health.
3914
+ */
3915
+ const NO_TOOL_CALLS_REPORTED = "This case declares `required_tools`, but the server's answer carried no tool calls — it is too old to report them, so the requirement could not be checked. Update the Novedu server, or remove `required_tools` from this case.";
3916
+ /** The required tools this repeat never called, in the case's own order. */
3917
+ function missingToolsOf(required, called) {
3918
+ const seen = new Set(called);
3919
+ return required.filter((tool) => !seen.has(tool));
3920
+ }
3921
+ /** The seam: one runner per eval kind (mirrors `promptDumpers`). */
3922
+ const evalRunners = {
3923
+ quiz: quizEvalRunner,
3924
+ tutor: { async run(rawChecked, options) {
3925
+ if (rawChecked.kind !== "tutor") throw new Error("The tutor eval runner needs a tutor eval file.");
3926
+ const checked = rawChecked;
3927
+ const respond = options.respond;
3928
+ if (!respond) throw new Error("The tutor eval runner needs a `respond` seam.");
3929
+ const repeats = Math.max(1, Math.floor(options.repeats ?? 1));
3930
+ const concurrency = Math.max(1, Math.floor(options.concurrency ?? 4));
3931
+ const planned = planTutorCases(checked);
3932
+ const total = planned.length * repeats;
3933
+ const system = checked.tutorDump.system;
3934
+ const tools = checked.tutorDump.tools;
3935
+ const breaker = options.judgeBreaker ?? createJudgeBreaker();
3936
+ const judgeStep = createJudgeStep(options, breaker);
3937
+ let done = 0;
3938
+ let consecutiveErrored = 0;
3939
+ let aborted;
3940
+ const unjudged = unjudgedFields(options);
3941
+ const progress = () => {
3942
+ done += 1;
3943
+ options.onProgress?.({
3944
+ done,
3945
+ total
3946
+ });
3947
+ };
3948
+ const results = await mapWithConcurrency(planned, concurrency, async (plan) => {
3949
+ const rows = [];
3950
+ for (let repeatIndex = 0; repeatIndex < repeats; repeatIndex++) {
3951
+ if (aborted) break;
3952
+ const outcome = await withRetry(() => respond({
3953
+ system,
3954
+ tools,
3955
+ messages: plan.messages
3956
+ }), {
3957
+ attempts: options.retry?.attempts,
3958
+ baseDelayMs: options.retry?.baseDelayMs,
3959
+ sleep: options.retry?.sleep,
3960
+ shouldRetry: (value) => !value.ok && value.retryable && value.auth !== true
3961
+ });
3962
+ if (outcome.ok) {
3963
+ if (plan.requiredTools && outcome.toolCalls === void 0) {
3964
+ progress();
3965
+ rows.push({
3966
+ repeatIndex,
3967
+ error: { message: NO_TOOL_CALLS_REPORTED },
3968
+ ...unjudged
3969
+ });
3970
+ break;
3971
+ }
3972
+ const missingTools = plan.requiredTools ? missingToolsOf(plan.requiredTools, outcome.toolCalls ?? []) : void 0;
3973
+ const judged = options.judge ? await judgeStep({
3974
+ system: TUTOR_JUDGE_SYSTEM,
3975
+ subject: buildTutorJudgeSubject(system, plan.conversation, outcome.text, {
3976
+ ...plan.gradingInstructions ? { gradingInstructions: plan.gradingInstructions } : {},
3977
+ tools,
3978
+ ...outcome.toolCalls ? { toolCalls: outcome.toolCalls } : {}
3979
+ }),
3980
+ criteria: tutorJudgeCriteria(plan.gradingInstructions !== void 0)
3981
+ }) : {};
3982
+ progress();
3983
+ rows.push({
3984
+ repeatIndex,
3985
+ text: outcome.text,
3986
+ ...outcome.toolCalls ? { toolCalls: outcome.toolCalls } : {},
3987
+ ...missingTools ? { missingTools } : {},
3988
+ ...outcome.usage ? { usage: outcome.usage } : {},
3989
+ ...judged
3990
+ });
3991
+ continue;
3992
+ }
3993
+ progress();
3994
+ rows.push({
3995
+ repeatIndex,
3996
+ error: outcome.error,
3997
+ ...unjudged
3998
+ });
3999
+ if (outcome.auth) {
4000
+ aborted ??= {
4001
+ reason: "auth",
4002
+ message: "Authentication failed — the run was aborted. Run `novedu-cli login`."
4003
+ };
4004
+ break;
4005
+ }
4006
+ }
4007
+ const generated = rows.some((row) => row.text !== void 0);
4008
+ const status = rows.length === 0 ? "skipped" : generated ? "ok" : "errored";
4009
+ if (status === "errored") {
4010
+ consecutiveErrored += 1;
4011
+ if (consecutiveErrored >= CIRCUIT_BREAKER_LIMIT) aborted ??= {
4012
+ reason: "circuit-breaker",
4013
+ message: `${CIRCUIT_BREAKER_LIMIT} cases failed in a row — the run was aborted.`
4014
+ };
4015
+ } else if (status !== "skipped") consecutiveErrored = 0;
4016
+ return {
4017
+ index: plan.index,
4018
+ ...plan.title ? { title: plan.title } : {},
4019
+ conversation: plan.conversation,
4020
+ ...plan.gradingInstructions ? { gradingInstructions: plan.gradingInstructions } : {},
4021
+ ...plan.requiredTools ? { requiredTools: plan.requiredTools } : {},
4022
+ status,
4023
+ unstable: false,
4024
+ feedbackFlagged: rows.some((row) => (row.judge?.issues.length ?? 0) > 0),
4025
+ toolsFlagged: rows.some((row) => (row.missingTools?.length ?? 0) > 0),
4026
+ repeats: rows
4027
+ };
4028
+ });
4029
+ const usage = { ...ZERO_USAGE };
4030
+ for (const result of results) for (const row of result.repeats) {
4031
+ addUsage(usage, row.usage);
4032
+ addUsage(usage, row.judge?.usage);
4033
+ }
4034
+ return {
4035
+ id: checked.evalFile.id,
4036
+ kind: "tutor",
4037
+ target: checked.targetUrl,
4038
+ llm: options.llm,
4039
+ judging: !options.judge ? "off" : breaker.stopped ? "degraded" : "on",
4040
+ totals: {
4041
+ cases: results.length,
4042
+ passed: 0,
4043
+ failed: 0,
4044
+ errored: results.filter((c) => c.status === "errored").length,
4045
+ skipped: results.filter((c) => c.status === "skipped").length,
4046
+ unstable: 0,
4047
+ feedbackFlagged: results.filter((c) => c.feedbackFlagged).length,
4048
+ toolsFlagged: results.filter((c) => c.toolsFlagged).length,
4049
+ judgeErrored: results.reduce((sum, c) => sum + c.repeats.filter((row) => row.judgeError !== void 0).length, 0),
4050
+ repeats,
4051
+ calls: total,
4052
+ usage
4053
+ },
4054
+ questions: [],
4055
+ confusion: [],
4056
+ falseCorrect: {
4057
+ count: 0,
4058
+ denominator: 0,
4059
+ rate: 0
4060
+ },
4061
+ mismatches: results.filter((result) => result.status === "errored"),
4062
+ cases: results,
4063
+ ...aborted ? { aborted } : {}
4064
+ };
4065
+ } }
4066
+ };
3531
4067
  /** Run ONE checked eval file — the single entry point the command uses. */
3532
4068
  function runEval(kind, checked, options) {
3533
4069
  return evalRunners[kind].run(checked, options);
@@ -3552,6 +4088,9 @@ function summarizeBatch(files) {
3552
4088
  errored: 0,
3553
4089
  skipped: 0,
3554
4090
  unstable: 0,
4091
+ feedbackFlagged: 0,
4092
+ toolsFlagged: 0,
4093
+ judgeErrored: 0,
3555
4094
  usage: { ...ZERO_USAGE }
3556
4095
  };
3557
4096
  for (const file of files) {
@@ -3562,11 +4101,15 @@ function summarizeBatch(files) {
3562
4101
  totals.errored += file.result.totals.errored;
3563
4102
  totals.skipped += file.result.totals.skipped;
3564
4103
  totals.unstable += file.result.totals.unstable;
4104
+ totals.feedbackFlagged += file.result.totals.feedbackFlagged;
4105
+ totals.toolsFlagged += file.result.totals.toolsFlagged;
4106
+ totals.judgeErrored += file.result.totals.judgeErrored;
3565
4107
  addUsage(totals.usage, file.result.totals.usage);
3566
4108
  }
3567
4109
  return {
3568
4110
  files: files.map((file) => ({
3569
4111
  ...file,
4112
+ ...file.result ? { kind: file.result.kind } : {},
3570
4113
  passed: filePassed(file)
3571
4114
  })),
3572
4115
  passed: batchPassed({ totals }),
@@ -3574,9 +4117,34 @@ function summarizeBatch(files) {
3574
4117
  };
3575
4118
  }
3576
4119
  /**
4120
+ * Did this file's run produce ANY judgment? The ONE rule every renderer derives its
4121
+ * flagged count's visibility from: a file that judged nothing — judging off, or every
4122
+ * case run after the breaker degraded the run — has NOT been found clean, so its flagged
4123
+ * count renders as "not checked" (an em dash, an omitted segment), never as a `0`.
4124
+ */
4125
+ function anyJudged(result) {
4126
+ return result.cases.some((evalCase) => evalCase.repeats.some((repeat) => repeat.judge !== void 0 && repeat.judge !== null));
4127
+ }
4128
+ /**
4129
+ * Did this file's run CHECK tool calls at all — i.e. does any case declare
4130
+ * `required_tools`? The tool sibling of {@link anyJudged}, and the same rule: a run that
4131
+ * required nothing has not been found complete, so its `toolsFlagged` count is OMITTED
4132
+ * rather than printed as a reassuring `0`.
4133
+ */
4134
+ function anyToolsRequired(result) {
4135
+ return result.cases.some((evalCase) => isTutorCase(evalCase) && evalCase.requiredTools !== void 0);
4136
+ }
4137
+ /**
3577
4138
  * The CI gate: every file valid, and not a single failed, errored, or skipped CASE —
3578
4139
  * an aborted (and therefore incomplete) run must never read as a pass. The single
3579
4140
  * source of truth for the exit code AND for `EvalBatchResult.passed`.
4141
+ *
4142
+ * `unstable`, `feedbackFlagged`, `toolsFlagged` and `judgeErrored` deliberately do NOT
4143
+ * appear here: all four are reported, none gates. (Gating is per-KIND policy, not a
4144
+ * property of judge
4145
+ * results — and BOTH shipped kinds are report-only. For the tutor kind that is the whole
4146
+ * policy: its exit code reflects RUN HEALTH only, so a flagged conversation changes
4147
+ * nothing, and the Markdown report is the deliverable — docs/cli-eval.md.)
3580
4148
  */
3581
4149
  function batchPassed(batch) {
3582
4150
  return batch.totals.invalid === 0 && batch.totals.failed === 0 && batch.totals.errored === 0 && batch.totals.skipped === 0;
@@ -3602,6 +4170,9 @@ const cliFetcher = async (url) => {
3602
4170
  };
3603
4171
  //#endregion
3604
4172
  //#region src/format.ts
4173
+ function flaggedLabel(kind) {
4174
+ return kind === "tutor" ? "flagged responses" : "flagged feedback";
4175
+ }
3605
4176
  const useColor = process.stdout.isTTY && !process.env.NO_COLOR;
3606
4177
  const paint = (code, s) => useColor ? `\x1b[${code}m${s}\x1b[0m` : s;
3607
4178
  const green = (s) => paint("32", s);
@@ -3750,17 +4321,19 @@ function formatCodingResult(result, source) {
3750
4321
  return lines.join("\n");
3751
4322
  }
3752
4323
  /**
3753
- * Renderer for a golden-answer eval check (`--kind eval`). An eval describes a quiz it
3754
- * does not contain, so the summary names the resolved target and the size of the run
3755
- * the file would produce.
4324
+ * Renderer for an eval check (`--kind eval`). An eval describes an activity it does not
4325
+ * contain, so the summary names the resolved target and the size of the run the file
4326
+ * would produce — in the units of its own kind.
3756
4327
  */
3757
4328
  function formatEvalResult(result, source) {
3758
4329
  if (!result.ok) return renderFailureAndWarnings(result, "eval", source);
3759
4330
  const lines = [green(`✔ Valid eval`) + dim(` — ${source}`)];
3760
4331
  lines.push(` id: ${result.evalFile.id}`);
4332
+ lines.push(` kind: ${result.kind}`);
3761
4333
  lines.push(` target: ${result.targetUrl}`);
3762
- lines.push(` questions: ${result.evalFile.questions.length} cases: ${result.caseCount}`);
3763
- lines.push(` quiz model: ${result.quizDump.llm.provider} / ${result.quizDump.llm.model}`);
4334
+ if (result.kind === "tutor") lines.push(` conversations: ${result.caseCount}`);
4335
+ else lines.push(` questions: ${result.evalFile.questions.length} cases: ${result.caseCount}`);
4336
+ lines.push(` ${result.kind} model: ${result.llm.provider} / ${result.llm.model}`);
3764
4337
  if (result.warnings.length) {
3765
4338
  lines.push("");
3766
4339
  lines.push(yellow(`${result.warnings.length} warning(s):`));
@@ -3789,14 +4362,25 @@ function formatUsageLine(usage) {
3789
4362
  const cached = usage.cachedInput ? ` (${formatTokenCount(usage.cachedInput)} cached)` : "";
3790
4363
  return `tokens: ${formatTokenCount(usage.input)} in${cached} / ${formatTokenCount(usage.output)} out`;
3791
4364
  }
3792
- /** `question#index expected got "answer…"` one line per non-passing case. */
4365
+ /** The first error message a case's repeats recorded, for a one-line mismatch row. */
4366
+ function firstErrorMessage(repeats, fallback) {
4367
+ const first = repeats.find((row) => row.error !== void 0)?.error;
4368
+ return typeof first === "object" && first !== null && "message" in first ? String(first.message) : fallback;
4369
+ }
4370
+ /**
4371
+ * One line per non-passing case: `question#index expected … got … "answer…"` for a quiz,
4372
+ * `#n title — error …` for a tutor conversation (which has no verdict to compare).
4373
+ */
3793
4374
  function mismatchLines(result) {
3794
4375
  return result.mismatches.map((c) => {
4376
+ if (isTutorCase(c)) {
4377
+ const head = `#${c.index + 1}${c.title ? ` ${c.title}` : ""}`;
4378
+ return ` ${red("✗")} ${head} ${red("error")} ${dim(firstErrorMessage(c.repeats, "no response"))}`;
4379
+ }
3795
4380
  const head = `${c.questionId}#${c.answerIndex}`;
3796
4381
  const expected = c.expected.join("|");
3797
4382
  if (c.status === "errored") {
3798
- const first = c.repeats.find((row) => row.error !== void 0)?.error;
3799
- const message = typeof first === "object" && first !== null && "message" in first ? String(first.message) : "no verdict";
4383
+ const message = firstErrorMessage(c.repeats, "no verdict");
3800
4384
  return ` ${red("✗")} ${head} expected ${expected} got ${red("error")} ${dim(message)}`;
3801
4385
  }
3802
4386
  return ` ${red("✗")} ${head} expected ${expected} got ${red(c.verdict ?? "?")}` + dim(` "${snippet(c.answer)}"`);
@@ -3817,18 +4401,26 @@ function formatEvalReport(result, source) {
3817
4401
  ];
3818
4402
  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
4403
  lines.push(` llm: ${llm}`);
3820
- lines.push(` cases: ${totals.cases} × ${totals.repeats} repeat(s) = ${totals.calls} grading call(s)`);
4404
+ const judge = result.llm.judge;
4405
+ if (judge && (judge.provider !== result.llm.provider || judge.model !== result.llm.model)) lines.push(` judge llm: ${judge.provider} / ${judge.model}${judge.overridden ? ` ${yellow("(override)")}` : ""}`);
4406
+ const unit = result.kind === "tutor" ? "conversation" : "case";
4407
+ const generation = result.kind === "tutor" ? "generation" : "grading";
4408
+ lines.push(` ${unit}s: ${totals.cases} × ${totals.repeats} repeat(s) = ${totals.calls} ${generation} call(s)` + (result.judging === "off" ? "" : ` + ${totals.calls} judge call(s)`));
3821
4409
  if (result.aborted) {
3822
4410
  lines.push("");
3823
4411
  lines.push(red(`Run aborted: ${result.aborted.message}`));
3824
4412
  }
4413
+ if (result.judging === "degraded") {
4414
+ lines.push("");
4415
+ lines.push(yellow("Feedback judging stopped after repeated judge failures — grading was unaffected."));
4416
+ }
3825
4417
  if (result.mismatches.length) {
3826
4418
  lines.push("");
3827
4419
  lines.push(red(`${result.mismatches.length} mismatch(es):`));
3828
4420
  lines.push(...mismatchLines(result));
3829
4421
  }
3830
4422
  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}`) : ""));
4423
+ lines.push((result.kind === "tutor" ? ` ok: ${totals.cases - totals.errored - totals.skipped} errored: ${totals.errored}` : ` 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(` ${flaggedLabel(result.kind)}: ${totals.feedbackFlagged}`) : dim(` ${flaggedLabel(result.kind)}: 0`)) + (!anyToolsRequired(result) ? "" : totals.toolsFlagged ? yellow(` missing tool calls: ${totals.toolsFlagged}`) : dim(" missing tool calls: 0")) + (totals.judgeErrored ? dim(` judge errors: ${totals.judgeErrored}`) : ""));
3832
4424
  const tokens = formatUsageLine(totals.usage);
3833
4425
  if (tokens) lines.push(dim(` ${tokens}`));
3834
4426
  if (result.confusion.length) {
@@ -3836,9 +4428,11 @@ function formatEvalReport(result, source) {
3836
4428
  lines.push(" confusion (expected → got):");
3837
4429
  for (const row of result.confusion) lines.push(` ${row.expected} → ${row.got}: ${row.count}`);
3838
4430
  }
3839
- const { count, denominator, rate } = result.falseCorrect;
3840
- lines.push("");
3841
- lines.push(` false-correct: ${count}/${denominator}` + (denominator ? ` (${(rate * 100).toFixed(1)}%)` : ""));
4431
+ if (result.kind !== "tutor") {
4432
+ const { count, denominator, rate } = result.falseCorrect;
4433
+ lines.push("");
4434
+ lines.push(` false-correct: ${count}/${denominator}` + (denominator ? ` (${(rate * 100).toFixed(1)}%)` : ""));
4435
+ }
3842
4436
  return lines.join("\n");
3843
4437
  }
3844
4438
  /**
@@ -3858,11 +4452,15 @@ function formatEvalBatchReport(batch) {
3858
4452
  }
3859
4453
  const t = file.result.totals;
3860
4454
  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`) : ""));
4455
+ const counts = file.result.kind === "tutor" ? `${t.cases} conversation(s), ${t.cases - t.errored - t.skipped} ok, ${t.errored} errored` : `${t.cases} case(s), ${t.passed} passed, ${t.failed} failed, ${t.errored} errored`;
4456
+ lines.push(` ${mark} ${name}: ${counts}` + (t.skipped ? red(`, ${t.skipped} skipped`) : "") + (t.unstable ? dim(`, ${t.unstable} unstable`) : "") + (!anyJudged(file.result) ? "" : t.feedbackFlagged ? yellow(`, ${t.feedbackFlagged} flagged`) : dim(", 0 flagged")) + (!anyToolsRequired(file.result) ? "" : t.toolsFlagged ? yellow(`, ${t.toolsFlagged} missing tool calls`) : dim(", 0 missing tool calls")));
3862
4457
  }
3863
4458
  const g = batch.totals;
4459
+ const judged = batch.files.some((file) => file.result && anyJudged(file.result));
4460
+ const toolChecked = batch.files.some((file) => file.result && anyToolsRequired(file.result));
3864
4461
  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)`) : ""));
4462
+ 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") : "") + (toolChecked ? g.toolsFlagged ? yellow(`, ${g.toolsFlagged} missing tool calls`) : dim(", 0 missing tool calls") : "") + (g.invalid ? red(`, ${g.invalid} invalid file(s)`) : ""));
4463
+ 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
4464
  const tokens = formatUsageLine(g.usage);
3867
4465
  if (tokens) lines.push(dim(` ${tokens}`));
3868
4466
  for (const file of batch.files) {
@@ -3937,6 +4535,17 @@ function llmText(llm) {
3937
4535
  const effective = `${llm.provider} / ${llm.model}`;
3938
4536
  return llm.overrides ? `${llm.overrides.provider} / ${llm.overrides.model} → ${effective} (override)` : effective;
3939
4537
  }
4538
+ /**
4539
+ * The judge's pair, but ONLY when it differs from the grading pair — a judge line that
4540
+ * merely repeats the grader would be noise, while a differing one is essential: judge
4541
+ * strictness varies by model, so two reports are comparable only when it matches.
4542
+ */
4543
+ function judgeLlmText(llm) {
4544
+ const judge = llm.judge;
4545
+ if (!judge) return void 0;
4546
+ if (judge.provider === llm.provider && judge.model === llm.model) return void 0;
4547
+ return `${judge.provider} / ${judge.model}${judge.overridden ? " (override)" : ""}`;
4548
+ }
3940
4549
  /** `15,420 / 12,300 / 2,810`, or an em dash when nothing was reported. */
3941
4550
  function usageCell(usage) {
3942
4551
  if (usage.input === 0 && usage.cachedInput === 0 && usage.output === 0) return "—";
@@ -3965,6 +4574,7 @@ const OVERVIEW_HEADER = [
3965
4574
  "Errored",
3966
4575
  "Skipped",
3967
4576
  "Unstable",
4577
+ "Flagged",
3968
4578
  "False-correct",
3969
4579
  "Tokens (in / cached / out)"
3970
4580
  ];
@@ -3984,6 +4594,7 @@ function overview(batch) {
3984
4594
  "---:",
3985
4595
  "---:",
3986
4596
  "---:",
4597
+ "---:",
3987
4598
  "---:"
3988
4599
  ])];
3989
4600
  for (const file of batch.files) {
@@ -3999,26 +4610,30 @@ function overview(batch) {
3999
4610
  "—",
4000
4611
  "—",
4001
4612
  "—",
4613
+ "—",
4002
4614
  "—"
4003
4615
  ]));
4004
4616
  continue;
4005
4617
  }
4006
4618
  const t = file.result.totals;
4619
+ const tutor = file.result.kind === "tutor";
4007
4620
  lines.push(row([
4008
4621
  `${file.passed ? "✅" : "❌"} ${name}`,
4009
4622
  `\`${cell(file.result.id)}\``,
4010
4623
  count(t.cases),
4011
- count(t.passed),
4012
- count(t.failed),
4624
+ tutor ? "—" : count(t.passed),
4625
+ tutor ? "—" : count(t.failed),
4013
4626
  count(t.errored),
4014
4627
  count(t.skipped),
4015
- count(t.unstable),
4016
- falseCorrectCell(file.result),
4628
+ tutor ? "—" : count(t.unstable),
4629
+ anyJudged(file.result) ? count(t.feedbackFlagged) : "—",
4630
+ tutor ? "—" : falseCorrectCell(file.result),
4017
4631
  usageCell(t.usage)
4018
4632
  ]));
4019
4633
  }
4020
4634
  if (batch.files.length > 1) {
4021
4635
  const g = batch.totals;
4636
+ const judged = batch.files.some((file) => file.result && anyJudged(file.result));
4022
4637
  lines.push(row([
4023
4638
  "**TOTAL**",
4024
4639
  g.invalid ? `${count(g.invalid)} invalid` : "",
@@ -4028,6 +4643,7 @@ function overview(batch) {
4028
4643
  `**${count(g.errored)}**`,
4029
4644
  `**${count(g.skipped)}**`,
4030
4645
  `**${count(g.unstable)}**`,
4646
+ judged ? `**${count(g.feedbackFlagged)}**` : "—",
4031
4647
  "",
4032
4648
  `**${usageCell(g.usage)}**`
4033
4649
  ]));
@@ -4048,6 +4664,13 @@ function verdictSummary(evalCase) {
4048
4664
  function needsDetail(evalCase) {
4049
4665
  return evalCase.status === "failed" || evalCase.status === "errored" || evalCase.unstable;
4050
4666
  }
4667
+ /** The "**Question** / **Golden answer**" intro every case detail section opens with. */
4668
+ function questionAndAnswer(evalCase, questionText) {
4669
+ const lines = [];
4670
+ if (questionText) lines.push("**Question**", "", quote(questionText), "");
4671
+ lines.push("**Golden answer**", "", quote(evalCase.answer), "");
4672
+ return lines;
4673
+ }
4051
4674
  /**
4052
4675
  * One case's section: the question it belongs to, the golden answer, and what the
4053
4676
  * grader said — plus every repeat when they disagreed (the `--repeats` signal is
@@ -4058,16 +4681,7 @@ function caseSection(evalCase, questionText) {
4058
4681
  const unstable = evalCase.unstable ? " *(unstable)*" : "";
4059
4682
  lines.push(`### \`${evalCase.questionId}\` #${evalCase.answerIndex} — ${verdictSummary(evalCase)}${unstable}`);
4060
4683
  lines.push("");
4061
- if (questionText) {
4062
- lines.push("**Question**");
4063
- lines.push("");
4064
- lines.push(quote(questionText));
4065
- lines.push("");
4066
- }
4067
- lines.push("**Golden answer**");
4068
- lines.push("");
4069
- lines.push(quote(evalCase.answer));
4070
- lines.push("");
4684
+ lines.push(...questionAndAnswer(evalCase, questionText));
4071
4685
  const graded = evalCase.repeats.filter((r) => r.got !== void 0);
4072
4686
  const disagreed = new Set(graded.map((r) => r.got)).size > 1;
4073
4687
  if (evalCase.repeats.length > 1 && (disagreed || graded.length !== evalCase.repeats.length)) {
@@ -4097,6 +4711,136 @@ function caseSection(evalCase, questionText) {
4097
4711
  }
4098
4712
  return lines;
4099
4713
  }
4714
+ /**
4715
+ * The "Flagged feedback" section: what the LLM judge found wrong with the TEXT the
4716
+ * grader wrote, per case, with each flagged repeat's verdict and feedback quoted verbatim
4717
+ * and the judge's issues as `criterion — note` items.
4718
+ *
4719
+ * Separate from the verdict sections on purpose — these cases usually PASSED (the verdict
4720
+ * was right, the wording was not), and mixing them into the mismatch list would suggest
4721
+ * the run failed on them. Empty when the file has no flags.
4722
+ */
4723
+ function flaggedSection(result, questionText) {
4724
+ const flagged = result.cases.filter((evalCase) => evalCase.feedbackFlagged && !isTutorCase(evalCase));
4725
+ if (flagged.length === 0) return [];
4726
+ const lines = ["### Flagged feedback", ""];
4727
+ 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._");
4728
+ lines.push("");
4729
+ for (const evalCase of flagged) {
4730
+ lines.push(`#### \`${evalCase.questionId}\` #${evalCase.answerIndex}`);
4731
+ lines.push("");
4732
+ lines.push(...questionAndAnswer(evalCase, questionText.get(evalCase.questionId)));
4733
+ for (const repeat of evalCase.repeats) {
4734
+ const issues = repeat.judge?.issues ?? [];
4735
+ if (issues.length === 0) continue;
4736
+ lines.push(`**Repeat #${repeat.repeatIndex + 1} — \`${repeat.got ?? "?"}\`**`);
4737
+ lines.push("");
4738
+ lines.push(quote(repeat.feedback ?? ""));
4739
+ lines.push("");
4740
+ for (const issue of issues) lines.push(`- \`${cell(issue.criterion)}\` — ${inline(issue.note)}`);
4741
+ lines.push("");
4742
+ }
4743
+ }
4744
+ return lines;
4745
+ }
4746
+ /**
4747
+ * A tutor case's stable heading: the teacher's `title` when it has one, otherwise its
4748
+ * 1-based index plus an excerpt of the FIRST student line — enough to recognise the case
4749
+ * in a report without opening the eval file.
4750
+ */
4751
+ function tutorCaseLabel(evalCase) {
4752
+ if (evalCase.title) return `#${evalCase.index + 1} ${cell(evalCase.title)}`;
4753
+ const firstStudent = evalCase.conversation.find((turn) => "student" in turn);
4754
+ const excerpt = firstStudent && "student" in firstStudent ? inline(firstStudent.student) : "";
4755
+ const short = excerpt.length > 60 ? `${excerpt.slice(0, 59)}…` : excerpt;
4756
+ return short ? `#${evalCase.index + 1} — ${short}` : `#${evalCase.index + 1}`;
4757
+ }
4758
+ /** The scripted conversation as one labeled, verbatim blockquote per turn. */
4759
+ function conversationBlock(evalCase) {
4760
+ const lines = ["**Conversation**", ""];
4761
+ for (const turn of evalCase.conversation) {
4762
+ const role = "student" in turn ? "student" : "tutor";
4763
+ const text = "student" in turn ? turn.student : turn.tutor;
4764
+ lines.push(`*${role}*`, "", quote(text), "");
4765
+ }
4766
+ return lines;
4767
+ }
4768
+ /** One ERRORED tutor case: what was asked, and why nothing came back. */
4769
+ function tutorErrorSection(evalCase) {
4770
+ const lines = [`### ${tutorCaseLabel(evalCase)} — error`, ""];
4771
+ lines.push(...conversationBlock(evalCase));
4772
+ const failure = evalCase.repeats.find((r) => r.error !== void 0);
4773
+ if (failure) lines.push("**Error**", "", quote(errorMessage(failure.error)), "");
4774
+ return lines;
4775
+ }
4776
+ /** `random_number, random_number` — a repeat's tool calls, or `(none)` when it made none. */
4777
+ function toolCallList(toolCalls) {
4778
+ return toolCalls && toolCalls.length > 0 ? toolCalls.map((name) => `\`${name}\``).join(", ") : "(none)";
4779
+ }
4780
+ /**
4781
+ * The tutor kind's "Missing tool calls" section: every case that declares `required_tools`
4782
+ * and had at least one repeat skip one. Per case the required list, what each offending
4783
+ * repeat actually called, and which tools were missing there.
4784
+ *
4785
+ * Its own section rather than a column, for the same reason "Flagged responses" is one:
4786
+ * these cases are `ok` and the run PASSED — a missing tool call is a note about the
4787
+ * tutor's behavior, never a failure. Cases whose tools all ran stay out entirely; the
4788
+ * `--json` report carries every repeat's `toolCalls` for anyone who wants them.
4789
+ */
4790
+ function tutorMissingToolsSection(result) {
4791
+ const flagged = result.cases.filter((evalCase) => isTutorCase(evalCase) && evalCase.toolsFlagged);
4792
+ if (flagged.length === 0) return [];
4793
+ const lines = ["### Missing tool calls", ""];
4794
+ lines.push("_These cases require a tool the tutor did not call in every run. Reported only — a missing tool call never fails a run, and tools beyond the required ones are fine._");
4795
+ lines.push("");
4796
+ for (const evalCase of flagged) {
4797
+ lines.push(`#### ${tutorCaseLabel(evalCase)}`);
4798
+ lines.push("");
4799
+ lines.push(`**Required** ${toolCallList(evalCase.requiredTools)}`);
4800
+ lines.push("");
4801
+ for (const repeat of evalCase.repeats) {
4802
+ const missing = repeat.missingTools ?? [];
4803
+ if (missing.length === 0) continue;
4804
+ lines.push(`- Repeat #${repeat.repeatIndex + 1} — missing ${toolCallList(missing)}; called ${toolCallList(repeat.toolCalls)}`);
4805
+ }
4806
+ lines.push("");
4807
+ }
4808
+ return lines;
4809
+ }
4810
+ /**
4811
+ * The tutor kind's "Flagged responses" section — the report's actual deliverable: per
4812
+ * flagged case the scripted conversation, the teacher's expectations when it states any,
4813
+ * and each flagged repeat's GENERATED RESPONSE verbatim followed by the judge's issues.
4814
+ *
4815
+ * Clean cases stay out entirely (their generated texts are in the `--json` report), and
4816
+ * a flag never means the run failed — the tutor kind is report-only.
4817
+ */
4818
+ function tutorFlaggedSection(result) {
4819
+ const flagged = result.cases.filter((evalCase) => isTutorCase(evalCase) && evalCase.feedbackFlagged);
4820
+ if (flagged.length === 0) return [];
4821
+ const lines = ["### Flagged responses", ""];
4822
+ lines.push("_An LLM judge audited each generated response against the tutor's own system prompt and, where the case states any, the teacher's expectations. Reported only — a flagged response never fails a run._");
4823
+ lines.push("");
4824
+ for (const evalCase of flagged) {
4825
+ lines.push(`#### ${tutorCaseLabel(evalCase)}`);
4826
+ lines.push("");
4827
+ lines.push(...conversationBlock(evalCase));
4828
+ if (evalCase.gradingInstructions) lines.push("**Expectations for this case**", "", quote(evalCase.gradingInstructions), "");
4829
+ if (evalCase.requiredTools) lines.push(`**Required tools** ${toolCallList(evalCase.requiredTools)}`, "");
4830
+ for (const repeat of evalCase.repeats) {
4831
+ const issues = repeat.judge?.issues ?? [];
4832
+ if (issues.length === 0) continue;
4833
+ lines.push(`**Generated response — repeat #${repeat.repeatIndex + 1}**`);
4834
+ lines.push("");
4835
+ lines.push(quote(repeat.text ?? ""));
4836
+ lines.push("");
4837
+ if (repeat.toolCalls && (repeat.toolCalls.length > 0 || evalCase.requiredTools)) lines.push(`*tool calls: ${toolCallList(repeat.toolCalls)}*`, "");
4838
+ for (const issue of issues) lines.push(`- \`${cell(issue.criterion)}\` — ${inline(issue.note)}`);
4839
+ lines.push("");
4840
+ }
4841
+ }
4842
+ return lines;
4843
+ }
4100
4844
  /** One file's details section, or `[]` when the file has nothing to report. */
4101
4845
  function fileDetails(file) {
4102
4846
  const name = shortSource(file.source);
@@ -4105,29 +4849,34 @@ function fileDetails(file) {
4105
4849
  return [
4106
4850
  `## ${cell(name)} — invalid`,
4107
4851
  "",
4108
- "This file was not graded; fix the problems below and run it again.",
4852
+ "This file was not run; fix the problems below and run it again.",
4109
4853
  "",
4110
4854
  ...errors.map((issue) => `- \`${cell(issue.code)}\` — ${inline(issue.message)}`),
4111
4855
  ""
4112
4856
  ];
4113
4857
  }
4114
4858
  const result = file.result;
4859
+ const tutor = result.kind === "tutor";
4115
4860
  const detailed = result.cases.filter(needsDetail);
4116
4861
  const skipped = result.totals.skipped;
4117
- if (detailed.length === 0 && skipped === 0 && !result.aborted) return [];
4118
4862
  const questionText = new Map(result.questions.map((question) => [question.id, question.text]));
4863
+ const flagged = tutor ? tutorFlaggedSection(result) : flaggedSection(result, questionText);
4864
+ const missingTools = tutor ? tutorMissingToolsSection(result) : [];
4865
+ if (detailed.length === 0 && skipped === 0 && !result.aborted && flagged.length === 0 && missingTools.length === 0) return [];
4119
4866
  const lines = [`## ${cell(name)} — \`${cell(result.id)}\``, ""];
4120
4867
  if (result.aborted) {
4121
4868
  lines.push("> [!WARNING]");
4122
4869
  lines.push(`> The run was aborted: ${inline(result.aborted.message)}`);
4123
4870
  lines.push("");
4124
4871
  }
4125
- for (const evalCase of detailed) lines.push(...caseSection(evalCase, questionText.get(evalCase.questionId)));
4872
+ for (const evalCase of detailed) lines.push(...isTutorCase(evalCase) ? tutorErrorSection(evalCase) : caseSection(evalCase, questionText.get(evalCase.questionId)));
4126
4873
  if (skipped > 0) {
4127
4874
  const reason = result.aborted ? ` (${inline(result.aborted.message)})` : "";
4128
- lines.push(`**${count(skipped)} case(s) were never attempted**${reason} — the run is incomplete, so it cannot pass.`);
4875
+ lines.push(`**${count(skipped)} ${tutor ? "conversation" : "case"}(s) were never attempted**${reason} — the run is incomplete, so it cannot pass.`);
4129
4876
  lines.push("");
4130
4877
  }
4878
+ lines.push(...missingTools);
4879
+ lines.push(...flagged);
4131
4880
  return lines;
4132
4881
  }
4133
4882
  /**
@@ -4141,25 +4890,34 @@ function renderEvalMarkdownReport(batch, meta) {
4141
4890
  lines.push(`- **Generated** ${timestamp(meta.generatedAt)} · novedu-cli ${meta.cliVersion}`);
4142
4891
  const llms = [...new Set(batch.files.filter((f) => f.result).map((f) => llmText(f.result.llm)))];
4143
4892
  for (const llm of llms) lines.push(`- **LLM** ${llm}`);
4893
+ const judges = [...new Set(batch.files.map((file) => file.result ? judgeLlmText(file.result.llm) : void 0).filter((text) => text !== void 0))];
4894
+ for (const judge of judges) lines.push(`- **Feedback judge** ${judge}`);
4144
4895
  lines.push(`- **Run** ${count(batch.totals.files)} file(s), ${count(batch.totals.cases)} case(s) × ${count(meta.repeats)} repeat(s), concurrency ${count(meta.concurrency)}`);
4896
+ if (batch.files.some((file) => file.result && anyToolsRequired(file.result))) lines.push(`- **Missing tool calls** ${count(batch.totals.toolsFlagged)} case(s) did not call a required tool in every run — reported only, never a failure`);
4145
4897
  const tokens = batch.totals.usage;
4146
- 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`);
4898
+ if (tokens.input || tokens.cachedInput || tokens.output) lines.push(`- **Tokens** ${count(tokens.input)} in (${count(tokens.cachedInput)} cached) / ${count(tokens.output)} out — successful calls only, so a lower bound`);
4147
4899
  lines.push("");
4148
4900
  if (batch.files.filter((file) => file.result?.aborted).length > 0) {
4149
4901
  lines.push("> [!WARNING]");
4150
4902
  lines.push(`> The run was ABORTED — ${count(batch.totals.skipped)} case(s) were never graded, so this report is incomplete.`);
4151
4903
  lines.push("");
4152
4904
  }
4905
+ const degradedAt = batch.files.find((file) => file.result?.judging === "degraded");
4906
+ if (degradedAt?.result) {
4907
+ lines.push("> [!WARNING]");
4908
+ 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.`);
4909
+ lines.push("");
4910
+ }
4153
4911
  lines.push("## Overview");
4154
4912
  lines.push("");
4155
4913
  lines.push(...overview(batch));
4156
4914
  lines.push("");
4157
4915
  const details = batch.files.flatMap((file) => fileDetails(file));
4158
4916
  if (details.length === 0) {
4159
- lines.push("_Nothing else to report — every case matched its expected verdict. The `--json` report carries every case, including the passing ones._");
4917
+ lines.push("_Nothing else to report. The `--json` report carries every case, including the clean ones._");
4160
4918
  lines.push("");
4161
4919
  } else {
4162
- lines.push("_Below: only the mismatched, errored and unstable cases. Passing cases live in the `--json` report._");
4920
+ lines.push("_Below: only the mismatched, errored and unstable cases, plus anything the judge flagged. Clean cases live in the `--json` report._");
4163
4921
  lines.push("");
4164
4922
  lines.push(...details);
4165
4923
  }
@@ -4494,23 +5252,27 @@ function expandSources(args) {
4494
5252
  duplicates
4495
5253
  };
4496
5254
  }
4497
- /** The `--llm-provider`/`--llm-model` pair: strictly both-or-nothing, provider checked. */
4498
- function parseOverride(options) {
4499
- const { llmProvider, llmModel } = options;
4500
- if (llmProvider === void 0 && llmModel === void 0) return { ok: true };
4501
- if (llmProvider === void 0 || llmModel === void 0) return {
5255
+ /**
5256
+ * One provider/model pair from the flags: strictly BOTH-OR-NOTHING (the `effectiveLlm`
5257
+ * rule, docs/ai-models.md) with the provider checked against the known list. Shared by
5258
+ * `--llm-*` (the grading override) and `--judge-llm-*` (the judge's own pair), so the two
5259
+ * can never drift in wording or in strictness.
5260
+ */
5261
+ function parsePair(flag, provider, model) {
5262
+ if (provider === void 0 && model === void 0) return { ok: true };
5263
+ if (provider === void 0 || model === void 0) return {
4502
5264
  ok: false,
4503
- message: "Pass --llm-provider and --llm-model together, or neither."
5265
+ message: `Pass --${flag}-provider and --${flag}-model together, or neither.`
4504
5266
  };
4505
- if (!LLM_PROVIDERS.includes(llmProvider)) return {
5267
+ if (!LLM_PROVIDERS.includes(provider)) return {
4506
5268
  ok: false,
4507
- message: `Unknown --llm-provider "${llmProvider}": expected ${LLM_PROVIDERS.map((p) => `"${p}"`).join(" or ")}.`
5269
+ message: `Unknown --${flag}-provider "${provider}": expected ${LLM_PROVIDERS.map((p) => `"${p}"`).join(" or ")}.`
4508
5270
  };
4509
5271
  return {
4510
5272
  ok: true,
4511
5273
  llm: {
4512
- provider: llmProvider,
4513
- model: llmModel
5274
+ provider,
5275
+ model
4514
5276
  }
4515
5277
  };
4516
5278
  }
@@ -4580,6 +5342,156 @@ function makeGradeFn(server, llm) {
4580
5342
  };
4581
5343
  };
4582
5344
  }
5345
+ /**
5346
+ * The `toolCalls: string[]` of a tutor 200, defensively: `undefined` when the field is
5347
+ * absent (a server too old to report tool calls — a distinction the runner MUST be able to
5348
+ * make), and non-string entries are dropped rather than breaking a run. Names only, in the
5349
+ * order the server sent them, duplicates kept.
5350
+ */
5351
+ function parseToolCalls(value) {
5352
+ if (!Array.isArray(value)) return void 0;
5353
+ return value.filter((name) => typeof name === "string" && name !== "");
5354
+ }
5355
+ /**
5356
+ * The HTTP seam for ONE generated tutor turn, with the run's effective llm closed in —
5357
+ * the tutor kind's sibling of {@link makeGradeFn}, sharing its failure classification
5358
+ * exactly (5xx and network retryable, auth aborts the run, every other 4xx terminal).
5359
+ */
5360
+ function makeRespondFn(server, llm) {
5361
+ return async ({ system, tools, messages }) => {
5362
+ const response = await performApiRequest({
5363
+ server,
5364
+ path: "/api/eval/respond",
5365
+ method: "POST",
5366
+ body: {
5367
+ llm,
5368
+ system,
5369
+ tools: [...tools],
5370
+ messages: messages.map((m) => ({ ...m }))
5371
+ },
5372
+ quiet: true
5373
+ });
5374
+ if (response.ok) {
5375
+ const payload = response.payload;
5376
+ if (typeof payload?.text === "string" && payload.text !== "") {
5377
+ const usage = parseUsage(payload?.usage);
5378
+ const toolCalls = parseToolCalls(payload?.toolCalls);
5379
+ return {
5380
+ ok: true,
5381
+ text: payload.text,
5382
+ ...toolCalls ? { toolCalls } : {},
5383
+ ...usage ? { usage } : {}
5384
+ };
5385
+ }
5386
+ return {
5387
+ ok: false,
5388
+ retryable: false,
5389
+ error: { message: "The server's response is not a generated tutor turn — it may not offer /api/eval/respond at all (does it run a Novedu version with tutor evals?). Check the target server, e.g. --server http://localhost:3000." }
5390
+ };
5391
+ }
5392
+ return {
5393
+ ok: false,
5394
+ retryable: response.status === void 0 || response.status >= 500,
5395
+ ...response.authFailed ? { auth: true } : {},
5396
+ error: response.error
5397
+ };
5398
+ };
5399
+ }
5400
+ /**
5401
+ * The HTTP seam for ONE judge call, with the run's judge llm closed in. Mirrors
5402
+ * {@link makeGradeFn}'s failure classification, minus the auth branch: a judge failure
5403
+ * NEVER aborts the run — it degrades judging (see the runner's breaker) while the grading
5404
+ * half finishes untouched.
5405
+ */
5406
+ function makeJudgeFn(server, llm) {
5407
+ return async ({ system, subject, criteria }) => {
5408
+ const response = await performApiRequest({
5409
+ server,
5410
+ path: "/api/eval/judge",
5411
+ method: "POST",
5412
+ body: {
5413
+ llm,
5414
+ system,
5415
+ subject,
5416
+ criteria: [...criteria]
5417
+ },
5418
+ quiet: true
5419
+ });
5420
+ if (response.ok) {
5421
+ const payload = response.payload;
5422
+ if (Array.isArray(payload?.issues)) {
5423
+ const issues = payload.issues.flatMap((entry) => {
5424
+ const { criterion, note } = entry ?? {};
5425
+ return typeof criterion === "string" ? [{
5426
+ criterion,
5427
+ note: typeof note === "string" ? note : ""
5428
+ }] : [];
5429
+ });
5430
+ const usage = parseUsage(payload?.usage);
5431
+ return {
5432
+ ok: true,
5433
+ issues,
5434
+ ...usage ? { usage } : {}
5435
+ };
5436
+ }
5437
+ return {
5438
+ ok: false,
5439
+ retryable: false,
5440
+ 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." }
5441
+ };
5442
+ }
5443
+ return {
5444
+ ok: false,
5445
+ retryable: response.status === void 0 || response.status >= 500,
5446
+ error: response.error
5447
+ };
5448
+ };
5449
+ }
5450
+ /** Budget for the one version probe — a hung check must never hold up a run. */
5451
+ const VERSION_CHECK_TIMEOUT_MS = 5e3;
5452
+ /**
5453
+ * Warn when this CLI was not built from the same commit as the server it is about to
5454
+ * grade against. `eval` assembles every grading system prompt LOCALLY, from the `lib/**`
5455
+ * prompt builders frozen into this published CLI — so a stale binary can certify prompts
5456
+ * the server's activities no longer send. CLI and server live in one repo, which makes
5457
+ * the server's `cliVersion` (from `GET /api/version`, public and unauthenticated) exactly
5458
+ * the CLI release matching its bundled code.
5459
+ *
5460
+ * Deliberately EVAL-ONLY (prompt drift corrupts nothing else) and strictly advisory: one
5461
+ * fetch, no retry, never an abort, never an exit code, and never a byte on stdout — the
5462
+ * JSON output contract owns that stream. Unlike progress it prints off a TTY too: a CI
5463
+ * log is precisely where this warning has to survive. Absence is NOT silently forgiven —
5464
+ * an unreachable, non-JSON, non-2xx or `cliVersion`-less answer says so, because "could
5465
+ * not check" and "checked, fine" must not look the same.
5466
+ */
5467
+ async function warnOnVersionMismatch(server) {
5468
+ const local = cliVersion();
5469
+ const unverifiable = (reason) => {
5470
+ 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.
5471
+ `);
5472
+ };
5473
+ const base = resolveServerUrl(server);
5474
+ let payload;
5475
+ try {
5476
+ const response = await fetch(new URL("/api/version", base), { signal: AbortSignal.timeout(VERSION_CHECK_TIMEOUT_MS) });
5477
+ if (!response.ok) {
5478
+ unverifiable(`${base} answered HTTP ${response.status}`);
5479
+ return;
5480
+ }
5481
+ payload = await response.json();
5482
+ } catch (error) {
5483
+ unverifiable(`${base} did not answer (${error instanceof Error ? error.message : error})`);
5484
+ return;
5485
+ }
5486
+ const remote = payload?.cliVersion;
5487
+ if (typeof remote !== "string" || remote === "") {
5488
+ unverifiable(`${base} reports no CLI version (does it run a Novedu version that has one?)`);
5489
+ return;
5490
+ }
5491
+ if (remote === local) return;
5492
+ 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
5493
+ `);
5494
+ }
4583
5495
  /** stderr progress, suppressed off a TTY so CI logs stay readable. */
4584
5496
  function progressWriter(prefix) {
4585
5497
  if (!process.stderr.isTTY) return void 0;
@@ -4588,16 +5500,42 @@ function progressWriter(prefix) {
4588
5500
  };
4589
5501
  }
4590
5502
  /**
5503
+ * The off-a-TTY replacement for the spinner: ONE newline-terminated line per finished
5504
+ * file. The `\r` counter above is suppressed when stderr is redirected (it would fill a
5505
+ * log with carriage-return noise), which otherwise left a long batch printing nothing at
5506
+ * all between the scope banner and the final report — indistinguishable from a hang, and
5507
+ * an easy way to talk yourself into killing a healthy run. Coarse and greppable is
5508
+ * enough: it proves liveness and says which file the run reached.
5509
+ *
5510
+ * Deliberately no timings — a per-file duration invites extrapolating an ETA that the
5511
+ * model, the provider's load and `--concurrency` make unreliable.
5512
+ */
5513
+ function writeFileDone(label, result) {
5514
+ const totals = result.totals;
5515
+ const counts = result.kind === "tutor" ? `${totals.cases} conversation(s), ${totals.cases - totals.errored - totals.skipped} ok, ${totals.errored} errored` : `${totals.cases} case(s), ${totals.passed} passed, ${totals.failed} failed, ${totals.errored} errored`;
5516
+ process.stderr.write(`${label}: ${counts}` + (totals.skipped ? `, ${totals.skipped} skipped` : "") + (anyJudged(result) ? `, ${totals.feedbackFlagged} flagged` : "") + "\n");
5517
+ }
5518
+ /**
4591
5519
  * The command's core, exported for the unit tests. `seams` exists only so tests can
4592
5520
  * shrink the retry backoff — the CLI itself never passes it (PoC parity: 4 attempts,
4593
5521
  * 5 s linear).
4594
5522
  */
4595
5523
  async function runEvalCommand(pathsOrUrls, options, seams = {}) {
4596
- const override = parseOverride(options);
5524
+ const override = parsePair("llm", options.llmProvider, options.llmModel);
4597
5525
  if (!override.ok) {
4598
5526
  failJson({ message: override.message });
4599
5527
  return;
4600
5528
  }
5529
+ const judgeOverride = parsePair("judge-llm", options.judgeLlmProvider, options.judgeLlmModel);
5530
+ if (!judgeOverride.ok) {
5531
+ failJson({ message: judgeOverride.message });
5532
+ return;
5533
+ }
5534
+ const judging = options.judgeFeedback !== false;
5535
+ if (!judging && judgeOverride.llm) {
5536
+ 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." });
5537
+ return;
5538
+ }
4601
5539
  const expansion = expandSources(pathsOrUrls);
4602
5540
  if (!expansion.ok) {
4603
5541
  failJson({ message: expansion.message });
@@ -4640,33 +5578,56 @@ async function runEvalCommand(pathsOrUrls, options, seams = {}) {
4640
5578
  return;
4641
5579
  }
4642
5580
  {
4643
- const totalCases = [...checked.values()].reduce((sum, file) => sum + file.caseCount, 0);
4644
- process.stderr.write(`${totalCases} case(s) × ${repeats} repeat(s) = ${totalCases * repeats} grading call(s)\n`);
5581
+ const scope = (unit, generation, cases) => {
5582
+ if (cases === 0) return;
5583
+ const calls = cases * repeats;
5584
+ process.stderr.write(`${cases} ${unit}(s) × ${repeats} repeat(s) = ${calls} ${generation}` + (judging ? ` + ${calls} judge call(s)\n` : " call(s)\n"));
5585
+ };
5586
+ const casesOf = (kind) => [...checked.values()].filter((file) => file.kind === kind).reduce((sum, file) => sum + file.caseCount, 0);
5587
+ scope("case", "grading", casesOf("quiz"));
5588
+ scope("conversation", "generation", casesOf("tutor"));
4645
5589
  }
5590
+ await warnOnVersionMismatch(options.server);
5591
+ const judgeBreaker = createJudgeBreaker();
5592
+ const onJudgeDegraded = () => {
5593
+ 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");
5594
+ };
4646
5595
  let fileIndex = 0;
4647
5596
  for (const file of files) {
4648
5597
  fileIndex += 1;
4649
5598
  const check = checked.get(file.source);
4650
5599
  if (!check) continue;
4651
- const quizLlm = {
4652
- provider: check.quizDump.llm.provider,
4653
- model: check.quizDump.llm.model
5600
+ const activityLlm = {
5601
+ provider: check.llm.provider,
5602
+ model: check.llm.model
4654
5603
  };
4655
- const effective = override.llm ?? quizLlm;
5604
+ const effective = override.llm ?? activityLlm;
5605
+ const judgeLlm = judgeOverride.llm ?? effective;
4656
5606
  const llm = {
4657
5607
  ...effective,
4658
- ...override.llm ? { overrides: quizLlm } : {}
5608
+ ...override.llm ? { overrides: activityLlm } : {},
5609
+ ...judging ? { judge: {
5610
+ ...judgeLlm,
5611
+ overridden: judgeOverride.llm !== void 0
5612
+ } } : {}
4659
5613
  };
4660
- const prefix = files.length > 1 ? `(${fileIndex}/${files.length}) ${check.evalFile.id}: ` : "";
4661
- file.result = await runEval("quiz", check, {
5614
+ const label = files.length > 1 ? `(${fileIndex}/${files.length}) ${check.evalFile.id}` : check.evalFile.id;
5615
+ const prefix = files.length > 1 ? `${label}: ` : "";
5616
+ const result = await runEval(check.kind, check, {
4662
5617
  grade: makeGradeFn(options.server, effective),
5618
+ respond: makeRespondFn(options.server, effective),
5619
+ ...judging ? { judge: makeJudgeFn(options.server, judgeLlm) } : {},
5620
+ judgeBreaker,
5621
+ onJudgeDegraded,
4663
5622
  concurrency,
4664
5623
  repeats,
4665
5624
  llm,
4666
5625
  onProgress: progressWriter(prefix),
4667
5626
  ...seams.retry ? { retry: seams.retry } : {}
4668
5627
  });
5628
+ file.result = result;
4669
5629
  if (process.stderr.isTTY) process.stderr.write("\n");
5630
+ else writeFileDone(label, result);
4670
5631
  }
4671
5632
  const batch = summarizeBatch(files);
4672
5633
  const payload = JSON.stringify(batch, null, 2);
@@ -4693,12 +5654,16 @@ async function runEvalCommand(pathsOrUrls, options, seams = {}) {
4693
5654
  process.exitCode = batchPassed(batch) ? 0 : 1;
4694
5655
  }
4695
5656
  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", `
5657
+ program.command("eval").description("Run an eval file (quiz golden answers, or tutor conversations) against the real activity path 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>", "cases in flight per file", String(CONCURRENCY_DEFAULT)).option("--repeats <n>", "run every case N times (quiz: take the majority verdict)", "1").option("--llm-provider <provider>", "run with this provider instead of the activity's (\"SCCH\" or \"Azure Foundry\"; needs --llm-model)").option("--llm-model <model>", "run with this model instead of the activity's (needs --llm-provider)").option("--no-judge-feedback", "skip the LLM audit of what the model wrote (halves the LLM calls)").option("--judge-llm-provider <provider>", "judge with this provider (\"SCCH\" or \"Azure Foundry\"; needs --judge-llm-model)").option("--judge-llm-model <model>", "judge with this model instead of the one under test (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
5658
  Examples:
4698
5659
  # Evaluate one quiz's golden answers
4699
5660
  $ novedu-cli eval ./0010-welcome-quiz.eval.yaml
4700
5661
 
4701
- # A whole course part (quote the pattern so the CLI expands it, ** included)
5662
+ # Check how a tutor answers a set of scripted conversations
5663
+ $ novedu-cli eval ./loops-tutor.eval.yaml
5664
+
5665
+ # A whole course part — quiz and tutor evals may be mixed
5666
+ # (quote the pattern so the CLI expands it, ** included)
4702
5667
  $ novedu-cli eval "./part-1/**/*.eval.yaml"
4703
5668
 
4704
5669
  # Measure grader stability: 3 runs per answer, majority verdict
@@ -4707,6 +5672,12 @@ Examples:
4707
5672
  # How would this rubric perform on another model? (both flags, always together)
4708
5673
  $ novedu-cli eval ./my-quiz.eval.yaml --llm-provider "Azure Foundry" --llm-model gpt-5-mini
4709
5674
 
5675
+ # A strong judge over the quiz's own grader — the recommended pairing
5676
+ $ novedu-cli eval ./my-quiz.eval.yaml --judge-llm-provider "Azure Foundry" --judge-llm-model gpt-5.6-terra
5677
+
5678
+ # Half the LLM calls: check the verdicts only, skip the feedback audit
5679
+ $ novedu-cli eval ./my-quiz.eval.yaml --no-judge-feedback
5680
+
4710
5681
  # Machine-readable, for CI
4711
5682
  $ novedu-cli eval ./my-quiz.eval.yaml --json --out eval-report.json
4712
5683