@novedu/cli 0.23.0 → 0.24.1

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 +197 -38
  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
@@ -85,17 +85,46 @@ async function acquireSilent(pca) {
85
85
  return null;
86
86
  }
87
87
  }
88
+ /**
89
+ * How the system browser is launched per platform. Exported for tests.
90
+ *
91
+ * Windows goes through `cmd /c start`, and cmd RE-PARSES the command line it
92
+ * receives: an unquoted URL is cut at the first `&`, so Entra only ever saw
93
+ * `authorize?client_id=…` and answered AADSTS900144 ("the request body must
94
+ * contain the following parameter: 'scope'"). Node quotes an argument only
95
+ * when it contains whitespace, so the URL is quoted here explicitly and the
96
+ * command line handed over verbatim. The empty `""` is `start`'s window title
97
+ * — without it, `start` would take the quoted URL as the title.
98
+ */
99
+ function browserCommand(url, platform = process.platform) {
100
+ if (platform === "darwin") return {
101
+ command: "open",
102
+ args: [url],
103
+ verbatim: false
104
+ };
105
+ if (platform === "win32") return {
106
+ command: "cmd",
107
+ args: [
108
+ "/c",
109
+ "start",
110
+ "\"\"",
111
+ `"${url}"`
112
+ ],
113
+ verbatim: true
114
+ };
115
+ return {
116
+ command: "xdg-open",
117
+ args: [url],
118
+ verbatim: false
119
+ };
120
+ }
88
121
  function defaultOpenBrowser(url) {
89
- const [command, args] = process.platform === "darwin" ? ["open", [url]] : process.platform === "win32" ? ["cmd", [
90
- "/c",
91
- "start",
92
- "",
93
- url
94
- ]] : ["xdg-open", [url]];
122
+ const { command, args, verbatim } = browserCommand(url);
95
123
  try {
96
124
  spawn(command, args, {
97
125
  stdio: "ignore",
98
- detached: true
126
+ detached: true,
127
+ windowsVerbatimArguments: verbatim
99
128
  }).unref();
100
129
  } catch {}
101
130
  }
@@ -284,9 +313,19 @@ async function runApiRequest(options) {
284
313
  const LLM_PROVIDERS = ["SCCH", "Azure Foundry"];
285
314
  const DEFAULT_PROVIDER = "SCCH";
286
315
  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." });
316
+ const REASONING_LEVELS = [
317
+ "minimal",
318
+ "low",
319
+ "medium",
320
+ "high"
321
+ ];
322
+ 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
323
  function parseLenientProvider(value) {
288
324
  return value === "SCCH" || value === "Azure Foundry" ? value : void 0;
289
325
  }
326
+ function parseLenientReasoningLevel(value) {
327
+ return typeof value === "string" && REASONING_LEVELS.includes(value) ? value : void 0;
328
+ }
290
329
  //#endregion
291
330
  //#region ../lib/registry-schema.ts
292
331
  /** The fixed group names and the code module each one mints for. */
@@ -307,6 +346,7 @@ function timestampField(field) {
307
346
  }, `${field} must not carry sub-second precision — the server stores whole seconds`);
308
347
  }
309
348
  const providerField = z.enum(LLM_PROVIDERS, { error: "must be \"SCCH\" or \"Azure Foundry\"" });
