@novedu/cli 0.22.0 → 0.24.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 +82 -25
  2. package/dist/main.js +837 -118
  3. package/package.json +2 -2
package/dist/main.js CHANGED
@@ -284,9 +284,19 @@ async function runApiRequest(options) {
284
284
  const LLM_PROVIDERS = ["SCCH", "Azure Foundry"];
285
285
  const DEFAULT_PROVIDER = "SCCH";
286
286
  const providerSchema = z.enum(LLM_PROVIDERS).default(DEFAULT_PROVIDER).meta({ description: "The LLM provider serving the model. For Azure Foundry, model is the deployment name." });
287
+ const REASONING_LEVELS = [
288
+ "minimal",
289
+ "low",
290
+ "medium",
291
+ "high"
292
+ ];
293
+ const reasoningLevelSchema = z.enum(REASONING_LEVELS).optional().meta({ description: "Optional reasoning effort for reasoning models. Omit to let the model decide (the parameter is then not sent)." });
287
294
  function parseLenientProvider(value) {
288
295
  return value === "SCCH" || value === "Azure Foundry" ? value : void 0;
289
296
  }
297
+ function parseLenientReasoningLevel(value) {
298
+ return typeof value === "string" && REASONING_LEVELS.includes(value) ? value : void 0;
299
+ }
290
300
  //#endregion
291
301
  //#region ../lib/registry-schema.ts
292
302
  /** The fixed group names and the code module each one mints for. */
@@ -307,6 +317,7 @@ function timestampField(field) {
307
317
  }, `${field} must not carry sub-second precision — the server stores whole seconds`);
308
318
  }
309
319
  const providerField = z.enum(LLM_PROVIDERS, { error: "must be \"SCCH\" or \"Azure Foundry\"" });
320
+ const reasoningField = z.enum(REASONING_LEVELS, { error: `must be one of ${REASONING_LEVELS.join(", ")}` });
310
321
  /**
311
322
  * One registry entry. Unknown extra properties are ACCEPTED and ignored so authors can
312
323
  * annotate freely and a newer registry keeps working with an older CLI — which is why
@@ -320,8 +331,9 @@ const RegistryEntrySchema = z.looseObject({
320
331
  note: z.string().trim().max(200, `note must be at most 200 characters`).optional().meta({ description: `Note shown in the codes list, at most 200 characters. No effect on behaviour.` }),
321
332
  llm: z.looseObject({
322
333
  provider: providerField.meta({ description: "LLM provider override for this code. Required when `llm` is present." }),
323
- model: z.string().trim().min(1).max(256).meta({ description: "Model id (for Azure Foundry, the deployment name). Required when `llm` is present." })
324
- }).optional().meta({ description: "Per-code LLM override replacing the activity YAML's own `llm:`. Provider and model must be given together." })
334
+ model: z.string().trim().min(1).max(256).meta({ description: "Model id (for Azure Foundry, the deployment name). Required when `llm` is present." }),
335
+ reasoning: reasoningField.optional().meta({ description: "Optional reasoning effort for reasoning models, applied on top of the provider/model pair. Omit to let the model decide." })
336
+ }).optional().meta({ description: "Per-code LLM override replacing the activity YAML's own `llm:`. Provider and model must be given together; `reasoning` is optional on top of them." })
325
337
  }).refine((entry) => entry.file === void 0 !== (entry.url === void 0), "give exactly one of `file` (relative to base-url) or `url` (absolute)").meta({
326
338
  id: "registryEntry",
327
339
  description: "One activity: where its YAML lives, plus the parameters its code is minted with."
@@ -451,7 +463,8 @@ function parseRegistry(text) {
451
463
  note: entry.note ?? null,
452
464
  llm: entry.llm ? {
453
465
  provider: entry.llm.provider,
454
- model: entry.llm.model
466
+ model: entry.llm.model,
467
+ ...entry.llm.reasoning ? { reasoning: entry.llm.reasoning } : {}
455
468
  } : null
456
469
  });
457
470
  }
@@ -521,7 +534,8 @@ function parseServerCodes(payload) {
521
534
  validUntil: typeof value.validUntil === "string" ? value.validUntil : null,
522
535
  llm: typeof llm === "object" && llm !== null ? {
523
536
  provider: String(llm.provider ?? ""),
524
- model: String(llm.model ?? "")
537
+ model: String(llm.model ?? ""),
538
+ reasoning: typeof llm.reasoning === "string" ? llm.reasoning : null
525
539
  } : null,
526
540
  createdAt: typeof value.createdAt === "string" ? value.createdAt : null
527
541
  });
@@ -539,9 +553,16 @@ function sameInstant(a, b) {
539
553
  const right = Date.parse(b);
540
554
  return !Number.isNaN(left) && left === right;
541
555
  }
556
+ /**
557
+ * The override compares WHOLE, reasoning level included: a code minted at a different
558
+ * effort serves different behavior, so it must not be reused. A differing level therefore
559
+ * fails the match and the entry mints a NEW code — sync never modifies an existing one
560
+ * (docs/registry.md). An absent level on either side compares as null, so an entry
561
+ * without `reasoning` keeps matching the codes minted before the field existed.
562
+ */
542
563
  function sameLlm(a, b) {
543
564
  if (a === null || b === null) return a === b;
544
- return a.provider === b.provider && a.model === b.model;
565
+ return a.provider === b.provider && a.model === b.model && (a.reasoning ?? null) === (b.reasoning ?? null);
545
566
  }
546
567
  /**
547
568
  * The codes that ARE this entry: same activity URL, module and availability
@@ -846,7 +867,8 @@ async function readLock(lockPath) {
846
867
  }
847
868
  function registerCodes(program) {
848
869
  const codes = program.command("codes").description("Manage activity codes on the Novedu server");
849
- codes.command("create").description("Create a code for an activity YAML (validated server-side before storing)").requiredOption("--module <module>", "activity module: tutor, quiz, writing or coding").requiredOption("--file <url>", "public http(s) URL of the activity YAML").option("--start <iso>", "window start, ISO 8601 with explicit offset (e.g. 2026-07-07T08:00:00Z)").option("--end <iso>", "window end, ISO 8601 with explicit offset").option("--note <text>", "note shown in the codes list").option("--llm-provider <provider>", "LLM override provider (\"SCCH\" or \"Azure Foundry\"; needs --llm-model)").option("--llm-model <model>", "LLM override model id (needs --llm-provider)").option(...SERVER_OPTION$3).action(async (options) => {
870
+ codes.command("create").description("Create a code for an activity YAML (validated server-side before storing)").requiredOption("--module <module>", "activity module: tutor, quiz, writing or coding").requiredOption("--file <url>", "public http(s) URL of the activity YAML").option("--start <iso>", "window start, ISO 8601 with explicit offset (e.g. 2026-07-07T08:00:00Z)").option("--end <iso>", "window end, ISO 8601 with explicit offset").option("--note <text>", "note shown in the codes list").option("--llm-provider <provider>", "LLM override provider (\"SCCH\" or \"Azure Foundry\"; needs --llm-model)").option("--llm-model <model>", "LLM override model id (needs --llm-provider)").option("--llm-reasoning <level>", "LLM override reasoning effort (\"minimal\", \"low\", \"medium\" or \"high\"; needs the provider/model pair)").option(...SERVER_OPTION$3).action(async (options) => {
871
+ const llmGiven = options.llmProvider !== void 0 || options.llmModel !== void 0 || options.llmReasoning !== void 0;
850
872
  await runApiRequest({
851
873
  server: options.server,
852
874
  path: "/api/codes",
@@ -857,10 +879,11 @@ function registerCodes(program) {
857
879
  ...options.start === void 0 ? {} : { validFrom: options.start },
858
880
  ...options.end === void 0 ? {} : { validUntil: options.end },
859
881
  ...options.note === void 0 ? {} : { note: options.note },
860
- ...options.llmProvider === void 0 && options.llmModel === void 0 ? {} : { llm: {
882
+ ...llmGiven ? { llm: {
861
883
  provider: options.llmProvider ?? "",
862
- model: options.llmModel ?? ""
863
- } }
884
+ model: options.llmModel ?? "",
885
+ ...options.llmReasoning === void 0 ? {} : { reasoning: options.llmReasoning }
886
+ } } : {}
864
887
  }
865
888
  });
866
889
  });
@@ -890,10 +913,19 @@ const QUIZ_VERDICT_SCHEMA = z.object({
890
913
  feedback: z.string()
891
914
  });
892
915
  const QUIZ_VERDICT_ENUM = QUIZ_VERDICT_SCHEMA.shape.result;
916
+ /** The three verdict literals, in the canonical best→worst order. */
917
+ const QUIZ_VERDICT_VALUES = QUIZ_VERDICT_ENUM.options;
918
+ //#endregion
919
+ //#region ../lib/tutor-tools/names.ts
920
+ const TUTOR_TOOL_NAMES = ["random_number"];
921
+ const tutorToolNameSchema = z.enum(TUTOR_TOOL_NAMES).meta({
922
+ id: "tutorToolName",
923
+ description: "Name of a built-in tutor tool."
924
+ });
893
925
  //#endregion
894
926
  //#region ../lib/eval-schema.ts
895
927
  /** The three verdicts in canonical order (best → worst); the sort key for expected sets. */
896
- const EVAL_VERDICTS = QUIZ_VERDICT_ENUM.options;
928
+ const EVAL_VERDICTS = QUIZ_VERDICT_VALUES;
897
929
  /**
898
930
  * Eval ids share the flat namespace of report headers and `--out` files, so they stay
899
931
  * URL- and YAML-plain: an alphanumeric start, then alphanumerics, `.`, `-` or `_`.
@@ -911,13 +943,72 @@ const EvalQuestionSchema = z.strictObject({
911
943
  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
944
  answers: z.array(EvalAnswerSchema).min(1).meta({ description: "The golden answers for this question — at least one." })
913
945
  });
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." }),
946
+ 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." });
947
+ /** The QUIZ arm: golden answers replayed through the real grader. */
948
+ const QuizEvalYamlSchema = z.strictObject({
949
+ kind: z.literal("quiz").optional().meta({ description: "The eval kind. Omit it (or write \"quiz\") for a golden-answer eval of a quiz rubric." }),
950
+ id: idSchema,
917
951
  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
952
  questions: z.array(EvalQuestionSchema).min(1).meta({ description: "The evaluated questions — at least one, each with its golden answers." })
919
953
  });
