@novedu/cli 0.18.0 → 0.19.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 +50 -1
  2. package/dist/main.js +1351 -411
  3. package/package.json +2 -2
package/dist/main.js CHANGED
@@ -9,8 +9,8 @@ import { homedir } from "node:os";
9
9
  import { CryptoProvider, PublicClientApplication } from "@azure/msal-node";
10
10
  import { parse, stringify } from "yaml";
11
11
  import { z } from "zod";
12
- import { fileURLToPath, pathToFileURL } from "node:url";
13
12
  import Handlebars from "handlebars";
13
+ import { fileURLToPath, pathToFileURL } from "node:url";
14
14
  //#region src/auth.ts
15
15
  const DEFAULT_TENANT_ID = "91fc072c-edef-4f97-bdc5-cfb67718ae3a";
16
16
  const DEFAULT_CLIENT_ID = "4d44fc4b-0434-4981-9765-62e2074ceecb";
@@ -278,7 +278,11 @@ async function runApiRequest(options) {
278
278
  //#endregion
279
279
  //#region ../lib/llm/provider.ts
280
280
  const LLM_PROVIDERS = ["SCCH", "Azure Foundry"];
281
- const providerSchema = z.enum(LLM_PROVIDERS).default("SCCH").meta({ description: "The LLM provider serving the model. For Azure Foundry, model is the deployment name." });
281
+ const DEFAULT_PROVIDER = "SCCH";
282
+ const providerSchema = z.enum(LLM_PROVIDERS).default(DEFAULT_PROVIDER).meta({ description: "The LLM provider serving the model. For Azure Foundry, model is the deployment name." });
283
+ function parseLenientProvider(value) {
284
+ return value === "SCCH" || value === "Azure Foundry" ? value : void 0;
285
+ }
282
286
  //#endregion
283
287
  //#region ../lib/registry-schema.ts
284
288
  /** The fixed group names and the code module each one mints for. */
@@ -1041,36 +1045,131 @@ Purely local — already-issued access tokens stay valid until they expire
1041
1045
  });
1042
1046
  }
1043
1047
  //#endregion
