@novedu/cli 0.22.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 +53 -13
  2. package/dist/main.js +681 -92
  3. package/package.json +2 -2
package/README.md CHANGED
@@ -5,12 +5,12 @@ requires Node >= 22 — `eval`'s glob expansion uses the built-in `fs.globSync`,
5
5
  and Node 20 is end-of-life). It covers two jobs:
6
6
 
7
7
  - **Validate activity YAML** — tutors, fragment libraries, quizzes, writing
8
- activities, coding activities, and golden-answer evals — with the app's exact
8
+ activities, coding activities, and eval files — with the app's exact
9
9
  validation pipeline, offline and without signing in. `prompts` dumps the exact
10
10
  system prompts an activity produces, the same way.
11
11
  - **Manage the app as a teacher** — sign in with Microsoft Entra ID, then mint
12
12
  activity codes, upload app-hosted YAML files and images, triage student
13
- reports, and **measure a quiz's grading rubric** (`eval`), straight from the
13
+ reports, and **measure what an activity's model really does** (`eval`), straight from the
14
14
  terminal (or from a coding agent, see below).
15
15
 
16
16
  No install needed:
@@ -32,7 +32,7 @@ npx @novedu/cli validate https://raw.githubusercontent.com/Teaching-HTL-Leonding
32
32
  npx @novedu/cli validate ./activities/examples/shared/general-fragments.yaml --kind fragment
33
33
  npx @novedu/cli validate ./activities/examples/sorting-algorithms/sorting-quiz.yaml --kind quiz
34
34
 
35
- # A golden-answer eval (also strict-checks the quiz it targets)
35
+ # An eval file, quiz or tutor (also strict-checks the activity it targets)
36
36
  npx @novedu/cli validate ./sorting-quiz.eval.yaml --kind eval
37
37
 
38
38
  # Machine-readable output (the raw validation result)
@@ -96,18 +96,28 @@ npx @novedu/cli prompts ./sorting-quiz.yaml --kind quiz --json \
96
96
  JSON errors on stderr. Use `validate` for the strict authoring check — the two
97
97
  are complementary.
98
98
 
99
- ## Measuring a quiz's grading rubric: `eval`
99
+ ## Measuring what the model really does: `eval`
100
100
 
101
- A quiz's `evaluation` prompt is a rubric, and a rubric is only as good as its
102
- behavior on real answers. Write an **eval file** student answers with the verdict
103
- each one must get and `eval` replays them through the **real grader**, then reports
104
- what it actually did. This is the one command that both **runs the model** and needs
105
- you signed in (`novedu-cli login`); everything else about it is local.
101
+ An activity's prompt is a specification, and a specification is only as good as the
102
+ behavior it produces. Write an **eval file** and `eval` replays it through the **real
103
+ production path**, then reports what the model actually did. This is the one command
104
+ that both **runs the model** and needs you signed in (`novedu-cli login`); everything
105
+ 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**.
107
+ Two kinds, chosen by the file's own `kind:` field there is no flag, and one
108
+ invocation may mix them:
109
+
110
+ - **quiz** (`kind` omitted): student answers with the verdict each one must get,
111
+ replayed through the real grader. Your `expect` gates the **verdict**, and an LLM
112
+ **feedback judge** audits the **feedback text** the student would have read.
113
+ - **tutor** (`kind: tutor`): conversations you script, each ending on a student turn.
114
+ The real tutor generates the next turn and the judge checks it against the tutor's
115
+ own system prompt plus your per-case expectations.
116
+
117
+ Either way the judge measures the output against the very prompt that produced it, so
118
+ there is nothing extra to author — and what it flags is **reported, never a failure**.
119
+ For a tutor eval that makes the `--report` Markdown the actual deliverable: the exit
120
+ code only reflects whether the run itself completed.
111
121
 
112
122
  ```yaml
113
123
  # sorting-quiz.eval.yaml
@@ -153,6 +163,28 @@ npx @novedu/cli eval ./sorting-quiz.eval.yaml --json --out eval-report.json
153
163
  npx @novedu/cli eval ./sorting-quiz.eval.yaml --report eval-report.md
154
164
  ```