920
954
  /**
955
+ * ONE turn of a scripted conversation: a single-key map naming its speaker. The two
956
+ * TEACHER-facing role names (`student` / `tutor`) are deliberate — the wire roles
957
+ * (`user` / `assistant`) are an implementation detail nobody should have to author.
958
+ */
959
+ 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." }) })]);
960
+ /** The last turn a conversation may end on: the student message the model must answer. */
961
+ function endsWithStudentTurn(turns) {
962
+ const last = turns.at(-1);
963
+ return last !== void 0 && "student" in last;
964
+ }
965
+ /**
966
+ * The tool names a case may REQUIRE, derived from the catalog's own name list
967
+ * (`lib/tutor-tools/names.ts`) rather than a mirrored literal set — so a tool added to the
968
+ * catalog is immediately requirable and a typo fails `validate` offline with a named enum
969
+ * error, no run and no tokens spent.
970
+ *
971
+ * Non-empty and UNIQUE: an empty list would say nothing (write no `required_tools` at
972
+ * all), and a repeated name is always an authoring slip — the check is "called at least
973
+ * once", so naming a tool twice cannot mean anything a single mention does not.
974
+ */
975
+ 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." });
976
+ /** ONE tutor case: a scripted conversation plus the teacher's optional expectations. */
977
+ const EvalConversationSchema = z.strictObject({
978
+ 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." }),
979
+ required_tools: requiredToolsSchema.optional(),
980
+ 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\")." }),
981
+ 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." })
982
+ });
983
+ /** The TUTOR arm: conversations whose next tutor turn is generated and judged. */
984
+ const TutorEvalYamlSchema = z.strictObject({
985
+ kind: z.literal("tutor").meta({ description: "The eval kind. \"tutor\" evaluates a tutor's next response in a conversation." }),
986
+ id: idSchema,
987
+ 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." }),
988
+ conversations: z.array(EvalConversationSchema).min(1).meta({ description: "The evaluated conversations — at least one; each one is a case." })
989
+ });
990
+ /**
991
+ * The whole eval file: quiz (the default, `kind` omissible) or tutor.
992
+ *
993
+ * A discriminated union rather than a loose one, so a `kind: tutor` file with a typo in
994
+ * `conversations` reports THAT problem instead of "no union member matched".
995
+ */
996
+ const EvalYamlSchema = z.discriminatedUnion("kind", [QuizEvalYamlSchema, TutorEvalYamlSchema]);
997
+ /** The eval file's kind, with the quiz arm's omitted `kind` resolved to its default. */
998
+ function evalKindOf(evalFile) {
999
+ return evalFile.kind ?? "quiz";
1000
+ }
1001
+ /** A scripted turn as `{ role, text }` — the wire shape `POST /api/eval/respond` takes. */
1002
+ function turnToMessage(turn) {
1003
+ return "student" in turn ? {
1004
+ role: "user",
1005
+ text: turn.student
1006
+ } : {
1007
+ role: "assistant",
1008
+ text: turn.tutor
1009
+ };
1010
+ }
1011
+ /**
921
1012
  * The canonical expected-verdict SET of one golden answer: a single verdict or a list,
922
1013
  * deduped and sorted into `EVAL_VERDICTS` order. Canonical because the confusion
923
1014
  * matrix keys its rows by this set — `correct|partial` must be one row no matter which
@@ -956,6 +1047,10 @@ function appendInstructions(existing, instructions) {
956
1047
  * client sent no system message, a leading one carrying only the teacher's instructions
957
1048
  * is added. Everything else (messages, tools, tool_choice, temperature, stream, …)
958
1049
  * passes through verbatim, so client-side tools and streaming are all preserved.
1050
+ *
1051
+ * A `reasoning` level (the effective activity/code setting) is pinned exactly like
1052
+ * `model` — it OVERWRITES whatever the client sent as `reasoning_effort`. Without one
1053
+ * the client's own `reasoning_effort` passes through untouched, like any other parameter.
959
1054
  */
960
1055
  function buildUpstreamChatBody(clientBody, opts) {
961
1056
  const clientMessages = Array.isArray(clientBody.messages) ? clientBody.messages : [];
@@ -978,6 +1073,7 @@ function buildUpstreamChatBody(clientBody, opts) {
978
1073
  model: opts.model,
979
1074
  messages
980
1075
  };
1076
+ if (opts.reasoning) upstream.reasoning_effort = opts.reasoning;
981
1077
  if (clientBody.stream === true) upstream.stream_options = {
982
1078
  ...isRecord(clientBody.stream_options) ? clientBody.stream_options : {},
983
1079
  include_usage: true
@@ -1040,6 +1136,11 @@ function parseCoding(content) {
1040
1136
  ok: false,
1041
1137
  message: "This coding activity uses an unsupported llm.provider (use \"SCCH\" or \"Azure Foundry\")."
1042
1138
  };
1139
+ const reasoning = llm?.reasoning === void 0 ? void 0 : parseLenientReasoningLevel(llm.reasoning);
1140
+ if (llm?.reasoning !== void 0 && !reasoning) return {
1141
+ ok: false,
1142
+ message: "This coding activity uses an unsupported llm.reasoning (use \"minimal\", \"low\", \"medium\" or \"high\")."
1143
+ };
1043
1144
  const instructions = asString$2(root.instructions);
1044
1145
  if (!instructions) return {
1045
1146
  ok: false,
@@ -1052,6 +1153,7 @@ function parseCoding(content) {
1052
1153
  title: asString$2(root.title),
1053
1154
  model,
1054
1155
  provider,
1156
+ reasoning,
1055
1157
  instructions,
1056
1158
  fragmentBlock: readFragmentBlock(root)
1057
1159
  }
@@ -2343,6 +2445,11 @@ function parseQuiz(content) {
2343
2445
  ok: false,
2344
2446
  message: "This quiz uses an unsupported llm.provider (use \"SCCH\" or \"Azure Foundry\")."
2345
2447
  };
2448
+ const reasoning = llm?.reasoning === void 0 ? void 0 : parseLenientReasoningLevel(llm.reasoning);
2449
+ if (llm?.reasoning !== void 0 && !reasoning) return {
2450
+ ok: false,
2451
+ message: "This quiz uses an unsupported llm.reasoning (use \"minimal\", \"low\", \"medium\" or \"high\")."
2452
+ };
2346
2453
  const quizFiles = Array.isArray(root.quiz_files) ? root.quiz_files : [];
2347
2454
  const rawQuestions = Array.isArray(root.questions) ? root.questions : [];
2348
2455
  if (rawQuestions.length === 0 && quizFiles.length === 0) return {
@@ -2385,6 +2492,7 @@ function parseQuiz(content) {
2385
2492
  shuffle: asBool$1(root.shuffle, true),
2386
2493
  model,
2387
2494
  provider,
2495
+ reasoning,
2388
2496
  questionCount,
2389
2497
  imageInput: asBool$1(llm?.imageInput, false),
2390
2498
  discussionInstructions: asString$1(root.discussion?.instructions),
@@ -2591,10 +2699,6 @@ async function loadQuizFrom(url, fetcher, opts = {}) {
2591
2699
  };
2592
2700
  }
2593
2701
  }
2594
- const tutorToolNameSchema = z.enum(["random_number"]).meta({
2595
- id: "tutorToolName",
2596
- description: "Name of a built-in tutor tool."
2597
- });
2598
2702
  //#endregion
2599
2703
  //#region ../lib/tutors/schemas.ts
2600
2704
  /**
@@ -2622,6 +2726,7 @@ const TutorSchema = z.strictObject({
2622
2726
  llm: z.strictObject({
2623
2727
  model: z.string().meta({ description: "Model used for this tutor." }),
2624
2728
  provider: providerSchema,
2729
+ reasoning: reasoningLevelSchema,
2625
2730
  imageInput: z.boolean().optional().meta({
2626
2731
  default: true,
2627
2732
  description: "Image uploads are enabled by default. Set to false to hide the upload UI for text-only tutors or non-vision-capable models."
@@ -2670,6 +2775,7 @@ async function loadAndBuildTutorPrompt(url, fetchImpl, opts = {}) {
2670
2775
  prompt: assembled.prompt,
2671
2776
  model: tutor.llm.model,
2672
2777
  provider: tutor.llm.provider,
2778
+ reasoning: tutor.llm.reasoning,
2673
2779
  imageInput: tutor.llm.imageInput ?? true,
2674
2780
  tools: tutor.tools,
2675
2781
  anonymous: tutor.anonymous ?? true,
@@ -2720,6 +2826,11 @@ function parseWriting(content) {
2720
2826
  ok: false,
2721
2827
  message: "This writing activity uses an unsupported llm.provider (use \"SCCH\" or \"Azure Foundry\")."
2722
2828
  };
2829
+ const reasoning = llm?.reasoning === void 0 ? void 0 : parseLenientReasoningLevel(llm.reasoning);
2830
+ if (llm?.reasoning !== void 0 && !reasoning) return {
2831
+ ok: false,
2832
+ message: "This writing activity uses an unsupported llm.reasoning (use \"minimal\", \"low\", \"medium\" or \"high\")."
2833
+ };
2723
2834
  const instructions = asString(root.instructions);
2724
2835
  if (!instructions) return {
2725
2836
  ok: false,
@@ -2735,6 +2846,7 @@ function parseWriting(content) {
2735
2846
  anonymous: asBool(root.anonymous, false),
2736
2847
  model,
2737
2848
  provider,
2849
+ reasoning,
2738
2850
  instructions,
2739
2851
  fragmentBlock: readFragmentBlock(root),
2740
2852
  placeholder: asString(root.placeholder)
@@ -2841,7 +2953,8 @@ const promptDumpers = {
2841
2953
  id: result.id,
2842
2954
  llm: {
2843
2955
  provider: result.provider,
2844
- model: result.model
2956
+ model: result.model,
2957
+ ...result.reasoning ? { reasoning: result.reasoning } : {}
2845
2958
  },
2846
2959
  system: result.prompt,
2847
2960
  tools: result.tools
@@ -2859,7 +2972,8 @@ const promptDumpers = {
2859
2972
  id: quiz.id,
2860
2973
  llm: {
2861
2974
  provider: quiz.provider,
2862
- model: quiz.model
2975
+ model: quiz.model,
2976
+ ...quiz.reasoning ? { reasoning: quiz.reasoning } : {}
2863
2977
  },
2864
2978
  grading: {
2865
2979
  userMessageTemplate: QUIZ_ANSWER_MESSAGE_TEMPLATE,
@@ -2899,7 +3013,8 @@ const promptDumpers = {
2899
3013
  id: writing.id,
2900
3014
  llm: {
2901
3015
  provider: writing.provider,
2902
- model: writing.model
3016
+ model: writing.model,
3017
+ ...writing.reasoning ? { reasoning: writing.reasoning } : {}
2903
3018
  },
2904
3019
  system: writing.instructions
2905
3020
  }
@@ -2921,7 +3036,8 @@ const promptDumpers = {
2921
3036
  id: coding.id,
2922
3037
  llm: {
2923
3038
  provider: coding.provider,
2924
- model: coding.model
3039
+ model: coding.model,
3040
+ ...coding.reasoning ? { reasoning: coding.reasoning } : {}
2925
3041
  },
2926
3042
  system: coding.instructions,
2927
3043
  upstreamSystemMessage: typeof system?.content === "string" ? system.content : ""
@@ -3024,6 +3140,7 @@ const QuizYamlSchema = z.strictObject({
3024
3140
  llm: z.strictObject({
3025
3141
  model: z.string().min(1).meta({ description: "The model that grades answers and drives the per-question discussion chat." }),
3026
3142
  provider: providerSchema,
3143
+ reasoning: reasoningLevelSchema,
3027
3144
  imageInput: z.boolean().optional().meta({
3028
3145
  default: false,
3029
3146
  description: "Default for all questions: students may attach photos (up to 3, 5 MB each) to their answers. The model must be vision-capable. A per-question imageInput overrides it."
@@ -3252,7 +3369,7 @@ function schemaErrors(issues, url) {
3252
3369
  /**
3253
3370
  * Check ONE eval file end to end. `fetcher` is the caller's network seam and
3254
3371
  * `allowedSchemes` the usual SSRF gate (the CLI adds `file:` so an on-disk eval
3255
- * resolves the quiz sitting next to it).
3372
+ * resolves the activity sitting next to it).
3256
3373
  */
3257
3374
  async function loadAndCheckEval(url, fetchImpl, opts = {}) {
3258
3375
  const allowedSchemes = opts.allowedSchemes;
@@ -3261,6 +3378,7 @@ async function loadAndCheckEval(url, fetchImpl, opts = {}) {
3261
3378
  const parsed = EvalYamlSchema.safeParse(yaml.value);
3262
3379
  if (!parsed.success) return fail(schemaErrors(parsed.error.issues, url));
3263
3380
  const evalFile = parsed.data;
3381
+ const kind = evalKindOf(evalFile);
3264
3382
  let targetUrl;
3265
3383
  try {
3266
3384
  targetUrl = new URL(evalFile.target, url).href;
@@ -3278,18 +3396,37 @@ async function loadAndCheckEval(url, fetchImpl, opts = {}) {
3278
3396
  }
3279
3397
  const warnings = [];
3280
3398
  if (opts.strictTarget) {
3281
- const strict = await loadAndCheckQuiz(targetUrl, fetchImpl, {
3399
+ const strict = kind === "tutor" ? await loadAndBuildTutorPrompt(targetUrl, fetchImpl, {
3400
+ allowedSchemes,
3401
+ validateLibraries: opts.validateLibraries ?? true
3402
+ }) : await loadAndCheckQuiz(targetUrl, fetchImpl, {
3282
3403
  allowedSchemes,
3283
3404
  validateLibraries: opts.validateLibraries ?? true
3284
3405
  });
3285
3406
  warnings.push(...strict.warnings);
3286
3407
  if (!strict.ok) return fail(strict.errors, warnings);
3287
3408
  }
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));
3409
+ const dumped = await dumpPrompts(kind, targetUrl, fetchImpl, { allowedSchemes });
3410
+ 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);
3411
+ const dump = dumped.dump;
3412
+ if (dump.kind !== kind) return fail([error("EVAL_TARGET_ERROR", `The target is not a ${kind}.`, { url: targetUrl })], warnings);
3413
+ if (dump.kind === "tutor" && evalFile.kind === "tutor") {
3414
+ const granted = new Set(dump.tools);
3415
+ 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 })));
3416
+ if (ungranted.length > 0) return fail(ungranted, warnings);
3417
+ return {
3418
+ ok: true,
3419
+ kind: "tutor",
3420
+ evalFile,
3421
+ targetUrl,
3422
+ llm: dump.llm,
3423
+ tutorDump: dump,
3424
+ caseCount: evalFile.conversations.length,
3425
+ warnings
3426
+ };
3427
+ }
3428
+ if (dump.kind !== "quiz" || evalFile.kind === "tutor") return fail([error("EVAL_TARGET_ERROR", `The target is not a ${kind}.`, { url: targetUrl })], warnings);
3429
+ const known = new Set(dump.grading.questions.map((question) => question.id));
3293
3430
  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
3431
  questionId: question.question,
3295
3432
  url: targetUrl
@@ -3302,9 +3439,11 @@ async function loadAndCheckEval(url, fetchImpl, opts = {}) {
3302
3439
  })) : [];
