@novedu/cli 0.23.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 +29 -12
  2. package/dist/main.js +161 -31
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -85,7 +85,8 @@ npx @novedu/cli prompts ./sorting-quiz.yaml --kind quiz --json \
85
85
  re-implementation), so what you see is what the model gets: fragments resolved,
86
86
  and — for a compound quiz — every `quiz_files` include fetched, each imported
87
87
  question carrying its source quiz's preamble.
88
- - Every dump carries `{ kind, id, llm: { provider, model } }`. A **quiz** adds
88
+ - Every dump carries `{ kind, id, llm: { provider, model, reasoning? } }` (the
89
+ reasoning level only when the file sets one). A **quiz** adds
89
90
  `grading` (a `system` prompt per question, the user-message templates and the
90
91
  grader's JSON-Schema response contract) and `discussion` (the chat's `system`
91
92
  prompt, the three seed-message templates and the verdict wording). A **coding**
@@ -149,6 +150,9 @@ npx @novedu/cli eval ./sorting-quiz.eval.yaml --repeats 3
149
150
  npx @novedu/cli eval ./sorting-quiz.eval.yaml \
150
151
  --llm-provider "Azure Foundry" --llm-model gpt-5-mini
151
152
 
153
+ # Same model, more thinking: the level alone keeps the activity's provider/model
154
+ npx @novedu/cli eval ./sorting-quiz.eval.yaml --llm-reasoning high
155
+
152
156
  # A strong judge over the quiz's own grader — the recommended pairing
153
157
  npx @novedu/cli eval ./sorting-quiz.eval.yaml \
154
158
  --judge-llm-provider "Azure Foundry" --judge-llm-model gpt-5.6-terra
@@ -225,12 +229,20 @@ npx @novedu/cli eval ./loops-tutor.eval.yaml --report loops.md
225
229
  only when some case required a tool, so no line means "not checked"), a **"Missing tool
226
230
  calls"** section in the Markdown report, and `totals.toolsFlagged` plus each repeat's
227
231
  `toolCalls` / `missingTools` in the JSON.
228
- - **Choosing the judge.** By default the judge runs on the same model as the grader.
229
- `--judge-llm-provider` + `--judge-llm-model` (both or neither) point it at another one,
230
- which is the **recommended** setup: a strong judge over a smaller grader finds real
231
- problems, while a small model judging itself mostly produces noise. `--no-judge-feedback`
232
- turns judging off and halves the LLM calls; combining the two is rejected as
233
- contradictory. Because judging roughly doubles the cost, the run's scope line says so
232
+ - **Choosing what runs.** `--llm-provider` + `--llm-model` (both or neither) replace the
233
+ activity's **whole** `llm:` block for the run its reasoning level included, so the
234
+ file's level is dropped unless `--llm-reasoning <level>` restates it.
235
+ `--llm-reasoning` on its own changes only the effort and keeps the file's
236
+ provider/model the "same model, more thinking" comparison run.
237
+ - **Choosing the judge.** By default the judge runs on the same model **and effort** as
238
+ the grader. `--judge-llm-provider` + `--judge-llm-model` (both or neither) point it at
239
+ another one — replacing the whole spec, exactly like the grading flags — which is the
240
+ **recommended** setup: a strong judge over a smaller grader finds real
241
+ problems, while a small model judging itself mostly produces noise.
242
+ `--judge-llm-reasoning` sets the judge's effort on its own, no pair needed.
243
+ `--no-judge-feedback` turns judging off and halves the LLM calls; combining it with any
244
+ of the judge flags is rejected as contradictory. Because judging roughly doubles the
245
+ cost, the run's scope line says so
234
246
  up front: `27 case(s) × 3 repeat(s) = 81 grading + 81 judge call(s)`.
235
247
  - **If the judge itself fails**, the run **degrades instead of aborting**: after three
236
248
  consecutive judge failures it stops judging (one warning on stderr) and finishes the
@@ -255,7 +267,7 @@ npx @novedu/cli eval ./loops-tutor.eval.yaml --report loops.md
255
267
  run localises the problem for the cost of a single grading call.
256
268
  - **Caveat**: a green run certifies **the file you ran it on**, not the app-hosted
257
269
  copy a live code serves — upload it (`files upload`) afterwards. An override run
258
- certifies the override pair, not the quiz's configured `llm`.
270
+ certifies the override, not the quiz's configured `llm`.
259
271
 