155
165
 
166
+ A tutor eval looks like this, and runs through the same command:
167
+
168
+ ```yaml
169
+ # loops-tutor.eval.yaml
170
+ id: loops-tutor-eval
171
+ kind: tutor
172
+ target: ./loops-tutor.yaml
173
+ conversations:
174
+ - title: refuses-full-solution
175
+ required_tools: [random_number] # optional: tools this answer must have called
176
+ grading_instructions: |
177
+ The response must not contain a complete working loop.
178
+ conversation: # must END with a student turn
179
+ - student: My loop never stops. Here is my code ...
180
+ - tutor: What does your condition evaluate to after the first pass?
181
+ - student: I don't know. Just fix it for me!
182
+ ```
183
+
184
+ ```bash
185
+ npx @novedu/cli eval ./loops-tutor.eval.yaml --report loops.md
186
+ ```
187
+
156
188
  - Check the file first, for free: `npx @novedu/cli validate ./x.eval.yaml --kind eval`
157
189
  (offline; it also strict-checks the quiz the eval targets).
158
190
  - **`expect`** is one of `correct` / `partial` / `incorrect`, or a list of the
@@ -185,6 +217,14 @@ npx @novedu/cli eval ./sorting-quiz.eval.yaml --report eval-report.md
185
217
  a **Flagged** column plus a **"Flagged feedback"** section in the Markdown report, and
186
218
  `totals.feedbackFlagged` / `repeats[].judge.issues` in the JSON. They never change the
187
219
  exit code.
220
+ - **`required_tools`** (tutor kind) names built-in tools the generated answer must have
221
+ called **at least once** — the one thing the judge cannot see, since a tool call leaves
222
+ no trace in the text. Extra tools are always fine, and a name the target tutor's own
223
+ `tools:` list does not grant makes the file invalid offline. Missing calls are
224
+ **reported, never a failure**: `missing tool calls: N` in the terminal report (printed
225
+ only when some case required a tool, so no line means "not checked"), a **"Missing tool
226
+ calls"** section in the Markdown report, and `totals.toolsFlagged` plus each repeat's
227
+ `toolCalls` / `missingTools` in the JSON.
188
228
  - **Choosing the judge.** By default the judge runs on the same model as the grader.
189
229
  `--judge-llm-provider` + `--judge-llm-model` (both or neither) point it at another one,
190
230
  which is the **recommended** setup: a strong judge over a smaller grader finds real
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,9 +3386,11 @@ 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
@@ -3394,6 +3480,106 @@ function buildFeedbackJudgeSubject(gradingSystem, answer, verdict, feedback) {
3394
3480
  ].join("\n");
3395
3481
  }
3396
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
3397
3583
  //#region src/retry.ts
3398
3584
  const defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
3399
3585
  /**
@@ -3455,6 +3641,10 @@ function createJudgeBreaker() {
3455
3641
  }
3456
3642
  /** Consecutive fully-errored judge calls that mean "stop judging for the rest of the run". */
3457
3643
  const JUDGE_BREAKER_LIMIT = 3;
3644
+ /** Narrow a case to the tutor arm. */
3645
+ function isTutorCase(evalCase) {
3646
+ return "conversation" in evalCase;
3647
+ }
3458
3648
  /** Consecutive fully-errored cases that mean "the server is down, stop now". */
3459
3649
  const CIRCUIT_BREAKER_LIMIT = 3;
3460
3650
  /** Flatten questions × answers into cases, each carrying its grading prompt. */
@@ -3495,38 +3685,30 @@ function judgeErrorMessage(error) {
3495
3685
  if (typeof error === "object" && error !== null && "message" in error) return String(error.message);
3496
3686
  return JSON.stringify(error ?? null);
3497
3687
  }