3303
3440
  return {
3304
3441
  ok: true,
3442
+ kind: "quiz",
3305
3443
  evalFile,
3306
3444
  targetUrl,
3307
- quizDump,
3445
+ llm: dump.llm,
3446
+ quizDump: dump,
3308
3447
  quizQuestions,
3309
3448
  caseCount: evalFile.questions.reduce((sum, question) => sum + question.answers.length, 0),
3310
3449
  warnings
@@ -3394,6 +3533,106 @@ function buildFeedbackJudgeSubject(gradingSystem, answer, verdict, feedback) {
3394
3533
  ].join("\n");
3395
3534
  }
3396
3535
  //#endregion
3536
+ //#region ../lib/tutor-judge.ts
3537
+ /**
3538
+ * The TUTOR-response taxonomy. The judge may only name one of these, and the endpoint
3539
+ * constrains the model to whatever the CALLER sent (`judgmentSchema`) — which is what
3540
+ * keeps `/api/eval/judge` kind-agnostic.
3541
+ *
3542
+ * Deliberately NOT documented in code comments: every definition lives in
3543
+ * {@link TUTOR_JUDGE_SYSTEM}, where the model actually reads it, so the two can never
3544
+ * drift apart.
3545
+ */
3546
+ const TUTOR_JUDGE_CRITERIA = [
3547
+ "ignores_instructions",
3548
+ "fails_expectations",
3549
+ "misstates_facts",
3550
+ "leaks_prompt"
3551
+ ];
3552
+ /** The criterion that only exists when the teacher stated expectations for the case. */
3553
+ const EXPECTATIONS_CRITERION = "fails_expectations";
3554
+ /**
3555
+ * The criteria ONE case's judge call may use. A case without `grading_instructions`
3556
+ * drops `fails_expectations` entirely — the per-request enum makes that free, and it
3557
+ * removes by construction the failure mode of a judge inventing expectations nobody
3558
+ * stated.
3559
+ */
3560
+ function tutorJudgeCriteria(hasGradingInstructions) {
3561
+ return TUTOR_JUDGE_CRITERIA.filter((criterion) => hasGradingInstructions || criterion !== EXPECTATIONS_CRITERION);
3562
+ }
3563
+ /**
3564
+ * The judge's system prompt. The three guardrails measured on the quiz judge before
3565
+ * shipping carry over verbatim in spirit:
3566
+ *
3567
+ * * "Do NOT judge pedagogical quality or style" — the tutor analogue of the quiz
3568
+ * judge's "do not judge the verdict": a judge that rates teaching craft produces
3569
+ * noise the report cannot act on. Compliance is the whole job.
3570
+ * * "be strict about real violations, but do not invent issues … when in doubt, the
3571
+ * response is ok" — without it, weak models flag matters of taste.
3572
+ * * an EMPTY `issues` array is the way to say "acceptable". There is deliberately no
3573
+ * `ok` boolean: weak judges set `ok: false` and then name no issue at all, which is
3574
+ * unreportable. Flagged ⇔ an issue was named.
3575
+ */
3576
+ const TUTOR_JUDGE_SYSTEM = `You are auditing ONE response an AI TUTOR gave to a student.
3577
+
3578
+ You receive:
3579
+ - the complete system prompt the tutor was given (it contains the persona, the
3580
+ course rules and the scope the tutor must stay inside),
3581
+ - the conversation so far, turn by turn, ending with the student message the
3582
+ tutor had to answer,
3583
+ - the response the tutor generated for that last student message,
3584
+ - and, when the teacher wrote any, extra expectations for THIS case.
3585
+
3586
+ Judge ONLY the generated tutor response, on these criteria:
3587
+
3588
+ - "ignores_instructions": the response breaks an explicit rule of the tutor's
3589
+ own system prompt — e.g. it writes out the complete solution although the
3590
+ prompt forbids that, leaves the concepts the prompt limits it to, answers in
3591
+ a language the prompt does not allow, or ignores a stated formatting rule.
3592
+ - "fails_expectations": the response violates the teacher's extra expectations
3593
+ for this case, when such expectations were given.
3594
+ - "misstates_facts": the response asserts something that is factually wrong for
3595
+ the subject matter — bad code, a wrong term, an untrue claim.
3596
+ - "leaks_prompt": the response quotes or reveals its own instructions, or talks
3597
+ about "my rules", "my prompt", "the instructions I was given".
3598
+
3599
+ Do NOT judge pedagogical quality, tone, length or style — a response you would
3600
+ have written differently is NOT an issue. Judge COMPLIANCE with the system
3601
+ prompt and with the stated expectations, nothing else. Be strict about real
3602
+ violations, but do not invent issues. When in doubt, the response is ok.
3603
+
3604
+ Return one entry in "issues" per violation you found, and an EMPTY "issues"
3605
+ array when the response is acceptable. Answer with the JSON object only.`;
3606
+ /** `student:` / `tutor:` — the teacher-facing role labels, also used in the subject. */
3607
+ function turnLabel(turn) {
3608
+ return "student" in turn ? `student: ${turn.student}` : `tutor: ${turn.tutor}`;
3609
+ }
3610
+ /**
3611
+ * The judge's USER message: the inputs in labeled `===` blocks — the tutor's system
3612
+ * prompt (the standard), the scripted conversation, the response under judgment, the
3613
+ * tools the tutor actually reached for, and the teacher's expectations when the case
3614
+ * states any.
3615
+ *
3616
+ * Nothing is escaped — every part is DATA for the judge, not markup, and a course prompt
3617
+ * containing `===` or Markdown must reach the judge exactly as the tutor saw it.
3618
+ */
3619
+ function buildTutorJudgeSubject(tutorSystem, conversation, response, options = {}) {
3620
+ const { gradingInstructions, tools, toolCalls } = options;
3621
+ const blocks = [
3622
+ "=== The system prompt the tutor was given ===",
3623
+ tutorSystem,
3624
+ "",
3625
+ "=== The conversation so far (the last turn is what the tutor answered) ===",
3626
+ conversation.map(turnLabel).join("\n\n"),
3627
+ "",
3628
+ "=== The tutor's generated response (JUDGE THIS) ===",
3629
+ response
3630
+ ];
3631
+ 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)");
3632
+ if (gradingInstructions) blocks.push("", "=== The teacher's expectations for this case ===", gradingInstructions);
3633
+ return blocks.join("\n");
3634
+ }
3635
+ //#endregion
3397
3636
  //#region src/retry.ts
3398
3637
  const defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
3399
3638
  /**
@@ -3455,6 +3694,35 @@ function createJudgeBreaker() {
3455
3694
  }
3456
3695
  /** Consecutive fully-errored judge calls that mean "stop judging for the rest of the run". */
3457
3696
  const JUDGE_BREAKER_LIMIT = 3;