260
272
  ## Authentication
261
273
 
@@ -300,7 +312,7 @@ authoritative — the CLI sends your input as-is and relays the server's answer.
300
312
  ```
301
313
  codes create --module <tutor|quiz|writing|coding> --file <url>
302
314
  [--start <iso>] [--end <iso>] [--note <text>]
303
- [--llm-provider <p> --llm-model <m>]
315
+ [--llm-provider <p> --llm-model <m>] [--llm-reasoning <level>]
304
316
  codes list [--search <q>] [--module <m>] [--all]
305
317
  codes sync <registry-file> [--lock <path>] [--dry-run] [--json]
306
318
  files upload <name> [--kind <tutor|fragment|quiz|writing|coding>]
@@ -318,7 +330,10 @@ images list [--search <q>] [--all]
318
330
  server-side before the code is stored, and the response includes the
319
331
  shareable `url`. `--start`/`--end` must be ISO 8601 **with an explicit
320
332
  offset or `Z`** (e.g. `2026-07-07T08:00:00Z`); the
321
- `--llm-provider`/`--llm-model` override pair is both-or-nothing.
333
+ `--llm-provider`/`--llm-model` override pair is both-or-nothing, and
334
+ `--llm-reasoning <level>` (`minimal`, `low`, `medium` or `high`) rides on top of
335
+ the pair — it is rejected without it. The override replaces the activity's whole
336
+ `llm:` block, so leaving the level out also drops the file's.
322
337
  - `codes sync <registry-file>` mints codes for a whole **course** at once — see
323
338
  [Many activities at once](#many-activities-at-once-codes-sync) below.
324
339
  - `files upload <name>` is an **upsert**: creating a new file requires
@@ -416,11 +431,13 @@ activity-codes:
416
431
  - **Groups decide the module:** `quizzes`, `tutors`, `writing`, `coding`. Each
417
432
  entry gives either `file` (relative to `base-url`, which must end in `/`) or
418
433
  an absolute `url`, plus any of `start`/`end` (ISO 8601 **with an offset or
419
- `Z`**, whole seconds), `note`, and an `llm: {provider, model}` override.
434
+ `Z`**, whole seconds), `note`, and an `llm: {provider, model, reasoning?}`
435
+ override.
420
436
  - **Keys are yours and must be unique across all groups** — lowercase letters,
421
437
  digits and hyphens. Your material references the key; the lock file maps it to
422
438
  the code.
423
- - **Re-runs are safe.** An entry whose activity, window and model override match
439
+ - **Re-runs are safe.** An entry whose activity, window and LLM override
440
+ (provider, model and reasoning level) match
424
441
  an existing code of yours **reuses** that code; only entries without a match
425
442
  are minted. So `codes sync` after every edit is the normal workflow, and the
426
443
  first run against already-minted codes should report all-reused.
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
  });
@@ -1024,6 +1047,10 @@ function appendInstructions(existing, instructions) {
1024
1047
  * client sent no system message, a leading one carrying only the teacher's instructions
1025
1048
  * is added. Everything else (messages, tools, tool_choice, temperature, stream, …)
1026
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.
1027
1054
  */
1028
1055
  function buildUpstreamChatBody(clientBody, opts) {
1029
1056
  const clientMessages = Array.isArray(clientBody.messages) ? clientBody.messages : [];
@@ -1046,6 +1073,7 @@ function buildUpstreamChatBody(clientBody, opts) {
1046
1073
  model: opts.model,
1047
1074
  messages
1048
1075
  };
1076
+ if (opts.reasoning) upstream.reasoning_effort = opts.reasoning;
1049
1077
  if (clientBody.stream === true) upstream.stream_options = {
1050
1078
  ...isRecord(clientBody.stream_options) ? clientBody.stream_options : {},
1051
1079
  include_usage: true
@@ -1108,6 +1136,11 @@ function parseCoding(content) {
1108
1136
  ok: false,
1109
1137
  message: "This coding activity uses an unsupported llm.provider (use \"SCCH\" or \"Azure Foundry\")."
1110
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
+ };
1111
1144
  const instructions = asString$2(root.instructions);
1112
1145
  if (!instructions) return {
1113
1146
  ok: false,
@@ -1120,6 +1153,7 @@ function parseCoding(content) {
1120
1153
  title: asString$2(root.title),
1121
1154
  model,
1122
1155
  provider,
1156
+ reasoning,
1123
1157
  instructions,
1124
1158
  fragmentBlock: readFragmentBlock(root)
1125
1159
  }
@@ -2411,6 +2445,11 @@ function parseQuiz(content) {
2411
2445
  ok: false,
2412
2446
  message: "This quiz uses an unsupported llm.provider (use \"SCCH\" or \"Azure Foundry\")."
2413
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
+ };
2414
2453
  const quizFiles = Array.isArray(root.quiz_files) ? root.quiz_files : [];
2415
2454
  const rawQuestions = Array.isArray(root.questions) ? root.questions : [];
2416
2455
  if (rawQuestions.length === 0 && quizFiles.length === 0) return {
@@ -2453,6 +2492,7 @@ function parseQuiz(content) {
2453
2492
  shuffle: asBool$1(root.shuffle, true),
2454
2493
  model,
2455
2494
  provider,
2495
+ reasoning,
2456
2496
  questionCount,
2457
2497
  imageInput: asBool$1(llm?.imageInput, false),
2458
2498
  discussionInstructions: asString$1(root.discussion?.instructions),
@@ -2686,6 +2726,7 @@ const TutorSchema = z.strictObject({
2686
2726
  llm: z.strictObject({
2687
2727
  model: z.string().meta({ description: "Model used for this tutor." }),
2688
2728
  provider: providerSchema,
2729
+ reasoning: reasoningLevelSchema,
2689
2730
  imageInput: z.boolean().optional().meta({
2690
2731
  default: true,
2691
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."
@@ -2734,6 +2775,7 @@ async function loadAndBuildTutorPrompt(url, fetchImpl, opts = {}) {
2734
2775
  prompt: assembled.prompt,
2735
2776
  model: tutor.llm.model,
2736
2777
  provider: tutor.llm.provider,
2778
+ reasoning: tutor.llm.reasoning,
2737
2779
  imageInput: tutor.llm.imageInput ?? true,
2738
2780
  tools: tutor.tools,
2739
2781
  anonymous: tutor.anonymous ?? true,
@@ -2784,6 +2826,11 @@ function parseWriting(content) {
2784
2826
  ok: false,
2785
2827
  message: "This writing activity uses an unsupported llm.provider (use \"SCCH\" or \"Azure Foundry\")."
2786
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
+ };
2787
2834
  const instructions = asString(root.instructions);
2788
2835
  if (!instructions) return {
2789
2836
  ok: false,
@@ -2799,6 +2846,7 @@ function parseWriting(content) {
2799
2846
  anonymous: asBool(root.anonymous, false),
2800
2847
  model,
2801
2848
  provider,
2849
+ reasoning,
2802
2850
  instructions,
2803
2851
  fragmentBlock: readFragmentBlock(root),
2804
2852
  placeholder: asString(root.placeholder)
@@ -2905,7 +2953,8 @@ const promptDumpers = {
2905
2953
  id: result.id,
2906
2954
  llm: {
2907
2955
  provider: result.provider,
2908
- model: result.model
2956
+ model: result.model,
2957
+ ...result.reasoning ? { reasoning: result.reasoning } : {}
2909
2958
  },
2910
2959
  system: result.prompt,
2911
2960
  tools: result.tools
@@ -2923,7 +2972,8 @@ const promptDumpers = {
2923
2972
  id: quiz.id,
2924
2973
  llm: {
2925
2974
  provider: quiz.provider,
2926
- model: quiz.model
2975
+ model: quiz.model,
2976
+ ...quiz.reasoning ? { reasoning: quiz.reasoning } : {}
2927
2977
  },
2928
2978
  grading: {
2929
2979
  userMessageTemplate: QUIZ_ANSWER_MESSAGE_TEMPLATE,
@@ -2963,7 +3013,8 @@ const promptDumpers = {
2963
3013
  id: writing.id,
2964
3014
  llm: {
2965
3015
  provider: writing.provider,
2966
- model: writing.model
3016
+ model: writing.model,
3017
+ ...writing.reasoning ? { reasoning: writing.reasoning } : {}
2967
3018
  },
2968
3019
  system: writing.instructions
2969
3020
  }
@@ -2985,7 +3036,8 @@ const promptDumpers = {
2985
3036
  id: coding.id,
2986
3037
  llm: {
2987
3038
  provider: coding.provider,
2988
- model: coding.model
3039
+ model: coding.model,
3040
+ ...coding.reasoning ? { reasoning: coding.reasoning } : {}
2989
3041
  },
2990
3042
  system: coding.instructions,
2991
3043
  upstreamSystemMessage: typeof system?.content === "string" ? system.content : ""
@@ -3088,6 +3140,7 @@ const QuizYamlSchema = z.strictObject({
3088
3140
  llm: z.strictObject({
3089
3141
  model: z.string().min(1).meta({ description: "The model that grades answers and drives the per-question discussion chat." }),
3090
3142
  provider: providerSchema,
3143
+ reasoning: reasoningLevelSchema,
3091
3144
  imageInput: z.boolean().optional().meta({
3092
3145
  default: false,
3093
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."
@@ -3641,6 +3694,31 @@ function createJudgeBreaker() {
3641
3694
  }
3642
3695
  /** Consecutive fully-errored judge calls that mean "stop judging for the rest of the run". */
3643
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
+ }
3644
3722
  /** Narrow a case to the tutor arm. */
3645
3723
  function isTutorCase(evalCase) {
3646
3724
  return "conversation" in evalCase;
@@ -4333,7 +4411,7 @@ function formatEvalResult(result, source) {
4333
4411
  lines.push(` target: ${result.targetUrl}`);
4334
4412
  if (result.kind === "tutor") lines.push(` conversations: ${result.caseCount}`);
4335
4413
  else lines.push(` questions: ${result.evalFile.questions.length} cases: ${result.caseCount}`);
4336
- lines.push(` ${result.kind} model: ${result.llm.provider} / ${result.llm.model}`);
4414
+ lines.push(` ${result.kind} model: ${llmSpecText(result.llm)}`);
4337
4415
  if (result.warnings.length) {
4338
4416
  lines.push("");
4339
4417
  lines.push(yellow(`${result.warnings.length} warning(s):`));
@@ -4387,6 +4465,14 @@ function mismatchLines(result) {
4387
4465
  });
4388
4466
  }
4389
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
+ /**
4390
4476
  * The human report for ONE eval run: header (id, target, the EFFECTIVE llm — rendered
4391
4477
  * as `quiz-llm → override-llm` when `--llm-provider`/`--llm-model` was used, so a
4392
4478
  * comparison report can never be mistaken for a baseline one), one line per
@@ -4399,10 +4485,10 @@ function formatEvalReport(result, source) {
4399
4485
  ` id: ${result.id}`,
4400
4486
  ` target: ${result.target}`
4401
4487
  ];
4402
- const llm = result.llm.overrides ? `${result.llm.overrides.provider} / ${result.llm.overrides.model} ${yellow("→")} ${result.llm.provider} / ${result.llm.model} ${yellow("(override)")}` : `${result.llm.provider} / ${result.llm.model}`;
4488
+ const llm = result.llm.overrides ? `${llmSpecText(result.llm.overrides)} ${yellow("→")} ${llmSpecText(result.llm)} ${yellow("(override)")}` : llmSpecText(result.llm);
4403
4489
  lines.push(` llm: ${llm}`);
4404
4490
  const judge = result.llm.judge;
4405
- if (judge && (judge.provider !== result.llm.provider || judge.model !== result.llm.model)) lines.push(` judge llm: ${judge.provider} / ${judge.model}${judge.overridden ? ` ${yellow("(override)")}` : ""}`);
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)")}` : ""}`);
4406
4492
  const unit = result.kind === "tutor" ? "conversation" : "case";
4407
4493
  const generation = result.kind === "tutor" ? "generation" : "grading";
4408
4494
  lines.push(` ${unit}s: ${totals.cases} × ${totals.repeats} repeat(s) = ${totals.calls} ${generation} call(s)` + (result.judging === "off" ? "" : ` + ${totals.calls} judge call(s)`));
@@ -4494,7 +4580,7 @@ function shortSource$1(source) {
4494
4580
  function formatPromptDump(dump, sections, source) {
4495
4581
  const lines = [green(`✔ Prompts — ${dump.kind}`) + dim(` — ${source}`)];
4496
4582
  lines.push(` id: ${dump.id}`);
4497
- 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}` : ""));
4498
4584
  if (dump.kind === "tutor" && dump.tools.length > 0) lines.push(` tools: ${dump.tools.join(", ")}`);