3498
- /** The seam: one runner per eval kind (mirrors `promptDumpers`). */
3499
- const evalRunners = { quiz: { async run(checked, options) {
3500
- const repeats = Math.max(1, Math.floor(options.repeats ?? 1));
3501
- const concurrency = Math.max(1, Math.floor(options.concurrency ?? 4));
3502
- const planned = planCases(checked);
3503
- const total = planned.length * repeats;
3504
- const questionTexts = new Map(checked.quizQuestions.map((q) => [q.id, q.text]));
3505
- const breaker = options.judgeBreaker ?? createJudgeBreaker();
3506
- let done = 0;
3507
- let consecutiveErrored = 0;
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) => {
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) => {
3523
3709
  const judge = options.judge;
3524
3710
  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
- }), {
3711
+ const outcome = await withRetry(() => judge(request), {
3530
3712
  attempts: options.retry?.attempts,
3531
3713
  baseDelayMs: options.retry?.baseDelayMs,
3532
3714
  sleep: options.retry?.sleep,
@@ -3549,6 +3731,33 @@ const evalRunners = { quiz: { async run(checked, options) {
3549
3731
  judgeError: judgeErrorMessage(outcome.error)
3550
3732
  };
3551
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.");
3740
+ const repeats = Math.max(1, Math.floor(options.repeats ?? 1));
3741
+ const concurrency = Math.max(1, Math.floor(options.concurrency ?? 4));
3742
+ const planned = planCases(checked);
3743
+ const total = planned.length * repeats;
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);
3747
+ let done = 0;
3748
+ let consecutiveErrored = 0;
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
+ });
3552
3761
  const progress = () => {
3553
3762
  done += 1;
3554
3763
  options.onProgress?.({
@@ -3569,7 +3778,7 @@ const evalRunners = { quiz: { async run(checked, options) {
3569
3778
  progress();
3570
3779
  continue;
3571
3780
  }
3572
- const outcome = await withRetry(() => options.grade({
3781
+ const outcome = await withRetry(() => grade({
3573
3782
  system: plan.system,
3574
3783
  answer: plan.answer
3575
3784
  }), {
@@ -3623,6 +3832,7 @@ const evalRunners = { quiz: { async run(checked, options) {
3623
3832
  ...winner ? { verdict: winner.verdict } : {},
3624
3833
  unstable: new Set(graded).size > 1,
3625
3834
  feedbackFlagged: rows.some((row) => (row.judge?.issues.length ?? 0) > 0),
3835
+ toolsFlagged: false,
3626
3836
  repeats: rows
3627
3837
  };
3628
3838
  });
@@ -3639,6 +3849,7 @@ const evalRunners = { quiz: { async run(checked, options) {
3639
3849
  skipped: results.filter((c) => c.status === "skipped").length,
3640
3850
  unstable: results.filter((c) => c.unstable).length,
3641
3851
  feedbackFlagged: results.filter((c) => c.feedbackFlagged).length,
3852
+ toolsFlagged: 0,
3642
3853
  judgeErrored: results.reduce((sum, c) => sum + c.repeats.filter((row) => row.judgeError !== void 0).length, 0),
3643
3854
  repeats,
3644
3855
  calls: total,
@@ -3647,7 +3858,7 @@ const evalRunners = { quiz: { async run(checked, options) {
3647
3858
  const confusionCounts = /* @__PURE__ */ new Map();
3648
3859
  for (const result of results) {
3649
3860
  if (!result.verdict) continue;
3650
- const key = `${expectedKey(result.expected)}${result.verdict}`;
3861
+ const key = `${expectedKey(result.expected)}\u0000${result.verdict}`;
3651
3862
  confusionCounts.set(key, (confusionCounts.get(key) ?? 0) + 1);
3652
3863
  }
3653
3864
  const confusion = [...confusionCounts.entries()].map(([key, count]) => {
@@ -3662,6 +3873,7 @@ const evalRunners = { quiz: { async run(checked, options) {
3662
3873
  const falseCorrectCount = strictCases.filter((result) => result.verdict === "correct").length;
3663
3874
  return {
3664
3875
  id: checked.evalFile.id,
3876
+ kind: "quiz",
3665
3877
  target: checked.targetUrl,
3666
3878
  llm: options.llm,
3667
3879
  judging: !options.judge ? "off" : breaker.stopped ? "degraded" : "on",
@@ -3680,7 +3892,178 @@ const evalRunners = { quiz: { async run(checked, options) {
3680
3892
  },
3681
3893
  ...aborted ? { aborted } : {}
3682
3894
  };
3683
- } } };
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
+ };
3684
4067
  /** Run ONE checked eval file — the single entry point the command uses. */
3685
4068
  function runEval(kind, checked, options) {
3686
4069
  return evalRunners[kind].run(checked, options);
@@ -3706,6 +4089,7 @@ function summarizeBatch(files) {
3706
4089
  skipped: 0,
3707
4090
  unstable: 0,
3708
4091
  feedbackFlagged: 0,
4092
+ toolsFlagged: 0,
3709
4093
  judgeErrored: 0,
3710
4094
  usage: { ...ZERO_USAGE }
3711
4095
  };
@@ -3718,12 +4102,14 @@ function summarizeBatch(files) {
3718
4102
  totals.skipped += file.result.totals.skipped;
3719
4103
  totals.unstable += file.result.totals.unstable;
3720
4104
  totals.feedbackFlagged += file.result.totals.feedbackFlagged;
4105
+ totals.toolsFlagged += file.result.totals.toolsFlagged;
3721
4106
  totals.judgeErrored += file.result.totals.judgeErrored;
3722
4107
  addUsage(totals.usage, file.result.totals.usage);
3723
4108
  }
3724
4109
  return {
3725
4110
  files: files.map((file) => ({
3726
4111
  ...file,
4112
+ ...file.result ? { kind: file.result.kind } : {},
3727
4113
  passed: filePassed(file)
3728
4114
  })),
3729
4115
  passed: batchPassed({ totals }),
@@ -3740,13 +4126,25 @@ function anyJudged(result) {
3740
4126
  return result.cases.some((evalCase) => evalCase.repeats.some((repeat) => repeat.judge !== void 0 && repeat.judge !== null));
3741
4127
  }
3742
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
+ /**
3743
4138
  * The CI gate: every file valid, and not a single failed, errored, or skipped CASE —
3744
4139
  * an aborted (and therefore incomplete) run must never read as a pass. The single
3745
4140
  * source of truth for the exit code AND for `EvalBatchResult.passed`.
3746
4141
  *
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.)
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.)
3750
4148
  */
3751
4149
  function batchPassed(batch) {
3752
4150
  return batch.totals.invalid === 0 && batch.totals.failed === 0 && batch.totals.errored === 0 && batch.totals.skipped === 0;
@@ -3772,6 +4170,9 @@ const cliFetcher = async (url) => {
3772
4170
  };
3773
4171
  //#endregion
3774
4172
  //#region src/format.ts
4173
+ function flaggedLabel(kind) {
4174
+ return kind === "tutor" ? "flagged responses" : "flagged feedback";
4175
+ }
3775
4176
  const useColor = process.stdout.isTTY && !process.env.NO_COLOR;
3776
4177
  const paint = (code, s) => useColor ? `\x1b[${code}m${s}\x1b[0m` : s;
3777
4178
  const green = (s) => paint("32", s);
@@ -3920,17 +4321,19 @@ function formatCodingResult(result, source) {
3920
4321
  return lines.join("\n");
3921
4322
  }
3922
4323
  /**
3923
- * Renderer for a golden-answer eval check (`--kind eval`). An eval describes a quiz it
3924
- * does not contain, so the summary names the resolved target and the size of the run
3925
- * 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.
3926
4327
  */
3927
4328
  function formatEvalResult(result, source) {
3928
4329
  if (!result.ok) return renderFailureAndWarnings(result, "eval", source);
3929
4330
  const lines = [green(`✔ Valid eval`) + dim(` — ${source}`)];
3930
4331
  lines.push(` id: ${result.evalFile.id}`);
4332
+ lines.push(` kind: ${result.kind}`);
3931
4333
  lines.push(` target: ${result.targetUrl}`);
3932
- lines.push(` questions: ${result.evalFile.questions.length} cases: ${result.caseCount}`);
3933
- 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}`);
3934
4337
  if (result.warnings.length) {
3935
4338
  lines.push("");
3936
4339
  lines.push(yellow(`${result.warnings.length} warning(s):`));
@@ -3959,14 +4362,25 @@ function formatUsageLine(usage) {
3959
4362
  const cached = usage.cachedInput ? ` (${formatTokenCount(usage.cachedInput)} cached)` : "";
3960
4363
  return `tokens: ${formatTokenCount(usage.input)} in${cached} / ${formatTokenCount(usage.output)} out`;
3961
4364
  }
3962
- /** `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
+ */
3963
4374
  function mismatchLines(result) {
3964
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
+ }
3965
4380
  const head = `${c.questionId}#${c.answerIndex}`;
3966
4381
  const expected = c.expected.join("|");
3967
4382
  if (c.status === "errored") {
3968
- const first = c.repeats.find((row) => row.error !== void 0)?.error;
3969
- const message = typeof first === "object" && first !== null && "message" in first ? String(first.message) : "no verdict";
4383
+ const message = firstErrorMessage(c.repeats, "no verdict");
3970
4384
  return ` ${red("✗")} ${head} expected ${expected} got ${red("error")} ${dim(message)}`;
3971
4385
  }
3972
4386
  return ` ${red("✗")} ${head} expected ${expected} got ${red(c.verdict ?? "?")}` + dim(` "${snippet(c.answer)}"`);
@@ -3989,7 +4403,9 @@ function formatEvalReport(result, source) {
3989
4403
  lines.push(` llm: ${llm}`);
3990
4404
  const judge = result.llm.judge;
3991
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)")}` : ""}`);
3992
- lines.push(` cases: ${totals.cases} × ${totals.repeats} repeat(s) = ${totals.calls} grading call(s)` + (result.judging === "off" ? "" : ` + ${totals.calls} judge call(s)`));
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)`));
3993
4409
  if (result.aborted) {
3994
4410
  lines.push("");
3995
4411
  lines.push(red(`Run aborted: ${result.aborted.message}`));
@@ -4004,7 +4420,7 @@ function formatEvalReport(result, source) {
4004
4420
  lines.push(...mismatchLines(result));
4005
4421
  }
4006
4422
  lines.push("");
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}`) : ""));
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}`) : ""));
4008
4424
  const tokens = formatUsageLine(totals.usage);
4009
4425
  if (tokens) lines.push(dim(` ${tokens}`));
4010
4426
  if (result.confusion.length) {
@@ -4012,9 +4428,11 @@ function formatEvalReport(result, source) {
4012
4428
  lines.push(" confusion (expected → got):");
4013
4429
  for (const row of result.confusion) lines.push(` ${row.expected} → ${row.got}: ${row.count}`);
4014
4430
  }
4015
- const { count, denominator, rate } = result.falseCorrect;
4016
- lines.push("");
4017
- 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
+ }
4018
4436
  return lines.join("\n");
4019
4437
  }
4020
4438
  /**
@@ -4034,12 +4452,14 @@ function formatEvalBatchReport(batch) {
4034
4452
  }
4035
4453
  const t = file.result.totals;
4036
4454
  const mark = t.failed === 0 && t.errored === 0 && t.skipped === 0 ? green("✔") : red("✗");
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")));
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")));
4038
4457
  }
4039
4458
  const g = batch.totals;
4040
4459
  const judged = batch.files.some((file) => file.result && anyJudged(file.result));
4460
+ const toolChecked = batch.files.some((file) => file.result && anyToolsRequired(file.result));
4041
4461
  lines.push("");
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)`) : ""));
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)`) : ""));
4043
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."));
4044
4464
  const tokens = formatUsageLine(g.usage);
4045
4465
  if (tokens) lines.push(dim(` ${tokens}`));
@@ -4196,17 +4616,18 @@ function overview(batch) {
4196
4616
  continue;
4197
4617
  }
4198
4618
  const t = file.result.totals;
4619
+ const tutor = file.result.kind === "tutor";
4199
4620
  lines.push(row([
4200
4621
  `${file.passed ? "✅" : "❌"} ${name}`,
4201
4622
  `\`${cell(file.result.id)}\``,
4202
4623
  count(t.cases),
4203
- count(t.passed),
4204
- count(t.failed),
4624
+ tutor ? "—" : count(t.passed),
4625
+ tutor ? "—" : count(t.failed),
4205
4626
  count(t.errored),
4206
4627
  count(t.skipped),
4207
- count(t.unstable),
4628
+ tutor ? "—" : count(t.unstable),
4208
4629
  anyJudged(file.result) ? count(t.feedbackFlagged) : "—",
4209
- falseCorrectCell(file.result),
4630
+ tutor ? "—" : falseCorrectCell(file.result),
4210
4631
  usageCell(t.usage)
4211
4632
  ]));
4212
4633
  }
@@ -4300,7 +4721,7 @@ function caseSection(evalCase, questionText) {
4300
4721
  * the run failed on them. Empty when the file has no flags.
4301
4722
  */
4302
4723
  function flaggedSection(result, questionText) {
4303
- const flagged = result.cases.filter((evalCase) => evalCase.feedbackFlagged);
4724
+ const flagged = result.cases.filter((evalCase) => evalCase.feedbackFlagged && !isTutorCase(evalCase));
4304
4725
  if (flagged.length === 0) return [];
4305
4726
  const lines = ["### Flagged feedback", ""];
4306
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._");
@@ -4322,6 +4743,104 @@ function flaggedSection(result, questionText) {
4322
4743
  }
4323
4744
  return lines;
4324
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
+ }
4325
4844
  /** One file's details section, or `[]` when the file has nothing to report. */
4326
4845
  function fileDetails(file) {
4327
4846
  const name = shortSource(file.source);
@@ -4330,30 +4849,33 @@ function fileDetails(file) {
4330
4849
  return [
4331
4850
  `## ${cell(name)} — invalid`,
4332
4851
  "",
4333
- "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.",
4334
4853
  "",
4335
4854
  ...errors.map((issue) => `- \`${cell(issue.code)}\` — ${inline(issue.message)}`),
4336
4855
  ""
4337
4856
  ];
4338
4857
  }
4339
4858
  const result = file.result;
4859
+ const tutor = result.kind === "tutor";
4340
4860
  const detailed = result.cases.filter(needsDetail);
4341
4861
  const skipped = result.totals.skipped;
4342
4862
  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 [];
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 [];
4345
4866
  const lines = [`## ${cell(name)} — \`${cell(result.id)}\``, ""];
4346
4867
  if (result.aborted) {
4347
4868
  lines.push("> [!WARNING]");
4348
4869
  lines.push(`> The run was aborted: ${inline(result.aborted.message)}`);
4349
4870
  lines.push("");
4350
4871
  }
4351
- 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)));
4352
4873
  if (skipped > 0) {
4353
4874
  const reason = result.aborted ? ` (${inline(result.aborted.message)})` : "";
4354
- 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.`);
4355
4876
  lines.push("");
4356
4877
  }
4878
+ lines.push(...missingTools);
4357
4879
  lines.push(...flagged);
4358
4880
  return lines;
4359
4881
  }
@@ -4371,8 +4893,9 @@ function renderEvalMarkdownReport(batch, meta) {
4371
4893
  const judges = [...new Set(batch.files.map((file) => file.result ? judgeLlmText(file.result.llm) : void 0).filter((text) => text !== void 0))];
4372
4894
  for (const judge of judges) lines.push(`- **Feedback judge** ${judge}`);
4373
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`);
4374
4897
  const tokens = batch.totals.usage;
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`);
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`);
4376
4899
  lines.push("");
4377
4900
  if (batch.files.filter((file) => file.result?.aborted).length > 0) {
4378
4901
  lines.push("> [!WARNING]");
@@ -4391,10 +4914,10 @@ function renderEvalMarkdownReport(batch, meta) {
4391
4914
  lines.push("");
4392
4915
  const details = batch.files.flatMap((file) => fileDetails(file));
4393
4916
  if (details.length === 0) {
4394
- 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._");
4395
4918
  lines.push("");
4396
4919
  } else {
4397
- lines.push("_Below: only the mismatched, errored and unstable cases, plus any feedback the judge flagged. 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._");
4398
4921
  lines.push("");
4399
4922
  lines.push(...details);
4400
4923
  }
@@ -4820,6 +5343,61 @@ function makeGradeFn(server, llm) {
4820
5343
  };
4821
5344
  }
4822
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
+ /**
4823
5401
  * The HTTP seam for ONE judge call, with the run's judge llm closed in. Mirrors
4824
5402
  * {@link makeGradeFn}'s failure classification, minus the auth branch: a judge failure
4825
5403
  * NEVER aborts the run — it degrades judging (see the runner's breaker) while the grading
@@ -4934,7 +5512,8 @@ function progressWriter(prefix) {
4934
5512
  */
4935
5513
  function writeFileDone(label, result) {
4936
5514
  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");
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");
4938
5517
  }
4939
5518
  /**
4940
5519
  * The command's core, exported for the unit tests. `seams` exists only so tests can
@@ -4999,9 +5578,14 @@ async function runEvalCommand(pathsOrUrls, options, seams = {}) {
4999
5578
  return;
5000
5579
  }
5001
5580
  {
5002
- const totalCases = [...checked.values()].reduce((sum, file) => sum + file.caseCount, 0);
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"));
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"));
5005
5589
  }
5006
5590
  await warnOnVersionMismatch(options.server);
5007
5591
  const judgeBreaker = createJudgeBreaker();
@@ -5013,15 +5597,15 @@ async function runEvalCommand(pathsOrUrls, options, seams = {}) {
5013
5597
  fileIndex += 1;
5014
5598
  const check = checked.get(file.source);
5015
5599
  if (!check) continue;
5016
- const quizLlm = {
5017
- provider: check.quizDump.llm.provider,
5018
- model: check.quizDump.llm.model
5600
+ const activityLlm = {
5601
+ provider: check.llm.provider,
5602
+ model: check.llm.model
5019
5603
  };
5020
- const effective = override.llm ?? quizLlm;
5604
+ const effective = override.llm ?? activityLlm;
5021
5605
  const judgeLlm = judgeOverride.llm ?? effective;
5022
5606
  const llm = {
5023
5607
  ...effective,
5024
- ...override.llm ? { overrides: quizLlm } : {},
5608
+ ...override.llm ? { overrides: activityLlm } : {},
5025
5609
  ...judging ? { judge: {
5026
5610
  ...judgeLlm,
5027
5611
  overridden: judgeOverride.llm !== void 0
@@ -5029,8 +5613,9 @@ async function runEvalCommand(pathsOrUrls, options, seams = {}) {
5029
5613
  };
5030
5614
  const label = files.length > 1 ? `(${fileIndex}/${files.length}) ${check.evalFile.id}` : check.evalFile.id;
5031
5615
  const prefix = files.length > 1 ? `${label}: ` : "";
5032
- const result = await runEval("quiz", check, {
5616
+ const result = await runEval(check.kind, check, {
5033
5617
  grade: makeGradeFn(options.server, effective),
5618
+ respond: makeRespondFn(options.server, effective),
5034
5619
  ...judging ? { judge: makeJudgeFn(options.server, judgeLlm) } : {},
5035
5620
  judgeBreaker,
5036
5621
  onJudgeDegraded,
@@ -5069,12 +5654,16 @@ async function runEvalCommand(pathsOrUrls, options, seams = {}) {
5069
5654
  process.exitCode = batchPassed(batch) ? 0 : 1;
5070
5655
  }
5071
5656
  function registerEval(program) {
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", `
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", `
5073
5658
  Examples:
5074
5659
  # Evaluate one quiz's golden answers
5075
5660
  $ novedu-cli eval ./0010-welcome-quiz.eval.yaml
5076
5661
 
5077
- # 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)
5078
5667
  $ novedu-cli eval "./part-1/**/*.eval.yaml"
5079
5668
 
5080
5669
  # Measure grader stability: 3 runs per answer, majority verdict
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@novedu/cli",
3
- "version": "0.22.0",
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.",
3
+ "version": "0.23.0",
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, evaluates a quiz's grading rubric against golden answers and replays scripted conversations against a tutor; 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": {
7
7
  "type": "git",