1044
- //#region src/commands/reports.ts
1045
- const SERVER_OPTION = ["--server <url>", "Novedu server base URL (defaults to the NOVEDU_SERVER env var, then production)"];
1046
- function registerReports(program) {
1047
- const reports = program.command("reports").description("Triage student reports on the Novedu server");
1048
- reports.command("list").description("List reports (defaults to open reports on your own codes, like the web inbox)").option("--status <status>", "open (default), resolved or all").option("--reaction <reaction>", "filter by reaction: good, omg, bad or holysh").option("--search <q>", "contains-filter over description, reporter, code and note").option("--all", "include reports on codes created by other teachers").option(...SERVER_OPTION).action(async (options) => {
1049
- const params = new URLSearchParams();
1050
- if (options.status) params.set("status", options.status);
1051
- if (options.reaction) params.set("reaction", options.reaction);
1052
- if (options.search) params.set("q", options.search);
1053
- if (options.all) params.set("mine", "0");
1054
- const query = params.toString();
1055
- await runApiRequest({
1056
- server: options.server,
1057
- path: `/api/reports${query ? `?${query}` : ""}`
1058
- });
1059
- });
1060
- reports.command("show <id>").description("Show one report; a chat report embeds its conversation transcript").option(...SERVER_OPTION).action(async (id, options) => {
1061
- await runApiRequest({
1062
- server: options.server,
1063
- path: `/api/reports/${encodeURIComponent(id)}`
1064
- });
1065
- });
1066
- reports.command("resolve <id...>").description("Resolve one or more reports by id (bulk, in a single request)").option(...SERVER_OPTION).action(async (ids, options) => {
1067
- await runApiRequest({
1068
- server: options.server,
1069
- path: "/api/reports/resolve",
1070
- method: "POST",
1071
- body: { ids }
1072
- });
1073
- });
1048
+ //#region ../lib/coding-proxy.ts
1049
+ function isRecord(value) {
1050
+ return typeof value === "object" && value !== null && !Array.isArray(value);
1051
+ }
1052
+ /**
1053
+ * Appends the teacher's instructions to the END of an existing system-message
1054
+ * `content`, handling both the string form and OpenAI's content-parts array form.
1055
+ * Falls back to the instructions alone when there is no usable existing content.
1056
+ */
1057
+ function appendInstructions(existing, instructions) {
1058
+ if (typeof existing === "string") return existing.trim() === "" ? instructions : `${existing}\n\n${instructions}`;
1059
+ if (Array.isArray(existing)) return [...existing, {
1060
+ type: "text",
1061
+ text: instructions
1062
+ }];
1063
+ return instructions;
1064
+ }
1065
+ /**
1066
+ * Builds the upstream Chat Completions body from the client's body: PIN the model and
1067
+ * fold in the teacher's system prompt. The teacher's instructions are appended to the
1068
+ * END of the client's LAST system message, so the teacher has the final word: a client
1069
+ * cannot smuggle a later system message after the teacher's to override it. If the
1070
+ * client sent no system message, a leading one carrying only the teacher's instructions
1071
+ * is added. Everything else (messages, tools, tool_choice, temperature, stream, …)
1072
+ * passes through verbatim, so client-side tools and streaming are all preserved.
1073
+ */
1074
+ function buildUpstreamChatBody(clientBody, opts) {
1075
+ const clientMessages = Array.isArray(clientBody.messages) ? clientBody.messages : [];
1076
+ const systemIndex = clientMessages.findLastIndex((m) => isRecord(m) && m.role === "system");
1077
+ let messages;
1078
+ if (systemIndex === -1) messages = [{
1079
+ role: "system",
1080
+ content: opts.instructions
1081
+ }, ...clientMessages];
1082
+ else {
1083
+ const existing = clientMessages[systemIndex];
1084
+ messages = [...clientMessages];
1085
+ messages[systemIndex] = {
1086
+ ...existing,
1087
+ content: appendInstructions(existing.content, opts.instructions)
1088
+ };
1089
+ }
1090
+ const upstream = {
1091
+ ...clientBody,
1092
+ model: opts.model,
1093
+ messages
1094
+ };
1095
+ if (clientBody.stream === true) upstream.stream_options = {
1096
+ ...isRecord(clientBody.stream_options) ? clientBody.stream_options : {},
1097
+ include_usage: true
1098
+ };
1099
+ return upstream;
1100
+ }
1101
+ //#endregion
1102
+ //#region ../lib/prompt-fragments/block.ts
1103
+ /**
1104
+ * The consumed/empty block a runtime loader leaves behind after resolving fragments
1105
+ * into its own field (`Quiz.instructionsPreamble`, or folded into writing/coding
1106
+ * `instructions`), so no stale unresolved block lingers as a second source of truth
1107
+ * on the loaded object.
1108
+ */
1109
+ const EMPTY_FRAGMENT_BLOCK = {
1110
+ fragment_files: [],
1111
+ text_files: []
1112
+ };
1113
+ function readFragmentBlock(root) {
1114
+ return {
1115
+ fragment_files: Array.isArray(root.fragment_files) ? root.fragment_files : [],
1116
+ text_files: Array.isArray(root.text_files) ? root.text_files : []
1117
+ };
1118
+ }
1119
+ //#endregion
1120
+ //#region ../lib/coding-yaml.ts
1121
+ function asString$2(value) {
1122
+ if (typeof value === "string") return value.trim() !== "" ? value : void 0;
1123
+ if (typeof value === "number") return Number.isFinite(value) ? String(value) : void 0;
1124
+ if (typeof value === "boolean") return String(value);
1125
+ }
1126
+ /**
1127
+ * Parses and lightly validates a coding YAML. Returns a friendly error message
1128
+ * (not structured errors) when an essential field is missing — the proxy and the
1129
+ * student page surface it as a notice.
1130
+ */
1131
+ function parseCoding(content) {
1132
+ let doc;
1133
+ try {
1134
+ doc = parse(content);
1135
+ } catch {
1136
+ return {
1137
+ ok: false,
1138
+ message: "This coding activity could not be read — its YAML is not valid."
1139
+ };
1140
+ }
1141
+ if (typeof doc !== "object" || doc === null || Array.isArray(doc)) return {
1142
+ ok: false,
1143
+ message: "This coding activity is empty or malformed."
1144
+ };
1145
+ const root = doc;
1146
+ const llm = root.llm;
1147
+ const model = asString$2(llm?.model);
1148
+ if (!model) return {
1149
+ ok: false,
1150
+ message: "This coding activity does not specify a model (llm.model)."
1151
+ };
1152
+ const provider = llm?.provider === void 0 ? DEFAULT_PROVIDER : parseLenientProvider(llm.provider);
1153
+ if (!provider) return {
1154
+ ok: false,
1155
+ message: "This coding activity uses an unsupported llm.provider (use \"SCCH\" or \"Azure Foundry\")."
1156
+ };
1157
+ const instructions = asString$2(root.instructions);
1158
+ if (!instructions) return {
1159
+ ok: false,
1160
+ message: "This coding activity has no instructions for the assistant."
1161
+ };
1162
+ return {
1163
+ ok: true,
1164
+ coding: {
1165
+ id: asString$2(root.id) ?? "coding",
1166
+ title: asString$2(root.title),
1167
+ model,
1168
+ provider,
1169
+ instructions,
1170
+ fragmentBlock: readFragmentBlock(root)
1171
+ }
1172
+ };
1074
1173
  }
1075
1174
  //#endregion
1076
1175
  //#region ../lib/prompt-fragments/assemble.ts
@@ -2152,134 +2251,1140 @@ async function loadAndCheckFragmentFile(url, fetchImpl, opts = {}) {
2152
2251
  return checkFragmentFileValue(yaml.value, url);
2153
2252
  }
2154
2253
  //#endregion
2155
- //#region ../lib/coding-schema.ts
2156
- const CodingYamlSchema = z.strictObject({
2157
- id: z.string().min(1).meta({ description: "Short machine-readable activity id, e.g. beginner-typescript." }),
2158
- name: z.string().optional().meta({ description: "Optional human-readable label (not shown to the student)." }),
2159
- title: z.string().optional().meta({ description: "Optional label shown to the student on the /<code> connection page." }),
2160
- llm: z.strictObject({
2161
- 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." }),
2162
- provider: providerSchema
2163
- }).meta({
2164
- id: "llm",
2165
- description: "The pinned model and provider that answer coding requests."
2166
- }),
2167
- fragment_files: z.array(FragmentFileRefSchema).default([]).meta({ description: "Optional fragment libraries this activity pulls shared prompt fragments from." }),
2168
- 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." }),
2169
- 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 \\{{)." })
2170
- });
2171
- //#endregion
2172
- //#region ../lib/coding-validate.ts
2254
+ //#region ../lib/coding-resolve.ts
2255
+ const DEFAULT_SCHEMES$2 = ["http:", "https:"];
2256
+ function schemeAllowed$2(url, allowed) {
2257
+ try {
2258
+ return allowed.includes(new URL(url).protocol);
2259
+ } catch {
2260
+ return false;
2261
+ }
2262
+ }
2173
2263
  /**
2174
- * Extract metadata from an already-schema-validated coding value. Split from
2175
- * `checkCodingValue` so `loadAndCheckCoding` can reuse the single `validate` it already
2176
- * ran (no second parse of the same document against the same schema).
2264
+ * Resolve a leniently parsed coding activity into the runnable one. Per-request
2265
+ * streaming hot path: consistency over the referenced fragments only
2266
+ * (`validateLibraries: false`); no extra passes.
2177
2267
  */
2178
- function checkCodingParsed(coding) {
2268
+ async function resolveCoding(coding, url, fetcher, opts = {}) {
2269
+ const resolved = await assembleFragmentPrompt(coding.fragmentBlock, url, fetcher, {
2270
+ validateLibraries: false,
2271
+ allowedSchemes: opts.allowedSchemes ?? DEFAULT_SCHEMES$2
2272
+ }, coding.instructions);
2273
+ if (!resolved.ok) return {
2274
+ ok: false,
2275
+ message: "This coding activity's prompt fragments could not be loaded."
2276
+ };
2179
2277
  return {
2180
2278
  ok: true,
2181
- codingId: coding.id,
2182
- model: coding.llm.model,
2183
- provider: coding.llm.provider,
2184
- title: coding.title ?? null,
2185
- warnings: []
2279
+ coding: {
2280
+ ...coding,
2281
+ fragmentBlock: EMPTY_FRAGMENT_BLOCK,
2282
+ instructions: resolved.prompt
2283
+ }
2186
2284
  };
2187
2285
  }
2188
2286
  /**
2189
- * Validate a coding FILE: scheme-gate + fetch + parse (shared `loadYaml`), then the
2190
- * pure `checkCodingValue`. The web app passes the default http(s)-only schemes; the
2191
- * CLI adds `file:` so a local coding YAML on disk validates too.
2287
+ * Fetch + lenient-parse + `resolveCoding`, all through the CALLER's fetcher the
2288
+ * app-free counterpart of `loadCoding` (`lib/coding-fetch.ts`) used by the prompt dump
2289
+ * and the CLI, where there is no database and an activity may live on disk (`file:`).
2192
2290
  */
2193
- async function loadAndCheckCoding(url, fetchImpl, opts = {}) {
2194
- const yaml = await loadYaml(url, fetchImpl, opts);
2195
- if (!yaml.ok) return {
2291
+ async function loadCodingFrom(url, fetcher, opts = {}) {
2292
+ const allowedSchemes = opts.allowedSchemes ?? DEFAULT_SCHEMES$2;
2293
+ if (!schemeAllowed$2(url, allowedSchemes)) return {
2196
2294
  ok: false,
2197
- errors: [yaml.error],
2198
- warnings: []
2295
+ message: `This coding activity's URL is not allowed: ${url}`
2199
2296
  };
2200
- const valid = validate(yaml.value, CodingYamlSchema, "CODING_SCHEMA_ERROR", url);
2201
- if (!valid.ok) return {
2297
+ try {
2298
+ const res = await fetcher(url);
2299
+ if (!res.ok) return res.status === 404 ? {
2300
+ ok: false,
2301
+ message: "This coding activity could not be found."
2302
+ } : {
2303
+ ok: false,
2304
+ message: `This coding activity could not be loaded (HTTP ${res.status}).`
2305
+ };
2306
+ const parsed = parseCoding(await res.text());
2307
+ if (!parsed.ok) return parsed;
2308
+ return await resolveCoding(parsed.coding, url, fetcher, { allowedSchemes });
2309
+ } catch {
2310
+ return {
2311
+ ok: false,
2312
+ message: "This coding activity could not be loaded. Try again."
2313
+ };
2314
+ }
2315
+ }
2316
+ //#endregion
2317
+ //#region ../lib/quiz-types.ts
2318
+ /** The student-facing wording for a verdict — `partial` reads as "partly correct". */
2319
+ function verdictLabel(verdict) {
2320
+ switch (verdict) {
2321
+ case "correct": return "correct";
2322
+ case "partial": return "partly correct";
2323
+ case "incorrect": return "wrong";
2324
+ }
2325
+ }
2326
+ //#endregion
2327
+ //#region ../lib/quiz-discussion-prompt.ts
2328
+ /**
2329
+ * The discussion chat's system prompt: the quiz-level `instructionsPreamble` (the
2330
+ * rendered `instructions` host text — shared safety/persona/language rules, the SAME
2331
+ * preamble the grader receives) followed by a default frame and the quiz's optional
2332
+ * `discussionInstructions`. The question/answer/verdict are the thread's seed messages,
2333
+ * recalled from memory, NOT repeated here.
2334
+ *
2335
+ * A compound quiz's imported questions each carry their SOURCE quiz's preamble
2336
+ * (`sourcePreamble`), but that applies to GRADING only (`buildGradingPrompt`): the
2337
+ * discussion prompt uses ONLY the compound file's own instructions — consistent with
2338
+ * every other include-level field (`llm`, `anonymous`, `shuffle`, ...), which the
2339
+ * compound file governs too. Mixing all chapters' preambles into one prompt would put
2340
+ * conflicting persona/language rules in force at once; the question/answer/verdict the
2341
+ * discussion needs are recalled from the thread's seed messages regardless.
2342
+ */
2343
+ function buildDiscussionInstructions(quiz) {
2344
+ const base = "You are helping a student understand a single quiz question. The conversation already contains the question, the student's submitted answer, and the verdict with feedback — use that context. Be concise and encouraging, and stay on this question.";
2345
+ const frame = quiz.discussionInstructions ? `${base}\n\n${quiz.discussionInstructions.trim()}` : base;
2346
+ return [quiz.instructionsPreamble, frame].filter(Boolean).join("\n\n");
2347
+ }
2348
+ /**
2349
+ * Seed message 1 (assistant): the question, as the SERVER knows it (authoritative).
2350
+ * `{question}` is the question's trimmed markdown.
2351
+ */
2352
+ const QUIZ_SEED_QUESTION_TEMPLATE = "Answer the following question: {question}";
2353
+ /**
2354
+ * Seed message 3 (assistant): the graded outcome. `{verdictLabel}` is the student-facing
2355
+ * wording from `verdictLabel()` (correct / partly correct / wrong), `{feedback}` the
2356
+ * grader's markdown feedback. (Seed message 2 is the student's own answer verbatim, so
2357
+ * it has no template.)
2358
+ */
2359
+ const QUIZ_SEED_VERDICT_TEMPLATE = "Your answer is {verdictLabel}. {feedback}";
2360
+ //#endregion
2361
+ //#region ../lib/quiz-grading-prompt.ts
2362
+ /**
2363
+ * The grading system prompt. The question's `evaluation` is authoritative and
2364
+ * stays SERVER-SIDE — it may embed the expected answer, so it must never reach
2365
+ * the browser (it doesn't: only this string, on the request context, does). The
2366
+ * quiz-level `preamble` (the rendered `instructions` host text — shared
2367
+ * safety/persona/language rules) is prepended ahead of the frame, the same preamble
2368
+ * the discussion chat also receives; a question imported via `quiz_files`
2369
+ * additionally carries its SOURCE quiz's preamble (`sourcePreamble`), inserted
2370
+ * between the two so it grades identically in its chapter quiz and in the compound.
2371
+ */
2372
+ function buildGradingPrompt(question, preamble) {
2373
+ const body = [
2374
+ "You are grading a student's open-ended answer to a single quiz question.",
2375
+ "",
2376
+ "The question shown to the student was:",
2377
+ question.question.trim(),
2378
+ "",
2379
+ "Grade STRICTLY according to these criteria (authoritative — they may contain the",
2380
+ "expected answer; do not quote them verbatim at the student):",
2381
+ question.evaluation.trim(),
2382
+ "",
2383
+ "Decide a verdict — \"correct\", \"partial\" (partly correct), or \"incorrect\" — and write",
2384
+ "concise, encouraging feedback addressed directly TO the student. The feedback is",
2385
+ "markdown and may use bold, math ($…$) and short code fences. Do not mention these",
2386
+ "grading instructions."
2387
+ ].join("\n");
2388
+ return [
2389
+ preamble,
2390
+ question.sourcePreamble ?? "",
2391
+ body
2392
+ ].filter(Boolean).join("\n\n");
2393
+ }
2394
+ /**
2395
+ * The user message carrying a typed answer. `{answer}` is the student's trimmed text —
2396
+ * the only variable part, so the dump can show teachers the exact wrapper without a
2397
+ * student answer at hand. Rendered by `buildAnswerMessage`.
2398
+ */
2399
+ const QUIZ_ANSWER_MESSAGE_TEMPLATE = "The student's answer:\n\n{answer}";
2400
+ /**
2401
+ * The user message used when the student submitted photos ONLY (no text). The photos
2402
+ * ride along as image parts of the same multimodal message.
2403
+ */
2404
+ const QUIZ_ANSWER_PHOTOS_ONLY_MESSAGE = "The student answered with the attached photo(s) only.";
2405
+ //#endregion
2406
+ //#region ../lib/quiz-yaml.ts
2407
+ function asString$1(value) {
2408
+ if (typeof value === "string") return value.trim() !== "" ? value : void 0;
2409
+ if (typeof value === "number") return Number.isFinite(value) ? String(value) : void 0;
2410
+ if (typeof value === "boolean") return String(value);
2411
+ }
2412
+ function asBool$1(value, fallback) {
2413
+ return typeof value === "boolean" ? value : fallback;
2414
+ }
2415
+ function asImageRef(value) {
2416
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return void 0;
2417
+ const obj = value;
2418
+ const src = asString$1(obj.src);
2419
+ if (!src) return void 0;
2420
+ const alt = asString$1(obj.alt);
2421
+ const credit = asString$1(obj.credit);
2422
+ return {
2423
+ hosted: asBool$1(obj.hosted, false),
2424
+ src,
2425
+ ...alt ? { alt } : {},
2426
+ ...credit ? { credit } : {}
2427
+ };
2428
+ }
2429
+ /**
2430
+ * Parses and lightly validates a quiz YAML. Returns a friendly error message
2431
+ * (not structured errors) when an essential field is missing — the student page
2432
+ * shows it as a notice. `anonymous` and `shuffle` default to `true`.
2433
+ */
2434
+ function parseQuiz(content) {
2435
+ let doc;
2436
+ try {
2437
+ doc = parse(content);
2438
+ } catch {
2439
+ return {
2440
+ ok: false,
2441
+ message: "This quiz could not be read — its YAML is not valid."
2442
+ };
2443
+ }
2444
+ if (typeof doc !== "object" || doc === null || Array.isArray(doc)) return {
2202
2445
  ok: false,
2203
- errors: [valid.error],
2204
- warnings: []
2446
+ message: "This quiz is empty or malformed."
2205
2447
  };
2206
- const checked = checkCodingParsed(valid.data);
2207
- if (!checked.ok) return checked;
2208
- const assembled = await assembleFragmentPrompt({
2209
- fragment_files: valid.data.fragment_files,
2210
- text_files: valid.data.text_files
2211
- }, url, fetchImpl, {
2212
- allowedSchemes: opts.allowedSchemes,
2213
- validateLibraries: opts.validateLibraries ?? true
2214
- }, valid.data.instructions);
2215
- const warnings = [...checked.warnings, ...assembled.warnings];
2216
- if (!assembled.ok) return {
2448
+ const root = doc;
2449
+ const llm = root.llm;
2450
+ const model = asString$1(llm?.model);
2451
+ if (!model) return {
2217
2452
  ok: false,
2218
- errors: assembled.errors,
2219
- warnings
2453
+ message: "This quiz does not specify a model (llm.model)."
2454
+ };
2455
+ const provider = llm?.provider === void 0 ? DEFAULT_PROVIDER : parseLenientProvider(llm.provider);
2456
+ if (!provider) return {
2457
+ ok: false,
2458
+ message: "This quiz uses an unsupported llm.provider (use \"SCCH\" or \"Azure Foundry\")."
2459
+ };
2460
+ const quizFiles = Array.isArray(root.quiz_files) ? root.quiz_files : [];
2461
+ const rawQuestions = Array.isArray(root.questions) ? root.questions : [];
2462
+ if (rawQuestions.length === 0 && quizFiles.length === 0) return {
2463
+ ok: false,
2464
+ message: "This quiz has no questions."
2465
+ };
2466
+ const questions = [];
2467
+ const seenIds = /* @__PURE__ */ new Set();
2468
+ for (const raw of rawQuestions) {
2469
+ if (typeof raw !== "object" || raw === null) continue;
2470
+ const q = raw;
2471
+ const id = asString$1(q.id);
2472
+ const question = asString$1(q.question);
2473
+ const evaluation = asString$1(q.evaluation);
2474
+ if (!id || !question || !evaluation || seenIds.has(id)) continue;
2475
+ seenIds.add(id);
2476
+ questions.push({
2477
+ id,
2478
+ title: asString$1(q.title),
2479
+ question,
2480
+ evaluation,
2481
+ image: asImageRef(q.image),
2482
+ ...typeof q.imageInput === "boolean" ? { imageInput: q.imageInput } : {}
2483
+ });
2484
+ }
2485
+ if (questions.length === 0 && quizFiles.length === 0) return {
2486
+ ok: false,
2487
+ message: "This quiz has no complete questions (each needs an id, question and evaluation)."
2220
2488
  };
2489
+ const rawCount = root.question_count;
2490
+ const questionCount = typeof rawCount === "number" && Number.isInteger(rawCount) && rawCount >= 1 ? rawCount : void 0;
2221
2491
  return {
2222
- ...checked,
2223
- warnings
2492
+ ok: true,
2493
+ quiz: {
2494
+ id: asString$1(root.id) ?? asString$1(root.name) ?? "quiz",
2495
+ name: asString$1(root.name),
2496
+ title: asString$1(root.title),
2497
+ description: asString$1(root.description),
2498
+ anonymous: asBool$1(root.anonymous, true),
2499
+ shuffle: asBool$1(root.shuffle, true),
2500
+ model,
2501
+ provider,
2502
+ questionCount,
2503
+ imageInput: asBool$1(llm?.imageInput, false),
2504
+ discussionInstructions: asString$1(root.discussion?.instructions),
2505
+ instructions: asString$1(root.instructions),
2506
+ fragmentBlock: readFragmentBlock(root),
2507
+ quizFiles,
2508
+ instructionsPreamble: "",
2509
+ questions
2510
+ }
2224
2511
  };
2225
2512
  }
2513
+ /**
2514
+ * A question's EFFECTIVE photo-answers flag: the per-question override when set, the
2515
+ * quiz-level `llm.imageInput` otherwise. The ONE definition of that two-level rule —
2516
+ * re-exported by `lib/quiz-verify.ts` for the server actions (which re-derive it on
2517
+ * every request, never trusting the client), applied by `toPublicQuiz` below, and
2518
+ * reported per question by the prompt dump.
2519
+ */
2520
+ function effectiveImageInput(quiz, question) {
2521
+ return question.imageInput ?? quiz.imageInput;
2522
+ }
2226
2523
  //#endregion
2227
- //#region ../lib/quiz-schema.ts
2524
+ //#region ../lib/quiz-resolve.ts
2525
+ const DEFAULT_SCHEMES$1 = ["http:", "https:"];
2526
+ function schemeAllowed$1(url, allowed) {
2527
+ try {
2528
+ return allowed.includes(new URL(url).protocol);
2529
+ } catch {
2530
+ return false;
2531
+ }
2532
+ }
2228
2533
  /**
2229
- * A live quiz include: alias + URL, mirroring `FragmentFileRefSchema` (same URL
2230
- * contract). The alias prefixes every imported question id as `"<alias>/<id>"`, so
2231
- * on top of the no-dot rule it may not contain a `/` either. Aliases live in their
2232
- * OWN namespace (they never appear in `{{…}}` markers only in question ids).
2534
+ * Renders ONE quiz document's `instructions` host text against its OWN fragment
2535
+ * block, relative to its OWN URL (`validateLibraries: false` the hot path). Used
2536
+ * for each included source quiz, so an imported question's `sourcePreamble` is
2537
+ * exactly what its chapter quiz would grade with. (The root document renders its
2538
+ * two host texts — `instructions` + `discussion.instructions` — in `resolveQuiz`.)
2233
2539
  */
2234
- const QuizFileRefSchema = z.strictObject({
2235
- id: z.string().regex(/^[^./]+$/, { message: "Alias must not contain a dot or a slash" }).meta({
2236
- pattern: "^[^./]+$",
2237
- description: "Local alias for this included quiz. Prefixes every imported question id as \"<alias>/<id>\", so it may not contain a dot or a slash."
2238
- }),
2239
- url: FragmentFileRefSchema.shape.url.meta({
2240
- pattern: "^(https?://|(?![A-Za-z][A-Za-z0-9+.-]*:).+)$",
2241
- description: "HTTP(S) URL or relative path to the included quiz file."
2242
- })
2243
- }).meta({
2244
- id: "quizFileRef",
2245
- description: "A reference to another quiz file whose questions are included live."
2246
- });
2247
- /** An optional content image attached to a question (carries no secret). */
2248
- const ImageRefSchema = z.strictObject({
2249
- hosted: z.boolean().optional().meta({
2250
- default: false,
2251
- description: "When true, src is an app-hosted image NAME resolved server-side; otherwise src is an absolute URL or a path relative to the quiz's own URL."
2252
- }),
2253
- src: z.string().min(1).meta({ description: "The hosted image name (when hosted) or the image URL / relative path." }),
2254
- alt: z.string().optional().meta({ description: "Accessible description shown if the image cannot be loaded." }),
2255
- credit: z.string().optional().meta({ description: "Optional attribution (\"Content Credentials\") shown small below the image." })
2256
- }).meta({
2257
- id: "image",
2258
- description: "An optional content image attached to a question."
2259
- });
2540
+ async function renderPreamble(quiz, url, fetcher, allowedSchemes) {
2541
+ const resolved = await assembleFragmentPrompt(quiz.fragmentBlock, url, fetcher, {
2542
+ validateLibraries: false,
2543
+ allowedSchemes
2544
+ }, quiz.instructions ?? "");
2545
+ if (!resolved.ok) return { ok: false };
2546
+ return {
2547
+ ok: true,
2548
+ preamble: resolved.prompt.trimEnd()
2549
+ };
2550
+ }
2260
2551
  /**
2261
- * One question. `id` keys the per-question stats (must be unique see
2262
- * `lib/quiz-validate.ts`); `question` is the Markdown shown to the student;
2263
- * `evaluation` is the server-only grading prompt.
2552
+ * Absolutize an imported question's content image against the SOURCE quiz URL, so
2553
+ * a `./diagram.png` next to the chapter quiz still resolves from the compound quiz
2554
+ * (whose own `file_url` is elsewhere). Hosted NAMES and absolute URLs pass through
2555
+ * unchanged — they resolve the same from anywhere.
2264
2556
  */
2265
- const QuizQuestionSchema = z.strictObject({
2266
- id: z.string().min(1).meta({ description: "Stable question id, unique within the quiz (the per-question stats key)." }),
2267
- title: z.string().optional().meta({ description: "Optional short label for the stats table and progress display." }),
2268
- question: z.string().min(1).meta({ description: "The Markdown shown to the student." }),
2269
- evaluation: z.string().min(1).meta({ description: "The grading prompt. SERVER-ONLY: never sent to the browser, so it may embed the expected answer and the grading rubric." }),
2270
- image: ImageRefSchema.optional(),
2271
- imageInput: z.boolean().optional().meta({ description: "Overrides the quiz-level llm.imageInput for this question only (photo answers on/off)." })
2272
- }).meta({
2273
- id: "question",
2274
- description: "One open-ended, LLM-graded quiz question."
2275
- });
2276
- const QuizYamlSchema = z.strictObject({
2277
- id: z.string().min(1).meta({ description: "Short machine-readable quiz id, e.g. countries-basics. Used as the per-quiz identity." }),
2278
- name: z.string().optional().meta({ description: "Optional human-readable quiz title (used as a label)." }),
2279
- title: z.string().optional().meta({ description: "Optional greeting shown to students on the welcome screen instead of the default message." }),
2280
- description: z.string().optional().meta({ description: "Optional description shown to students below the welcome greeting (Markdown)." }),
2281
- anonymous: z.boolean().optional().meta({
2282
- default: true,
2557
+ function absolutizeImage(image, sourceUrl) {
2558
+ if (!image || image.hosted === true || /^https?:\/\//i.test(image.src)) return image;
2559
+ try {
2560
+ return {
2561
+ ...image,
2562
+ src: resolveFragmentUrl(image.src, sourceUrl)
2563
+ };
2564
+ } catch {
2565
+ return image;
2566
+ }
2567
+ }
2568
+ /** Resolve ONE `quiz_files` include into its namespaced, import-transformed questions. */
2569
+ async function resolveInclude(ref, baseUrl, fetcher, allowedSchemes) {
2570
+ const alias = typeof ref?.id === "string" ? ref.id.trim() : "";
2571
+ const rawUrl = typeof ref?.url === "string" ? ref.url.trim() : "";
2572
+ if (!alias || /[./]/.test(alias) || !rawUrl) return {
2573
+ ok: false,
2574
+ message: "This quiz declares an invalid quiz_files entry."
2575
+ };
2576
+ let sourceUrl;
2577
+ try {
2578
+ sourceUrl = resolveFragmentUrl(rawUrl, baseUrl);
2579
+ } catch {
2580
+ return {
2581
+ ok: false,
2582
+ message: `The included quiz "${alias}" has an invalid URL.`
2583
+ };
2584
+ }
2585
+ if (!schemeAllowed$1(sourceUrl, allowedSchemes)) return {
2586
+ ok: false,
2587
+ message: `The included quiz "${alias}" has an invalid URL.`
2588
+ };
2589
+ let body;
2590
+ try {
2591
+ const res = await fetcher(sourceUrl);
2592
+ if (!res.ok) return {
2593
+ ok: false,
2594
+ message: `The included quiz "${alias}" could not be loaded.`
2595
+ };
2596
+ body = await res.text();
2597
+ } catch {
2598
+ return {
2599
+ ok: false,
2600
+ message: `The included quiz "${alias}" could not be loaded.`
2601
+ };
2602
+ }
2603
+ const parsed = parseQuiz(body);
2604
+ if (!parsed.ok) return {
2605
+ ok: false,
2606
+ message: `The included quiz "${alias}" is not a usable quiz file.`
2607
+ };
2608
+ if (parsed.quiz.quizFiles.length > 0) return {
2609
+ ok: false,
2610
+ message: `The included quiz "${alias}" itself includes other quizzes — includes cannot be nested.`
2611
+ };
2612
+ const preamble = await renderPreamble(parsed.quiz, sourceUrl, fetcher, allowedSchemes);
2613
+ if (!preamble.ok) return {
2614
+ ok: false,
2615
+ message: `The included quiz "${alias}"'s prompt fragments could not be loaded.`
2616
+ };
2617
+ const source = parsed.quiz;
2618
+ return {
2619
+ ok: true,
2620
+ questions: source.questions.map((q) => ({
2621
+ ...q,
2622
+ id: `${alias}/${q.id}`,
2623
+ imageInput: q.imageInput ?? source.imageInput,
2624
+ image: absolutizeImage(q.image, sourceUrl),
2625
+ ...preamble.preamble ? { sourcePreamble: preamble.preamble } : {}
2626
+ }))
2627
+ };
2628
+ }
2629
+ /**
2630
+ * Resolve a leniently parsed quiz into the runnable one: render its two host texts,
2631
+ * then merge in every `quiz_files` include. `url` is the quiz's own URL (the base for
2632
+ * relative fragment/include refs); `fetcher` is the caller's network seam.
2633
+ */
2634
+ async function resolveQuiz(quiz, url, fetcher, opts = {}) {
2635
+ const allowedSchemes = opts.allowedSchemes ?? DEFAULT_SCHEMES$1;
2636
+ const resolved = await assembleFragmentPrompts(quiz.fragmentBlock, url, fetcher, {
2637
+ validateLibraries: false,
2638
+ allowedSchemes
2639
+ }, [quiz.instructions ?? "", quiz.discussionInstructions ?? ""]);
2640
+ if (!resolved.ok) return {
2641
+ ok: false,
2642
+ message: "This quiz's prompt fragments could not be loaded."
2643
+ };
2644
+ const [instructionsPreamble = "", discussionInstructions = ""] = resolved.prompts;
2645
+ const refs = quiz.quizFiles;
2646
+ const aliases = /* @__PURE__ */ new Set();
2647
+ for (const ref of refs) {
2648
+ const alias = typeof ref?.id === "string" ? ref.id.trim() : "";
2649
+ if (aliases.has(alias)) return {
2650
+ ok: false,
2651
+ message: `This quiz declares the included-quiz alias "${alias}" twice.`
2652
+ };
2653
+ aliases.add(alias);
2654
+ }
2655
+ const includes = await Promise.all(refs.map((ref) => resolveInclude(ref, url, fetcher, allowedSchemes)));
2656
+ const imported = [];
2657
+ for (const include of includes) {
2658
+ if (!include.ok) return include;
2659
+ imported.push(...include.questions);
2660
+ }
2661
+ const questions = [...quiz.questions, ...imported];
2662
+ if (questions.length === 0) return {
2663
+ ok: false,
2664
+ message: "This quiz has no questions."
2665
+ };
2666
+ return {
2667
+ ok: true,
2668
+ quiz: {
2669
+ ...quiz,
2670
+ fragmentBlock: EMPTY_FRAGMENT_BLOCK,
2671
+ quizFiles: [],
2672
+ instructionsPreamble: instructionsPreamble.trimEnd(),
2673
+ discussionInstructions: discussionInstructions.trim() !== "" ? discussionInstructions.trimEnd() : void 0,
2674
+ questions
2675
+ }
2676
+ };
2677
+ }
2678
+ /**
2679
+ * Fetch + lenient-parse + `resolveQuiz`, all through the CALLER's fetcher — the
2680
+ * app-free counterpart of `loadQuiz` (`lib/quiz-fetch.ts`) used by the prompt dump and
2681
+ * the CLI, where there is no database and a quiz may live on disk (`file:`).
2682
+ */
2683
+ async function loadQuizFrom(url, fetcher, opts = {}) {
2684
+ const allowedSchemes = opts.allowedSchemes ?? DEFAULT_SCHEMES$1;
2685
+ if (!schemeAllowed$1(url, allowedSchemes)) return {
2686
+ ok: false,
2687
+ message: `This quiz's URL is not allowed: ${url}`
2688
+ };
2689
+ try {
2690
+ const res = await fetcher(url);
2691
+ if (!res.ok) return res.status === 404 ? {
2692
+ ok: false,
2693
+ message: "This quiz could not be found."
2694
+ } : {
2695
+ ok: false,
2696
+ message: `This quiz could not be loaded (HTTP ${res.status}).`
2697
+ };
2698
+ const parsed = parseQuiz(await res.text());
2699
+ if (!parsed.ok) return parsed;
2700
+ return await resolveQuiz(parsed.quiz, url, fetcher, { allowedSchemes });
2701
+ } catch {
2702
+ return {
2703
+ ok: false,
2704
+ message: "This quiz could not be loaded. Try again."
2705
+ };
2706
+ }
2707
+ }
2708
+ //#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
+ //#region ../lib/tutors/schemas.ts
2720
+ /**
2721
+ * An example question offered to students on the welcome screen: the `title` is
2722
+ * the clickable label, the `question` is the full text placed into the chat
2723
+ * input on click. Tutors may define any number; the UI samples at most 5.
2724
+ */
2725
+ const ExampleQuestionSchema = z.strictObject({
2726
+ title: z.string().min(1).meta({ description: "Short clickable label shown on the welcome screen." }),
2727
+ question: z.string().min(1).meta({ description: "Full question text. Shown as a tooltip and placed into the chat input on click." })
2728
+ }).meta({
2729
+ id: "exampleQuestion",
2730
+ description: "An example question shown on the welcome screen."
2731
+ });
2732
+ const TutorSchema = z.strictObject({
2733
+ id: z.string().meta({ description: "Short machine-readable tutor id, e.g. fractions-de." }),
2734
+ name: z.string().meta({ description: "Human-readable tutor title." }),
2735
+ title: z.string().optional().meta({ description: "Optional greeting shown to students on the empty chat instead of the default welcome message." }),
2736
+ description: z.string().meta({ description: "Short description of what this tutor does. Shown to students below the welcome greeting." }),
2737
+ exampleQuestions: z.array(ExampleQuestionSchema).optional().meta({ description: "Optional example questions shown to students below the description on the empty chat. Clicking one puts the question text into the chat input. At most 5 are shown; with more, a random 5 are picked per page load." }),
2738
+ anonymous: z.boolean().optional().meta({
2739
+ default: true,
2740
+ description: "Chats are anonymous by default: no link between the signed-in student and their chat is stored. Set to false to record which student each chat belongs to."
2741
+ }),
2742
+ llm: z.strictObject({
2743
+ model: z.string().meta({ description: "Model used for this tutor." }),
2744
+ provider: providerSchema,
2745
+ imageInput: z.boolean().optional().meta({
2746
+ default: true,
2747
+ description: "Image uploads are enabled by default. Set to false to hide the upload UI for text-only tutors or non-vision-capable models."
2748
+ })
2749
+ }).meta({
2750
+ id: "llm",
2751
+ description: "The model and provider that back this tutor."
2752
+ }),
2753
+ prompt: z.strictObject({
2754
+ fragment_files: z.array(FragmentFileRefSchema).default([]).meta({ description: "Optional fragment libraries used by this tutor." }),
2755
+ text_files: z.array(TextFileRefSchema).default([]).meta({ description: "Optional plain-text files (markdown / source) embedded verbatim via {{file \"alias\"}} markers." }),
2756
+ tutor_instructions: z.string().meta({ description: "The tutor's system prompt. When any fragment_files or text_files are declared this is a Handlebars template: place fragments inline with {{fragment \"alias.id\" key=\"v\"}} markers and embed text files with {{file \"alias\"}} (optionally {{file \"alias\" from=10 to=40}} for a line range; escape a literal {{ as \\{{). For single-file tutors it is the whole prompt." })
2757
+ }).meta({
2758
+ id: "prompt",
2759
+ description: "The tutor system prompt: a host template with inline fragment markers."
2760
+ })
2761
+ });
2762
+ //#endregion
2763
+ //#region ../lib/tutors/load.ts
2764
+ async function loadAndBuildTutorPrompt(url, fetchImpl, opts = {}) {
2765
+ const warnings = [];
2766
+ const tutorYaml = await loadYaml(url, fetchImpl, opts);
2767
+ if (!tutorYaml.ok) return {
2768
+ ok: false,
2769
+ errors: [tutorYaml.error],
2770
+ warnings
2771
+ };
2772
+ const tutorValid = validate(tutorYaml.value, TutorSchema, "TUTOR_SCHEMA_ERROR", url);
2773
+ if (!tutorValid.ok) return {
2774
+ ok: false,
2775
+ errors: [tutorValid.error],
2776
+ warnings
2777
+ };
2778
+ const tutor = tutorValid.data;
2779
+ const assembled = await assembleFragmentPrompt(tutor.prompt, url, fetchImpl, opts, tutor.prompt.tutor_instructions);
2780
+ warnings.push(...assembled.warnings);
2781
+ if (!assembled.ok) return {
2782
+ ok: false,
2783
+ errors: assembled.errors,
2784
+ warnings
2785
+ };
2786
+ return {
2787
+ ok: true,
2788
+ id: tutor.id,
2789
+ prompt: assembled.prompt,
2790
+ model: tutor.llm.model,
2791
+ provider: tutor.llm.provider,
2792
+ imageInput: tutor.llm.imageInput ?? true,
2793
+ anonymous: tutor.anonymous ?? true,
2794
+ title: tutor.title,
2795
+ description: tutor.description,
2796
+ exampleQuestions: tutor.exampleQuestions ?? [],
2797
+ warnings
2798
+ };
2799
+ }
2800
+ //#endregion
2801
+ //#region ../lib/writing-yaml.ts
2802
+ function asString(value) {
2803
+ if (typeof value === "string") return value.trim() !== "" ? value : void 0;
2804
+ if (typeof value === "number") return Number.isFinite(value) ? String(value) : void 0;
2805
+ if (typeof value === "boolean") return String(value);
2806
+ }
2807
+ function asBool(value, fallback) {
2808
+ return typeof value === "boolean" ? value : fallback;
2809
+ }
2810
+ /**
2811
+ * Parses and lightly validates a writing YAML. Returns a friendly error message
2812
+ * (not structured errors) when an essential field is missing — the student page
2813
+ * shows it as a notice. `anonymous` DEFAULTS to `false` (the writing divergence).
2814
+ */
2815
+ function parseWriting(content) {
2816
+ let doc;
2817
+ try {
2818
+ doc = parse(content);
2819
+ } catch {
2820
+ return {
2821
+ ok: false,
2822
+ message: "This writing activity could not be read — its YAML is not valid."
2823
+ };
2824
+ }
2825
+ if (typeof doc !== "object" || doc === null || Array.isArray(doc)) return {
2826
+ ok: false,
2827
+ message: "This writing activity is empty or malformed."
2828
+ };
2829
+ const root = doc;
2830
+ const llm = root.llm;
2831
+ const model = asString(llm?.model);
2832
+ if (!model) return {
2833
+ ok: false,
2834
+ message: "This writing activity does not specify a model (llm.model)."
2835
+ };
2836
+ const provider = llm?.provider === void 0 ? DEFAULT_PROVIDER : parseLenientProvider(llm.provider);
2837
+ if (!provider) return {
2838
+ ok: false,
2839
+ message: "This writing activity uses an unsupported llm.provider (use \"SCCH\" or \"Azure Foundry\")."
2840
+ };
2841
+ const instructions = asString(root.instructions);
2842
+ if (!instructions) return {
2843
+ ok: false,
2844
+ message: "This writing activity has no instructions for the assistant."
2845
+ };
2846
+ return {
2847
+ ok: true,
2848
+ writing: {
2849
+ id: asString(root.id) ?? asString(root.name) ?? "writing",
2850
+ name: asString(root.name) ?? "writing",
2851
+ title: asString(root.title),
2852
+ description: asString(root.description),
2853
+ anonymous: asBool(root.anonymous, false),
2854
+ model,
2855
+ provider,
2856
+ instructions,
2857
+ fragmentBlock: readFragmentBlock(root),
2858
+ placeholder: asString(root.placeholder)
2859
+ }
2860
+ };
2861
+ }
2862
+ //#endregion
2863
+ //#region ../lib/writing-resolve.ts
2864
+ const DEFAULT_SCHEMES = ["http:", "https:"];
2865
+ function schemeAllowed(url, allowed) {
2866
+ try {
2867
+ return allowed.includes(new URL(url).protocol);
2868
+ } catch {
2869
+ return false;
2870
+ }
2871
+ }
2872
+ /**
2873
+ * Resolve a leniently parsed writing activity into the runnable one. `url` is the
2874
+ * activity's own URL (the base for relative fragment refs); `fetcher` the network seam.
2875
+ */
2876
+ async function resolveWriting(writing, url, fetcher, opts = {}) {
2877
+ const resolved = await assembleFragmentPrompt(writing.fragmentBlock, url, fetcher, {
2878
+ validateLibraries: false,
2879
+ allowedSchemes: opts.allowedSchemes ?? DEFAULT_SCHEMES
2880
+ }, writing.instructions);
2881
+ if (!resolved.ok) return {
2882
+ ok: false,
2883
+ message: "This writing activity's prompt fragments could not be loaded."
2884
+ };
2885
+ return {
2886
+ ok: true,
2887
+ writing: {
2888
+ ...writing,
2889
+ fragmentBlock: EMPTY_FRAGMENT_BLOCK,
2890
+ instructions: resolved.prompt
2891
+ }
2892
+ };
2893
+ }
2894
+ /**
2895
+ * Fetch + lenient-parse + `resolveWriting`, all through the CALLER's fetcher — the
2896
+ * app-free counterpart of `loadWriting` (`lib/writing-fetch.ts`) used by the prompt dump
2897
+ * and the CLI, where there is no database and an activity may live on disk (`file:`).
2898
+ */
2899
+ async function loadWritingFrom(url, fetcher, opts = {}) {
2900
+ const allowedSchemes = opts.allowedSchemes ?? DEFAULT_SCHEMES;
2901
+ if (!schemeAllowed(url, allowedSchemes)) return {
2902
+ ok: false,
2903
+ message: `This writing activity's URL is not allowed: ${url}`
2904
+ };
2905
+ try {
2906
+ const res = await fetcher(url);
2907
+ if (!res.ok) return res.status === 404 ? {
2908
+ ok: false,
2909
+ message: "This writing activity could not be found."
2910
+ } : {
2911
+ ok: false,
2912
+ message: `This writing activity could not be loaded (HTTP ${res.status}).`
2913
+ };
2914
+ const parsed = parseWriting(await res.text());
2915
+ if (!parsed.ok) return parsed;
2916
+ return await resolveWriting(parsed.writing, url, fetcher, { allowedSchemes });
2917
+ } catch {
2918
+ return {
2919
+ ok: false,
2920
+ message: "This writing activity could not be loaded. Try again."
2921
+ };
2922
+ }
2923
+ }
2924
+ //#endregion
2925
+ //#region ../lib/prompt-dump.ts
2926
+ const PROMPT_KINDS = [
2927
+ "tutor",
2928
+ "quiz",
2929
+ "writing",
2930
+ "coding"
2931
+ ];
2932
+ /** Wrap a runtime loader's friendly message as the structured failure shape. */
2933
+ function loadFailed(message, url) {
2934
+ return {
2935
+ ok: false,
2936
+ errors: [error("ACTIVITY_LOAD_FAILED", message, { url })]
2937
+ };
2938
+ }
2939
+ /**
2940
+ * The verdict schema as plain JSON Schema, generated from the zod source of truth with
2941
+ * zod 4's native converter — the same mechanism `lib/schema-gen` uses for the authoring
2942
+ * schemas, so there is no second conversion story in the repo.
2943
+ */
2944
+ function verdictResponseJsonSchema() {
2945
+ return z.toJSONSchema(QUIZ_VERDICT_SCHEMA, { target: "draft-2020-12" });
2946
+ }
2947
+ /** The seam: one dumper per prompt-producing `FileKind`. */
2948
+ const promptDumpers = {
2949
+ tutor: { async dump(url, fetcher, opts = {}) {
2950
+ const result = await loadAndBuildTutorPrompt(url, fetcher, opts);
2951
+ if (!result.ok) return {
2952
+ ok: false,
2953
+ errors: result.errors
2954
+ };
2955
+ return {
2956
+ ok: true,
2957
+ dump: {
2958
+ kind: "tutor",
2959
+ id: result.id,
2960
+ llm: {
2961
+ provider: result.provider,
2962
+ model: result.model
2963
+ },
2964
+ system: result.prompt
2965
+ }
2966
+ };
2967
+ } },
2968
+ quiz: { async dump(url, fetcher, opts = {}) {
2969
+ const loaded = await loadQuizFrom(url, fetcher, { allowedSchemes: opts.allowedSchemes });
2970
+ if (!loaded.ok) return loadFailed(loaded.message, url);
2971
+ const quiz = loaded.quiz;
2972
+ return {
2973
+ ok: true,
2974
+ dump: {
2975
+ kind: "quiz",
2976
+ id: quiz.id,
2977
+ llm: {
2978
+ provider: quiz.provider,
2979
+ model: quiz.model
2980
+ },
2981
+ grading: {
2982
+ userMessageTemplate: QUIZ_ANSWER_MESSAGE_TEMPLATE,
2983
+ userMessagePhotosOnly: QUIZ_ANSWER_PHOTOS_ONLY_MESSAGE,
2984
+ responseSchema: verdictResponseJsonSchema(),
2985
+ questions: quiz.questions.map((question) => ({
2986
+ id: question.id,
2987
+ ...question.title ? { title: question.title } : {},
2988
+ system: buildGradingPrompt(question, quiz.instructionsPreamble),
2989
+ imageInput: effectiveImageInput(quiz, question)
2990
+ }))
2991
+ },
2992
+ discussion: {
2993
+ system: buildDiscussionInstructions(quiz),
2994
+ seedMessages: {
2995
+ question: QUIZ_SEED_QUESTION_TEMPLATE,
2996
+ answer: "{answer}",
2997
+ verdict: QUIZ_SEED_VERDICT_TEMPLATE
2998
+ },
2999
+ verdictLabels: {
3000
+ correct: verdictLabel("correct"),
3001
+ partial: verdictLabel("partial"),
3002
+ incorrect: verdictLabel("incorrect")
3003
+ }
3004
+ }
3005
+ }
3006
+ };
3007
+ } },
3008
+ writing: { async dump(url, fetcher, opts = {}) {
3009
+ const loaded = await loadWritingFrom(url, fetcher, { allowedSchemes: opts.allowedSchemes });
3010
+ if (!loaded.ok) return loadFailed(loaded.message, url);
3011
+ const writing = loaded.writing;
3012
+ return {
3013
+ ok: true,
3014
+ dump: {
3015
+ kind: "writing",
3016
+ id: writing.id,
3017
+ llm: {
3018
+ provider: writing.provider,
3019
+ model: writing.model
3020
+ },
3021
+ system: writing.instructions
3022
+ }
3023
+ };
3024
+ } },
3025
+ coding: { async dump(url, fetcher, opts = {}) {
3026
+ const loaded = await loadCodingFrom(url, fetcher, { allowedSchemes: opts.allowedSchemes });
3027
+ if (!loaded.ok) return loadFailed(loaded.message, url);
3028
+ const coding = loaded.coding;
3029
+ const upstream = buildUpstreamChatBody({ messages: [] }, {
3030
+ instructions: coding.instructions,
3031
+ model: coding.model
3032
+ });
3033
+ const system = (Array.isArray(upstream.messages) ? upstream.messages : []).find((m) => typeof m === "object" && m !== null && m.role === "system");
3034
+ return {
3035
+ ok: true,
3036
+ dump: {
3037
+ kind: "coding",
3038
+ id: coding.id,
3039
+ llm: {
3040
+ provider: coding.provider,
3041
+ model: coding.model
3042
+ },
3043
+ system: coding.instructions,
3044
+ upstreamSystemMessage: typeof system?.content === "string" ? system.content : ""
3045
+ }
3046
+ };
3047
+ } }
3048
+ };
3049
+ /** Dump the prompts of ONE activity file — the single entry point callers use. */
3050
+ function dumpPrompts(kind, url, fetcher, opts = {}) {
3051
+ return promptDumpers[kind].dump(url, fetcher, opts);
3052
+ }
3053
+ /**
3054
+ * The dump's prompts as a flat, kind-agnostic list — what a summary renderer walks so it
3055
+ * never has to switch on the kind. Order is stable (and, for a quiz, question order).
3056
+ */
3057
+ function promptSections(dump) {
3058
+ switch (dump.kind) {
3059
+ case "quiz": return [...dump.grading.questions.map((q) => ({
3060
+ name: `grading: ${q.id}`,
3061
+ text: q.system
3062
+ })), {
3063
+ name: "discussion",
3064
+ text: dump.discussion.system
3065
+ }];
3066
+ case "coding": return [{
3067
+ name: "system (injected upstream)",
3068
+ text: dump.system
3069
+ }];
3070
+ default: return [{
3071
+ name: "system",
3072
+ text: dump.system
3073
+ }];
3074
+ }
3075
+ }
3076
+ //#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
+ }
3118
+ /**
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).
3338
+ */
3339
+ const QuizFileRefSchema = z.strictObject({
3340
+ id: z.string().regex(/^[^./]+$/, { message: "Alias must not contain a dot or a slash" }).meta({
3341
+ pattern: "^[^./]+$",
3342
+ description: "Local alias for this included quiz. Prefixes every imported question id as \"<alias>/<id>\", so it may not contain a dot or a slash."
3343
+ }),
3344
+ url: FragmentFileRefSchema.shape.url.meta({
3345
+ pattern: "^(https?://|(?![A-Za-z][A-Za-z0-9+.-]*:).+)$",
3346
+ description: "HTTP(S) URL or relative path to the included quiz file."
3347
+ })
3348
+ }).meta({
3349
+ id: "quizFileRef",
3350
+ description: "A reference to another quiz file whose questions are included live."
3351
+ });
3352
+ /** An optional content image attached to a question (carries no secret). */
3353
+ const ImageRefSchema = z.strictObject({
3354
+ hosted: z.boolean().optional().meta({
3355
+ default: false,
3356
+ description: "When true, src is an app-hosted image NAME resolved server-side; otherwise src is an absolute URL or a path relative to the quiz's own URL."
3357
+ }),
3358
+ src: z.string().min(1).meta({ description: "The hosted image name (when hosted) or the image URL / relative path." }),
3359
+ alt: z.string().optional().meta({ description: "Accessible description shown if the image cannot be loaded." }),
3360
+ credit: z.string().optional().meta({ description: "Optional attribution (\"Content Credentials\") shown small below the image." })
3361
+ }).meta({
3362
+ id: "image",
3363
+ description: "An optional content image attached to a question."
3364
+ });
3365
+ /**
3366
+ * One question. `id` keys the per-question stats (must be unique — see
3367
+ * `lib/quiz-validate.ts`); `question` is the Markdown shown to the student;
3368
+ * `evaluation` is the server-only grading prompt.
3369
+ */
3370
+ const QuizQuestionSchema = z.strictObject({
3371
+ id: z.string().min(1).meta({ description: "Stable question id, unique within the quiz (the per-question stats key)." }),
3372
+ title: z.string().optional().meta({ description: "Optional short label for the stats table and progress display." }),
3373
+ question: z.string().min(1).meta({ description: "The Markdown shown to the student." }),
3374
+ evaluation: z.string().min(1).meta({ description: "The grading prompt. SERVER-ONLY: never sent to the browser, so it may embed the expected answer and the grading rubric." }),
3375
+ image: ImageRefSchema.optional(),
3376
+ imageInput: z.boolean().optional().meta({ description: "Overrides the quiz-level llm.imageInput for this question only (photo answers on/off)." })
3377
+ }).meta({
3378
+ id: "question",
3379
+ description: "One open-ended, LLM-graded quiz question."
3380
+ });
3381
+ const QuizYamlSchema = z.strictObject({
3382
+ id: z.string().min(1).meta({ description: "Short machine-readable quiz id, e.g. countries-basics. Used as the per-quiz identity." }),
3383
+ name: z.string().optional().meta({ description: "Optional human-readable quiz title (used as a label)." }),
3384
+ title: z.string().optional().meta({ description: "Optional greeting shown to students on the welcome screen instead of the default message." }),
3385
+ description: z.string().optional().meta({ description: "Optional description shown to students below the welcome greeting (Markdown)." }),
3386
+ anonymous: z.boolean().optional().meta({
3387
+ default: true,
2283
3388
  description: "Quizzes are anonymous by default: answers are recorded for aggregate stats but not linked to a student. Set to false to attribute each attempt to the signed-in student."
2284
3389
  }),
2285
3390
  shuffle: z.boolean().optional().meta({
@@ -2466,118 +3571,37 @@ async function loadAndCheckQuiz(url, fetchImpl, opts = {}) {
2466
3571
  errors: [valid.error],
2467
3572
  warnings: []
2468
3573
  };
2469
- const checked = checkQuizParsed(valid.data);
2470
- if (!checked.ok) return checked;
2471
- const assembled = await assembleFragmentPrompts({
2472
- fragment_files: valid.data.fragment_files,
2473
- text_files: valid.data.text_files
2474
- }, url, fetchImpl, {
2475
- allowedSchemes: opts.allowedSchemes,
2476
- validateLibraries: opts.validateLibraries ?? true
2477
- }, [valid.data.instructions ?? "", valid.data.discussion?.instructions ?? ""]);
2478
- const warnings = [...checked.warnings, ...assembled.warnings];
2479
- if (!assembled.ok) return {
2480
- ok: false,
2481
- errors: assembled.errors,
2482
- warnings
2483
- };
2484
- const includes = await Promise.all(valid.data.quiz_files.map((ref) => checkInclude(ref, url, fetchImpl, opts)));
2485
- const includeErrors = [];
2486
- let importedCount = 0;
2487
- for (const include of includes) {
2488
- warnings.push(...include.warnings);
2489
- if (include.ok) importedCount += include.questionCount;
2490
- else includeErrors.push(...include.errors);
2491
- }
2492
- if (includeErrors.length > 0) return {
2493
- ok: false,
2494
- errors: includeErrors,
2495
- warnings
2496
- };
2497
- return {
2498
- ...checked,
2499
- questionCount: checked.questionCount + importedCount,
2500
- warnings
2501
- };
2502
- }
2503
- //#endregion
2504
- //#region ../lib/tutors/schemas.ts
2505
- /**
2506
- * An example question offered to students on the welcome screen: the `title` is
2507
- * the clickable label, the `question` is the full text placed into the chat
2508
- * input on click. Tutors may define any number; the UI samples at most 5.
2509
- */
2510
- const ExampleQuestionSchema = z.strictObject({
2511
- title: z.string().min(1).meta({ description: "Short clickable label shown on the welcome screen." }),
2512
- question: z.string().min(1).meta({ description: "Full question text. Shown as a tooltip and placed into the chat input on click." })
2513
- }).meta({
2514
- id: "exampleQuestion",
2515
- description: "An example question shown on the welcome screen."
2516
- });
2517
- const TutorSchema = z.strictObject({
2518
- id: z.string().meta({ description: "Short machine-readable tutor id, e.g. fractions-de." }),
2519
- name: z.string().meta({ description: "Human-readable tutor title." }),
2520
- title: z.string().optional().meta({ description: "Optional greeting shown to students on the empty chat instead of the default welcome message." }),
2521
- description: z.string().meta({ description: "Short description of what this tutor does. Shown to students below the welcome greeting." }),
2522
- exampleQuestions: z.array(ExampleQuestionSchema).optional().meta({ description: "Optional example questions shown to students below the description on the empty chat. Clicking one puts the question text into the chat input. At most 5 are shown; with more, a random 5 are picked per page load." }),
2523
- anonymous: z.boolean().optional().meta({
2524
- default: true,
2525
- description: "Chats are anonymous by default: no link between the signed-in student and their chat is stored. Set to false to record which student each chat belongs to."
2526
- }),
2527
- llm: z.strictObject({
2528
- model: z.string().meta({ description: "Model used for this tutor." }),
2529
- provider: providerSchema,
2530
- imageInput: z.boolean().optional().meta({
2531
- default: true,
2532
- description: "Image uploads are enabled by default. Set to false to hide the upload UI for text-only tutors or non-vision-capable models."
2533
- })
2534
- }).meta({
2535
- id: "llm",
2536
- description: "The model and provider that back this tutor."
2537
- }),
2538
- prompt: z.strictObject({
2539
- fragment_files: z.array(FragmentFileRefSchema).default([]).meta({ description: "Optional fragment libraries used by this tutor." }),
2540
- text_files: z.array(TextFileRefSchema).default([]).meta({ description: "Optional plain-text files (markdown / source) embedded verbatim via {{file \"alias\"}} markers." }),
2541
- tutor_instructions: z.string().meta({ description: "The tutor's system prompt. When any fragment_files or text_files are declared this is a Handlebars template: place fragments inline with {{fragment \"alias.id\" key=\"v\"}} markers and embed text files with {{file \"alias\"}} (optionally {{file \"alias\" from=10 to=40}} for a line range; escape a literal {{ as \\{{). For single-file tutors it is the whole prompt." })
2542
- }).meta({
2543
- id: "prompt",
2544
- description: "The tutor system prompt: a host template with inline fragment markers."
2545
- })
2546
- });
2547
- //#endregion
2548
- //#region ../lib/tutors/load.ts
2549
- async function loadAndBuildTutorPrompt(url, fetchImpl, opts = {}) {
2550
- const warnings = [];
2551
- const tutorYaml = await loadYaml(url, fetchImpl, opts);
2552
- if (!tutorYaml.ok) return {
2553
- ok: false,
2554
- errors: [tutorYaml.error],
2555
- warnings
2556
- };
2557
- const tutorValid = validate(tutorYaml.value, TutorSchema, "TUTOR_SCHEMA_ERROR", url);
2558
- if (!tutorValid.ok) return {
2559
- ok: false,
2560
- errors: [tutorValid.error],
2561
- warnings
2562
- };
2563
- const tutor = tutorValid.data;
2564
- const assembled = await assembleFragmentPrompt(tutor.prompt, url, fetchImpl, opts, tutor.prompt.tutor_instructions);
2565
- warnings.push(...assembled.warnings);
3574
+ const checked = checkQuizParsed(valid.data);
3575
+ if (!checked.ok) return checked;
3576
+ const assembled = await assembleFragmentPrompts({
3577
+ fragment_files: valid.data.fragment_files,
3578
+ text_files: valid.data.text_files
3579
+ }, url, fetchImpl, {
3580
+ allowedSchemes: opts.allowedSchemes,
3581
+ validateLibraries: opts.validateLibraries ?? true
3582
+ }, [valid.data.instructions ?? "", valid.data.discussion?.instructions ?? ""]);
3583
+ const warnings = [...checked.warnings, ...assembled.warnings];
2566
3584
  if (!assembled.ok) return {
2567
3585
  ok: false,
2568
3586
  errors: assembled.errors,
2569
3587
  warnings
2570
3588
  };
3589
+ const includes = await Promise.all(valid.data.quiz_files.map((ref) => checkInclude(ref, url, fetchImpl, opts)));
3590
+ const includeErrors = [];
3591
+ let importedCount = 0;
3592
+ for (const include of includes) {
3593
+ warnings.push(...include.warnings);
3594
+ if (include.ok) importedCount += include.questionCount;
3595
+ else includeErrors.push(...include.errors);
3596
+ }
3597
+ if (includeErrors.length > 0) return {
3598
+ ok: false,
3599
+ errors: includeErrors,
3600
+ warnings
3601
+ };
2571
3602
  return {
2572
- ok: true,
2573
- prompt: assembled.prompt,
2574
- model: tutor.llm.model,
2575
- provider: tutor.llm.provider,
2576
- imageInput: tutor.llm.imageInput ?? true,
2577
- anonymous: tutor.anonymous ?? true,
2578
- title: tutor.title,
2579
- description: tutor.description,
2580
- exampleQuestions: tutor.exampleQuestions ?? [],
3603
+ ...checked,
3604
+ questionCount: checked.questionCount + importedCount,
2581
3605
  warnings
2582
3606
  };
2583
3607
  }
@@ -2663,173 +3687,6 @@ async function loadAndCheckWriting(url, fetchImpl, opts = {}) {
2663
3687
  };
2664
3688
  }
2665
3689
  //#endregion
2666
- //#region src/file-fetcher.ts
2667
- const cliFetcher = async (url) => {
2668
- if (url.startsWith("file:")) try {
2669
- const text = await readFile(fileURLToPath(url), "utf8");
2670
- return {
2671
- ok: true,
2672
- status: 200,
2673
- text: async () => text
2674
- };
2675
- } catch {
2676
- return {
2677
- ok: false,
2678
- status: 404,
2679
- text: async () => ""
2680
- };
2681
- }
2682
- return defaultFetcher(url);
2683
- };
2684
- //#endregion
2685
- //#region src/format.ts
2686
- const useColor = process.stdout.isTTY && !process.env.NO_COLOR;
2687
- const paint = (code, s) => useColor ? `\x1b[${code}m${s}\x1b[0m` : s;
2688
- const green = (s) => paint("32", s);
2689
- const red = (s) => paint("31", s);
2690
- const yellow = (s) => paint("33", s);
2691
- const dim = (s) => paint("2", s);
2692
- /** Append the context fields an error/warning carries, when present. */
2693
- function context(item) {
2694
- const parts = [];
2695
- if (item.fileAlias) parts.push(`file=${item.fileAlias}`);
2696
- if (item.fragmentId) parts.push(`fragment=${item.fragmentId}`);
2697
- if ("questionId" in item && item.questionId) parts.push(`question=${item.questionId}`);
2698
- if (item.variable) parts.push(`variable=${item.variable}`);
2699
- if ("url" in item && item.url) parts.push(`url=${item.url}`);
2700
- if ("expectedType" in item && item.expectedType) parts.push(`expected=${item.expectedType}`);
2701
- if ("actualType" in item && item.actualType) parts.push(`actual=${item.actualType}`);
2702
- return parts.length ? dim(` (${parts.join(", ")})`) : "";
2703
- }
2704
- function renderWarnings(warnings) {
2705
- return warnings.map((w) => ` ${yellow("⚠")} ${yellow(w.code)} ${w.message}${context(w)}`);
2706
- }
2707
- /**
2708
- * Render each error as a line, with any flattened Zod schema-issue detail
2709
- * indented beneath it — so a generic "Document does not match the expected
2710
- * structure" is followed by the actual field paths (e.g. `Unrecognized key:
2711
- * "nae"`), matching what the web UI shows.
2712
- */
2713
- function renderErrors(errors) {
2714
- const lines = [];
2715
- for (const e of errors) {
2716
- lines.push(` ${red("✗")} ${red(e.code)} ${e.message}${context(e)}`);
2717
- if (e.zodIssues) for (const issue of formatZodIssues(e.zodIssues)) lines.push(` ${dim(issue)}`);
2718
- }
2719
- return lines;
2720
- }
2721
- function formatResult(result, source) {
2722
- const lines = [];
2723
- if (result.ok) {
2724
- lines.push(green(`✔ Valid tutor`) + dim(` — ${source}`));
2725
- lines.push(` model: ${result.model}`);
2726
- lines.push(` system prompt: ${result.prompt.length} chars`);
2727
- lines.push(` imageInput: ${result.imageInput} anonymous: ${result.anonymous}` + (result.exampleQuestions.length ? ` exampleQuestions: ${result.exampleQuestions.length}` : ""));
2728
- if (result.warnings.length) {
2729
- lines.push("");
2730
- lines.push(yellow(`${result.warnings.length} warning(s):`));
2731
- lines.push(...renderWarnings(result.warnings));
2732
- }
2733
- return lines.join("\n");
2734
- }
2735
- lines.push(red(`✘ Invalid tutor`) + dim(` — ${source}`));
2736
- lines.push("");
2737
- lines.push(red(`${result.errors.length} error(s):`));
2738
- lines.push(...renderErrors(result.errors));
2739
- if (result.warnings.length) {
2740
- lines.push("");
2741
- lines.push(yellow(`${result.warnings.length} warning(s):`));
2742
- lines.push(...renderWarnings(result.warnings));
2743
- }
2744
- return lines.join("\n");
2745
- }
2746
- /** Same renderer, for a standalone fragment-FILE check (`--kind fragment`). */
2747
- function formatFragmentResult(result, source) {
2748
- const lines = [];
2749
- if (result.ok) {
2750
- lines.push(green(`✔ Valid fragment file`) + dim(` — ${source}`));
2751
- lines.push(` id: ${result.fragmentFileId}`);
2752
- lines.push(` fragments: ${result.fragmentIds.length}` + (result.fragmentIds.length ? ` (${result.fragmentIds.join(", ")})` : ""));
2753
- if (result.warnings.length) {
2754
- lines.push("");
2755
- lines.push(yellow(`${result.warnings.length} warning(s):`));
2756
- lines.push(...renderWarnings(result.warnings));
2757
- }
2758
- return lines.join("\n");
2759
- }
2760
- lines.push(red(`✘ Invalid fragment file`) + dim(` — ${source}`));
2761
- lines.push("");
2762
- lines.push(red(`${result.errors.length} error(s):`));
2763
- lines.push(...renderErrors(result.errors));
2764
- if (result.warnings.length) {
2765
- lines.push("");
2766
- lines.push(yellow(`${result.warnings.length} warning(s):`));
2767
- lines.push(...renderWarnings(result.warnings));
2768
- }
2769
- return lines.join("\n");
2770
- }
2771
- /**
2772
- * Shared tail for the quiz/writing renderers: on failure, the error list (with any
2773
- * flattened Zod issues); plus any warnings on either branch.
2774
- */
2775
- function renderFailureAndWarnings(result, label, source) {
2776
- const lines = [red(`✘ Invalid ${label}`) + dim(` — ${source}`), ""];
2777
- lines.push(red(`${result.errors.length} error(s):`));
2778
- lines.push(...renderErrors(result.errors));
2779
- if (result.warnings.length) {
2780
- lines.push("");
2781
- lines.push(yellow(`${result.warnings.length} warning(s):`));
2782
- lines.push(...renderWarnings(result.warnings));
2783
- }
2784
- return lines.join("\n");
2785
- }
2786
- /** Renderer for a quiz check (`--kind quiz`). */
2787
- function formatQuizResult(result, source) {
2788
- if (!result.ok) return renderFailureAndWarnings(result, "quiz", source);
2789
- const lines = [green(`✔ Valid quiz`) + dim(` — ${source}`)];
2790
- lines.push(` id: ${result.quizId}`);
2791
- lines.push(` model: ${result.model}`);
2792
- lines.push(` questions: ${result.questionCount} anonymous: ${result.anonymous}`);
2793
- if (result.warnings.length) {
2794
- lines.push("");
2795
- lines.push(yellow(`${result.warnings.length} warning(s):`));
2796
- lines.push(...renderWarnings(result.warnings));
2797
- }
2798
- return lines.join("\n");
2799
- }
2800
- /** Renderer for a writing-activity check (`--kind writing`). */
2801
- function formatWritingResult(result, source) {
2802
- if (!result.ok) return renderFailureAndWarnings(result, "writing activity", source);
2803
- const lines = [green(`✔ Valid writing activity`) + dim(` — ${source}`)];
2804
- lines.push(` id: ${result.writingId}`);
2805
- lines.push(` model: ${result.model}`);
2806
- lines.push(` anonymous: ${result.anonymous}`);
2807
- if (result.warnings.length) {
2808
- lines.push("");
2809
- lines.push(yellow(`${result.warnings.length} warning(s):`));
2810
- lines.push(...renderWarnings(result.warnings));
2811
- }
2812
- return lines.join("\n");
2813
- }
2814
- /**
2815
- * Renderer for a coding-activity check (`--kind coding`). Coding is ALWAYS anonymous
2816
- * (the API path carries no per-student identity), so — unlike quiz/writing — that is
2817
- * shown as a fixed note, not a per-file value.
2818
- */
2819
- function formatCodingResult(result, source) {
2820
- if (!result.ok) return renderFailureAndWarnings(result, "coding activity", source);
2821
- const lines = [green(`✔ Valid coding activity`) + dim(` — ${source}`)];
2822
- lines.push(` id: ${result.codingId}`);
2823
- lines.push(` model: ${result.model}`);
2824
- lines.push(` anonymous: true ${dim("(always — the API path carries no identity)")}`);
2825
- if (result.warnings.length) {
2826
- lines.push("");
2827
- lines.push(yellow(`${result.warnings.length} warning(s):`));
2828
- lines.push(...renderWarnings(result.warnings));
2829
- }
2830
- return lines.join("\n");
2831
- }
2832
- //#endregion
2833
3690
  //#region src/commands/validate.ts
2834
3691
  /** Every kind the `--kind` flag accepts (used for the option help + guard). */
2835
3692
  const VALIDATE_KINDS = [
@@ -2926,6 +3783,88 @@ function formatOutcome(outcome, source) {
2926
3783
  }
2927
3784
  }
2928
3785
  //#endregion
3786
+ //#region src/commands/prompts.ts
3787
+ /**
3788
+ * The command's pure core: dump the prompts of a local file or public URL. `file:` is
3789
+ * allowed in addition to http(s) so an on-disk activity dumps (the web app deliberately
3790
+ * stays http(s)-only), and relative `fragment_files` / `quiz_files` resolve against the
3791
+ * activity's own location.
3792
+ *
3793
+ * This is the RUNTIME path — the lenient loaders the app runs when a student opens the
3794
+ * activity — so the output is what the model really receives. Use `validate` for the
3795
+ * strict authoring gate.
3796
+ */
3797
+ function runPrompts(pathOrUrl, kind) {
3798
+ return dumpPrompts(kind, toUrl(pathOrUrl), cliFetcher, { allowedSchemes: [
3799
+ "http:",
3800
+ "https:",
3801
+ "file:"
3802
+ ] });
3803
+ }
3804
+ function registerPrompts(program) {
3805
+ program.command("prompts").description("Print the exact LLM prompts a tutor (default), quiz, writing or coding YAML produces").argument("<pathOrUrl>", "path to a tutor, quiz, writing or coding YAML file, or a public http(s) URL").option("--kind <kind>", `what the file is: ${PROMPT_KINDS.map((k) => `'${k}'`).join(", ")} ('tutor' is the default)`, "tutor").option("--json", "print the full prompt dump as JSON").addHelpText("after", `
3806
+ Examples:
3807
+ # The tutor's assembled system prompt (fragments resolved in place)
3808
+ $ novedu-cli prompts ./activities/tutors/my-tutor.yaml
3809
+
3810
+ # Every grading prompt of a quiz, plus its discussion prompt, as JSON
3811
+ $ novedu-cli prompts ./activities/quizzes/my-quiz.yaml --kind quiz --json
3812
+
3813
+ # A writing activity's coach prompt / a coding activity's injected system prompt
3814
+ $ novedu-cli prompts ./activities/writings/my-writing.yaml --kind writing
3815
+ $ novedu-cli prompts ./activities/coding/my-coding.yaml --kind coding
3816
+
3817
+ # One question's grading prompt, straight out of the JSON dump
3818
+ $ novedu-cli prompts ./my-quiz.yaml --kind quiz --json | jq -r '.grading.questions[0].system'`).action(async (pathOrUrl, options) => {
3819
+ if (options.kind !== void 0 && !PROMPT_KINDS.includes(options.kind)) {
3820
+ console.error(`Invalid --kind "${options.kind}": expected ${PROMPT_KINDS.map((k) => `"${k}"`).join(", ")}.`);
3821
+ process.exitCode = 1;
3822
+ return;
3823
+ }
3824
+ const result = await runPrompts(pathOrUrl, options.kind ?? "tutor");
3825
+ if (!result.ok) {
3826
+ console.error(JSON.stringify({ errors: result.errors }, null, 2));
3827
+ process.exitCode = 1;
3828
+ return;
3829
+ }
3830
+ if (options.json) console.log(JSON.stringify(result.dump, null, 2));
3831
+ else console.log(formatPromptDump(result.dump, promptSections(result.dump), pathOrUrl));
3832
+ process.exitCode = 0;
3833
+ });
3834
+ }
3835
+ //#endregion
3836
+ //#region src/commands/reports.ts
3837
+ const SERVER_OPTION = ["--server <url>", "Novedu server base URL (defaults to the NOVEDU_SERVER env var, then production)"];
3838
+ function registerReports(program) {
3839
+ const reports = program.command("reports").description("Triage student reports on the Novedu server");
3840
+ reports.command("list").description("List reports (defaults to open reports on your own codes, like the web inbox)").option("--status <status>", "open (default), resolved or all").option("--reaction <reaction>", "filter by reaction: good, omg, bad or holysh").option("--search <q>", "contains-filter over description, reporter, code and note").option("--all", "include reports on codes created by other teachers").option(...SERVER_OPTION).action(async (options) => {
3841
+ const params = new URLSearchParams();
3842
+ if (options.status) params.set("status", options.status);
3843
+ if (options.reaction) params.set("reaction", options.reaction);
3844
+ if (options.search) params.set("q", options.search);
3845
+ if (options.all) params.set("mine", "0");
3846
+ const query = params.toString();
3847
+ await runApiRequest({
3848
+ server: options.server,
3849
+ path: `/api/reports${query ? `?${query}` : ""}`
3850
+ });
3851
+ });
3852
+ reports.command("show <id>").description("Show one report; a chat report embeds its conversation transcript").option(...SERVER_OPTION).action(async (id, options) => {
3853
+ await runApiRequest({
3854
+ server: options.server,
3855
+ path: `/api/reports/${encodeURIComponent(id)}`
3856
+ });
3857
+ });
3858
+ reports.command("resolve <id...>").description("Resolve one or more reports by id (bulk, in a single request)").option(...SERVER_OPTION).action(async (ids, options) => {
3859
+ await runApiRequest({
3860
+ server: options.server,
3861
+ path: "/api/reports/resolve",
3862
+ method: "POST",
3863
+ body: { ids }
3864
+ });
3865
+ });
3866
+ }
3867
+ //#endregion
2929
3868
  //#region src/commands/whoami.ts
2930
3869
  function registerWhoami(program) {
2931
3870
  program.command("whoami").description("Show who is signed in by calling the Novedu server's /api/me").option("--server <url>", "Novedu server base URL (defaults to the NOVEDU_SERVER env var, then production)").action(async (options) => {
@@ -2966,6 +3905,7 @@ const { version } = JSON.parse(readFileSync(new URL("../package.json", import.me
2966
3905
  const program = new Command();
2967
3906
  program.name("novedu-cli").description("Command-line companion for the Novedu chat app").version(version);
2968
3907
  registerValidate(program);
3908
+ registerPrompts(program);
2969
3909
  registerLogin(program);
2970
3910
  registerLogout(program);
2971
3911
  registerWhoami(program);