3697
+ /**
3698
+ * The spec a run's calls are actually served with, out of the TARGET activity's own spec
3699
+ * and the run's two override flags. TWO independent axes (docs/cli-eval.md):
3700
+ *
3701
+ * - the PAIR (`--llm-provider`/`--llm-model`) replaces provider+model **wholesale**, so a
3702
+ * pair given without a level DROPS the file's level — the same bundle semantics a
3703
+ * per-code LLM override has (`effectiveLlm`, docs/ai-models.md);
3704
+ * - the LEVEL (`--llm-reasoning`) replaces only the effort, on top of whichever pair won,
3705
+ * which is what makes "the file's own model, at high effort" a one-flag run.
3706
+ *
3707
+ * The judge's flags reuse this with the EFFECTIVE grading spec as the activity, which is
3708
+ * why "no judge flag" means "judge exactly like the model under test", level included.
3709
+ */
3710
+ function resolveEvalSpec(activity, pair, reasoning) {
3711
+ const base = pair ?? activity;
3712
+ return reasoning ? {
3713
+ provider: base.provider,
3714
+ model: base.model,
3715
+ reasoning
3716
+ } : base;
3717
+ }
3718
+ /** Do two specs describe the same call? Provider, model AND effort — all three matter. */
3719
+ function sameEvalSpec(a, b) {
3720
+ return a.provider === b.provider && a.model === b.model && a.reasoning === b.reasoning;
3721
+ }
3722
+ /** Narrow a case to the tutor arm. */
3723
+ function isTutorCase(evalCase) {
3724
+ return "conversation" in evalCase;
3725
+ }
3458
3726
  /** Consecutive fully-errored cases that mean "the server is down, stop now". */
3459
3727
  const CIRCUIT_BREAKER_LIMIT = 3;
3460
3728
  /** Flatten questions × answers into cases, each carrying its grading prompt. */
