@novedu/cli 0.19.0 → 0.20.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 +99 -8
  2. package/dist/main.js +1684 -591
  3. package/package.json +3 -3
package/dist/main.js CHANGED
@@ -1,9 +1,9 @@
1
1
  #!/usr/bin/env node
2
- import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
2
  import { Command } from "commander";
4
3
  import { readFile, writeFile } from "node:fs/promises";
5
4
  import { basename, dirname, join, resolve } from "node:path";
6
5
  import { spawn } from "node:child_process";
6
+ import { globSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
7
7
  import { createServer } from "node:http";
8
8
  import { homedir } from "node:os";
9
9
  import { CryptoProvider, PublicClientApplication } from "@azure/msal-node";
@@ -226,18 +226,19 @@ function failJson(value) {
226
226
  * not abort the run).
227
227
  */
228
228
  async function performApiRequest(options) {
229
- const fail = (value) => {
229
+ const fail = (value, extra = {}) => {
230
230
  if (!options.quiet) failJson(value);
231
231
  return {
232
232
  ok: false,
233
- error: value
233
+ error: value,
234
+ ...extra
234
235
  };
235
236
  };
236
237
  let token;
237
238
  try {
238
239
  token = await getAccessToken();
239
240
  } catch (error) {
240
- if (error instanceof NotSignedInError) return fail({ message: error.message });
241
+ if (error instanceof NotSignedInError) return fail({ message: error.message }, { authFailed: true });
241
242
  throw error;
242
243
  }
243
244
  const server = resolveServerUrl(options.server);
@@ -260,7 +261,10 @@ async function performApiRequest(options) {
260
261
  } catch {
261
262
  payload = void 0;
262
263
  }
263
- if (!response.ok) return fail(payload ?? { message: `${server} rejected the request: HTTP ${response.status}` });
264
+ if (!response.ok) return fail(payload ?? { message: `${server} rejected the request: HTTP ${response.status}` }, {
265
+ status: response.status,
266
+ ...response.status === 401 || response.status === 403 ? { authFailed: true } : {}
267
+ });
264
268
  return {
265
269
  ok: true,
266
270
  payload
@@ -876,173 +880,55 @@ function registerCodes(program) {
876
880
  });
877
881
  }
878
882
  //#endregion
879
- //#region src/commands/files.ts
880
- const SERVER_OPTION$2 = ["--server <url>", "Novedu server base URL (defaults to the NOVEDU_SERVER env var, then production)"];
881
- async function readStdin() {
882
- const chunks = [];
883
- for await (const chunk of process.stdin) chunks.push(chunk);
884
- return Buffer.concat(chunks).toString("utf8");
885
- }
886
- function registerFiles(program) {
887
- const files = program.command("files").description("Manage app-hosted YAML files on the Novedu server");
888
- files.command("upload <name>").description("Create or update an app-hosted YAML file from --file or stdin (validated server-side)").option("--kind <kind>", "file kind (tutor, fragment, quiz, writing, coding) — required when creating").option("--file <path>", "read the YAML from this path instead of stdin").option(...SERVER_OPTION$2).action(async (name, options) => {
889
- let content;
890
- try {
891
- content = options.file === void 0 ? await readStdin() : await readFile(options.file, "utf8");
892
- } catch (error) {
893
- failJson({ message: `Could not read ${options.file}: ${error instanceof Error ? error.message : error}` });
894
- return;
895
- }
896
- await runApiRequest({
897
- server: options.server,
898
- path: `/api/files/${encodeURIComponent(name)}`,
899
- method: "PUT",
900
- body: {
901
- ...options.kind === void 0 ? {} : { kind: options.kind },
902
- content
903
- }
904
- });
905
- });
906
- files.command("list").description("List app-hosted YAML files (defaults to only your own, like the web list)").option("--search <q>", "contains-filter over name/title/description").option("--all", "include files last written by other teachers").option(...SERVER_OPTION$2).action(async (options) => {
907
- const params = new URLSearchParams();
908
- if (options.search) params.set("q", options.search);
909
- if (options.all) params.set("mine", "0");
910
- const query = params.toString();
911
- await runApiRequest({
912
- server: options.server,
913
- path: `/api/files${query ? `?${query}` : ""}`
914
- });
915
- });
916
- }
883
+ //#region ../lib/quiz-verdict-schema.ts
884
+ const QUIZ_VERDICT_SCHEMA = z.object({
885
+ result: z.enum([
886
+ "correct",
887
+ "partial",
888
+ "incorrect"
889
+ ]),
890
+ feedback: z.string()
891
+ });
892
+ const QUIZ_VERDICT_ENUM = QUIZ_VERDICT_SCHEMA.shape.result;
917
893
  //#endregion
918
- //#region ../lib/file-name.ts
894
+ //#region ../lib/eval-schema.ts
895
+ /** The three verdicts in canonical order (best → worst); the sort key for expected sets. */
896
+ const EVAL_VERDICTS = QUIZ_VERDICT_ENUM.options;
919
897
  /**
920
- * Maps a filename or bare extension (with or without a leading dot, any case)
921
- * to an {@link ImageMime}; returns `null` for anything unrecognized. `jpg`/`jpeg`
922
- * both map to `image/jpeg`, `svg` to `image/svg+xml`.
898
+ * Eval ids share the flat namespace of report headers and `--out` files, so they stay
899
+ * URL- and YAML-plain: an alphanumeric start, then alphanumerics, `.`, `-` or `_`.
923
900
  */
924
- function imageMimeFromExtension(filename) {
925
- const lastDot = filename.lastIndexOf(".");
926
- switch ((lastDot >= 0 ? filename.slice(lastDot + 1) : filename).toLowerCase()) {
927
- case "png": return "image/png";
928
- case "jpg":
929
- case "jpeg": return "image/jpeg";
930
- case "svg": return "image/svg+xml";
931
- default: return null;
932
- }
933
- }
934
- //#endregion
935
- //#region src/commands/images.ts
936
- const SERVER_OPTION$1 = ["--server <url>", "Novedu server base URL (defaults to the NOVEDU_SERVER env var, then production)"];
937
- function registerImages(program) {
938
- const images = program.command("images").description("Manage app-hosted images on the Novedu server");
939
- images.command("upload <name>").description("Upload a NEW image (.png, .jpg/.jpeg or .svg, max 5 MB) from --file").option("--file <path>", "the image file to upload (required images are binary, no stdin)").option("--credit <text>", "optional attribution shown with the image (max 512 chars)").option(...SERVER_OPTION$1).action(async (name, options) => {
940
- if (options.file === void 0) {
941
- failJson({ message: "Pass --file <path>images are binary, stdin is not supported." });
942
- return;
943
- }
944
- const mime = imageMimeFromExtension(options.file);
945
- if (mime === null) {
946
- failJson({ message: "Only .png, .jpg/.jpeg and .svg files can be uploaded." });
947
- return;
948
- }
949
- let bytes;
950
- try {
951
- bytes = await readFile(options.file);
952
- } catch (error) {
953
- failJson({ message: `Could not read ${options.file}: ${error instanceof Error ? error.message : error}` });
954
- return;
955
- }
956
- const slot = await performApiRequest({
957
- server: options.server,
958
- path: `/api/images/${encodeURIComponent(name)}`,
959
- method: "POST",
960
- body: {
961
- mime,
962
- byteSize: bytes.length
963
- }
964
- });
965
- if (!slot.ok) return;
966
- const { uploadUrl, blobPath } = slot.payload ?? {};
967
- if (typeof uploadUrl !== "string" || typeof blobPath !== "string") {
968
- failJson({ message: "Unexpected response from the server." });
969
- return;
970
- }
971
- let putResponse;
972
- try {
973
- putResponse = await fetch(uploadUrl, {
974
- method: "PUT",
975
- headers: {
976
- "x-ms-blob-type": "BlockBlob",
977
- "content-type": mime
978
- },
979
- body: new Uint8Array(bytes)
980
- });
981
- } catch (error) {
982
- failJson({ message: `Could not reach storage: ${error instanceof Error ? error.message : error}` });
983
- return;
984
- }
985
- if (!putResponse.ok) {
986
- failJson({ message: `The upload to storage failed: HTTP ${putResponse.status}. Try again.` });
987
- return;
988
- }
989
- await runApiRequest({
990
- server: options.server,
991
- path: `/api/images/${encodeURIComponent(name)}/confirm`,
992
- method: "POST",
993
- body: {
994
- blobPath,
995
- mime,
996
- ...options.credit === void 0 ? {} : { credit: options.credit }
997
- }
998
- });
999
- });
1000
- images.command("list").description("List app-hosted images (defaults to only your own, like the web list)").option("--search <q>", "contains-filter over the name").option("--all", "include images uploaded by other teachers").option(...SERVER_OPTION$1).action(async (options) => {
1001
- const params = new URLSearchParams();
1002
- if (options.search) params.set("q", options.search);
1003
- if (options.all) params.set("mine", "0");
1004
- const query = params.toString();
1005
- await runApiRequest({
1006
- server: options.server,
1007
- path: `/api/images${query ? `?${query}` : ""}`
1008
- });
1009
- });
1010
- }
1011
- //#endregion
1012
- //#region src/commands/login.ts
1013
- function registerLogin(program) {
1014
- program.command("login").description("Sign in to Microsoft Entra ID (opens your browser)").option("--device-code", "sign in with the device code flow instead (for machines without a browser; the tenant must allow it)").addHelpText("after", `
1015
- Sign-in is the one human-assisted step: by default a browser window opens for
1016
- the Microsoft sign-in (first-time users see a one-time consent prompt). On a
1017
- machine without a browser, --device-code prints a verification URL and a code
1018
- to enter from any other device — note that some tenants block the device code
1019
- flow by policy (error 53003). Every other command then works non-interactively
1020
- from the cached credentials. Already signed in? The command says so and exits
1021
- — it never blocks.`).action(async (options) => {
1022
- const pca = buildPca();
1023
- const cached = await acquireSilent(pca);
1024
- if (cached) {
1025
- console.log(`Already signed in as ${displayName(cached)}.`);
1026
- return;
1027
- }
1028
- const result = options.deviceCode ? await acquireByDeviceCode(pca, (message) => console.log(message)) : await acquireInteractive(pca, (url) => {
1029
- console.log("A browser window should open for the Microsoft sign-in.");
1030
- console.log(`If it does not, open this URL yourself:\n${url}`);
1031
- });
1032
- console.log(`Signed in as ${displayName(result)}.`);
1033
- });
901
+ const EVAL_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
902
+ const MAX_ID_LENGTH = 128;
903
+ const expectSchema = z.union([QUIZ_VERDICT_ENUM, z.array(QUIZ_VERDICT_ENUM).min(1)]).meta({ description: "The verdict this answer must be graded with: one of \"correct\", \"partial\", \"incorrect\", or a non-empty list of acceptable verdicts (e.g. [correct, partial]) when more than one grading is defensible." });
904
+ /** One golden answer: the student text plus the verdict(s) the grader must produce. */
905
+ const EvalAnswerSchema = z.strictObject({
906
+ expect: expectSchema,
907
+ answer: z.string().min(1).meta({ description: "The student answer to grade, verbatim. Written as a YAML block scalar (|) for multi-line answers." })
908
+ });
909
+ /** All golden answers for ONE question of the target quiz. */
910
+ const EvalQuestionSchema = z.strictObject({
911
+ question: z.string().min(1).meta({ description: "The question id in the target quiz. For a question imported through quiz_files this is the namespaced \"<alias>/<id>\" form." }),
912
+ answers: z.array(EvalAnswerSchema).min(1).meta({ description: "The golden answers for this question — at least one." })
913
+ });
914
+ /** The whole eval file. */
915
+ const EvalYamlSchema = z.strictObject({
916
+ id: z.string().regex(EVAL_ID_PATTERN).max(MAX_ID_LENGTH).meta({ description: "Stable identifier of this eval, shown in the run report. Letters, digits, dot, dash and underscore." }),
917
+ target: z.string().min(1).meta({ description: "The quiz YAML this eval grades against — a path relative to THIS file, or an absolute http(s) URL." }),
918
+ questions: z.array(EvalQuestionSchema).min(1).meta({ description: "The evaluated questionsat least one, each with its golden answers." })
919
+ });
920
+ /**
921
+ * The canonical expected-verdict SET of one golden answer: a single verdict or a list,
922
+ * deduped and sorted into `EVAL_VERDICTS` order. Canonical because the confusion
923
+ * matrix keys its rows by this set `correct|partial` must be one row no matter which
924
+ * order the author happened to write it in.
925
+ */
926
+ function normalizeExpect(expect) {
927
+ return [...new Set(Array.isArray(expect) ? expect : [expect])].sort((a, b) => EVAL_VERDICTS.indexOf(a) - EVAL_VERDICTS.indexOf(b));
1034
928
  }
1035
- //#endregion
1036
- //#region src/commands/logout.ts
1037
- function registerLogout(program) {
1038
- program.command("logout").description("Sign out: remove the cached credentials from this machine").addHelpText("after", `
1039
- Purely local — already-issued access tokens stay valid until they expire
1040
- (about an hour). Running it while signed out is fine.`).action(async () => {
1041
- const cache = buildPca().getTokenCache();
1042
- for (const account of await cache.getAllAccounts()) await cache.removeAccount(account);
1043
- rmSync(TOKEN_CACHE_PATH, { force: true });
1044
- console.log("Signed out.");
1045
- });
929
+ /** The confusion matrix's row key for an expected set (already canonical). */
930
+ function expectedKey(expected) {
931
+ return expected.join("|");
1046
932
  }
1047
933
  //#endregion
1048
934
  //#region ../lib/coding-proxy.ts
@@ -2706,16 +2592,6 @@ async function loadQuizFrom(url, fetcher, opts = {}) {
2706
2592
  }
2707
2593
  }
2708
2594
  //#endregion
2709
- //#region ../lib/quiz-verdict-schema.ts
2710
- const QUIZ_VERDICT_SCHEMA = z.object({
2711
- result: z.enum([
2712
- "correct",
2713
- "partial",
2714
- "incorrect"
2715
- ]),
2716
- feedback: z.string()
2717
- });
2718
- //#endregion
2719
2595
  //#region ../lib/tutors/schemas.ts
2720
2596
  /**
2721
2597
  * An example question offered to students on the welcome screen: the `title` is
@@ -3074,267 +2950,12 @@ function promptSections(dump) {
3074
2950
  }
3075
2951
  }
3076
2952
  //#endregion
3077
- //#region src/file-fetcher.ts
3078
- const cliFetcher = async (url) => {
3079
- if (url.startsWith("file:")) try {
3080
- const text = await readFile(fileURLToPath(url), "utf8");
3081
- return {
3082
- ok: true,
3083
- status: 200,
3084
- text: async () => text
3085
- };
3086
- } catch {
3087
- return {
3088
- ok: false,
3089
- status: 404,
3090
- text: async () => ""
3091
- };
3092
- }
3093
- return defaultFetcher(url);
3094
- };
3095
- //#endregion
3096
- //#region src/format.ts
3097
- const useColor = process.stdout.isTTY && !process.env.NO_COLOR;
3098
- const paint = (code, s) => useColor ? `\x1b[${code}m${s}\x1b[0m` : s;
3099
- const green = (s) => paint("32", s);
3100
- const red = (s) => paint("31", s);
3101
- const yellow = (s) => paint("33", s);
3102
- const dim = (s) => paint("2", s);
3103
- /** Append the context fields an error/warning carries, when present. */
3104
- function context(item) {
3105
- const parts = [];
3106
- if (item.fileAlias) parts.push(`file=${item.fileAlias}`);
3107
- if (item.fragmentId) parts.push(`fragment=${item.fragmentId}`);
3108
- if ("questionId" in item && item.questionId) parts.push(`question=${item.questionId}`);
3109
- if (item.variable) parts.push(`variable=${item.variable}`);
3110
- if ("url" in item && item.url) parts.push(`url=${item.url}`);
3111
- if ("expectedType" in item && item.expectedType) parts.push(`expected=${item.expectedType}`);
3112
- if ("actualType" in item && item.actualType) parts.push(`actual=${item.actualType}`);
3113
- return parts.length ? dim(` (${parts.join(", ")})`) : "";
3114
- }
3115
- function renderWarnings(warnings) {
3116
- return warnings.map((w) => ` ${yellow("⚠")} ${yellow(w.code)} ${w.message}${context(w)}`);
3117
- }
2953
+ //#region ../lib/quiz-schema.ts
3118
2954
  /**
3119
- * Render each error as a line, with any flattened Zod schema-issue detail
3120
- * indented beneath it so a generic "Document does not match the expected
3121
- * structure" is followed by the actual field paths (e.g. `Unrecognized key:
3122
- * "nae"`), matching what the web UI shows.
3123
- */
3124
- function renderErrors(errors) {
3125
- const lines = [];
3126
- for (const e of errors) {
3127
- lines.push(` ${red("✗")} ${red(e.code)} ${e.message}${context(e)}`);
3128
- if (e.zodIssues) for (const issue of formatZodIssues(e.zodIssues)) lines.push(` ${dim(issue)}`);
3129
- }
3130
- return lines;
3131
- }
3132
- function formatResult(result, source) {
3133
- const lines = [];
3134
- if (result.ok) {
3135
- lines.push(green(`✔ Valid tutor`) + dim(` — ${source}`));
3136
- lines.push(` model: ${result.model}`);
3137
- lines.push(` system prompt: ${result.prompt.length} chars`);
3138
- lines.push(` imageInput: ${result.imageInput} anonymous: ${result.anonymous}` + (result.exampleQuestions.length ? ` exampleQuestions: ${result.exampleQuestions.length}` : ""));
3139
- if (result.warnings.length) {
3140
- lines.push("");
3141
- lines.push(yellow(`${result.warnings.length} warning(s):`));
3142
- lines.push(...renderWarnings(result.warnings));
3143
- }
3144
- return lines.join("\n");
3145
- }
3146
- lines.push(red(`✘ Invalid tutor`) + dim(` — ${source}`));
3147
- lines.push("");
3148
- lines.push(red(`${result.errors.length} error(s):`));
3149
- lines.push(...renderErrors(result.errors));
3150
- if (result.warnings.length) {
3151
- lines.push("");
3152
- lines.push(yellow(`${result.warnings.length} warning(s):`));
3153
- lines.push(...renderWarnings(result.warnings));
3154
- }
3155
- return lines.join("\n");
3156
- }
3157
- /** Same renderer, for a standalone fragment-FILE check (`--kind fragment`). */
3158
- function formatFragmentResult(result, source) {
3159
- const lines = [];
3160
- if (result.ok) {
3161
- lines.push(green(`✔ Valid fragment file`) + dim(` — ${source}`));
3162
- lines.push(` id: ${result.fragmentFileId}`);
3163
- lines.push(` fragments: ${result.fragmentIds.length}` + (result.fragmentIds.length ? ` (${result.fragmentIds.join(", ")})` : ""));
3164
- if (result.warnings.length) {
3165
- lines.push("");
3166
- lines.push(yellow(`${result.warnings.length} warning(s):`));
3167
- lines.push(...renderWarnings(result.warnings));
3168
- }
3169
- return lines.join("\n");
3170
- }
3171
- lines.push(red(`✘ Invalid fragment file`) + dim(` — ${source}`));
3172
- lines.push("");
3173
- lines.push(red(`${result.errors.length} error(s):`));
3174
- lines.push(...renderErrors(result.errors));
3175
- if (result.warnings.length) {
3176
- lines.push("");
3177
- lines.push(yellow(`${result.warnings.length} warning(s):`));
3178
- lines.push(...renderWarnings(result.warnings));
3179
- }
3180
- return lines.join("\n");
3181
- }
3182
- /**
3183
- * Shared tail for the quiz/writing renderers: on failure, the error list (with any
3184
- * flattened Zod issues); plus any warnings on either branch.
3185
- */
3186
- function renderFailureAndWarnings(result, label, source) {
3187
- const lines = [red(`✘ Invalid ${label}`) + dim(` — ${source}`), ""];
3188
- lines.push(red(`${result.errors.length} error(s):`));
3189
- lines.push(...renderErrors(result.errors));
3190
- if (result.warnings.length) {
3191
- lines.push("");
3192
- lines.push(yellow(`${result.warnings.length} warning(s):`));
3193
- lines.push(...renderWarnings(result.warnings));
3194
- }
3195
- return lines.join("\n");
3196
- }
3197
- /** Renderer for a quiz check (`--kind quiz`). */
3198
- function formatQuizResult(result, source) {
3199
- if (!result.ok) return renderFailureAndWarnings(result, "quiz", source);
3200
- const lines = [green(`✔ Valid quiz`) + dim(` — ${source}`)];
3201
- lines.push(` id: ${result.quizId}`);
3202
- lines.push(` model: ${result.model}`);
3203
- lines.push(` questions: ${result.questionCount} anonymous: ${result.anonymous}`);
3204
- if (result.warnings.length) {
3205
- lines.push("");
3206
- lines.push(yellow(`${result.warnings.length} warning(s):`));
3207
- lines.push(...renderWarnings(result.warnings));
3208
- }
3209
- return lines.join("\n");
3210
- }
3211
- /** Renderer for a writing-activity check (`--kind writing`). */
3212
- function formatWritingResult(result, source) {
3213
- if (!result.ok) return renderFailureAndWarnings(result, "writing activity", source);
3214
- const lines = [green(`✔ Valid writing activity`) + dim(` — ${source}`)];
3215
- lines.push(` id: ${result.writingId}`);
3216
- lines.push(` model: ${result.model}`);
3217
- lines.push(` anonymous: ${result.anonymous}`);
3218
- if (result.warnings.length) {
3219
- lines.push("");
3220
- lines.push(yellow(`${result.warnings.length} warning(s):`));
3221
- lines.push(...renderWarnings(result.warnings));
3222
- }
3223
- return lines.join("\n");
3224
- }
3225
- /**
3226
- * Renderer for a coding-activity check (`--kind coding`). Coding is ALWAYS anonymous
3227
- * (the API path carries no per-student identity), so — unlike quiz/writing — that is
3228
- * shown as a fixed note, not a per-file value.
3229
- */
3230
- function formatCodingResult(result, source) {
3231
- if (!result.ok) return renderFailureAndWarnings(result, "coding activity", source);
3232
- const lines = [green(`✔ Valid coding activity`) + dim(` — ${source}`)];
3233
- lines.push(` id: ${result.codingId}`);
3234
- lines.push(` model: ${result.model}`);
3235
- lines.push(` anonymous: true ${dim("(always — the API path carries no identity)")}`);
3236
- if (result.warnings.length) {
3237
- lines.push("");
3238
- lines.push(yellow(`${result.warnings.length} warning(s):`));
3239
- lines.push(...renderWarnings(result.warnings));
3240
- }
3241
- return lines.join("\n");
3242
- }
3243
- /**
3244
- * Renderer for a prompt dump (`prompts`). Kind-agnostic by construction: the envelope
3245
- * (kind / id / provider+model) plus one line per prompt with its character count — the
3246
- * sections come from `promptSections`, so a new kind needs no change here. `--json`
3247
- * carries the prompt text itself.
3248
- */
3249
- function formatPromptDump(dump, sections, source) {
3250
- const lines = [green(`✔ Prompts — ${dump.kind}`) + dim(` — ${source}`)];
3251
- lines.push(` id: ${dump.id}`);
3252
- lines.push(` provider: ${dump.llm.provider} model: ${dump.llm.model}`);
3253
- lines.push(` prompts: ${sections.length}`);
3254
- for (const section of sections) lines.push(` ${section.name}: ${section.text.length} chars`);
3255
- lines.push("");
3256
- lines.push(dim(" Run again with --json for the full prompt text."));
3257
- return lines.join("\n");
3258
- }
3259
- //#endregion
3260
- //#region ../lib/coding-schema.ts
3261
- const CodingYamlSchema = z.strictObject({
3262
- id: z.string().min(1).meta({ description: "Short machine-readable activity id, e.g. beginner-typescript." }),
3263
- name: z.string().optional().meta({ description: "Optional human-readable label (not shown to the student)." }),
3264
- title: z.string().optional().meta({ description: "Optional label shown to the student on the /<code> connection page." }),
3265
- llm: z.strictObject({
3266
- 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." }),
3267
- provider: providerSchema
3268
- }).meta({
3269
- id: "llm",
3270
- description: "The pinned model and provider that answer coding requests."
3271
- }),
3272
- fragment_files: z.array(FragmentFileRefSchema).default([]).meta({ description: "Optional fragment libraries this activity pulls shared prompt fragments from." }),
3273
- text_files: z.array(TextFileRefSchema).default([]).meta({ description: "Optional plain-text files (markdown / source, e.g. a sample solution) embedded verbatim into instructions via {{file \"alias\"}} markers." }),
3274
- instructions: z.string().min(1).meta({ description: "The assistant's system prompt. SERVER-ONLY: never sent to the browser or the coding agent, and appended AFTER the coding tool's own prompt (so the teacher has the final word). Constrain the assistant to what your class has learned. When any fragment_files or text_files are declared it is a Handlebars template: place fragments inline with {{fragment \"alias.id\" …}} and embed text files with {{file \"alias\"}} (optionally {{file \"alias\" from=10 to=40}} for a line range; escape a literal {{ as \\{{)." })
3275
- });
3276
- //#endregion
3277
- //#region ../lib/coding-validate.ts
3278
- /**
3279
- * Extract metadata from an already-schema-validated coding value. Split from
3280
- * `checkCodingValue` so `loadAndCheckCoding` can reuse the single `validate` it already
3281
- * ran (no second parse of the same document against the same schema).
3282
- */
3283
- function checkCodingParsed(coding) {
3284
- return {
3285
- ok: true,
3286
- codingId: coding.id,
3287
- model: coding.llm.model,
3288
- provider: coding.llm.provider,
3289
- title: coding.title ?? null,
3290
- warnings: []
3291
- };
3292
- }
3293
- /**
3294
- * Validate a coding FILE: scheme-gate + fetch + parse (shared `loadYaml`), then the
3295
- * pure `checkCodingValue`. The web app passes the default http(s)-only schemes; the
3296
- * CLI adds `file:` so a local coding YAML on disk validates too.
3297
- */
3298
- async function loadAndCheckCoding(url, fetchImpl, opts = {}) {
3299
- const yaml = await loadYaml(url, fetchImpl, opts);
3300
- if (!yaml.ok) return {
3301
- ok: false,
3302
- errors: [yaml.error],
3303
- warnings: []
3304
- };
3305
- const valid = validate(yaml.value, CodingYamlSchema, "CODING_SCHEMA_ERROR", url);
3306
- if (!valid.ok) return {
3307
- ok: false,
3308
- errors: [valid.error],
3309
- warnings: []
3310
- };
3311
- const checked = checkCodingParsed(valid.data);
3312
- if (!checked.ok) return checked;
3313
- const assembled = await assembleFragmentPrompt({
3314
- fragment_files: valid.data.fragment_files,
3315
- text_files: valid.data.text_files
3316
- }, url, fetchImpl, {
3317
- allowedSchemes: opts.allowedSchemes,
3318
- validateLibraries: opts.validateLibraries ?? true
3319
- }, valid.data.instructions);
3320
- const warnings = [...checked.warnings, ...assembled.warnings];
3321
- if (!assembled.ok) return {
3322
- ok: false,
3323
- errors: assembled.errors,
3324
- warnings
3325
- };
3326
- return {
3327
- ...checked,
3328
- warnings
3329
- };
3330
- }
3331
- //#endregion
3332
- //#region ../lib/quiz-schema.ts
3333
- /**
3334
- * A live quiz include: alias + URL, mirroring `FragmentFileRefSchema` (same URL
3335
- * contract). The alias prefixes every imported question id as `"<alias>/<id>"`, so
3336
- * on top of the no-dot rule it may not contain a `/` either. Aliases live in their
3337
- * OWN namespace (they never appear in `{{…}}` markers — only in question ids).
2955
+ * A live quiz include: alias + URL, mirroring `FragmentFileRefSchema` (same URL
2956
+ * contract). The alias prefixes every imported question id as `"<alias>/<id>"`, so
2957
+ * on top of the no-dot rule it may not contain a `/` either. Aliases live in their
2958
+ * OWN namespace (they never appear in `{{…}}` markers only in question ids).
3338
2959
  */
3339
2960
  const QuizFileRefSchema = z.strictObject({
3340
2961
  id: z.string().regex(/^[^./]+$/, { message: "Alias must not contain a dot or a slash" }).meta({
@@ -3606,181 +3227,1653 @@ async function loadAndCheckQuiz(url, fetchImpl, opts = {}) {
3606
3227
  };
3607
3228
  }
3608
3229
  //#endregion
3609
- //#region ../lib/writing-schema.ts
3610
- const WritingYamlSchema = z.strictObject({
3611
- id: z.string().min(1).meta({ description: "Short machine-readable activity id, e.g. human-animal-short-story." }),
3612
- name: z.string().optional().meta({ description: "Optional human-readable title (used as a label)." }),
3613
- title: z.string().optional().meta({ description: "Optional greeting shown to students on the welcome screen instead of the default message." }),
3614
- description: z.string().optional().meta({ description: "Optional description shown to students below the welcome greeting (Markdown)." }),
3615
- anonymous: z.boolean().optional().meta({
3616
- default: false,
3617
- description: "Writing DIVERGES: it defaults to false (attributed), because review and the Save feature need to know whose text it is. Set to true for ephemeral, unattributed writing — which also disables saving."
3618
- }),
3619
- llm: z.strictObject({
3620
- model: z.string().min(1).meta({ description: "The model that drives the feedback chat." }),
3621
- provider: providerSchema
3622
- }).meta({
3623
- id: "llm",
3624
- description: "The model and provider that back the writing coach."
3625
- }),
3626
- fragment_files: z.array(FragmentFileRefSchema).default([]).meta({ description: "Optional fragment libraries this activity pulls shared prompt fragments from." }),
3627
- text_files: z.array(TextFileRefSchema).default([]).meta({ description: "Optional plain-text files (markdown / source) embedded verbatim into instructions via {{file \"alias\"}} markers." }),
3628
- instructions: z.string().min(1).meta({ description: "The writing coach's system prompt. SERVER-ONLY: never sent to the browser, so it may describe the assessment criteria and coaching strategy. When any fragment_files or text_files are declared it is a Handlebars template: place fragments inline with {{fragment \"alias.id\" …}} and embed text files with {{file \"alias\"}} (optionally {{file \"alias\" from=10 to=40}} for a line range; escape a literal {{ as \\{{)." }),
3629
- placeholder: z.string().optional().meta({ description: "Optional starter text prefilled into the editor. Empty for a blank page." })
3630
- });
3631
- //#endregion
3632
- //#region ../lib/writing-validate.ts
3633
- /** Writing DIVERGES from tutor/quiz: it defaults to attributed (`anonymous: false`). */
3634
- const DEFAULT_ANONYMOUS = false;
3230
+ //#region ../lib/eval-validate.ts
3231
+ function fail(errors, warnings = []) {
3232
+ return {
3233
+ ok: false,
3234
+ errors,
3235
+ warnings
3236
+ };
3237
+ }
3238
+ /** Zod issues as one error each, the dotted path leading the message. */
3239
+ function schemaErrors(issues, url) {
3240
+ return issues.map((issue) => {
3241
+ const path = issue.path.map((segment) => String(segment)).join(".");
3242
+ return error("EVAL_SCHEMA", path ? `${path}: ${issue.message}` : issue.message, { url });
3243
+ });
3244
+ }
3635
3245
  /**
3636
- * Extract metadata from an already-schema-validated writing value. Split from
3637
- * `checkWritingValue` so `loadAndCheckWriting` can reuse the single `validate` it already
3638
- * ran (no second parse of the same document against the same schema).
3246
+ * Check ONE eval file end to end. `fetcher` is the caller's network seam and
3247
+ * `allowedSchemes` the usual SSRF gate (the CLI adds `file:` so an on-disk eval
3248
+ * resolves the quiz sitting next to it).
3639
3249
  */
3640
- function checkWritingParsed(writing) {
3250
+ async function loadAndCheckEval(url, fetchImpl, opts = {}) {
3251
+ const allowedSchemes = opts.allowedSchemes;
3252
+ const yaml = await loadYaml(url, fetchImpl, { allowedSchemes });
3253
+ if (!yaml.ok) return fail([error(yaml.error.code === "YAML_PARSE_ERROR" ? "EVAL_PARSE" : "EVAL_READ", yaml.error.message, { url })]);
3254
+ const parsed = EvalYamlSchema.safeParse(yaml.value);
3255
+ if (!parsed.success) return fail(schemaErrors(parsed.error.issues, url));
3256
+ const evalFile = parsed.data;
3257
+ let targetUrl;
3258
+ try {
3259
+ targetUrl = new URL(evalFile.target, url).href;
3260
+ } catch {
3261
+ return fail([error("EVAL_TARGET_ERROR", `The target "${evalFile.target}" is not a usable URL.`, { url })]);
3262
+ }
3263
+ if (allowedSchemes !== void 0) {
3264
+ let scheme = "";
3265
+ try {
3266
+ scheme = new URL(targetUrl).protocol;
3267
+ } catch {
3268
+ scheme = "";
3269
+ }
3270
+ if (!allowedSchemes.includes(scheme)) return fail([error("EVAL_TARGET_ERROR", `The target URL is not allowed: ${targetUrl}`, { url: targetUrl })]);
3271
+ }
3272
+ const warnings = [];
3273
+ if (opts.strictTarget) {
3274
+ const strict = await loadAndCheckQuiz(targetUrl, fetchImpl, {
3275
+ allowedSchemes,
3276
+ validateLibraries: opts.validateLibraries ?? true
3277
+ });
3278
+ warnings.push(...strict.warnings);
3279
+ if (!strict.ok) return fail(strict.errors, warnings);
3280
+ }
3281
+ const dumped = await dumpPrompts("quiz", targetUrl, fetchImpl, { allowedSchemes });
3282
+ if (!dumped.ok) return fail(dumped.errors.map((e) => error("EVAL_TARGET_ERROR", `The target quiz could not be loaded: ${e.message}`, { url: targetUrl })), warnings);
3283
+ const quizDump = dumped.dump;
3284
+ if (quizDump.kind !== "quiz") return fail([error("EVAL_TARGET_ERROR", "The target is not a quiz.", { url: targetUrl })], warnings);
3285
+ const known = new Set(quizDump.grading.questions.map((question) => question.id));
3286
+ const unknown = evalFile.questions.filter((question) => !known.has(question.question)).map((question) => error("EVAL_UNKNOWN_QUESTION", `The target quiz has no question "${question.question}".`, {
3287
+ questionId: question.question,
3288
+ url: targetUrl
3289
+ }));
3290
+ if (unknown.length > 0) return fail(unknown, warnings);
3291
+ const resolved = await loadQuizFrom(targetUrl, fetchImpl, { allowedSchemes });
3292
+ const quizQuestions = resolved.ok ? resolved.quiz.questions.map((question) => ({
3293
+ id: question.id,
3294
+ text: question.question
3295
+ })) : [];
3641
3296
  return {
3642
3297
  ok: true,
3643
- writingId: writing.id,
3644
- model: writing.llm.model,
3645
- provider: writing.llm.provider,
3646
- anonymous: writing.anonymous ?? DEFAULT_ANONYMOUS,
3647
- title: writing.title ?? null,
3648
- warnings: []
3298
+ evalFile,
3299
+ targetUrl,
3300
+ quizDump,
3301
+ quizQuestions,
3302
+ caseCount: evalFile.questions.reduce((sum, question) => sum + question.answers.length, 0),
3303
+ warnings
3649
3304
  };
3650
3305
  }
3306
+ //#endregion
3307
+ //#region src/retry.ts
3308
+ const defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
3651
3309
  /**
3652
- * Validate a writing FILE: scheme-gate + fetch + parse (shared `loadYaml`), then the
3653
- * pure `checkWritingValue`. The web app passes the default http(s)-only schemes; the
3654
- * CLI adds `file:` so a local writing YAML on disk validates too.
3310
+ * Runs `work` up to `attempts` times, waiting a LINEARLY growing delay between tries
3311
+ * (the shape the Python PoC used against SCCH's occasional 504s). Returns the first
3312
+ * outcome `shouldRetry` rejects, or the last outcome when the budget runs out.
3655
3313
  */
3656
- async function loadAndCheckWriting(url, fetchImpl, opts = {}) {
3657
- const yaml = await loadYaml(url, fetchImpl, opts);
3658
- if (!yaml.ok) return {
3659
- ok: false,
3660
- errors: [yaml.error],
3661
- warnings: []
3314
+ async function withRetry(work, options) {
3315
+ const attempts = Math.max(1, options.attempts ?? 4);
3316
+ const baseDelayMs = options.baseDelayMs ?? 5e3;
3317
+ const sleep = options.sleep ?? defaultSleep;
3318
+ let last;
3319
+ for (let attempt = 1; attempt <= attempts; attempt++) {
3320
+ if (attempt > 1) await sleep(baseDelayMs * (attempt - 1));
3321
+ last = await work(attempt);
3322
+ if (!options.shouldRetry(last)) return last;
3323
+ }
3324
+ return last;
3325
+ }
3326
+ /**
3327
+ * Maps `items` through `fn` with at most `limit` in flight, preserving INPUT order in
3328
+ * the result (a worker pool, not `Promise.all` in chunks — a slow item never stalls the
3329
+ * pool behind it). `fn` receives the item's original index so callers can report
3330
+ * progress meaningfully.
3331
+ */
3332
+ async function mapWithConcurrency(items, limit, fn) {
3333
+ const results = new Array(items.length);
3334
+ const workers = Math.max(1, Math.min(Math.floor(limit) || 1, items.length));
3335
+ let next = 0;
3336
+ async function worker() {
3337
+ for (;;) {
3338
+ const index = next++;
3339
+ if (index >= items.length) return;
3340
+ results[index] = await fn(items[index], index);
3341
+ }
3342
+ }
3343
+ await Promise.all(Array.from({ length: workers }, () => worker()));
3344
+ return results;
3345
+ }
3346
+ //#endregion
3347
+ //#region src/eval-run.ts
3348
+ const ZERO_USAGE = {
3349
+ input: 0,
3350
+ cachedInput: 0,
3351
+ output: 0
3352
+ };
3353
+ /** Add one call's usage into an accumulator (mutating it — internal to the sums below). */
3354
+ function addUsage(total, usage) {
3355
+ if (!usage) return;
3356
+ total.input += usage.input;
3357
+ total.cachedInput += usage.cachedInput;
3358
+ total.output += usage.output;
3359
+ }
3360
+ /** Consecutive fully-errored cases that mean "the server is down, stop now". */
3361
+ const CIRCUIT_BREAKER_LIMIT = 3;
3362
+ /** Flatten questions × answers into cases, each carrying its grading prompt. */
3363
+ function planCases(checked) {
3364
+ const systemById = new Map(checked.quizDump.grading.questions.map((question) => [question.id, question.system]));
3365
+ const cases = [];
3366
+ for (const question of checked.evalFile.questions) question.answers.forEach((answer, answerIndex) => {
3367
+ cases.push({
3368
+ questionId: question.question,
3369
+ answerIndex,
3370
+ expected: normalizeExpect(answer.expect),
3371
+ answer: answer.answer,
3372
+ system: systemById.get(question.question)
3373
+ });
3374
+ });
3375
+ return cases;
3376
+ }
3377
+ /**
3378
+ * The majority verdict over the graded repeats. Returns the winner plus whether the
3379
+ * case PASSES: a unique majority passes when it is expected; a TIE passes only when
3380
+ * every tied verdict is expected (so a coin-flip between two acceptable gradings is a
3381
+ * pass, and one between an acceptable and an unacceptable one is not).
3382
+ */
3383
+ function majority(graded, expected) {
3384
+ if (graded.length === 0) return void 0;
3385
+ const counts = /* @__PURE__ */ new Map();
3386
+ for (const verdict of graded) counts.set(verdict, (counts.get(verdict) ?? 0) + 1);
3387
+ const top = Math.max(...counts.values());
3388
+ const tied = [...counts.entries()].filter(([, count]) => count === top).map(([verdict]) => verdict).sort((a, b) => EVAL_VERDICTS.indexOf(a) - EVAL_VERDICTS.indexOf(b));
3389
+ return {
3390
+ verdict: tied[0],
3391
+ passed: tied.every((verdict) => expected.includes(verdict))
3662
3392
  };
3663
- const valid = validate(yaml.value, WritingYamlSchema, "WRITING_SCHEMA_ERROR", url);
3664
- if (!valid.ok) return {
3665
- ok: false,
3666
- errors: [valid.error],
3667
- warnings: []
3393
+ }
3394
+ /** The seam: one runner per eval kind (mirrors `promptDumpers`). */
3395
+ const evalRunners = { quiz: { async run(checked, options) {
3396
+ const repeats = Math.max(1, Math.floor(options.repeats ?? 1));
3397
+ const concurrency = Math.max(1, Math.floor(options.concurrency ?? 4));
3398
+ const planned = planCases(checked);
3399
+ const total = planned.length * repeats;
3400
+ const questionTexts = new Map(checked.quizQuestions.map((q) => [q.id, q.text]));
3401
+ let done = 0;
3402
+ let consecutiveErrored = 0;
3403
+ let aborted;
3404
+ const progress = () => {
3405
+ done += 1;
3406
+ options.onProgress?.({
3407
+ done,
3408
+ total
3409
+ });
3668
3410
  };
3669
- const checked = checkWritingParsed(valid.data);
3670
- if (!checked.ok) return checked;
3671
- const assembled = await assembleFragmentPrompt({
3672
- fragment_files: valid.data.fragment_files,
3673
- text_files: valid.data.text_files
3674
- }, url, fetchImpl, {
3675
- allowedSchemes: opts.allowedSchemes,
3676
- validateLibraries: opts.validateLibraries ?? true
3677
- }, valid.data.instructions);
3678
- const warnings = [...checked.warnings, ...assembled.warnings];
3679
- if (!assembled.ok) return {
3680
- ok: false,
3681
- errors: assembled.errors,
3682
- warnings
3411
+ const results = await mapWithConcurrency(planned, concurrency, async (plan) => {
3412
+ const rows = [];
3413
+ for (let repeatIndex = 0; repeatIndex < repeats; repeatIndex++) {
3414
+ if (aborted) break;
3415
+ if (plan.system === void 0) {
3416
+ rows.push({
3417
+ repeatIndex,
3418
+ error: { message: `The quiz has no question "${plan.questionId}".` }
3419
+ });
3420
+ progress();
3421
+ continue;
3422
+ }
3423
+ const outcome = await withRetry(() => options.grade({
3424
+ system: plan.system,
3425
+ answer: plan.answer
3426
+ }), {
3427
+ attempts: options.retry?.attempts,
3428
+ baseDelayMs: options.retry?.baseDelayMs,
3429
+ sleep: options.retry?.sleep,
3430
+ shouldRetry: (value) => !value.ok && value.retryable && value.auth !== true
3431
+ });
3432
+ progress();
3433
+ if (outcome.ok) {
3434
+ rows.push({
3435
+ repeatIndex,
3436
+ got: outcome.verdict,
3437
+ feedback: outcome.feedback,
3438
+ ...outcome.usage ? { usage: outcome.usage } : {}
3439
+ });
3440
+ continue;
3441
+ }
3442
+ rows.push({
3443
+ repeatIndex,
3444
+ error: outcome.error
3445
+ });
3446
+ if (outcome.auth) {
3447
+ aborted ??= {
3448
+ reason: "auth",
3449
+ message: "Authentication failed — the run was aborted. Run `novedu-cli login`."
3450
+ };
3451
+ break;
3452
+ }
3453
+ }
3454
+ const graded = rows.flatMap((row) => row.got ? [row.got] : []);
3455
+ const winner = majority(graded, plan.expected);
3456
+ const status = rows.length === 0 ? "skipped" : !winner ? "errored" : winner.passed ? "passed" : "failed";
3457
+ if (status === "errored") {
3458
+ consecutiveErrored += 1;
3459
+ if (consecutiveErrored >= CIRCUIT_BREAKER_LIMIT) aborted ??= {
3460
+ reason: "circuit-breaker",
3461
+ message: `${CIRCUIT_BREAKER_LIMIT} cases failed in a row — the run was aborted.`
3462
+ };
3463
+ } else if (status !== "skipped") consecutiveErrored = 0;
3464
+ return {
3465
+ questionId: plan.questionId,
3466
+ answerIndex: plan.answerIndex,
3467
+ expected: plan.expected,
3468
+ answer: plan.answer,
3469
+ status,
3470
+ ...winner ? { verdict: winner.verdict } : {},
3471
+ unstable: new Set(graded).size > 1,
3472
+ repeats: rows
3473
+ };
3474
+ });
3475
+ const usage = { ...ZERO_USAGE };
3476
+ for (const result of results) for (const row of result.repeats) addUsage(usage, row.usage);
3477
+ const totals = {
3478
+ cases: results.length,
3479
+ passed: results.filter((c) => c.status === "passed").length,
3480
+ failed: results.filter((c) => c.status === "failed").length,
3481
+ errored: results.filter((c) => c.status === "errored").length,
3482
+ skipped: results.filter((c) => c.status === "skipped").length,
3483
+ unstable: results.filter((c) => c.unstable).length,
3484
+ repeats,
3485
+ calls: total,
3486
+ usage
3683
3487
  };
3488
+ const confusionCounts = /* @__PURE__ */ new Map();
3489
+ for (const result of results) {
3490
+ if (!result.verdict) continue;
3491
+ const key = `${expectedKey(result.expected)}${result.verdict}`;
3492
+ confusionCounts.set(key, (confusionCounts.get(key) ?? 0) + 1);
3493
+ }
3494
+ const confusion = [...confusionCounts.entries()].map(([key, count]) => {
3495
+ const [expected = "", got = ""] = key.split("\0");
3496
+ return {
3497
+ expected,
3498
+ got,
3499
+ count
3500
+ };
3501
+ }).sort((a, b) => a.expected.localeCompare(b.expected) || a.got.localeCompare(b.got));
3502
+ const strictCases = results.filter((result) => !result.expected.includes("correct"));
3503
+ const falseCorrectCount = strictCases.filter((result) => result.verdict === "correct").length;
3684
3504
  return {
3685
- ...checked,
3686
- warnings
3505
+ id: checked.evalFile.id,
3506
+ target: checked.targetUrl,
3507
+ llm: options.llm,
3508
+ totals,
3509
+ questions: [...new Set(checked.evalFile.questions.map((question) => question.question))].map((id) => ({
3510
+ id,
3511
+ text: questionTexts.get(id) ?? ""
3512
+ })),
3513
+ mismatches: results.filter((result) => result.status === "failed" || result.status === "errored"),
3514
+ cases: results,
3515
+ confusion,
3516
+ falseCorrect: {
3517
+ count: falseCorrectCount,
3518
+ denominator: strictCases.length,
3519
+ rate: strictCases.length === 0 ? 0 : falseCorrectCount / strictCases.length
3520
+ },
3521
+ ...aborted ? { aborted } : {}
3687
3522
  };
3523
+ } } };
3524
+ /** Run ONE checked eval file — the single entry point the command uses. */
3525
+ function runEval(kind, checked, options) {
3526
+ return evalRunners[kind].run(checked, options);
3527
+ }
3528
+ /** One file's own verdict: valid, graded, and not a single non-passing case. */
3529
+ function filePassed(file) {
3530
+ if (file.status === "invalid" || !file.result) return false;
3531
+ const { failed, errored, skipped } = file.result.totals;
3532
+ return failed === 0 && errored === 0 && skipped === 0;
3688
3533
  }
3689
- //#endregion
3690
- //#region src/commands/validate.ts
3691
- /** Every kind the `--kind` flag accepts (used for the option help + guard). */
3692
- const VALIDATE_KINDS = [
3693
- "tutor",
3694
- "fragment",
3695
- "quiz",
3696
- "writing",
3697
- "coding"
3698
- ];
3699
3534
  /**
3700
- * Turn the CLI argument into a URL the tutor core understands: an http(s) URL is
3701
- * used as-is; anything else is treated as a filesystem path and converted to an
3702
- * absolute `file://` URL.
3535
+ * Grand totals across a batch. This is the ONE machine-readable shape `--json` /
3536
+ * `--out` emit, single file or not, so scripts never branch on the file count.
3703
3537
  */
3704
- function toUrl(pathOrUrl) {
3705
- if (/^https?:\/\//i.test(pathOrUrl)) return pathOrUrl;
3706
- return pathToFileURL(resolve(pathOrUrl)).href;
3538
+ function summarizeBatch(files) {
3539
+ const totals = {
3540
+ files: files.length,
3541
+ invalid: files.filter((file) => file.status === "invalid").length,
3542
+ cases: 0,
3543
+ passed: 0,
3544
+ failed: 0,
3545
+ errored: 0,
3546
+ skipped: 0,
3547
+ unstable: 0,
3548
+ usage: { ...ZERO_USAGE }
3549
+ };
3550
+ for (const file of files) {
3551
+ if (!file.result) continue;
3552
+ totals.cases += file.result.totals.cases;
3553
+ totals.passed += file.result.totals.passed;
3554
+ totals.failed += file.result.totals.failed;
3555
+ totals.errored += file.result.totals.errored;
3556
+ totals.skipped += file.result.totals.skipped;
3557
+ totals.unstable += file.result.totals.unstable;
3558
+ addUsage(totals.usage, file.result.totals.usage);
3559
+ }
3560
+ return {
3561
+ files: files.map((file) => ({
3562
+ ...file,
3563
+ passed: filePassed(file)
3564
+ })),
3565
+ passed: batchPassed({ totals }),
3566
+ totals
3567
+ };
3707
3568
  }
3708
3569
  /**
3709
- * The validate command's pure core: run the requested pipeline over a local file or
3710
- * public URL. `file:` is allowed in addition to http(s) so local YAML can be
3711
- * validated (the web app deliberately stays http(s)-only). As an authoring tool, the
3712
- * tutor path runs the THOROUGH check (`validateLibraries`), so every fragment in every
3713
- * referenced library is rendered — not just the ones the tutor uses.
3570
+ * The CI gate: every file valid, and not a single failed, errored, or skipped CASE —
3571
+ * an aborted (and therefore incomplete) run must never read as a pass. The single
3572
+ * source of truth for the exit code AND for `EvalBatchResult.passed`.
3714
3573
  */
3715
- function runValidate(pathOrUrl, kind) {
3716
- const url = toUrl(pathOrUrl);
3717
- const allowedSchemes = [
3718
- "http:",
3719
- "https:",
3720
- "file:"
3721
- ];
3722
- switch (kind) {
3723
- case "fragment": return loadAndCheckFragmentFile(url, cliFetcher, { allowedSchemes }).then((result) => ({
3724
- kind,
3725
- result
3726
- }));
3727
- case "quiz": return loadAndCheckQuiz(url, cliFetcher, { allowedSchemes }).then((result) => ({
3728
- kind,
3729
- result
3730
- }));
3731
- case "writing": return loadAndCheckWriting(url, cliFetcher, { allowedSchemes }).then((result) => ({
3732
- kind,
3733
- result
3734
- }));
3735
- case "coding": return loadAndCheckCoding(url, cliFetcher, { allowedSchemes }).then((result) => ({
3736
- kind,
3737
- result
3738
- }));
3739
- default: return loadAndBuildTutorPrompt(url, cliFetcher, {
3740
- allowedSchemes,
3741
- validateLibraries: true
3742
- }).then((result) => ({
3743
- kind,
3744
- result
3745
- }));
3574
+ function batchPassed(batch) {
3575
+ return batch.totals.invalid === 0 && batch.totals.failed === 0 && batch.totals.errored === 0 && batch.totals.skipped === 0;
3576
+ }
3577
+ //#endregion
3578
+ //#region src/file-fetcher.ts
3579
+ const cliFetcher = async (url) => {
3580
+ if (url.startsWith("file:")) try {
3581
+ const text = await readFile(fileURLToPath(url), "utf8");
3582
+ return {
3583
+ ok: true,
3584
+ status: 200,
3585
+ text: async () => text
3586
+ };
3587
+ } catch {
3588
+ return {
3589
+ ok: false,
3590
+ status: 404,
3591
+ text: async () => ""
3592
+ };
3746
3593
  }
3594
+ return defaultFetcher(url);
3595
+ };
3596
+ //#endregion
3597
+ //#region src/format.ts
3598
+ const useColor = process.stdout.isTTY && !process.env.NO_COLOR;
3599
+ const paint = (code, s) => useColor ? `\x1b[${code}m${s}\x1b[0m` : s;
3600
+ const green = (s) => paint("32", s);
3601
+ const red = (s) => paint("31", s);
3602
+ const yellow = (s) => paint("33", s);
3603
+ const dim = (s) => paint("2", s);
3604
+ /** Append the context fields an error/warning carries, when present. */
3605
+ function context(item) {
3606
+ const parts = [];
3607
+ if (item.fileAlias) parts.push(`file=${item.fileAlias}`);
3608
+ if (item.fragmentId) parts.push(`fragment=${item.fragmentId}`);
3609
+ if ("questionId" in item && item.questionId) parts.push(`question=${item.questionId}`);
3610
+ if (item.variable) parts.push(`variable=${item.variable}`);
3611
+ if ("url" in item && item.url) parts.push(`url=${item.url}`);
3612
+ if ("expectedType" in item && item.expectedType) parts.push(`expected=${item.expectedType}`);
3613
+ if ("actualType" in item && item.actualType) parts.push(`actual=${item.actualType}`);
3614
+ return parts.length ? dim(` (${parts.join(", ")})`) : "";
3747
3615
  }
3748
- function registerValidate(program) {
3749
- program.command("validate").description("Validate a tutor (default), fragment library, quiz, writing or coding YAML by local path or public http(s) URL").argument("<pathOrUrl>", "path to a tutor, fragment, quiz, writing or coding YAML file, or a public http(s) URL").option("--kind <kind>", `what the file is: ${VALIDATE_KINDS.map((k) => `'${k}'`).join(", ")} ('tutor' is the default)`, "tutor").option("--json", "print the raw validation result as JSON").addHelpText("after", `
3750
- Examples:
3751
- # Validate a tutor (also strict-renders every fragment in every referenced library)
3752
- $ novedu-cli validate ./activities/tutors/my-tutor.yaml
3753
-
3754
- # Validate a fragment library on its own
3755
- $ novedu-cli validate ./activities/tutors/my-fragments.yaml --kind fragment
3756
-
3757
- # Validate a quiz, a writing activity, or a coding activity
3758
- $ novedu-cli validate ./activities/quizzes/my-quiz.yaml --kind quiz
3759
- $ novedu-cli validate ./activities/writings/my-writing.yaml --kind writing
3760
- $ novedu-cli validate ./activities/coding/my-coding.yaml --kind coding
3761
-
3762
- # Machine-readable output for CI
3763
- $ novedu-cli validate https://example.com/tutor.yaml --json`).action(async (pathOrUrl, options) => {
3764
- if (options.kind !== void 0 && !VALIDATE_KINDS.includes(options.kind)) {
3765
- console.error(`Invalid --kind "${options.kind}": expected ${VALIDATE_KINDS.map((k) => `"${k}"`).join(", ")}.`);
3766
- process.exitCode = 1;
3767
- return;
3616
+ function renderWarnings(warnings) {
3617
+ return warnings.map((w) => ` ${yellow("")} ${yellow(w.code)} ${w.message}${context(w)}`);
3618
+ }
3619
+ /**
3620
+ * Render each error as a line, with any flattened Zod schema-issue detail
3621
+ * indented beneath it — so a generic "Document does not match the expected
3622
+ * structure" is followed by the actual field paths (e.g. `Unrecognized key:
3623
+ * "nae"`), matching what the web UI shows.
3624
+ */
3625
+ function renderErrors(errors) {
3626
+ const lines = [];
3627
+ for (const e of errors) {
3628
+ lines.push(` ${red("✗")} ${red(e.code)} ${e.message}${context(e)}`);
3629
+ if (e.zodIssues) for (const issue of formatZodIssues(e.zodIssues)) lines.push(` ${dim(issue)}`);
3630
+ }
3631
+ return lines;
3632
+ }
3633
+ function formatResult(result, source) {
3634
+ const lines = [];
3635
+ if (result.ok) {
3636
+ lines.push(green(`✔ Valid tutor`) + dim(` — ${source}`));
3637
+ lines.push(` model: ${result.model}`);
3638
+ lines.push(` system prompt: ${result.prompt.length} chars`);
3639
+ lines.push(` imageInput: ${result.imageInput} anonymous: ${result.anonymous}` + (result.exampleQuestions.length ? ` exampleQuestions: ${result.exampleQuestions.length}` : ""));
3640
+ if (result.warnings.length) {
3641
+ lines.push("");
3642
+ lines.push(yellow(`${result.warnings.length} warning(s):`));
3643
+ lines.push(...renderWarnings(result.warnings));
3768
3644
  }
3769
- const outcome = await runValidate(pathOrUrl, options.kind ?? "tutor");
3770
- if (options.json) console.log(JSON.stringify(outcome.result, null, 2));
3771
- else console.log(formatOutcome(outcome, pathOrUrl));
3772
- process.exitCode = outcome.result.ok ? 0 : 1;
3773
- });
3645
+ return lines.join("\n");
3646
+ }
3647
+ lines.push(red(`✘ Invalid tutor`) + dim(` — ${source}`));
3648
+ lines.push("");
3649
+ lines.push(red(`${result.errors.length} error(s):`));
3650
+ lines.push(...renderErrors(result.errors));
3651
+ if (result.warnings.length) {
3652
+ lines.push("");
3653
+ lines.push(yellow(`${result.warnings.length} warning(s):`));
3654
+ lines.push(...renderWarnings(result.warnings));
3655
+ }
3656
+ return lines.join("\n");
3774
3657
  }
3775
- /** Pick the formatter for the outcome's kind (each result type has its own renderer). */
3776
- function formatOutcome(outcome, source) {
3777
- switch (outcome.kind) {
3778
- case "fragment": return formatFragmentResult(outcome.result, source);
3779
- case "quiz": return formatQuizResult(outcome.result, source);
3780
- case "writing": return formatWritingResult(outcome.result, source);
3781
- case "coding": return formatCodingResult(outcome.result, source);
3782
- default: return formatResult(outcome.result, source);
3658
+ /** Same renderer, for a standalone fragment-FILE check (`--kind fragment`). */
3659
+ function formatFragmentResult(result, source) {
3660
+ const lines = [];
3661
+ if (result.ok) {
3662
+ lines.push(green(`✔ Valid fragment file`) + dim(` — ${source}`));
3663
+ lines.push(` id: ${result.fragmentFileId}`);
3664
+ lines.push(` fragments: ${result.fragmentIds.length}` + (result.fragmentIds.length ? ` (${result.fragmentIds.join(", ")})` : ""));
3665
+ if (result.warnings.length) {
3666
+ lines.push("");
3667
+ lines.push(yellow(`${result.warnings.length} warning(s):`));
3668
+ lines.push(...renderWarnings(result.warnings));
3669
+ }
3670
+ return lines.join("\n");
3671
+ }
3672
+ lines.push(red(`✘ Invalid fragment file`) + dim(` — ${source}`));
3673
+ lines.push("");
3674
+ lines.push(red(`${result.errors.length} error(s):`));
3675
+ lines.push(...renderErrors(result.errors));
3676
+ if (result.warnings.length) {
3677
+ lines.push("");
3678
+ lines.push(yellow(`${result.warnings.length} warning(s):`));
3679
+ lines.push(...renderWarnings(result.warnings));
3783
3680
  }
3681
+ return lines.join("\n");
3682
+ }
3683
+ /**
3684
+ * Shared tail for the quiz/writing renderers: on failure, the error list (with any
3685
+ * flattened Zod issues); plus any warnings on either branch.
3686
+ */
3687
+ function renderFailureAndWarnings(result, label, source) {
3688
+ const lines = [red(`✘ Invalid ${label}`) + dim(` — ${source}`), ""];
3689
+ lines.push(red(`${result.errors.length} error(s):`));
3690
+ lines.push(...renderErrors(result.errors));
3691
+ if (result.warnings.length) {
3692
+ lines.push("");
3693
+ lines.push(yellow(`${result.warnings.length} warning(s):`));
3694
+ lines.push(...renderWarnings(result.warnings));
3695
+ }
3696
+ return lines.join("\n");
3697
+ }
3698
+ /** Renderer for a quiz check (`--kind quiz`). */
3699
+ function formatQuizResult(result, source) {
3700
+ if (!result.ok) return renderFailureAndWarnings(result, "quiz", source);
3701
+ const lines = [green(`✔ Valid quiz`) + dim(` — ${source}`)];
3702
+ lines.push(` id: ${result.quizId}`);
3703
+ lines.push(` model: ${result.model}`);
3704
+ lines.push(` questions: ${result.questionCount} anonymous: ${result.anonymous}`);
3705
+ if (result.warnings.length) {
3706
+ lines.push("");
3707
+ lines.push(yellow(`${result.warnings.length} warning(s):`));
3708
+ lines.push(...renderWarnings(result.warnings));
3709
+ }
3710
+ return lines.join("\n");
3711
+ }
3712
+ /** Renderer for a writing-activity check (`--kind writing`). */
3713
+ function formatWritingResult(result, source) {
3714
+ if (!result.ok) return renderFailureAndWarnings(result, "writing activity", source);
3715
+ const lines = [green(`✔ Valid writing activity`) + dim(` — ${source}`)];
3716
+ lines.push(` id: ${result.writingId}`);
3717
+ lines.push(` model: ${result.model}`);
3718
+ lines.push(` anonymous: ${result.anonymous}`);
3719
+ if (result.warnings.length) {
3720
+ lines.push("");
3721
+ lines.push(yellow(`${result.warnings.length} warning(s):`));
3722
+ lines.push(...renderWarnings(result.warnings));
3723
+ }
3724
+ return lines.join("\n");
3725
+ }
3726
+ /**
3727
+ * Renderer for a coding-activity check (`--kind coding`). Coding is ALWAYS anonymous
3728
+ * (the API path carries no per-student identity), so — unlike quiz/writing — that is
3729
+ * shown as a fixed note, not a per-file value.
3730
+ */
3731
+ function formatCodingResult(result, source) {
3732
+ if (!result.ok) return renderFailureAndWarnings(result, "coding activity", source);
3733
+ const lines = [green(`✔ Valid coding activity`) + dim(` — ${source}`)];
3734
+ lines.push(` id: ${result.codingId}`);
3735
+ lines.push(` model: ${result.model}`);
3736
+ lines.push(` anonymous: true ${dim("(always — the API path carries no identity)")}`);
3737
+ if (result.warnings.length) {
3738
+ lines.push("");
3739
+ lines.push(yellow(`${result.warnings.length} warning(s):`));
3740
+ lines.push(...renderWarnings(result.warnings));
3741
+ }
3742
+ return lines.join("\n");
3743
+ }
3744
+ /**
3745
+ * Renderer for a golden-answer eval check (`--kind eval`). An eval describes a quiz it
3746
+ * does not contain, so the summary names the resolved target and the size of the run
3747
+ * the file would produce.
3748
+ */
3749
+ function formatEvalResult(result, source) {
3750
+ if (!result.ok) return renderFailureAndWarnings(result, "eval", source);
3751
+ const lines = [green(`✔ Valid eval`) + dim(` — ${source}`)];
3752
+ lines.push(` id: ${result.evalFile.id}`);
3753
+ lines.push(` target: ${result.targetUrl}`);
3754
+ lines.push(` questions: ${result.evalFile.questions.length} cases: ${result.caseCount}`);
3755
+ lines.push(` quiz model: ${result.quizDump.llm.provider} / ${result.quizDump.llm.model}`);
3756
+ if (result.warnings.length) {
3757
+ lines.push("");
3758
+ lines.push(yellow(`${result.warnings.length} warning(s):`));
3759
+ lines.push(...renderWarnings(result.warnings));
3760
+ }
3761
+ return lines.join("\n");
3762
+ }
3763
+ /** One-line answer snippet for a mismatch line (single-line, bounded). */
3764
+ function snippet(text, max = 60) {
3765
+ const flat = text.replace(/\s+/g, " ").trim();
3766
+ return flat.length <= max ? flat : `${flat.slice(0, max - 1)}…`;
3767
+ }
3768
+ /** Thousands-separated, en-US so a report reads the same on every machine. */
3769
+ function formatTokenCount(count) {
3770
+ return count.toLocaleString("en-US");
3771
+ }
3772
+ /**
3773
+ * `tokens: 15,420 in (12,300 cached) / 2,810 out` — or `undefined` when nothing was
3774
+ * reported at all (no server usage, or none of the calls succeeded), in which case the
3775
+ * reports print no token line rather than a misleading row of zeros. The cached
3776
+ * parenthetical is dropped when the provider reported no cache reads. Counts SUCCESSFUL
3777
+ * grading calls only, so it is a lower bound (docs/cli-eval.md).
3778
+ */
3779
+ function formatUsageLine(usage) {
3780
+ if (usage.input === 0 && usage.cachedInput === 0 && usage.output === 0) return void 0;
3781
+ const cached = usage.cachedInput ? ` (${formatTokenCount(usage.cachedInput)} cached)` : "";
3782
+ return `tokens: ${formatTokenCount(usage.input)} in${cached} / ${formatTokenCount(usage.output)} out`;
3783
+ }
3784
+ /** `question#index expected … got … "answer…"` — one line per non-passing case. */
3785
+ function mismatchLines(result) {
3786
+ return result.mismatches.map((c) => {
3787
+ const head = `${c.questionId}#${c.answerIndex}`;
3788
+ const expected = c.expected.join("|");
3789
+ if (c.status === "errored") {
3790
+ const first = c.repeats.find((row) => row.error !== void 0)?.error;
3791
+ const message = typeof first === "object" && first !== null && "message" in first ? String(first.message) : "no verdict";
3792
+ return ` ${red("✗")} ${head} expected ${expected} got ${red("error")} ${dim(message)}`;
3793
+ }
3794
+ return ` ${red("✗")} ${head} expected ${expected} got ${red(c.verdict ?? "?")}` + dim(` "${snippet(c.answer)}"`);
3795
+ });
3796
+ }
3797
+ /**
3798
+ * The human report for ONE eval run: header (id, target, the EFFECTIVE llm — rendered
3799
+ * as `quiz-llm → override-llm` when `--llm-provider`/`--llm-model` was used, so a
3800
+ * comparison report can never be mistaken for a baseline one), one line per
3801
+ * mismatch/error, totals, the confusion matrix and the false-correct rate.
3802
+ */
3803
+ function formatEvalReport(result, source) {
3804
+ const { totals } = result;
3805
+ const lines = [
3806
+ (totals.failed === 0 && totals.errored === 0 && totals.skipped === 0 ? green("✔ Eval passed") : red("✘ Eval failed")) + dim(` — ${source}`),
3807
+ ` id: ${result.id}`,
3808
+ ` target: ${result.target}`
3809
+ ];
3810
+ 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}`;
3811
+ lines.push(` llm: ${llm}`);
3812
+ lines.push(` cases: ${totals.cases} × ${totals.repeats} repeat(s) = ${totals.calls} grading call(s)`);
3813
+ if (result.aborted) {
3814
+ lines.push("");
3815
+ lines.push(red(`Run aborted: ${result.aborted.message}`));
3816
+ }
3817
+ if (result.mismatches.length) {
3818
+ lines.push("");
3819
+ lines.push(red(`${result.mismatches.length} mismatch(es):`));
3820
+ lines.push(...mismatchLines(result));
3821
+ }
3822
+ lines.push("");
3823
+ lines.push(` passed: ${totals.passed} failed: ${totals.failed} errored: ${totals.errored}` + (totals.skipped ? red(` skipped: ${totals.skipped} (run aborted)`) : "") + (totals.unstable ? dim(` unstable: ${totals.unstable}`) : ""));
3824
+ const tokens = formatUsageLine(totals.usage);
3825
+ if (tokens) lines.push(dim(` ${tokens}`));
3826
+ if (result.confusion.length) {
3827
+ lines.push("");
3828
+ lines.push(" confusion (expected → got):");
3829
+ for (const row of result.confusion) lines.push(` ${row.expected} → ${row.got}: ${row.count}`);
3830
+ }
3831
+ const { count, denominator, rate } = result.falseCorrect;
3832
+ lines.push("");
3833
+ lines.push(` false-correct: ${count}/${denominator}` + (denominator ? ` (${(rate * 100).toFixed(1)}%)` : ""));
3834
+ return lines.join("\n");
3835
+ }
3836
+ /**
3837
+ * The human report for a MULTI-file run: a per-file summary table, grand totals, then
3838
+ * the per-file detail sections only for files that had mismatches. The confusion matrix
3839
+ * and false-correct rate stay per file — mixing verdicts across unrelated quizzes is
3840
+ * not meaningful. A single-file run keeps the plain {@link formatEvalReport}.
3841
+ */
3842
+ function formatEvalBatchReport(batch) {
3843
+ const lines = [];
3844
+ lines.push(`Evaluated ${batch.totals.files} file(s):`);
3845
+ for (const file of batch.files) {
3846
+ const name = shortSource$1(file.source);
3847
+ if (file.status === "invalid" || !file.result) {
3848
+ lines.push(` ${red("✘")} ${name}: ${red("invalid")} (${file.errors?.length ?? 0} error(s))`);
3849
+ continue;
3850
+ }
3851
+ const t = file.result.totals;
3852
+ const mark = t.failed === 0 && t.errored === 0 && t.skipped === 0 ? green("✔") : red("✗");
3853
+ lines.push(` ${mark} ${name}: ${t.cases} case(s), ${t.passed} passed, ${t.failed} failed, ${t.errored} errored` + (t.skipped ? red(`, ${t.skipped} skipped`) : "") + (t.unstable ? dim(`, ${t.unstable} unstable`) : ""));
3854
+ }
3855
+ const g = batch.totals;
3856
+ lines.push("");
3857
+ lines.push(` TOTAL: ${g.cases} case(s), ${g.passed} passed, ${g.failed} failed, ${g.errored} errored` + (g.skipped ? red(`, ${g.skipped} skipped`) : "") + (g.invalid ? red(`, ${g.invalid} invalid file(s)`) : ""));
3858
+ const tokens = formatUsageLine(g.usage);
3859
+ if (tokens) lines.push(dim(` ${tokens}`));
3860
+ for (const file of batch.files) {
3861
+ if (file.status === "invalid") {
3862
+ lines.push("");
3863
+ lines.push(red(`✘ ${shortSource$1(file.source)} — not a usable eval:`));
3864
+ lines.push(...renderErrors(file.errors ?? []));
3865
+ continue;
3866
+ }
3867
+ if (!file.result || file.result.mismatches.length === 0) continue;
3868
+ lines.push("");
3869
+ lines.push(formatEvalReport(file.result, shortSource$1(file.source)));
3870
+ }
3871
+ return lines.join("\n");
3872
+ }
3873
+ /** The last path segment of a source URL — enough to tell files apart in a table. */
3874
+ function shortSource$1(source) {
3875
+ try {
3876
+ const { pathname } = new URL(source);
3877
+ return decodeURIComponent(pathname.split("/").pop() || source);
3878
+ } catch {
3879
+ return source;
3880
+ }
3881
+ }
3882
+ /**
3883
+ * Renderer for a prompt dump (`prompts`). Kind-agnostic by construction: the envelope
3884
+ * (kind / id / provider+model) plus one line per prompt with its character count — the
3885
+ * sections come from `promptSections`, so a new kind needs no change here. `--json`
3886
+ * carries the prompt text itself.
3887
+ */
3888
+ function formatPromptDump(dump, sections, source) {
3889
+ const lines = [green(`✔ Prompts — ${dump.kind}`) + dim(` — ${source}`)];
3890
+ lines.push(` id: ${dump.id}`);
3891
+ lines.push(` provider: ${dump.llm.provider} model: ${dump.llm.model}`);
3892
+ lines.push(` prompts: ${sections.length}`);
3893
+ for (const section of sections) lines.push(` ${section.name}: ${section.text.length} chars`);
3894
+ lines.push("");
3895
+ lines.push(dim(" Run again with --json for the full prompt text."));
3896
+ return lines.join("\n");
3897
+ }
3898
+ //#endregion
3899
+ //#region src/report-md.ts
3900
+ /** `2026-08-08 14:32 UTC` — UTC always, so two reports are comparable across machines. */
3901
+ function timestamp(at) {
3902
+ return `${at.toISOString().slice(0, 16).replace("T", " ")} UTC`;
3903
+ }
3904
+ /** Thousands-separated, en-US so the report reads the same on every machine. */
3905
+ function count(value) {
3906
+ return value.toLocaleString("en-US");
3907
+ }
3908
+ /**
3909
+ * A teacher-authored string made safe for a TABLE CELL: newlines collapse to spaces
3910
+ * (a raw newline would end the row) and pipes are escaped (they would split it).
3911
+ */
3912
+ function cell(text) {
3913
+ return text.replace(/\s+/g, " ").replace(/\|/g, "\\|").trim();
3914
+ }
3915
+ /** The same neutralization for a LIST ITEM, where only the newline is structural. */
3916
+ function inline(text) {
3917
+ return text.replace(/\s+/g, " ").trim();
3918
+ }
3919
+ /**
3920
+ * A verbatim blockquote: every line prefixed, so the text keeps its own line breaks and
3921
+ * can never escape into the surrounding document structure.
3922
+ */
3923
+ function quote(text) {
3924
+ return text.replace(/\s+$/, "").split(/\r?\n/).map((line) => line ? `> ${line}` : ">").join("\n");
3925
+ }
3926
+ /** `SCCH / gemma-4`, or `SCCH / gemma-4 → Azure Foundry / gpt-5-mini (override)`. */
3927
+ function llmText(llm) {
3928
+ const effective = `${llm.provider} / ${llm.model}`;
3929
+ return llm.overrides ? `${llm.overrides.provider} / ${llm.overrides.model} → ${effective} (override)` : effective;
3930
+ }
3931
+ /** `15,420 / 12,300 / 2,810`, or an em dash when nothing was reported. */
3932
+ function usageCell(usage) {
3933
+ if (usage.input === 0 && usage.cachedInput === 0 && usage.output === 0) return "—";
3934
+ return `${count(usage.input)} / ${count(usage.cachedInput)} / ${count(usage.output)}`;
3935
+ }
3936
+ /** The last path segment of a source URL — how a file is named throughout the report. */
3937
+ function shortSource(source) {
3938
+ try {
3939
+ const { pathname } = new URL(source);
3940
+ return decodeURIComponent(pathname.split("/").pop() || source);
3941
+ } catch {
3942
+ return source;
3943
+ }
3944
+ }
3945
+ /** `1/12 (8.3%)` — the false-correct rate, or `0/0` when nothing could be false-correct. */
3946
+ function falseCorrectCell(result) {
3947
+ const { count: hits, denominator, rate } = result.falseCorrect;
3948
+ return denominator === 0 ? `${hits}/0` : `${hits}/${denominator} (${(rate * 100).toFixed(1)}%)`;
3949
+ }
3950
+ const OVERVIEW_HEADER = [
3951
+ "File",
3952
+ "Eval",
3953
+ "Cases",
3954
+ "Passed",
3955
+ "Failed",
3956
+ "Errored",
3957
+ "Skipped",
3958
+ "Unstable",
3959
+ "False-correct",
3960
+ "Tokens (in / cached / out)"
3961
+ ];
3962
+ /** `| a | b |` for one row of the overview table. */
3963
+ function row(cells) {
3964
+ return `| ${cells.join(" | ")} |`;
3965
+ }
3966
+ /** The overview table: one row per file, plus a grand TOTAL row for a real batch. */
3967
+ function overview(batch) {
3968
+ const lines = [row(OVERVIEW_HEADER), row([
3969
+ "---",
3970
+ "---",
3971
+ "---:",
3972
+ "---:",
3973
+ "---:",
3974
+ "---:",
3975
+ "---:",
3976
+ "---:",
3977
+ "---:",
3978
+ "---:"
3979
+ ])];
3980
+ for (const file of batch.files) {
3981
+ const name = cell(shortSource(file.source));
3982
+ if (!file.result) {
3983
+ lines.push(row([
3984
+ name,
3985
+ "**invalid**",
3986
+ "—",
3987
+ "—",
3988
+ "—",
3989
+ "—",
3990
+ "—",
3991
+ "—",
3992
+ "—",
3993
+ "—"
3994
+ ]));
3995
+ continue;
3996
+ }
3997
+ const t = file.result.totals;
3998
+ lines.push(row([
3999
+ `${file.passed ? "✅" : "❌"} ${name}`,
4000
+ `\`${cell(file.result.id)}\``,
4001
+ count(t.cases),
4002
+ count(t.passed),
4003
+ count(t.failed),
4004
+ count(t.errored),
4005
+ count(t.skipped),
4006
+ count(t.unstable),
4007
+ falseCorrectCell(file.result),
4008
+ usageCell(t.usage)
4009
+ ]));
4010
+ }
4011
+ if (batch.files.length > 1) {
4012
+ const g = batch.totals;
4013
+ lines.push(row([
4014
+ "**TOTAL**",
4015
+ g.invalid ? `${count(g.invalid)} invalid` : "",
4016
+ `**${count(g.cases)}**`,
4017
+ `**${count(g.passed)}**`,
4018
+ `**${count(g.failed)}**`,
4019
+ `**${count(g.errored)}**`,
4020
+ `**${count(g.skipped)}**`,
4021
+ `**${count(g.unstable)}**`,
4022
+ "",
4023
+ `**${usageCell(g.usage)}**`
4024
+ ]));
4025
+ }
4026
+ return lines;
4027
+ }
4028
+ /** The one-line error message an errored repeat row carries. */
4029
+ function errorMessage(error) {
4030
+ if (typeof error === "string") return error;
4031
+ if (typeof error === "object" && error !== null && "message" in error) return String(error.message);
4032
+ return JSON.stringify(error ?? null);
4033
+ }
4034
+ /** `expected correct, got incorrect` — the heading's verdict half. */
4035
+ function verdictSummary(evalCase) {
4036
+ return `expected ${evalCase.expected.join(" | ")}, got ${evalCase.status === "errored" ? "error" : evalCase.verdict ?? "no verdict"}`;
4037
+ }
4038
+ /** Whether a case belongs in the details section at all (§ details only for problems). */
4039
+ function needsDetail(evalCase) {
4040
+ return evalCase.status === "failed" || evalCase.status === "errored" || evalCase.unstable;
4041
+ }
4042
+ /**
4043
+ * One case's section: the question it belongs to, the golden answer, and what the
4044
+ * grader said — plus every repeat when they disagreed (the `--repeats` signal is
4045
+ * exactly what a teacher wants to read here, not a majority hidden behind one line).
4046
+ */
4047
+ function caseSection(evalCase, questionText) {
4048
+ const lines = [];
4049
+ const unstable = evalCase.unstable ? " *(unstable)*" : "";
4050
+ lines.push(`### \`${evalCase.questionId}\` #${evalCase.answerIndex} — ${verdictSummary(evalCase)}${unstable}`);
4051
+ lines.push("");
4052
+ if (questionText) {
4053
+ lines.push("**Question**");
4054
+ lines.push("");
4055
+ lines.push(quote(questionText));
4056
+ lines.push("");
4057
+ }
4058
+ lines.push("**Golden answer**");
4059
+ lines.push("");
4060
+ lines.push(quote(evalCase.answer));
4061
+ lines.push("");
4062
+ const graded = evalCase.repeats.filter((r) => r.got !== void 0);
4063
+ const disagreed = new Set(graded.map((r) => r.got)).size > 1;
4064
+ if (evalCase.repeats.length > 1 && (disagreed || graded.length !== evalCase.repeats.length)) {
4065
+ lines.push("**Repeats**");
4066
+ lines.push("");
4067
+ for (const repeat of evalCase.repeats) {
4068
+ const verdict = repeat.got ? `\`${repeat.got}\`` : "**error**";
4069
+ const detail = repeat.got ? inline(repeat.feedback ?? "") : inline(errorMessage(repeat.error));
4070
+ lines.push(`- #${repeat.repeatIndex + 1} — ${verdict}${detail ? ` — ${detail}` : ""}`);
4071
+ }
4072
+ lines.push("");
4073
+ return lines;
4074
+ }
4075
+ const feedback = graded.find((r) => r.feedback)?.feedback;
4076
+ if (feedback) {
4077
+ lines.push("**Grader feedback**");
4078
+ lines.push("");
4079
+ lines.push(quote(feedback));
4080
+ lines.push("");
4081
+ }
4082
+ const failure = evalCase.repeats.find((r) => r.error !== void 0);
4083
+ if (failure) {
4084
+ lines.push("**Error**");
4085
+ lines.push("");
4086
+ lines.push(quote(errorMessage(failure.error)));
4087
+ lines.push("");
4088
+ }
4089
+ return lines;
4090
+ }
4091
+ /** One file's details section, or `[]` when the file has nothing to report. */
4092
+ function fileDetails(file) {
4093
+ const name = shortSource(file.source);
4094
+ if (!file.result) {
4095
+ const errors = file.errors ?? [];
4096
+ return [
4097
+ `## ${cell(name)} — invalid`,
4098
+ "",
4099
+ "This file was not graded; fix the problems below and run it again.",
4100
+ "",
4101
+ ...errors.map((issue) => `- \`${cell(issue.code)}\` — ${inline(issue.message)}`),
4102
+ ""
4103
+ ];
4104
+ }
4105
+ const result = file.result;
4106
+ const detailed = result.cases.filter(needsDetail);
4107
+ const skipped = result.totals.skipped;
4108
+ if (detailed.length === 0 && skipped === 0 && !result.aborted) return [];
4109
+ const questionText = new Map(result.questions.map((question) => [question.id, question.text]));
4110
+ const lines = [`## ${cell(name)} — \`${cell(result.id)}\``, ""];
4111
+ if (result.aborted) {
4112
+ lines.push("> [!WARNING]");
4113
+ lines.push(`> The run was aborted: ${inline(result.aborted.message)}`);
4114
+ lines.push("");
4115
+ }
4116
+ for (const evalCase of detailed) lines.push(...caseSection(evalCase, questionText.get(evalCase.questionId)));
4117
+ if (skipped > 0) {
4118
+ const reason = result.aborted ? ` (${inline(result.aborted.message)})` : "";
4119
+ lines.push(`**${count(skipped)} case(s) were never attempted**${reason} — the run is incomplete, so it cannot pass.`);
4120
+ lines.push("");
4121
+ }
4122
+ return lines;
4123
+ }
4124
+ /**
4125
+ * Render the whole run as Markdown: verdict headline, the run's facts, the overview
4126
+ * table, then the details of everything that needs a teacher's attention.
4127
+ */
4128
+ function renderEvalMarkdownReport(batch, meta) {
4129
+ const lines = [];
4130
+ lines.push(`# Eval report — ${batch.passed ? "✅ passed" : "❌ failed"}`);
4131
+ lines.push("");
4132
+ lines.push(`- **Generated** ${timestamp(meta.generatedAt)} · novedu-cli ${meta.cliVersion}`);
4133
+ const llms = [...new Set(batch.files.filter((f) => f.result).map((f) => llmText(f.result.llm)))];
4134
+ for (const llm of llms) lines.push(`- **LLM** ${llm}`);
4135
+ lines.push(`- **Run** ${count(batch.totals.files)} file(s), ${count(batch.totals.cases)} case(s) × ${count(meta.repeats)} repeat(s), concurrency ${count(meta.concurrency)}`);
4136
+ const tokens = batch.totals.usage;
4137
+ if (tokens.input || tokens.cachedInput || tokens.output) lines.push(`- **Tokens** ${count(tokens.input)} in (${count(tokens.cachedInput)} cached) / ${count(tokens.output)} out — successful grading calls only, so a lower bound`);
4138
+ lines.push("");
4139
+ if (batch.files.filter((file) => file.result?.aborted).length > 0) {
4140
+ lines.push("> [!WARNING]");
4141
+ lines.push(`> The run was ABORTED — ${count(batch.totals.skipped)} case(s) were never graded, so this report is incomplete.`);
4142
+ lines.push("");
4143
+ }
4144
+ lines.push("## Overview");
4145
+ lines.push("");
4146
+ lines.push(...overview(batch));
4147
+ lines.push("");
4148
+ const details = batch.files.flatMap((file) => fileDetails(file));
4149
+ if (details.length === 0) {
4150
+ lines.push("_Nothing else to report — every case matched its expected verdict. The `--json` report carries every case, including the passing ones._");
4151
+ lines.push("");
4152
+ } else {
4153
+ lines.push("_Below: only the mismatched, errored and unstable cases. Passing cases live in the `--json` report._");
4154
+ lines.push("");
4155
+ lines.push(...details);
4156
+ }
4157
+ return `${lines.join("\n").replace(/\n{3,}/g, "\n\n").trimEnd()}\n`;
4158
+ }
4159
+ //#endregion
4160
+ //#region src/version.ts
4161
+ let cached;
4162
+ /** The `version` field of the CLI's package.json; `"unknown"` if it cannot be read. */
4163
+ function cliVersion() {
4164
+ if (cached !== void 0) return cached;
4165
+ try {
4166
+ const { version } = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
4167
+ cached = version ?? "unknown";
4168
+ } catch {
4169
+ cached = "unknown";
4170
+ }
4171
+ return cached;
4172
+ }
4173
+ //#endregion
4174
+ //#region ../lib/coding-schema.ts
4175
+ const CodingYamlSchema = z.strictObject({
4176
+ id: z.string().min(1).meta({ description: "Short machine-readable activity id, e.g. beginner-typescript." }),
4177
+ name: z.string().optional().meta({ description: "Optional human-readable label (not shown to the student)." }),
4178
+ title: z.string().optional().meta({ description: "Optional label shown to the student on the /<code> connection page." }),
4179
+ llm: z.strictObject({
4180
+ 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." }),
4181
+ provider: providerSchema
4182
+ }).meta({
4183
+ id: "llm",
4184
+ description: "The pinned model and provider that answer coding requests."
4185
+ }),
4186
+ fragment_files: z.array(FragmentFileRefSchema).default([]).meta({ description: "Optional fragment libraries this activity pulls shared prompt fragments from." }),
4187
+ text_files: z.array(TextFileRefSchema).default([]).meta({ description: "Optional plain-text files (markdown / source, e.g. a sample solution) embedded verbatim into instructions via {{file \"alias\"}} markers." }),
4188
+ instructions: z.string().min(1).meta({ description: "The assistant's system prompt. SERVER-ONLY: never sent to the browser or the coding agent, and appended AFTER the coding tool's own prompt (so the teacher has the final word). Constrain the assistant to what your class has learned. When any fragment_files or text_files are declared it is a Handlebars template: place fragments inline with {{fragment \"alias.id\" …}} and embed text files with {{file \"alias\"}} (optionally {{file \"alias\" from=10 to=40}} for a line range; escape a literal {{ as \\{{)." })
4189
+ });
4190
+ //#endregion
4191
+ //#region ../lib/coding-validate.ts
4192
+ /**
4193
+ * Extract metadata from an already-schema-validated coding value. Split from
4194
+ * `checkCodingValue` so `loadAndCheckCoding` can reuse the single `validate` it already
4195
+ * ran (no second parse of the same document against the same schema).
4196
+ */
4197
+ function checkCodingParsed(coding) {
4198
+ return {
4199
+ ok: true,
4200
+ codingId: coding.id,
4201
+ model: coding.llm.model,
4202
+ provider: coding.llm.provider,
4203
+ title: coding.title ?? null,
4204
+ warnings: []
4205
+ };
4206
+ }
4207
+ /**
4208
+ * Validate a coding FILE: scheme-gate + fetch + parse (shared `loadYaml`), then the
4209
+ * pure `checkCodingValue`. The web app passes the default http(s)-only schemes; the
4210
+ * CLI adds `file:` so a local coding YAML on disk validates too.
4211
+ */
4212
+ async function loadAndCheckCoding(url, fetchImpl, opts = {}) {
4213
+ const yaml = await loadYaml(url, fetchImpl, opts);
4214
+ if (!yaml.ok) return {
4215
+ ok: false,
4216
+ errors: [yaml.error],
4217
+ warnings: []
4218
+ };
4219
+ const valid = validate(yaml.value, CodingYamlSchema, "CODING_SCHEMA_ERROR", url);
4220
+ if (!valid.ok) return {
4221
+ ok: false,
4222
+ errors: [valid.error],
4223
+ warnings: []
4224
+ };
4225
+ const checked = checkCodingParsed(valid.data);
4226
+ if (!checked.ok) return checked;
4227
+ const assembled = await assembleFragmentPrompt({
4228
+ fragment_files: valid.data.fragment_files,
4229
+ text_files: valid.data.text_files
4230
+ }, url, fetchImpl, {
4231
+ allowedSchemes: opts.allowedSchemes,
4232
+ validateLibraries: opts.validateLibraries ?? true
4233
+ }, valid.data.instructions);
4234
+ const warnings = [...checked.warnings, ...assembled.warnings];
4235
+ if (!assembled.ok) return {
4236
+ ok: false,
4237
+ errors: assembled.errors,
4238
+ warnings
4239
+ };
4240
+ return {
4241
+ ...checked,
4242
+ warnings
4243
+ };
4244
+ }
4245
+ //#endregion
4246
+ //#region ../lib/writing-schema.ts
4247
+ const WritingYamlSchema = z.strictObject({
4248
+ id: z.string().min(1).meta({ description: "Short machine-readable activity id, e.g. human-animal-short-story." }),
4249
+ name: z.string().optional().meta({ description: "Optional human-readable title (used as a label)." }),
4250
+ title: z.string().optional().meta({ description: "Optional greeting shown to students on the welcome screen instead of the default message." }),
4251
+ description: z.string().optional().meta({ description: "Optional description shown to students below the welcome greeting (Markdown)." }),
4252
+ anonymous: z.boolean().optional().meta({
4253
+ default: false,
4254
+ description: "Writing DIVERGES: it defaults to false (attributed), because review and the Save feature need to know whose text it is. Set to true for ephemeral, unattributed writing — which also disables saving."
4255
+ }),
4256
+ llm: z.strictObject({
4257
+ model: z.string().min(1).meta({ description: "The model that drives the feedback chat." }),
4258
+ provider: providerSchema
4259
+ }).meta({
4260
+ id: "llm",
4261
+ description: "The model and provider that back the writing coach."
4262
+ }),
4263
+ fragment_files: z.array(FragmentFileRefSchema).default([]).meta({ description: "Optional fragment libraries this activity pulls shared prompt fragments from." }),
4264
+ text_files: z.array(TextFileRefSchema).default([]).meta({ description: "Optional plain-text files (markdown / source) embedded verbatim into instructions via {{file \"alias\"}} markers." }),
4265
+ instructions: z.string().min(1).meta({ description: "The writing coach's system prompt. SERVER-ONLY: never sent to the browser, so it may describe the assessment criteria and coaching strategy. When any fragment_files or text_files are declared it is a Handlebars template: place fragments inline with {{fragment \"alias.id\" …}} and embed text files with {{file \"alias\"}} (optionally {{file \"alias\" from=10 to=40}} for a line range; escape a literal {{ as \\{{)." }),
4266
+ placeholder: z.string().optional().meta({ description: "Optional starter text prefilled into the editor. Empty for a blank page." })
4267
+ });
4268
+ //#endregion
4269
+ //#region ../lib/writing-validate.ts
4270
+ /** Writing DIVERGES from tutor/quiz: it defaults to attributed (`anonymous: false`). */
4271
+ const DEFAULT_ANONYMOUS = false;
4272
+ /**
4273
+ * Extract metadata from an already-schema-validated writing value. Split from
4274
+ * `checkWritingValue` so `loadAndCheckWriting` can reuse the single `validate` it already
4275
+ * ran (no second parse of the same document against the same schema).
4276
+ */
4277
+ function checkWritingParsed(writing) {
4278
+ return {
4279
+ ok: true,
4280
+ writingId: writing.id,
4281
+ model: writing.llm.model,
4282
+ provider: writing.llm.provider,
4283
+ anonymous: writing.anonymous ?? DEFAULT_ANONYMOUS,
4284
+ title: writing.title ?? null,
4285
+ warnings: []
4286
+ };
4287
+ }
4288
+ /**
4289
+ * Validate a writing FILE: scheme-gate + fetch + parse (shared `loadYaml`), then the
4290
+ * pure `checkWritingValue`. The web app passes the default http(s)-only schemes; the
4291
+ * CLI adds `file:` so a local writing YAML on disk validates too.
4292
+ */
4293
+ async function loadAndCheckWriting(url, fetchImpl, opts = {}) {
4294
+ const yaml = await loadYaml(url, fetchImpl, opts);
4295
+ if (!yaml.ok) return {
4296
+ ok: false,
4297
+ errors: [yaml.error],
4298
+ warnings: []
4299
+ };
4300
+ const valid = validate(yaml.value, WritingYamlSchema, "WRITING_SCHEMA_ERROR", url);
4301
+ if (!valid.ok) return {
4302
+ ok: false,
4303
+ errors: [valid.error],
4304
+ warnings: []
4305
+ };
4306
+ const checked = checkWritingParsed(valid.data);
4307
+ if (!checked.ok) return checked;
4308
+ const assembled = await assembleFragmentPrompt({
4309
+ fragment_files: valid.data.fragment_files,
4310
+ text_files: valid.data.text_files
4311
+ }, url, fetchImpl, {
4312
+ allowedSchemes: opts.allowedSchemes,
4313
+ validateLibraries: opts.validateLibraries ?? true
4314
+ }, valid.data.instructions);
4315
+ const warnings = [...checked.warnings, ...assembled.warnings];
4316
+ if (!assembled.ok) return {
4317
+ ok: false,
4318
+ errors: assembled.errors,
4319
+ warnings
4320
+ };
4321
+ return {
4322
+ ...checked,
4323
+ warnings
4324
+ };
4325
+ }
4326
+ //#endregion
4327
+ //#region src/commands/validate.ts
4328
+ /** Every kind the `--kind` flag accepts (used for the option help + guard). */
4329
+ const VALIDATE_KINDS = [
4330
+ "tutor",
4331
+ "fragment",
4332
+ "quiz",
4333
+ "writing",
4334
+ "coding",
4335
+ "eval"
4336
+ ];
4337
+ /**
4338
+ * Turn the CLI argument into a URL the tutor core understands: an http(s) URL is
4339
+ * used as-is; anything else is treated as a filesystem path and converted to an
4340
+ * absolute `file://` URL.
4341
+ */
4342
+ function toUrl(pathOrUrl) {
4343
+ if (/^https?:\/\//i.test(pathOrUrl)) return pathOrUrl;
4344
+ return pathToFileURL(resolve(pathOrUrl)).href;
4345
+ }
4346
+ /**
4347
+ * The validate command's pure core: run the requested pipeline over a local file or
4348
+ * public URL. `file:` is allowed in addition to http(s) so local YAML can be
4349
+ * validated (the web app deliberately stays http(s)-only). As an authoring tool, the
4350
+ * tutor path runs the THOROUGH check (`validateLibraries`), so every fragment in every
4351
+ * referenced library is rendered — not just the ones the tutor uses.
4352
+ */
4353
+ function runValidate(pathOrUrl, kind) {
4354
+ const url = toUrl(pathOrUrl);
4355
+ const allowedSchemes = [
4356
+ "http:",
4357
+ "https:",
4358
+ "file:"
4359
+ ];
4360
+ switch (kind) {
4361
+ case "fragment": return loadAndCheckFragmentFile(url, cliFetcher, { allowedSchemes }).then((result) => ({
4362
+ kind,
4363
+ result
4364
+ }));
4365
+ case "quiz": return loadAndCheckQuiz(url, cliFetcher, { allowedSchemes }).then((result) => ({
4366
+ kind,
4367
+ result
4368
+ }));
4369
+ case "writing": return loadAndCheckWriting(url, cliFetcher, { allowedSchemes }).then((result) => ({
4370
+ kind,
4371
+ result
4372
+ }));
4373
+ case "coding": return loadAndCheckCoding(url, cliFetcher, { allowedSchemes }).then((result) => ({
4374
+ kind,
4375
+ result
4376
+ }));
4377
+ case "eval": return loadAndCheckEval(url, cliFetcher, {
4378
+ allowedSchemes,
4379
+ validateLibraries: true,
4380
+ strictTarget: true
4381
+ }).then((result) => ({
4382
+ kind,
4383
+ result
4384
+ }));
4385
+ default: return loadAndBuildTutorPrompt(url, cliFetcher, {
4386
+ allowedSchemes,
4387
+ validateLibraries: true
4388
+ }).then((result) => ({
4389
+ kind,
4390
+ result
4391
+ }));
4392
+ }
4393
+ }
4394
+ function registerValidate(program) {
4395
+ program.command("validate").description("Validate a tutor (default), fragment library, quiz, writing, coding or eval YAML by local path or public http(s) URL").argument("<pathOrUrl>", "path to a tutor, fragment, quiz, writing, coding or eval YAML file, or a public http(s) URL").option("--kind <kind>", `what the file is: ${VALIDATE_KINDS.map((k) => `'${k}'`).join(", ")} ('tutor' is the default)`, "tutor").option("--json", "print the raw validation result as JSON").addHelpText("after", `
4396
+ Examples:
4397
+ # Validate a tutor (also strict-renders every fragment in every referenced library)
4398
+ $ novedu-cli validate ./activities/tutors/my-tutor.yaml
4399
+
4400
+ # Validate a fragment library on its own
4401
+ $ novedu-cli validate ./activities/tutors/my-fragments.yaml --kind fragment
4402
+
4403
+ # Validate a quiz, a writing activity, or a coding activity
4404
+ $ novedu-cli validate ./activities/quizzes/my-quiz.yaml --kind quiz
4405
+ $ novedu-cli validate ./activities/writings/my-writing.yaml --kind writing
4406
+ $ novedu-cli validate ./activities/coding/my-coding.yaml --kind coding
4407
+
4408
+ # Validate a golden-answer eval (also strict-checks the quiz it targets)
4409
+ $ novedu-cli validate ./my-quiz.eval.yaml --kind eval
4410
+
4411
+ # Machine-readable output for CI
4412
+ $ novedu-cli validate https://example.com/tutor.yaml --json`).action(async (pathOrUrl, options) => {
4413
+ if (options.kind !== void 0 && !VALIDATE_KINDS.includes(options.kind)) {
4414
+ console.error(`Invalid --kind "${options.kind}": expected ${VALIDATE_KINDS.map((k) => `"${k}"`).join(", ")}.`);
4415
+ process.exitCode = 1;
4416
+ return;
4417
+ }
4418
+ const outcome = await runValidate(pathOrUrl, options.kind ?? "tutor");
4419
+ if (options.json) console.log(JSON.stringify(outcome.result, null, 2));
4420
+ else console.log(formatOutcome(outcome, pathOrUrl));
4421
+ process.exitCode = outcome.result.ok ? 0 : 1;
4422
+ });
4423
+ }
4424
+ /** Pick the formatter for the outcome's kind (each result type has its own renderer). */
4425
+ function formatOutcome(outcome, source) {
4426
+ switch (outcome.kind) {
4427
+ case "fragment": return formatFragmentResult(outcome.result, source);
4428
+ case "quiz": return formatQuizResult(outcome.result, source);
4429
+ case "writing": return formatWritingResult(outcome.result, source);
4430
+ case "coding": return formatCodingResult(outcome.result, source);
4431
+ case "eval": return formatEvalResult(outcome.result, source);
4432
+ default: return formatResult(outcome.result, source);
4433
+ }
4434
+ }
4435
+ //#endregion
4436
+ //#region src/commands/eval.ts
4437
+ const CONCURRENCY_DEFAULT = 4;
4438
+ /** Shell metacharacters that make a plain argument a PATTERN rather than a path. */
4439
+ const GLOB_MAGIC = /[*?[\]{}]/;
4440
+ /**
4441
+ * Turn the positional arguments into the list of sources to evaluate. A `file:` /
4442
+ * `http(s):` URL and a plain path pass through untouched (so a shell-expanded glob —
4443
+ * which arrives as plain paths — behaves identically); an argument carrying glob magic
4444
+ * is expanded relative to the cwd and sorted, which is what makes `"./**\/*.eval.yaml"`
4445
+ * and PowerShell/cmd work. A pattern that matches NOTHING is a hard failure: it is
4446
+ * almost certainly a typo, and silently evaluating zero files would exit 0.
4447
+ */
4448
+ function expandSources(args) {
4449
+ const expanded = [];
4450
+ for (const arg of args) {
4451
+ if (/^(?:https?|file):/i.test(arg) || !GLOB_MAGIC.test(arg)) {
4452
+ expanded.push(arg);
4453
+ continue;
4454
+ }
4455
+ let matches;
4456
+ try {
4457
+ matches = [...globSync(arg)];
4458
+ } catch (error) {
4459
+ return {
4460
+ ok: false,
4461
+ message: `Could not expand the pattern "${arg}": ${error instanceof Error ? error.message : error}`
4462
+ };
4463
+ }
4464
+ if (matches.length === 0) return {
4465
+ ok: false,
4466
+ message: `The pattern "${arg}" matched no files.`
4467
+ };
4468
+ expanded.push(...matches.sort((a, b) => a.localeCompare(b)));
4469
+ }
4470
+ const sources = [];
4471
+ const duplicates = [];
4472
+ const seen = /* @__PURE__ */ new Set();
4473
+ for (const entry of expanded) {
4474
+ const url = toUrl(entry);
4475
+ if (seen.has(url)) {
4476
+ duplicates.push(url);
4477
+ continue;
4478
+ }
4479
+ seen.add(url);
4480
+ sources.push(url);
4481
+ }
4482
+ return {
4483
+ ok: true,
4484
+ sources,
4485
+ duplicates
4486
+ };
4487
+ }
4488
+ /** The `--llm-provider`/`--llm-model` pair: strictly both-or-nothing, provider checked. */
4489
+ function parseOverride(options) {
4490
+ const { llmProvider, llmModel } = options;
4491
+ if (llmProvider === void 0 && llmModel === void 0) return { ok: true };
4492
+ if (llmProvider === void 0 || llmModel === void 0) return {
4493
+ ok: false,
4494
+ message: "Pass --llm-provider and --llm-model together, or neither."
4495
+ };
4496
+ if (!LLM_PROVIDERS.includes(llmProvider)) return {
4497
+ ok: false,
4498
+ message: `Unknown --llm-provider "${llmProvider}": expected ${LLM_PROVIDERS.map((p) => `"${p}"`).join(" or ")}.`
4499
+ };
4500
+ return {
4501
+ ok: true,
4502
+ llm: {
4503
+ provider: llmProvider,
4504
+ model: llmModel
4505
+ }
4506
+ };
4507
+ }
4508
+ /**
4509
+ * The optional `usage: { input, cachedInput, output }` of a 200 response, defensively:
4510
+ * anything that is not three finite numbers is simply absent (an older server, or one
4511
+ * whose provider reports nothing, must never break a run).
4512
+ */
4513
+ function parseUsage(value) {
4514
+ if (typeof value !== "object" || value === null) return void 0;
4515
+ const { input, cachedInput, output } = value;
4516
+ const counts = [
4517
+ input,
4518
+ cachedInput,
4519
+ output
4520
+ ].map((count) => typeof count === "number" && Number.isFinite(count) ? Math.max(0, count) : void 0);
4521
+ if (counts.some((count) => count === void 0)) return void 0;
4522
+ const [inputCount = 0, cachedCount = 0, outputCount = 0] = counts;
4523
+ return {
4524
+ input: inputCount,
4525
+ cachedInput: cachedCount,
4526
+ output: outputCount
4527
+ };
4528
+ }
4529
+ /**
4530
+ * The HTTP seam for ONE grading call, with the run's effective llm closed in — so the
4531
+ * runner itself never learns whether the pair came from the quiz or from `--llm-*`.
4532
+ * Classifies the failure for the retry policy: 5xx and true network failures are
4533
+ * retryable, auth failures abort the run, every other 4xx is terminal.
4534
+ */
4535
+ function makeGradeFn(server, llm) {
4536
+ return async ({ system, answer }) => {
4537
+ const response = await performApiRequest({
4538
+ server,
4539
+ path: "/api/eval/grade",
4540
+ method: "POST",
4541
+ body: {
4542
+ llm,
4543
+ system,
4544
+ answer
4545
+ },
4546
+ quiet: true
4547
+ });
4548
+ if (response.ok) {
4549
+ const payload = response.payload;
4550
+ const verdict = payload?.result;
4551
+ if (verdict === "correct" || verdict === "partial" || verdict === "incorrect") {
4552
+ const usage = parseUsage(payload?.usage);
4553
+ return {
4554
+ ok: true,
4555
+ verdict,
4556
+ feedback: typeof payload?.feedback === "string" ? payload.feedback : "",
4557
+ ...usage ? { usage } : {}
4558
+ };
4559
+ }
4560
+ return {
4561
+ ok: false,
4562
+ retryable: false,
4563
+ error: { message: "The server's response is not a grading verdict — it may not offer /api/eval/grade at all (does it run a Novedu version with the eval feature?). Check the target server, e.g. --server http://localhost:3000." }
4564
+ };
4565
+ }
4566
+ return {
4567
+ ok: false,
4568
+ retryable: response.status === void 0 || response.status >= 500,
4569
+ ...response.authFailed ? { auth: true } : {},
4570
+ error: response.error
4571
+ };
4572
+ };
4573
+ }
4574
+ /** stderr progress, suppressed off a TTY so CI logs stay readable. */
4575
+ function progressWriter(prefix) {
4576
+ if (!process.stderr.isTTY) return void 0;
4577
+ return ({ done, total }) => {
4578
+ process.stderr.write(`\r${prefix}${done}/${total} `);
4579
+ };
4580
+ }
4581
+ /**
4582
+ * The command's core, exported for the unit tests. `seams` exists only so tests can
4583
+ * shrink the retry backoff — the CLI itself never passes it (PoC parity: 4 attempts,
4584
+ * 5 s linear).
4585
+ */
4586
+ async function runEvalCommand(pathsOrUrls, options, seams = {}) {
4587
+ const override = parseOverride(options);
4588
+ if (!override.ok) {
4589
+ failJson({ message: override.message });
4590
+ return;
4591
+ }
4592
+ const expansion = expandSources(pathsOrUrls);
4593
+ if (!expansion.ok) {
4594
+ failJson({ message: expansion.message });
4595
+ return;
4596
+ }
4597
+ for (const duplicate of expansion.duplicates) process.stderr.write(`Warning: ${duplicate} was given more than once — ignoring the copy.\n`);
4598
+ const repeats = Math.max(1, Number.parseInt(options.repeats ?? "1", 10) || 1);
4599
+ const concurrency = Math.max(1, Number.parseInt(options.concurrency ?? String(CONCURRENCY_DEFAULT), 10) || CONCURRENCY_DEFAULT);
4600
+ const checked = /* @__PURE__ */ new Map();
4601
+ const files = [];
4602
+ for (const source of expansion.sources) {
4603
+ const result = await loadAndCheckEval(source, cliFetcher, { allowedSchemes: [
4604
+ "http:",
4605
+ "https:",
4606
+ "file:"
4607
+ ] });
4608
+ if (!result.ok) {
4609
+ files.push({
4610
+ source,
4611
+ status: "invalid",
4612
+ errors: result.errors
4613
+ });
4614
+ continue;
4615
+ }
4616
+ checked.set(source, result);
4617
+ files.push({
4618
+ source,
4619
+ status: "ok"
4620
+ });
4621
+ }
4622
+ if (checked.size === 0) {
4623
+ failJson({
4624
+ message: files.length === 1 ? "The eval file is not usable." : "None of the eval files are usable.",
4625
+ files: files.map((file) => ({
4626
+ source: file.source,
4627
+ errors: file.errors ?? []
4628
+ })),
4629
+ errors: files.flatMap((file) => file.errors ?? [])
4630
+ });
4631
+ return;
4632
+ }
4633
+ {
4634
+ const totalCases = [...checked.values()].reduce((sum, file) => sum + file.caseCount, 0);
4635
+ process.stderr.write(`${totalCases} case(s) × ${repeats} repeat(s) = ${totalCases * repeats} grading call(s)\n`);
4636
+ }
4637
+ let fileIndex = 0;
4638
+ for (const file of files) {
4639
+ fileIndex += 1;
4640
+ const check = checked.get(file.source);
4641
+ if (!check) continue;
4642
+ const quizLlm = {
4643
+ provider: check.quizDump.llm.provider,
4644
+ model: check.quizDump.llm.model
4645
+ };
4646
+ const effective = override.llm ?? quizLlm;
4647
+ const llm = {
4648
+ ...effective,
4649
+ ...override.llm ? { overrides: quizLlm } : {}
4650
+ };
4651
+ const prefix = files.length > 1 ? `(${fileIndex}/${files.length}) ${check.evalFile.id}: ` : "";
4652
+ file.result = await runEval("quiz", check, {
4653
+ grade: makeGradeFn(options.server, effective),
4654
+ concurrency,
4655
+ repeats,
4656
+ llm,
4657
+ onProgress: progressWriter(prefix),
4658
+ ...seams.retry ? { retry: seams.retry } : {}
4659
+ });
4660
+ if (process.stderr.isTTY) process.stderr.write("\n");
4661
+ }
4662
+ const batch = summarizeBatch(files);
4663
+ const payload = JSON.stringify(batch, null, 2);
4664
+ if (options.json) console.log(payload);
4665
+ else if (files.length === 1 && files[0]?.result) console.log(formatEvalReport(files[0].result, files[0].source));
4666
+ else console.log(formatEvalBatchReport(batch));
4667
+ if (options.out) try {
4668
+ await writeFile(options.out, `${payload}\n`, "utf8");
4669
+ } catch (error) {
4670
+ failJson({ message: `Could not write ${options.out}: ${error instanceof Error ? error.message : error}` });
4671
+ return;
4672
+ }
4673
+ if (options.report) try {
4674
+ await writeFile(options.report, renderEvalMarkdownReport(batch, {
4675
+ generatedAt: /* @__PURE__ */ new Date(),
4676
+ cliVersion: cliVersion(),
4677
+ repeats,
4678
+ concurrency
4679
+ }), "utf8");
4680
+ } catch (error) {
4681
+ failJson({ message: `Could not write ${options.report}: ${error instanceof Error ? error.message : error}` });
4682
+ return;
4683
+ }
4684
+ process.exitCode = batchPassed(batch) ? 0 : 1;
4685
+ }
4686
+ function registerEval(program) {
4687
+ program.command("eval").description("Grade a file of golden answers against its quiz's real rubric and report the result").argument("<evalPathOrUrl...>", "one or more eval YAML files (paths, http(s)/file URLs, or a quoted glob pattern)").option("--server <url>", "Novedu server base URL (defaults to the NOVEDU_SERVER env var, then production)").option("--concurrency <n>", "grading calls in flight per file", String(CONCURRENCY_DEFAULT)).option("--repeats <n>", "grade every answer N times and take the majority verdict", "1").option("--llm-provider <provider>", "grade with this provider instead of the quiz's (\"SCCH\" or \"Azure Foundry\"; needs --llm-model)").option("--llm-model <model>", "grade with this model instead of the quiz's (needs --llm-provider)").option("--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", `
4688
+ Examples:
4689
+ # Evaluate one quiz's golden answers
4690
+ $ novedu-cli eval ./0010-welcome-quiz.eval.yaml
4691
+
4692
+ # A whole course part (quote the pattern so the CLI expands it, ** included)
4693
+ $ novedu-cli eval "./part-1/**/*.eval.yaml"
4694
+
4695
+ # Measure grader stability: 3 runs per answer, majority verdict
4696
+ $ novedu-cli eval ./my-quiz.eval.yaml --repeats 3
4697
+
4698
+ # How would this rubric perform on another model? (both flags, always together)
4699
+ $ novedu-cli eval ./my-quiz.eval.yaml --llm-provider "Azure Foundry" --llm-model gpt-5-mini
4700
+
4701
+ # Machine-readable, for CI
4702
+ $ novedu-cli eval ./my-quiz.eval.yaml --json --out eval-report.json
4703
+
4704
+ # A readable Markdown report (questions, golden answers, grader feedback, tokens)
4705
+ $ novedu-cli eval ./my-quiz.eval.yaml --report eval-report.md`).action(async (pathsOrUrls, options) => {
4706
+ await runEvalCommand(pathsOrUrls, options);
4707
+ });
4708
+ }
4709
+ //#endregion
4710
+ //#region src/commands/files.ts
4711
+ const SERVER_OPTION$2 = ["--server <url>", "Novedu server base URL (defaults to the NOVEDU_SERVER env var, then production)"];
4712
+ async function readStdin() {
4713
+ const chunks = [];
4714
+ for await (const chunk of process.stdin) chunks.push(chunk);
4715
+ return Buffer.concat(chunks).toString("utf8");
4716
+ }
4717
+ function registerFiles(program) {
4718
+ const files = program.command("files").description("Manage app-hosted YAML files on the Novedu server");
4719
+ files.command("upload <name>").description("Create or update an app-hosted YAML file from --file or stdin (validated server-side)").option("--kind <kind>", "file kind (tutor, fragment, quiz, writing, coding) — required when creating").option("--file <path>", "read the YAML from this path instead of stdin").option(...SERVER_OPTION$2).action(async (name, options) => {
4720
+ let content;
4721
+ try {
4722
+ content = options.file === void 0 ? await readStdin() : await readFile(options.file, "utf8");
4723
+ } catch (error) {
4724
+ failJson({ message: `Could not read ${options.file}: ${error instanceof Error ? error.message : error}` });
4725
+ return;
4726
+ }
4727
+ await runApiRequest({
4728
+ server: options.server,
4729
+ path: `/api/files/${encodeURIComponent(name)}`,
4730
+ method: "PUT",
4731
+ body: {
4732
+ ...options.kind === void 0 ? {} : { kind: options.kind },
4733
+ content
4734
+ }
4735
+ });
4736
+ });
4737
+ files.command("list").description("List app-hosted YAML files (defaults to only your own, like the web list)").option("--search <q>", "contains-filter over name/title/description").option("--all", "include files last written by other teachers").option(...SERVER_OPTION$2).action(async (options) => {
4738
+ const params = new URLSearchParams();
4739
+ if (options.search) params.set("q", options.search);
4740
+ if (options.all) params.set("mine", "0");
4741
+ const query = params.toString();
4742
+ await runApiRequest({
4743
+ server: options.server,
4744
+ path: `/api/files${query ? `?${query}` : ""}`
4745
+ });
4746
+ });
4747
+ }
4748
+ //#endregion
4749
+ //#region ../lib/file-name.ts
4750
+ /**
4751
+ * Maps a filename or bare extension (with or without a leading dot, any case)
4752
+ * to an {@link ImageMime}; returns `null` for anything unrecognized. `jpg`/`jpeg`
4753
+ * both map to `image/jpeg`, `svg` to `image/svg+xml`.
4754
+ */
4755
+ function imageMimeFromExtension(filename) {
4756
+ const lastDot = filename.lastIndexOf(".");
4757
+ switch ((lastDot >= 0 ? filename.slice(lastDot + 1) : filename).toLowerCase()) {
4758
+ case "png": return "image/png";
4759
+ case "jpg":
4760
+ case "jpeg": return "image/jpeg";
4761
+ case "svg": return "image/svg+xml";
4762
+ default: return null;
4763
+ }
4764
+ }
4765
+ //#endregion
4766
+ //#region src/commands/images.ts
4767
+ const SERVER_OPTION$1 = ["--server <url>", "Novedu server base URL (defaults to the NOVEDU_SERVER env var, then production)"];
4768
+ function registerImages(program) {
4769
+ const images = program.command("images").description("Manage app-hosted images on the Novedu server");
4770
+ images.command("upload <name>").description("Upload a NEW image (.png, .jpg/.jpeg or .svg, max 5 MB) from --file").option("--file <path>", "the image file to upload (required — images are binary, no stdin)").option("--credit <text>", "optional attribution shown with the image (max 512 chars)").option(...SERVER_OPTION$1).action(async (name, options) => {
4771
+ if (options.file === void 0) {
4772
+ failJson({ message: "Pass --file <path> — images are binary, stdin is not supported." });
4773
+ return;
4774
+ }
4775
+ const mime = imageMimeFromExtension(options.file);
4776
+ if (mime === null) {
4777
+ failJson({ message: "Only .png, .jpg/.jpeg and .svg files can be uploaded." });
4778
+ return;
4779
+ }
4780
+ let bytes;
4781
+ try {
4782
+ bytes = await readFile(options.file);
4783
+ } catch (error) {
4784
+ failJson({ message: `Could not read ${options.file}: ${error instanceof Error ? error.message : error}` });
4785
+ return;
4786
+ }
4787
+ const slot = await performApiRequest({
4788
+ server: options.server,
4789
+ path: `/api/images/${encodeURIComponent(name)}`,
4790
+ method: "POST",
4791
+ body: {
4792
+ mime,
4793
+ byteSize: bytes.length
4794
+ }
4795
+ });
4796
+ if (!slot.ok) return;
4797
+ const { uploadUrl, blobPath } = slot.payload ?? {};
4798
+ if (typeof uploadUrl !== "string" || typeof blobPath !== "string") {
4799
+ failJson({ message: "Unexpected response from the server." });
4800
+ return;
4801
+ }
4802
+ let putResponse;
4803
+ try {
4804
+ putResponse = await fetch(uploadUrl, {
4805
+ method: "PUT",
4806
+ headers: {
4807
+ "x-ms-blob-type": "BlockBlob",
4808
+ "content-type": mime
4809
+ },
4810
+ body: new Uint8Array(bytes)
4811
+ });
4812
+ } catch (error) {
4813
+ failJson({ message: `Could not reach storage: ${error instanceof Error ? error.message : error}` });
4814
+ return;
4815
+ }
4816
+ if (!putResponse.ok) {
4817
+ failJson({ message: `The upload to storage failed: HTTP ${putResponse.status}. Try again.` });
4818
+ return;
4819
+ }
4820
+ await runApiRequest({
4821
+ server: options.server,
4822
+ path: `/api/images/${encodeURIComponent(name)}/confirm`,
4823
+ method: "POST",
4824
+ body: {
4825
+ blobPath,
4826
+ mime,
4827
+ ...options.credit === void 0 ? {} : { credit: options.credit }
4828
+ }
4829
+ });
4830
+ });
4831
+ images.command("list").description("List app-hosted images (defaults to only your own, like the web list)").option("--search <q>", "contains-filter over the name").option("--all", "include images uploaded by other teachers").option(...SERVER_OPTION$1).action(async (options) => {
4832
+ const params = new URLSearchParams();
4833
+ if (options.search) params.set("q", options.search);
4834
+ if (options.all) params.set("mine", "0");
4835
+ const query = params.toString();
4836
+ await runApiRequest({
4837
+ server: options.server,
4838
+ path: `/api/images${query ? `?${query}` : ""}`
4839
+ });
4840
+ });
4841
+ }
4842
+ //#endregion
4843
+ //#region src/commands/login.ts
4844
+ function registerLogin(program) {
4845
+ program.command("login").description("Sign in to Microsoft Entra ID (opens your browser)").option("--device-code", "sign in with the device code flow instead (for machines without a browser; the tenant must allow it)").addHelpText("after", `
4846
+ Sign-in is the one human-assisted step: by default a browser window opens for
4847
+ the Microsoft sign-in (first-time users see a one-time consent prompt). On a
4848
+ machine without a browser, --device-code prints a verification URL and a code
4849
+ to enter from any other device — note that some tenants block the device code
4850
+ flow by policy (error 53003). Every other command then works non-interactively
4851
+ from the cached credentials. Already signed in? The command says so and exits
4852
+ — it never blocks.`).action(async (options) => {
4853
+ const pca = buildPca();
4854
+ const cached = await acquireSilent(pca);
4855
+ if (cached) {
4856
+ console.log(`Already signed in as ${displayName(cached)}.`);
4857
+ return;
4858
+ }
4859
+ const result = options.deviceCode ? await acquireByDeviceCode(pca, (message) => console.log(message)) : await acquireInteractive(pca, (url) => {
4860
+ console.log("A browser window should open for the Microsoft sign-in.");
4861
+ console.log(`If it does not, open this URL yourself:\n${url}`);
4862
+ });
4863
+ console.log(`Signed in as ${displayName(result)}.`);
4864
+ });
4865
+ }
4866
+ //#endregion
4867
+ //#region src/commands/logout.ts
4868
+ function registerLogout(program) {
4869
+ program.command("logout").description("Sign out: remove the cached credentials from this machine").addHelpText("after", `
4870
+ Purely local — already-issued access tokens stay valid until they expire
4871
+ (about an hour). Running it while signed out is fine.`).action(async () => {
4872
+ const cache = buildPca().getTokenCache();
4873
+ for (const account of await cache.getAllAccounts()) await cache.removeAccount(account);
4874
+ rmSync(TOKEN_CACHE_PATH, { force: true });
4875
+ console.log("Signed out.");
4876
+ });
3784
4877
  }
3785
4878
  //#endregion
3786
4879
  //#region src/commands/prompts.ts
@@ -3901,11 +4994,11 @@ function registerWhoami(program) {
3901
4994
  }
3902
4995
  //#endregion
3903
4996
  //#region src/main.ts
3904
- const { version } = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
3905
4997
  const program = new Command();
3906
- program.name("novedu-cli").description("Command-line companion for the Novedu chat app").version(version);
4998
+ program.name("novedu-cli").description("Command-line companion for the Novedu chat app").version(cliVersion());
3907
4999
  registerValidate(program);
3908
5000
  registerPrompts(program);
5001
+ registerEval(program);
3909
5002
  registerLogin(program);
3910
5003
  registerLogout(program);
3911
5004
  registerWhoami(program);