349
+ const reasoningField = z.enum(REASONING_LEVELS, { error: `must be one of ${REASONING_LEVELS.join(", ")}` });
310
350
  /**
311
351
  * One registry entry. Unknown extra properties are ACCEPTED and ignored so authors can
312
352
  * annotate freely and a newer registry keeps working with an older CLI — which is why
@@ -320,8 +360,9 @@ const RegistryEntrySchema = z.looseObject({
320
360
  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
361
  llm: z.looseObject({
322
362
  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." })
363
+ model: z.string().trim().min(1).max(256).meta({ description: "Model id (for Azure Foundry, the deployment name). Required when `llm` is present." }),
364
+ 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." })
365
+ }).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
366
  }).refine((entry) => entry.file === void 0 !== (entry.url === void 0), "give exactly one of `file` (relative to base-url) or `url` (absolute)").meta({
326
367
  id: "registryEntry",
327
368
  description: "One activity: where its YAML lives, plus the parameters its code is minted with."
@@ -451,7 +492,8 @@ function parseRegistry(text) {
451
492
  note: entry.note ?? null,
452
493
  llm: entry.llm ? {
453
494
  provider: entry.llm.provider,
454
- model: entry.llm.model
495
+ model: entry.llm.model,
496
+ ...entry.llm.reasoning ? { reasoning: entry.llm.reasoning } : {}
455
497
  } : null
456
498
  });
457
499
  }
@@ -521,7 +563,8 @@ function parseServerCodes(payload) {
521
563
  validUntil: typeof value.validUntil === "string" ? value.validUntil : null,
522
564
  llm: typeof llm === "object" && llm !== null ? {
523
565
  provider: String(llm.provider ?? ""),
524
- model: String(llm.model ?? "")
566
+ model: String(llm.model ?? ""),
567
+ reasoning: typeof llm.reasoning === "string" ? llm.reasoning : null
525
568
  } : null,
526
569
  createdAt: typeof value.createdAt === "string" ? value.createdAt : null
527
570
  });
@@ -539,9 +582,16 @@ function sameInstant(a, b) {
539
582
  const right = Date.parse(b);
540
583
  return !Number.isNaN(left) && left === right;
541
584
  }
585
+ /**
586
+ * The override compares WHOLE, reasoning level included: a code minted at a different
587
+ * effort serves different behavior, so it must not be reused. A differing level therefore
588
+ * fails the match and the entry mints a NEW code — sync never modifies an existing one
589
+ * (docs/registry.md). An absent level on either side compares as null, so an entry
590
+ * without `reasoning` keeps matching the codes minted before the field existed.
591
+ */
542
592
  function sameLlm(a, b) {
543
593
  if (a === null || b === null) return a === b;
544
- return a.provider === b.provider && a.model === b.model;
594
+ return a.provider === b.provider && a.model === b.model && (a.reasoning ?? null) === (b.reasoning ?? null);
545
595
  }
546
596
  /**
547
597
  * The codes that ARE this entry: same activity URL, module and availability
@@ -846,7 +896,8 @@ async function readLock(lockPath) {
846
896
  }
847
897
  function registerCodes(program) {
848
898
  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) => {
899
+ 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) => {
900
+ const llmGiven = options.llmProvider !== void 0 || options.llmModel !== void 0 || options.llmReasoning !== void 0;
850
901
  await runApiRequest({
851
902
  server: options.server,
852
903
  path: "/api/codes",
@@ -857,10 +908,11 @@ function registerCodes(program) {
857
908
  ...options.start === void 0 ? {} : { validFrom: options.start },
858
909
  ...options.end === void 0 ? {} : { validUntil: options.end },
859
910
  ...options.note === void 0 ? {} : { note: options.note },
860
- ...options.llmProvider === void 0 && options.llmModel === void 0 ? {} : { llm: {
911
+ ...llmGiven ? { llm: {
861
912
  provider: options.llmProvider ?? "",
862
- model: options.llmModel ?? ""
863
- } }
913
+ model: options.llmModel ?? "",
914
+ ...options.llmReasoning === void 0 ? {} : { reasoning: options.llmReasoning }
915
+ } } : {}
864
916
  }
865
917
  });
866
918
  });
@@ -1024,6 +1076,10 @@ function appendInstructions(existing, instructions) {
1024
1076
  * client sent no system message, a leading one carrying only the teacher's instructions
1025
1077
  * is added. Everything else (messages, tools, tool_choice, temperature, stream, …)
1026
1078
  * passes through verbatim, so client-side tools and streaming are all preserved.
1079
+ *
1080
+ * A `reasoning` level (the effective activity/code setting) is pinned exactly like
1081
+ * `model` — it OVERWRITES whatever the client sent as `reasoning_effort`. Without one
1082
+ * the client's own `reasoning_effort` passes through untouched, like any other parameter.
1027
1083
  */