4499
4585
  lines.push(` prompts: ${sections.length}`);
4500
4586
  for (const section of sections) lines.push(` ${section.name}: ${section.text.length} chars`);
@@ -4530,10 +4616,18 @@ function inline(text) {
4530
4616
  function quote(text) {
4531
4617
  return text.replace(/\s+$/, "").split(/\r?\n/).map((line) => line ? `> ${line}` : ">").join("\n");
4532
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
+ }
4533
4627
  /** `SCCH / gemma-4`, or `SCCH / gemma-4 → Azure Foundry / gpt-5-mini (override)`. */
4534
4628
  function llmText(llm) {
4535
- const effective = `${llm.provider} / ${llm.model}`;
4536
- 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;
4537
4631
  }
4538
4632
  /**
4539
4633
  * The judge's pair, but ONLY when it differs from the grading pair — a judge line that
@@ -4543,8 +4637,8 @@ function llmText(llm) {
4543
4637
  function judgeLlmText(llm) {
4544
4638
  const judge = llm.judge;
4545
4639
  if (!judge) return void 0;
4546
- if (judge.provider === llm.provider && judge.model === llm.model) return void 0;
4547
- return `${judge.provider} / ${judge.model}${judge.overridden ? " (override)" : ""}`;
4640
+ if (judge.provider === llm.provider && judge.model === llm.model && judge.reasoning === llm.reasoning) return;
4641
+ return `${specText(judge)}${judge.overridden ? " (override)" : ""}`;
4548
4642
  }
4549
4643
  /** `15,420 / 12,300 / 2,810`, or an em dash when nothing was reported. */
4550
4644
  function usageCell(usage) {
@@ -4945,7 +5039,8 @@ const CodingYamlSchema = z.strictObject({
4945
5039
  title: z.string().optional().meta({ description: "Optional label shown to the student on the /<code> connection page." }),
4946
5040
  llm: z.strictObject({
4947
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." }),
4948
- provider: providerSchema
5042
+ provider: providerSchema,
5043
+ reasoning: reasoningLevelSchema
4949
5044
  }).meta({
4950
5045
  id: "llm",
4951
5046
  description: "The pinned model and provider that answer coding requests."
@@ -5022,7 +5117,8 @@ const WritingYamlSchema = z.strictObject({
5022
5117
  }),
5023
5118
  llm: z.strictObject({
5024
5119
  model: z.string().min(1).meta({ description: "The model that drives the feedback chat." }),
5025
- provider: providerSchema
5120
+ provider: providerSchema,
5121
+ reasoning: reasoningLevelSchema
5026
5122
  }).meta({
5027
5123
  id: "llm",
5028
5124
  description: "The model and provider that back the writing coach."
@@ -5277,6 +5373,26 @@ function parsePair(flag, provider, model) {
5277
5373
  };
5278
5374
  }
5279
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
+ /**
5280
5396
  * The optional `usage: { input, cachedInput, output }` of a 200 response, defensively:
5281
5397
  * anything that is not three finite numbers is simply absent (an older server, or one
5282
5398
  * whose provider reports nothing, must never break a run).
@@ -5526,14 +5642,24 @@ async function runEvalCommand(pathsOrUrls, options, seams = {}) {
5526
5642
  failJson({ message: override.message });
5527
5643
  return;
5528
5644
  }
5645
+ const overrideReasoning = parseReasoning("llm-reasoning", options.llmReasoning);
5646
+ if (!overrideReasoning.ok) {
5647
+ failJson({ message: overrideReasoning.message });
5648
+ return;
5649
+ }
5529
5650
  const judgeOverride = parsePair("judge-llm", options.judgeLlmProvider, options.judgeLlmModel);
5530
5651
  if (!judgeOverride.ok) {
5531
5652
  failJson({ message: judgeOverride.message });
5532
5653
  return;
5533
5654
  }
5655
+ const judgeOverrideReasoning = parseReasoning("judge-llm-reasoning", options.judgeLlmReasoning);
5656
+ if (!judgeOverrideReasoning.ok) {
5657
+ failJson({ message: judgeOverrideReasoning.message });
5658
+ return;
5659
+ }
5534
5660
  const judging = options.judgeFeedback !== false;
5535
- if (!judging && judgeOverride.llm) {
5536
- failJson({ message: "--judge-llm-provider/--judge-llm-model cannot be combined with --no-judge-feedback: the first configures the feedback judge, the second switches it off." });
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." });
5537
5663
  return;
5538
5664
  }
5539
5665
  const expansion = expandSources(pathsOrUrls);
@@ -5599,16 +5725,17 @@ async function runEvalCommand(pathsOrUrls, options, seams = {}) {
5599
5725
  if (!check) continue;
5600
5726
  const activityLlm = {
5601
5727
  provider: check.llm.provider,
5602
- model: check.llm.model
5728
+ model: check.llm.model,
5729
+ ...check.llm.reasoning ? { reasoning: check.llm.reasoning } : {}
5603
5730
  };
5604
- const effective = override.llm ?? activityLlm;
5605
- const judgeLlm = judgeOverride.llm ?? effective;
5731
+ const effective = resolveEvalSpec(activityLlm, override.llm, overrideReasoning.reasoning);
5732
+ const judgeLlm = resolveEvalSpec(effective, judgeOverride.llm, judgeOverrideReasoning.reasoning);
5606
5733
  const llm = {
5607
5734
  ...effective,
5608
- ...override.llm ? { overrides: activityLlm } : {},
5735
+ ...sameEvalSpec(effective, activityLlm) ? {} : { overrides: activityLlm },
5609
5736
  ...judging ? { judge: {
5610
5737
  ...judgeLlm,
5611
- overridden: judgeOverride.llm !== void 0
5738
+ overridden: judgeOverride.llm !== void 0 || judgeOverrideReasoning.reasoning !== void 0
5612
5739
  } } : {}
5613
5740
  };
5614
5741
  const label = files.length > 1 ? `(${fileIndex}/${files.length}) ${check.evalFile.id}` : check.evalFile.id;
@@ -5654,7 +5781,7 @@ async function runEvalCommand(pathsOrUrls, options, seams = {}) {
5654
5781
  process.exitCode = batchPassed(batch) ? 0 : 1;
5655
5782
  }
5656
5783
  function registerEval(program) {
5657
- program.command("eval").description("Run an eval file (quiz golden answers, or tutor conversations) against the real activity path and report the result").argument("<evalPathOrUrl...>", "one or more eval YAML files (paths, http(s)/file URLs, or a quoted glob pattern)").option("--server <url>", "Novedu server base URL (defaults to the NOVEDU_SERVER env var, then production)").option("--concurrency <n>", "cases in flight per file", String(CONCURRENCY_DEFAULT)).option("--repeats <n>", "run every case N times (quiz: take the majority verdict)", "1").option("--llm-provider <provider>", "run with this provider instead of the activity's (\"SCCH\" or \"Azure Foundry\"; needs --llm-model)").option("--llm-model <model>", "run with this model instead of the activity's (needs --llm-provider)").option("--no-judge-feedback", "skip the LLM audit of what the model wrote (halves the LLM calls)").option("--judge-llm-provider <provider>", "judge with this provider (\"SCCH\" or \"Azure Foundry\"; needs --judge-llm-model)").option("--judge-llm-model <model>", "judge with this model instead of the one under test (needs --judge-llm-provider)").option("--json", "print the machine-readable batch report on stdout").option("--out <file>", "additionally write the machine-readable batch report to a file").option("--report <file>", "additionally write a readable Markdown report to a file").addHelpText("after", `
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", `
5658
5785
  Examples:
5659
5786
  # Evaluate one quiz's golden answers
5660
5787
  $ novedu-cli eval ./0010-welcome-quiz.eval.yaml
@@ -5672,6 +5799,9 @@ Examples:
5672
5799
  # How would this rubric perform on another model? (both flags, always together)
5673
5800
  $ novedu-cli eval ./my-quiz.eval.yaml --llm-provider "Azure Foundry" --llm-model gpt-5-mini
5674
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
+
5675
5805
  # A strong judge over the quiz's own grader — the recommended pairing
5676
5806
  $ novedu-cli eval ./my-quiz.eval.yaml --judge-llm-provider "Azure Foundry" --judge-llm-model gpt-5.6-terra
5677
5807
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@novedu/cli",
3
- "version": "0.23.0",
3
+ "version": "0.24.0",
4
4
  "description": "Command-line companion for the Novedu chat app. Validates tutor, fragment, quiz, writing, coding and eval YAML definitions, dumps the exact LLM prompts an activity produces, evaluates a quiz's grading rubric against golden answers and replays scripted conversations against a tutor; signs in with Entra ID and manages codes, app-hosted files and images over the app's API.",
5
5
  "type": "module",
6
6
  "repository": {