@novedu/cli 0.17.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.
- package/README.md +134 -1
- package/dist/main.js +1857 -343
- package/package.json +2 -2
package/dist/main.js
CHANGED
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { Command } from "commander";
|
|
4
|
+
import { readFile, writeFile } from "node:fs/promises";
|
|
5
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
4
6
|
import { spawn } from "node:child_process";
|
|
5
7
|
import { createServer } from "node:http";
|
|
6
8
|
import { homedir } from "node:os";
|
|
7
|
-
import { dirname, join, resolve } from "node:path";
|
|
8
9
|
import { CryptoProvider, PublicClientApplication } from "@azure/msal-node";
|
|
9
|
-
import {
|
|
10
|
-
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
11
|
-
import Handlebars from "handlebars";
|
|
12
|
-
import { parse } from "yaml";
|
|
10
|
+
import { parse, stringify } from "yaml";
|
|
13
11
|
import { z } from "zod";
|
|
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";
|
|
@@ -174,8 +174,16 @@ async function acquireByDeviceCode(pca, onMessage) {
|
|
|
174
174
|
* The one call every API command makes: a silently-acquired access token for
|
|
175
175
|
* the Authorization header. Throws NotSignedInError when interactive login is
|
|
176
176
|
* required first.
|
|
177
|
+
*
|
|
178
|
+
* `NOVEDU_TOKEN` short-circuits the MSAL cache with a caller-supplied bearer
|
|
179
|
+
* token. It exists for TESTS and CI (the CLI integration suite runs the real
|
|
180
|
+
* binary against a fake API, with no browser to sign in) — the token is still
|
|
181
|
+
* validated by the server on every request, so this weakens nothing; it only
|
|
182
|
+
* removes the interactive step. Not a substitute for `login` in normal use.
|
|
177
183
|
*/
|
|
178
184
|
async function getAccessToken() {
|
|
185
|
+
const override = process.env.NOVEDU_TOKEN?.trim();
|
|
186
|
+
if (override) return override;
|
|
179
187
|
const result = await acquireSilent(buildPca());
|
|
180
188
|
if (!result) throw new NotSignedInError();
|
|
181
189
|
return result.accessToken;
|
|
@@ -206,20 +214,30 @@ function failJson(value) {
|
|
|
206
214
|
* network, non-2xx) it prints the JSON error to stderr per the contract —
|
|
207
215
|
* server error bodies (`{ message }` — incl. the generic 401/403 — or
|
|
208
216
|
* `{ errors }`) passed through VERBATIM — marks the process failed, and
|
|
209
|
-
* returns `{ ok: false }
|
|
210
|
-
* printing, so multi-step commands (`images upload`)
|
|
211
|
-
* responses silently. No client-side pre-validation:
|
|
212
|
-
* identical pipeline; offline checking is the `validate`
|
|
217
|
+
* returns `{ ok: false, error }` with that same payload. On success it returns
|
|
218
|
+
* the parsed payload WITHOUT printing, so multi-step commands (`images upload`)
|
|
219
|
+
* can consume intermediate responses silently. No client-side pre-validation:
|
|
220
|
+
* the server runs the identical pipeline; offline checking is the `validate`
|
|
221
|
+
* command's job.
|
|
222
|
+
*
|
|
223
|
+
* `quiet` suppresses both the stderr print and the exit-code marking and hands
|
|
224
|
+
* the failure payload back instead — for commands that make MANY requests and
|
|
225
|
+
* report the outcome themselves (`codes sync`, where one entry's rejection must
|
|
226
|
+
* not abort the run).
|
|
213
227
|
*/
|
|
214
228
|
async function performApiRequest(options) {
|
|
229
|
+
const fail = (value) => {
|
|
230
|
+
if (!options.quiet) failJson(value);
|
|
231
|
+
return {
|
|
232
|
+
ok: false,
|
|
233
|
+
error: value
|
|
234
|
+
};
|
|
235
|
+
};
|
|
215
236
|
let token;
|
|
216
237
|
try {
|
|
217
238
|
token = await getAccessToken();
|
|
218
239
|
} catch (error) {
|
|
219
|
-
if (error instanceof NotSignedInError) {
|
|
220
|
-
failJson({ message: error.message });
|
|
221
|
-
return { ok: false };
|
|
222
|
-
}
|
|
240
|
+
if (error instanceof NotSignedInError) return fail({ message: error.message });
|
|
223
241
|
throw error;
|
|
224
242
|
}
|
|
225
243
|
const server = resolveServerUrl(options.server);
|
|
@@ -234,8 +252,7 @@ async function performApiRequest(options) {
|
|
|
234
252
|
body: options.body === void 0 ? void 0 : JSON.stringify(options.body)
|
|
235
253
|
});
|
|
236
254
|
} catch (error) {
|
|
237
|
-
|
|
238
|
-
return { ok: false };
|
|
255
|
+
return fail({ message: `Could not reach ${server}: ${error instanceof Error ? error.message : error}` });
|
|
239
256
|
}
|
|
240
257
|
let payload;
|
|
241
258
|
try {
|
|
@@ -243,10 +260,7 @@ async function performApiRequest(options) {
|
|
|
243
260
|
} catch {
|
|
244
261
|
payload = void 0;
|
|
245
262
|
}
|
|
246
|
-
if (!response.ok) {
|
|
247
|
-
failJson(payload ?? { message: `${server} rejected the request: HTTP ${response.status}` });
|
|
248
|
-
return { ok: false };
|
|
249
|
-
}
|
|
263
|
+
if (!response.ok) return fail(payload ?? { message: `${server} rejected the request: HTTP ${response.status}` });
|
|
250
264
|
return {
|
|
251
265
|
ok: true,
|
|
252
266
|
payload
|
|
@@ -262,8 +276,570 @@ async function runApiRequest(options) {
|
|
|
262
276
|
if (result.ok) printJson(result.payload ?? null);
|
|
263
277
|
}
|
|
264
278
|
//#endregion
|
|
279
|
+
//#region ../lib/llm/provider.ts
|
|
280
|
+
const LLM_PROVIDERS = ["SCCH", "Azure Foundry"];
|
|
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
|
+
}
|
|
286
|
+
//#endregion
|
|
287
|
+
//#region ../lib/registry-schema.ts
|
|
288
|
+
/** The fixed group names and the code module each one mints for. */
|
|
289
|
+
const GROUP_MODULES = {
|
|
290
|
+
quizzes: "quiz",
|
|
291
|
+
tutors: "tutor",
|
|
292
|
+
writing: "writing",
|
|
293
|
+
coding: "coding"
|
|
294
|
+
};
|
|
295
|
+
const GROUP_NAMES = Object.keys(GROUP_MODULES);
|
|
296
|
+
/** Registry keys share the lock file's flat namespace, so they stay URL/YAML-plain. */
|
|
297
|
+
const KEY_PATTERN = /^[a-z0-9][a-z0-9-]*$/;
|
|
298
|
+
const EXPLICIT_OFFSET = /(?:Z|[+-]\d{2}:?\d{2})$/;
|
|
299
|
+
function timestampField(field) {
|
|
300
|
+
return z.string().refine((value) => EXPLICIT_OFFSET.test(value) && !Number.isNaN(Date.parse(value)), `${field} must be an ISO 8601 datetime with an explicit offset or Z, e.g. 2026-09-01T08:00:00+02:00`).refine((value) => {
|
|
301
|
+
const parsed = Date.parse(value);
|
|
302
|
+
return Number.isNaN(parsed) || parsed % 1e3 === 0;
|
|
303
|
+
}, `${field} must not carry sub-second precision — the server stores whole seconds`);
|
|
304
|
+
}
|
|
305
|
+
const providerField = z.enum(LLM_PROVIDERS, { error: "must be \"SCCH\" or \"Azure Foundry\"" });
|
|
306
|
+
/**
|
|
307
|
+
* One registry entry. Unknown extra properties are ACCEPTED and ignored so authors can
|
|
308
|
+
* annotate freely and a newer registry keeps working with an older CLI — which is why
|
|
309
|
+
* this is a `looseObject` and the generated JSON Schema does NOT flag a misspelled field.
|
|
310
|
+
*/
|
|
311
|
+
const RegistryEntrySchema = z.looseObject({
|
|
312
|
+
file: z.string().trim().min(1).optional().meta({ description: "Path to the activity YAML, resolved against `base-url`. Give exactly one of `file` or `url`." }),
|
|
313
|
+
url: z.string().trim().min(1).optional().meta({ description: "Absolute http(s) URL of the activity YAML. Give exactly one of `file` or `url`." }),
|
|
314
|
+
start: timestampField("start").optional().meta({ description: "Start of the code's validity window, ISO 8601 with an explicit offset or Z (e.g. 2026-09-01T08:00:00+02:00), whole seconds." }),
|
|
315
|
+
end: timestampField("end").optional().meta({ description: "End of the code's validity window, same format as `start`, and must be after it." }),
|
|
316
|
+
note: z.string().trim().max(200, `note must be at most 200 characters`).optional().meta({ description: `Note shown in the codes list, at most 200 characters. No effect on behaviour.` }),
|
|
317
|
+
llm: z.looseObject({
|
|
318
|
+
provider: providerField.meta({ description: "LLM provider override for this code. Required when `llm` is present." }),
|
|
319
|
+
model: z.string().trim().min(1).max(256).meta({ description: "Model id (for Azure Foundry, the deployment name). Required when `llm` is present." })
|
|
320
|
+
}).optional().meta({ description: "Per-code LLM override replacing the activity YAML's own `llm:`. Provider and model must be given together." })
|
|
321
|
+
}).refine((entry) => entry.file === void 0 !== (entry.url === void 0), "give exactly one of `file` (relative to base-url) or `url` (absolute)").meta({
|
|
322
|
+
id: "registryEntry",
|
|
323
|
+
description: "One activity: where its YAML lives, plus the parameters its code is minted with."
|
|
324
|
+
});
|
|
325
|
+
/** A group holds `key: entry` pairs; an empty group (`quizzes:` with nothing under it) is fine. */
|
|
326
|
+
function groupOf(group) {
|
|
327
|
+
return z.record(z.string().regex(KEY_PATTERN).max(64), RegistryEntrySchema).nullable().optional().meta({ description: `Activities minted as \`${GROUP_MODULES[group]}\` codes, keyed by the name your material references.` });
|
|
328
|
+
}
|
|
329
|
+
z.looseObject({
|
|
330
|
+
"base-url": z.string().optional().meta({ description: "Base URL each entry's `file` is resolved against. Must end with a slash. Only needed when an entry uses `file`." }),
|
|
331
|
+
activities: z.strictObject(Object.fromEntries(GROUP_NAMES.map((group) => [group, groupOf(group)]))).meta({ description: "The activities, grouped by the kind of code each one is minted as." })
|
|
332
|
+
});
|
|
333
|
+
//#endregion
|
|
334
|
+
//#region src/registry.ts
|
|
335
|
+
const rootSchema = z.looseObject({
|
|
336
|
+
"base-url": z.string().optional(),
|
|
337
|
+
activities: z.record(z.string(), z.unknown(), { error: "activities must be a mapping of activity groups" })
|
|
338
|
+
});
|
|
339
|
+
function issue(code, path, message) {
|
|
340
|
+
return {
|
|
341
|
+
code,
|
|
342
|
+
path,
|
|
343
|
+
message
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
/** Turns zod's issue list into registry issues rooted at `basePath`. */
|
|
347
|
+
function schemaIssues(error, basePath) {
|
|
348
|
+
return error.issues.map((item) => issue("REGISTRY_SCHEMA_ERROR", [basePath, ...item.path.map(String)].filter(Boolean).join("."), item.message));
|
|
349
|
+
}
|
|
350
|
+
/** A plain YAML mapping — the shape both a group and an entry must have. */
|
|
351
|
+
function isMapping(value) {
|
|
352
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
353
|
+
}
|
|
354
|
+
/**
|
|
355
|
+
* Parses and validates a registry document, resolving every entry's file URL.
|
|
356
|
+
* Returns ALL issues found rather than the first — a registry is edited by hand,
|
|
357
|
+
* so one run should surface every problem.
|
|
358
|
+
*/
|
|
359
|
+
function parseRegistry(text) {
|
|
360
|
+
let document;
|
|
361
|
+
try {
|
|
362
|
+
document = parse(text);
|
|
363
|
+
} catch (error) {
|
|
364
|
+
return {
|
|
365
|
+
ok: false,
|
|
366
|
+
errors: [issue("REGISTRY_PARSE_ERROR", "", `Invalid YAML: ${error instanceof Error ? error.message : String(error)}`)]
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
const root = rootSchema.safeParse(document ?? {});
|
|
370
|
+
if (!root.success) return {
|
|
371
|
+
ok: false,
|
|
372
|
+
errors: schemaIssues(root.error, "")
|
|
373
|
+
};
|
|
374
|
+
const errors = [];
|
|
375
|
+
const entries = [];
|
|
376
|
+
const seenKeys = /* @__PURE__ */ new Map();
|
|
377
|
+
const baseUrlText = root.data["base-url"];
|
|
378
|
+
let baseUrl;
|
|
379
|
+
if (baseUrlText !== void 0) {
|
|
380
|
+
const parsed = safeHttpUrl(baseUrlText);
|
|
381
|
+
if (!parsed) errors.push(issue("REGISTRY_SCHEMA_ERROR", "base-url", "must be an http(s) URL"));
|
|
382
|
+
else if (!parsed.endsWith("/")) errors.push(issue("REGISTRY_SCHEMA_ERROR", "base-url", "must end with a slash — it is resolved against, not concatenated with, each entry's `file`"));
|
|
383
|
+
else baseUrl = parsed;
|
|
384
|
+
}
|
|
385
|
+
for (const [groupName, groupValue] of Object.entries(root.data.activities)) {
|
|
386
|
+
const groupPath = `activities.${groupName}`;
|
|
387
|
+
if (!(groupName in GROUP_MODULES)) {
|
|
388
|
+
errors.push(issue("REGISTRY_SCHEMA_ERROR", groupPath, `unknown activity group — use one of ${GROUP_NAMES.join(", ")}`));
|
|
389
|
+
continue;
|
|
390
|
+
}
|
|
391
|
+
if (groupValue === null || groupValue === void 0) continue;
|
|
392
|
+
if (!isMapping(groupValue)) {
|
|
393
|
+
errors.push(issue("REGISTRY_SCHEMA_ERROR", groupPath, "must be a mapping of key → entry"));
|
|
394
|
+
continue;
|
|
395
|
+
}
|
|
396
|
+
const module = GROUP_MODULES[groupName];
|
|
397
|
+
for (const [key, value] of Object.entries(groupValue)) {
|
|
398
|
+
if (value !== null && !isMapping(value)) continue;
|
|
399
|
+
const entryPath = `${groupPath}.${key}`;
|
|
400
|
+
if (value === null) {
|
|
401
|
+
errors.push(issue("REGISTRY_SCHEMA_ERROR", entryPath, "entry has no fields — check the indentation of the lines below it"));
|
|
402
|
+
continue;
|
|
403
|
+
}
|
|
404
|
+
if (!KEY_PATTERN.test(key) || key.length > 64) {
|
|
405
|
+
errors.push(issue("REGISTRY_SCHEMA_ERROR", entryPath, `invalid key — use lowercase letters, digits and hyphens (max 64 characters)`));
|
|
406
|
+
continue;
|
|
407
|
+
}
|
|
408
|
+
const previousGroup = seenKeys.get(key);
|
|
409
|
+
if (previousGroup) {
|
|
410
|
+
errors.push(issue("REGISTRY_SCHEMA_ERROR", entryPath, `duplicate key — already defined under activities.${previousGroup}; keys are unique across all groups`));
|
|
411
|
+
continue;
|
|
412
|
+
}
|
|
413
|
+
seenKeys.set(key, groupName);
|
|
414
|
+
const parsed = RegistryEntrySchema.safeParse(value);
|
|
415
|
+
if (!parsed.success) {
|
|
416
|
+
errors.push(...schemaIssues(parsed.error, entryPath));
|
|
417
|
+
continue;
|
|
418
|
+
}
|
|
419
|
+
const entry = parsed.data;
|
|
420
|
+
if (entry.start && entry.end && Date.parse(entry.end) <= Date.parse(entry.start)) {
|
|
421
|
+
errors.push(issue("REGISTRY_SCHEMA_ERROR", `${entryPath}.end`, "must be after `start`"));
|
|
422
|
+
continue;
|
|
423
|
+
}
|
|
424
|
+
let fileUrl;
|
|
425
|
+
if (entry.url !== void 0) {
|
|
426
|
+
fileUrl = safeHttpUrl(entry.url);
|
|
427
|
+
if (!fileUrl) {
|
|
428
|
+
errors.push(issue("REGISTRY_SCHEMA_ERROR", `${entryPath}.url`, "must be an absolute http(s) URL"));
|
|
429
|
+
continue;
|
|
430
|
+
}
|
|
431
|
+
} else if (baseUrl === void 0) {
|
|
432
|
+
errors.push(issue("REGISTRY_SCHEMA_ERROR", `${entryPath}.file`, baseUrlText === void 0 ? "`file` is relative, but the registry has no `base-url`" : "`file` cannot be resolved — fix `base-url`"));
|
|
433
|
+
continue;
|
|
434
|
+
} else {
|
|
435
|
+
fileUrl = safeHttpUrl(entry.file ?? "", baseUrl);
|
|
436
|
+
if (!fileUrl) {
|
|
437
|
+
errors.push(issue("REGISTRY_SCHEMA_ERROR", `${entryPath}.file`, "does not resolve to an http(s) URL against `base-url`"));
|
|
438
|
+
continue;
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
entries.push({
|
|
442
|
+
key,
|
|
443
|
+
module,
|
|
444
|
+
fileUrl,
|
|
445
|
+
validFrom: entry.start ?? null,
|
|
446
|
+
validUntil: entry.end ?? null,
|
|
447
|
+
note: entry.note ?? null,
|
|
448
|
+
llm: entry.llm ? {
|
|
449
|
+
provider: entry.llm.provider,
|
|
450
|
+
model: entry.llm.model
|
|
451
|
+
} : null
|
|
452
|
+
});
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
return errors.length > 0 ? {
|
|
456
|
+
ok: false,
|
|
457
|
+
errors
|
|
458
|
+
} : {
|
|
459
|
+
ok: true,
|
|
460
|
+
entries
|
|
461
|
+
};
|
|
462
|
+
}
|
|
463
|
+
/**
|
|
464
|
+
* Normalizes a (possibly relative) URL the way `validateCodeRequest` does —
|
|
465
|
+
* `URL.href` — so a resolved entry compares byte-identical to the `file_url` the
|
|
466
|
+
* server stored. Returns undefined for anything that is not http(s).
|
|
467
|
+
*/
|
|
468
|
+
function safeHttpUrl(value, base) {
|
|
469
|
+
let url;
|
|
470
|
+
try {
|
|
471
|
+
url = new URL(value.trim(), base);
|
|
472
|
+
} catch {
|
|
473
|
+
return;
|
|
474
|
+
}
|
|
475
|
+
return url.protocol === "http:" || url.protocol === "https:" ? url.href : void 0;
|
|
476
|
+
}
|
|
477
|
+
/** Reads and validates a registry file; a read failure is reported like a schema issue. */
|
|
478
|
+
async function loadRegistry(path) {
|
|
479
|
+
let text;
|
|
480
|
+
try {
|
|
481
|
+
text = await readFile(path, "utf8");
|
|
482
|
+
} catch (error) {
|
|
483
|
+
return {
|
|
484
|
+
ok: false,
|
|
485
|
+
errors: [issue("REGISTRY_READ_ERROR", "", `Could not read ${path}: ${error instanceof Error ? error.message : String(error)}`)]
|
|
486
|
+
};
|
|
487
|
+
}
|
|
488
|
+
return parseRegistry(text);
|
|
489
|
+
}
|
|
490
|
+
/** The lock file that belongs to a registry: `<name>.yaml` → `<name>.lock.yaml`. */
|
|
491
|
+
function defaultLockPath(registryPath) {
|
|
492
|
+
return `${registryPath.replace(/\.ya?ml$/i, "")}.lock.yaml`;
|
|
493
|
+
}
|
|
494
|
+
//#endregion
|
|
495
|
+
//#region src/sync.ts
|
|
496
|
+
/**
|
|
497
|
+
* Narrows the `GET /api/codes` payload to the fields matching needs, dropping
|
|
498
|
+
* anything unrecognizable. The CLI never fails on an unexpected extra field —
|
|
499
|
+
* the server may grow the shape at any time.
|
|
500
|
+
*/
|
|
501
|
+
function parseServerCodes(payload) {
|
|
502
|
+
if (!Array.isArray(payload)) return [];
|
|
503
|
+
const codes = [];
|
|
504
|
+
for (const row of payload) {
|
|
505
|
+
if (typeof row !== "object" || row === null) continue;
|
|
506
|
+
const value = row;
|
|
507
|
+
if (typeof value.code !== "string" || typeof value.fileUrl !== "string") continue;
|
|
508
|
+
if (typeof value.module !== "string") continue;
|
|
509
|
+
const llm = value.llm;
|
|
510
|
+
codes.push({
|
|
511
|
+
code: value.code,
|
|
512
|
+
url: typeof value.url === "string" ? value.url : null,
|
|
513
|
+
module: value.module,
|
|
514
|
+
fileUrl: value.fileUrl,
|
|
515
|
+
note: typeof value.note === "string" ? value.note : null,
|
|
516
|
+
validFrom: typeof value.validFrom === "string" ? value.validFrom : null,
|
|
517
|
+
validUntil: typeof value.validUntil === "string" ? value.validUntil : null,
|
|
518
|
+
llm: typeof llm === "object" && llm !== null ? {
|
|
519
|
+
provider: String(llm.provider ?? ""),
|
|
520
|
+
model: String(llm.model ?? "")
|
|
521
|
+
} : null,
|
|
522
|
+
createdAt: typeof value.createdAt === "string" ? value.createdAt : null
|
|
523
|
+
});
|
|
524
|
+
}
|
|
525
|
+
return codes;
|
|
526
|
+
}
|
|
527
|
+
/**
|
|
528
|
+
* Window bounds compare as INSTANTS, not as strings: the registry may spell a
|
|
529
|
+
* moment `+02:00` while the server always answers in `Z`. An absent bound (null)
|
|
530
|
+
* only matches an absent one.
|
|
531
|
+
*/
|
|
532
|
+
function sameInstant(a, b) {
|
|
533
|
+
if (a === null || b === null) return a === b;
|
|
534
|
+
const left = Date.parse(a);
|
|
535
|
+
const right = Date.parse(b);
|
|
536
|
+
return !Number.isNaN(left) && left === right;
|
|
537
|
+
}
|
|
538
|
+
function sameLlm(a, b) {
|
|
539
|
+
if (a === null || b === null) return a === b;
|
|
540
|
+
return a.provider === b.provider && a.model === b.model;
|
|
541
|
+
}
|
|
542
|
+
/**
|
|
543
|
+
* The codes that ARE this entry: same activity URL, module and availability
|
|
544
|
+
* window, same LLM override. `note` is deliberately excluded — it is a label for
|
|
545
|
+
* the teacher, not part of the code's behavior, so editing it must not fork a
|
|
546
|
+
* new code. Newest first, so the caller reuses the most recent one.
|
|
547
|
+
*/
|
|
548
|
+
function matchEntry(entry, codes) {
|
|
549
|
+
return codes.filter((code) => code.fileUrl === entry.fileUrl && code.module === entry.module && sameInstant(code.validFrom, entry.validFrom) && sameInstant(code.validUntil, entry.validUntil) && sameLlm(entry.llm, code.llm)).sort((a, b) => Date.parse(b.createdAt ?? "") - Date.parse(a.createdAt ?? "") || 0);
|
|
550
|
+
}
|
|
551
|
+
/**
|
|
552
|
+
* Picks ONE code per registry key out of the pool, so a key's code never moves
|
|
553
|
+
* while a matching code exists.
|
|
554
|
+
*
|
|
555
|
+
* Two entries may legitimately describe the same activity with the same window —
|
|
556
|
+
* one quiz linked from two chapters, each wanting its own statistics — and both
|
|
557
|
+
* then match both of the codes that minted for them. Taking "the newest match"
|
|
558
|
+
* per entry independently would hand BOTH keys the same code, strand the other,
|
|
559
|
+
* and flip the assignment whenever a newer code appeared: a key's published code
|
|
560
|
+
* would move under the students already using it, and two consecutive runs of an
|
|
561
|
+
* unchanged registry would write different lock files. Selection therefore
|
|
562
|
+
* claims: every key that still matches the code it already has keeps it (all of
|
|
563
|
+
* them, before any key takes a free one), then each remaining key takes the
|
|
564
|
+
* newest code nobody has claimed.
|
|
565
|
+
*/
|
|
566
|
+
function selectMatches(entries, codes, previousLock) {
|
|
567
|
+
const candidates = /* @__PURE__ */ new Map();
|
|
568
|
+
for (const entry of entries) candidates.set(entry.key, matchEntry(entry, codes));
|
|
569
|
+
const selected = /* @__PURE__ */ new Map();
|
|
570
|
+
const claimed = /* @__PURE__ */ new Set();
|
|
571
|
+
for (const entry of entries) {
|
|
572
|
+
const previous = previousLock[entry.key];
|
|
573
|
+
if (!previous || claimed.has(previous)) continue;
|
|
574
|
+
const kept = candidates.get(entry.key)?.find((code) => code.code === previous);
|
|
575
|
+
if (!kept) continue;
|
|
576
|
+
selected.set(entry.key, kept);
|
|
577
|
+
claimed.add(kept.code);
|
|
578
|
+
}
|
|
579
|
+
for (const entry of entries) {
|
|
580
|
+
if (selected.has(entry.key)) continue;
|
|
581
|
+
const free = candidates.get(entry.key)?.find((code) => !claimed.has(code.code));
|
|
582
|
+
if (!free) continue;
|
|
583
|
+
selected.set(entry.key, free);
|
|
584
|
+
claimed.add(free.code);
|
|
585
|
+
}
|
|
586
|
+
return selected;
|
|
587
|
+
}
|
|
588
|
+
/** The mint body for an entry — exactly what `POST /api/codes` accepts. */
|
|
589
|
+
function mintBody(entry) {
|
|
590
|
+
return {
|
|
591
|
+
module: entry.module,
|
|
592
|
+
fileUrl: entry.fileUrl,
|
|
593
|
+
...entry.validFrom === null ? {} : { validFrom: entry.validFrom },
|
|
594
|
+
...entry.validUntil === null ? {} : { validUntil: entry.validUntil },
|
|
595
|
+
...entry.note === null ? {} : { note: entry.note },
|
|
596
|
+
...entry.llm === null ? {} : { llm: entry.llm }
|
|
597
|
+
};
|
|
598
|
+
}
|
|
599
|
+
/**
|
|
600
|
+
* Advisory findings for one run: codes the registry no longer describes but that
|
|
601
|
+
* still exist for one of its activities (a parameter change mints a NEW code —
|
|
602
|
+
* the old one is never touched), several codes matching one entry, and lock keys
|
|
603
|
+
* that have left the registry. Nothing here is an error; superseded codes stay
|
|
604
|
+
* live until a teacher deletes them in the web app.
|
|
605
|
+
*/
|
|
606
|
+
function collectWarnings(results, serverCodes, previousLock) {
|
|
607
|
+
const warnings = [];
|
|
608
|
+
const claimed = /* @__PURE__ */ new Set();
|
|
609
|
+
const inUse = new Set(results.filter((result) => result.action === "reused").map((result) => result.code));
|
|
610
|
+
for (const result of results) {
|
|
611
|
+
if (result.action !== "reused") continue;
|
|
612
|
+
const matches = matchEntry(result.entry, serverCodes);
|
|
613
|
+
for (const match of matches) claimed.add(match.code);
|
|
614
|
+
const spare = matches.filter((match) => match.code !== result.code && !inUse.has(match.code));
|
|
615
|
+
if (spare.length > 0) warnings.push({
|
|
616
|
+
type: "duplicate",
|
|
617
|
+
key: result.entry.key,
|
|
618
|
+
codes: matches.map((match) => match.code),
|
|
619
|
+
message: `${result.entry.key}: ${matches.length} codes match this entry — using ${result.code}; unused: ${spare.map((match) => match.code).join(", ")}`
|
|
620
|
+
});
|
|
621
|
+
const matched = matches.find((match) => match.code === result.code);
|
|
622
|
+
if (matched && (matched.note ?? "") !== (result.entry.note ?? "")) warnings.push({
|
|
623
|
+
type: "note",
|
|
624
|
+
key: result.entry.key,
|
|
625
|
+
codes: [matched.code],
|
|
626
|
+
message: `${result.entry.key}: the existing code's note differs from the registry's — a note is never re-applied to a minted code`
|
|
627
|
+
});
|
|
628
|
+
}
|
|
629
|
+
const activities = new Set(results.map((result) => `${result.entry.module} ${result.entry.fileUrl}`));
|
|
630
|
+
const superseded = serverCodes.filter((code) => !claimed.has(code.code) && activities.has(`${code.module} ${code.fileUrl}`));
|
|
631
|
+
for (const code of superseded) warnings.push({
|
|
632
|
+
type: "superseded",
|
|
633
|
+
codes: [code.code],
|
|
634
|
+
message: `${code.code}: an older code for ${code.fileUrl} no longer matches any registry entry — it still works; delete it in the web app when the class has moved on`
|
|
635
|
+
});
|
|
636
|
+
const keys = new Set(results.map((result) => result.entry.key));
|
|
637
|
+
for (const key of Object.keys(previousLock)) {
|
|
638
|
+
if (keys.has(key)) continue;
|
|
639
|
+
warnings.push({
|
|
640
|
+
type: "orphaned",
|
|
641
|
+
key,
|
|
642
|
+
codes: [previousLock[key] ?? ""],
|
|
643
|
+
message: `${key}: in the lock file but no longer in the registry — dropped from the lock; the code ${previousLock[key]} still exists`
|
|
644
|
+
});
|
|
645
|
+
}
|
|
646
|
+
return warnings;
|
|
647
|
+
}
|
|
648
|
+
/**
|
|
649
|
+
* The lock content for a run. An entry that FAILED this run keeps the code the
|
|
650
|
+
* previous lock had for it: a transient server error must never break the
|
|
651
|
+
* consumer's build. A failed entry with no previous code is simply absent.
|
|
652
|
+
*/
|
|
653
|
+
function buildLockCodes(results, previousLock) {
|
|
654
|
+
const codes = {};
|
|
655
|
+
for (const result of results) {
|
|
656
|
+
const code = result.code ?? previousLock[result.entry.key];
|
|
657
|
+
if (code) codes[result.entry.key] = code;
|
|
658
|
+
}
|
|
659
|
+
return codes;
|
|
660
|
+
}
|
|
661
|
+
/** The single top-level key of a lock file — namespaced so it can be merged into other metadata. */
|
|
662
|
+
const LOCK_ROOT_KEY = "activity-codes";
|
|
663
|
+
/** Serializes the lock file: keys sorted, so a re-run produces a byte-identical file. */
|
|
664
|
+
function serializeLock(codes, registryFileName) {
|
|
665
|
+
const sorted = {};
|
|
666
|
+
for (const key of Object.keys(codes).sort()) sorted[key] = codes[key];
|
|
667
|
+
return [
|
|
668
|
+
"# Generated by @novedu/cli — do not edit.",
|
|
669
|
+
`# Regenerate with: novedu-cli codes sync ${registryFileName}`,
|
|
670
|
+
stringify({ [LOCK_ROOT_KEY]: sorted })
|
|
671
|
+
].join("\n");
|
|
672
|
+
}
|
|
673
|
+
/** Reads a lock file's `activity-codes` map; anything unusable yields an empty map. */
|
|
674
|
+
function parseLock(text) {
|
|
675
|
+
let document;
|
|
676
|
+
try {
|
|
677
|
+
document = parse(text);
|
|
678
|
+
} catch {
|
|
679
|
+
return {};
|
|
680
|
+
}
|
|
681
|
+
if (typeof document !== "object" || document === null) return {};
|
|
682
|
+
const map = document[LOCK_ROOT_KEY];
|
|
683
|
+
if (typeof map !== "object" || map === null) return {};
|
|
684
|
+
const codes = {};
|
|
685
|
+
for (const [key, value] of Object.entries(map)) if (typeof value === "string" && value) codes[key] = value;
|
|
686
|
+
return codes;
|
|
687
|
+
}
|
|
688
|
+
/**
|
|
689
|
+
* The human-readable report: one line per entry (action, key, code, share URL or
|
|
690
|
+
* the server's complaint), then the advisory findings, then a summary. Returned
|
|
691
|
+
* as lines so the command decides where they go.
|
|
692
|
+
*/
|
|
693
|
+
function formatSyncReport(results, warnings, options) {
|
|
694
|
+
const width = Math.max(0, ...results.map((result) => result.entry.key.length));
|
|
695
|
+
const label = (action) => (options.dryRun && action === "minted" ? "would mint" : action).padEnd(9);
|
|
696
|
+
const lines = [`${options.registryFileName}: ${results.length} ${results.length === 1 ? "entry" : "entries"}`];
|
|
697
|
+
for (const result of results) {
|
|
698
|
+
const detail = result.action === "failed" ? describeError(result.error) : result.url ?? result.code ?? "(not minted — dry run)";
|
|
699
|
+
lines.push(` ${label(result.action)} ${result.entry.key.padEnd(width)} ${result.code ? `${result.code} ` : ""}${detail}`);
|
|
700
|
+
}
|
|
701
|
+
if (warnings.length > 0) {
|
|
702
|
+
lines.push("", "Notes:");
|
|
703
|
+
for (const warning of warnings) lines.push(` - ${warning.message}`);
|
|
704
|
+
}
|
|
705
|
+
const counts = {
|
|
706
|
+
reused: 0,
|
|
707
|
+
minted: 0,
|
|
708
|
+
failed: 0
|
|
709
|
+
};
|
|
710
|
+
for (const result of results) counts[result.action] += 1;
|
|
711
|
+
lines.push("", `${counts.reused} reused, ${counts.minted} ${options.dryRun ? "to mint" : "minted"}, ${counts.failed} failed`);
|
|
712
|
+
return lines;
|
|
713
|
+
}
|
|
714
|
+
/** A one-line rendering of the server's failure payload for the report. */
|
|
715
|
+
function describeError(error) {
|
|
716
|
+
if (typeof error !== "object" || error === null) return String(error ?? "unknown error");
|
|
717
|
+
const value = error;
|
|
718
|
+
if (typeof value.message === "string") return value.message;
|
|
719
|
+
if (Array.isArray(value.errors)) return value.errors.map((item) => {
|
|
720
|
+
if (typeof item !== "object" || item === null) return String(item);
|
|
721
|
+
const detail = item;
|
|
722
|
+
return [detail.code, detail.message].filter(Boolean).join(": ");
|
|
723
|
+
}).join("; ");
|
|
724
|
+
return JSON.stringify(error);
|
|
725
|
+
}
|
|
726
|
+
//#endregion
|
|
265
727
|
//#region src/commands/codes.ts
|
|
266
728
|
const SERVER_OPTION$3 = ["--server <url>", "Novedu server base URL (defaults to the NOVEDU_SERVER env var, then production)"];
|
|
729
|
+
/**
|
|
730
|
+
* Reconciles a registry file with the server and rewrites its lock file: match
|
|
731
|
+
* every entry against the caller's existing codes (URL + module + window + LLM
|
|
732
|
+
* override), mint what has no match, and report the rest. Existing codes are
|
|
733
|
+
* never modified or deleted — changed parameters produce a NEW code and the old
|
|
734
|
+
* one is reported as superseded (docs/registry.md).
|
|
735
|
+
*/
|
|
736
|
+
async function runSync(registryFile, options) {
|
|
737
|
+
const registry = await loadRegistry(registryFile);
|
|
738
|
+
if (!registry.ok) {
|
|
739
|
+
failJson({
|
|
740
|
+
message: `${registryFile} is not a usable activity registry.`,
|
|
741
|
+
errors: registry.errors
|
|
742
|
+
});
|
|
743
|
+
return;
|
|
744
|
+
}
|
|
745
|
+
const lockPath = options.lock ?? defaultLockPath(registryFile);
|
|
746
|
+
const previousLock = await readLock(lockPath);
|
|
747
|
+
const listed = await performApiRequest({
|
|
748
|
+
server: options.server,
|
|
749
|
+
path: "/api/codes"
|
|
750
|
+
});
|
|
751
|
+
if (!listed.ok) return;
|
|
752
|
+
const serverCodes = parseServerCodes(listed.payload);
|
|
753
|
+
const results = [];
|
|
754
|
+
const selected = selectMatches(registry.entries, serverCodes, previousLock);
|
|
755
|
+
for (const entry of registry.entries) {
|
|
756
|
+
const match = selected.get(entry.key);
|
|
757
|
+
if (match) {
|
|
758
|
+
results.push({
|
|
759
|
+
entry,
|
|
760
|
+
action: "reused",
|
|
761
|
+
code: match.code,
|
|
762
|
+
url: match.url ?? void 0
|
|
763
|
+
});
|
|
764
|
+
continue;
|
|
765
|
+
}
|
|
766
|
+
if (options.dryRun) {
|
|
767
|
+
results.push({
|
|
768
|
+
entry,
|
|
769
|
+
action: "minted"
|
|
770
|
+
});
|
|
771
|
+
continue;
|
|
772
|
+
}
|
|
773
|
+
const created = await performApiRequest({
|
|
774
|
+
server: options.server,
|
|
775
|
+
path: "/api/codes",
|
|
776
|
+
method: "POST",
|
|
777
|
+
body: mintBody(entry),
|
|
778
|
+
quiet: true
|
|
779
|
+
});
|
|
780
|
+
if (!created.ok) {
|
|
781
|
+
results.push({
|
|
782
|
+
entry,
|
|
783
|
+
action: "failed",
|
|
784
|
+
error: created.error
|
|
785
|
+
});
|
|
786
|
+
continue;
|
|
787
|
+
}
|
|
788
|
+
const minted = created.payload;
|
|
789
|
+
if (typeof minted?.code !== "string" || minted.code === "") {
|
|
790
|
+
results.push({
|
|
791
|
+
entry,
|
|
792
|
+
action: "failed",
|
|
793
|
+
error: { message: "the server accepted the request but returned no code" }
|
|
794
|
+
});
|
|
795
|
+
continue;
|
|
796
|
+
}
|
|
797
|
+
results.push({
|
|
798
|
+
entry,
|
|
799
|
+
action: "minted",
|
|
800
|
+
code: minted.code,
|
|
801
|
+
url: typeof minted.url === "string" ? minted.url : void 0
|
|
802
|
+
});
|
|
803
|
+
}
|
|
804
|
+
const warnings = collectWarnings(results, serverCodes, previousLock);
|
|
805
|
+
const failed = results.filter((result) => result.action === "failed").length;
|
|
806
|
+
if (options.json) printJson({
|
|
807
|
+
...options.dryRun ? { dryRun: true } : {},
|
|
808
|
+
entries: results.map((result) => ({
|
|
809
|
+
key: result.entry.key,
|
|
810
|
+
module: result.entry.module,
|
|
811
|
+
fileUrl: result.entry.fileUrl,
|
|
812
|
+
action: result.action,
|
|
813
|
+
...result.code ? { code: result.code } : {},
|
|
814
|
+
...result.url ? { url: result.url } : {},
|
|
815
|
+
...result.action === "failed" ? { error: result.error } : {}
|
|
816
|
+
})),
|
|
817
|
+
warnings
|
|
818
|
+
});
|
|
819
|
+
else for (const line of formatSyncReport(results, warnings, {
|
|
820
|
+
registryFileName: basename(registryFile),
|
|
821
|
+
dryRun: Boolean(options.dryRun)
|
|
822
|
+
})) console.log(line);
|
|
823
|
+
if (!options.dryRun) {
|
|
824
|
+
const lock = serializeLock(buildLockCodes(results, previousLock), basename(registryFile));
|
|
825
|
+
try {
|
|
826
|
+
await writeFile(lockPath, lock, "utf8");
|
|
827
|
+
} catch (error) {
|
|
828
|
+
failJson({ message: `Could not write the lock file ${lockPath}: ${error instanceof Error ? error.message : error}` });
|
|
829
|
+
return;
|
|
830
|
+
}
|
|
831
|
+
if (!options.json) console.log(`Lock file: ${lockPath}`);
|
|
832
|
+
}
|
|
833
|
+
if (failed > 0) process.exitCode = 1;
|
|
834
|
+
}
|
|
835
|
+
/** The lock file's previous content; a missing or unreadable lock is simply empty. */
|
|
836
|
+
async function readLock(lockPath) {
|
|
837
|
+
try {
|
|
838
|
+
return parseLock(await readFile(lockPath, "utf8"));
|
|
839
|
+
} catch {
|
|
840
|
+
return {};
|
|
841
|
+
}
|
|
842
|
+
}
|
|
267
843
|
function registerCodes(program) {
|
|
268
844
|
const codes = program.command("codes").description("Manage activity codes on the Novedu server");
|
|
269
845
|
codes.command("create").description("Create a code for an activity YAML (validated server-side before storing)").requiredOption("--module <module>", "activity module: tutor, quiz, writing or coding").requiredOption("--file <url>", "public http(s) URL of the activity YAML").option("--start <iso>", "window start, ISO 8601 with explicit offset (e.g. 2026-07-07T08:00:00Z)").option("--end <iso>", "window end, ISO 8601 with explicit offset").option("--note <text>", "note shown in the codes list").option("--llm-provider <provider>", "LLM override provider (\"SCCH\" or \"Azure Foundry\"; needs --llm-model)").option("--llm-model <model>", "LLM override model id (needs --llm-provider)").option(...SERVER_OPTION$3).action(async (options) => {
|
|
@@ -295,6 +871,9 @@ function registerCodes(program) {
|
|
|
295
871
|
path: `/api/codes${query ? `?${query}` : ""}`
|
|
296
872
|
});
|
|
297
873
|
});
|
|
874
|
+
codes.command("sync").description("Reconcile an activity registry file with the server and write its lock file").argument("<registry-file>", "path to the hand-written activity registry YAML").option("--lock <path>", "lock file path (default: the registry path with .lock.yaml)").option("--dry-run", "report what would happen; mint nothing and write no lock file").option("--json", "machine-readable report on stdout instead of the per-entry lines").option(...SERVER_OPTION$3).action(async (registryFile, options) => {
|
|
875
|
+
await runSync(registryFile, options);
|
|
876
|
+
});
|
|
298
877
|
}
|
|
299
878
|
//#endregion
|
|
300
879
|
//#region src/commands/files.ts
|
|
@@ -466,36 +1045,131 @@ Purely local — already-issued access tokens stay valid until they expire
|
|
|
466
1045
|
});
|
|
467
1046
|
}
|
|
468
1047
|
//#endregion
|
|
469
|
-
//#region
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
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
|
+
};
|
|
499
1173
|
}
|
|
500
1174
|
//#endregion
|
|
501
1175
|
//#region ../lib/prompt-fragments/assemble.ts
|
|
@@ -1576,28 +2250,1033 @@ async function loadAndCheckFragmentFile(url, fetchImpl, opts = {}) {
|
|
|
1576
2250
|
};
|
|
1577
2251
|
return checkFragmentFileValue(yaml.value, url);
|
|
1578
2252
|
}
|
|
1579
|
-
const providerSchema = z.enum(["SCCH", "Azure Foundry"]).default("SCCH").meta({ description: "The LLM provider serving the model. For Azure Foundry, model is the deployment name." });
|
|
1580
|
-
//#endregion
|
|
1581
|
-
//#region ../lib/coding-schema.ts
|
|
1582
|
-
const CodingYamlSchema = z.strictObject({
|
|
1583
|
-
id: z.string().min(1).meta({ description: "Short machine-readable activity id, e.g. beginner-typescript." }),
|
|
1584
|
-
name: z.string().optional().meta({ description: "Optional human-readable label (not shown to the student)." }),
|
|
1585
|
-
title: z.string().optional().meta({ description: "Optional label shown to the student on the /<code> connection page." }),
|
|
1586
|
-
llm: z.strictObject({
|
|
1587
|
-
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." }),
|
|
1588
|
-
provider: providerSchema
|
|
1589
|
-
}).meta({
|
|
1590
|
-
id: "llm",
|
|
1591
|
-
description: "The pinned model and provider that answer coding requests."
|
|
1592
|
-
}),
|
|
1593
|
-
fragment_files: z.array(FragmentFileRefSchema).default([]).meta({ description: "Optional fragment libraries this activity pulls shared prompt fragments from." }),
|
|
1594
|
-
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." }),
|
|
1595
|
-
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 \\{{)." })
|
|
1596
|
-
});
|
|
1597
2253
|
//#endregion
|
|
1598
|
-
//#region ../lib/coding-
|
|
1599
|
-
|
|
1600
|
-
|
|
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
|
+
}
|
|
2263
|
+
/**
|
|
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.
|
|
2267
|
+
*/
|
|
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
|
+
};
|
|
2277
|
+
return {
|
|
2278
|
+
ok: true,
|
|
2279
|
+
coding: {
|
|
2280
|
+
...coding,
|
|
2281
|
+
fragmentBlock: EMPTY_FRAGMENT_BLOCK,
|
|
2282
|
+
instructions: resolved.prompt
|
|
2283
|
+
}
|
|
2284
|
+
};
|
|
2285
|
+
}
|
|
2286
|
+
/**
|
|
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:`).
|
|
2290
|
+
*/
|
|
2291
|
+
async function loadCodingFrom(url, fetcher, opts = {}) {
|
|
2292
|
+
const allowedSchemes = opts.allowedSchemes ?? DEFAULT_SCHEMES$2;
|
|
2293
|
+
if (!schemeAllowed$2(url, allowedSchemes)) return {
|
|
2294
|
+
ok: false,
|
|
2295
|
+
message: `This coding activity's URL is not allowed: ${url}`
|
|
2296
|
+
};
|
|
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 {
|
|
2445
|
+
ok: false,
|
|
2446
|
+
message: "This quiz is empty or malformed."
|
|
2447
|
+
};
|
|
2448
|
+
const root = doc;
|
|
2449
|
+
const llm = root.llm;
|
|
2450
|
+
const model = asString$1(llm?.model);
|
|
2451
|
+
if (!model) return {
|
|
2452
|
+
ok: false,
|
|
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)."
|
|
2488
|
+
};
|
|
2489
|
+
const rawCount = root.question_count;
|
|
2490
|
+
const questionCount = typeof rawCount === "number" && Number.isInteger(rawCount) && rawCount >= 1 ? rawCount : void 0;
|
|
2491
|
+
return {
|
|
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
|
+
}
|
|
2511
|
+
};
|
|
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
|
+
}
|
|
2523
|
+
//#endregion
|
|
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
|
+
}
|
|
2533
|
+
/**
|
|
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`.)
|
|
2539
|
+
*/
|
|
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
|
+
}
|
|
2551
|
+
/**
|
|
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.
|
|
2556
|
+
*/
|
|
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
|
|
1601
3280
|
* `checkCodingValue` so `loadAndCheckCoding` can reuse the single `validate` it already
|
|
1602
3281
|
* ran (no second parse of the same document against the same schema).
|
|
1603
3282
|
*/
|
|
@@ -1892,118 +3571,37 @@ async function loadAndCheckQuiz(url, fetchImpl, opts = {}) {
|
|
|
1892
3571
|
errors: [valid.error],
|
|
1893
3572
|
warnings: []
|
|
1894
3573
|
};
|
|
1895
|
-
const checked = checkQuizParsed(valid.data);
|
|
1896
|
-
if (!checked.ok) return checked;
|
|
1897
|
-
const assembled = await assembleFragmentPrompts({
|
|
1898
|
-
fragment_files: valid.data.fragment_files,
|
|
1899
|
-
text_files: valid.data.text_files
|
|
1900
|
-
}, url, fetchImpl, {
|
|
1901
|
-
allowedSchemes: opts.allowedSchemes,
|
|
1902
|
-
validateLibraries: opts.validateLibraries ?? true
|
|
1903
|
-
}, [valid.data.instructions ?? "", valid.data.discussion?.instructions ?? ""]);
|
|
1904
|
-
const warnings = [...checked.warnings, ...assembled.warnings];
|
|
1905
|
-
if (!assembled.ok) return {
|
|
1906
|
-
ok: false,
|
|
1907
|
-
errors: assembled.errors,
|
|
1908
|
-
warnings
|
|
1909
|
-
};
|
|
1910
|
-
const includes = await Promise.all(valid.data.quiz_files.map((ref) => checkInclude(ref, url, fetchImpl, opts)));
|
|
1911
|
-
const includeErrors = [];
|
|
1912
|
-
let importedCount = 0;
|
|
1913
|
-
for (const include of includes) {
|
|
1914
|
-
warnings.push(...include.warnings);
|
|
1915
|
-
if (include.ok) importedCount += include.questionCount;
|
|
1916
|
-
else includeErrors.push(...include.errors);
|
|
1917
|
-
}
|
|
1918
|
-
if (includeErrors.length > 0) return {
|
|
1919
|
-
ok: false,
|
|
1920
|
-
errors: includeErrors,
|
|
1921
|
-
warnings
|
|
1922
|
-
};
|
|
1923
|
-
return {
|
|
1924
|
-
...checked,
|
|
1925
|
-
questionCount: checked.questionCount + importedCount,
|
|
1926
|
-
warnings
|
|
1927
|
-
};
|
|
1928
|
-
}
|
|
1929
|
-
//#endregion
|
|
1930
|
-
//#region ../lib/tutors/schemas.ts
|
|
1931
|
-
/**
|
|
1932
|
-
* An example question offered to students on the welcome screen: the `title` is
|
|
1933
|
-
* the clickable label, the `question` is the full text placed into the chat
|
|
1934
|
-
* input on click. Tutors may define any number; the UI samples at most 5.
|
|
1935
|
-
*/
|
|
1936
|
-
const ExampleQuestionSchema = z.strictObject({
|
|
1937
|
-
title: z.string().min(1).meta({ description: "Short clickable label shown on the welcome screen." }),
|
|
1938
|
-
question: z.string().min(1).meta({ description: "Full question text. Shown as a tooltip and placed into the chat input on click." })
|
|
1939
|
-
}).meta({
|
|
1940
|
-
id: "exampleQuestion",
|
|
1941
|
-
description: "An example question shown on the welcome screen."
|
|
1942
|
-
});
|
|
1943
|
-
const TutorSchema = z.strictObject({
|
|
1944
|
-
id: z.string().meta({ description: "Short machine-readable tutor id, e.g. fractions-de." }),
|
|
1945
|
-
name: z.string().meta({ description: "Human-readable tutor title." }),
|
|
1946
|
-
title: z.string().optional().meta({ description: "Optional greeting shown to students on the empty chat instead of the default welcome message." }),
|
|
1947
|
-
description: z.string().meta({ description: "Short description of what this tutor does. Shown to students below the welcome greeting." }),
|
|
1948
|
-
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." }),
|
|
1949
|
-
anonymous: z.boolean().optional().meta({
|
|
1950
|
-
default: true,
|
|
1951
|
-
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."
|
|
1952
|
-
}),
|
|
1953
|
-
llm: z.strictObject({
|
|
1954
|
-
model: z.string().meta({ description: "Model used for this tutor." }),
|
|
1955
|
-
provider: providerSchema,
|
|
1956
|
-
imageInput: z.boolean().optional().meta({
|
|
1957
|
-
default: true,
|
|
1958
|
-
description: "Image uploads are enabled by default. Set to false to hide the upload UI for text-only tutors or non-vision-capable models."
|
|
1959
|
-
})
|
|
1960
|
-
}).meta({
|
|
1961
|
-
id: "llm",
|
|
1962
|
-
description: "The model and provider that back this tutor."
|
|
1963
|
-
}),
|
|
1964
|
-
prompt: z.strictObject({
|
|
1965
|
-
fragment_files: z.array(FragmentFileRefSchema).default([]).meta({ description: "Optional fragment libraries used by this tutor." }),
|
|
1966
|
-
text_files: z.array(TextFileRefSchema).default([]).meta({ description: "Optional plain-text files (markdown / source) embedded verbatim via {{file \"alias\"}} markers." }),
|
|
1967
|
-
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." })
|
|
1968
|
-
}).meta({
|
|
1969
|
-
id: "prompt",
|
|
1970
|
-
description: "The tutor system prompt: a host template with inline fragment markers."
|
|
1971
|
-
})
|
|
1972
|
-
});
|
|
1973
|
-
//#endregion
|
|
1974
|
-
//#region ../lib/tutors/load.ts
|
|
1975
|
-
async function loadAndBuildTutorPrompt(url, fetchImpl, opts = {}) {
|
|
1976
|
-
const warnings = [];
|
|
1977
|
-
const tutorYaml = await loadYaml(url, fetchImpl, opts);
|
|
1978
|
-
if (!tutorYaml.ok) return {
|
|
1979
|
-
ok: false,
|
|
1980
|
-
errors: [tutorYaml.error],
|
|
1981
|
-
warnings
|
|
1982
|
-
};
|
|
1983
|
-
const tutorValid = validate(tutorYaml.value, TutorSchema, "TUTOR_SCHEMA_ERROR", url);
|
|
1984
|
-
if (!tutorValid.ok) return {
|
|
1985
|
-
ok: false,
|
|
1986
|
-
errors: [tutorValid.error],
|
|
1987
|
-
warnings
|
|
1988
|
-
};
|
|
1989
|
-
const tutor = tutorValid.data;
|
|
1990
|
-
const assembled = await assembleFragmentPrompt(tutor.prompt, url, fetchImpl, opts, tutor.prompt.tutor_instructions);
|
|
1991
|
-
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];
|
|
1992
3584
|
if (!assembled.ok) return {
|
|
1993
3585
|
ok: false,
|
|
1994
3586
|
errors: assembled.errors,
|
|
1995
3587
|
warnings
|
|
1996
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
|
+
};
|
|
1997
3602
|
return {
|
|
1998
|
-
|
|
1999
|
-
|
|
2000
|
-
model: tutor.llm.model,
|
|
2001
|
-
provider: tutor.llm.provider,
|
|
2002
|
-
imageInput: tutor.llm.imageInput ?? true,
|
|
2003
|
-
anonymous: tutor.anonymous ?? true,
|
|
2004
|
-
title: tutor.title,
|
|
2005
|
-
description: tutor.description,
|
|
2006
|
-
exampleQuestions: tutor.exampleQuestions ?? [],
|
|
3603
|
+
...checked,
|
|
3604
|
+
questionCount: checked.questionCount + importedCount,
|
|
2007
3605
|
warnings
|
|
2008
3606
|
};
|
|
2009
3607
|
}
|
|
@@ -2089,173 +3687,6 @@ async function loadAndCheckWriting(url, fetchImpl, opts = {}) {
|
|
|
2089
3687
|
};
|
|
2090
3688
|
}
|
|
2091
3689
|
//#endregion
|
|
2092
|
-
//#region src/file-fetcher.ts
|
|
2093
|
-
const cliFetcher = async (url) => {
|
|
2094
|
-
if (url.startsWith("file:")) try {
|
|
2095
|
-
const text = await readFile(fileURLToPath(url), "utf8");
|
|
2096
|
-
return {
|
|
2097
|
-
ok: true,
|
|
2098
|
-
status: 200,
|
|
2099
|
-
text: async () => text
|
|
2100
|
-
};
|
|
2101
|
-
} catch {
|
|
2102
|
-
return {
|
|
2103
|
-
ok: false,
|
|
2104
|
-
status: 404,
|
|
2105
|
-
text: async () => ""
|
|
2106
|
-
};
|
|
2107
|
-
}
|
|
2108
|
-
return defaultFetcher(url);
|
|
2109
|
-
};
|
|
2110
|
-
//#endregion
|
|
2111
|
-
//#region src/format.ts
|
|
2112
|
-
const useColor = process.stdout.isTTY && !process.env.NO_COLOR;
|
|
2113
|
-
const paint = (code, s) => useColor ? `\x1b[${code}m${s}\x1b[0m` : s;
|
|
2114
|
-
const green = (s) => paint("32", s);
|
|
2115
|
-
const red = (s) => paint("31", s);
|
|
2116
|
-
const yellow = (s) => paint("33", s);
|
|
2117
|
-
const dim = (s) => paint("2", s);
|
|
2118
|
-
/** Append the context fields an error/warning carries, when present. */
|
|
2119
|
-
function context(item) {
|
|
2120
|
-
const parts = [];
|
|
2121
|
-
if (item.fileAlias) parts.push(`file=${item.fileAlias}`);
|
|
2122
|
-
if (item.fragmentId) parts.push(`fragment=${item.fragmentId}`);
|
|
2123
|
-
if ("questionId" in item && item.questionId) parts.push(`question=${item.questionId}`);
|
|
2124
|
-
if (item.variable) parts.push(`variable=${item.variable}`);
|
|
2125
|
-
if ("url" in item && item.url) parts.push(`url=${item.url}`);
|
|
2126
|
-
if ("expectedType" in item && item.expectedType) parts.push(`expected=${item.expectedType}`);
|
|
2127
|
-
if ("actualType" in item && item.actualType) parts.push(`actual=${item.actualType}`);
|
|
2128
|
-
return parts.length ? dim(` (${parts.join(", ")})`) : "";
|
|
2129
|
-
}
|
|
2130
|
-
function renderWarnings(warnings) {
|
|
2131
|
-
return warnings.map((w) => ` ${yellow("⚠")} ${yellow(w.code)} ${w.message}${context(w)}`);
|
|
2132
|
-
}
|
|
2133
|
-
/**
|
|
2134
|
-
* Render each error as a line, with any flattened Zod schema-issue detail
|
|
2135
|
-
* indented beneath it — so a generic "Document does not match the expected
|
|
2136
|
-
* structure" is followed by the actual field paths (e.g. `Unrecognized key:
|
|
2137
|
-
* "nae"`), matching what the web UI shows.
|
|
2138
|
-
*/
|
|
2139
|
-
function renderErrors(errors) {
|
|
2140
|
-
const lines = [];
|
|
2141
|
-
for (const e of errors) {
|
|
2142
|
-
lines.push(` ${red("✗")} ${red(e.code)} ${e.message}${context(e)}`);
|
|
2143
|
-
if (e.zodIssues) for (const issue of formatZodIssues(e.zodIssues)) lines.push(` ${dim(issue)}`);
|
|
2144
|
-
}
|
|
2145
|
-
return lines;
|
|
2146
|
-
}
|
|
2147
|
-
function formatResult(result, source) {
|
|
2148
|
-
const lines = [];
|
|
2149
|
-
if (result.ok) {
|
|
2150
|
-
lines.push(green(`✔ Valid tutor`) + dim(` — ${source}`));
|
|
2151
|
-
lines.push(` model: ${result.model}`);
|
|
2152
|
-
lines.push(` system prompt: ${result.prompt.length} chars`);
|
|
2153
|
-
lines.push(` imageInput: ${result.imageInput} anonymous: ${result.anonymous}` + (result.exampleQuestions.length ? ` exampleQuestions: ${result.exampleQuestions.length}` : ""));
|
|
2154
|
-
if (result.warnings.length) {
|
|
2155
|
-
lines.push("");
|
|
2156
|
-
lines.push(yellow(`${result.warnings.length} warning(s):`));
|
|
2157
|
-
lines.push(...renderWarnings(result.warnings));
|
|
2158
|
-
}
|
|
2159
|
-
return lines.join("\n");
|
|
2160
|
-
}
|
|
2161
|
-
lines.push(red(`✘ Invalid tutor`) + dim(` — ${source}`));
|
|
2162
|
-
lines.push("");
|
|
2163
|
-
lines.push(red(`${result.errors.length} error(s):`));
|
|
2164
|
-
lines.push(...renderErrors(result.errors));
|
|
2165
|
-
if (result.warnings.length) {
|
|
2166
|
-
lines.push("");
|
|
2167
|
-
lines.push(yellow(`${result.warnings.length} warning(s):`));
|
|
2168
|
-
lines.push(...renderWarnings(result.warnings));
|
|
2169
|
-
}
|
|
2170
|
-
return lines.join("\n");
|
|
2171
|
-
}
|
|
2172
|
-
/** Same renderer, for a standalone fragment-FILE check (`--kind fragment`). */
|
|
2173
|
-
function formatFragmentResult(result, source) {
|
|
2174
|
-
const lines = [];
|
|
2175
|
-
if (result.ok) {
|
|
2176
|
-
lines.push(green(`✔ Valid fragment file`) + dim(` — ${source}`));
|
|
2177
|
-
lines.push(` id: ${result.fragmentFileId}`);
|
|
2178
|
-
lines.push(` fragments: ${result.fragmentIds.length}` + (result.fragmentIds.length ? ` (${result.fragmentIds.join(", ")})` : ""));
|
|
2179
|
-
if (result.warnings.length) {
|
|
2180
|
-
lines.push("");
|
|
2181
|
-
lines.push(yellow(`${result.warnings.length} warning(s):`));
|
|
2182
|
-
lines.push(...renderWarnings(result.warnings));
|
|
2183
|
-
}
|
|
2184
|
-
return lines.join("\n");
|
|
2185
|
-
}
|
|
2186
|
-
lines.push(red(`✘ Invalid fragment file`) + dim(` — ${source}`));
|
|
2187
|
-
lines.push("");
|
|
2188
|
-
lines.push(red(`${result.errors.length} error(s):`));
|
|
2189
|
-
lines.push(...renderErrors(result.errors));
|
|
2190
|
-
if (result.warnings.length) {
|
|
2191
|
-
lines.push("");
|
|
2192
|
-
lines.push(yellow(`${result.warnings.length} warning(s):`));
|
|
2193
|
-
lines.push(...renderWarnings(result.warnings));
|
|
2194
|
-
}
|
|
2195
|
-
return lines.join("\n");
|
|
2196
|
-
}
|
|
2197
|
-
/**
|
|
2198
|
-
* Shared tail for the quiz/writing renderers: on failure, the error list (with any
|
|
2199
|
-
* flattened Zod issues); plus any warnings on either branch.
|
|
2200
|
-
*/
|
|
2201
|
-
function renderFailureAndWarnings(result, label, source) {
|
|
2202
|
-
const lines = [red(`✘ Invalid ${label}`) + dim(` — ${source}`), ""];
|
|
2203
|
-
lines.push(red(`${result.errors.length} error(s):`));
|
|
2204
|
-
lines.push(...renderErrors(result.errors));
|
|
2205
|
-
if (result.warnings.length) {
|
|
2206
|
-
lines.push("");
|
|
2207
|
-
lines.push(yellow(`${result.warnings.length} warning(s):`));
|
|
2208
|
-
lines.push(...renderWarnings(result.warnings));
|
|
2209
|
-
}
|
|
2210
|
-
return lines.join("\n");
|
|
2211
|
-
}
|
|
2212
|
-
/** Renderer for a quiz check (`--kind quiz`). */
|
|
2213
|
-
function formatQuizResult(result, source) {
|
|
2214
|
-
if (!result.ok) return renderFailureAndWarnings(result, "quiz", source);
|
|
2215
|
-
const lines = [green(`✔ Valid quiz`) + dim(` — ${source}`)];
|
|
2216
|
-
lines.push(` id: ${result.quizId}`);
|
|
2217
|
-
lines.push(` model: ${result.model}`);
|
|
2218
|
-
lines.push(` questions: ${result.questionCount} anonymous: ${result.anonymous}`);
|
|
2219
|
-
if (result.warnings.length) {
|
|
2220
|
-
lines.push("");
|
|
2221
|
-
lines.push(yellow(`${result.warnings.length} warning(s):`));
|
|
2222
|
-
lines.push(...renderWarnings(result.warnings));
|
|
2223
|
-
}
|
|
2224
|
-
return lines.join("\n");
|
|
2225
|
-
}
|
|
2226
|
-
/** Renderer for a writing-activity check (`--kind writing`). */
|
|
2227
|
-
function formatWritingResult(result, source) {
|
|
2228
|
-
if (!result.ok) return renderFailureAndWarnings(result, "writing activity", source);
|
|
2229
|
-
const lines = [green(`✔ Valid writing activity`) + dim(` — ${source}`)];
|
|
2230
|
-
lines.push(` id: ${result.writingId}`);
|
|
2231
|
-
lines.push(` model: ${result.model}`);
|
|
2232
|
-
lines.push(` anonymous: ${result.anonymous}`);
|
|
2233
|
-
if (result.warnings.length) {
|
|
2234
|
-
lines.push("");
|
|
2235
|
-
lines.push(yellow(`${result.warnings.length} warning(s):`));
|
|
2236
|
-
lines.push(...renderWarnings(result.warnings));
|
|
2237
|
-
}
|
|
2238
|
-
return lines.join("\n");
|
|
2239
|
-
}
|
|
2240
|
-
/**
|
|
2241
|
-
* Renderer for a coding-activity check (`--kind coding`). Coding is ALWAYS anonymous
|
|
2242
|
-
* (the API path carries no per-student identity), so — unlike quiz/writing — that is
|
|
2243
|
-
* shown as a fixed note, not a per-file value.
|
|
2244
|
-
*/
|
|
2245
|
-
function formatCodingResult(result, source) {
|
|
2246
|
-
if (!result.ok) return renderFailureAndWarnings(result, "coding activity", source);
|
|
2247
|
-
const lines = [green(`✔ Valid coding activity`) + dim(` — ${source}`)];
|
|
2248
|
-
lines.push(` id: ${result.codingId}`);
|
|
2249
|
-
lines.push(` model: ${result.model}`);
|
|
2250
|
-
lines.push(` anonymous: true ${dim("(always — the API path carries no identity)")}`);
|
|
2251
|
-
if (result.warnings.length) {
|
|
2252
|
-
lines.push("");
|
|
2253
|
-
lines.push(yellow(`${result.warnings.length} warning(s):`));
|
|
2254
|
-
lines.push(...renderWarnings(result.warnings));
|
|
2255
|
-
}
|
|
2256
|
-
return lines.join("\n");
|
|
2257
|
-
}
|
|
2258
|
-
//#endregion
|
|
2259
3690
|
//#region src/commands/validate.ts
|
|
2260
3691
|
/** Every kind the `--kind` flag accepts (used for the option help + guard). */
|
|
2261
3692
|
const VALIDATE_KINDS = [
|
|
@@ -2352,6 +3783,88 @@ function formatOutcome(outcome, source) {
|
|
|
2352
3783
|
}
|
|
2353
3784
|
}
|
|
2354
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
|
|
2355
3868
|
//#region src/commands/whoami.ts
|
|
2356
3869
|
function registerWhoami(program) {
|
|
2357
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) => {
|
|
@@ -2392,6 +3905,7 @@ const { version } = JSON.parse(readFileSync(new URL("../package.json", import.me
|
|
|
2392
3905
|
const program = new Command();
|
|
2393
3906
|
program.name("novedu-cli").description("Command-line companion for the Novedu chat app").version(version);
|
|
2394
3907
|
registerValidate(program);
|
|
3908
|
+
registerPrompts(program);
|
|
2395
3909
|
registerLogin(program);
|
|
2396
3910
|
registerLogout(program);
|
|
2397
3911
|
registerWhoami(program);
|