1028
1084
  function buildUpstreamChatBody(clientBody, opts) {
1029
1085
  const clientMessages = Array.isArray(clientBody.messages) ? clientBody.messages : [];
@@ -1046,6 +1102,7 @@ function buildUpstreamChatBody(clientBody, opts) {
1046
1102
  model: opts.model,
1047
1103
  messages
1048
1104
  };
1105
+ if (opts.reasoning) upstream.reasoning_effort = opts.reasoning;
1049
1106
  if (clientBody.stream === true) upstream.stream_options = {
1050
1107
  ...isRecord(clientBody.stream_options) ? clientBody.stream_options : {},
1051
1108
  include_usage: true
@@ -1108,6 +1165,11 @@ function parseCoding(content) {
1108
1165
  ok: false,
1109
1166
  message: "This coding activity uses an unsupported llm.provider (use \"SCCH\" or \"Azure Foundry\")."
1110
1167
  };
1168
+ const reasoning = llm?.reasoning === void 0 ? void 0 : parseLenientReasoningLevel(llm.reasoning);
1169
+ if (llm?.reasoning !== void 0 && !reasoning) return {
1170
+ ok: false,
1171
+ message: "This coding activity uses an unsupported llm.reasoning (use \"minimal\", \"low\", \"medium\" or \"high\")."
1172
+ };
1111
1173
  const instructions = asString$2(root.instructions);
1112
1174
  if (!instructions) return {
1113
1175
  ok: false,
@@ -1120,6 +1182,7 @@ function parseCoding(content) {
1120
1182
  title: asString$2(root.title),
1121
1183
  model,
1122
1184
  provider,
1185
+ reasoning,
1123
1186
  instructions,
1124
1187
  fragmentBlock: readFragmentBlock(root)
1125
1188
  }
@@ -2411,6 +2474,11 @@ function parseQuiz(content) {
2411
2474
  ok: false,
2412
2475
  message: "This quiz uses an unsupported llm.provider (use \"SCCH\" or \"Azure Foundry\")."
2413
2476
  };
2477
+ const reasoning = llm?.reasoning === void 0 ? void 0 : parseLenientReasoningLevel(llm.reasoning);
2478
+ if (llm?.reasoning !== void 0 && !reasoning) return {
2479
+ ok: false,
2480
+ message: "This quiz uses an unsupported llm.reasoning (use \"minimal\", \"low\", \"medium\" or \"high\")."
2481
+ };
2414
2482
  const quizFiles = Array.isArray(root.quiz_files) ? root.quiz_files : [];
2415
2483
  const rawQuestions = Array.isArray(root.questions) ? root.questions : [];
2416
2484
  if (rawQuestions.length === 0 && quizFiles.length === 0) return {
@@ -2453,6 +2521,7 @@ function parseQuiz(content) {
2453
2521
  shuffle: asBool$1(root.shuffle, true),
2454
2522
  model,
2455
2523
  provider,
2524
+ reasoning,
2456
2525
  questionCount,
2457
2526
  imageInput: asBool$1(llm?.imageInput, false),
2458
2527
  discussionInstructions: asString$1(root.discussion?.instructions),
@@ -2686,6 +2755,7 @@ const TutorSchema = z.strictObject({
2686
2755
  llm: z.strictObject({
2687
2756
  model: z.string().meta({ description: "Model used for this tutor." }),
2688
2757
  provider: providerSchema,
2758
+ reasoning: reasoningLevelSchema,
2689
2759
  imageInput: z.boolean().optional().meta({
2690
2760
  default: true,
2691
2761
  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 +2804,7 @@ async function loadAndBuildTutorPrompt(url, fetchImpl, opts = {}) {
2734
2804
  prompt: assembled.prompt,
2735
2805
  model: tutor.llm.model,
2736
2806
  provider: tutor.llm.provider,
2807
+ reasoning: tutor.llm.reasoning,
2737
2808
  imageInput: tutor.llm.imageInput ?? true,
2738
2809
  tools: tutor.tools,
2739
2810
  anonymous: tutor.anonymous ?? true,
@@ -2784,6 +2855,11 @@ function parseWriting(content) {
2784
2855
  ok: false,
2785
2856
  message: "This writing activity uses an unsupported llm.provider (use \"SCCH\" or \"Azure Foundry\")."
2786
2857
  };
2858
+ const reasoning = llm?.reasoning === void 0 ? void 0 : parseLenientReasoningLevel(llm.reasoning);
2859
+ if (llm?.reasoning !== void 0 && !reasoning) return {
2860
+ ok: false,
2861
+ message: "This writing activity uses an unsupported llm.reasoning (use \"minimal\", \"low\", \"medium\" or \"high\")."
2862
+ };
2787
2863
  const instructions = asString(root.instructions);
2788
2864
  if (!instructions) return {
2789
2865
  ok: false,
@@ -2799,6 +2875,7 @@ function parseWriting(content) {
2799
2875
  anonymous: asBool(root.anonymous, false),
2800
2876
  model,
2801
2877
  provider,
2878
+ reasoning,
2802
2879
  instructions,
2803
2880
  fragmentBlock: readFragmentBlock(root),
2804
2881
  placeholder: asString(root.placeholder)
@@ -2905,7 +2982,8 @@ const promptDumpers = {
2905
2982
  id: result.id,
2906
2983
  llm: {
2907
2984
  provider: result.provider,
2908
- model: result.model
2985
+ model: result.model,
2986
+ ...result.reasoning ? { reasoning: result.reasoning } : {}
2909
2987
  },
2910
2988
  system: result.prompt,
2911
2989
  tools: result.tools
@@ -2923,7 +3001,8 @@ const promptDumpers = {
2923
3001
  id: quiz.id,
2924
3002
  llm: {
2925
3003
  provider: quiz.provider,
2926
- model: quiz.model
3004
+ model: quiz.model,
3005
+ ...quiz.reasoning ? { reasoning: quiz.reasoning } : {}
2927
3006
  },
2928
3007
  grading: {
2929
3008
  userMessageTemplate: QUIZ_ANSWER_MESSAGE_TEMPLATE,
@@ -2963,7 +3042,8 @@ const promptDumpers = {
2963
3042
  id: writing.id,
2964
3043
  llm: {
2965
3044
  provider: writing.provider,
2966
- model: writing.model
3045
+ model: writing.model,
3046
+ ...writing.reasoning ? { reasoning: writing.reasoning } : {}
2967
3047
  },
2968
3048
  system: writing.instructions
2969
3049
  }
@@ -2985,7 +3065,8 @@ const promptDumpers = {
2985
3065
  id: coding.id,
2986
3066
  llm: {
2987
3067
  provider: coding.provider,
2988
- model: coding.model
3068
+ model: coding.model,
3069
+ ...coding.reasoning ? { reasoning: coding.reasoning } : {}
2989
3070
  },
2990
3071
  system: coding.instructions,
2991
3072
  upstreamSystemMessage: typeof system?.content === "string" ? system.content : ""
@@ -3088,6 +3169,7 @@ const QuizYamlSchema = z.strictObject({
3088
3169
  llm: z.strictObject({
3089
3170
  model: z.string().min(1).meta({ description: "The model that grades answers and drives the per-question discussion chat." }),
3090
3171
  provider: providerSchema,
3172
+ reasoning: reasoningLevelSchema,
3091
3173
  imageInput: z.boolean().optional().meta({
3092
3174
  default: false,
3093
3175
  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 +3723,31 @@ function createJudgeBreaker() {
3641
3723
  }
3642
3724
  /** Consecutive fully-errored judge calls that mean "stop judging for the rest of the run". */
3643
3725
  const JUDGE_BREAKER_LIMIT = 3;
3726
+ /**
3727
+ * The spec a run's calls are actually served with, out of the TARGET activity's own spec
3728
+ * and the run's two override flags. TWO independent axes (docs/cli-eval.md):
3729
+ *
3730
+ * - the PAIR (`--llm-provider`/`--llm-model`) replaces provider+model **wholesale**, so a
3731
+ * pair given without a level DROPS the file's level — the same bundle semantics a
3732
+ * per-code LLM override has (`effectiveLlm`, docs/ai-models.md);
3733
+ * - the LEVEL (`--llm-reasoning`) replaces only the effort, on top of whichever pair won,
3734
+ * which is what makes "the file's own model, at high effort" a one-flag run.
3735
+ *
3736
+ * The judge's flags reuse this with the EFFECTIVE grading spec as the activity, which is
3737
+ * why "no judge flag" means "judge exactly like the model under test", level included.
3738
+ */
3739
+ function resolveEvalSpec(activity, pair, reasoning) {
3740
+ const base = pair ?? activity;
3741
+ return reasoning ? {
3742
+ provider: base.provider,
3743
+ model: base.model,
3744
+ reasoning
3745
+ } : base;
3746
+ }
3747
+ /** Do two specs describe the same call? Provider, model AND effort — all three matter. */
3748
+ function sameEvalSpec(a, b) {
3749
+ return a.provider === b.provider && a.model === b.model && a.reasoning === b.reasoning;
3750
+ }
3644
3751
  /** Narrow a case to the tutor arm. */
3645
3752
  function isTutorCase(evalCase) {
3646
3753
  return "conversation" in evalCase;
@@ -4333,7 +4440,7 @@ function formatEvalResult(result, source) {
4333
4440
  lines.push(` target: ${result.targetUrl}`);
4334
4441
  if (result.kind === "tutor") lines.push(` conversations: ${result.caseCount}`);
4335
4442
  else lines.push(` questions: ${result.evalFile.questions.length} cases: ${result.caseCount}`);
4336
- lines.push(` ${result.kind} model: ${result.llm.provider} / ${result.llm.model}`);
4443
+ lines.push(` ${result.kind} model: ${llmSpecText(result.llm)}`);
4337
4444
  if (result.warnings.length) {
4338
4445
  lines.push("");
4339
4446
  lines.push(yellow(`${result.warnings.length} warning(s):`));
@@ -4387,6 +4494,14 @@ function mismatchLines(result) {
4387
4494
  });
4388
4495
  }
4389
4496
  /**
4497
+ * One llm spec as the reports name it: `SCCH / gemma-4`, with ` (reasoning: high)`
4498
+ * appended whenever an effort level applies — two runs of one model at different efforts
4499
+ * behave differently, so the level belongs in the header.
4500
+ */
4501
+ function llmSpecText(spec) {
4502
+ return `${spec.provider} / ${spec.model}${spec.reasoning ? ` (reasoning: ${spec.reasoning})` : ""}`;
4503
+ }
4504
+ /**
4390
4505
  * The human report for ONE eval run: header (id, target, the EFFECTIVE llm — rendered
4391
4506
  * as `quiz-llm → override-llm` when `--llm-provider`/`--llm-model` was used, so a
4392
4507
  * comparison report can never be mistaken for a baseline one), one line per
@@ -4399,10 +4514,10 @@ function formatEvalReport(result, source) {
4399
4514
  ` id: ${result.id}`,
4400
4515
  ` target: ${result.target}`
4401
4516
  ];
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}`;
4517
+ const llm = result.llm.overrides ? `${llmSpecText(result.llm.overrides)} ${yellow("→")} ${llmSpecText(result.llm)} ${yellow("(override)")}` : llmSpecText(result.llm);
4403
4518
  lines.push(` llm: ${llm}`);
4404
4519
  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)")}` : ""}`);
4520
+ 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
4521
  const unit = result.kind === "tutor" ? "conversation" : "case";
4407
4522
  const generation = result.kind === "tutor" ? "generation" : "grading";
4408
4523
  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 +4609,7 @@ function shortSource$1(source) {
4494
4609
  function formatPromptDump(dump, sections, source) {
4495
4610
  const lines = [green(`✔ Prompts — ${dump.kind}`) + dim(` — ${source}`)];
4496
4611
  lines.push(` id: ${dump.id}`);
4497
- lines.push(` provider: ${dump.llm.provider} model: ${dump.llm.model}`);
4612
+ lines.push(` provider: ${dump.llm.provider} model: ${dump.llm.model}` + (dump.llm.reasoning ? ` reasoning: ${dump.llm.reasoning}` : ""));
4498
4613
  if (dump.kind === "tutor" && dump.tools.length > 0) lines.push(` tools: ${dump.tools.join(", ")}`);
4499
4614
  lines.push(` prompts: ${sections.length}`);
4500
4615
  for (const section of sections) lines.push(` ${section.name}: ${section.text.length} chars`);
@@ -4530,10 +4645,18 @@ function inline(text) {
4530
4645
  function quote(text) {
4531
4646
  return text.replace(/\s+$/, "").split(/\r?\n/).map((line) => line ? `> ${line}` : ">").join("\n");
4532
4647
  }
4648
+ /**
4649
+ * One spec: `SCCH / gemma-4`, and `Azure Foundry / gpt-5.6-terra (reasoning: high)` when
4650
+ * an effort level applies. The level is part of a run's identity — two runs of one model
4651
+ * at different efforts produce different behavior — so it must be readable off the report.
4652
+ */
4653
+ function specText(spec) {
4654
+ return `${spec.provider} / ${spec.model}${spec.reasoning ? ` (reasoning: ${spec.reasoning})` : ""}`;
4655
+ }
4533
4656
  /** `SCCH / gemma-4`, or `SCCH / gemma-4 → Azure Foundry / gpt-5-mini (override)`. */
4534
4657
  function llmText(llm) {
4535
- const effective = `${llm.provider} / ${llm.model}`;
4536
- return llm.overrides ? `${llm.overrides.provider} / ${llm.overrides.model} → ${effective} (override)` : effective;
4658
+ const effective = specText(llm);
4659
+ return llm.overrides ? `${specText(llm.overrides)} → ${effective} (override)` : effective;
4537
4660
  }
4538
4661
  /**
4539
4662
  * The judge's pair, but ONLY when it differs from the grading pair — a judge line that
@@ -4543,8 +4666,8 @@ function llmText(llm) {
4543
4666
  function judgeLlmText(llm) {
4544
4667
  const judge = llm.judge;
4545
4668
  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)" : ""}`;
4669
+ if (judge.provider === llm.provider && judge.model === llm.model && judge.reasoning === llm.reasoning) return;
4670
+ return `${specText(judge)}${judge.overridden ? " (override)" : ""}`;
4548
4671
  }
4549
4672
  /** `15,420 / 12,300 / 2,810`, or an em dash when nothing was reported. */
4550
4673
  function usageCell(usage) {
@@ -4945,7 +5068,8 @@ const CodingYamlSchema = z.strictObject({
4945
5068
  title: z.string().optional().meta({ description: "Optional label shown to the student on the /<code> connection page." }),
4946
5069
  llm: z.strictObject({
4947
5070
  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
5071
+ provider: providerSchema,
5072
+ reasoning: reasoningLevelSchema
4949
5073
  }).meta({
4950
5074
  id: "llm",
4951
5075
  description: "The pinned model and provider that answer coding requests."
@@ -5022,7 +5146,8 @@ const WritingYamlSchema = z.strictObject({
5022
5146
  }),
5023
5147
  llm: z.strictObject({
5024
5148
  model: z.string().min(1).meta({ description: "The model that drives the feedback chat." }),
5025
- provider: providerSchema
5149
+ provider: providerSchema,
5150
+ reasoning: reasoningLevelSchema
5026
5151
  }).meta({
5027
5152
  id: "llm",
5028
5153
  description: "The model and provider that back the writing coach."
@@ -5277,6 +5402,26 @@ function parsePair(flag, provider, model) {
5277
5402
  };
5278
5403
  }
5279
5404
  /**
5405
+ * One reasoning-effort flag: absent, or one of the four known levels. Checked here rather
5406
+ * than left to the server for the same reason `parsePair` checks the provider — a typo
5407
+ * must cost nothing, not a whole run's worth of terminal 400s.
5408
+ *
5409
+ * Deliberately INDEPENDENT of its pair flag (unlike the pair's both-or-nothing rule): the
5410
+ * common comparison run is "same model, different effort", so `--llm-reasoning` alone is
5411
+ * a first-class invocation rather than a usage error.
5412
+ */
5413
+ function parseReasoning(flag, value) {
5414
+ if (value === void 0) return { ok: true };
5415
+ if (!REASONING_LEVELS.includes(value)) return {
5416
+ ok: false,
5417
+ message: `Unknown --${flag} "${value}": expected ${REASONING_LEVELS.map((level) => `"${level}"`).join(", ")}.`
5418
+ };
5419
+ return {
5420
+ ok: true,
5421
+ reasoning: value
5422
+ };
5423
+ }
5424
+ /**
5280
5425
  * The optional `usage: { input, cachedInput, output }` of a 200 response, defensively:
5281
5426
  * anything that is not three finite numbers is simply absent (an older server, or one
5282
5427
  * whose provider reports nothing, must never break a run).
@@ -5526,14 +5671,24 @@ async function runEvalCommand(pathsOrUrls, options, seams = {}) {
5526
5671
  failJson({ message: override.message });
5527
5672
  return;
5528
5673
  }
5674
+ const overrideReasoning = parseReasoning("llm-reasoning", options.llmReasoning);
5675
+ if (!overrideReasoning.ok) {
5676
+ failJson({ message: overrideReasoning.message });
5677
+ return;
5678
+ }
5529
5679
  const judgeOverride = parsePair("judge-llm", options.judgeLlmProvider, options.judgeLlmModel);
5530
5680
  if (!judgeOverride.ok) {
5531
5681
  failJson({ message: judgeOverride.message });
5532
5682
  return;
5533
5683
  }
5684
+ const judgeOverrideReasoning = parseReasoning("judge-llm-reasoning", options.judgeLlmReasoning);
5685
+ if (!judgeOverrideReasoning.ok) {
5686
+ failJson({ message: judgeOverrideReasoning.message });
5687
+ return;
5688
+ }
5534
5689
  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." });
5690
+ if (!judging && (judgeOverride.llm || judgeOverrideReasoning.reasoning)) {
5691
+ 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
5692
  return;
5538
5693
  }
5539
5694
  const expansion = expandSources(pathsOrUrls);
@@ -5599,16 +5754,17 @@ async function runEvalCommand(pathsOrUrls, options, seams = {}) {
5599
5754
  if (!check) continue;
5600
5755
  const activityLlm = {
5601
5756
  provider: check.llm.provider,
5602
- model: check.llm.model
5757
+ model: check.llm.model,
5758
+ ...check.llm.reasoning ? { reasoning: check.llm.reasoning } : {}
5603
5759
  };
5604
- const effective = override.llm ?? activityLlm;
5605
- const judgeLlm = judgeOverride.llm ?? effective;
5760
+ const effective = resolveEvalSpec(activityLlm, override.llm, overrideReasoning.reasoning);
5761
+ const judgeLlm = resolveEvalSpec(effective, judgeOverride.llm, judgeOverrideReasoning.reasoning);
5606
5762
  const llm = {
5607
5763
  ...effective,
5608
- ...override.llm ? { overrides: activityLlm } : {},
5764
+ ...sameEvalSpec(effective, activityLlm) ? {} : { overrides: activityLlm },
5609
5765
  ...judging ? { judge: {
5610
5766
  ...judgeLlm,
5611
- overridden: judgeOverride.llm !== void 0
5767
+ overridden: judgeOverride.llm !== void 0 || judgeOverrideReasoning.reasoning !== void 0
5612
5768
  } } : {}
5613
5769
  };
5614
5770
  const label = files.length > 1 ? `(${fileIndex}/${files.length}) ${check.evalFile.id}` : check.evalFile.id;
@@ -5654,7 +5810,7 @@ async function runEvalCommand(pathsOrUrls, options, seams = {}) {
5654
5810
  process.exitCode = batchPassed(batch) ? 0 : 1;
5655
5811
  }
5656
5812
  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", `
5813
+ 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
5814
  Examples:
5659
5815
  # Evaluate one quiz's golden answers
5660
5816
  $ novedu-cli eval ./0010-welcome-quiz.eval.yaml
@@ -5672,6 +5828,9 @@ Examples:
5672
5828
  # How would this rubric perform on another model? (both flags, always together)
5673
5829
  $ novedu-cli eval ./my-quiz.eval.yaml --llm-provider "Azure Foundry" --llm-model gpt-5-mini
5674
5830
 
5831
+ # Same model, more thinking: the level alone keeps the activity's provider/model
5832
+ $ novedu-cli eval ./my-quiz.eval.yaml --llm-reasoning high
5833
+
5675
5834
  # A strong judge over the quiz's own grader — the recommended pairing
5676
5835
  $ novedu-cli eval ./my-quiz.eval.yaml --judge-llm-provider "Azure Foundry" --judge-llm-model gpt-5.6-terra
5677
5836
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@novedu/cli",
3
- "version": "0.23.0",
3
+ "version": "0.24.1",
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": {