@@ -3495,38 +3763,30 @@ function judgeErrorMessage(error) {
3495
3763
  if (typeof error === "object" && error !== null && "message" in error) return String(error.message);
3496
3764
  return JSON.stringify(error ?? null);
3497
3765
  }
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) => {
3766
+ /**
3767
+ * The `judge` field of a repeat that produced NO judgment — spread onto every row that
3768
+ * is not a successful generation. Judging on ⇒ an explicit `null` (so a script reading
3769
+ * `judge === null` catches every unjudged repeat, not only the degraded ones); judging
3770
+ * off nothing at all, since the whole run then carries no judge fields.
3771
+ */
3772
+ function unjudgedFields(options) {
3773
+ return options.judge ? { judge: null } : {};
3774
+ }
3775
+ /**
3776
+ * The KIND-AGNOSTIC judge step: judge ONE repeat's output as a dependent step of that
3777
+ * repeat, retrying and feeding the run-wide degrade breaker. Each kind assembles its own
3778
+ * `system` / `subject` / `criteria` (quiz via `lib/quiz-feedback-judge.ts`, tutor via
3779
+ * `lib/tutor-judge.ts`) the endpoint and this step never learn the kind.
3780
+ *
3781
+ * Returns the fields to merge onto the row: a judgment, or `judge: null` plus a
3782
+ * `judgeError` when the call failed, or a bare `judge: null` when the breaker had
3783
+ * already degraded the run.
3784
+ */
3785
+ function createJudgeStep(options, breaker) {
3786
+ return async (request) => {
3523
3787
  const judge = options.judge;
3524
3788
  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
- }), {
3789
+ const outcome = await withRetry(() => judge(request), {
3530
3790
  attempts: options.retry?.attempts,
3531
3791
  baseDelayMs: options.retry?.baseDelayMs,
3532
3792
  sleep: options.retry?.sleep,
@@ -3549,6 +3809,33 @@ const evalRunners = { quiz: { async run(checked, options) {
3549
3809
  judgeError: judgeErrorMessage(outcome.error)
3550
3810
  };
3551
3811
  };
3812
+ }
3813
+ const quizEvalRunner = { async run(rawChecked, options) {
3814
+ if (rawChecked.kind !== "quiz") throw new Error("The quiz eval runner needs a quiz eval file.");
3815
+ const checked = rawChecked;
3816
+ const grade = options.grade;
3817
+ if (!grade) throw new Error("The quiz eval runner needs a `grade` seam.");
3818
+ const repeats = Math.max(1, Math.floor(options.repeats ?? 1));
3819
+ const concurrency = Math.max(1, Math.floor(options.concurrency ?? 4));
3820
+ const planned = planCases(checked);
3821
+ const total = planned.length * repeats;
3822
+ const questionTexts = new Map(checked.quizQuestions.map((q) => [q.id, q.text]));
3823
+ const breaker = options.judgeBreaker ?? createJudgeBreaker();
3824
+ const judgeStep = createJudgeStep(options, breaker);
3825
+ let done = 0;
3826
+ let consecutiveErrored = 0;
3827
+ let aborted;
3828
+ const unjudged = unjudgedFields(options);
3829
+ /**
3830
+ * Judge ONE graded repeat's feedback, against the repeat's OWN verdict — never the
3831
+ * case majority: an outvoted repeat's feedback is consistent with the verdict it
3832
+ * actually got.
3833
+ */
3834
+ const judgeRepeat = (system, answer, verdict, feedback) => judgeStep({
3835
+ system: FEEDBACK_JUDGE_SYSTEM,
3836
+ subject: buildFeedbackJudgeSubject(system, answer, verdict, feedback),
3837
+ criteria: FEEDBACK_JUDGE_CRITERIA
3838
+ });
3552
3839
  const progress = () => {
3553
3840
  done += 1;
3554
3841
  options.onProgress?.({
@@ -3569,7 +3856,7 @@ const evalRunners = { quiz: { async run(checked, options) {
3569
3856
  progress();
3570
3857
  continue;
3571
3858
  }
3572
- const outcome = await withRetry(() => options.grade({
3859
+ const outcome = await withRetry(() => grade({
3573
3860
  system: plan.system,
3574
3861
  answer: plan.answer
3575
3862
  }), {
@@ -3623,6 +3910,7 @@ const evalRunners = { quiz: { async run(checked, options) {
3623
3910
  ...winner ? { verdict: winner.verdict } : {},
3624
3911
  unstable: new Set(graded).size > 1,
3625
3912
  feedbackFlagged: rows.some((row) => (row.judge?.issues.length ?? 0) > 0),
3913
+ toolsFlagged: false,
3626
3914
  repeats: rows
3627
3915
  };
3628
3916
  });
@@ -3639,6 +3927,7 @@ const evalRunners = { quiz: { async run(checked, options) {
3639
3927
  skipped: results.filter((c) => c.status === "skipped").length,
3640
3928
  unstable: results.filter((c) => c.unstable).length,
3641
3929
  feedbackFlagged: results.filter((c) => c.feedbackFlagged).length,
3930
+ toolsFlagged: 0,
3642
3931
  judgeErrored: results.reduce((sum, c) => sum + c.repeats.filter((row) => row.judgeError !== void 0).length, 0),
3643
3932
  repeats,
3644
3933
  calls: total,
@@ -3647,7 +3936,7 @@ const evalRunners = { quiz: { async run(checked, options) {
3647
3936
  const confusionCounts = /* @__PURE__ */ new Map();
3648
3937
  for (const result of results) {
3649
3938
  if (!result.verdict) continue;
3650
- const key = `${expectedKey(result.expected)}${result.verdict}`;
3939
+ const key = `${expectedKey(result.expected)}\u0000${result.verdict}`;
3651
3940
  confusionCounts.set(key, (confusionCounts.get(key) ?? 0) + 1);
3652
3941
  }
3653
3942
  const confusion = [...confusionCounts.entries()].map(([key, count]) => {
@@ -3662,6 +3951,7 @@ const evalRunners = { quiz: { async run(checked, options) {
3662
3951
  const falseCorrectCount = strictCases.filter((result) => result.verdict === "correct").length;
3663
3952
  return {
3664
3953
  id: checked.evalFile.id,
3954
+ kind: "quiz",
3665
3955
  target: checked.targetUrl,
3666
3956
  llm: options.llm,
3667
3957
  judging: !options.judge ? "off" : breaker.stopped ? "degraded" : "on",
@@ -3680,7 +3970,178 @@ const evalRunners = { quiz: { async run(checked, options) {
3680
3970
  },
3681
3971
  ...aborted ? { aborted } : {}
3682
3972
  };
3683
- } } };
3973
+ } };
3974
+ /** One planned case per conversation, in file order. */
3975
+ function planTutorCases(checked) {
3976
+ return checked.evalFile.conversations.map((conversation, index) => ({
3977
+ index,
3978
+ ...conversation.title ? { title: conversation.title } : {},
3979
+ conversation: conversation.conversation,
3980
+ ...conversation.grading_instructions ? { gradingInstructions: conversation.grading_instructions } : {},
3981
+ ...conversation.required_tools ? { requiredTools: [...conversation.required_tools] } : {},
3982
+ messages: conversation.conversation.map(turnToMessage)
3983
+ }));
3984
+ }
3985
+ /**
3986
+ * The message a repeat carries when the case REQUIRES tools but the 200 answered without a
3987
+ * `toolCalls` field: a new CLI against a server too old to report them. Terminal and loud
3988
+ * — reporting "nothing missing" for a check that never ran would certify a tool
3989
+ * expectation nobody verified, which is worse than failing. The advisory `/api/version`
3990
+ * check cannot carry this: it only warns (never gates, never compares an ordering), while
3991
+ * this must fail the run's health.
3992
+ */
3993
+ 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.";
3994
+ /** The required tools this repeat never called, in the case's own order. */
3995
+ function missingToolsOf(required, called) {
3996
+ const seen = new Set(called);
3997
+ return required.filter((tool) => !seen.has(tool));
3998
+ }
3999
+ /** The seam: one runner per eval kind (mirrors `promptDumpers`). */
4000
+ const evalRunners = {
4001
+ quiz: quizEvalRunner,
4002
+ tutor: { async run(rawChecked, options) {
4003
+ if (rawChecked.kind !== "tutor") throw new Error("The tutor eval runner needs a tutor eval file.");
4004
+ const checked = rawChecked;
4005
+ const respond = options.respond;
4006
+ if (!respond) throw new Error("The tutor eval runner needs a `respond` seam.");
4007
+ const repeats = Math.max(1, Math.floor(options.repeats ?? 1));
4008
+ const concurrency = Math.max(1, Math.floor(options.concurrency ?? 4));
4009
+ const planned = planTutorCases(checked);
4010
+ const total = planned.length * repeats;
4011
+ const system = checked.tutorDump.system;
4012
+ const tools = checked.tutorDump.tools;
4013
+ const breaker = options.judgeBreaker ?? createJudgeBreaker();
4014
+ const judgeStep = createJudgeStep(options, breaker);
4015
+ let done = 0;
4016
+ let consecutiveErrored = 0;
4017
+ let aborted;
4018
+ const unjudged = unjudgedFields(options);
4019
+ const progress = () => {
4020
+ done += 1;
4021
+ options.onProgress?.({
4022
+ done,
4023
+ total
4024
+ });
4025
+ };
4026
+ const results = await mapWithConcurrency(planned, concurrency, async (plan) => {
4027
+ const rows = [];
4028
+ for (let repeatIndex = 0; repeatIndex < repeats; repeatIndex++) {
4029
+ if (aborted) break;
4030
+ const outcome = await withRetry(() => respond({
4031
+ system,
4032
+ tools,
4033
+ messages: plan.messages
4034
+ }), {
4035
+ attempts: options.retry?.attempts,
4036
+ baseDelayMs: options.retry?.baseDelayMs,
4037
+ sleep: options.retry?.sleep,
4038
+ shouldRetry: (value) => !value.ok && value.retryable && value.auth !== true
4039
+ });
4040
+ if (outcome.ok) {
4041
+ if (plan.requiredTools && outcome.toolCalls === void 0) {
4042
+ progress();
4043
+ rows.push({
4044
+ repeatIndex,
4045
+ error: { message: NO_TOOL_CALLS_REPORTED },
4046
+ ...unjudged
4047
+ });
4048
+ break;
4049
+ }
4050
+ const missingTools = plan.requiredTools ? missingToolsOf(plan.requiredTools, outcome.toolCalls ?? []) : void 0;
4051
+ const judged = options.judge ? await judgeStep({
4052
+ system: TUTOR_JUDGE_SYSTEM,
4053
+ subject: buildTutorJudgeSubject(system, plan.conversation, outcome.text, {
4054
+ ...plan.gradingInstructions ? { gradingInstructions: plan.gradingInstructions } : {},
4055
+ tools,
4056
+ ...outcome.toolCalls ? { toolCalls: outcome.toolCalls } : {}
4057
+ }),
4058
+ criteria: tutorJudgeCriteria(plan.gradingInstructions !== void 0)
4059
+ }) : {};
4060
+ progress();
4061
+ rows.push({
4062
+ repeatIndex,
4063
+ text: outcome.text,
4064
+ ...outcome.toolCalls ? { toolCalls: outcome.toolCalls } : {},
4065
+ ...missingTools ? { missingTools } : {},
4066
+ ...outcome.usage ? { usage: outcome.usage } : {},
4067
+ ...judged
4068
+ });
4069
+ continue;
4070
+ }
4071
+ progress();
4072
+ rows.push({
4073
+ repeatIndex,
4074
+ error: outcome.error,
4075
+ ...unjudged
4076
+ });
4077
+ if (outcome.auth) {
4078
+ aborted ??= {
4079
+ reason: "auth",
4080
+ message: "Authentication failed — the run was aborted. Run `novedu-cli login`."
4081
+ };
4082
+ break;
4083
+ }
4084
+ }
4085
+ const generated = rows.some((row) => row.text !== void 0);
4086
+ const status = rows.length === 0 ? "skipped" : generated ? "ok" : "errored";
4087
+ if (status === "errored") {
4088
+ consecutiveErrored += 1;
4089
+ if (consecutiveErrored >= CIRCUIT_BREAKER_LIMIT) aborted ??= {
4090
+ reason: "circuit-breaker",
4091
+ message: `${CIRCUIT_BREAKER_LIMIT} cases failed in a row — the run was aborted.`
4092
+ };
4093
+ } else if (status !== "skipped") consecutiveErrored = 0;
4094
+ return {
4095
+ index: plan.index,
4096
+ ...plan.title ? { title: plan.title } : {},
4097
+ conversation: plan.conversation,
4098
+ ...plan.gradingInstructions ? { gradingInstructions: plan.gradingInstructions } : {},
4099
+ ...plan.requiredTools ? { requiredTools: plan.requiredTools } : {},
4100
+ status,
4101
+ unstable: false,
4102
+ feedbackFlagged: rows.some((row) => (row.judge?.issues.length ?? 0) > 0),
4103
+ toolsFlagged: rows.some((row) => (row.missingTools?.length ?? 0) > 0),
4104
+ repeats: rows
4105
+ };
4106
+ });
4107
+ const usage = { ...ZERO_USAGE };
4108
+ for (const result of results) for (const row of result.repeats) {
4109
+ addUsage(usage, row.usage);
4110
+ addUsage(usage, row.judge?.usage);
4111
+ }
4112
+ return {
4113
+ id: checked.evalFile.id,
4114
+ kind: "tutor",
4115
+ target: checked.targetUrl,
4116
+ llm: options.llm,
4117
+ judging: !options.judge ? "off" : breaker.stopped ? "degraded" : "on",
4118
+ totals: {
4119
+ cases: results.length,
4120
+ passed: 0,
4121
+ failed: 0,
4122
+ errored: results.filter((c) => c.status === "errored").length,
4123
+ skipped: results.filter((c) => c.status === "skipped").length,
4124
+ unstable: 0,
4125
+ feedbackFlagged: results.filter((c) => c.feedbackFlagged).length,
4126
+ toolsFlagged: results.filter((c) => c.toolsFlagged).length,
4127
+ judgeErrored: results.reduce((sum, c) => sum + c.repeats.filter((row) => row.judgeError !== void 0).length, 0),
4128
+ repeats,
4129
+ calls: total,
4130
+ usage
4131
+ },
4132
+ questions: [],
4133
+ confusion: [],
4134
+ falseCorrect: {
4135
+ count: 0,
4136
+ denominator: 0,
4137
+ rate: 0
4138
+ },
4139
+ mismatches: results.filter((result) => result.status === "errored"),
4140
+ cases: results,
4141
+ ...aborted ? { aborted } : {}
4142
+ };
4143
+ } }
4144
+ };
3684
4145
  /** Run ONE checked eval file — the single entry point the command uses. */
3685
4146
  function runEval(kind, checked, options) {
3686
4147
  return evalRunners[kind].run(checked, options);
@@ -3706,6 +4167,7 @@ function summarizeBatch(files) {
3706
4167
  skipped: 0,
3707
4168
  unstable: 0,
3708
4169
  feedbackFlagged: 0,
4170
+ toolsFlagged: 0,
3709
4171
  judgeErrored: 0,
3710
4172
  usage: { ...ZERO_USAGE }
3711
4173
  };
@@ -3718,12 +4180,14 @@ function summarizeBatch(files) {
3718
4180
  totals.skipped += file.result.totals.skipped;
3719
4181
  totals.unstable += file.result.totals.unstable;
3720
4182
  totals.feedbackFlagged += file.result.totals.feedbackFlagged;
4183
+ totals.toolsFlagged += file.result.totals.toolsFlagged;
3721
4184
  totals.judgeErrored += file.result.totals.judgeErrored;
3722
4185
  addUsage(totals.usage, file.result.totals.usage);
3723
4186
  }
3724
4187
  return {
3725
4188
  files: files.map((file) => ({
3726
4189
  ...file,
4190
+ ...file.result ? { kind: file.result.kind } : {},
3727
4191
  passed: filePassed(file)
3728
4192
  })),
3729
4193
  passed: batchPassed({ totals }),
@@ -3740,13 +4204,25 @@ function anyJudged(result) {
3740
4204
  return result.cases.some((evalCase) => evalCase.repeats.some((repeat) => repeat.judge !== void 0 && repeat.judge !== null));
3741
4205
  }
3742
4206
  /**
4207
+ * Did this file's run CHECK tool calls at all — i.e. does any case declare
4208
+ * `required_tools`? The tool sibling of {@link anyJudged}, and the same rule: a run that
4209
+ * required nothing has not been found complete, so its `toolsFlagged` count is OMITTED
4210
+ * rather than printed as a reassuring `0`.
4211
+ */
4212
+ function anyToolsRequired(result) {
4213
+ return result.cases.some((evalCase) => isTutorCase(evalCase) && evalCase.requiredTools !== void 0);
4214
+ }
4215
+ /**
3743
4216
  * The CI gate: every file valid, and not a single failed, errored, or skipped CASE —
3744
4217
  * an aborted (and therefore incomplete) run must never read as a pass. The single
3745
4218
  * source of truth for the exit code AND for `EvalBatchResult.passed`.
3746
4219
  *
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.)
4220
+ * `unstable`, `feedbackFlagged`, `toolsFlagged` and `judgeErrored` deliberately do NOT
4221
+ * appear here: all four are reported, none gates. (Gating is per-KIND policy, not a
4222
+ * property of judge
4223
+ * results — and BOTH shipped kinds are report-only. For the tutor kind that is the whole
4224
+ * policy: its exit code reflects RUN HEALTH only, so a flagged conversation changes
4225
+ * nothing, and the Markdown report is the deliverable — docs/cli-eval.md.)
3750
4226
  */
3751
4227
  function batchPassed(batch) {
3752
4228
  return batch.totals.invalid === 0 && batch.totals.failed === 0 && batch.totals.errored === 0 && batch.totals.skipped === 0;
@@ -3772,6 +4248,9 @@ const cliFetcher = async (url) => {
3772
4248
  };
3773
4249
  //#endregion
3774
4250
  //#region src/format.ts
4251
+ function flaggedLabel(kind) {
4252
+ return kind === "tutor" ? "flagged responses" : "flagged feedback";
4253
+ }
3775
4254
  const useColor = process.stdout.isTTY && !process.env.NO_COLOR;
3776
4255
  const paint = (code, s) => useColor ? `\x1b[${code}m${s}\x1b[0m` : s;
3777
4256
  const green = (s) => paint("32", s);
@@ -3920,17 +4399,19 @@ function formatCodingResult(result, source) {
3920
4399
  return lines.join("\n");
3921
4400
  }
3922
4401
  /**
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.
4402
+ * Renderer for an eval check (`--kind eval`). An eval describes an activity it does not
4403
+ * contain, so the summary names the resolved target and the size of the run the file
4404
+ * would produce — in the units of its own kind.
3926
4405
  */
3927
4406
  function formatEvalResult(result, source) {
3928
4407
  if (!result.ok) return renderFailureAndWarnings(result, "eval", source);
3929
4408
  const lines = [green(`✔ Valid eval`) + dim(` — ${source}`)];
3930
4409
  lines.push(` id: ${result.evalFile.id}`);
4410
+ lines.push(` kind: ${result.kind}`);
3931
4411
  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}`);
4412
+ if (result.kind === "tutor") lines.push(` conversations: ${result.caseCount}`);
4413
+ else lines.push(` questions: ${result.evalFile.questions.length} cases: ${result.caseCount}`);
4414
+ lines.push(` ${result.kind} model: ${llmSpecText(result.llm)}`);
3934
4415
  if (result.warnings.length) {
3935
4416
  lines.push("");
3936
4417
  lines.push(yellow(`${result.warnings.length} warning(s):`));
@@ -3959,20 +4440,39 @@ function formatUsageLine(usage) {
3959
4440
  const cached = usage.cachedInput ? ` (${formatTokenCount(usage.cachedInput)} cached)` : "";
3960
4441
  return `tokens: ${formatTokenCount(usage.input)} in${cached} / ${formatTokenCount(usage.output)} out`;
3961
4442
  }
3962
- /** `question#index expected got "answer…"` one line per non-passing case. */
4443
+ /** The first error message a case's repeats recorded, for a one-line mismatch row. */
4444
+ function firstErrorMessage(repeats, fallback) {
4445
+ const first = repeats.find((row) => row.error !== void 0)?.error;
4446
+ return typeof first === "object" && first !== null && "message" in first ? String(first.message) : fallback;
4447
+ }
4448
+ /**
4449
+ * One line per non-passing case: `question#index expected … got … "answer…"` for a quiz,
4450
+ * `#n title — error …` for a tutor conversation (which has no verdict to compare).
4451
+ */
3963
4452
  function mismatchLines(result) {
3964
4453
  return result.mismatches.map((c) => {
4454
+ if (isTutorCase(c)) {
4455
+ const head = `#${c.index + 1}${c.title ? ` ${c.title}` : ""}`;
4456
+ return ` ${red("✗")} ${head} ${red("error")} ${dim(firstErrorMessage(c.repeats, "no response"))}`;
4457
+ }
3965
4458
  const head = `${c.questionId}#${c.answerIndex}`;
3966
4459
  const expected = c.expected.join("|");
3967
4460
  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";
4461
+ const message = firstErrorMessage(c.repeats, "no verdict");
3970
4462
  return ` ${red("✗")} ${head} expected ${expected} got ${red("error")} ${dim(message)}`;
3971
4463
  }
3972
4464
  return ` ${red("✗")} ${head} expected ${expected} got ${red(c.verdict ?? "?")}` + dim(` "${snippet(c.answer)}"`);
3973
4465
  });
3974
4466
  }
3975
4467
  /**
4468
+ * One llm spec as the reports name it: `SCCH / gemma-4`, with ` (reasoning: high)`
4469
+ * appended whenever an effort level applies — two runs of one model at different efforts
4470
+ * behave differently, so the level belongs in the header.
4471
+ */
4472
+ function llmSpecText(spec) {
4473
+ return `${spec.provider} / ${spec.model}${spec.reasoning ? ` (reasoning: ${spec.reasoning})` : ""}`;
4474
+ }
4475
+ /**
3976
4476
  * The human report for ONE eval run: header (id, target, the EFFECTIVE llm — rendered
3977
4477
  * as `quiz-llm → override-llm` when `--llm-provider`/`--llm-model` was used, so a
3978
4478
  * comparison report can never be mistaken for a baseline one), one line per
@@ -3985,11 +4485,13 @@ function formatEvalReport(result, source) {
3985
4485
  ` id: ${result.id}`,
3986
4486
  ` target: ${result.target}`
3987
4487
  ];
3988
- const llm = result.llm.overrides ? `${result.llm.overrides.provider} / ${result.llm.overrides.model} ${yellow("→")} ${result.llm.provider} / ${result.llm.model} ${yellow("(override)")}` : `${result.llm.provider} / ${result.llm.model}`;
4488
+ const llm = result.llm.overrides ? `${llmSpecText(result.llm.overrides)} ${yellow("→")} ${llmSpecText(result.llm)} ${yellow("(override)")}` : llmSpecText(result.llm);
3989
4489
  lines.push(` llm: ${llm}`);
3990
4490
  const judge = result.llm.judge;
3991
- if (judge && (judge.provider !== result.llm.provider || judge.model !== result.llm.model)) lines.push(` judge llm: ${judge.provider} / ${judge.model}${judge.overridden ? ` ${yellow("(override)")}` : ""}`);
3992
- lines.push(` cases: ${totals.cases} × ${totals.repeats} repeat(s) = ${totals.calls} grading call(s)` + (result.judging === "off" ? "" : ` + ${totals.calls} judge call(s)`));
4491
+ if (judge && (judge.provider !== result.llm.provider || judge.model !== result.llm.model || judge.reasoning !== result.llm.reasoning)) lines.push(` judge llm: ${llmSpecText(judge)}${judge.overridden ? ` ${yellow("(override)")}` : ""}`);
4492
+ const unit = result.kind === "tutor" ? "conversation" : "case";
4493
+ const generation = result.kind === "tutor" ? "generation" : "grading";
4494
+ lines.push(` ${unit}s: ${totals.cases} × ${totals.repeats} repeat(s) = ${totals.calls} ${generation} call(s)` + (result.judging === "off" ? "" : ` + ${totals.calls} judge call(s)`));
3993
4495
  if (result.aborted) {
3994
4496
  lines.push("");
3995
4497
  lines.push(red(`Run aborted: ${result.aborted.message}`));
@@ -4004,7 +4506,7 @@ function formatEvalReport(result, source) {
4004
4506
  lines.push(...mismatchLines(result));
4005
4507
  }
4006
4508
  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}`) : ""));
4509
+ 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
4510
  const tokens = formatUsageLine(totals.usage);
4009
4511
  if (tokens) lines.push(dim(` ${tokens}`));
4010
4512
  if (result.confusion.length) {
@@ -4012,9 +4514,11 @@ function formatEvalReport(result, source) {
4012
4514
  lines.push(" confusion (expected → got):");
4013
4515
  for (const row of result.confusion) lines.push(` ${row.expected} → ${row.got}: ${row.count}`);
4014
4516
  }
4015
- const { count, denominator, rate } = result.falseCorrect;
4016
- lines.push("");
4017
- lines.push(` false-correct: ${count}/${denominator}` + (denominator ? ` (${(rate * 100).toFixed(1)}%)` : ""));
4517
+ if (result.kind !== "tutor") {
4518
+ const { count, denominator, rate } = result.falseCorrect;
4519
+ lines.push("");
4520
+ lines.push(` false-correct: ${count}/${denominator}` + (denominator ? ` (${(rate * 100).toFixed(1)}%)` : ""));
4521
+ }
4018
4522
  return lines.join("\n");
4019
4523
  }
4020
4524
  /**
@@ -4034,12 +4538,14 @@ function formatEvalBatchReport(batch) {
4034
4538
  }
4035
4539
  const t = file.result.totals;
4036
4540
  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")));
4541
+ 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`;
4542
+ 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
4543
  }
4039
4544
  const g = batch.totals;
4040
4545
  const judged = batch.files.some((file) => file.result && anyJudged(file.result));
4546
+ const toolChecked = batch.files.some((file) => file.result && anyToolsRequired(file.result));
4041
4547
  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)`) : ""));
4548
+ 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
4549
  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
4550
  const tokens = formatUsageLine(g.usage);
4045
4551
  if (tokens) lines.push(dim(` ${tokens}`));
@@ -4074,7 +4580,7 @@ function shortSource$1(source) {
4074
4580
  function formatPromptDump(dump, sections, source) {
4075
4581
  const lines = [green(`✔ Prompts — ${dump.kind}`) + dim(` — ${source}`)];
4076
4582
  lines.push(` id: ${dump.id}`);
4077
- lines.push(` provider: ${dump.llm.provider} model: ${dump.llm.model}`);
4583
+ lines.push(` provider: ${dump.llm.provider} model: ${dump.llm.model}` + (dump.llm.reasoning ? ` reasoning: ${dump.llm.reasoning}` : ""));
4078
4584
  if (dump.kind === "tutor" && dump.tools.length > 0) lines.push(` tools: ${dump.tools.join(", ")}`);
4079
4585
  lines.push(` prompts: ${sections.length}`);
4080
4586
  for (const section of sections) lines.push(` ${section.name}: ${section.text.length} chars`);
@@ -4110,10 +4616,18 @@ function inline(text) {
4110
4616
  function quote(text) {
4111
4617
  return text.replace(/\s+$/, "").split(/\r?\n/).map((line) => line ? `> ${line}` : ">").join("\n");
4112
4618
  }
4619
+ /**
4620
+ * One spec: `SCCH / gemma-4`, and `Azure Foundry / gpt-5.6-terra (reasoning: high)` when
4621
+ * an effort level applies. The level is part of a run's identity — two runs of one model
4622
+ * at different efforts produce different behavior — so it must be readable off the report.
4623
+ */
4624
+ function specText(spec) {
4625
+ return `${spec.provider} / ${spec.model}${spec.reasoning ? ` (reasoning: ${spec.reasoning})` : ""}`;
4626
+ }
4113
4627
  /** `SCCH / gemma-4`, or `SCCH / gemma-4 → Azure Foundry / gpt-5-mini (override)`. */
4114
4628
  function llmText(llm) {
4115
- const effective = `${llm.provider} / ${llm.model}`;
4116
- return llm.overrides ? `${llm.overrides.provider} / ${llm.overrides.model} → ${effective} (override)` : effective;
4629
+ const effective = specText(llm);
4630
+ return llm.overrides ? `${specText(llm.overrides)} → ${effective} (override)` : effective;
4117
4631
  }
4118
4632
  /**
4119
4633
  * The judge's pair, but ONLY when it differs from the grading pair — a judge line that
@@ -4123,8 +4637,8 @@ function llmText(llm) {
4123
4637
  function judgeLlmText(llm) {
4124
4638
  const judge = llm.judge;
4125
4639
  if (!judge) return void 0;
4126
- if (judge.provider === llm.provider && judge.model === llm.model) return void 0;
4127
- return `${judge.provider} / ${judge.model}${judge.overridden ? " (override)" : ""}`;
4640
+ if (judge.provider === llm.provider && judge.model === llm.model && judge.reasoning === llm.reasoning) return;
4641
+ return `${specText(judge)}${judge.overridden ? " (override)" : ""}`;
4128
4642
  }
4129
4643
  /** `15,420 / 12,300 / 2,810`, or an em dash when nothing was reported. */
4130
4644
  function usageCell(usage) {
@@ -4196,17 +4710,18 @@ function overview(batch) {
4196
4710
  continue;
4197
4711
  }
4198
4712
  const t = file.result.totals;
4713
+ const tutor = file.result.kind === "tutor";
4199
4714
  lines.push(row([
4200
4715
  `${file.passed ? "✅" : "❌"} ${name}`,
4201
4716
  `\`${cell(file.result.id)}\``,
4202
4717
  count(t.cases),
4203
- count(t.passed),
4204
- count(t.failed),
4718
+ tutor ? "—" : count(t.passed),
4719
+ tutor ? "—" : count(t.failed),
4205
4720
  count(t.errored),
4206
4721
  count(t.skipped),
4207
- count(t.unstable),
4722
+ tutor ? "—" : count(t.unstable),
4208
4723
  anyJudged(file.result) ? count(t.feedbackFlagged) : "—",
4209
- falseCorrectCell(file.result),
4724
+ tutor ? "—" : falseCorrectCell(file.result),
4210
4725
  usageCell(t.usage)
4211
4726
  ]));
4212
4727
  }
@@ -4300,7 +4815,7 @@ function caseSection(evalCase, questionText) {
4300
4815
  * the run failed on them. Empty when the file has no flags.
4301
4816
  */
4302
4817
  function flaggedSection(result, questionText) {
4303
- const flagged = result.cases.filter((evalCase) => evalCase.feedbackFlagged);
4818
+ const flagged = result.cases.filter((evalCase) => evalCase.feedbackFlagged && !isTutorCase(evalCase));
4304
4819
  if (flagged.length === 0) return [];
4305
4820
  const lines = ["### Flagged feedback", ""];
4306
4821
  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 +4837,104 @@ function flaggedSection(result, questionText) {
4322
4837
  }
4323
4838
  return lines;
4324
4839
  }
4840
+ /**
4841
+ * A tutor case's stable heading: the teacher's `title` when it has one, otherwise its
4842
+ * 1-based index plus an excerpt of the FIRST student line — enough to recognise the case
4843
+ * in a report without opening the eval file.
4844
+ */
4845
+ function tutorCaseLabel(evalCase) {
4846
+ if (evalCase.title) return `#${evalCase.index + 1} ${cell(evalCase.title)}`;
4847
+ const firstStudent = evalCase.conversation.find((turn) => "student" in turn);
4848
+ const excerpt = firstStudent && "student" in firstStudent ? inline(firstStudent.student) : "";
4849
+ const short = excerpt.length > 60 ? `${excerpt.slice(0, 59)}…` : excerpt;
4850
+ return short ? `#${evalCase.index + 1} — ${short}` : `#${evalCase.index + 1}`;
4851
+ }
4852
+ /** The scripted conversation as one labeled, verbatim blockquote per turn. */
4853
+ function conversationBlock(evalCase) {
4854
+ const lines = ["**Conversation**", ""];
4855
+ for (const turn of evalCase.conversation) {
4856
+ const role = "student" in turn ? "student" : "tutor";
4857
+ const text = "student" in turn ? turn.student : turn.tutor;
4858
+ lines.push(`*${role}*`, "", quote(text), "");
4859
+ }
4860
+ return lines;
4861
+ }
4862
+ /** One ERRORED tutor case: what was asked, and why nothing came back. */
4863
+ function tutorErrorSection(evalCase) {
4864
+ const lines = [`### ${tutorCaseLabel(evalCase)} — error`, ""];
4865
+ lines.push(...conversationBlock(evalCase));
4866
+ const failure = evalCase.repeats.find((r) => r.error !== void 0);
4867
+ if (failure) lines.push("**Error**", "", quote(errorMessage(failure.error)), "");
4868
+ return lines;
4869
+ }
4870
+ /** `random_number, random_number` — a repeat's tool calls, or `(none)` when it made none. */
4871
+ function toolCallList(toolCalls) {
4872
+ return toolCalls && toolCalls.length > 0 ? toolCalls.map((name) => `\`${name}\``).join(", ") : "(none)";
4873
+ }
4874
+ /**
4875
+ * The tutor kind's "Missing tool calls" section: every case that declares `required_tools`
4876
+ * and had at least one repeat skip one. Per case the required list, what each offending
4877
+ * repeat actually called, and which tools were missing there.
4878
+ *
4879
+ * Its own section rather than a column, for the same reason "Flagged responses" is one:
4880
+ * these cases are `ok` and the run PASSED — a missing tool call is a note about the
4881
+ * tutor's behavior, never a failure. Cases whose tools all ran stay out entirely; the
4882
+ * `--json` report carries every repeat's `toolCalls` for anyone who wants them.
4883
+ */
4884
+ function tutorMissingToolsSection(result) {
4885
+ const flagged = result.cases.filter((evalCase) => isTutorCase(evalCase) && evalCase.toolsFlagged);
4886
+ if (flagged.length === 0) return [];
4887
+ const lines = ["### Missing tool calls", ""];
4888
+ 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._");
4889
+ lines.push("");
4890
+ for (const evalCase of flagged) {
4891
+ lines.push(`#### ${tutorCaseLabel(evalCase)}`);
4892
+ lines.push("");
4893
+ lines.push(`**Required** ${toolCallList(evalCase.requiredTools)}`);
4894
+ lines.push("");
4895
+ for (const repeat of evalCase.repeats) {
4896
+ const missing = repeat.missingTools ?? [];
4897
+ if (missing.length === 0) continue;
4898
+ lines.push(`- Repeat #${repeat.repeatIndex + 1} — missing ${toolCallList(missing)}; called ${toolCallList(repeat.toolCalls)}`);
4899
+ }
4900
+ lines.push("");
4901
+ }
4902
+ return lines;
4903
+ }
4904
+ /**
4905
+ * The tutor kind's "Flagged responses" section — the report's actual deliverable: per
4906
+ * flagged case the scripted conversation, the teacher's expectations when it states any,
4907
+ * and each flagged repeat's GENERATED RESPONSE verbatim followed by the judge's issues.
4908
+ *
4909
+ * Clean cases stay out entirely (their generated texts are in the `--json` report), and
4910
+ * a flag never means the run failed — the tutor kind is report-only.
4911
+ */
4912
+ function tutorFlaggedSection(result) {
4913
+ const flagged = result.cases.filter((evalCase) => isTutorCase(evalCase) && evalCase.feedbackFlagged);
4914
+ if (flagged.length === 0) return [];
4915
+ const lines = ["### Flagged responses", ""];
4916
+ 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._");
4917
+ lines.push("");
4918
+ for (const evalCase of flagged) {
4919
+ lines.push(`#### ${tutorCaseLabel(evalCase)}`);
4920
+ lines.push("");
4921
+ lines.push(...conversationBlock(evalCase));
4922
+ if (evalCase.gradingInstructions) lines.push("**Expectations for this case**", "", quote(evalCase.gradingInstructions), "");
4923
+ if (evalCase.requiredTools) lines.push(`**Required tools** ${toolCallList(evalCase.requiredTools)}`, "");
4924
+ for (const repeat of evalCase.repeats) {
4925
+ const issues = repeat.judge?.issues ?? [];
4926
+ if (issues.length === 0) continue;
4927
+ lines.push(`**Generated response — repeat #${repeat.repeatIndex + 1}**`);
4928
+ lines.push("");
4929
+ lines.push(quote(repeat.text ?? ""));
4930
+ lines.push("");
4931
+ if (repeat.toolCalls && (repeat.toolCalls.length > 0 || evalCase.requiredTools)) lines.push(`*tool calls: ${toolCallList(repeat.toolCalls)}*`, "");
4932
+ for (const issue of issues) lines.push(`- \`${cell(issue.criterion)}\` — ${inline(issue.note)}`);
4933
+ lines.push("");
4934
+ }
4935
+ }
4936
+ return lines;
4937
+ }
4325
4938
  /** One file's details section, or `[]` when the file has nothing to report. */
4326
4939
  function fileDetails(file) {
4327
4940
  const name = shortSource(file.source);
@@ -4330,30 +4943,33 @@ function fileDetails(file) {
4330
4943
  return [
4331
4944
  `## ${cell(name)} — invalid`,
4332
4945
  "",
4333
- "This file was not graded; fix the problems below and run it again.",
4946
+ "This file was not run; fix the problems below and run it again.",
4334
4947
  "",
4335
4948
  ...errors.map((issue) => `- \`${cell(issue.code)}\` — ${inline(issue.message)}`),
4336
4949
  ""
4337
4950
  ];
4338
4951
  }
4339
4952
  const result = file.result;
4953
+ const tutor = result.kind === "tutor";
4340
4954
  const detailed = result.cases.filter(needsDetail);
4341
4955
  const skipped = result.totals.skipped;
4342
4956
  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 [];
4957
+ const flagged = tutor ? tutorFlaggedSection(result) : flaggedSection(result, questionText);
4958
+ const missingTools = tutor ? tutorMissingToolsSection(result) : [];
4959
+ if (detailed.length === 0 && skipped === 0 && !result.aborted && flagged.length === 0 && missingTools.length === 0) return [];
4345
4960
  const lines = [`## ${cell(name)} — \`${cell(result.id)}\``, ""];
4346
4961
  if (result.aborted) {
4347
4962
  lines.push("> [!WARNING]");
4348
4963
  lines.push(`> The run was aborted: ${inline(result.aborted.message)}`);
4349
4964
  lines.push("");
4350
4965
  }
4351
- for (const evalCase of detailed) lines.push(...caseSection(evalCase, questionText.get(evalCase.questionId)));
4966
+ for (const evalCase of detailed) lines.push(...isTutorCase(evalCase) ? tutorErrorSection(evalCase) : caseSection(evalCase, questionText.get(evalCase.questionId)));
4352
4967
  if (skipped > 0) {
4353
4968
  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.`);
4969
+ lines.push(`**${count(skipped)} ${tutor ? "conversation" : "case"}(s) were never attempted**${reason} — the run is incomplete, so it cannot pass.`);
4355
4970
  lines.push("");
4356
4971
  }
4972
+ lines.push(...missingTools);
4357
4973
  lines.push(...flagged);
4358
4974
  return lines;
4359
4975
  }
@@ -4371,8 +4987,9 @@ function renderEvalMarkdownReport(batch, meta) {
4371
4987
  const judges = [...new Set(batch.files.map((file) => file.result ? judgeLlmText(file.result.llm) : void 0).filter((text) => text !== void 0))];
4372
4988
  for (const judge of judges) lines.push(`- **Feedback judge** ${judge}`);
4373
4989
  lines.push(`- **Run** ${count(batch.totals.files)} file(s), ${count(batch.totals.cases)} case(s) × ${count(meta.repeats)} repeat(s), concurrency ${count(meta.concurrency)}`);
4990
+ 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
4991
  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`);
4992
+ 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
4993
  lines.push("");
4377
4994
  if (batch.files.filter((file) => file.result?.aborted).length > 0) {
4378
4995
  lines.push("> [!WARNING]");
@@ -4391,10 +5008,10 @@ function renderEvalMarkdownReport(batch, meta) {
4391
5008
  lines.push("");
4392
5009
  const details = batch.files.flatMap((file) => fileDetails(file));
4393
5010
  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._");
5011
+ lines.push("_Nothing else to report. The `--json` report carries every case, including the clean ones._");
4395
5012
  lines.push("");
4396
5013
  } 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._");
5014
+ lines.push("_Below: only the mismatched, errored and unstable cases, plus anything the judge flagged. Clean cases live in the `--json` report._");
4398
5015
  lines.push("");
4399
5016
  lines.push(...details);
4400
5017
  }
@@ -4422,7 +5039,8 @@ const CodingYamlSchema = z.strictObject({
4422
5039
  title: z.string().optional().meta({ description: "Optional label shown to the student on the /<code> connection page." }),
4423
5040
  llm: z.strictObject({
4424
5041
  model: z.string().min(1).meta({ description: "The model that answers. SERVER-ONLY and PINNED: the proxy always uses this model and ignores whatever model the coding agent sends." }),
4425
- provider: providerSchema
5042
+ provider: providerSchema,
5043
+ reasoning: reasoningLevelSchema
4426
5044
  }).meta({
4427
5045
  id: "llm",
4428
5046
  description: "The pinned model and provider that answer coding requests."
@@ -4499,7 +5117,8 @@ const WritingYamlSchema = z.strictObject({
4499
5117
  }),
4500
5118
  llm: z.strictObject({
4501
5119
  model: z.string().min(1).meta({ description: "The model that drives the feedback chat." }),
4502
- provider: providerSchema
5120
+ provider: providerSchema,
5121
+ reasoning: reasoningLevelSchema
4503
5122
  }).meta({
4504
5123
  id: "llm",
4505
5124
  description: "The model and provider that back the writing coach."
@@ -4754,6 +5373,26 @@ function parsePair(flag, provider, model) {
4754
5373
  };
4755
5374
  }
4756
5375
  /**
5376
+ * One reasoning-effort flag: absent, or one of the four known levels. Checked here rather
5377
+ * than left to the server for the same reason `parsePair` checks the provider — a typo
5378
+ * must cost nothing, not a whole run's worth of terminal 400s.
5379
+ *
5380
+ * Deliberately INDEPENDENT of its pair flag (unlike the pair's both-or-nothing rule): the
5381
+ * common comparison run is "same model, different effort", so `--llm-reasoning` alone is
5382
+ * a first-class invocation rather than a usage error.
5383
+ */
5384
+ function parseReasoning(flag, value) {
5385
+ if (value === void 0) return { ok: true };
5386
+ if (!REASONING_LEVELS.includes(value)) return {
5387
+ ok: false,
5388
+ message: `Unknown --${flag} "${value}": expected ${REASONING_LEVELS.map((level) => `"${level}"`).join(", ")}.`
5389
+ };
5390
+ return {
5391
+ ok: true,
5392
+ reasoning: value
5393
+ };
5394
+ }
5395
+ /**
4757
5396
  * The optional `usage: { input, cachedInput, output }` of a 200 response, defensively:
4758
5397
  * anything that is not three finite numbers is simply absent (an older server, or one
4759
5398
  * whose provider reports nothing, must never break a run).
@@ -4820,6 +5459,61 @@ function makeGradeFn(server, llm) {
4820
5459
  };
4821
5460
  }
4822
5461
  /**
5462
+ * The `toolCalls: string[]` of a tutor 200, defensively: `undefined` when the field is
5463
+ * absent (a server too old to report tool calls — a distinction the runner MUST be able to
5464
+ * make), and non-string entries are dropped rather than breaking a run. Names only, in the
5465
+ * order the server sent them, duplicates kept.
5466
+ */
5467
+ function parseToolCalls(value) {
5468
+ if (!Array.isArray(value)) return void 0;
5469
+ return value.filter((name) => typeof name === "string" && name !== "");
5470
+ }
5471
+ /**
5472
+ * The HTTP seam for ONE generated tutor turn, with the run's effective llm closed in —
5473
+ * the tutor kind's sibling of {@link makeGradeFn}, sharing its failure classification
5474
+ * exactly (5xx and network retryable, auth aborts the run, every other 4xx terminal).
5475
+ */
5476
+ function makeRespondFn(server, llm) {
5477
+ return async ({ system, tools, messages }) => {
5478
+ const response = await performApiRequest({
5479
+ server,
5480
+ path: "/api/eval/respond",
5481
+ method: "POST",
5482
+ body: {
5483
+ llm,
5484
+ system,
5485
+ tools: [...tools],
5486
+ messages: messages.map((m) => ({ ...m }))
5487
+ },
5488
+ quiet: true
5489
+ });
5490
+ if (response.ok) {
5491
+ const payload = response.payload;
5492
+ if (typeof payload?.text === "string" && payload.text !== "") {
5493
+ const usage = parseUsage(payload?.usage);
5494
+ const toolCalls = parseToolCalls(payload?.toolCalls);
5495
+ return {
5496
+ ok: true,
5497
+ text: payload.text,
5498
+ ...toolCalls ? { toolCalls } : {},
5499
+ ...usage ? { usage } : {}
5500
+ };
5501
+ }
5502
+ return {
5503
+ ok: false,
5504
+ retryable: false,
5505
+ 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." }
5506
+ };
5507
+ }
5508
+ return {
5509
+ ok: false,
5510
+ retryable: response.status === void 0 || response.status >= 500,
5511
+ ...response.authFailed ? { auth: true } : {},
5512
+ error: response.error
5513
+ };
5514
+ };
5515
+ }
5516
+ /**
4823
5517
  * The HTTP seam for ONE judge call, with the run's judge llm closed in. Mirrors
4824
5518
  * {@link makeGradeFn}'s failure classification, minus the auth branch: a judge failure
4825
5519
  * NEVER aborts the run — it degrades judging (see the runner's breaker) while the grading
@@ -4934,7 +5628,8 @@ function progressWriter(prefix) {
4934
5628
  */
4935
5629
  function writeFileDone(label, result) {
4936
5630
  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");
5631
+ 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`;
5632
+ process.stderr.write(`${label}: ${counts}` + (totals.skipped ? `, ${totals.skipped} skipped` : "") + (anyJudged(result) ? `, ${totals.feedbackFlagged} flagged` : "") + "\n");
4938
5633
  }
4939
5634
  /**
4940
5635
  * The command's core, exported for the unit tests. `seams` exists only so tests can
@@ -4947,14 +5642,24 @@ async function runEvalCommand(pathsOrUrls, options, seams = {}) {
4947
5642
  failJson({ message: override.message });
4948
5643
  return;
4949
5644
  }
5645
+ const overrideReasoning = parseReasoning("llm-reasoning", options.llmReasoning);
5646
+ if (!overrideReasoning.ok) {
5647
+ failJson({ message: overrideReasoning.message });
5648
+ return;
5649
+ }
4950
5650
  const judgeOverride = parsePair("judge-llm", options.judgeLlmProvider, options.judgeLlmModel);
4951
5651
  if (!judgeOverride.ok) {
4952
5652
  failJson({ message: judgeOverride.message });
4953
5653
  return;
4954
5654
  }
5655
+ const judgeOverrideReasoning = parseReasoning("judge-llm-reasoning", options.judgeLlmReasoning);
5656
+ if (!judgeOverrideReasoning.ok) {
5657
+ failJson({ message: judgeOverrideReasoning.message });
5658
+ return;
5659
+ }
4955
5660
  const judging = options.judgeFeedback !== false;
4956
- if (!judging && judgeOverride.llm) {
4957
- failJson({ message: "--judge-llm-provider/--judge-llm-model cannot be combined with --no-judge-feedback: the first configures the feedback judge, the second switches it off." });
5661
+ if (!judging && (judgeOverride.llm || judgeOverrideReasoning.reasoning)) {
5662
+ failJson({ message: "--judge-llm-provider/--judge-llm-model/--judge-llm-reasoning cannot be combined with --no-judge-feedback: the first configure the feedback judge, the second switches it off." });
4958
5663
  return;
4959
5664
  }
4960
5665
  const expansion = expandSources(pathsOrUrls);
@@ -4999,9 +5704,14 @@ async function runEvalCommand(pathsOrUrls, options, seams = {}) {
4999
5704
  return;
5000
5705
  }
5001
5706
  {
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"));
5707
+ const scope = (unit, generation, cases) => {
5708
+ if (cases === 0) return;
5709
+ const calls = cases * repeats;
5710
+ process.stderr.write(`${cases} ${unit}(s) × ${repeats} repeat(s) = ${calls} ${generation}` + (judging ? ` + ${calls} judge call(s)\n` : " call(s)\n"));
5711
+ };
5712
+ const casesOf = (kind) => [...checked.values()].filter((file) => file.kind === kind).reduce((sum, file) => sum + file.caseCount, 0);
5713
+ scope("case", "grading", casesOf("quiz"));
5714
+ scope("conversation", "generation", casesOf("tutor"));
5005
5715
  }
5006
5716
  await warnOnVersionMismatch(options.server);
5007
5717
  const judgeBreaker = createJudgeBreaker();
@@ -5013,24 +5723,26 @@ async function runEvalCommand(pathsOrUrls, options, seams = {}) {
5013
5723
  fileIndex += 1;
5014
5724
  const check = checked.get(file.source);
5015
5725
  if (!check) continue;
5016
- const quizLlm = {
5017
- provider: check.quizDump.llm.provider,
5018
- model: check.quizDump.llm.model
5726
+ const activityLlm = {
5727
+ provider: check.llm.provider,
5728
+ model: check.llm.model,
5729
+ ...check.llm.reasoning ? { reasoning: check.llm.reasoning } : {}
5019
5730
  };
5020
- const effective = override.llm ?? quizLlm;
5021
- const judgeLlm = judgeOverride.llm ?? effective;
5731
+ const effective = resolveEvalSpec(activityLlm, override.llm, overrideReasoning.reasoning);
5732
+ const judgeLlm = resolveEvalSpec(effective, judgeOverride.llm, judgeOverrideReasoning.reasoning);
5022
5733
  const llm = {
5023
5734
  ...effective,
5024
- ...override.llm ? { overrides: quizLlm } : {},
5735
+ ...sameEvalSpec(effective, activityLlm) ? {} : { overrides: activityLlm },
5025
5736
  ...judging ? { judge: {
5026
5737
  ...judgeLlm,
5027
- overridden: judgeOverride.llm !== void 0
5738
+ overridden: judgeOverride.llm !== void 0 || judgeOverrideReasoning.reasoning !== void 0
5028
5739
  } } : {}
5029
5740
  };
5030
5741
  const label = files.length > 1 ? `(${fileIndex}/${files.length}) ${check.evalFile.id}` : check.evalFile.id;
5031
5742
  const prefix = files.length > 1 ? `${label}: ` : "";
5032
- const result = await runEval("quiz", check, {
5743
+ const result = await runEval(check.kind, check, {
5033
5744
  grade: makeGradeFn(options.server, effective),
5745
+ respond: makeRespondFn(options.server, effective),
5034
5746
  ...judging ? { judge: makeJudgeFn(options.server, judgeLlm) } : {},
5035
5747
  judgeBreaker,
5036
5748
  onJudgeDegraded,
@@ -5069,12 +5781,16 @@ async function runEvalCommand(pathsOrUrls, options, seams = {}) {
5069
5781
  process.exitCode = batchPassed(batch) ? 0 : 1;
5070
5782
  }
5071
5783
  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", `
5784
+ 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("--llm-reasoning <level>", "run at this reasoning effort (\"minimal\", \"low\", \"medium\" or \"high\"); on its own it keeps the activity's model").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("--judge-llm-reasoning <level>", "judge at this reasoning effort (\"minimal\", \"low\", \"medium\" or \"high\"); on its own it keeps the judge's model").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
5785
  Examples:
5074
5786
  # Evaluate one quiz's golden answers
5075
5787
  $ novedu-cli eval ./0010-welcome-quiz.eval.yaml
5076
5788
 
5077
- # A whole course part (quote the pattern so the CLI expands it, ** included)
5789
+ # Check how a tutor answers a set of scripted conversations
5790
+ $ novedu-cli eval ./loops-tutor.eval.yaml
5791
+
5792
+ # A whole course part — quiz and tutor evals may be mixed
5793
+ # (quote the pattern so the CLI expands it, ** included)
5078
5794
  $ novedu-cli eval "./part-1/**/*.eval.yaml"
5079
5795
 
5080
5796
  # Measure grader stability: 3 runs per answer, majority verdict
@@ -5083,6 +5799,9 @@ Examples:
5083
5799
  # How would this rubric perform on another model? (both flags, always together)
5084
5800
  $ novedu-cli eval ./my-quiz.eval.yaml --llm-provider "Azure Foundry" --llm-model gpt-5-mini
5085
5801
 
5802
+ # Same model, more thinking: the level alone keeps the activity's provider/model
5803
+ $ novedu-cli eval ./my-quiz.eval.yaml --llm-reasoning high
5804
+
5086
5805
  # A strong judge over the quiz's own grader — the recommended pairing
5087
5806
  $ novedu-cli eval ./my-quiz.eval.yaml --judge-llm-provider "Azure Foundry" --judge-llm-model gpt-5.6-terra
5088
5807