@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.
- package/README.md +29 -12
- package/dist/main.js +161 -31
- 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 } }
|
|
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
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
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
|
|
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}`
|
|
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
|
|
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
|
-
|
|
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
|
-
...
|
|
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
|
|
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
|
|
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
|
|
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 =
|
|
4536
|
-
return llm.overrides ? `${llm.overrides
|
|
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
|
|
4547
|
-
return `${judge
|
|
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
|
|
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
|
|
5605
|
-
const judgeLlm = judgeOverride.llm
|
|
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
|
-
...
|
|
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.
|
|
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": {
|