@novedu/cli 0.18.0 → 0.20.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +147 -7
- package/dist/main.js +2796 -763
- package/package.json +3 -3
package/dist/main.js
CHANGED
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
3
2
|
import { Command } from "commander";
|
|
4
3
|
import { readFile, writeFile } from "node:fs/promises";
|
|
5
4
|
import { basename, dirname, join, resolve } from "node:path";
|
|
6
5
|
import { spawn } from "node:child_process";
|
|
6
|
+
import { globSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
7
7
|
import { createServer } from "node:http";
|
|
8
8
|
import { homedir } from "node:os";
|
|
9
9
|
import { CryptoProvider, PublicClientApplication } from "@azure/msal-node";
|
|
10
10
|
import { parse, stringify } from "yaml";
|
|
11
11
|
import { z } from "zod";
|
|
12
|
-
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
13
12
|
import Handlebars from "handlebars";
|
|
13
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
14
14
|
//#region src/auth.ts
|
|
15
15
|
const DEFAULT_TENANT_ID = "91fc072c-edef-4f97-bdc5-cfb67718ae3a";
|
|
16
16
|
const DEFAULT_CLIENT_ID = "4d44fc4b-0434-4981-9765-62e2074ceecb";
|
|
@@ -226,18 +226,19 @@ function failJson(value) {
|
|
|
226
226
|
* not abort the run).
|
|
227
227
|
*/
|
|
228
228
|
async function performApiRequest(options) {
|
|
229
|
-
const fail = (value) => {
|
|
229
|
+
const fail = (value, extra = {}) => {
|
|
230
230
|
if (!options.quiet) failJson(value);
|
|
231
231
|
return {
|
|
232
232
|
ok: false,
|
|
233
|
-
error: value
|
|
233
|
+
error: value,
|
|
234
|
+
...extra
|
|
234
235
|
};
|
|
235
236
|
};
|
|
236
237
|
let token;
|
|
237
238
|
try {
|
|
238
239
|
token = await getAccessToken();
|
|
239
240
|
} catch (error) {
|
|
240
|
-
if (error instanceof NotSignedInError) return fail({ message: error.message });
|
|
241
|
+
if (error instanceof NotSignedInError) return fail({ message: error.message }, { authFailed: true });
|
|
241
242
|
throw error;
|
|
242
243
|
}
|
|
243
244
|
const server = resolveServerUrl(options.server);
|
|
@@ -260,7 +261,10 @@ async function performApiRequest(options) {
|
|
|
260
261
|
} catch {
|
|
261
262
|
payload = void 0;
|
|
262
263
|
}
|
|
263
|
-
if (!response.ok) return fail(payload ?? { message: `${server} rejected the request: HTTP ${response.status}` }
|
|
264
|
+
if (!response.ok) return fail(payload ?? { message: `${server} rejected the request: HTTP ${response.status}` }, {
|
|
265
|
+
status: response.status,
|
|
266
|
+
...response.status === 401 || response.status === 403 ? { authFailed: true } : {}
|
|
267
|
+
});
|
|
264
268
|
return {
|
|
265
269
|
ok: true,
|
|
266
270
|
payload
|
|
@@ -278,7 +282,11 @@ async function runApiRequest(options) {
|
|
|
278
282
|
//#endregion
|
|
279
283
|
//#region ../lib/llm/provider.ts
|
|
280
284
|
const LLM_PROVIDERS = ["SCCH", "Azure Foundry"];
|
|
281
|
-
const
|
|
285
|
+
const DEFAULT_PROVIDER = "SCCH";
|
|
286
|
+
const providerSchema = z.enum(LLM_PROVIDERS).default(DEFAULT_PROVIDER).meta({ description: "The LLM provider serving the model. For Azure Foundry, model is the deployment name." });
|
|
287
|
+
function parseLenientProvider(value) {
|
|
288
|
+
return value === "SCCH" || value === "Azure Foundry" ? value : void 0;
|
|
289
|
+
}
|
|
282
290
|
//#endregion
|
|
283
291
|
//#region ../lib/registry-schema.ts
|
|
284
292
|
/** The fixed group names and the code module each one mints for. */
|
|
@@ -872,205 +880,182 @@ function registerCodes(program) {
|
|
|
872
880
|
});
|
|
873
881
|
}
|
|
874
882
|
//#endregion
|
|
875
|
-
//#region
|
|
876
|
-
const
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
883
|
+
//#region ../lib/quiz-verdict-schema.ts
|
|
884
|
+
const QUIZ_VERDICT_SCHEMA = z.object({
|
|
885
|
+
result: z.enum([
|
|
886
|
+
"correct",
|
|
887
|
+
"partial",
|
|
888
|
+
"incorrect"
|
|
889
|
+
]),
|
|
890
|
+
feedback: z.string()
|
|
891
|
+
});
|
|
892
|
+
const QUIZ_VERDICT_ENUM = QUIZ_VERDICT_SCHEMA.shape.result;
|
|
893
|
+
//#endregion
|
|
894
|
+
//#region ../lib/eval-schema.ts
|
|
895
|
+
/** The three verdicts in canonical order (best → worst); the sort key for expected sets. */
|
|
896
|
+
const EVAL_VERDICTS = QUIZ_VERDICT_ENUM.options;
|
|
897
|
+
/**
|
|
898
|
+
* Eval ids share the flat namespace of report headers and `--out` files, so they stay
|
|
899
|
+
* URL- and YAML-plain: an alphanumeric start, then alphanumerics, `.`, `-` or `_`.
|
|
900
|
+
*/
|
|
901
|
+
const EVAL_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
902
|
+
const MAX_ID_LENGTH = 128;
|
|
903
|
+
const expectSchema = z.union([QUIZ_VERDICT_ENUM, z.array(QUIZ_VERDICT_ENUM).min(1)]).meta({ description: "The verdict this answer must be graded with: one of \"correct\", \"partial\", \"incorrect\", or a non-empty list of acceptable verdicts (e.g. [correct, partial]) when more than one grading is defensible." });
|
|
904
|
+
/** One golden answer: the student text plus the verdict(s) the grader must produce. */
|
|
905
|
+
const EvalAnswerSchema = z.strictObject({
|
|
906
|
+
expect: expectSchema,
|
|
907
|
+
answer: z.string().min(1).meta({ description: "The student answer to grade, verbatim. Written as a YAML block scalar (|) for multi-line answers." })
|
|
908
|
+
});
|
|
909
|
+
/** All golden answers for ONE question of the target quiz. */
|
|
910
|
+
const EvalQuestionSchema = z.strictObject({
|
|
911
|
+
question: z.string().min(1).meta({ description: "The question id in the target quiz. For a question imported through quiz_files this is the namespaced \"<alias>/<id>\" form." }),
|
|
912
|
+
answers: z.array(EvalAnswerSchema).min(1).meta({ description: "The golden answers for this question — at least one." })
|
|
913
|
+
});
|
|
914
|
+
/** The whole eval file. */
|
|
915
|
+
const EvalYamlSchema = z.strictObject({
|
|
916
|
+
id: z.string().regex(EVAL_ID_PATTERN).max(MAX_ID_LENGTH).meta({ description: "Stable identifier of this eval, shown in the run report. Letters, digits, dot, dash and underscore." }),
|
|
917
|
+
target: z.string().min(1).meta({ description: "The quiz YAML this eval grades against — a path relative to THIS file, or an absolute http(s) URL." }),
|
|
918
|
+
questions: z.array(EvalQuestionSchema).min(1).meta({ description: "The evaluated questions — at least one, each with its golden answers." })
|
|
919
|
+
});
|
|
920
|
+
/**
|
|
921
|
+
* The canonical expected-verdict SET of one golden answer: a single verdict or a list,
|
|
922
|
+
* deduped and sorted into `EVAL_VERDICTS` order. Canonical because the confusion
|
|
923
|
+
* matrix keys its rows by this set — `correct|partial` must be one row no matter which
|
|
924
|
+
* order the author happened to write it in.
|
|
925
|
+
*/
|
|
926
|
+
function normalizeExpect(expect) {
|
|
927
|
+
return [...new Set(Array.isArray(expect) ? expect : [expect])].sort((a, b) => EVAL_VERDICTS.indexOf(a) - EVAL_VERDICTS.indexOf(b));
|
|
881
928
|
}
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
let content;
|
|
886
|
-
try {
|
|
887
|
-
content = options.file === void 0 ? await readStdin() : await readFile(options.file, "utf8");
|
|
888
|
-
} catch (error) {
|
|
889
|
-
failJson({ message: `Could not read ${options.file}: ${error instanceof Error ? error.message : error}` });
|
|
890
|
-
return;
|
|
891
|
-
}
|
|
892
|
-
await runApiRequest({
|
|
893
|
-
server: options.server,
|
|
894
|
-
path: `/api/files/${encodeURIComponent(name)}`,
|
|
895
|
-
method: "PUT",
|
|
896
|
-
body: {
|
|
897
|
-
...options.kind === void 0 ? {} : { kind: options.kind },
|
|
898
|
-
content
|
|
899
|
-
}
|
|
900
|
-
});
|
|
901
|
-
});
|
|
902
|
-
files.command("list").description("List app-hosted YAML files (defaults to only your own, like the web list)").option("--search <q>", "contains-filter over name/title/description").option("--all", "include files last written by other teachers").option(...SERVER_OPTION$2).action(async (options) => {
|
|
903
|
-
const params = new URLSearchParams();
|
|
904
|
-
if (options.search) params.set("q", options.search);
|
|
905
|
-
if (options.all) params.set("mine", "0");
|
|
906
|
-
const query = params.toString();
|
|
907
|
-
await runApiRequest({
|
|
908
|
-
server: options.server,
|
|
909
|
-
path: `/api/files${query ? `?${query}` : ""}`
|
|
910
|
-
});
|
|
911
|
-
});
|
|
929
|
+
/** The confusion matrix's row key for an expected set (already canonical). */
|
|
930
|
+
function expectedKey(expected) {
|
|
931
|
+
return expected.join("|");
|
|
912
932
|
}
|
|
913
933
|
//#endregion
|
|
914
|
-
//#region ../lib/
|
|
934
|
+
//#region ../lib/coding-proxy.ts
|
|
935
|
+
function isRecord(value) {
|
|
936
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
937
|
+
}
|
|
915
938
|
/**
|
|
916
|
-
*
|
|
917
|
-
*
|
|
918
|
-
*
|
|
939
|
+
* Appends the teacher's instructions to the END of an existing system-message
|
|
940
|
+
* `content`, handling both the string form and OpenAI's content-parts array form.
|
|
941
|
+
* Falls back to the instructions alone when there is no usable existing content.
|
|
919
942
|
*/
|
|
920
|
-
function
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
default: return null;
|
|
928
|
-
}
|
|
943
|
+
function appendInstructions(existing, instructions) {
|
|
944
|
+
if (typeof existing === "string") return existing.trim() === "" ? instructions : `${existing}\n\n${instructions}`;
|
|
945
|
+
if (Array.isArray(existing)) return [...existing, {
|
|
946
|
+
type: "text",
|
|
947
|
+
text: instructions
|
|
948
|
+
}];
|
|
949
|
+
return instructions;
|
|
929
950
|
}
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
return;
|
|
966
|
-
}
|
|
967
|
-
let putResponse;
|
|
968
|
-
try {
|
|
969
|
-
putResponse = await fetch(uploadUrl, {
|
|
970
|
-
method: "PUT",
|
|
971
|
-
headers: {
|
|
972
|
-
"x-ms-blob-type": "BlockBlob",
|
|
973
|
-
"content-type": mime
|
|
974
|
-
},
|
|
975
|
-
body: new Uint8Array(bytes)
|
|
976
|
-
});
|
|
977
|
-
} catch (error) {
|
|
978
|
-
failJson({ message: `Could not reach storage: ${error instanceof Error ? error.message : error}` });
|
|
979
|
-
return;
|
|
980
|
-
}
|
|
981
|
-
if (!putResponse.ok) {
|
|
982
|
-
failJson({ message: `The upload to storage failed: HTTP ${putResponse.status}. Try again.` });
|
|
983
|
-
return;
|
|
984
|
-
}
|
|
985
|
-
await runApiRequest({
|
|
986
|
-
server: options.server,
|
|
987
|
-
path: `/api/images/${encodeURIComponent(name)}/confirm`,
|
|
988
|
-
method: "POST",
|
|
989
|
-
body: {
|
|
990
|
-
blobPath,
|
|
991
|
-
mime,
|
|
992
|
-
...options.credit === void 0 ? {} : { credit: options.credit }
|
|
993
|
-
}
|
|
994
|
-
});
|
|
995
|
-
});
|
|
996
|
-
images.command("list").description("List app-hosted images (defaults to only your own, like the web list)").option("--search <q>", "contains-filter over the name").option("--all", "include images uploaded by other teachers").option(...SERVER_OPTION$1).action(async (options) => {
|
|
997
|
-
const params = new URLSearchParams();
|
|
998
|
-
if (options.search) params.set("q", options.search);
|
|
999
|
-
if (options.all) params.set("mine", "0");
|
|
1000
|
-
const query = params.toString();
|
|
1001
|
-
await runApiRequest({
|
|
1002
|
-
server: options.server,
|
|
1003
|
-
path: `/api/images${query ? `?${query}` : ""}`
|
|
1004
|
-
});
|
|
1005
|
-
});
|
|
951
|
+
/**
|
|
952
|
+
* Builds the upstream Chat Completions body from the client's body: PIN the model and
|
|
953
|
+
* fold in the teacher's system prompt. The teacher's instructions are appended to the
|
|
954
|
+
* END of the client's LAST system message, so the teacher has the final word: a client
|
|
955
|
+
* cannot smuggle a later system message after the teacher's to override it. If the
|
|
956
|
+
* client sent no system message, a leading one carrying only the teacher's instructions
|
|
957
|
+
* is added. Everything else (messages, tools, tool_choice, temperature, stream, …)
|
|
958
|
+
* passes through verbatim, so client-side tools and streaming are all preserved.
|
|
959
|
+
*/
|
|
960
|
+
function buildUpstreamChatBody(clientBody, opts) {
|
|
961
|
+
const clientMessages = Array.isArray(clientBody.messages) ? clientBody.messages : [];
|
|
962
|
+
const systemIndex = clientMessages.findLastIndex((m) => isRecord(m) && m.role === "system");
|
|
963
|
+
let messages;
|
|
964
|
+
if (systemIndex === -1) messages = [{
|
|
965
|
+
role: "system",
|
|
966
|
+
content: opts.instructions
|
|
967
|
+
}, ...clientMessages];
|
|
968
|
+
else {
|
|
969
|
+
const existing = clientMessages[systemIndex];
|
|
970
|
+
messages = [...clientMessages];
|
|
971
|
+
messages[systemIndex] = {
|
|
972
|
+
...existing,
|
|
973
|
+
content: appendInstructions(existing.content, opts.instructions)
|
|
974
|
+
};
|
|
975
|
+
}
|
|
976
|
+
const upstream = {
|
|
977
|
+
...clientBody,
|
|
978
|
+
model: opts.model,
|
|
979
|
+
messages
|
|
980
|
+
};
|
|
981
|
+
if (clientBody.stream === true) upstream.stream_options = {
|
|
982
|
+
...isRecord(clientBody.stream_options) ? clientBody.stream_options : {},
|
|
983
|
+
include_usage: true
|
|
984
|
+
};
|
|
985
|
+
return upstream;
|
|
1006
986
|
}
|
|
1007
987
|
//#endregion
|
|
1008
|
-
//#region
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
const result = options.deviceCode ? await acquireByDeviceCode(pca, (message) => console.log(message)) : await acquireInteractive(pca, (url) => {
|
|
1025
|
-
console.log("A browser window should open for the Microsoft sign-in.");
|
|
1026
|
-
console.log(`If it does not, open this URL yourself:\n${url}`);
|
|
1027
|
-
});
|
|
1028
|
-
console.log(`Signed in as ${displayName(result)}.`);
|
|
1029
|
-
});
|
|
988
|
+
//#region ../lib/prompt-fragments/block.ts
|
|
989
|
+
/**
|
|
990
|
+
* The consumed/empty block a runtime loader leaves behind after resolving fragments
|
|
991
|
+
* into its own field (`Quiz.instructionsPreamble`, or folded into writing/coding
|
|
992
|
+
* `instructions`), so no stale unresolved block lingers as a second source of truth
|
|
993
|
+
* on the loaded object.
|
|
994
|
+
*/
|
|
995
|
+
const EMPTY_FRAGMENT_BLOCK = {
|
|
996
|
+
fragment_files: [],
|
|
997
|
+
text_files: []
|
|
998
|
+
};
|
|
999
|
+
function readFragmentBlock(root) {
|
|
1000
|
+
return {
|
|
1001
|
+
fragment_files: Array.isArray(root.fragment_files) ? root.fragment_files : [],
|
|
1002
|
+
text_files: Array.isArray(root.text_files) ? root.text_files : []
|
|
1003
|
+
};
|
|
1030
1004
|
}
|
|
1031
1005
|
//#endregion
|
|
1032
|
-
//#region
|
|
1033
|
-
function
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
(
|
|
1037
|
-
const cache = buildPca().getTokenCache();
|
|
1038
|
-
for (const account of await cache.getAllAccounts()) await cache.removeAccount(account);
|
|
1039
|
-
rmSync(TOKEN_CACHE_PATH, { force: true });
|
|
1040
|
-
console.log("Signed out.");
|
|
1041
|
-
});
|
|
1006
|
+
//#region ../lib/coding-yaml.ts
|
|
1007
|
+
function asString$2(value) {
|
|
1008
|
+
if (typeof value === "string") return value.trim() !== "" ? value : void 0;
|
|
1009
|
+
if (typeof value === "number") return Number.isFinite(value) ? String(value) : void 0;
|
|
1010
|
+
if (typeof value === "boolean") return String(value);
|
|
1042
1011
|
}
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
}
|
|
1012
|
+
/**
|
|
1013
|
+
* Parses and lightly validates a coding YAML. Returns a friendly error message
|
|
1014
|
+
* (not structured errors) when an essential field is missing — the proxy and the
|
|
1015
|
+
* student page surface it as a notice.
|
|
1016
|
+
*/
|
|
1017
|
+
function parseCoding(content) {
|
|
1018
|
+
let doc;
|
|
1019
|
+
try {
|
|
1020
|
+
doc = parse(content);
|
|
1021
|
+
} catch {
|
|
1022
|
+
return {
|
|
1023
|
+
ok: false,
|
|
1024
|
+
message: "This coding activity could not be read — its YAML is not valid."
|
|
1025
|
+
};
|
|
1026
|
+
}
|
|
1027
|
+
if (typeof doc !== "object" || doc === null || Array.isArray(doc)) return {
|
|
1028
|
+
ok: false,
|
|
1029
|
+
message: "This coding activity is empty or malformed."
|
|
1030
|
+
};
|
|
1031
|
+
const root = doc;
|
|
1032
|
+
const llm = root.llm;
|
|
1033
|
+
const model = asString$2(llm?.model);
|
|
1034
|
+
if (!model) return {
|
|
1035
|
+
ok: false,
|
|
1036
|
+
message: "This coding activity does not specify a model (llm.model)."
|
|
1037
|
+
};
|
|
1038
|
+
const provider = llm?.provider === void 0 ? DEFAULT_PROVIDER : parseLenientProvider(llm.provider);
|
|
1039
|
+
if (!provider) return {
|
|
1040
|
+
ok: false,
|
|
1041
|
+
message: "This coding activity uses an unsupported llm.provider (use \"SCCH\" or \"Azure Foundry\")."
|
|
1042
|
+
};
|
|
1043
|
+
const instructions = asString$2(root.instructions);
|
|
1044
|
+
if (!instructions) return {
|
|
1045
|
+
ok: false,
|
|
1046
|
+
message: "This coding activity has no instructions for the assistant."
|
|
1047
|
+
};
|
|
1048
|
+
return {
|
|
1049
|
+
ok: true,
|
|
1050
|
+
coding: {
|
|
1051
|
+
id: asString$2(root.id) ?? "coding",
|
|
1052
|
+
title: asString$2(root.title),
|
|
1053
|
+
model,
|
|
1054
|
+
provider,
|
|
1055
|
+
instructions,
|
|
1056
|
+
fragmentBlock: readFragmentBlock(root)
|
|
1057
|
+
}
|
|
1058
|
+
};
|
|
1074
1059
|
}
|
|
1075
1060
|
//#endregion
|
|
1076
1061
|
//#region ../lib/prompt-fragments/assemble.ts
|
|
@@ -2152,353 +2137,459 @@ async function loadAndCheckFragmentFile(url, fetchImpl, opts = {}) {
|
|
|
2152
2137
|
return checkFragmentFileValue(yaml.value, url);
|
|
2153
2138
|
}
|
|
2154
2139
|
//#endregion
|
|
2155
|
-
//#region ../lib/coding-
|
|
2156
|
-
const
|
|
2157
|
-
|
|
2158
|
-
|
|
2159
|
-
|
|
2160
|
-
|
|
2161
|
-
|
|
2162
|
-
|
|
2163
|
-
|
|
2164
|
-
|
|
2165
|
-
|
|
2166
|
-
|
|
2167
|
-
|
|
2168
|
-
text_files: z.array(TextFileRefSchema).default([]).meta({ description: "Optional plain-text files (markdown / source, e.g. a sample solution) embedded verbatim into instructions via {{file \"alias\"}} markers." }),
|
|
2169
|
-
instructions: z.string().min(1).meta({ description: "The assistant's system prompt. SERVER-ONLY: never sent to the browser or the coding agent, and appended AFTER the coding tool's own prompt (so the teacher has the final word). Constrain the assistant to what your class has learned. When any fragment_files or text_files are declared it is a Handlebars template: place fragments inline with {{fragment \"alias.id\" …}} and embed text files with {{file \"alias\"}} (optionally {{file \"alias\" from=10 to=40}} for a line range; escape a literal {{ as \\{{)." })
|
|
2170
|
-
});
|
|
2171
|
-
//#endregion
|
|
2172
|
-
//#region ../lib/coding-validate.ts
|
|
2173
|
-
/**
|
|
2174
|
-
* Extract metadata from an already-schema-validated coding value. Split from
|
|
2175
|
-
* `checkCodingValue` so `loadAndCheckCoding` can reuse the single `validate` it already
|
|
2176
|
-
* ran (no second parse of the same document against the same schema).
|
|
2140
|
+
//#region ../lib/coding-resolve.ts
|
|
2141
|
+
const DEFAULT_SCHEMES$2 = ["http:", "https:"];
|
|
2142
|
+
function schemeAllowed$2(url, allowed) {
|
|
2143
|
+
try {
|
|
2144
|
+
return allowed.includes(new URL(url).protocol);
|
|
2145
|
+
} catch {
|
|
2146
|
+
return false;
|
|
2147
|
+
}
|
|
2148
|
+
}
|
|
2149
|
+
/**
|
|
2150
|
+
* Resolve a leniently parsed coding activity into the runnable one. Per-request
|
|
2151
|
+
* streaming hot path: consistency over the referenced fragments only
|
|
2152
|
+
* (`validateLibraries: false`); no extra passes.
|
|
2177
2153
|
*/
|
|
2178
|
-
function
|
|
2154
|
+
async function resolveCoding(coding, url, fetcher, opts = {}) {
|
|
2155
|
+
const resolved = await assembleFragmentPrompt(coding.fragmentBlock, url, fetcher, {
|
|
2156
|
+
validateLibraries: false,
|
|
2157
|
+
allowedSchemes: opts.allowedSchemes ?? DEFAULT_SCHEMES$2
|
|
2158
|
+
}, coding.instructions);
|
|
2159
|
+
if (!resolved.ok) return {
|
|
2160
|
+
ok: false,
|
|
2161
|
+
message: "This coding activity's prompt fragments could not be loaded."
|
|
2162
|
+
};
|
|
2179
2163
|
return {
|
|
2180
2164
|
ok: true,
|
|
2181
|
-
|
|
2182
|
-
|
|
2183
|
-
|
|
2184
|
-
|
|
2185
|
-
|
|
2165
|
+
coding: {
|
|
2166
|
+
...coding,
|
|
2167
|
+
fragmentBlock: EMPTY_FRAGMENT_BLOCK,
|
|
2168
|
+
instructions: resolved.prompt
|
|
2169
|
+
}
|
|
2186
2170
|
};
|
|
2187
2171
|
}
|
|
2188
2172
|
/**
|
|
2189
|
-
*
|
|
2190
|
-
*
|
|
2191
|
-
* CLI
|
|
2173
|
+
* Fetch + lenient-parse + `resolveCoding`, all through the CALLER's fetcher — the
|
|
2174
|
+
* app-free counterpart of `loadCoding` (`lib/coding-fetch.ts`) used by the prompt dump
|
|
2175
|
+
* and the CLI, where there is no database and an activity may live on disk (`file:`).
|
|
2192
2176
|
*/
|
|
2193
|
-
async function
|
|
2194
|
-
const
|
|
2195
|
-
if (!
|
|
2196
|
-
ok: false,
|
|
2197
|
-
errors: [yaml.error],
|
|
2198
|
-
warnings: []
|
|
2199
|
-
};
|
|
2200
|
-
const valid = validate(yaml.value, CodingYamlSchema, "CODING_SCHEMA_ERROR", url);
|
|
2201
|
-
if (!valid.ok) return {
|
|
2202
|
-
ok: false,
|
|
2203
|
-
errors: [valid.error],
|
|
2204
|
-
warnings: []
|
|
2205
|
-
};
|
|
2206
|
-
const checked = checkCodingParsed(valid.data);
|
|
2207
|
-
if (!checked.ok) return checked;
|
|
2208
|
-
const assembled = await assembleFragmentPrompt({
|
|
2209
|
-
fragment_files: valid.data.fragment_files,
|
|
2210
|
-
text_files: valid.data.text_files
|
|
2211
|
-
}, url, fetchImpl, {
|
|
2212
|
-
allowedSchemes: opts.allowedSchemes,
|
|
2213
|
-
validateLibraries: opts.validateLibraries ?? true
|
|
2214
|
-
}, valid.data.instructions);
|
|
2215
|
-
const warnings = [...checked.warnings, ...assembled.warnings];
|
|
2216
|
-
if (!assembled.ok) return {
|
|
2177
|
+
async function loadCodingFrom(url, fetcher, opts = {}) {
|
|
2178
|
+
const allowedSchemes = opts.allowedSchemes ?? DEFAULT_SCHEMES$2;
|
|
2179
|
+
if (!schemeAllowed$2(url, allowedSchemes)) return {
|
|
2217
2180
|
ok: false,
|
|
2218
|
-
|
|
2219
|
-
warnings
|
|
2220
|
-
};
|
|
2221
|
-
return {
|
|
2222
|
-
...checked,
|
|
2223
|
-
warnings
|
|
2181
|
+
message: `This coding activity's URL is not allowed: ${url}`
|
|
2224
2182
|
};
|
|
2183
|
+
try {
|
|
2184
|
+
const res = await fetcher(url);
|
|
2185
|
+
if (!res.ok) return res.status === 404 ? {
|
|
2186
|
+
ok: false,
|
|
2187
|
+
message: "This coding activity could not be found."
|
|
2188
|
+
} : {
|
|
2189
|
+
ok: false,
|
|
2190
|
+
message: `This coding activity could not be loaded (HTTP ${res.status}).`
|
|
2191
|
+
};
|
|
2192
|
+
const parsed = parseCoding(await res.text());
|
|
2193
|
+
if (!parsed.ok) return parsed;
|
|
2194
|
+
return await resolveCoding(parsed.coding, url, fetcher, { allowedSchemes });
|
|
2195
|
+
} catch {
|
|
2196
|
+
return {
|
|
2197
|
+
ok: false,
|
|
2198
|
+
message: "This coding activity could not be loaded. Try again."
|
|
2199
|
+
};
|
|
2200
|
+
}
|
|
2225
2201
|
}
|
|
2226
2202
|
//#endregion
|
|
2227
|
-
//#region ../lib/quiz-
|
|
2203
|
+
//#region ../lib/quiz-types.ts
|
|
2204
|
+
/** The student-facing wording for a verdict — `partial` reads as "partly correct". */
|
|
2205
|
+
function verdictLabel(verdict) {
|
|
2206
|
+
switch (verdict) {
|
|
2207
|
+
case "correct": return "correct";
|
|
2208
|
+
case "partial": return "partly correct";
|
|
2209
|
+
case "incorrect": return "wrong";
|
|
2210
|
+
}
|
|
2211
|
+
}
|
|
2212
|
+
//#endregion
|
|
2213
|
+
//#region ../lib/quiz-discussion-prompt.ts
|
|
2228
2214
|
/**
|
|
2229
|
-
*
|
|
2230
|
-
*
|
|
2231
|
-
*
|
|
2232
|
-
*
|
|
2215
|
+
* The discussion chat's system prompt: the quiz-level `instructionsPreamble` (the
|
|
2216
|
+
* rendered `instructions` host text — shared safety/persona/language rules, the SAME
|
|
2217
|
+
* preamble the grader receives) followed by a default frame and the quiz's optional
|
|
2218
|
+
* `discussionInstructions`. The question/answer/verdict are the thread's seed messages,
|
|
2219
|
+
* recalled from memory, NOT repeated here.
|
|
2220
|
+
*
|
|
2221
|
+
* A compound quiz's imported questions each carry their SOURCE quiz's preamble
|
|
2222
|
+
* (`sourcePreamble`), but that applies to GRADING only (`buildGradingPrompt`): the
|
|
2223
|
+
* discussion prompt uses ONLY the compound file's own instructions — consistent with
|
|
2224
|
+
* every other include-level field (`llm`, `anonymous`, `shuffle`, ...), which the
|
|
2225
|
+
* compound file governs too. Mixing all chapters' preambles into one prompt would put
|
|
2226
|
+
* conflicting persona/language rules in force at once; the question/answer/verdict the
|
|
2227
|
+
* discussion needs are recalled from the thread's seed messages regardless.
|
|
2233
2228
|
*/
|
|
2234
|
-
|
|
2235
|
-
|
|
2236
|
-
|
|
2237
|
-
|
|
2238
|
-
|
|
2239
|
-
url: FragmentFileRefSchema.shape.url.meta({
|
|
2240
|
-
pattern: "^(https?://|(?![A-Za-z][A-Za-z0-9+.-]*:).+)$",
|
|
2241
|
-
description: "HTTP(S) URL or relative path to the included quiz file."
|
|
2242
|
-
})
|
|
2243
|
-
}).meta({
|
|
2244
|
-
id: "quizFileRef",
|
|
2245
|
-
description: "A reference to another quiz file whose questions are included live."
|
|
2246
|
-
});
|
|
2247
|
-
/** An optional content image attached to a question (carries no secret). */
|
|
2248
|
-
const ImageRefSchema = z.strictObject({
|
|
2249
|
-
hosted: z.boolean().optional().meta({
|
|
2250
|
-
default: false,
|
|
2251
|
-
description: "When true, src is an app-hosted image NAME resolved server-side; otherwise src is an absolute URL or a path relative to the quiz's own URL."
|
|
2252
|
-
}),
|
|
2253
|
-
src: z.string().min(1).meta({ description: "The hosted image name (when hosted) or the image URL / relative path." }),
|
|
2254
|
-
alt: z.string().optional().meta({ description: "Accessible description shown if the image cannot be loaded." }),
|
|
2255
|
-
credit: z.string().optional().meta({ description: "Optional attribution (\"Content Credentials\") shown small below the image." })
|
|
2256
|
-
}).meta({
|
|
2257
|
-
id: "image",
|
|
2258
|
-
description: "An optional content image attached to a question."
|
|
2259
|
-
});
|
|
2229
|
+
function buildDiscussionInstructions(quiz) {
|
|
2230
|
+
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.";
|
|
2231
|
+
const frame = quiz.discussionInstructions ? `${base}\n\n${quiz.discussionInstructions.trim()}` : base;
|
|
2232
|
+
return [quiz.instructionsPreamble, frame].filter(Boolean).join("\n\n");
|
|
2233
|
+
}
|
|
2260
2234
|
/**
|
|
2261
|
-
*
|
|
2262
|
-
* `
|
|
2263
|
-
* `evaluation` is the server-only grading prompt.
|
|
2235
|
+
* Seed message 1 (assistant): the question, as the SERVER knows it (authoritative).
|
|
2236
|
+
* `{question}` is the question's trimmed markdown.
|
|
2264
2237
|
*/
|
|
2265
|
-
const
|
|
2266
|
-
|
|
2267
|
-
|
|
2268
|
-
|
|
2269
|
-
|
|
2270
|
-
|
|
2271
|
-
|
|
2272
|
-
}
|
|
2273
|
-
id: "question",
|
|
2274
|
-
description: "One open-ended, LLM-graded quiz question."
|
|
2275
|
-
});
|
|
2276
|
-
const QuizYamlSchema = z.strictObject({
|
|
2277
|
-
id: z.string().min(1).meta({ description: "Short machine-readable quiz id, e.g. countries-basics. Used as the per-quiz identity." }),
|
|
2278
|
-
name: z.string().optional().meta({ description: "Optional human-readable quiz title (used as a label)." }),
|
|
2279
|
-
title: z.string().optional().meta({ description: "Optional greeting shown to students on the welcome screen instead of the default message." }),
|
|
2280
|
-
description: z.string().optional().meta({ description: "Optional description shown to students below the welcome greeting (Markdown)." }),
|
|
2281
|
-
anonymous: z.boolean().optional().meta({
|
|
2282
|
-
default: true,
|
|
2283
|
-
description: "Quizzes are anonymous by default: answers are recorded for aggregate stats but not linked to a student. Set to false to attribute each attempt to the signed-in student."
|
|
2284
|
-
}),
|
|
2285
|
-
shuffle: z.boolean().optional().meta({
|
|
2286
|
-
default: true,
|
|
2287
|
-
description: "Present questions in a random order per attempt. Set to false to keep the authored order."
|
|
2288
|
-
}),
|
|
2289
|
-
question_count: z.number().int().min(1).optional().meta({ description: "How many questions one attempt asks (default: every question exactly once). May exceed the pool size — then questions repeat (drill mode). With shuffle off, fewer than the pool means the first N in authored order." }),
|
|
2290
|
-
quiz_files: z.array(QuizFileRefSchema).default([]).meta({ description: "Other quiz files whose questions are ALL included live into this quiz (a compound/final quiz). One level deep — an included quiz may not itself declare quiz_files." }),
|
|
2291
|
-
llm: z.strictObject({
|
|
2292
|
-
model: z.string().min(1).meta({ description: "The model that grades answers and drives the per-question discussion chat." }),
|
|
2293
|
-
provider: providerSchema,
|
|
2294
|
-
imageInput: z.boolean().optional().meta({
|
|
2295
|
-
default: false,
|
|
2296
|
-
description: "Default for all questions: students may attach photos (up to 3, 5 MB each) to their answers. The model must be vision-capable. A per-question imageInput overrides it."
|
|
2297
|
-
})
|
|
2298
|
-
}).meta({
|
|
2299
|
-
id: "llm",
|
|
2300
|
-
description: "The single model + provider that grades and discusses answers."
|
|
2301
|
-
}),
|
|
2302
|
-
discussion: z.strictObject({ instructions: z.string().min(1).meta({ description: "Optional guidance appended to the per-question follow-up discussion chat's system prompt. When any fragment_files or text_files are declared, place fragments inline with {{fragment \"alias.id\" …}} and embed text files with {{file \"alias\"}} (escape a literal {{ as \\{{) — same rules as instructions." }) }).optional().meta({
|
|
2303
|
-
id: "discussion",
|
|
2304
|
-
description: "Optional guidance for the per-question follow-up discussion chat."
|
|
2305
|
-
}),
|
|
2306
|
-
fragment_files: z.array(FragmentFileRefSchema).default([]).meta({ description: "Optional fragment libraries this quiz pulls shared prompt fragments from." }),
|
|
2307
|
-
text_files: z.array(TextFileRefSchema).default([]).meta({ description: "Optional plain-text files (markdown / source) embedded verbatim into instructions via {{file \"alias\"}} markers." }),
|
|
2308
|
-
instructions: z.string().optional().meta({ description: "Optional quiz-level preamble prepended to BOTH the grader prompt and the discussion chat. When any fragment_files or text_files are declared, 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 \\{{)." }),
|
|
2309
|
-
questions: z.array(QuizQuestionSchema).default([]).meta({ description: "The quiz questions. Each is open-ended and graded by the LLM via its evaluation prompt. May be omitted when quiz_files supplies the questions." })
|
|
2310
|
-
});
|
|
2238
|
+
const QUIZ_SEED_QUESTION_TEMPLATE = "Answer the following question: {question}";
|
|
2239
|
+
/**
|
|
2240
|
+
* Seed message 3 (assistant): the graded outcome. `{verdictLabel}` is the student-facing
|
|
2241
|
+
* wording from `verdictLabel()` (correct / partly correct / wrong), `{feedback}` the
|
|
2242
|
+
* grader's markdown feedback. (Seed message 2 is the student's own answer verbatim, so
|
|
2243
|
+
* it has no template.)
|
|
2244
|
+
*/
|
|
2245
|
+
const QUIZ_SEED_VERDICT_TEMPLATE = "Your answer is {verdictLabel}. {feedback}";
|
|
2311
2246
|
//#endregion
|
|
2312
|
-
//#region ../lib/quiz-
|
|
2313
|
-
/** Quizzes default to anonymous — answers are recorded for stats but not attributed. */
|
|
2314
|
-
const DEFAULT_ANONYMOUS$1 = true;
|
|
2315
|
-
/** Question ids declared on more than one question (the per-question stats key). */
|
|
2316
|
-
function findDuplicateQuestionIds(quiz) {
|
|
2317
|
-
const errors = [];
|
|
2318
|
-
const seen = /* @__PURE__ */ new Set();
|
|
2319
|
-
for (const question of quiz.questions) {
|
|
2320
|
-
if (seen.has(question.id)) {
|
|
2321
|
-
errors.push(error("DUPLICATE_QUIZ_QUESTION_ID", `Question id "${question.id}" is declared more than once`, { questionId: question.id }));
|
|
2322
|
-
continue;
|
|
2323
|
-
}
|
|
2324
|
-
seen.add(question.id);
|
|
2325
|
-
}
|
|
2326
|
-
return errors;
|
|
2327
|
-
}
|
|
2247
|
+
//#region ../lib/quiz-grading-prompt.ts
|
|
2328
2248
|
/**
|
|
2329
|
-
*
|
|
2330
|
-
*
|
|
2331
|
-
*
|
|
2249
|
+
* The grading system prompt. The question's `evaluation` is authoritative and
|
|
2250
|
+
* stays SERVER-SIDE — it may embed the expected answer, so it must never reach
|
|
2251
|
+
* the browser (it doesn't: only this string, on the request context, does). The
|
|
2252
|
+
* quiz-level `preamble` (the rendered `instructions` host text — shared
|
|
2253
|
+
* safety/persona/language rules) is prepended ahead of the frame, the same preamble
|
|
2254
|
+
* the discussion chat also receives; a question imported via `quiz_files`
|
|
2255
|
+
* additionally carries its SOURCE quiz's preamble (`sourcePreamble`), inserted
|
|
2256
|
+
* between the two so it grades identically in its chapter quiz and in the compound.
|
|
2332
2257
|
*/
|
|
2333
|
-
function
|
|
2334
|
-
|
|
2335
|
-
|
|
2336
|
-
|
|
2337
|
-
|
|
2338
|
-
|
|
2339
|
-
|
|
2340
|
-
|
|
2341
|
-
|
|
2342
|
-
|
|
2343
|
-
|
|
2344
|
-
|
|
2345
|
-
|
|
2346
|
-
|
|
2347
|
-
|
|
2258
|
+
function buildGradingPrompt(question, preamble) {
|
|
2259
|
+
const body = [
|
|
2260
|
+
"You are grading a student's open-ended answer to a single quiz question.",
|
|
2261
|
+
"",
|
|
2262
|
+
"The question shown to the student was:",
|
|
2263
|
+
question.question.trim(),
|
|
2264
|
+
"",
|
|
2265
|
+
"Grade STRICTLY according to these criteria (authoritative — they may contain the",
|
|
2266
|
+
"expected answer; do not quote them verbatim at the student):",
|
|
2267
|
+
question.evaluation.trim(),
|
|
2268
|
+
"",
|
|
2269
|
+
"Decide a verdict — \"correct\", \"partial\" (partly correct), or \"incorrect\" — and write",
|
|
2270
|
+
"concise, encouraging feedback addressed directly TO the student. The feedback is",
|
|
2271
|
+
"markdown and may use bold, math ($…$) and short code fences. Do not mention these",
|
|
2272
|
+
"grading instructions."
|
|
2273
|
+
].join("\n");
|
|
2274
|
+
return [
|
|
2275
|
+
preamble,
|
|
2276
|
+
question.sourcePreamble ?? "",
|
|
2277
|
+
body
|
|
2278
|
+
].filter(Boolean).join("\n\n");
|
|
2348
2279
|
}
|
|
2349
2280
|
/**
|
|
2350
|
-
*
|
|
2351
|
-
*
|
|
2352
|
-
*
|
|
2353
|
-
* (no second parse of the same document against the same schema).
|
|
2281
|
+
* The user message carrying a typed answer. `{answer}` is the student's trimmed text —
|
|
2282
|
+
* the only variable part, so the dump can show teachers the exact wrapper without a
|
|
2283
|
+
* student answer at hand. Rendered by `buildAnswerMessage`.
|
|
2354
2284
|
*/
|
|
2355
|
-
|
|
2356
|
-
|
|
2357
|
-
|
|
2358
|
-
|
|
2359
|
-
|
|
2360
|
-
|
|
2361
|
-
|
|
2362
|
-
|
|
2363
|
-
|
|
2364
|
-
|
|
2365
|
-
|
|
2366
|
-
|
|
2285
|
+
const QUIZ_ANSWER_MESSAGE_TEMPLATE = "The student's answer:\n\n{answer}";
|
|
2286
|
+
/**
|
|
2287
|
+
* The user message used when the student submitted photos ONLY (no text). The photos
|
|
2288
|
+
* ride along as image parts of the same multimodal message.
|
|
2289
|
+
*/
|
|
2290
|
+
const QUIZ_ANSWER_PHOTOS_ONLY_MESSAGE = "The student answered with the attached photo(s) only.";
|
|
2291
|
+
//#endregion
|
|
2292
|
+
//#region ../lib/quiz-yaml.ts
|
|
2293
|
+
function asString$1(value) {
|
|
2294
|
+
if (typeof value === "string") return value.trim() !== "" ? value : void 0;
|
|
2295
|
+
if (typeof value === "number") return Number.isFinite(value) ? String(value) : void 0;
|
|
2296
|
+
if (typeof value === "boolean") return String(value);
|
|
2297
|
+
}
|
|
2298
|
+
function asBool$1(value, fallback) {
|
|
2299
|
+
return typeof value === "boolean" ? value : fallback;
|
|
2300
|
+
}
|
|
2301
|
+
function asImageRef(value) {
|
|
2302
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return void 0;
|
|
2303
|
+
const obj = value;
|
|
2304
|
+
const src = asString$1(obj.src);
|
|
2305
|
+
if (!src) return void 0;
|
|
2306
|
+
const alt = asString$1(obj.alt);
|
|
2307
|
+
const credit = asString$1(obj.credit);
|
|
2367
2308
|
return {
|
|
2368
|
-
|
|
2369
|
-
|
|
2370
|
-
|
|
2371
|
-
|
|
2372
|
-
questionCount: quiz.questions.length,
|
|
2373
|
-
anonymous: quiz.anonymous ?? DEFAULT_ANONYMOUS$1,
|
|
2374
|
-
title: quiz.title ?? null,
|
|
2375
|
-
warnings: []
|
|
2309
|
+
hosted: asBool$1(obj.hosted, false),
|
|
2310
|
+
src,
|
|
2311
|
+
...alt ? { alt } : {},
|
|
2312
|
+
...credit ? { credit } : {}
|
|
2376
2313
|
};
|
|
2377
2314
|
}
|
|
2378
|
-
/** Wrap an include's nested failures into ONE error carrying the alias + URL. */
|
|
2379
|
-
function includeUnreadable(alias, url, nested) {
|
|
2380
|
-
return error("QUIZ_INCLUDE_UNREADABLE", `Included quiz "${alias}" is not usable: ${nested.map((e) => e.message).join("; ")}`, url === void 0 ? { fileAlias: alias } : {
|
|
2381
|
-
fileAlias: alias,
|
|
2382
|
-
url
|
|
2383
|
-
});
|
|
2384
|
-
}
|
|
2385
2315
|
/**
|
|
2386
|
-
*
|
|
2387
|
-
*
|
|
2388
|
-
*
|
|
2389
|
-
* as `QUIZ_INCLUDE_UNREADABLE` with the alias + resolved URL.
|
|
2316
|
+
* Parses and lightly validates a quiz YAML. Returns a friendly error message
|
|
2317
|
+
* (not structured errors) when an essential field is missing — the student page
|
|
2318
|
+
* shows it as a notice. `anonymous` and `shuffle` default to `true`.
|
|
2390
2319
|
*/
|
|
2391
|
-
|
|
2392
|
-
let
|
|
2320
|
+
function parseQuiz(content) {
|
|
2321
|
+
let doc;
|
|
2393
2322
|
try {
|
|
2394
|
-
|
|
2323
|
+
doc = parse(content);
|
|
2395
2324
|
} catch {
|
|
2396
2325
|
return {
|
|
2397
2326
|
ok: false,
|
|
2398
|
-
|
|
2399
|
-
warnings: []
|
|
2327
|
+
message: "This quiz could not be read — its YAML is not valid."
|
|
2400
2328
|
};
|
|
2401
2329
|
}
|
|
2402
|
-
|
|
2403
|
-
if (!yaml.ok) return {
|
|
2330
|
+
if (typeof doc !== "object" || doc === null || Array.isArray(doc)) return {
|
|
2404
2331
|
ok: false,
|
|
2405
|
-
|
|
2406
|
-
warnings: []
|
|
2332
|
+
message: "This quiz is empty or malformed."
|
|
2407
2333
|
};
|
|
2408
|
-
const
|
|
2409
|
-
|
|
2334
|
+
const root = doc;
|
|
2335
|
+
const llm = root.llm;
|
|
2336
|
+
const model = asString$1(llm?.model);
|
|
2337
|
+
if (!model) return {
|
|
2410
2338
|
ok: false,
|
|
2411
|
-
|
|
2412
|
-
warnings: []
|
|
2339
|
+
message: "This quiz does not specify a model (llm.model)."
|
|
2413
2340
|
};
|
|
2414
|
-
|
|
2341
|
+
const provider = llm?.provider === void 0 ? DEFAULT_PROVIDER : parseLenientProvider(llm.provider);
|
|
2342
|
+
if (!provider) return {
|
|
2415
2343
|
ok: false,
|
|
2416
|
-
|
|
2417
|
-
fileAlias: ref.id,
|
|
2418
|
-
url: includeUrl
|
|
2419
|
-
})],
|
|
2420
|
-
warnings: []
|
|
2344
|
+
message: "This quiz uses an unsupported llm.provider (use \"SCCH\" or \"Azure Foundry\")."
|
|
2421
2345
|
};
|
|
2422
|
-
const
|
|
2423
|
-
|
|
2346
|
+
const quizFiles = Array.isArray(root.quiz_files) ? root.quiz_files : [];
|
|
2347
|
+
const rawQuestions = Array.isArray(root.questions) ? root.questions : [];
|
|
2348
|
+
if (rawQuestions.length === 0 && quizFiles.length === 0) return {
|
|
2424
2349
|
ok: false,
|
|
2425
|
-
|
|
2426
|
-
warnings: checked.warnings
|
|
2350
|
+
message: "This quiz has no questions."
|
|
2427
2351
|
};
|
|
2428
|
-
const
|
|
2429
|
-
|
|
2430
|
-
|
|
2431
|
-
|
|
2432
|
-
|
|
2433
|
-
|
|
2434
|
-
|
|
2435
|
-
|
|
2352
|
+
const questions = [];
|
|
2353
|
+
const seenIds = /* @__PURE__ */ new Set();
|
|
2354
|
+
for (const raw of rawQuestions) {
|
|
2355
|
+
if (typeof raw !== "object" || raw === null) continue;
|
|
2356
|
+
const q = raw;
|
|
2357
|
+
const id = asString$1(q.id);
|
|
2358
|
+
const question = asString$1(q.question);
|
|
2359
|
+
const evaluation = asString$1(q.evaluation);
|
|
2360
|
+
if (!id || !question || !evaluation || seenIds.has(id)) continue;
|
|
2361
|
+
seenIds.add(id);
|
|
2362
|
+
questions.push({
|
|
2363
|
+
id,
|
|
2364
|
+
title: asString$1(q.title),
|
|
2365
|
+
question,
|
|
2366
|
+
evaluation,
|
|
2367
|
+
image: asImageRef(q.image),
|
|
2368
|
+
...typeof q.imageInput === "boolean" ? { imageInput: q.imageInput } : {}
|
|
2369
|
+
});
|
|
2370
|
+
}
|
|
2371
|
+
if (questions.length === 0 && quizFiles.length === 0) return {
|
|
2436
2372
|
ok: false,
|
|
2437
|
-
|
|
2438
|
-
warnings: assembled.warnings
|
|
2373
|
+
message: "This quiz has no complete questions (each needs an id, question and evaluation)."
|
|
2439
2374
|
};
|
|
2375
|
+
const rawCount = root.question_count;
|
|
2376
|
+
const questionCount = typeof rawCount === "number" && Number.isInteger(rawCount) && rawCount >= 1 ? rawCount : void 0;
|
|
2440
2377
|
return {
|
|
2441
2378
|
ok: true,
|
|
2442
|
-
|
|
2443
|
-
|
|
2379
|
+
quiz: {
|
|
2380
|
+
id: asString$1(root.id) ?? asString$1(root.name) ?? "quiz",
|
|
2381
|
+
name: asString$1(root.name),
|
|
2382
|
+
title: asString$1(root.title),
|
|
2383
|
+
description: asString$1(root.description),
|
|
2384
|
+
anonymous: asBool$1(root.anonymous, true),
|
|
2385
|
+
shuffle: asBool$1(root.shuffle, true),
|
|
2386
|
+
model,
|
|
2387
|
+
provider,
|
|
2388
|
+
questionCount,
|
|
2389
|
+
imageInput: asBool$1(llm?.imageInput, false),
|
|
2390
|
+
discussionInstructions: asString$1(root.discussion?.instructions),
|
|
2391
|
+
instructions: asString$1(root.instructions),
|
|
2392
|
+
fragmentBlock: readFragmentBlock(root),
|
|
2393
|
+
quizFiles,
|
|
2394
|
+
instructionsPreamble: "",
|
|
2395
|
+
questions
|
|
2396
|
+
}
|
|
2444
2397
|
};
|
|
2445
2398
|
}
|
|
2446
2399
|
/**
|
|
2447
|
-
*
|
|
2448
|
-
* `
|
|
2449
|
-
*
|
|
2450
|
-
*
|
|
2451
|
-
*
|
|
2452
|
-
* (own + imported), so the `/files` save UI and code-create metadata reflect the
|
|
2453
|
-
* real exam size. The web app passes the default http(s)-only schemes; the CLI adds
|
|
2454
|
-
* `file:` so a local quiz YAML on disk validates too.
|
|
2400
|
+
* A question's EFFECTIVE photo-answers flag: the per-question override when set, the
|
|
2401
|
+
* quiz-level `llm.imageInput` otherwise. The ONE definition of that two-level rule —
|
|
2402
|
+
* re-exported by `lib/quiz-verify.ts` for the server actions (which re-derive it on
|
|
2403
|
+
* every request, never trusting the client), applied by `toPublicQuiz` below, and
|
|
2404
|
+
* reported per question by the prompt dump.
|
|
2455
2405
|
*/
|
|
2456
|
-
|
|
2457
|
-
|
|
2458
|
-
|
|
2406
|
+
function effectiveImageInput(quiz, question) {
|
|
2407
|
+
return question.imageInput ?? quiz.imageInput;
|
|
2408
|
+
}
|
|
2409
|
+
//#endregion
|
|
2410
|
+
//#region ../lib/quiz-resolve.ts
|
|
2411
|
+
const DEFAULT_SCHEMES$1 = ["http:", "https:"];
|
|
2412
|
+
function schemeAllowed$1(url, allowed) {
|
|
2413
|
+
try {
|
|
2414
|
+
return allowed.includes(new URL(url).protocol);
|
|
2415
|
+
} catch {
|
|
2416
|
+
return false;
|
|
2417
|
+
}
|
|
2418
|
+
}
|
|
2419
|
+
/**
|
|
2420
|
+
* Renders ONE quiz document's `instructions` host text against its OWN fragment
|
|
2421
|
+
* block, relative to its OWN URL (`validateLibraries: false` — the hot path). Used
|
|
2422
|
+
* for each included source quiz, so an imported question's `sourcePreamble` is
|
|
2423
|
+
* exactly what its chapter quiz would grade with. (The root document renders its
|
|
2424
|
+
* two host texts — `instructions` + `discussion.instructions` — in `resolveQuiz`.)
|
|
2425
|
+
*/
|
|
2426
|
+
async function renderPreamble(quiz, url, fetcher, allowedSchemes) {
|
|
2427
|
+
const resolved = await assembleFragmentPrompt(quiz.fragmentBlock, url, fetcher, {
|
|
2428
|
+
validateLibraries: false,
|
|
2429
|
+
allowedSchemes
|
|
2430
|
+
}, quiz.instructions ?? "");
|
|
2431
|
+
if (!resolved.ok) return { ok: false };
|
|
2432
|
+
return {
|
|
2433
|
+
ok: true,
|
|
2434
|
+
preamble: resolved.prompt.trimEnd()
|
|
2435
|
+
};
|
|
2436
|
+
}
|
|
2437
|
+
/**
|
|
2438
|
+
* Absolutize an imported question's content image against the SOURCE quiz URL, so
|
|
2439
|
+
* a `./diagram.png` next to the chapter quiz still resolves from the compound quiz
|
|
2440
|
+
* (whose own `file_url` is elsewhere). Hosted NAMES and absolute URLs pass through
|
|
2441
|
+
* unchanged — they resolve the same from anywhere.
|
|
2442
|
+
*/
|
|
2443
|
+
function absolutizeImage(image, sourceUrl) {
|
|
2444
|
+
if (!image || image.hosted === true || /^https?:\/\//i.test(image.src)) return image;
|
|
2445
|
+
try {
|
|
2446
|
+
return {
|
|
2447
|
+
...image,
|
|
2448
|
+
src: resolveFragmentUrl(image.src, sourceUrl)
|
|
2449
|
+
};
|
|
2450
|
+
} catch {
|
|
2451
|
+
return image;
|
|
2452
|
+
}
|
|
2453
|
+
}
|
|
2454
|
+
/** Resolve ONE `quiz_files` include into its namespaced, import-transformed questions. */
|
|
2455
|
+
async function resolveInclude(ref, baseUrl, fetcher, allowedSchemes) {
|
|
2456
|
+
const alias = typeof ref?.id === "string" ? ref.id.trim() : "";
|
|
2457
|
+
const rawUrl = typeof ref?.url === "string" ? ref.url.trim() : "";
|
|
2458
|
+
if (!alias || /[./]/.test(alias) || !rawUrl) return {
|
|
2459
2459
|
ok: false,
|
|
2460
|
-
|
|
2461
|
-
warnings: []
|
|
2460
|
+
message: "This quiz declares an invalid quiz_files entry."
|
|
2462
2461
|
};
|
|
2463
|
-
|
|
2464
|
-
|
|
2462
|
+
let sourceUrl;
|
|
2463
|
+
try {
|
|
2464
|
+
sourceUrl = resolveFragmentUrl(rawUrl, baseUrl);
|
|
2465
|
+
} catch {
|
|
2466
|
+
return {
|
|
2467
|
+
ok: false,
|
|
2468
|
+
message: `The included quiz "${alias}" has an invalid URL.`
|
|
2469
|
+
};
|
|
2470
|
+
}
|
|
2471
|
+
if (!schemeAllowed$1(sourceUrl, allowedSchemes)) return {
|
|
2465
2472
|
ok: false,
|
|
2466
|
-
|
|
2467
|
-
warnings: []
|
|
2473
|
+
message: `The included quiz "${alias}" has an invalid URL.`
|
|
2468
2474
|
};
|
|
2469
|
-
|
|
2470
|
-
|
|
2471
|
-
|
|
2472
|
-
|
|
2473
|
-
|
|
2474
|
-
|
|
2475
|
-
|
|
2476
|
-
|
|
2477
|
-
}
|
|
2478
|
-
|
|
2479
|
-
|
|
2475
|
+
let body;
|
|
2476
|
+
try {
|
|
2477
|
+
const res = await fetcher(sourceUrl);
|
|
2478
|
+
if (!res.ok) return {
|
|
2479
|
+
ok: false,
|
|
2480
|
+
message: `The included quiz "${alias}" could not be loaded.`
|
|
2481
|
+
};
|
|
2482
|
+
body = await res.text();
|
|
2483
|
+
} catch {
|
|
2484
|
+
return {
|
|
2485
|
+
ok: false,
|
|
2486
|
+
message: `The included quiz "${alias}" could not be loaded.`
|
|
2487
|
+
};
|
|
2488
|
+
}
|
|
2489
|
+
const parsed = parseQuiz(body);
|
|
2490
|
+
if (!parsed.ok) return {
|
|
2480
2491
|
ok: false,
|
|
2481
|
-
|
|
2482
|
-
warnings
|
|
2492
|
+
message: `The included quiz "${alias}" is not a usable quiz file.`
|
|
2483
2493
|
};
|
|
2484
|
-
|
|
2485
|
-
|
|
2486
|
-
|
|
2494
|
+
if (parsed.quiz.quizFiles.length > 0) return {
|
|
2495
|
+
ok: false,
|
|
2496
|
+
message: `The included quiz "${alias}" itself includes other quizzes — includes cannot be nested.`
|
|
2497
|
+
};
|
|
2498
|
+
const preamble = await renderPreamble(parsed.quiz, sourceUrl, fetcher, allowedSchemes);
|
|
2499
|
+
if (!preamble.ok) return {
|
|
2500
|
+
ok: false,
|
|
2501
|
+
message: `The included quiz "${alias}"'s prompt fragments could not be loaded.`
|
|
2502
|
+
};
|
|
2503
|
+
const source = parsed.quiz;
|
|
2504
|
+
return {
|
|
2505
|
+
ok: true,
|
|
2506
|
+
questions: source.questions.map((q) => ({
|
|
2507
|
+
...q,
|
|
2508
|
+
id: `${alias}/${q.id}`,
|
|
2509
|
+
imageInput: q.imageInput ?? source.imageInput,
|
|
2510
|
+
image: absolutizeImage(q.image, sourceUrl),
|
|
2511
|
+
...preamble.preamble ? { sourcePreamble: preamble.preamble } : {}
|
|
2512
|
+
}))
|
|
2513
|
+
};
|
|
2514
|
+
}
|
|
2515
|
+
/**
|
|
2516
|
+
* Resolve a leniently parsed quiz into the runnable one: render its two host texts,
|
|
2517
|
+
* then merge in every `quiz_files` include. `url` is the quiz's own URL (the base for
|
|
2518
|
+
* relative fragment/include refs); `fetcher` is the caller's network seam.
|
|
2519
|
+
*/
|
|
2520
|
+
async function resolveQuiz(quiz, url, fetcher, opts = {}) {
|
|
2521
|
+
const allowedSchemes = opts.allowedSchemes ?? DEFAULT_SCHEMES$1;
|
|
2522
|
+
const resolved = await assembleFragmentPrompts(quiz.fragmentBlock, url, fetcher, {
|
|
2523
|
+
validateLibraries: false,
|
|
2524
|
+
allowedSchemes
|
|
2525
|
+
}, [quiz.instructions ?? "", quiz.discussionInstructions ?? ""]);
|
|
2526
|
+
if (!resolved.ok) return {
|
|
2527
|
+
ok: false,
|
|
2528
|
+
message: "This quiz's prompt fragments could not be loaded."
|
|
2529
|
+
};
|
|
2530
|
+
const [instructionsPreamble = "", discussionInstructions = ""] = resolved.prompts;
|
|
2531
|
+
const refs = quiz.quizFiles;
|
|
2532
|
+
const aliases = /* @__PURE__ */ new Set();
|
|
2533
|
+
for (const ref of refs) {
|
|
2534
|
+
const alias = typeof ref?.id === "string" ? ref.id.trim() : "";
|
|
2535
|
+
if (aliases.has(alias)) return {
|
|
2536
|
+
ok: false,
|
|
2537
|
+
message: `This quiz declares the included-quiz alias "${alias}" twice.`
|
|
2538
|
+
};
|
|
2539
|
+
aliases.add(alias);
|
|
2540
|
+
}
|
|
2541
|
+
const includes = await Promise.all(refs.map((ref) => resolveInclude(ref, url, fetcher, allowedSchemes)));
|
|
2542
|
+
const imported = [];
|
|
2487
2543
|
for (const include of includes) {
|
|
2488
|
-
|
|
2489
|
-
|
|
2490
|
-
else includeErrors.push(...include.errors);
|
|
2544
|
+
if (!include.ok) return include;
|
|
2545
|
+
imported.push(...include.questions);
|
|
2491
2546
|
}
|
|
2492
|
-
|
|
2547
|
+
const questions = [...quiz.questions, ...imported];
|
|
2548
|
+
if (questions.length === 0) return {
|
|
2493
2549
|
ok: false,
|
|
2494
|
-
|
|
2495
|
-
warnings
|
|
2550
|
+
message: "This quiz has no questions."
|
|
2496
2551
|
};
|
|
2497
2552
|
return {
|
|
2498
|
-
|
|
2499
|
-
|
|
2500
|
-
|
|
2553
|
+
ok: true,
|
|
2554
|
+
quiz: {
|
|
2555
|
+
...quiz,
|
|
2556
|
+
fragmentBlock: EMPTY_FRAGMENT_BLOCK,
|
|
2557
|
+
quizFiles: [],
|
|
2558
|
+
instructionsPreamble: instructionsPreamble.trimEnd(),
|
|
2559
|
+
discussionInstructions: discussionInstructions.trim() !== "" ? discussionInstructions.trimEnd() : void 0,
|
|
2560
|
+
questions
|
|
2561
|
+
}
|
|
2562
|
+
};
|
|
2563
|
+
}
|
|
2564
|
+
/**
|
|
2565
|
+
* Fetch + lenient-parse + `resolveQuiz`, all through the CALLER's fetcher — the
|
|
2566
|
+
* app-free counterpart of `loadQuiz` (`lib/quiz-fetch.ts`) used by the prompt dump and
|
|
2567
|
+
* the CLI, where there is no database and a quiz may live on disk (`file:`).
|
|
2568
|
+
*/
|
|
2569
|
+
async function loadQuizFrom(url, fetcher, opts = {}) {
|
|
2570
|
+
const allowedSchemes = opts.allowedSchemes ?? DEFAULT_SCHEMES$1;
|
|
2571
|
+
if (!schemeAllowed$1(url, allowedSchemes)) return {
|
|
2572
|
+
ok: false,
|
|
2573
|
+
message: `This quiz's URL is not allowed: ${url}`
|
|
2501
2574
|
};
|
|
2575
|
+
try {
|
|
2576
|
+
const res = await fetcher(url);
|
|
2577
|
+
if (!res.ok) return res.status === 404 ? {
|
|
2578
|
+
ok: false,
|
|
2579
|
+
message: "This quiz could not be found."
|
|
2580
|
+
} : {
|
|
2581
|
+
ok: false,
|
|
2582
|
+
message: `This quiz could not be loaded (HTTP ${res.status}).`
|
|
2583
|
+
};
|
|
2584
|
+
const parsed = parseQuiz(await res.text());
|
|
2585
|
+
if (!parsed.ok) return parsed;
|
|
2586
|
+
return await resolveQuiz(parsed.quiz, url, fetcher, { allowedSchemes });
|
|
2587
|
+
} catch {
|
|
2588
|
+
return {
|
|
2589
|
+
ok: false,
|
|
2590
|
+
message: "This quiz could not be loaded. Try again."
|
|
2591
|
+
};
|
|
2592
|
+
}
|
|
2502
2593
|
}
|
|
2503
2594
|
//#endregion
|
|
2504
2595
|
//#region ../lib/tutors/schemas.ts
|
|
@@ -2570,6 +2661,7 @@ async function loadAndBuildTutorPrompt(url, fetchImpl, opts = {}) {
|
|
|
2570
2661
|
};
|
|
2571
2662
|
return {
|
|
2572
2663
|
ok: true,
|
|
2664
|
+
id: tutor.id,
|
|
2573
2665
|
prompt: assembled.prompt,
|
|
2574
2666
|
model: tutor.llm.model,
|
|
2575
2667
|
provider: tutor.llm.provider,
|
|
@@ -2582,348 +2674,2288 @@ async function loadAndBuildTutorPrompt(url, fetchImpl, opts = {}) {
|
|
|
2582
2674
|
};
|
|
2583
2675
|
}
|
|
2584
2676
|
//#endregion
|
|
2585
|
-
//#region ../lib/writing-
|
|
2586
|
-
|
|
2587
|
-
|
|
2588
|
-
|
|
2589
|
-
|
|
2590
|
-
|
|
2591
|
-
|
|
2592
|
-
|
|
2593
|
-
description: "Writing DIVERGES: it defaults to false (attributed), because review and the Save feature need to know whose text it is. Set to true for ephemeral, unattributed writing — which also disables saving."
|
|
2594
|
-
}),
|
|
2595
|
-
llm: z.strictObject({
|
|
2596
|
-
model: z.string().min(1).meta({ description: "The model that drives the feedback chat." }),
|
|
2597
|
-
provider: providerSchema
|
|
2598
|
-
}).meta({
|
|
2599
|
-
id: "llm",
|
|
2600
|
-
description: "The model and provider that back the writing coach."
|
|
2601
|
-
}),
|
|
2602
|
-
fragment_files: z.array(FragmentFileRefSchema).default([]).meta({ description: "Optional fragment libraries this activity pulls shared prompt fragments from." }),
|
|
2603
|
-
text_files: z.array(TextFileRefSchema).default([]).meta({ description: "Optional plain-text files (markdown / source) embedded verbatim into instructions via {{file \"alias\"}} markers." }),
|
|
2604
|
-
instructions: z.string().min(1).meta({ description: "The writing coach's system prompt. SERVER-ONLY: never sent to the browser, so it may describe the assessment criteria and coaching strategy. When any fragment_files or text_files are declared it is a Handlebars template: place fragments inline with {{fragment \"alias.id\" …}} and embed text files with {{file \"alias\"}} (optionally {{file \"alias\" from=10 to=40}} for a line range; escape a literal {{ as \\{{)." }),
|
|
2605
|
-
placeholder: z.string().optional().meta({ description: "Optional starter text prefilled into the editor. Empty for a blank page." })
|
|
2606
|
-
});
|
|
2607
|
-
//#endregion
|
|
2608
|
-
//#region ../lib/writing-validate.ts
|
|
2609
|
-
/** Writing DIVERGES from tutor/quiz: it defaults to attributed (`anonymous: false`). */
|
|
2610
|
-
const DEFAULT_ANONYMOUS = false;
|
|
2611
|
-
/**
|
|
2612
|
-
* Extract metadata from an already-schema-validated writing value. Split from
|
|
2613
|
-
* `checkWritingValue` so `loadAndCheckWriting` can reuse the single `validate` it already
|
|
2614
|
-
* ran (no second parse of the same document against the same schema).
|
|
2615
|
-
*/
|
|
2616
|
-
function checkWritingParsed(writing) {
|
|
2617
|
-
return {
|
|
2618
|
-
ok: true,
|
|
2619
|
-
writingId: writing.id,
|
|
2620
|
-
model: writing.llm.model,
|
|
2621
|
-
provider: writing.llm.provider,
|
|
2622
|
-
anonymous: writing.anonymous ?? DEFAULT_ANONYMOUS,
|
|
2623
|
-
title: writing.title ?? null,
|
|
2624
|
-
warnings: []
|
|
2625
|
-
};
|
|
2677
|
+
//#region ../lib/writing-yaml.ts
|
|
2678
|
+
function asString(value) {
|
|
2679
|
+
if (typeof value === "string") return value.trim() !== "" ? value : void 0;
|
|
2680
|
+
if (typeof value === "number") return Number.isFinite(value) ? String(value) : void 0;
|
|
2681
|
+
if (typeof value === "boolean") return String(value);
|
|
2682
|
+
}
|
|
2683
|
+
function asBool(value, fallback) {
|
|
2684
|
+
return typeof value === "boolean" ? value : fallback;
|
|
2626
2685
|
}
|
|
2627
2686
|
/**
|
|
2628
|
-
*
|
|
2629
|
-
*
|
|
2630
|
-
*
|
|
2687
|
+
* Parses and lightly validates a writing YAML. Returns a friendly error message
|
|
2688
|
+
* (not structured errors) when an essential field is missing — the student page
|
|
2689
|
+
* shows it as a notice. `anonymous` DEFAULTS to `false` (the writing divergence).
|
|
2631
2690
|
*/
|
|
2632
|
-
|
|
2633
|
-
|
|
2634
|
-
|
|
2691
|
+
function parseWriting(content) {
|
|
2692
|
+
let doc;
|
|
2693
|
+
try {
|
|
2694
|
+
doc = parse(content);
|
|
2695
|
+
} catch {
|
|
2696
|
+
return {
|
|
2697
|
+
ok: false,
|
|
2698
|
+
message: "This writing activity could not be read — its YAML is not valid."
|
|
2699
|
+
};
|
|
2700
|
+
}
|
|
2701
|
+
if (typeof doc !== "object" || doc === null || Array.isArray(doc)) return {
|
|
2635
2702
|
ok: false,
|
|
2636
|
-
|
|
2637
|
-
warnings: []
|
|
2703
|
+
message: "This writing activity is empty or malformed."
|
|
2638
2704
|
};
|
|
2639
|
-
const
|
|
2640
|
-
|
|
2705
|
+
const root = doc;
|
|
2706
|
+
const llm = root.llm;
|
|
2707
|
+
const model = asString(llm?.model);
|
|
2708
|
+
if (!model) return {
|
|
2641
2709
|
ok: false,
|
|
2642
|
-
|
|
2643
|
-
warnings: []
|
|
2710
|
+
message: "This writing activity does not specify a model (llm.model)."
|
|
2644
2711
|
};
|
|
2645
|
-
const
|
|
2646
|
-
if (!
|
|
2647
|
-
const assembled = await assembleFragmentPrompt({
|
|
2648
|
-
fragment_files: valid.data.fragment_files,
|
|
2649
|
-
text_files: valid.data.text_files
|
|
2650
|
-
}, url, fetchImpl, {
|
|
2651
|
-
allowedSchemes: opts.allowedSchemes,
|
|
2652
|
-
validateLibraries: opts.validateLibraries ?? true
|
|
2653
|
-
}, valid.data.instructions);
|
|
2654
|
-
const warnings = [...checked.warnings, ...assembled.warnings];
|
|
2655
|
-
if (!assembled.ok) return {
|
|
2712
|
+
const provider = llm?.provider === void 0 ? DEFAULT_PROVIDER : parseLenientProvider(llm.provider);
|
|
2713
|
+
if (!provider) return {
|
|
2656
2714
|
ok: false,
|
|
2657
|
-
|
|
2658
|
-
|
|
2715
|
+
message: "This writing activity uses an unsupported llm.provider (use \"SCCH\" or \"Azure Foundry\")."
|
|
2716
|
+
};
|
|
2717
|
+
const instructions = asString(root.instructions);
|
|
2718
|
+
if (!instructions) return {
|
|
2719
|
+
ok: false,
|
|
2720
|
+
message: "This writing activity has no instructions for the assistant."
|
|
2659
2721
|
};
|
|
2660
2722
|
return {
|
|
2661
|
-
|
|
2662
|
-
|
|
2723
|
+
ok: true,
|
|
2724
|
+
writing: {
|
|
2725
|
+
id: asString(root.id) ?? asString(root.name) ?? "writing",
|
|
2726
|
+
name: asString(root.name) ?? "writing",
|
|
2727
|
+
title: asString(root.title),
|
|
2728
|
+
description: asString(root.description),
|
|
2729
|
+
anonymous: asBool(root.anonymous, false),
|
|
2730
|
+
model,
|
|
2731
|
+
provider,
|
|
2732
|
+
instructions,
|
|
2733
|
+
fragmentBlock: readFragmentBlock(root),
|
|
2734
|
+
placeholder: asString(root.placeholder)
|
|
2735
|
+
}
|
|
2663
2736
|
};
|
|
2664
2737
|
}
|
|
2665
2738
|
//#endregion
|
|
2666
|
-
//#region
|
|
2667
|
-
const
|
|
2668
|
-
|
|
2669
|
-
|
|
2670
|
-
return
|
|
2671
|
-
|
|
2672
|
-
|
|
2673
|
-
|
|
2739
|
+
//#region ../lib/writing-resolve.ts
|
|
2740
|
+
const DEFAULT_SCHEMES = ["http:", "https:"];
|
|
2741
|
+
function schemeAllowed(url, allowed) {
|
|
2742
|
+
try {
|
|
2743
|
+
return allowed.includes(new URL(url).protocol);
|
|
2744
|
+
} catch {
|
|
2745
|
+
return false;
|
|
2746
|
+
}
|
|
2747
|
+
}
|
|
2748
|
+
/**
|
|
2749
|
+
* Resolve a leniently parsed writing activity into the runnable one. `url` is the
|
|
2750
|
+
* activity's own URL (the base for relative fragment refs); `fetcher` the network seam.
|
|
2751
|
+
*/
|
|
2752
|
+
async function resolveWriting(writing, url, fetcher, opts = {}) {
|
|
2753
|
+
const resolved = await assembleFragmentPrompt(writing.fragmentBlock, url, fetcher, {
|
|
2754
|
+
validateLibraries: false,
|
|
2755
|
+
allowedSchemes: opts.allowedSchemes ?? DEFAULT_SCHEMES
|
|
2756
|
+
}, writing.instructions);
|
|
2757
|
+
if (!resolved.ok) return {
|
|
2758
|
+
ok: false,
|
|
2759
|
+
message: "This writing activity's prompt fragments could not be loaded."
|
|
2760
|
+
};
|
|
2761
|
+
return {
|
|
2762
|
+
ok: true,
|
|
2763
|
+
writing: {
|
|
2764
|
+
...writing,
|
|
2765
|
+
fragmentBlock: EMPTY_FRAGMENT_BLOCK,
|
|
2766
|
+
instructions: resolved.prompt
|
|
2767
|
+
}
|
|
2768
|
+
};
|
|
2769
|
+
}
|
|
2770
|
+
/**
|
|
2771
|
+
* Fetch + lenient-parse + `resolveWriting`, all through the CALLER's fetcher — the
|
|
2772
|
+
* app-free counterpart of `loadWriting` (`lib/writing-fetch.ts`) used by the prompt dump
|
|
2773
|
+
* and the CLI, where there is no database and an activity may live on disk (`file:`).
|
|
2774
|
+
*/
|
|
2775
|
+
async function loadWritingFrom(url, fetcher, opts = {}) {
|
|
2776
|
+
const allowedSchemes = opts.allowedSchemes ?? DEFAULT_SCHEMES;
|
|
2777
|
+
if (!schemeAllowed(url, allowedSchemes)) return {
|
|
2778
|
+
ok: false,
|
|
2779
|
+
message: `This writing activity's URL is not allowed: ${url}`
|
|
2780
|
+
};
|
|
2781
|
+
try {
|
|
2782
|
+
const res = await fetcher(url);
|
|
2783
|
+
if (!res.ok) return res.status === 404 ? {
|
|
2784
|
+
ok: false,
|
|
2785
|
+
message: "This writing activity could not be found."
|
|
2786
|
+
} : {
|
|
2787
|
+
ok: false,
|
|
2788
|
+
message: `This writing activity could not be loaded (HTTP ${res.status}).`
|
|
2674
2789
|
};
|
|
2790
|
+
const parsed = parseWriting(await res.text());
|
|
2791
|
+
if (!parsed.ok) return parsed;
|
|
2792
|
+
return await resolveWriting(parsed.writing, url, fetcher, { allowedSchemes });
|
|
2675
2793
|
} catch {
|
|
2676
2794
|
return {
|
|
2677
2795
|
ok: false,
|
|
2678
|
-
|
|
2679
|
-
text: async () => ""
|
|
2796
|
+
message: "This writing activity could not be loaded. Try again."
|
|
2680
2797
|
};
|
|
2681
2798
|
}
|
|
2682
|
-
return defaultFetcher(url);
|
|
2683
|
-
};
|
|
2684
|
-
//#endregion
|
|
2685
|
-
//#region src/format.ts
|
|
2686
|
-
const useColor = process.stdout.isTTY && !process.env.NO_COLOR;
|
|
2687
|
-
const paint = (code, s) => useColor ? `\x1b[${code}m${s}\x1b[0m` : s;
|
|
2688
|
-
const green = (s) => paint("32", s);
|
|
2689
|
-
const red = (s) => paint("31", s);
|
|
2690
|
-
const yellow = (s) => paint("33", s);
|
|
2691
|
-
const dim = (s) => paint("2", s);
|
|
2692
|
-
/** Append the context fields an error/warning carries, when present. */
|
|
2693
|
-
function context(item) {
|
|
2694
|
-
const parts = [];
|
|
2695
|
-
if (item.fileAlias) parts.push(`file=${item.fileAlias}`);
|
|
2696
|
-
if (item.fragmentId) parts.push(`fragment=${item.fragmentId}`);
|
|
2697
|
-
if ("questionId" in item && item.questionId) parts.push(`question=${item.questionId}`);
|
|
2698
|
-
if (item.variable) parts.push(`variable=${item.variable}`);
|
|
2699
|
-
if ("url" in item && item.url) parts.push(`url=${item.url}`);
|
|
2700
|
-
if ("expectedType" in item && item.expectedType) parts.push(`expected=${item.expectedType}`);
|
|
2701
|
-
if ("actualType" in item && item.actualType) parts.push(`actual=${item.actualType}`);
|
|
2702
|
-
return parts.length ? dim(` (${parts.join(", ")})`) : "";
|
|
2703
2799
|
}
|
|
2704
|
-
|
|
2705
|
-
|
|
2800
|
+
//#endregion
|
|
2801
|
+
//#region ../lib/prompt-dump.ts
|
|
2802
|
+
const PROMPT_KINDS = [
|
|
2803
|
+
"tutor",
|
|
2804
|
+
"quiz",
|
|
2805
|
+
"writing",
|
|
2806
|
+
"coding"
|
|
2807
|
+
];
|
|
2808
|
+
/** Wrap a runtime loader's friendly message as the structured failure shape. */
|
|
2809
|
+
function loadFailed(message, url) {
|
|
2810
|
+
return {
|
|
2811
|
+
ok: false,
|
|
2812
|
+
errors: [error("ACTIVITY_LOAD_FAILED", message, { url })]
|
|
2813
|
+
};
|
|
2706
2814
|
}
|
|
2707
2815
|
/**
|
|
2708
|
-
*
|
|
2816
|
+
* The verdict schema as plain JSON Schema, generated from the zod source of truth with
|
|
2817
|
+
* zod 4's native converter — the same mechanism `lib/schema-gen` uses for the authoring
|
|
2818
|
+
* schemas, so there is no second conversion story in the repo.
|
|
2819
|
+
*/
|
|
2820
|
+
function verdictResponseJsonSchema() {
|
|
2821
|
+
return z.toJSONSchema(QUIZ_VERDICT_SCHEMA, { target: "draft-2020-12" });
|
|
2822
|
+
}
|
|
2823
|
+
/** The seam: one dumper per prompt-producing `FileKind`. */
|
|
2824
|
+
const promptDumpers = {
|
|
2825
|
+
tutor: { async dump(url, fetcher, opts = {}) {
|
|
2826
|
+
const result = await loadAndBuildTutorPrompt(url, fetcher, opts);
|
|
2827
|
+
if (!result.ok) return {
|
|
2828
|
+
ok: false,
|
|
2829
|
+
errors: result.errors
|
|
2830
|
+
};
|
|
2831
|
+
return {
|
|
2832
|
+
ok: true,
|
|
2833
|
+
dump: {
|
|
2834
|
+
kind: "tutor",
|
|
2835
|
+
id: result.id,
|
|
2836
|
+
llm: {
|
|
2837
|
+
provider: result.provider,
|
|
2838
|
+
model: result.model
|
|
2839
|
+
},
|
|
2840
|
+
system: result.prompt
|
|
2841
|
+
}
|
|
2842
|
+
};
|
|
2843
|
+
} },
|
|
2844
|
+
quiz: { async dump(url, fetcher, opts = {}) {
|
|
2845
|
+
const loaded = await loadQuizFrom(url, fetcher, { allowedSchemes: opts.allowedSchemes });
|
|
2846
|
+
if (!loaded.ok) return loadFailed(loaded.message, url);
|
|
2847
|
+
const quiz = loaded.quiz;
|
|
2848
|
+
return {
|
|
2849
|
+
ok: true,
|
|
2850
|
+
dump: {
|
|
2851
|
+
kind: "quiz",
|
|
2852
|
+
id: quiz.id,
|
|
2853
|
+
llm: {
|
|
2854
|
+
provider: quiz.provider,
|
|
2855
|
+
model: quiz.model
|
|
2856
|
+
},
|
|
2857
|
+
grading: {
|
|
2858
|
+
userMessageTemplate: QUIZ_ANSWER_MESSAGE_TEMPLATE,
|
|
2859
|
+
userMessagePhotosOnly: QUIZ_ANSWER_PHOTOS_ONLY_MESSAGE,
|
|
2860
|
+
responseSchema: verdictResponseJsonSchema(),
|
|
2861
|
+
questions: quiz.questions.map((question) => ({
|
|
2862
|
+
id: question.id,
|
|
2863
|
+
...question.title ? { title: question.title } : {},
|
|
2864
|
+
system: buildGradingPrompt(question, quiz.instructionsPreamble),
|
|
2865
|
+
imageInput: effectiveImageInput(quiz, question)
|
|
2866
|
+
}))
|
|
2867
|
+
},
|
|
2868
|
+
discussion: {
|
|
2869
|
+
system: buildDiscussionInstructions(quiz),
|
|
2870
|
+
seedMessages: {
|
|
2871
|
+
question: QUIZ_SEED_QUESTION_TEMPLATE,
|
|
2872
|
+
answer: "{answer}",
|
|
2873
|
+
verdict: QUIZ_SEED_VERDICT_TEMPLATE
|
|
2874
|
+
},
|
|
2875
|
+
verdictLabels: {
|
|
2876
|
+
correct: verdictLabel("correct"),
|
|
2877
|
+
partial: verdictLabel("partial"),
|
|
2878
|
+
incorrect: verdictLabel("incorrect")
|
|
2879
|
+
}
|
|
2880
|
+
}
|
|
2881
|
+
}
|
|
2882
|
+
};
|
|
2883
|
+
} },
|
|
2884
|
+
writing: { async dump(url, fetcher, opts = {}) {
|
|
2885
|
+
const loaded = await loadWritingFrom(url, fetcher, { allowedSchemes: opts.allowedSchemes });
|
|
2886
|
+
if (!loaded.ok) return loadFailed(loaded.message, url);
|
|
2887
|
+
const writing = loaded.writing;
|
|
2888
|
+
return {
|
|
2889
|
+
ok: true,
|
|
2890
|
+
dump: {
|
|
2891
|
+
kind: "writing",
|
|
2892
|
+
id: writing.id,
|
|
2893
|
+
llm: {
|
|
2894
|
+
provider: writing.provider,
|
|
2895
|
+
model: writing.model
|
|
2896
|
+
},
|
|
2897
|
+
system: writing.instructions
|
|
2898
|
+
}
|
|
2899
|
+
};
|
|
2900
|
+
} },
|
|
2901
|
+
coding: { async dump(url, fetcher, opts = {}) {
|
|
2902
|
+
const loaded = await loadCodingFrom(url, fetcher, { allowedSchemes: opts.allowedSchemes });
|
|
2903
|
+
if (!loaded.ok) return loadFailed(loaded.message, url);
|
|
2904
|
+
const coding = loaded.coding;
|
|
2905
|
+
const upstream = buildUpstreamChatBody({ messages: [] }, {
|
|
2906
|
+
instructions: coding.instructions,
|
|
2907
|
+
model: coding.model
|
|
2908
|
+
});
|
|
2909
|
+
const system = (Array.isArray(upstream.messages) ? upstream.messages : []).find((m) => typeof m === "object" && m !== null && m.role === "system");
|
|
2910
|
+
return {
|
|
2911
|
+
ok: true,
|
|
2912
|
+
dump: {
|
|
2913
|
+
kind: "coding",
|
|
2914
|
+
id: coding.id,
|
|
2915
|
+
llm: {
|
|
2916
|
+
provider: coding.provider,
|
|
2917
|
+
model: coding.model
|
|
2918
|
+
},
|
|
2919
|
+
system: coding.instructions,
|
|
2920
|
+
upstreamSystemMessage: typeof system?.content === "string" ? system.content : ""
|
|
2921
|
+
}
|
|
2922
|
+
};
|
|
2923
|
+
} }
|
|
2924
|
+
};
|
|
2925
|
+
/** Dump the prompts of ONE activity file — the single entry point callers use. */
|
|
2926
|
+
function dumpPrompts(kind, url, fetcher, opts = {}) {
|
|
2927
|
+
return promptDumpers[kind].dump(url, fetcher, opts);
|
|
2928
|
+
}
|
|
2929
|
+
/**
|
|
2930
|
+
* The dump's prompts as a flat, kind-agnostic list — what a summary renderer walks so it
|
|
2931
|
+
* never has to switch on the kind. Order is stable (and, for a quiz, question order).
|
|
2932
|
+
*/
|
|
2933
|
+
function promptSections(dump) {
|
|
2934
|
+
switch (dump.kind) {
|
|
2935
|
+
case "quiz": return [...dump.grading.questions.map((q) => ({
|
|
2936
|
+
name: `grading: ${q.id}`,
|
|
2937
|
+
text: q.system
|
|
2938
|
+
})), {
|
|
2939
|
+
name: "discussion",
|
|
2940
|
+
text: dump.discussion.system
|
|
2941
|
+
}];
|
|
2942
|
+
case "coding": return [{
|
|
2943
|
+
name: "system (injected upstream)",
|
|
2944
|
+
text: dump.system
|
|
2945
|
+
}];
|
|
2946
|
+
default: return [{
|
|
2947
|
+
name: "system",
|
|
2948
|
+
text: dump.system
|
|
2949
|
+
}];
|
|
2950
|
+
}
|
|
2951
|
+
}
|
|
2952
|
+
//#endregion
|
|
2953
|
+
//#region ../lib/quiz-schema.ts
|
|
2954
|
+
/**
|
|
2955
|
+
* A live quiz include: alias + URL, mirroring `FragmentFileRefSchema` (same URL
|
|
2956
|
+
* contract). The alias prefixes every imported question id as `"<alias>/<id>"`, so
|
|
2957
|
+
* on top of the no-dot rule it may not contain a `/` either. Aliases live in their
|
|
2958
|
+
* OWN namespace (they never appear in `{{…}}` markers — only in question ids).
|
|
2959
|
+
*/
|
|
2960
|
+
const QuizFileRefSchema = z.strictObject({
|
|
2961
|
+
id: z.string().regex(/^[^./]+$/, { message: "Alias must not contain a dot or a slash" }).meta({
|
|
2962
|
+
pattern: "^[^./]+$",
|
|
2963
|
+
description: "Local alias for this included quiz. Prefixes every imported question id as \"<alias>/<id>\", so it may not contain a dot or a slash."
|
|
2964
|
+
}),
|
|
2965
|
+
url: FragmentFileRefSchema.shape.url.meta({
|
|
2966
|
+
pattern: "^(https?://|(?![A-Za-z][A-Za-z0-9+.-]*:).+)$",
|
|
2967
|
+
description: "HTTP(S) URL or relative path to the included quiz file."
|
|
2968
|
+
})
|
|
2969
|
+
}).meta({
|
|
2970
|
+
id: "quizFileRef",
|
|
2971
|
+
description: "A reference to another quiz file whose questions are included live."
|
|
2972
|
+
});
|
|
2973
|
+
/** An optional content image attached to a question (carries no secret). */
|
|
2974
|
+
const ImageRefSchema = z.strictObject({
|
|
2975
|
+
hosted: z.boolean().optional().meta({
|
|
2976
|
+
default: false,
|
|
2977
|
+
description: "When true, src is an app-hosted image NAME resolved server-side; otherwise src is an absolute URL or a path relative to the quiz's own URL."
|
|
2978
|
+
}),
|
|
2979
|
+
src: z.string().min(1).meta({ description: "The hosted image name (when hosted) or the image URL / relative path." }),
|
|
2980
|
+
alt: z.string().optional().meta({ description: "Accessible description shown if the image cannot be loaded." }),
|
|
2981
|
+
credit: z.string().optional().meta({ description: "Optional attribution (\"Content Credentials\") shown small below the image." })
|
|
2982
|
+
}).meta({
|
|
2983
|
+
id: "image",
|
|
2984
|
+
description: "An optional content image attached to a question."
|
|
2985
|
+
});
|
|
2986
|
+
/**
|
|
2987
|
+
* One question. `id` keys the per-question stats (must be unique — see
|
|
2988
|
+
* `lib/quiz-validate.ts`); `question` is the Markdown shown to the student;
|
|
2989
|
+
* `evaluation` is the server-only grading prompt.
|
|
2990
|
+
*/
|
|
2991
|
+
const QuizQuestionSchema = z.strictObject({
|
|
2992
|
+
id: z.string().min(1).meta({ description: "Stable question id, unique within the quiz (the per-question stats key)." }),
|
|
2993
|
+
title: z.string().optional().meta({ description: "Optional short label for the stats table and progress display." }),
|
|
2994
|
+
question: z.string().min(1).meta({ description: "The Markdown shown to the student." }),
|
|
2995
|
+
evaluation: z.string().min(1).meta({ description: "The grading prompt. SERVER-ONLY: never sent to the browser, so it may embed the expected answer and the grading rubric." }),
|
|
2996
|
+
image: ImageRefSchema.optional(),
|
|
2997
|
+
imageInput: z.boolean().optional().meta({ description: "Overrides the quiz-level llm.imageInput for this question only (photo answers on/off)." })
|
|
2998
|
+
}).meta({
|
|
2999
|
+
id: "question",
|
|
3000
|
+
description: "One open-ended, LLM-graded quiz question."
|
|
3001
|
+
});
|
|
3002
|
+
const QuizYamlSchema = z.strictObject({
|
|
3003
|
+
id: z.string().min(1).meta({ description: "Short machine-readable quiz id, e.g. countries-basics. Used as the per-quiz identity." }),
|
|
3004
|
+
name: z.string().optional().meta({ description: "Optional human-readable quiz title (used as a label)." }),
|
|
3005
|
+
title: z.string().optional().meta({ description: "Optional greeting shown to students on the welcome screen instead of the default message." }),
|
|
3006
|
+
description: z.string().optional().meta({ description: "Optional description shown to students below the welcome greeting (Markdown)." }),
|
|
3007
|
+
anonymous: z.boolean().optional().meta({
|
|
3008
|
+
default: true,
|
|
3009
|
+
description: "Quizzes are anonymous by default: answers are recorded for aggregate stats but not linked to a student. Set to false to attribute each attempt to the signed-in student."
|
|
3010
|
+
}),
|
|
3011
|
+
shuffle: z.boolean().optional().meta({
|
|
3012
|
+
default: true,
|
|
3013
|
+
description: "Present questions in a random order per attempt. Set to false to keep the authored order."
|
|
3014
|
+
}),
|
|
3015
|
+
question_count: z.number().int().min(1).optional().meta({ description: "How many questions one attempt asks (default: every question exactly once). May exceed the pool size — then questions repeat (drill mode). With shuffle off, fewer than the pool means the first N in authored order." }),
|
|
3016
|
+
quiz_files: z.array(QuizFileRefSchema).default([]).meta({ description: "Other quiz files whose questions are ALL included live into this quiz (a compound/final quiz). One level deep — an included quiz may not itself declare quiz_files." }),
|
|
3017
|
+
llm: z.strictObject({
|
|
3018
|
+
model: z.string().min(1).meta({ description: "The model that grades answers and drives the per-question discussion chat." }),
|
|
3019
|
+
provider: providerSchema,
|
|
3020
|
+
imageInput: z.boolean().optional().meta({
|
|
3021
|
+
default: false,
|
|
3022
|
+
description: "Default for all questions: students may attach photos (up to 3, 5 MB each) to their answers. The model must be vision-capable. A per-question imageInput overrides it."
|
|
3023
|
+
})
|
|
3024
|
+
}).meta({
|
|
3025
|
+
id: "llm",
|
|
3026
|
+
description: "The single model + provider that grades and discusses answers."
|
|
3027
|
+
}),
|
|
3028
|
+
discussion: z.strictObject({ instructions: z.string().min(1).meta({ description: "Optional guidance appended to the per-question follow-up discussion chat's system prompt. When any fragment_files or text_files are declared, place fragments inline with {{fragment \"alias.id\" …}} and embed text files with {{file \"alias\"}} (escape a literal {{ as \\{{) — same rules as instructions." }) }).optional().meta({
|
|
3029
|
+
id: "discussion",
|
|
3030
|
+
description: "Optional guidance for the per-question follow-up discussion chat."
|
|
3031
|
+
}),
|
|
3032
|
+
fragment_files: z.array(FragmentFileRefSchema).default([]).meta({ description: "Optional fragment libraries this quiz pulls shared prompt fragments from." }),
|
|
3033
|
+
text_files: z.array(TextFileRefSchema).default([]).meta({ description: "Optional plain-text files (markdown / source) embedded verbatim into instructions via {{file \"alias\"}} markers." }),
|
|
3034
|
+
instructions: z.string().optional().meta({ description: "Optional quiz-level preamble prepended to BOTH the grader prompt and the discussion chat. When any fragment_files or text_files are declared, 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 \\{{)." }),
|
|
3035
|
+
questions: z.array(QuizQuestionSchema).default([]).meta({ description: "The quiz questions. Each is open-ended and graded by the LLM via its evaluation prompt. May be omitted when quiz_files supplies the questions." })
|
|
3036
|
+
});
|
|
3037
|
+
//#endregion
|
|
3038
|
+
//#region ../lib/quiz-validate.ts
|
|
3039
|
+
/** Quizzes default to anonymous — answers are recorded for stats but not attributed. */
|
|
3040
|
+
const DEFAULT_ANONYMOUS$1 = true;
|
|
3041
|
+
/** Question ids declared on more than one question (the per-question stats key). */
|
|
3042
|
+
function findDuplicateQuestionIds(quiz) {
|
|
3043
|
+
const errors = [];
|
|
3044
|
+
const seen = /* @__PURE__ */ new Set();
|
|
3045
|
+
for (const question of quiz.questions) {
|
|
3046
|
+
if (seen.has(question.id)) {
|
|
3047
|
+
errors.push(error("DUPLICATE_QUIZ_QUESTION_ID", `Question id "${question.id}" is declared more than once`, { questionId: question.id }));
|
|
3048
|
+
continue;
|
|
3049
|
+
}
|
|
3050
|
+
seen.add(question.id);
|
|
3051
|
+
}
|
|
3052
|
+
return errors;
|
|
3053
|
+
}
|
|
3054
|
+
/**
|
|
3055
|
+
* Own question ids containing `/` — reserved as the namespace delimiter for
|
|
3056
|
+
* questions imported via `quiz_files` (`"<alias>/<id>"`), so an own id can never
|
|
3057
|
+
* collide with (or masquerade as) an imported one.
|
|
3058
|
+
*/
|
|
3059
|
+
function findReservedSlashIds(quiz) {
|
|
3060
|
+
return quiz.questions.filter((question) => question.id.includes("/")).map((question) => error("QUIZ_QUESTION_ID_RESERVED_SLASH", `Question id "${question.id}" contains "/" — reserved for questions imported via quiz_files`, { questionId: question.id }));
|
|
3061
|
+
}
|
|
3062
|
+
/** Include aliases declared on more than one `quiz_files` entry. */
|
|
3063
|
+
function findDuplicateIncludeAliases(quiz) {
|
|
3064
|
+
const errors = [];
|
|
3065
|
+
const seen = /* @__PURE__ */ new Set();
|
|
3066
|
+
for (const ref of quiz.quiz_files) {
|
|
3067
|
+
if (seen.has(ref.id)) {
|
|
3068
|
+
errors.push(error("DUPLICATE_QUIZ_INCLUDE_ALIAS", `Included-quiz alias "${ref.id}" is declared more than once`, { fileAlias: ref.id }));
|
|
3069
|
+
continue;
|
|
3070
|
+
}
|
|
3071
|
+
seen.add(ref.id);
|
|
3072
|
+
}
|
|
3073
|
+
return errors;
|
|
3074
|
+
}
|
|
3075
|
+
/**
|
|
3076
|
+
* Check an already-schema-validated quiz: unique question ids, no reserved `/` ids,
|
|
3077
|
+
* unique include aliases, and a non-empty (potential) pool → metadata. Split from
|
|
3078
|
+
* `checkQuizValue` so `loadAndCheckQuiz` can reuse the single `validate` it already ran
|
|
3079
|
+
* (no second parse of the same document against the same schema).
|
|
3080
|
+
*/
|
|
3081
|
+
function checkQuizParsed(quiz) {
|
|
3082
|
+
const errors = [
|
|
3083
|
+
...findDuplicateQuestionIds(quiz),
|
|
3084
|
+
...findReservedSlashIds(quiz),
|
|
3085
|
+
...findDuplicateIncludeAliases(quiz)
|
|
3086
|
+
];
|
|
3087
|
+
if (quiz.questions.length === 0 && quiz.quiz_files.length === 0) errors.push(error("QUIZ_NO_QUESTIONS", "This quiz has no questions and no quiz_files includes"));
|
|
3088
|
+
if (errors.length > 0) return {
|
|
3089
|
+
ok: false,
|
|
3090
|
+
errors,
|
|
3091
|
+
warnings: []
|
|
3092
|
+
};
|
|
3093
|
+
return {
|
|
3094
|
+
ok: true,
|
|
3095
|
+
quizId: quiz.id,
|
|
3096
|
+
model: quiz.llm.model,
|
|
3097
|
+
provider: quiz.llm.provider,
|
|
3098
|
+
questionCount: quiz.questions.length,
|
|
3099
|
+
anonymous: quiz.anonymous ?? DEFAULT_ANONYMOUS$1,
|
|
3100
|
+
title: quiz.title ?? null,
|
|
3101
|
+
warnings: []
|
|
3102
|
+
};
|
|
3103
|
+
}
|
|
3104
|
+
/** Wrap an include's nested failures into ONE error carrying the alias + URL. */
|
|
3105
|
+
function includeUnreadable(alias, url, nested) {
|
|
3106
|
+
return error("QUIZ_INCLUDE_UNREADABLE", `Included quiz "${alias}" is not usable: ${nested.map((e) => e.message).join("; ")}`, url === void 0 ? { fileAlias: alias } : {
|
|
3107
|
+
fileAlias: alias,
|
|
3108
|
+
url
|
|
3109
|
+
});
|
|
3110
|
+
}
|
|
3111
|
+
/**
|
|
3112
|
+
* Deep-check ONE `quiz_files` include: resolve + fetch + parse + the FULL strict
|
|
3113
|
+
* quiz check (schema, consistency passes, its own fragment-block authoring gate),
|
|
3114
|
+
* plus the one-level rule (`QUIZ_INCLUDE_NESTED`). Every other failure is wrapped
|
|
3115
|
+
* as `QUIZ_INCLUDE_UNREADABLE` with the alias + resolved URL.
|
|
3116
|
+
*/
|
|
3117
|
+
async function checkInclude(ref, baseUrl, fetchImpl, opts) {
|
|
3118
|
+
let includeUrl;
|
|
3119
|
+
try {
|
|
3120
|
+
includeUrl = resolveFragmentUrl(ref.url, baseUrl);
|
|
3121
|
+
} catch {
|
|
3122
|
+
return {
|
|
3123
|
+
ok: false,
|
|
3124
|
+
errors: [includeUnreadable(ref.id, void 0, [error("INVALID_URL", `Invalid include URL: ${ref.url}`)])],
|
|
3125
|
+
warnings: []
|
|
3126
|
+
};
|
|
3127
|
+
}
|
|
3128
|
+
const yaml = await loadYaml(includeUrl, fetchImpl, opts);
|
|
3129
|
+
if (!yaml.ok) return {
|
|
3130
|
+
ok: false,
|
|
3131
|
+
errors: [includeUnreadable(ref.id, includeUrl, [yaml.error])],
|
|
3132
|
+
warnings: []
|
|
3133
|
+
};
|
|
3134
|
+
const valid = validate(yaml.value, QuizYamlSchema, "QUIZ_SCHEMA_ERROR", includeUrl);
|
|
3135
|
+
if (!valid.ok) return {
|
|
3136
|
+
ok: false,
|
|
3137
|
+
errors: [includeUnreadable(ref.id, includeUrl, [valid.error])],
|
|
3138
|
+
warnings: []
|
|
3139
|
+
};
|
|
3140
|
+
if (valid.data.quiz_files.length > 0) return {
|
|
3141
|
+
ok: false,
|
|
3142
|
+
errors: [error("QUIZ_INCLUDE_NESTED", `Included quiz "${ref.id}" itself declares quiz_files — includes are one level deep`, {
|
|
3143
|
+
fileAlias: ref.id,
|
|
3144
|
+
url: includeUrl
|
|
3145
|
+
})],
|
|
3146
|
+
warnings: []
|
|
3147
|
+
};
|
|
3148
|
+
const checked = checkQuizParsed(valid.data);
|
|
3149
|
+
if (!checked.ok) return {
|
|
3150
|
+
ok: false,
|
|
3151
|
+
errors: [includeUnreadable(ref.id, includeUrl, checked.errors)],
|
|
3152
|
+
warnings: checked.warnings
|
|
3153
|
+
};
|
|
3154
|
+
const assembled = await assembleFragmentPrompts({
|
|
3155
|
+
fragment_files: valid.data.fragment_files,
|
|
3156
|
+
text_files: valid.data.text_files
|
|
3157
|
+
}, includeUrl, fetchImpl, {
|
|
3158
|
+
allowedSchemes: opts.allowedSchemes,
|
|
3159
|
+
validateLibraries: opts.validateLibraries ?? true
|
|
3160
|
+
}, [valid.data.instructions ?? "", valid.data.discussion?.instructions ?? ""]);
|
|
3161
|
+
if (!assembled.ok) return {
|
|
3162
|
+
ok: false,
|
|
3163
|
+
errors: [includeUnreadable(ref.id, includeUrl, assembled.errors)],
|
|
3164
|
+
warnings: assembled.warnings
|
|
3165
|
+
};
|
|
3166
|
+
return {
|
|
3167
|
+
ok: true,
|
|
3168
|
+
questionCount: valid.data.questions.length,
|
|
3169
|
+
warnings: assembled.warnings
|
|
3170
|
+
};
|
|
3171
|
+
}
|
|
3172
|
+
/**
|
|
3173
|
+
* Validate a quiz FILE: scheme-gate + fetch + parse (shared `loadYaml`), the pure
|
|
3174
|
+
* `checkQuizValue`, the document-level fragment block's authoring gate — fetch
|
|
3175
|
+
* every referenced library, run the THOROUGH whole-library check, consistency, and an
|
|
3176
|
+
* assembly dry-run (the strict-Handlebars backstop) — and a DEEP check of every
|
|
3177
|
+
* `quiz_files` include. On success `questionCount` is the RESOLVED pool size
|
|
3178
|
+
* (own + imported), so the `/files` save UI and code-create metadata reflect the
|
|
3179
|
+
* real exam size. The web app passes the default http(s)-only schemes; the CLI adds
|
|
3180
|
+
* `file:` so a local quiz YAML on disk validates too.
|
|
3181
|
+
*/
|
|
3182
|
+
async function loadAndCheckQuiz(url, fetchImpl, opts = {}) {
|
|
3183
|
+
const yaml = await loadYaml(url, fetchImpl, opts);
|
|
3184
|
+
if (!yaml.ok) return {
|
|
3185
|
+
ok: false,
|
|
3186
|
+
errors: [yaml.error],
|
|
3187
|
+
warnings: []
|
|
3188
|
+
};
|
|
3189
|
+
const valid = validate(yaml.value, QuizYamlSchema, "QUIZ_SCHEMA_ERROR", url);
|
|
3190
|
+
if (!valid.ok) return {
|
|
3191
|
+
ok: false,
|
|
3192
|
+
errors: [valid.error],
|
|
3193
|
+
warnings: []
|
|
3194
|
+
};
|
|
3195
|
+
const checked = checkQuizParsed(valid.data);
|
|
3196
|
+
if (!checked.ok) return checked;
|
|
3197
|
+
const assembled = await assembleFragmentPrompts({
|
|
3198
|
+
fragment_files: valid.data.fragment_files,
|
|
3199
|
+
text_files: valid.data.text_files
|
|
3200
|
+
}, url, fetchImpl, {
|
|
3201
|
+
allowedSchemes: opts.allowedSchemes,
|
|
3202
|
+
validateLibraries: opts.validateLibraries ?? true
|
|
3203
|
+
}, [valid.data.instructions ?? "", valid.data.discussion?.instructions ?? ""]);
|
|
3204
|
+
const warnings = [...checked.warnings, ...assembled.warnings];
|
|
3205
|
+
if (!assembled.ok) return {
|
|
3206
|
+
ok: false,
|
|
3207
|
+
errors: assembled.errors,
|
|
3208
|
+
warnings
|
|
3209
|
+
};
|
|
3210
|
+
const includes = await Promise.all(valid.data.quiz_files.map((ref) => checkInclude(ref, url, fetchImpl, opts)));
|
|
3211
|
+
const includeErrors = [];
|
|
3212
|
+
let importedCount = 0;
|
|
3213
|
+
for (const include of includes) {
|
|
3214
|
+
warnings.push(...include.warnings);
|
|
3215
|
+
if (include.ok) importedCount += include.questionCount;
|
|
3216
|
+
else includeErrors.push(...include.errors);
|
|
3217
|
+
}
|
|
3218
|
+
if (includeErrors.length > 0) return {
|
|
3219
|
+
ok: false,
|
|
3220
|
+
errors: includeErrors,
|
|
3221
|
+
warnings
|
|
3222
|
+
};
|
|
3223
|
+
return {
|
|
3224
|
+
...checked,
|
|
3225
|
+
questionCount: checked.questionCount + importedCount,
|
|
3226
|
+
warnings
|
|
3227
|
+
};
|
|
3228
|
+
}
|
|
3229
|
+
//#endregion
|
|
3230
|
+
//#region ../lib/eval-validate.ts
|
|
3231
|
+
function fail(errors, warnings = []) {
|
|
3232
|
+
return {
|
|
3233
|
+
ok: false,
|
|
3234
|
+
errors,
|
|
3235
|
+
warnings
|
|
3236
|
+
};
|
|
3237
|
+
}
|
|
3238
|
+
/** Zod issues as one error each, the dotted path leading the message. */
|
|
3239
|
+
function schemaErrors(issues, url) {
|
|
3240
|
+
return issues.map((issue) => {
|
|
3241
|
+
const path = issue.path.map((segment) => String(segment)).join(".");
|
|
3242
|
+
return error("EVAL_SCHEMA", path ? `${path}: ${issue.message}` : issue.message, { url });
|
|
3243
|
+
});
|
|
3244
|
+
}
|
|
3245
|
+
/**
|
|
3246
|
+
* Check ONE eval file end to end. `fetcher` is the caller's network seam and
|
|
3247
|
+
* `allowedSchemes` the usual SSRF gate (the CLI adds `file:` so an on-disk eval
|
|
3248
|
+
* resolves the quiz sitting next to it).
|
|
3249
|
+
*/
|
|
3250
|
+
async function loadAndCheckEval(url, fetchImpl, opts = {}) {
|
|
3251
|
+
const allowedSchemes = opts.allowedSchemes;
|
|
3252
|
+
const yaml = await loadYaml(url, fetchImpl, { allowedSchemes });
|
|
3253
|
+
if (!yaml.ok) return fail([error(yaml.error.code === "YAML_PARSE_ERROR" ? "EVAL_PARSE" : "EVAL_READ", yaml.error.message, { url })]);
|
|
3254
|
+
const parsed = EvalYamlSchema.safeParse(yaml.value);
|
|
3255
|
+
if (!parsed.success) return fail(schemaErrors(parsed.error.issues, url));
|
|
3256
|
+
const evalFile = parsed.data;
|
|
3257
|
+
let targetUrl;
|
|
3258
|
+
try {
|
|
3259
|
+
targetUrl = new URL(evalFile.target, url).href;
|
|
3260
|
+
} catch {
|
|
3261
|
+
return fail([error("EVAL_TARGET_ERROR", `The target "${evalFile.target}" is not a usable URL.`, { url })]);
|
|
3262
|
+
}
|
|
3263
|
+
if (allowedSchemes !== void 0) {
|
|
3264
|
+
let scheme = "";
|
|
3265
|
+
try {
|
|
3266
|
+
scheme = new URL(targetUrl).protocol;
|
|
3267
|
+
} catch {
|
|
3268
|
+
scheme = "";
|
|
3269
|
+
}
|
|
3270
|
+
if (!allowedSchemes.includes(scheme)) return fail([error("EVAL_TARGET_ERROR", `The target URL is not allowed: ${targetUrl}`, { url: targetUrl })]);
|
|
3271
|
+
}
|
|
3272
|
+
const warnings = [];
|
|
3273
|
+
if (opts.strictTarget) {
|
|
3274
|
+
const strict = await loadAndCheckQuiz(targetUrl, fetchImpl, {
|
|
3275
|
+
allowedSchemes,
|
|
3276
|
+
validateLibraries: opts.validateLibraries ?? true
|
|
3277
|
+
});
|
|
3278
|
+
warnings.push(...strict.warnings);
|
|
3279
|
+
if (!strict.ok) return fail(strict.errors, warnings);
|
|
3280
|
+
}
|
|
3281
|
+
const dumped = await dumpPrompts("quiz", targetUrl, fetchImpl, { allowedSchemes });
|
|
3282
|
+
if (!dumped.ok) return fail(dumped.errors.map((e) => error("EVAL_TARGET_ERROR", `The target quiz could not be loaded: ${e.message}`, { url: targetUrl })), warnings);
|
|
3283
|
+
const quizDump = dumped.dump;
|
|
3284
|
+
if (quizDump.kind !== "quiz") return fail([error("EVAL_TARGET_ERROR", "The target is not a quiz.", { url: targetUrl })], warnings);
|
|
3285
|
+
const known = new Set(quizDump.grading.questions.map((question) => question.id));
|
|
3286
|
+
const unknown = evalFile.questions.filter((question) => !known.has(question.question)).map((question) => error("EVAL_UNKNOWN_QUESTION", `The target quiz has no question "${question.question}".`, {
|
|
3287
|
+
questionId: question.question,
|
|
3288
|
+
url: targetUrl
|
|
3289
|
+
}));
|
|
3290
|
+
if (unknown.length > 0) return fail(unknown, warnings);
|
|
3291
|
+
const resolved = await loadQuizFrom(targetUrl, fetchImpl, { allowedSchemes });
|
|
3292
|
+
const quizQuestions = resolved.ok ? resolved.quiz.questions.map((question) => ({
|
|
3293
|
+
id: question.id,
|
|
3294
|
+
text: question.question
|
|
3295
|
+
})) : [];
|
|
3296
|
+
return {
|
|
3297
|
+
ok: true,
|
|
3298
|
+
evalFile,
|
|
3299
|
+
targetUrl,
|
|
3300
|
+
quizDump,
|
|
3301
|
+
quizQuestions,
|
|
3302
|
+
caseCount: evalFile.questions.reduce((sum, question) => sum + question.answers.length, 0),
|
|
3303
|
+
warnings
|
|
3304
|
+
};
|
|
3305
|
+
}
|
|
3306
|
+
//#endregion
|
|
3307
|
+
//#region src/retry.ts
|
|
3308
|
+
const defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
3309
|
+
/**
|
|
3310
|
+
* Runs `work` up to `attempts` times, waiting a LINEARLY growing delay between tries
|
|
3311
|
+
* (the shape the Python PoC used against SCCH's occasional 504s). Returns the first
|
|
3312
|
+
* outcome `shouldRetry` rejects, or the last outcome when the budget runs out.
|
|
3313
|
+
*/
|
|
3314
|
+
async function withRetry(work, options) {
|
|
3315
|
+
const attempts = Math.max(1, options.attempts ?? 4);
|
|
3316
|
+
const baseDelayMs = options.baseDelayMs ?? 5e3;
|
|
3317
|
+
const sleep = options.sleep ?? defaultSleep;
|
|
3318
|
+
let last;
|
|
3319
|
+
for (let attempt = 1; attempt <= attempts; attempt++) {
|
|
3320
|
+
if (attempt > 1) await sleep(baseDelayMs * (attempt - 1));
|
|
3321
|
+
last = await work(attempt);
|
|
3322
|
+
if (!options.shouldRetry(last)) return last;
|
|
3323
|
+
}
|
|
3324
|
+
return last;
|
|
3325
|
+
}
|
|
3326
|
+
/**
|
|
3327
|
+
* Maps `items` through `fn` with at most `limit` in flight, preserving INPUT order in
|
|
3328
|
+
* the result (a worker pool, not `Promise.all` in chunks — a slow item never stalls the
|
|
3329
|
+
* pool behind it). `fn` receives the item's original index so callers can report
|
|
3330
|
+
* progress meaningfully.
|
|
3331
|
+
*/
|
|
3332
|
+
async function mapWithConcurrency(items, limit, fn) {
|
|
3333
|
+
const results = new Array(items.length);
|
|
3334
|
+
const workers = Math.max(1, Math.min(Math.floor(limit) || 1, items.length));
|
|
3335
|
+
let next = 0;
|
|
3336
|
+
async function worker() {
|
|
3337
|
+
for (;;) {
|
|
3338
|
+
const index = next++;
|
|
3339
|
+
if (index >= items.length) return;
|
|
3340
|
+
results[index] = await fn(items[index], index);
|
|
3341
|
+
}
|
|
3342
|
+
}
|
|
3343
|
+
await Promise.all(Array.from({ length: workers }, () => worker()));
|
|
3344
|
+
return results;
|
|
3345
|
+
}
|
|
3346
|
+
//#endregion
|
|
3347
|
+
//#region src/eval-run.ts
|
|
3348
|
+
const ZERO_USAGE = {
|
|
3349
|
+
input: 0,
|
|
3350
|
+
cachedInput: 0,
|
|
3351
|
+
output: 0
|
|
3352
|
+
};
|
|
3353
|
+
/** Add one call's usage into an accumulator (mutating it — internal to the sums below). */
|
|
3354
|
+
function addUsage(total, usage) {
|
|
3355
|
+
if (!usage) return;
|
|
3356
|
+
total.input += usage.input;
|
|
3357
|
+
total.cachedInput += usage.cachedInput;
|
|
3358
|
+
total.output += usage.output;
|
|
3359
|
+
}
|
|
3360
|
+
/** Consecutive fully-errored cases that mean "the server is down, stop now". */
|
|
3361
|
+
const CIRCUIT_BREAKER_LIMIT = 3;
|
|
3362
|
+
/** Flatten questions × answers into cases, each carrying its grading prompt. */
|
|
3363
|
+
function planCases(checked) {
|
|
3364
|
+
const systemById = new Map(checked.quizDump.grading.questions.map((question) => [question.id, question.system]));
|
|
3365
|
+
const cases = [];
|
|
3366
|
+
for (const question of checked.evalFile.questions) question.answers.forEach((answer, answerIndex) => {
|
|
3367
|
+
cases.push({
|
|
3368
|
+
questionId: question.question,
|
|
3369
|
+
answerIndex,
|
|
3370
|
+
expected: normalizeExpect(answer.expect),
|
|
3371
|
+
answer: answer.answer,
|
|
3372
|
+
system: systemById.get(question.question)
|
|
3373
|
+
});
|
|
3374
|
+
});
|
|
3375
|
+
return cases;
|
|
3376
|
+
}
|
|
3377
|
+
/**
|
|
3378
|
+
* The majority verdict over the graded repeats. Returns the winner plus whether the
|
|
3379
|
+
* case PASSES: a unique majority passes when it is expected; a TIE passes only when
|
|
3380
|
+
* every tied verdict is expected (so a coin-flip between two acceptable gradings is a
|
|
3381
|
+
* pass, and one between an acceptable and an unacceptable one is not).
|
|
3382
|
+
*/
|
|
3383
|
+
function majority(graded, expected) {
|
|
3384
|
+
if (graded.length === 0) return void 0;
|
|
3385
|
+
const counts = /* @__PURE__ */ new Map();
|
|
3386
|
+
for (const verdict of graded) counts.set(verdict, (counts.get(verdict) ?? 0) + 1);
|
|
3387
|
+
const top = Math.max(...counts.values());
|
|
3388
|
+
const tied = [...counts.entries()].filter(([, count]) => count === top).map(([verdict]) => verdict).sort((a, b) => EVAL_VERDICTS.indexOf(a) - EVAL_VERDICTS.indexOf(b));
|
|
3389
|
+
return {
|
|
3390
|
+
verdict: tied[0],
|
|
3391
|
+
passed: tied.every((verdict) => expected.includes(verdict))
|
|
3392
|
+
};
|
|
3393
|
+
}
|
|
3394
|
+
/** The seam: one runner per eval kind (mirrors `promptDumpers`). */
|
|
3395
|
+
const evalRunners = { quiz: { async run(checked, options) {
|
|
3396
|
+
const repeats = Math.max(1, Math.floor(options.repeats ?? 1));
|
|
3397
|
+
const concurrency = Math.max(1, Math.floor(options.concurrency ?? 4));
|
|
3398
|
+
const planned = planCases(checked);
|
|
3399
|
+
const total = planned.length * repeats;
|
|
3400
|
+
const questionTexts = new Map(checked.quizQuestions.map((q) => [q.id, q.text]));
|
|
3401
|
+
let done = 0;
|
|
3402
|
+
let consecutiveErrored = 0;
|
|
3403
|
+
let aborted;
|
|
3404
|
+
const progress = () => {
|
|
3405
|
+
done += 1;
|
|
3406
|
+
options.onProgress?.({
|
|
3407
|
+
done,
|
|
3408
|
+
total
|
|
3409
|
+
});
|
|
3410
|
+
};
|
|
3411
|
+
const results = await mapWithConcurrency(planned, concurrency, async (plan) => {
|
|
3412
|
+
const rows = [];
|
|
3413
|
+
for (let repeatIndex = 0; repeatIndex < repeats; repeatIndex++) {
|
|
3414
|
+
if (aborted) break;
|
|
3415
|
+
if (plan.system === void 0) {
|
|
3416
|
+
rows.push({
|
|
3417
|
+
repeatIndex,
|
|
3418
|
+
error: { message: `The quiz has no question "${plan.questionId}".` }
|
|
3419
|
+
});
|
|
3420
|
+
progress();
|
|
3421
|
+
continue;
|
|
3422
|
+
}
|
|
3423
|
+
const outcome = await withRetry(() => options.grade({
|
|
3424
|
+
system: plan.system,
|
|
3425
|
+
answer: plan.answer
|
|
3426
|
+
}), {
|
|
3427
|
+
attempts: options.retry?.attempts,
|
|
3428
|
+
baseDelayMs: options.retry?.baseDelayMs,
|
|
3429
|
+
sleep: options.retry?.sleep,
|
|
3430
|
+
shouldRetry: (value) => !value.ok && value.retryable && value.auth !== true
|
|
3431
|
+
});
|
|
3432
|
+
progress();
|
|
3433
|
+
if (outcome.ok) {
|
|
3434
|
+
rows.push({
|
|
3435
|
+
repeatIndex,
|
|
3436
|
+
got: outcome.verdict,
|
|
3437
|
+
feedback: outcome.feedback,
|
|
3438
|
+
...outcome.usage ? { usage: outcome.usage } : {}
|
|
3439
|
+
});
|
|
3440
|
+
continue;
|
|
3441
|
+
}
|
|
3442
|
+
rows.push({
|
|
3443
|
+
repeatIndex,
|
|
3444
|
+
error: outcome.error
|
|
3445
|
+
});
|
|
3446
|
+
if (outcome.auth) {
|
|
3447
|
+
aborted ??= {
|
|
3448
|
+
reason: "auth",
|
|
3449
|
+
message: "Authentication failed — the run was aborted. Run `novedu-cli login`."
|
|
3450
|
+
};
|
|
3451
|
+
break;
|
|
3452
|
+
}
|
|
3453
|
+
}
|
|
3454
|
+
const graded = rows.flatMap((row) => row.got ? [row.got] : []);
|
|
3455
|
+
const winner = majority(graded, plan.expected);
|
|
3456
|
+
const status = rows.length === 0 ? "skipped" : !winner ? "errored" : winner.passed ? "passed" : "failed";
|
|
3457
|
+
if (status === "errored") {
|
|
3458
|
+
consecutiveErrored += 1;
|
|
3459
|
+
if (consecutiveErrored >= CIRCUIT_BREAKER_LIMIT) aborted ??= {
|
|
3460
|
+
reason: "circuit-breaker",
|
|
3461
|
+
message: `${CIRCUIT_BREAKER_LIMIT} cases failed in a row — the run was aborted.`
|
|
3462
|
+
};
|
|
3463
|
+
} else if (status !== "skipped") consecutiveErrored = 0;
|
|
3464
|
+
return {
|
|
3465
|
+
questionId: plan.questionId,
|
|
3466
|
+
answerIndex: plan.answerIndex,
|
|
3467
|
+
expected: plan.expected,
|
|
3468
|
+
answer: plan.answer,
|
|
3469
|
+
status,
|
|
3470
|
+
...winner ? { verdict: winner.verdict } : {},
|
|
3471
|
+
unstable: new Set(graded).size > 1,
|
|
3472
|
+
repeats: rows
|
|
3473
|
+
};
|
|
3474
|
+
});
|
|
3475
|
+
const usage = { ...ZERO_USAGE };
|
|
3476
|
+
for (const result of results) for (const row of result.repeats) addUsage(usage, row.usage);
|
|
3477
|
+
const totals = {
|
|
3478
|
+
cases: results.length,
|
|
3479
|
+
passed: results.filter((c) => c.status === "passed").length,
|
|
3480
|
+
failed: results.filter((c) => c.status === "failed").length,
|
|
3481
|
+
errored: results.filter((c) => c.status === "errored").length,
|
|
3482
|
+
skipped: results.filter((c) => c.status === "skipped").length,
|
|
3483
|
+
unstable: results.filter((c) => c.unstable).length,
|
|
3484
|
+
repeats,
|
|
3485
|
+
calls: total,
|
|
3486
|
+
usage
|
|
3487
|
+
};
|
|
3488
|
+
const confusionCounts = /* @__PURE__ */ new Map();
|
|
3489
|
+
for (const result of results) {
|
|
3490
|
+
if (!result.verdict) continue;
|
|
3491
|
+
const key = `${expectedKey(result.expected)}${result.verdict}`;
|
|
3492
|
+
confusionCounts.set(key, (confusionCounts.get(key) ?? 0) + 1);
|
|
3493
|
+
}
|
|
3494
|
+
const confusion = [...confusionCounts.entries()].map(([key, count]) => {
|
|
3495
|
+
const [expected = "", got = ""] = key.split("\0");
|
|
3496
|
+
return {
|
|
3497
|
+
expected,
|
|
3498
|
+
got,
|
|
3499
|
+
count
|
|
3500
|
+
};
|
|
3501
|
+
}).sort((a, b) => a.expected.localeCompare(b.expected) || a.got.localeCompare(b.got));
|
|
3502
|
+
const strictCases = results.filter((result) => !result.expected.includes("correct"));
|
|
3503
|
+
const falseCorrectCount = strictCases.filter((result) => result.verdict === "correct").length;
|
|
3504
|
+
return {
|
|
3505
|
+
id: checked.evalFile.id,
|
|
3506
|
+
target: checked.targetUrl,
|
|
3507
|
+
llm: options.llm,
|
|
3508
|
+
totals,
|
|
3509
|
+
questions: [...new Set(checked.evalFile.questions.map((question) => question.question))].map((id) => ({
|
|
3510
|
+
id,
|
|
3511
|
+
text: questionTexts.get(id) ?? ""
|
|
3512
|
+
})),
|
|
3513
|
+
mismatches: results.filter((result) => result.status === "failed" || result.status === "errored"),
|
|
3514
|
+
cases: results,
|
|
3515
|
+
confusion,
|
|
3516
|
+
falseCorrect: {
|
|
3517
|
+
count: falseCorrectCount,
|
|
3518
|
+
denominator: strictCases.length,
|
|
3519
|
+
rate: strictCases.length === 0 ? 0 : falseCorrectCount / strictCases.length
|
|
3520
|
+
},
|
|
3521
|
+
...aborted ? { aborted } : {}
|
|
3522
|
+
};
|
|
3523
|
+
} } };
|
|
3524
|
+
/** Run ONE checked eval file — the single entry point the command uses. */
|
|
3525
|
+
function runEval(kind, checked, options) {
|
|
3526
|
+
return evalRunners[kind].run(checked, options);
|
|
3527
|
+
}
|
|
3528
|
+
/** One file's own verdict: valid, graded, and not a single non-passing case. */
|
|
3529
|
+
function filePassed(file) {
|
|
3530
|
+
if (file.status === "invalid" || !file.result) return false;
|
|
3531
|
+
const { failed, errored, skipped } = file.result.totals;
|
|
3532
|
+
return failed === 0 && errored === 0 && skipped === 0;
|
|
3533
|
+
}
|
|
3534
|
+
/**
|
|
3535
|
+
* Grand totals across a batch. This is the ONE machine-readable shape `--json` /
|
|
3536
|
+
* `--out` emit, single file or not, so scripts never branch on the file count.
|
|
3537
|
+
*/
|
|
3538
|
+
function summarizeBatch(files) {
|
|
3539
|
+
const totals = {
|
|
3540
|
+
files: files.length,
|
|
3541
|
+
invalid: files.filter((file) => file.status === "invalid").length,
|
|
3542
|
+
cases: 0,
|
|
3543
|
+
passed: 0,
|
|
3544
|
+
failed: 0,
|
|
3545
|
+
errored: 0,
|
|
3546
|
+
skipped: 0,
|
|
3547
|
+
unstable: 0,
|
|
3548
|
+
usage: { ...ZERO_USAGE }
|
|
3549
|
+
};
|
|
3550
|
+
for (const file of files) {
|
|
3551
|
+
if (!file.result) continue;
|
|
3552
|
+
totals.cases += file.result.totals.cases;
|
|
3553
|
+
totals.passed += file.result.totals.passed;
|
|
3554
|
+
totals.failed += file.result.totals.failed;
|
|
3555
|
+
totals.errored += file.result.totals.errored;
|
|
3556
|
+
totals.skipped += file.result.totals.skipped;
|
|
3557
|
+
totals.unstable += file.result.totals.unstable;
|
|
3558
|
+
addUsage(totals.usage, file.result.totals.usage);
|
|
3559
|
+
}
|
|
3560
|
+
return {
|
|
3561
|
+
files: files.map((file) => ({
|
|
3562
|
+
...file,
|
|
3563
|
+
passed: filePassed(file)
|
|
3564
|
+
})),
|
|
3565
|
+
passed: batchPassed({ totals }),
|
|
3566
|
+
totals
|
|
3567
|
+
};
|
|
3568
|
+
}
|
|
3569
|
+
/**
|
|
3570
|
+
* The CI gate: every file valid, and not a single failed, errored, or skipped CASE —
|
|
3571
|
+
* an aborted (and therefore incomplete) run must never read as a pass. The single
|
|
3572
|
+
* source of truth for the exit code AND for `EvalBatchResult.passed`.
|
|
3573
|
+
*/
|
|
3574
|
+
function batchPassed(batch) {
|
|
3575
|
+
return batch.totals.invalid === 0 && batch.totals.failed === 0 && batch.totals.errored === 0 && batch.totals.skipped === 0;
|
|
3576
|
+
}
|
|
3577
|
+
//#endregion
|
|
3578
|
+
//#region src/file-fetcher.ts
|
|
3579
|
+
const cliFetcher = async (url) => {
|
|
3580
|
+
if (url.startsWith("file:")) try {
|
|
3581
|
+
const text = await readFile(fileURLToPath(url), "utf8");
|
|
3582
|
+
return {
|
|
3583
|
+
ok: true,
|
|
3584
|
+
status: 200,
|
|
3585
|
+
text: async () => text
|
|
3586
|
+
};
|
|
3587
|
+
} catch {
|
|
3588
|
+
return {
|
|
3589
|
+
ok: false,
|
|
3590
|
+
status: 404,
|
|
3591
|
+
text: async () => ""
|
|
3592
|
+
};
|
|
3593
|
+
}
|
|
3594
|
+
return defaultFetcher(url);
|
|
3595
|
+
};
|
|
3596
|
+
//#endregion
|
|
3597
|
+
//#region src/format.ts
|
|
3598
|
+
const useColor = process.stdout.isTTY && !process.env.NO_COLOR;
|
|
3599
|
+
const paint = (code, s) => useColor ? `\x1b[${code}m${s}\x1b[0m` : s;
|
|
3600
|
+
const green = (s) => paint("32", s);
|
|
3601
|
+
const red = (s) => paint("31", s);
|
|
3602
|
+
const yellow = (s) => paint("33", s);
|
|
3603
|
+
const dim = (s) => paint("2", s);
|
|
3604
|
+
/** Append the context fields an error/warning carries, when present. */
|
|
3605
|
+
function context(item) {
|
|
3606
|
+
const parts = [];
|
|
3607
|
+
if (item.fileAlias) parts.push(`file=${item.fileAlias}`);
|
|
3608
|
+
if (item.fragmentId) parts.push(`fragment=${item.fragmentId}`);
|
|
3609
|
+
if ("questionId" in item && item.questionId) parts.push(`question=${item.questionId}`);
|
|
3610
|
+
if (item.variable) parts.push(`variable=${item.variable}`);
|
|
3611
|
+
if ("url" in item && item.url) parts.push(`url=${item.url}`);
|
|
3612
|
+
if ("expectedType" in item && item.expectedType) parts.push(`expected=${item.expectedType}`);
|
|
3613
|
+
if ("actualType" in item && item.actualType) parts.push(`actual=${item.actualType}`);
|
|
3614
|
+
return parts.length ? dim(` (${parts.join(", ")})`) : "";
|
|
3615
|
+
}
|
|
3616
|
+
function renderWarnings(warnings) {
|
|
3617
|
+
return warnings.map((w) => ` ${yellow("⚠")} ${yellow(w.code)} ${w.message}${context(w)}`);
|
|
3618
|
+
}
|
|
3619
|
+
/**
|
|
3620
|
+
* Render each error as a line, with any flattened Zod schema-issue detail
|
|
2709
3621
|
* indented beneath it — so a generic "Document does not match the expected
|
|
2710
3622
|
* structure" is followed by the actual field paths (e.g. `Unrecognized key:
|
|
2711
3623
|
* "nae"`), matching what the web UI shows.
|
|
2712
3624
|
*/
|
|
2713
|
-
function renderErrors(errors) {
|
|
3625
|
+
function renderErrors(errors) {
|
|
3626
|
+
const lines = [];
|
|
3627
|
+
for (const e of errors) {
|
|
3628
|
+
lines.push(` ${red("✗")} ${red(e.code)} ${e.message}${context(e)}`);
|
|
3629
|
+
if (e.zodIssues) for (const issue of formatZodIssues(e.zodIssues)) lines.push(` ${dim(issue)}`);
|
|
3630
|
+
}
|
|
3631
|
+
return lines;
|
|
3632
|
+
}
|
|
3633
|
+
function formatResult(result, source) {
|
|
3634
|
+
const lines = [];
|
|
3635
|
+
if (result.ok) {
|
|
3636
|
+
lines.push(green(`✔ Valid tutor`) + dim(` — ${source}`));
|
|
3637
|
+
lines.push(` model: ${result.model}`);
|
|
3638
|
+
lines.push(` system prompt: ${result.prompt.length} chars`);
|
|
3639
|
+
lines.push(` imageInput: ${result.imageInput} anonymous: ${result.anonymous}` + (result.exampleQuestions.length ? ` exampleQuestions: ${result.exampleQuestions.length}` : ""));
|
|
3640
|
+
if (result.warnings.length) {
|
|
3641
|
+
lines.push("");
|
|
3642
|
+
lines.push(yellow(`${result.warnings.length} warning(s):`));
|
|
3643
|
+
lines.push(...renderWarnings(result.warnings));
|
|
3644
|
+
}
|
|
3645
|
+
return lines.join("\n");
|
|
3646
|
+
}
|
|
3647
|
+
lines.push(red(`✘ Invalid tutor`) + dim(` — ${source}`));
|
|
3648
|
+
lines.push("");
|
|
3649
|
+
lines.push(red(`${result.errors.length} error(s):`));
|
|
3650
|
+
lines.push(...renderErrors(result.errors));
|
|
3651
|
+
if (result.warnings.length) {
|
|
3652
|
+
lines.push("");
|
|
3653
|
+
lines.push(yellow(`${result.warnings.length} warning(s):`));
|
|
3654
|
+
lines.push(...renderWarnings(result.warnings));
|
|
3655
|
+
}
|
|
3656
|
+
return lines.join("\n");
|
|
3657
|
+
}
|
|
3658
|
+
/** Same renderer, for a standalone fragment-FILE check (`--kind fragment`). */
|
|
3659
|
+
function formatFragmentResult(result, source) {
|
|
3660
|
+
const lines = [];
|
|
3661
|
+
if (result.ok) {
|
|
3662
|
+
lines.push(green(`✔ Valid fragment file`) + dim(` — ${source}`));
|
|
3663
|
+
lines.push(` id: ${result.fragmentFileId}`);
|
|
3664
|
+
lines.push(` fragments: ${result.fragmentIds.length}` + (result.fragmentIds.length ? ` (${result.fragmentIds.join(", ")})` : ""));
|
|
3665
|
+
if (result.warnings.length) {
|
|
3666
|
+
lines.push("");
|
|
3667
|
+
lines.push(yellow(`${result.warnings.length} warning(s):`));
|
|
3668
|
+
lines.push(...renderWarnings(result.warnings));
|
|
3669
|
+
}
|
|
3670
|
+
return lines.join("\n");
|
|
3671
|
+
}
|
|
3672
|
+
lines.push(red(`✘ Invalid fragment file`) + dim(` — ${source}`));
|
|
3673
|
+
lines.push("");
|
|
3674
|
+
lines.push(red(`${result.errors.length} error(s):`));
|
|
3675
|
+
lines.push(...renderErrors(result.errors));
|
|
3676
|
+
if (result.warnings.length) {
|
|
3677
|
+
lines.push("");
|
|
3678
|
+
lines.push(yellow(`${result.warnings.length} warning(s):`));
|
|
3679
|
+
lines.push(...renderWarnings(result.warnings));
|
|
3680
|
+
}
|
|
3681
|
+
return lines.join("\n");
|
|
3682
|
+
}
|
|
3683
|
+
/**
|
|
3684
|
+
* Shared tail for the quiz/writing renderers: on failure, the error list (with any
|
|
3685
|
+
* flattened Zod issues); plus any warnings on either branch.
|
|
3686
|
+
*/
|
|
3687
|
+
function renderFailureAndWarnings(result, label, source) {
|
|
3688
|
+
const lines = [red(`✘ Invalid ${label}`) + dim(` — ${source}`), ""];
|
|
3689
|
+
lines.push(red(`${result.errors.length} error(s):`));
|
|
3690
|
+
lines.push(...renderErrors(result.errors));
|
|
3691
|
+
if (result.warnings.length) {
|
|
3692
|
+
lines.push("");
|
|
3693
|
+
lines.push(yellow(`${result.warnings.length} warning(s):`));
|
|
3694
|
+
lines.push(...renderWarnings(result.warnings));
|
|
3695
|
+
}
|
|
3696
|
+
return lines.join("\n");
|
|
3697
|
+
}
|
|
3698
|
+
/** Renderer for a quiz check (`--kind quiz`). */
|
|
3699
|
+
function formatQuizResult(result, source) {
|
|
3700
|
+
if (!result.ok) return renderFailureAndWarnings(result, "quiz", source);
|
|
3701
|
+
const lines = [green(`✔ Valid quiz`) + dim(` — ${source}`)];
|
|
3702
|
+
lines.push(` id: ${result.quizId}`);
|
|
3703
|
+
lines.push(` model: ${result.model}`);
|
|
3704
|
+
lines.push(` questions: ${result.questionCount} anonymous: ${result.anonymous}`);
|
|
3705
|
+
if (result.warnings.length) {
|
|
3706
|
+
lines.push("");
|
|
3707
|
+
lines.push(yellow(`${result.warnings.length} warning(s):`));
|
|
3708
|
+
lines.push(...renderWarnings(result.warnings));
|
|
3709
|
+
}
|
|
3710
|
+
return lines.join("\n");
|
|
3711
|
+
}
|
|
3712
|
+
/** Renderer for a writing-activity check (`--kind writing`). */
|
|
3713
|
+
function formatWritingResult(result, source) {
|
|
3714
|
+
if (!result.ok) return renderFailureAndWarnings(result, "writing activity", source);
|
|
3715
|
+
const lines = [green(`✔ Valid writing activity`) + dim(` — ${source}`)];
|
|
3716
|
+
lines.push(` id: ${result.writingId}`);
|
|
3717
|
+
lines.push(` model: ${result.model}`);
|
|
3718
|
+
lines.push(` anonymous: ${result.anonymous}`);
|
|
3719
|
+
if (result.warnings.length) {
|
|
3720
|
+
lines.push("");
|
|
3721
|
+
lines.push(yellow(`${result.warnings.length} warning(s):`));
|
|
3722
|
+
lines.push(...renderWarnings(result.warnings));
|
|
3723
|
+
}
|
|
3724
|
+
return lines.join("\n");
|
|
3725
|
+
}
|
|
3726
|
+
/**
|
|
3727
|
+
* Renderer for a coding-activity check (`--kind coding`). Coding is ALWAYS anonymous
|
|
3728
|
+
* (the API path carries no per-student identity), so — unlike quiz/writing — that is
|
|
3729
|
+
* shown as a fixed note, not a per-file value.
|
|
3730
|
+
*/
|
|
3731
|
+
function formatCodingResult(result, source) {
|
|
3732
|
+
if (!result.ok) return renderFailureAndWarnings(result, "coding activity", source);
|
|
3733
|
+
const lines = [green(`✔ Valid coding activity`) + dim(` — ${source}`)];
|
|
3734
|
+
lines.push(` id: ${result.codingId}`);
|
|
3735
|
+
lines.push(` model: ${result.model}`);
|
|
3736
|
+
lines.push(` anonymous: true ${dim("(always — the API path carries no identity)")}`);
|
|
3737
|
+
if (result.warnings.length) {
|
|
3738
|
+
lines.push("");
|
|
3739
|
+
lines.push(yellow(`${result.warnings.length} warning(s):`));
|
|
3740
|
+
lines.push(...renderWarnings(result.warnings));
|
|
3741
|
+
}
|
|
3742
|
+
return lines.join("\n");
|
|
3743
|
+
}
|
|
3744
|
+
/**
|
|
3745
|
+
* Renderer for a golden-answer eval check (`--kind eval`). An eval describes a quiz it
|
|
3746
|
+
* does not contain, so the summary names the resolved target and the size of the run
|
|
3747
|
+
* the file would produce.
|
|
3748
|
+
*/
|
|
3749
|
+
function formatEvalResult(result, source) {
|
|
3750
|
+
if (!result.ok) return renderFailureAndWarnings(result, "eval", source);
|
|
3751
|
+
const lines = [green(`✔ Valid eval`) + dim(` — ${source}`)];
|
|
3752
|
+
lines.push(` id: ${result.evalFile.id}`);
|
|
3753
|
+
lines.push(` target: ${result.targetUrl}`);
|
|
3754
|
+
lines.push(` questions: ${result.evalFile.questions.length} cases: ${result.caseCount}`);
|
|
3755
|
+
lines.push(` quiz model: ${result.quizDump.llm.provider} / ${result.quizDump.llm.model}`);
|
|
3756
|
+
if (result.warnings.length) {
|
|
3757
|
+
lines.push("");
|
|
3758
|
+
lines.push(yellow(`${result.warnings.length} warning(s):`));
|
|
3759
|
+
lines.push(...renderWarnings(result.warnings));
|
|
3760
|
+
}
|
|
3761
|
+
return lines.join("\n");
|
|
3762
|
+
}
|
|
3763
|
+
/** One-line answer snippet for a mismatch line (single-line, bounded). */
|
|
3764
|
+
function snippet(text, max = 60) {
|
|
3765
|
+
const flat = text.replace(/\s+/g, " ").trim();
|
|
3766
|
+
return flat.length <= max ? flat : `${flat.slice(0, max - 1)}…`;
|
|
3767
|
+
}
|
|
3768
|
+
/** Thousands-separated, en-US so a report reads the same on every machine. */
|
|
3769
|
+
function formatTokenCount(count) {
|
|
3770
|
+
return count.toLocaleString("en-US");
|
|
3771
|
+
}
|
|
3772
|
+
/**
|
|
3773
|
+
* `tokens: 15,420 in (12,300 cached) / 2,810 out` — or `undefined` when nothing was
|
|
3774
|
+
* reported at all (no server usage, or none of the calls succeeded), in which case the
|
|
3775
|
+
* reports print no token line rather than a misleading row of zeros. The cached
|
|
3776
|
+
* parenthetical is dropped when the provider reported no cache reads. Counts SUCCESSFUL
|
|
3777
|
+
* grading calls only, so it is a lower bound (docs/cli-eval.md).
|
|
3778
|
+
*/
|
|
3779
|
+
function formatUsageLine(usage) {
|
|
3780
|
+
if (usage.input === 0 && usage.cachedInput === 0 && usage.output === 0) return void 0;
|
|
3781
|
+
const cached = usage.cachedInput ? ` (${formatTokenCount(usage.cachedInput)} cached)` : "";
|
|
3782
|
+
return `tokens: ${formatTokenCount(usage.input)} in${cached} / ${formatTokenCount(usage.output)} out`;
|
|
3783
|
+
}
|
|
3784
|
+
/** `question#index expected … got … "answer…"` — one line per non-passing case. */
|
|
3785
|
+
function mismatchLines(result) {
|
|
3786
|
+
return result.mismatches.map((c) => {
|
|
3787
|
+
const head = `${c.questionId}#${c.answerIndex}`;
|
|
3788
|
+
const expected = c.expected.join("|");
|
|
3789
|
+
if (c.status === "errored") {
|
|
3790
|
+
const first = c.repeats.find((row) => row.error !== void 0)?.error;
|
|
3791
|
+
const message = typeof first === "object" && first !== null && "message" in first ? String(first.message) : "no verdict";
|
|
3792
|
+
return ` ${red("✗")} ${head} expected ${expected} got ${red("error")} ${dim(message)}`;
|
|
3793
|
+
}
|
|
3794
|
+
return ` ${red("✗")} ${head} expected ${expected} got ${red(c.verdict ?? "?")}` + dim(` "${snippet(c.answer)}"`);
|
|
3795
|
+
});
|
|
3796
|
+
}
|
|
3797
|
+
/**
|
|
3798
|
+
* The human report for ONE eval run: header (id, target, the EFFECTIVE llm — rendered
|
|
3799
|
+
* as `quiz-llm → override-llm` when `--llm-provider`/`--llm-model` was used, so a
|
|
3800
|
+
* comparison report can never be mistaken for a baseline one), one line per
|
|
3801
|
+
* mismatch/error, totals, the confusion matrix and the false-correct rate.
|
|
3802
|
+
*/
|
|
3803
|
+
function formatEvalReport(result, source) {
|
|
3804
|
+
const { totals } = result;
|
|
3805
|
+
const lines = [
|
|
3806
|
+
(totals.failed === 0 && totals.errored === 0 && totals.skipped === 0 ? green("✔ Eval passed") : red("✘ Eval failed")) + dim(` — ${source}`),
|
|
3807
|
+
` id: ${result.id}`,
|
|
3808
|
+
` target: ${result.target}`
|
|
3809
|
+
];
|
|
3810
|
+
const llm = result.llm.overrides ? `${result.llm.overrides.provider} / ${result.llm.overrides.model} ${yellow("→")} ${result.llm.provider} / ${result.llm.model} ${yellow("(override)")}` : `${result.llm.provider} / ${result.llm.model}`;
|
|
3811
|
+
lines.push(` llm: ${llm}`);
|
|
3812
|
+
lines.push(` cases: ${totals.cases} × ${totals.repeats} repeat(s) = ${totals.calls} grading call(s)`);
|
|
3813
|
+
if (result.aborted) {
|
|
3814
|
+
lines.push("");
|
|
3815
|
+
lines.push(red(`Run aborted: ${result.aborted.message}`));
|
|
3816
|
+
}
|
|
3817
|
+
if (result.mismatches.length) {
|
|
3818
|
+
lines.push("");
|
|
3819
|
+
lines.push(red(`${result.mismatches.length} mismatch(es):`));
|
|
3820
|
+
lines.push(...mismatchLines(result));
|
|
3821
|
+
}
|
|
3822
|
+
lines.push("");
|
|
3823
|
+
lines.push(` passed: ${totals.passed} failed: ${totals.failed} errored: ${totals.errored}` + (totals.skipped ? red(` skipped: ${totals.skipped} (run aborted)`) : "") + (totals.unstable ? dim(` unstable: ${totals.unstable}`) : ""));
|
|
3824
|
+
const tokens = formatUsageLine(totals.usage);
|
|
3825
|
+
if (tokens) lines.push(dim(` ${tokens}`));
|
|
3826
|
+
if (result.confusion.length) {
|
|
3827
|
+
lines.push("");
|
|
3828
|
+
lines.push(" confusion (expected → got):");
|
|
3829
|
+
for (const row of result.confusion) lines.push(` ${row.expected} → ${row.got}: ${row.count}`);
|
|
3830
|
+
}
|
|
3831
|
+
const { count, denominator, rate } = result.falseCorrect;
|
|
3832
|
+
lines.push("");
|
|
3833
|
+
lines.push(` false-correct: ${count}/${denominator}` + (denominator ? ` (${(rate * 100).toFixed(1)}%)` : ""));
|
|
3834
|
+
return lines.join("\n");
|
|
3835
|
+
}
|
|
3836
|
+
/**
|
|
3837
|
+
* The human report for a MULTI-file run: a per-file summary table, grand totals, then
|
|
3838
|
+
* the per-file detail sections only for files that had mismatches. The confusion matrix
|
|
3839
|
+
* and false-correct rate stay per file — mixing verdicts across unrelated quizzes is
|
|
3840
|
+
* not meaningful. A single-file run keeps the plain {@link formatEvalReport}.
|
|
3841
|
+
*/
|
|
3842
|
+
function formatEvalBatchReport(batch) {
|
|
3843
|
+
const lines = [];
|
|
3844
|
+
lines.push(`Evaluated ${batch.totals.files} file(s):`);
|
|
3845
|
+
for (const file of batch.files) {
|
|
3846
|
+
const name = shortSource$1(file.source);
|
|
3847
|
+
if (file.status === "invalid" || !file.result) {
|
|
3848
|
+
lines.push(` ${red("✘")} ${name}: ${red("invalid")} (${file.errors?.length ?? 0} error(s))`);
|
|
3849
|
+
continue;
|
|
3850
|
+
}
|
|
3851
|
+
const t = file.result.totals;
|
|
3852
|
+
const mark = t.failed === 0 && t.errored === 0 && t.skipped === 0 ? green("✔") : red("✗");
|
|
3853
|
+
lines.push(` ${mark} ${name}: ${t.cases} case(s), ${t.passed} passed, ${t.failed} failed, ${t.errored} errored` + (t.skipped ? red(`, ${t.skipped} skipped`) : "") + (t.unstable ? dim(`, ${t.unstable} unstable`) : ""));
|
|
3854
|
+
}
|
|
3855
|
+
const g = batch.totals;
|
|
3856
|
+
lines.push("");
|
|
3857
|
+
lines.push(` TOTAL: ${g.cases} case(s), ${g.passed} passed, ${g.failed} failed, ${g.errored} errored` + (g.skipped ? red(`, ${g.skipped} skipped`) : "") + (g.invalid ? red(`, ${g.invalid} invalid file(s)`) : ""));
|
|
3858
|
+
const tokens = formatUsageLine(g.usage);
|
|
3859
|
+
if (tokens) lines.push(dim(` ${tokens}`));
|
|
3860
|
+
for (const file of batch.files) {
|
|
3861
|
+
if (file.status === "invalid") {
|
|
3862
|
+
lines.push("");
|
|
3863
|
+
lines.push(red(`✘ ${shortSource$1(file.source)} — not a usable eval:`));
|
|
3864
|
+
lines.push(...renderErrors(file.errors ?? []));
|
|
3865
|
+
continue;
|
|
3866
|
+
}
|
|
3867
|
+
if (!file.result || file.result.mismatches.length === 0) continue;
|
|
3868
|
+
lines.push("");
|
|
3869
|
+
lines.push(formatEvalReport(file.result, shortSource$1(file.source)));
|
|
3870
|
+
}
|
|
3871
|
+
return lines.join("\n");
|
|
3872
|
+
}
|
|
3873
|
+
/** The last path segment of a source URL — enough to tell files apart in a table. */
|
|
3874
|
+
function shortSource$1(source) {
|
|
3875
|
+
try {
|
|
3876
|
+
const { pathname } = new URL(source);
|
|
3877
|
+
return decodeURIComponent(pathname.split("/").pop() || source);
|
|
3878
|
+
} catch {
|
|
3879
|
+
return source;
|
|
3880
|
+
}
|
|
3881
|
+
}
|
|
3882
|
+
/**
|
|
3883
|
+
* Renderer for a prompt dump (`prompts`). Kind-agnostic by construction: the envelope
|
|
3884
|
+
* (kind / id / provider+model) plus one line per prompt with its character count — the
|
|
3885
|
+
* sections come from `promptSections`, so a new kind needs no change here. `--json`
|
|
3886
|
+
* carries the prompt text itself.
|
|
3887
|
+
*/
|
|
3888
|
+
function formatPromptDump(dump, sections, source) {
|
|
3889
|
+
const lines = [green(`✔ Prompts — ${dump.kind}`) + dim(` — ${source}`)];
|
|
3890
|
+
lines.push(` id: ${dump.id}`);
|
|
3891
|
+
lines.push(` provider: ${dump.llm.provider} model: ${dump.llm.model}`);
|
|
3892
|
+
lines.push(` prompts: ${sections.length}`);
|
|
3893
|
+
for (const section of sections) lines.push(` ${section.name}: ${section.text.length} chars`);
|
|
3894
|
+
lines.push("");
|
|
3895
|
+
lines.push(dim(" Run again with --json for the full prompt text."));
|
|
3896
|
+
return lines.join("\n");
|
|
3897
|
+
}
|
|
3898
|
+
//#endregion
|
|
3899
|
+
//#region src/report-md.ts
|
|
3900
|
+
/** `2026-08-08 14:32 UTC` — UTC always, so two reports are comparable across machines. */
|
|
3901
|
+
function timestamp(at) {
|
|
3902
|
+
return `${at.toISOString().slice(0, 16).replace("T", " ")} UTC`;
|
|
3903
|
+
}
|
|
3904
|
+
/** Thousands-separated, en-US so the report reads the same on every machine. */
|
|
3905
|
+
function count(value) {
|
|
3906
|
+
return value.toLocaleString("en-US");
|
|
3907
|
+
}
|
|
3908
|
+
/**
|
|
3909
|
+
* A teacher-authored string made safe for a TABLE CELL: newlines collapse to spaces
|
|
3910
|
+
* (a raw newline would end the row) and pipes are escaped (they would split it).
|
|
3911
|
+
*/
|
|
3912
|
+
function cell(text) {
|
|
3913
|
+
return text.replace(/\s+/g, " ").replace(/\|/g, "\\|").trim();
|
|
3914
|
+
}
|
|
3915
|
+
/** The same neutralization for a LIST ITEM, where only the newline is structural. */
|
|
3916
|
+
function inline(text) {
|
|
3917
|
+
return text.replace(/\s+/g, " ").trim();
|
|
3918
|
+
}
|
|
3919
|
+
/**
|
|
3920
|
+
* A verbatim blockquote: every line prefixed, so the text keeps its own line breaks and
|
|
3921
|
+
* can never escape into the surrounding document structure.
|
|
3922
|
+
*/
|
|
3923
|
+
function quote(text) {
|
|
3924
|
+
return text.replace(/\s+$/, "").split(/\r?\n/).map((line) => line ? `> ${line}` : ">").join("\n");
|
|
3925
|
+
}
|
|
3926
|
+
/** `SCCH / gemma-4`, or `SCCH / gemma-4 → Azure Foundry / gpt-5-mini (override)`. */
|
|
3927
|
+
function llmText(llm) {
|
|
3928
|
+
const effective = `${llm.provider} / ${llm.model}`;
|
|
3929
|
+
return llm.overrides ? `${llm.overrides.provider} / ${llm.overrides.model} → ${effective} (override)` : effective;
|
|
3930
|
+
}
|
|
3931
|
+
/** `15,420 / 12,300 / 2,810`, or an em dash when nothing was reported. */
|
|
3932
|
+
function usageCell(usage) {
|
|
3933
|
+
if (usage.input === 0 && usage.cachedInput === 0 && usage.output === 0) return "—";
|
|
3934
|
+
return `${count(usage.input)} / ${count(usage.cachedInput)} / ${count(usage.output)}`;
|
|
3935
|
+
}
|
|
3936
|
+
/** The last path segment of a source URL — how a file is named throughout the report. */
|
|
3937
|
+
function shortSource(source) {
|
|
3938
|
+
try {
|
|
3939
|
+
const { pathname } = new URL(source);
|
|
3940
|
+
return decodeURIComponent(pathname.split("/").pop() || source);
|
|
3941
|
+
} catch {
|
|
3942
|
+
return source;
|
|
3943
|
+
}
|
|
3944
|
+
}
|
|
3945
|
+
/** `1/12 (8.3%)` — the false-correct rate, or `0/0` when nothing could be false-correct. */
|
|
3946
|
+
function falseCorrectCell(result) {
|
|
3947
|
+
const { count: hits, denominator, rate } = result.falseCorrect;
|
|
3948
|
+
return denominator === 0 ? `${hits}/0` : `${hits}/${denominator} (${(rate * 100).toFixed(1)}%)`;
|
|
3949
|
+
}
|
|
3950
|
+
const OVERVIEW_HEADER = [
|
|
3951
|
+
"File",
|
|
3952
|
+
"Eval",
|
|
3953
|
+
"Cases",
|
|
3954
|
+
"Passed",
|
|
3955
|
+
"Failed",
|
|
3956
|
+
"Errored",
|
|
3957
|
+
"Skipped",
|
|
3958
|
+
"Unstable",
|
|
3959
|
+
"False-correct",
|
|
3960
|
+
"Tokens (in / cached / out)"
|
|
3961
|
+
];
|
|
3962
|
+
/** `| a | b |` for one row of the overview table. */
|
|
3963
|
+
function row(cells) {
|
|
3964
|
+
return `| ${cells.join(" | ")} |`;
|
|
3965
|
+
}
|
|
3966
|
+
/** The overview table: one row per file, plus a grand TOTAL row for a real batch. */
|
|
3967
|
+
function overview(batch) {
|
|
3968
|
+
const lines = [row(OVERVIEW_HEADER), row([
|
|
3969
|
+
"---",
|
|
3970
|
+
"---",
|
|
3971
|
+
"---:",
|
|
3972
|
+
"---:",
|
|
3973
|
+
"---:",
|
|
3974
|
+
"---:",
|
|
3975
|
+
"---:",
|
|
3976
|
+
"---:",
|
|
3977
|
+
"---:",
|
|
3978
|
+
"---:"
|
|
3979
|
+
])];
|
|
3980
|
+
for (const file of batch.files) {
|
|
3981
|
+
const name = cell(shortSource(file.source));
|
|
3982
|
+
if (!file.result) {
|
|
3983
|
+
lines.push(row([
|
|
3984
|
+
name,
|
|
3985
|
+
"**invalid**",
|
|
3986
|
+
"—",
|
|
3987
|
+
"—",
|
|
3988
|
+
"—",
|
|
3989
|
+
"—",
|
|
3990
|
+
"—",
|
|
3991
|
+
"—",
|
|
3992
|
+
"—",
|
|
3993
|
+
"—"
|
|
3994
|
+
]));
|
|
3995
|
+
continue;
|
|
3996
|
+
}
|
|
3997
|
+
const t = file.result.totals;
|
|
3998
|
+
lines.push(row([
|
|
3999
|
+
`${file.passed ? "✅" : "❌"} ${name}`,
|
|
4000
|
+
`\`${cell(file.result.id)}\``,
|
|
4001
|
+
count(t.cases),
|
|
4002
|
+
count(t.passed),
|
|
4003
|
+
count(t.failed),
|
|
4004
|
+
count(t.errored),
|
|
4005
|
+
count(t.skipped),
|
|
4006
|
+
count(t.unstable),
|
|
4007
|
+
falseCorrectCell(file.result),
|
|
4008
|
+
usageCell(t.usage)
|
|
4009
|
+
]));
|
|
4010
|
+
}
|
|
4011
|
+
if (batch.files.length > 1) {
|
|
4012
|
+
const g = batch.totals;
|
|
4013
|
+
lines.push(row([
|
|
4014
|
+
"**TOTAL**",
|
|
4015
|
+
g.invalid ? `${count(g.invalid)} invalid` : "",
|
|
4016
|
+
`**${count(g.cases)}**`,
|
|
4017
|
+
`**${count(g.passed)}**`,
|
|
4018
|
+
`**${count(g.failed)}**`,
|
|
4019
|
+
`**${count(g.errored)}**`,
|
|
4020
|
+
`**${count(g.skipped)}**`,
|
|
4021
|
+
`**${count(g.unstable)}**`,
|
|
4022
|
+
"",
|
|
4023
|
+
`**${usageCell(g.usage)}**`
|
|
4024
|
+
]));
|
|
4025
|
+
}
|
|
4026
|
+
return lines;
|
|
4027
|
+
}
|
|
4028
|
+
/** The one-line error message an errored repeat row carries. */
|
|
4029
|
+
function errorMessage(error) {
|
|
4030
|
+
if (typeof error === "string") return error;
|
|
4031
|
+
if (typeof error === "object" && error !== null && "message" in error) return String(error.message);
|
|
4032
|
+
return JSON.stringify(error ?? null);
|
|
4033
|
+
}
|
|
4034
|
+
/** `expected correct, got incorrect` — the heading's verdict half. */
|
|
4035
|
+
function verdictSummary(evalCase) {
|
|
4036
|
+
return `expected ${evalCase.expected.join(" | ")}, got ${evalCase.status === "errored" ? "error" : evalCase.verdict ?? "no verdict"}`;
|
|
4037
|
+
}
|
|
4038
|
+
/** Whether a case belongs in the details section at all (§ details only for problems). */
|
|
4039
|
+
function needsDetail(evalCase) {
|
|
4040
|
+
return evalCase.status === "failed" || evalCase.status === "errored" || evalCase.unstable;
|
|
4041
|
+
}
|
|
4042
|
+
/**
|
|
4043
|
+
* One case's section: the question it belongs to, the golden answer, and what the
|
|
4044
|
+
* grader said — plus every repeat when they disagreed (the `--repeats` signal is
|
|
4045
|
+
* exactly what a teacher wants to read here, not a majority hidden behind one line).
|
|
4046
|
+
*/
|
|
4047
|
+
function caseSection(evalCase, questionText) {
|
|
2714
4048
|
const lines = [];
|
|
2715
|
-
|
|
2716
|
-
|
|
2717
|
-
|
|
4049
|
+
const unstable = evalCase.unstable ? " *(unstable)*" : "";
|
|
4050
|
+
lines.push(`### \`${evalCase.questionId}\` #${evalCase.answerIndex} — ${verdictSummary(evalCase)}${unstable}`);
|
|
4051
|
+
lines.push("");
|
|
4052
|
+
if (questionText) {
|
|
4053
|
+
lines.push("**Question**");
|
|
4054
|
+
lines.push("");
|
|
4055
|
+
lines.push(quote(questionText));
|
|
4056
|
+
lines.push("");
|
|
4057
|
+
}
|
|
4058
|
+
lines.push("**Golden answer**");
|
|
4059
|
+
lines.push("");
|
|
4060
|
+
lines.push(quote(evalCase.answer));
|
|
4061
|
+
lines.push("");
|
|
4062
|
+
const graded = evalCase.repeats.filter((r) => r.got !== void 0);
|
|
4063
|
+
const disagreed = new Set(graded.map((r) => r.got)).size > 1;
|
|
4064
|
+
if (evalCase.repeats.length > 1 && (disagreed || graded.length !== evalCase.repeats.length)) {
|
|
4065
|
+
lines.push("**Repeats**");
|
|
4066
|
+
lines.push("");
|
|
4067
|
+
for (const repeat of evalCase.repeats) {
|
|
4068
|
+
const verdict = repeat.got ? `\`${repeat.got}\`` : "**error**";
|
|
4069
|
+
const detail = repeat.got ? inline(repeat.feedback ?? "") : inline(errorMessage(repeat.error));
|
|
4070
|
+
lines.push(`- #${repeat.repeatIndex + 1} — ${verdict}${detail ? ` — ${detail}` : ""}`);
|
|
4071
|
+
}
|
|
4072
|
+
lines.push("");
|
|
4073
|
+
return lines;
|
|
4074
|
+
}
|
|
4075
|
+
const feedback = graded.find((r) => r.feedback)?.feedback;
|
|
4076
|
+
if (feedback) {
|
|
4077
|
+
lines.push("**Grader feedback**");
|
|
4078
|
+
lines.push("");
|
|
4079
|
+
lines.push(quote(feedback));
|
|
4080
|
+
lines.push("");
|
|
4081
|
+
}
|
|
4082
|
+
const failure = evalCase.repeats.find((r) => r.error !== void 0);
|
|
4083
|
+
if (failure) {
|
|
4084
|
+
lines.push("**Error**");
|
|
4085
|
+
lines.push("");
|
|
4086
|
+
lines.push(quote(errorMessage(failure.error)));
|
|
4087
|
+
lines.push("");
|
|
4088
|
+
}
|
|
4089
|
+
return lines;
|
|
4090
|
+
}
|
|
4091
|
+
/** One file's details section, or `[]` when the file has nothing to report. */
|
|
4092
|
+
function fileDetails(file) {
|
|
4093
|
+
const name = shortSource(file.source);
|
|
4094
|
+
if (!file.result) {
|
|
4095
|
+
const errors = file.errors ?? [];
|
|
4096
|
+
return [
|
|
4097
|
+
`## ${cell(name)} — invalid`,
|
|
4098
|
+
"",
|
|
4099
|
+
"This file was not graded; fix the problems below and run it again.",
|
|
4100
|
+
"",
|
|
4101
|
+
...errors.map((issue) => `- \`${cell(issue.code)}\` — ${inline(issue.message)}`),
|
|
4102
|
+
""
|
|
4103
|
+
];
|
|
4104
|
+
}
|
|
4105
|
+
const result = file.result;
|
|
4106
|
+
const detailed = result.cases.filter(needsDetail);
|
|
4107
|
+
const skipped = result.totals.skipped;
|
|
4108
|
+
if (detailed.length === 0 && skipped === 0 && !result.aborted) return [];
|
|
4109
|
+
const questionText = new Map(result.questions.map((question) => [question.id, question.text]));
|
|
4110
|
+
const lines = [`## ${cell(name)} — \`${cell(result.id)}\``, ""];
|
|
4111
|
+
if (result.aborted) {
|
|
4112
|
+
lines.push("> [!WARNING]");
|
|
4113
|
+
lines.push(`> The run was aborted: ${inline(result.aborted.message)}`);
|
|
4114
|
+
lines.push("");
|
|
4115
|
+
}
|
|
4116
|
+
for (const evalCase of detailed) lines.push(...caseSection(evalCase, questionText.get(evalCase.questionId)));
|
|
4117
|
+
if (skipped > 0) {
|
|
4118
|
+
const reason = result.aborted ? ` (${inline(result.aborted.message)})` : "";
|
|
4119
|
+
lines.push(`**${count(skipped)} case(s) were never attempted**${reason} — the run is incomplete, so it cannot pass.`);
|
|
4120
|
+
lines.push("");
|
|
4121
|
+
}
|
|
4122
|
+
return lines;
|
|
4123
|
+
}
|
|
4124
|
+
/**
|
|
4125
|
+
* Render the whole run as Markdown: verdict headline, the run's facts, the overview
|
|
4126
|
+
* table, then the details of everything that needs a teacher's attention.
|
|
4127
|
+
*/
|
|
4128
|
+
function renderEvalMarkdownReport(batch, meta) {
|
|
4129
|
+
const lines = [];
|
|
4130
|
+
lines.push(`# Eval report — ${batch.passed ? "✅ passed" : "❌ failed"}`);
|
|
4131
|
+
lines.push("");
|
|
4132
|
+
lines.push(`- **Generated** ${timestamp(meta.generatedAt)} · novedu-cli ${meta.cliVersion}`);
|
|
4133
|
+
const llms = [...new Set(batch.files.filter((f) => f.result).map((f) => llmText(f.result.llm)))];
|
|
4134
|
+
for (const llm of llms) lines.push(`- **LLM** ${llm}`);
|
|
4135
|
+
lines.push(`- **Run** ${count(batch.totals.files)} file(s), ${count(batch.totals.cases)} case(s) × ${count(meta.repeats)} repeat(s), concurrency ${count(meta.concurrency)}`);
|
|
4136
|
+
const tokens = batch.totals.usage;
|
|
4137
|
+
if (tokens.input || tokens.cachedInput || tokens.output) lines.push(`- **Tokens** ${count(tokens.input)} in (${count(tokens.cachedInput)} cached) / ${count(tokens.output)} out — successful grading calls only, so a lower bound`);
|
|
4138
|
+
lines.push("");
|
|
4139
|
+
if (batch.files.filter((file) => file.result?.aborted).length > 0) {
|
|
4140
|
+
lines.push("> [!WARNING]");
|
|
4141
|
+
lines.push(`> The run was ABORTED — ${count(batch.totals.skipped)} case(s) were never graded, so this report is incomplete.`);
|
|
4142
|
+
lines.push("");
|
|
4143
|
+
}
|
|
4144
|
+
lines.push("## Overview");
|
|
4145
|
+
lines.push("");
|
|
4146
|
+
lines.push(...overview(batch));
|
|
4147
|
+
lines.push("");
|
|
4148
|
+
const details = batch.files.flatMap((file) => fileDetails(file));
|
|
4149
|
+
if (details.length === 0) {
|
|
4150
|
+
lines.push("_Nothing else to report — every case matched its expected verdict. The `--json` report carries every case, including the passing ones._");
|
|
4151
|
+
lines.push("");
|
|
4152
|
+
} else {
|
|
4153
|
+
lines.push("_Below: only the mismatched, errored and unstable cases. Passing cases live in the `--json` report._");
|
|
4154
|
+
lines.push("");
|
|
4155
|
+
lines.push(...details);
|
|
4156
|
+
}
|
|
4157
|
+
return `${lines.join("\n").replace(/\n{3,}/g, "\n\n").trimEnd()}\n`;
|
|
4158
|
+
}
|
|
4159
|
+
//#endregion
|
|
4160
|
+
//#region src/version.ts
|
|
4161
|
+
let cached;
|
|
4162
|
+
/** The `version` field of the CLI's package.json; `"unknown"` if it cannot be read. */
|
|
4163
|
+
function cliVersion() {
|
|
4164
|
+
if (cached !== void 0) return cached;
|
|
4165
|
+
try {
|
|
4166
|
+
const { version } = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
|
|
4167
|
+
cached = version ?? "unknown";
|
|
4168
|
+
} catch {
|
|
4169
|
+
cached = "unknown";
|
|
4170
|
+
}
|
|
4171
|
+
return cached;
|
|
4172
|
+
}
|
|
4173
|
+
//#endregion
|
|
4174
|
+
//#region ../lib/coding-schema.ts
|
|
4175
|
+
const CodingYamlSchema = z.strictObject({
|
|
4176
|
+
id: z.string().min(1).meta({ description: "Short machine-readable activity id, e.g. beginner-typescript." }),
|
|
4177
|
+
name: z.string().optional().meta({ description: "Optional human-readable label (not shown to the student)." }),
|
|
4178
|
+
title: z.string().optional().meta({ description: "Optional label shown to the student on the /<code> connection page." }),
|
|
4179
|
+
llm: z.strictObject({
|
|
4180
|
+
model: z.string().min(1).meta({ description: "The model that answers. SERVER-ONLY and PINNED: the proxy always uses this model and ignores whatever model the coding agent sends." }),
|
|
4181
|
+
provider: providerSchema
|
|
4182
|
+
}).meta({
|
|
4183
|
+
id: "llm",
|
|
4184
|
+
description: "The pinned model and provider that answer coding requests."
|
|
4185
|
+
}),
|
|
4186
|
+
fragment_files: z.array(FragmentFileRefSchema).default([]).meta({ description: "Optional fragment libraries this activity pulls shared prompt fragments from." }),
|
|
4187
|
+
text_files: z.array(TextFileRefSchema).default([]).meta({ description: "Optional plain-text files (markdown / source, e.g. a sample solution) embedded verbatim into instructions via {{file \"alias\"}} markers." }),
|
|
4188
|
+
instructions: z.string().min(1).meta({ description: "The assistant's system prompt. SERVER-ONLY: never sent to the browser or the coding agent, and appended AFTER the coding tool's own prompt (so the teacher has the final word). Constrain the assistant to what your class has learned. When any fragment_files or text_files are declared it is a Handlebars template: place fragments inline with {{fragment \"alias.id\" …}} and embed text files with {{file \"alias\"}} (optionally {{file \"alias\" from=10 to=40}} for a line range; escape a literal {{ as \\{{)." })
|
|
4189
|
+
});
|
|
4190
|
+
//#endregion
|
|
4191
|
+
//#region ../lib/coding-validate.ts
|
|
4192
|
+
/**
|
|
4193
|
+
* Extract metadata from an already-schema-validated coding value. Split from
|
|
4194
|
+
* `checkCodingValue` so `loadAndCheckCoding` can reuse the single `validate` it already
|
|
4195
|
+
* ran (no second parse of the same document against the same schema).
|
|
4196
|
+
*/
|
|
4197
|
+
function checkCodingParsed(coding) {
|
|
4198
|
+
return {
|
|
4199
|
+
ok: true,
|
|
4200
|
+
codingId: coding.id,
|
|
4201
|
+
model: coding.llm.model,
|
|
4202
|
+
provider: coding.llm.provider,
|
|
4203
|
+
title: coding.title ?? null,
|
|
4204
|
+
warnings: []
|
|
4205
|
+
};
|
|
4206
|
+
}
|
|
4207
|
+
/**
|
|
4208
|
+
* Validate a coding FILE: scheme-gate + fetch + parse (shared `loadYaml`), then the
|
|
4209
|
+
* pure `checkCodingValue`. The web app passes the default http(s)-only schemes; the
|
|
4210
|
+
* CLI adds `file:` so a local coding YAML on disk validates too.
|
|
4211
|
+
*/
|
|
4212
|
+
async function loadAndCheckCoding(url, fetchImpl, opts = {}) {
|
|
4213
|
+
const yaml = await loadYaml(url, fetchImpl, opts);
|
|
4214
|
+
if (!yaml.ok) return {
|
|
4215
|
+
ok: false,
|
|
4216
|
+
errors: [yaml.error],
|
|
4217
|
+
warnings: []
|
|
4218
|
+
};
|
|
4219
|
+
const valid = validate(yaml.value, CodingYamlSchema, "CODING_SCHEMA_ERROR", url);
|
|
4220
|
+
if (!valid.ok) return {
|
|
4221
|
+
ok: false,
|
|
4222
|
+
errors: [valid.error],
|
|
4223
|
+
warnings: []
|
|
4224
|
+
};
|
|
4225
|
+
const checked = checkCodingParsed(valid.data);
|
|
4226
|
+
if (!checked.ok) return checked;
|
|
4227
|
+
const assembled = await assembleFragmentPrompt({
|
|
4228
|
+
fragment_files: valid.data.fragment_files,
|
|
4229
|
+
text_files: valid.data.text_files
|
|
4230
|
+
}, url, fetchImpl, {
|
|
4231
|
+
allowedSchemes: opts.allowedSchemes,
|
|
4232
|
+
validateLibraries: opts.validateLibraries ?? true
|
|
4233
|
+
}, valid.data.instructions);
|
|
4234
|
+
const warnings = [...checked.warnings, ...assembled.warnings];
|
|
4235
|
+
if (!assembled.ok) return {
|
|
4236
|
+
ok: false,
|
|
4237
|
+
errors: assembled.errors,
|
|
4238
|
+
warnings
|
|
4239
|
+
};
|
|
4240
|
+
return {
|
|
4241
|
+
...checked,
|
|
4242
|
+
warnings
|
|
4243
|
+
};
|
|
4244
|
+
}
|
|
4245
|
+
//#endregion
|
|
4246
|
+
//#region ../lib/writing-schema.ts
|
|
4247
|
+
const WritingYamlSchema = z.strictObject({
|
|
4248
|
+
id: z.string().min(1).meta({ description: "Short machine-readable activity id, e.g. human-animal-short-story." }),
|
|
4249
|
+
name: z.string().optional().meta({ description: "Optional human-readable title (used as a label)." }),
|
|
4250
|
+
title: z.string().optional().meta({ description: "Optional greeting shown to students on the welcome screen instead of the default message." }),
|
|
4251
|
+
description: z.string().optional().meta({ description: "Optional description shown to students below the welcome greeting (Markdown)." }),
|
|
4252
|
+
anonymous: z.boolean().optional().meta({
|
|
4253
|
+
default: false,
|
|
4254
|
+
description: "Writing DIVERGES: it defaults to false (attributed), because review and the Save feature need to know whose text it is. Set to true for ephemeral, unattributed writing — which also disables saving."
|
|
4255
|
+
}),
|
|
4256
|
+
llm: z.strictObject({
|
|
4257
|
+
model: z.string().min(1).meta({ description: "The model that drives the feedback chat." }),
|
|
4258
|
+
provider: providerSchema
|
|
4259
|
+
}).meta({
|
|
4260
|
+
id: "llm",
|
|
4261
|
+
description: "The model and provider that back the writing coach."
|
|
4262
|
+
}),
|
|
4263
|
+
fragment_files: z.array(FragmentFileRefSchema).default([]).meta({ description: "Optional fragment libraries this activity pulls shared prompt fragments from." }),
|
|
4264
|
+
text_files: z.array(TextFileRefSchema).default([]).meta({ description: "Optional plain-text files (markdown / source) embedded verbatim into instructions via {{file \"alias\"}} markers." }),
|
|
4265
|
+
instructions: z.string().min(1).meta({ description: "The writing coach's system prompt. SERVER-ONLY: never sent to the browser, so it may describe the assessment criteria and coaching strategy. When any fragment_files or text_files are declared it is a Handlebars template: place fragments inline with {{fragment \"alias.id\" …}} and embed text files with {{file \"alias\"}} (optionally {{file \"alias\" from=10 to=40}} for a line range; escape a literal {{ as \\{{)." }),
|
|
4266
|
+
placeholder: z.string().optional().meta({ description: "Optional starter text prefilled into the editor. Empty for a blank page." })
|
|
4267
|
+
});
|
|
4268
|
+
//#endregion
|
|
4269
|
+
//#region ../lib/writing-validate.ts
|
|
4270
|
+
/** Writing DIVERGES from tutor/quiz: it defaults to attributed (`anonymous: false`). */
|
|
4271
|
+
const DEFAULT_ANONYMOUS = false;
|
|
4272
|
+
/**
|
|
4273
|
+
* Extract metadata from an already-schema-validated writing value. Split from
|
|
4274
|
+
* `checkWritingValue` so `loadAndCheckWriting` can reuse the single `validate` it already
|
|
4275
|
+
* ran (no second parse of the same document against the same schema).
|
|
4276
|
+
*/
|
|
4277
|
+
function checkWritingParsed(writing) {
|
|
4278
|
+
return {
|
|
4279
|
+
ok: true,
|
|
4280
|
+
writingId: writing.id,
|
|
4281
|
+
model: writing.llm.model,
|
|
4282
|
+
provider: writing.llm.provider,
|
|
4283
|
+
anonymous: writing.anonymous ?? DEFAULT_ANONYMOUS,
|
|
4284
|
+
title: writing.title ?? null,
|
|
4285
|
+
warnings: []
|
|
4286
|
+
};
|
|
4287
|
+
}
|
|
4288
|
+
/**
|
|
4289
|
+
* Validate a writing FILE: scheme-gate + fetch + parse (shared `loadYaml`), then the
|
|
4290
|
+
* pure `checkWritingValue`. The web app passes the default http(s)-only schemes; the
|
|
4291
|
+
* CLI adds `file:` so a local writing YAML on disk validates too.
|
|
4292
|
+
*/
|
|
4293
|
+
async function loadAndCheckWriting(url, fetchImpl, opts = {}) {
|
|
4294
|
+
const yaml = await loadYaml(url, fetchImpl, opts);
|
|
4295
|
+
if (!yaml.ok) return {
|
|
4296
|
+
ok: false,
|
|
4297
|
+
errors: [yaml.error],
|
|
4298
|
+
warnings: []
|
|
4299
|
+
};
|
|
4300
|
+
const valid = validate(yaml.value, WritingYamlSchema, "WRITING_SCHEMA_ERROR", url);
|
|
4301
|
+
if (!valid.ok) return {
|
|
4302
|
+
ok: false,
|
|
4303
|
+
errors: [valid.error],
|
|
4304
|
+
warnings: []
|
|
4305
|
+
};
|
|
4306
|
+
const checked = checkWritingParsed(valid.data);
|
|
4307
|
+
if (!checked.ok) return checked;
|
|
4308
|
+
const assembled = await assembleFragmentPrompt({
|
|
4309
|
+
fragment_files: valid.data.fragment_files,
|
|
4310
|
+
text_files: valid.data.text_files
|
|
4311
|
+
}, url, fetchImpl, {
|
|
4312
|
+
allowedSchemes: opts.allowedSchemes,
|
|
4313
|
+
validateLibraries: opts.validateLibraries ?? true
|
|
4314
|
+
}, valid.data.instructions);
|
|
4315
|
+
const warnings = [...checked.warnings, ...assembled.warnings];
|
|
4316
|
+
if (!assembled.ok) return {
|
|
4317
|
+
ok: false,
|
|
4318
|
+
errors: assembled.errors,
|
|
4319
|
+
warnings
|
|
4320
|
+
};
|
|
4321
|
+
return {
|
|
4322
|
+
...checked,
|
|
4323
|
+
warnings
|
|
4324
|
+
};
|
|
4325
|
+
}
|
|
4326
|
+
//#endregion
|
|
4327
|
+
//#region src/commands/validate.ts
|
|
4328
|
+
/** Every kind the `--kind` flag accepts (used for the option help + guard). */
|
|
4329
|
+
const VALIDATE_KINDS = [
|
|
4330
|
+
"tutor",
|
|
4331
|
+
"fragment",
|
|
4332
|
+
"quiz",
|
|
4333
|
+
"writing",
|
|
4334
|
+
"coding",
|
|
4335
|
+
"eval"
|
|
4336
|
+
];
|
|
4337
|
+
/**
|
|
4338
|
+
* Turn the CLI argument into a URL the tutor core understands: an http(s) URL is
|
|
4339
|
+
* used as-is; anything else is treated as a filesystem path and converted to an
|
|
4340
|
+
* absolute `file://` URL.
|
|
4341
|
+
*/
|
|
4342
|
+
function toUrl(pathOrUrl) {
|
|
4343
|
+
if (/^https?:\/\//i.test(pathOrUrl)) return pathOrUrl;
|
|
4344
|
+
return pathToFileURL(resolve(pathOrUrl)).href;
|
|
4345
|
+
}
|
|
4346
|
+
/**
|
|
4347
|
+
* The validate command's pure core: run the requested pipeline over a local file or
|
|
4348
|
+
* public URL. `file:` is allowed in addition to http(s) so local YAML can be
|
|
4349
|
+
* validated (the web app deliberately stays http(s)-only). As an authoring tool, the
|
|
4350
|
+
* tutor path runs the THOROUGH check (`validateLibraries`), so every fragment in every
|
|
4351
|
+
* referenced library is rendered — not just the ones the tutor uses.
|
|
4352
|
+
*/
|
|
4353
|
+
function runValidate(pathOrUrl, kind) {
|
|
4354
|
+
const url = toUrl(pathOrUrl);
|
|
4355
|
+
const allowedSchemes = [
|
|
4356
|
+
"http:",
|
|
4357
|
+
"https:",
|
|
4358
|
+
"file:"
|
|
4359
|
+
];
|
|
4360
|
+
switch (kind) {
|
|
4361
|
+
case "fragment": return loadAndCheckFragmentFile(url, cliFetcher, { allowedSchemes }).then((result) => ({
|
|
4362
|
+
kind,
|
|
4363
|
+
result
|
|
4364
|
+
}));
|
|
4365
|
+
case "quiz": return loadAndCheckQuiz(url, cliFetcher, { allowedSchemes }).then((result) => ({
|
|
4366
|
+
kind,
|
|
4367
|
+
result
|
|
4368
|
+
}));
|
|
4369
|
+
case "writing": return loadAndCheckWriting(url, cliFetcher, { allowedSchemes }).then((result) => ({
|
|
4370
|
+
kind,
|
|
4371
|
+
result
|
|
4372
|
+
}));
|
|
4373
|
+
case "coding": return loadAndCheckCoding(url, cliFetcher, { allowedSchemes }).then((result) => ({
|
|
4374
|
+
kind,
|
|
4375
|
+
result
|
|
4376
|
+
}));
|
|
4377
|
+
case "eval": return loadAndCheckEval(url, cliFetcher, {
|
|
4378
|
+
allowedSchemes,
|
|
4379
|
+
validateLibraries: true,
|
|
4380
|
+
strictTarget: true
|
|
4381
|
+
}).then((result) => ({
|
|
4382
|
+
kind,
|
|
4383
|
+
result
|
|
4384
|
+
}));
|
|
4385
|
+
default: return loadAndBuildTutorPrompt(url, cliFetcher, {
|
|
4386
|
+
allowedSchemes,
|
|
4387
|
+
validateLibraries: true
|
|
4388
|
+
}).then((result) => ({
|
|
4389
|
+
kind,
|
|
4390
|
+
result
|
|
4391
|
+
}));
|
|
2718
4392
|
}
|
|
2719
|
-
return lines;
|
|
2720
4393
|
}
|
|
2721
|
-
function
|
|
2722
|
-
|
|
2723
|
-
|
|
2724
|
-
|
|
2725
|
-
|
|
2726
|
-
|
|
2727
|
-
|
|
2728
|
-
|
|
2729
|
-
|
|
2730
|
-
|
|
2731
|
-
|
|
4394
|
+
function registerValidate(program) {
|
|
4395
|
+
program.command("validate").description("Validate a tutor (default), fragment library, quiz, writing, coding or eval YAML by local path or public http(s) URL").argument("<pathOrUrl>", "path to a tutor, fragment, quiz, writing, coding or eval YAML file, or a public http(s) URL").option("--kind <kind>", `what the file is: ${VALIDATE_KINDS.map((k) => `'${k}'`).join(", ")} ('tutor' is the default)`, "tutor").option("--json", "print the raw validation result as JSON").addHelpText("after", `
|
|
4396
|
+
Examples:
|
|
4397
|
+
# Validate a tutor (also strict-renders every fragment in every referenced library)
|
|
4398
|
+
$ novedu-cli validate ./activities/tutors/my-tutor.yaml
|
|
4399
|
+
|
|
4400
|
+
# Validate a fragment library on its own
|
|
4401
|
+
$ novedu-cli validate ./activities/tutors/my-fragments.yaml --kind fragment
|
|
4402
|
+
|
|
4403
|
+
# Validate a quiz, a writing activity, or a coding activity
|
|
4404
|
+
$ novedu-cli validate ./activities/quizzes/my-quiz.yaml --kind quiz
|
|
4405
|
+
$ novedu-cli validate ./activities/writings/my-writing.yaml --kind writing
|
|
4406
|
+
$ novedu-cli validate ./activities/coding/my-coding.yaml --kind coding
|
|
4407
|
+
|
|
4408
|
+
# Validate a golden-answer eval (also strict-checks the quiz it targets)
|
|
4409
|
+
$ novedu-cli validate ./my-quiz.eval.yaml --kind eval
|
|
4410
|
+
|
|
4411
|
+
# Machine-readable output for CI
|
|
4412
|
+
$ novedu-cli validate https://example.com/tutor.yaml --json`).action(async (pathOrUrl, options) => {
|
|
4413
|
+
if (options.kind !== void 0 && !VALIDATE_KINDS.includes(options.kind)) {
|
|
4414
|
+
console.error(`Invalid --kind "${options.kind}": expected ${VALIDATE_KINDS.map((k) => `"${k}"`).join(", ")}.`);
|
|
4415
|
+
process.exitCode = 1;
|
|
4416
|
+
return;
|
|
2732
4417
|
}
|
|
2733
|
-
|
|
2734
|
-
|
|
2735
|
-
|
|
2736
|
-
|
|
2737
|
-
|
|
2738
|
-
|
|
2739
|
-
|
|
2740
|
-
|
|
2741
|
-
|
|
2742
|
-
|
|
4418
|
+
const outcome = await runValidate(pathOrUrl, options.kind ?? "tutor");
|
|
4419
|
+
if (options.json) console.log(JSON.stringify(outcome.result, null, 2));
|
|
4420
|
+
else console.log(formatOutcome(outcome, pathOrUrl));
|
|
4421
|
+
process.exitCode = outcome.result.ok ? 0 : 1;
|
|
4422
|
+
});
|
|
4423
|
+
}
|
|
4424
|
+
/** Pick the formatter for the outcome's kind (each result type has its own renderer). */
|
|
4425
|
+
function formatOutcome(outcome, source) {
|
|
4426
|
+
switch (outcome.kind) {
|
|
4427
|
+
case "fragment": return formatFragmentResult(outcome.result, source);
|
|
4428
|
+
case "quiz": return formatQuizResult(outcome.result, source);
|
|
4429
|
+
case "writing": return formatWritingResult(outcome.result, source);
|
|
4430
|
+
case "coding": return formatCodingResult(outcome.result, source);
|
|
4431
|
+
case "eval": return formatEvalResult(outcome.result, source);
|
|
4432
|
+
default: return formatResult(outcome.result, source);
|
|
2743
4433
|
}
|
|
2744
|
-
return lines.join("\n");
|
|
2745
4434
|
}
|
|
2746
|
-
|
|
2747
|
-
|
|
2748
|
-
|
|
2749
|
-
|
|
2750
|
-
|
|
2751
|
-
|
|
2752
|
-
|
|
2753
|
-
|
|
2754
|
-
|
|
2755
|
-
|
|
2756
|
-
|
|
4435
|
+
//#endregion
|
|
4436
|
+
//#region src/commands/eval.ts
|
|
4437
|
+
const CONCURRENCY_DEFAULT = 4;
|
|
4438
|
+
/** Shell metacharacters that make a plain argument a PATTERN rather than a path. */
|
|
4439
|
+
const GLOB_MAGIC = /[*?[\]{}]/;
|
|
4440
|
+
/**
|
|
4441
|
+
* Turn the positional arguments into the list of sources to evaluate. A `file:` /
|
|
4442
|
+
* `http(s):` URL and a plain path pass through untouched (so a shell-expanded glob —
|
|
4443
|
+
* which arrives as plain paths — behaves identically); an argument carrying glob magic
|
|
4444
|
+
* is expanded relative to the cwd and sorted, which is what makes `"./**\/*.eval.yaml"`
|
|
4445
|
+
* and PowerShell/cmd work. A pattern that matches NOTHING is a hard failure: it is
|
|
4446
|
+
* almost certainly a typo, and silently evaluating zero files would exit 0.
|
|
4447
|
+
*/
|
|
4448
|
+
function expandSources(args) {
|
|
4449
|
+
const expanded = [];
|
|
4450
|
+
for (const arg of args) {
|
|
4451
|
+
if (/^(?:https?|file):/i.test(arg) || !GLOB_MAGIC.test(arg)) {
|
|
4452
|
+
expanded.push(arg);
|
|
4453
|
+
continue;
|
|
2757
4454
|
}
|
|
2758
|
-
|
|
4455
|
+
let matches;
|
|
4456
|
+
try {
|
|
4457
|
+
matches = [...globSync(arg)];
|
|
4458
|
+
} catch (error) {
|
|
4459
|
+
return {
|
|
4460
|
+
ok: false,
|
|
4461
|
+
message: `Could not expand the pattern "${arg}": ${error instanceof Error ? error.message : error}`
|
|
4462
|
+
};
|
|
4463
|
+
}
|
|
4464
|
+
if (matches.length === 0) return {
|
|
4465
|
+
ok: false,
|
|
4466
|
+
message: `The pattern "${arg}" matched no files.`
|
|
4467
|
+
};
|
|
4468
|
+
expanded.push(...matches.sort((a, b) => a.localeCompare(b)));
|
|
2759
4469
|
}
|
|
2760
|
-
|
|
2761
|
-
|
|
2762
|
-
|
|
2763
|
-
|
|
2764
|
-
|
|
2765
|
-
|
|
2766
|
-
|
|
2767
|
-
|
|
4470
|
+
const sources = [];
|
|
4471
|
+
const duplicates = [];
|
|
4472
|
+
const seen = /* @__PURE__ */ new Set();
|
|
4473
|
+
for (const entry of expanded) {
|
|
4474
|
+
const url = toUrl(entry);
|
|
4475
|
+
if (seen.has(url)) {
|
|
4476
|
+
duplicates.push(url);
|
|
4477
|
+
continue;
|
|
4478
|
+
}
|
|
4479
|
+
seen.add(url);
|
|
4480
|
+
sources.push(url);
|
|
2768
4481
|
}
|
|
2769
|
-
return
|
|
4482
|
+
return {
|
|
4483
|
+
ok: true,
|
|
4484
|
+
sources,
|
|
4485
|
+
duplicates
|
|
4486
|
+
};
|
|
4487
|
+
}
|
|
4488
|
+
/** The `--llm-provider`/`--llm-model` pair: strictly both-or-nothing, provider checked. */
|
|
4489
|
+
function parseOverride(options) {
|
|
4490
|
+
const { llmProvider, llmModel } = options;
|
|
4491
|
+
if (llmProvider === void 0 && llmModel === void 0) return { ok: true };
|
|
4492
|
+
if (llmProvider === void 0 || llmModel === void 0) return {
|
|
4493
|
+
ok: false,
|
|
4494
|
+
message: "Pass --llm-provider and --llm-model together, or neither."
|
|
4495
|
+
};
|
|
4496
|
+
if (!LLM_PROVIDERS.includes(llmProvider)) return {
|
|
4497
|
+
ok: false,
|
|
4498
|
+
message: `Unknown --llm-provider "${llmProvider}": expected ${LLM_PROVIDERS.map((p) => `"${p}"`).join(" or ")}.`
|
|
4499
|
+
};
|
|
4500
|
+
return {
|
|
4501
|
+
ok: true,
|
|
4502
|
+
llm: {
|
|
4503
|
+
provider: llmProvider,
|
|
4504
|
+
model: llmModel
|
|
4505
|
+
}
|
|
4506
|
+
};
|
|
2770
4507
|
}
|
|
2771
4508
|
/**
|
|
2772
|
-
*
|
|
2773
|
-
*
|
|
4509
|
+
* The optional `usage: { input, cachedInput, output }` of a 200 response, defensively:
|
|
4510
|
+
* anything that is not three finite numbers is simply absent (an older server, or one
|
|
4511
|
+
* whose provider reports nothing, must never break a run).
|
|
2774
4512
|
*/
|
|
2775
|
-
function
|
|
2776
|
-
|
|
2777
|
-
|
|
2778
|
-
|
|
2779
|
-
|
|
2780
|
-
|
|
2781
|
-
|
|
2782
|
-
|
|
2783
|
-
|
|
2784
|
-
|
|
4513
|
+
function parseUsage(value) {
|
|
4514
|
+
if (typeof value !== "object" || value === null) return void 0;
|
|
4515
|
+
const { input, cachedInput, output } = value;
|
|
4516
|
+
const counts = [
|
|
4517
|
+
input,
|
|
4518
|
+
cachedInput,
|
|
4519
|
+
output
|
|
4520
|
+
].map((count) => typeof count === "number" && Number.isFinite(count) ? Math.max(0, count) : void 0);
|
|
4521
|
+
if (counts.some((count) => count === void 0)) return void 0;
|
|
4522
|
+
const [inputCount = 0, cachedCount = 0, outputCount = 0] = counts;
|
|
4523
|
+
return {
|
|
4524
|
+
input: inputCount,
|
|
4525
|
+
cachedInput: cachedCount,
|
|
4526
|
+
output: outputCount
|
|
4527
|
+
};
|
|
2785
4528
|
}
|
|
2786
|
-
/**
|
|
2787
|
-
|
|
2788
|
-
|
|
2789
|
-
|
|
2790
|
-
|
|
2791
|
-
|
|
2792
|
-
|
|
2793
|
-
|
|
2794
|
-
|
|
2795
|
-
|
|
2796
|
-
|
|
2797
|
-
|
|
2798
|
-
|
|
4529
|
+
/**
|
|
4530
|
+
* The HTTP seam for ONE grading call, with the run's effective llm closed in — so the
|
|
4531
|
+
* runner itself never learns whether the pair came from the quiz or from `--llm-*`.
|
|
4532
|
+
* Classifies the failure for the retry policy: 5xx and true network failures are
|
|
4533
|
+
* retryable, auth failures abort the run, every other 4xx is terminal.
|
|
4534
|
+
*/
|
|
4535
|
+
function makeGradeFn(server, llm) {
|
|
4536
|
+
return async ({ system, answer }) => {
|
|
4537
|
+
const response = await performApiRequest({
|
|
4538
|
+
server,
|
|
4539
|
+
path: "/api/eval/grade",
|
|
4540
|
+
method: "POST",
|
|
4541
|
+
body: {
|
|
4542
|
+
llm,
|
|
4543
|
+
system,
|
|
4544
|
+
answer
|
|
4545
|
+
},
|
|
4546
|
+
quiet: true
|
|
4547
|
+
});
|
|
4548
|
+
if (response.ok) {
|
|
4549
|
+
const payload = response.payload;
|
|
4550
|
+
const verdict = payload?.result;
|
|
4551
|
+
if (verdict === "correct" || verdict === "partial" || verdict === "incorrect") {
|
|
4552
|
+
const usage = parseUsage(payload?.usage);
|
|
4553
|
+
return {
|
|
4554
|
+
ok: true,
|
|
4555
|
+
verdict,
|
|
4556
|
+
feedback: typeof payload?.feedback === "string" ? payload.feedback : "",
|
|
4557
|
+
...usage ? { usage } : {}
|
|
4558
|
+
};
|
|
4559
|
+
}
|
|
4560
|
+
return {
|
|
4561
|
+
ok: false,
|
|
4562
|
+
retryable: false,
|
|
4563
|
+
error: { message: "The server's response is not a grading verdict — it may not offer /api/eval/grade at all (does it run a Novedu version with the eval feature?). Check the target server, e.g. --server http://localhost:3000." }
|
|
4564
|
+
};
|
|
4565
|
+
}
|
|
4566
|
+
return {
|
|
4567
|
+
ok: false,
|
|
4568
|
+
retryable: response.status === void 0 || response.status >= 500,
|
|
4569
|
+
...response.authFailed ? { auth: true } : {},
|
|
4570
|
+
error: response.error
|
|
4571
|
+
};
|
|
4572
|
+
};
|
|
2799
4573
|
}
|
|
2800
|
-
/**
|
|
2801
|
-
function
|
|
2802
|
-
if (!
|
|
2803
|
-
|
|
2804
|
-
|
|
2805
|
-
|
|
2806
|
-
|
|
2807
|
-
|
|
2808
|
-
|
|
2809
|
-
|
|
2810
|
-
|
|
4574
|
+
/** stderr progress, suppressed off a TTY so CI logs stay readable. */
|
|
4575
|
+
function progressWriter(prefix) {
|
|
4576
|
+
if (!process.stderr.isTTY) return void 0;
|
|
4577
|
+
return ({ done, total }) => {
|
|
4578
|
+
process.stderr.write(`\r${prefix}${done}/${total} `);
|
|
4579
|
+
};
|
|
4580
|
+
}
|
|
4581
|
+
/**
|
|
4582
|
+
* The command's core, exported for the unit tests. `seams` exists only so tests can
|
|
4583
|
+
* shrink the retry backoff — the CLI itself never passes it (PoC parity: 4 attempts,
|
|
4584
|
+
* 5 s linear).
|
|
4585
|
+
*/
|
|
4586
|
+
async function runEvalCommand(pathsOrUrls, options, seams = {}) {
|
|
4587
|
+
const override = parseOverride(options);
|
|
4588
|
+
if (!override.ok) {
|
|
4589
|
+
failJson({ message: override.message });
|
|
4590
|
+
return;
|
|
2811
4591
|
}
|
|
2812
|
-
|
|
4592
|
+
const expansion = expandSources(pathsOrUrls);
|
|
4593
|
+
if (!expansion.ok) {
|
|
4594
|
+
failJson({ message: expansion.message });
|
|
4595
|
+
return;
|
|
4596
|
+
}
|
|
4597
|
+
for (const duplicate of expansion.duplicates) process.stderr.write(`Warning: ${duplicate} was given more than once — ignoring the copy.\n`);
|
|
4598
|
+
const repeats = Math.max(1, Number.parseInt(options.repeats ?? "1", 10) || 1);
|
|
4599
|
+
const concurrency = Math.max(1, Number.parseInt(options.concurrency ?? String(CONCURRENCY_DEFAULT), 10) || CONCURRENCY_DEFAULT);
|
|
4600
|
+
const checked = /* @__PURE__ */ new Map();
|
|
4601
|
+
const files = [];
|
|
4602
|
+
for (const source of expansion.sources) {
|
|
4603
|
+
const result = await loadAndCheckEval(source, cliFetcher, { allowedSchemes: [
|
|
4604
|
+
"http:",
|
|
4605
|
+
"https:",
|
|
4606
|
+
"file:"
|
|
4607
|
+
] });
|
|
4608
|
+
if (!result.ok) {
|
|
4609
|
+
files.push({
|
|
4610
|
+
source,
|
|
4611
|
+
status: "invalid",
|
|
4612
|
+
errors: result.errors
|
|
4613
|
+
});
|
|
4614
|
+
continue;
|
|
4615
|
+
}
|
|
4616
|
+
checked.set(source, result);
|
|
4617
|
+
files.push({
|
|
4618
|
+
source,
|
|
4619
|
+
status: "ok"
|
|
4620
|
+
});
|
|
4621
|
+
}
|
|
4622
|
+
if (checked.size === 0) {
|
|
4623
|
+
failJson({
|
|
4624
|
+
message: files.length === 1 ? "The eval file is not usable." : "None of the eval files are usable.",
|
|
4625
|
+
files: files.map((file) => ({
|
|
4626
|
+
source: file.source,
|
|
4627
|
+
errors: file.errors ?? []
|
|
4628
|
+
})),
|
|
4629
|
+
errors: files.flatMap((file) => file.errors ?? [])
|
|
4630
|
+
});
|
|
4631
|
+
return;
|
|
4632
|
+
}
|
|
4633
|
+
{
|
|
4634
|
+
const totalCases = [...checked.values()].reduce((sum, file) => sum + file.caseCount, 0);
|
|
4635
|
+
process.stderr.write(`${totalCases} case(s) × ${repeats} repeat(s) = ${totalCases * repeats} grading call(s)\n`);
|
|
4636
|
+
}
|
|
4637
|
+
let fileIndex = 0;
|
|
4638
|
+
for (const file of files) {
|
|
4639
|
+
fileIndex += 1;
|
|
4640
|
+
const check = checked.get(file.source);
|
|
4641
|
+
if (!check) continue;
|
|
4642
|
+
const quizLlm = {
|
|
4643
|
+
provider: check.quizDump.llm.provider,
|
|
4644
|
+
model: check.quizDump.llm.model
|
|
4645
|
+
};
|
|
4646
|
+
const effective = override.llm ?? quizLlm;
|
|
4647
|
+
const llm = {
|
|
4648
|
+
...effective,
|
|
4649
|
+
...override.llm ? { overrides: quizLlm } : {}
|
|
4650
|
+
};
|
|
4651
|
+
const prefix = files.length > 1 ? `(${fileIndex}/${files.length}) ${check.evalFile.id}: ` : "";
|
|
4652
|
+
file.result = await runEval("quiz", check, {
|
|
4653
|
+
grade: makeGradeFn(options.server, effective),
|
|
4654
|
+
concurrency,
|
|
4655
|
+
repeats,
|
|
4656
|
+
llm,
|
|
4657
|
+
onProgress: progressWriter(prefix),
|
|
4658
|
+
...seams.retry ? { retry: seams.retry } : {}
|
|
4659
|
+
});
|
|
4660
|
+
if (process.stderr.isTTY) process.stderr.write("\n");
|
|
4661
|
+
}
|
|
4662
|
+
const batch = summarizeBatch(files);
|
|
4663
|
+
const payload = JSON.stringify(batch, null, 2);
|
|
4664
|
+
if (options.json) console.log(payload);
|
|
4665
|
+
else if (files.length === 1 && files[0]?.result) console.log(formatEvalReport(files[0].result, files[0].source));
|
|
4666
|
+
else console.log(formatEvalBatchReport(batch));
|
|
4667
|
+
if (options.out) try {
|
|
4668
|
+
await writeFile(options.out, `${payload}\n`, "utf8");
|
|
4669
|
+
} catch (error) {
|
|
4670
|
+
failJson({ message: `Could not write ${options.out}: ${error instanceof Error ? error.message : error}` });
|
|
4671
|
+
return;
|
|
4672
|
+
}
|
|
4673
|
+
if (options.report) try {
|
|
4674
|
+
await writeFile(options.report, renderEvalMarkdownReport(batch, {
|
|
4675
|
+
generatedAt: /* @__PURE__ */ new Date(),
|
|
4676
|
+
cliVersion: cliVersion(),
|
|
4677
|
+
repeats,
|
|
4678
|
+
concurrency
|
|
4679
|
+
}), "utf8");
|
|
4680
|
+
} catch (error) {
|
|
4681
|
+
failJson({ message: `Could not write ${options.report}: ${error instanceof Error ? error.message : error}` });
|
|
4682
|
+
return;
|
|
4683
|
+
}
|
|
4684
|
+
process.exitCode = batchPassed(batch) ? 0 : 1;
|
|
4685
|
+
}
|
|
4686
|
+
function registerEval(program) {
|
|
4687
|
+
program.command("eval").description("Grade a file of golden answers against its quiz's real rubric and report the result").argument("<evalPathOrUrl...>", "one or more eval YAML files (paths, http(s)/file URLs, or a quoted glob pattern)").option("--server <url>", "Novedu server base URL (defaults to the NOVEDU_SERVER env var, then production)").option("--concurrency <n>", "grading calls in flight per file", String(CONCURRENCY_DEFAULT)).option("--repeats <n>", "grade every answer N times and take the majority verdict", "1").option("--llm-provider <provider>", "grade with this provider instead of the quiz's (\"SCCH\" or \"Azure Foundry\"; needs --llm-model)").option("--llm-model <model>", "grade with this model instead of the quiz's (needs --llm-provider)").option("--json", "print the machine-readable batch report on stdout").option("--out <file>", "additionally write the machine-readable batch report to a file").option("--report <file>", "additionally write a readable Markdown report to a file").addHelpText("after", `
|
|
4688
|
+
Examples:
|
|
4689
|
+
# Evaluate one quiz's golden answers
|
|
4690
|
+
$ novedu-cli eval ./0010-welcome-quiz.eval.yaml
|
|
4691
|
+
|
|
4692
|
+
# A whole course part (quote the pattern so the CLI expands it, ** included)
|
|
4693
|
+
$ novedu-cli eval "./part-1/**/*.eval.yaml"
|
|
4694
|
+
|
|
4695
|
+
# Measure grader stability: 3 runs per answer, majority verdict
|
|
4696
|
+
$ novedu-cli eval ./my-quiz.eval.yaml --repeats 3
|
|
4697
|
+
|
|
4698
|
+
# How would this rubric perform on another model? (both flags, always together)
|
|
4699
|
+
$ novedu-cli eval ./my-quiz.eval.yaml --llm-provider "Azure Foundry" --llm-model gpt-5-mini
|
|
4700
|
+
|
|
4701
|
+
# Machine-readable, for CI
|
|
4702
|
+
$ novedu-cli eval ./my-quiz.eval.yaml --json --out eval-report.json
|
|
4703
|
+
|
|
4704
|
+
# A readable Markdown report (questions, golden answers, grader feedback, tokens)
|
|
4705
|
+
$ novedu-cli eval ./my-quiz.eval.yaml --report eval-report.md`).action(async (pathsOrUrls, options) => {
|
|
4706
|
+
await runEvalCommand(pathsOrUrls, options);
|
|
4707
|
+
});
|
|
4708
|
+
}
|
|
4709
|
+
//#endregion
|
|
4710
|
+
//#region src/commands/files.ts
|
|
4711
|
+
const SERVER_OPTION$2 = ["--server <url>", "Novedu server base URL (defaults to the NOVEDU_SERVER env var, then production)"];
|
|
4712
|
+
async function readStdin() {
|
|
4713
|
+
const chunks = [];
|
|
4714
|
+
for await (const chunk of process.stdin) chunks.push(chunk);
|
|
4715
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
4716
|
+
}
|
|
4717
|
+
function registerFiles(program) {
|
|
4718
|
+
const files = program.command("files").description("Manage app-hosted YAML files on the Novedu server");
|
|
4719
|
+
files.command("upload <name>").description("Create or update an app-hosted YAML file from --file or stdin (validated server-side)").option("--kind <kind>", "file kind (tutor, fragment, quiz, writing, coding) — required when creating").option("--file <path>", "read the YAML from this path instead of stdin").option(...SERVER_OPTION$2).action(async (name, options) => {
|
|
4720
|
+
let content;
|
|
4721
|
+
try {
|
|
4722
|
+
content = options.file === void 0 ? await readStdin() : await readFile(options.file, "utf8");
|
|
4723
|
+
} catch (error) {
|
|
4724
|
+
failJson({ message: `Could not read ${options.file}: ${error instanceof Error ? error.message : error}` });
|
|
4725
|
+
return;
|
|
4726
|
+
}
|
|
4727
|
+
await runApiRequest({
|
|
4728
|
+
server: options.server,
|
|
4729
|
+
path: `/api/files/${encodeURIComponent(name)}`,
|
|
4730
|
+
method: "PUT",
|
|
4731
|
+
body: {
|
|
4732
|
+
...options.kind === void 0 ? {} : { kind: options.kind },
|
|
4733
|
+
content
|
|
4734
|
+
}
|
|
4735
|
+
});
|
|
4736
|
+
});
|
|
4737
|
+
files.command("list").description("List app-hosted YAML files (defaults to only your own, like the web list)").option("--search <q>", "contains-filter over name/title/description").option("--all", "include files last written by other teachers").option(...SERVER_OPTION$2).action(async (options) => {
|
|
4738
|
+
const params = new URLSearchParams();
|
|
4739
|
+
if (options.search) params.set("q", options.search);
|
|
4740
|
+
if (options.all) params.set("mine", "0");
|
|
4741
|
+
const query = params.toString();
|
|
4742
|
+
await runApiRequest({
|
|
4743
|
+
server: options.server,
|
|
4744
|
+
path: `/api/files${query ? `?${query}` : ""}`
|
|
4745
|
+
});
|
|
4746
|
+
});
|
|
2813
4747
|
}
|
|
4748
|
+
//#endregion
|
|
4749
|
+
//#region ../lib/file-name.ts
|
|
2814
4750
|
/**
|
|
2815
|
-
*
|
|
2816
|
-
*
|
|
2817
|
-
*
|
|
4751
|
+
* Maps a filename or bare extension (with or without a leading dot, any case)
|
|
4752
|
+
* to an {@link ImageMime}; returns `null` for anything unrecognized. `jpg`/`jpeg`
|
|
4753
|
+
* both map to `image/jpeg`, `svg` to `image/svg+xml`.
|
|
2818
4754
|
*/
|
|
2819
|
-
function
|
|
2820
|
-
|
|
2821
|
-
|
|
2822
|
-
|
|
2823
|
-
|
|
2824
|
-
|
|
2825
|
-
|
|
2826
|
-
|
|
2827
|
-
lines.push(yellow(`${result.warnings.length} warning(s):`));
|
|
2828
|
-
lines.push(...renderWarnings(result.warnings));
|
|
4755
|
+
function imageMimeFromExtension(filename) {
|
|
4756
|
+
const lastDot = filename.lastIndexOf(".");
|
|
4757
|
+
switch ((lastDot >= 0 ? filename.slice(lastDot + 1) : filename).toLowerCase()) {
|
|
4758
|
+
case "png": return "image/png";
|
|
4759
|
+
case "jpg":
|
|
4760
|
+
case "jpeg": return "image/jpeg";
|
|
4761
|
+
case "svg": return "image/svg+xml";
|
|
4762
|
+
default: return null;
|
|
2829
4763
|
}
|
|
2830
|
-
return lines.join("\n");
|
|
2831
4764
|
}
|
|
2832
4765
|
//#endregion
|
|
2833
|
-
//#region src/commands/
|
|
2834
|
-
|
|
2835
|
-
|
|
2836
|
-
"
|
|
2837
|
-
"
|
|
2838
|
-
|
|
2839
|
-
|
|
2840
|
-
|
|
2841
|
-
|
|
2842
|
-
|
|
2843
|
-
|
|
2844
|
-
|
|
2845
|
-
|
|
2846
|
-
|
|
2847
|
-
|
|
2848
|
-
|
|
2849
|
-
|
|
4766
|
+
//#region src/commands/images.ts
|
|
4767
|
+
const SERVER_OPTION$1 = ["--server <url>", "Novedu server base URL (defaults to the NOVEDU_SERVER env var, then production)"];
|
|
4768
|
+
function registerImages(program) {
|
|
4769
|
+
const images = program.command("images").description("Manage app-hosted images on the Novedu server");
|
|
4770
|
+
images.command("upload <name>").description("Upload a NEW image (.png, .jpg/.jpeg or .svg, max 5 MB) from --file").option("--file <path>", "the image file to upload (required — images are binary, no stdin)").option("--credit <text>", "optional attribution shown with the image (max 512 chars)").option(...SERVER_OPTION$1).action(async (name, options) => {
|
|
4771
|
+
if (options.file === void 0) {
|
|
4772
|
+
failJson({ message: "Pass --file <path> — images are binary, stdin is not supported." });
|
|
4773
|
+
return;
|
|
4774
|
+
}
|
|
4775
|
+
const mime = imageMimeFromExtension(options.file);
|
|
4776
|
+
if (mime === null) {
|
|
4777
|
+
failJson({ message: "Only .png, .jpg/.jpeg and .svg files can be uploaded." });
|
|
4778
|
+
return;
|
|
4779
|
+
}
|
|
4780
|
+
let bytes;
|
|
4781
|
+
try {
|
|
4782
|
+
bytes = await readFile(options.file);
|
|
4783
|
+
} catch (error) {
|
|
4784
|
+
failJson({ message: `Could not read ${options.file}: ${error instanceof Error ? error.message : error}` });
|
|
4785
|
+
return;
|
|
4786
|
+
}
|
|
4787
|
+
const slot = await performApiRequest({
|
|
4788
|
+
server: options.server,
|
|
4789
|
+
path: `/api/images/${encodeURIComponent(name)}`,
|
|
4790
|
+
method: "POST",
|
|
4791
|
+
body: {
|
|
4792
|
+
mime,
|
|
4793
|
+
byteSize: bytes.length
|
|
4794
|
+
}
|
|
4795
|
+
});
|
|
4796
|
+
if (!slot.ok) return;
|
|
4797
|
+
const { uploadUrl, blobPath } = slot.payload ?? {};
|
|
4798
|
+
if (typeof uploadUrl !== "string" || typeof blobPath !== "string") {
|
|
4799
|
+
failJson({ message: "Unexpected response from the server." });
|
|
4800
|
+
return;
|
|
4801
|
+
}
|
|
4802
|
+
let putResponse;
|
|
4803
|
+
try {
|
|
4804
|
+
putResponse = await fetch(uploadUrl, {
|
|
4805
|
+
method: "PUT",
|
|
4806
|
+
headers: {
|
|
4807
|
+
"x-ms-blob-type": "BlockBlob",
|
|
4808
|
+
"content-type": mime
|
|
4809
|
+
},
|
|
4810
|
+
body: new Uint8Array(bytes)
|
|
4811
|
+
});
|
|
4812
|
+
} catch (error) {
|
|
4813
|
+
failJson({ message: `Could not reach storage: ${error instanceof Error ? error.message : error}` });
|
|
4814
|
+
return;
|
|
4815
|
+
}
|
|
4816
|
+
if (!putResponse.ok) {
|
|
4817
|
+
failJson({ message: `The upload to storage failed: HTTP ${putResponse.status}. Try again.` });
|
|
4818
|
+
return;
|
|
4819
|
+
}
|
|
4820
|
+
await runApiRequest({
|
|
4821
|
+
server: options.server,
|
|
4822
|
+
path: `/api/images/${encodeURIComponent(name)}/confirm`,
|
|
4823
|
+
method: "POST",
|
|
4824
|
+
body: {
|
|
4825
|
+
blobPath,
|
|
4826
|
+
mime,
|
|
4827
|
+
...options.credit === void 0 ? {} : { credit: options.credit }
|
|
4828
|
+
}
|
|
4829
|
+
});
|
|
4830
|
+
});
|
|
4831
|
+
images.command("list").description("List app-hosted images (defaults to only your own, like the web list)").option("--search <q>", "contains-filter over the name").option("--all", "include images uploaded by other teachers").option(...SERVER_OPTION$1).action(async (options) => {
|
|
4832
|
+
const params = new URLSearchParams();
|
|
4833
|
+
if (options.search) params.set("q", options.search);
|
|
4834
|
+
if (options.all) params.set("mine", "0");
|
|
4835
|
+
const query = params.toString();
|
|
4836
|
+
await runApiRequest({
|
|
4837
|
+
server: options.server,
|
|
4838
|
+
path: `/api/images${query ? `?${query}` : ""}`
|
|
4839
|
+
});
|
|
4840
|
+
});
|
|
4841
|
+
}
|
|
4842
|
+
//#endregion
|
|
4843
|
+
//#region src/commands/login.ts
|
|
4844
|
+
function registerLogin(program) {
|
|
4845
|
+
program.command("login").description("Sign in to Microsoft Entra ID (opens your browser)").option("--device-code", "sign in with the device code flow instead (for machines without a browser; the tenant must allow it)").addHelpText("after", `
|
|
4846
|
+
Sign-in is the one human-assisted step: by default a browser window opens for
|
|
4847
|
+
the Microsoft sign-in (first-time users see a one-time consent prompt). On a
|
|
4848
|
+
machine without a browser, --device-code prints a verification URL and a code
|
|
4849
|
+
to enter from any other device — note that some tenants block the device code
|
|
4850
|
+
flow by policy (error 53003). Every other command then works non-interactively
|
|
4851
|
+
from the cached credentials. Already signed in? The command says so and exits
|
|
4852
|
+
— it never blocks.`).action(async (options) => {
|
|
4853
|
+
const pca = buildPca();
|
|
4854
|
+
const cached = await acquireSilent(pca);
|
|
4855
|
+
if (cached) {
|
|
4856
|
+
console.log(`Already signed in as ${displayName(cached)}.`);
|
|
4857
|
+
return;
|
|
4858
|
+
}
|
|
4859
|
+
const result = options.deviceCode ? await acquireByDeviceCode(pca, (message) => console.log(message)) : await acquireInteractive(pca, (url) => {
|
|
4860
|
+
console.log("A browser window should open for the Microsoft sign-in.");
|
|
4861
|
+
console.log(`If it does not, open this URL yourself:\n${url}`);
|
|
4862
|
+
});
|
|
4863
|
+
console.log(`Signed in as ${displayName(result)}.`);
|
|
4864
|
+
});
|
|
4865
|
+
}
|
|
4866
|
+
//#endregion
|
|
4867
|
+
//#region src/commands/logout.ts
|
|
4868
|
+
function registerLogout(program) {
|
|
4869
|
+
program.command("logout").description("Sign out: remove the cached credentials from this machine").addHelpText("after", `
|
|
4870
|
+
Purely local — already-issued access tokens stay valid until they expire
|
|
4871
|
+
(about an hour). Running it while signed out is fine.`).action(async () => {
|
|
4872
|
+
const cache = buildPca().getTokenCache();
|
|
4873
|
+
for (const account of await cache.getAllAccounts()) await cache.removeAccount(account);
|
|
4874
|
+
rmSync(TOKEN_CACHE_PATH, { force: true });
|
|
4875
|
+
console.log("Signed out.");
|
|
4876
|
+
});
|
|
2850
4877
|
}
|
|
4878
|
+
//#endregion
|
|
4879
|
+
//#region src/commands/prompts.ts
|
|
2851
4880
|
/**
|
|
2852
|
-
* The
|
|
2853
|
-
*
|
|
2854
|
-
*
|
|
2855
|
-
*
|
|
2856
|
-
*
|
|
4881
|
+
* The command's pure core: dump the prompts of a local file or public URL. `file:` is
|
|
4882
|
+
* allowed in addition to http(s) so an on-disk activity dumps (the web app deliberately
|
|
4883
|
+
* stays http(s)-only), and relative `fragment_files` / `quiz_files` resolve against the
|
|
4884
|
+
* activity's own location.
|
|
4885
|
+
*
|
|
4886
|
+
* This is the RUNTIME path — the lenient loaders the app runs when a student opens the
|
|
4887
|
+
* activity — so the output is what the model really receives. Use `validate` for the
|
|
4888
|
+
* strict authoring gate.
|
|
2857
4889
|
*/
|
|
2858
|
-
function
|
|
2859
|
-
|
|
2860
|
-
const allowedSchemes = [
|
|
4890
|
+
function runPrompts(pathOrUrl, kind) {
|
|
4891
|
+
return dumpPrompts(kind, toUrl(pathOrUrl), cliFetcher, { allowedSchemes: [
|
|
2861
4892
|
"http:",
|
|
2862
4893
|
"https:",
|
|
2863
4894
|
"file:"
|
|
2864
|
-
];
|
|
2865
|
-
switch (kind) {
|
|
2866
|
-
case "fragment": return loadAndCheckFragmentFile(url, cliFetcher, { allowedSchemes }).then((result) => ({
|
|
2867
|
-
kind,
|
|
2868
|
-
result
|
|
2869
|
-
}));
|
|
2870
|
-
case "quiz": return loadAndCheckQuiz(url, cliFetcher, { allowedSchemes }).then((result) => ({
|
|
2871
|
-
kind,
|
|
2872
|
-
result
|
|
2873
|
-
}));
|
|
2874
|
-
case "writing": return loadAndCheckWriting(url, cliFetcher, { allowedSchemes }).then((result) => ({
|
|
2875
|
-
kind,
|
|
2876
|
-
result
|
|
2877
|
-
}));
|
|
2878
|
-
case "coding": return loadAndCheckCoding(url, cliFetcher, { allowedSchemes }).then((result) => ({
|
|
2879
|
-
kind,
|
|
2880
|
-
result
|
|
2881
|
-
}));
|
|
2882
|
-
default: return loadAndBuildTutorPrompt(url, cliFetcher, {
|
|
2883
|
-
allowedSchemes,
|
|
2884
|
-
validateLibraries: true
|
|
2885
|
-
}).then((result) => ({
|
|
2886
|
-
kind,
|
|
2887
|
-
result
|
|
2888
|
-
}));
|
|
2889
|
-
}
|
|
4895
|
+
] });
|
|
2890
4896
|
}
|
|
2891
|
-
function
|
|
2892
|
-
program.command("
|
|
4897
|
+
function registerPrompts(program) {
|
|
4898
|
+
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", `
|
|
2893
4899
|
Examples:
|
|
2894
|
-
#
|
|
2895
|
-
$ novedu-cli
|
|
4900
|
+
# The tutor's assembled system prompt (fragments resolved in place)
|
|
4901
|
+
$ novedu-cli prompts ./activities/tutors/my-tutor.yaml
|
|
2896
4902
|
|
|
2897
|
-
#
|
|
2898
|
-
$ novedu-cli
|
|
4903
|
+
# Every grading prompt of a quiz, plus its discussion prompt, as JSON
|
|
4904
|
+
$ novedu-cli prompts ./activities/quizzes/my-quiz.yaml --kind quiz --json
|
|
2899
4905
|
|
|
2900
|
-
#
|
|
2901
|
-
$ novedu-cli
|
|
2902
|
-
$ novedu-cli
|
|
2903
|
-
$ novedu-cli validate ./activities/coding/my-coding.yaml --kind coding
|
|
4906
|
+
# A writing activity's coach prompt / a coding activity's injected system prompt
|
|
4907
|
+
$ novedu-cli prompts ./activities/writings/my-writing.yaml --kind writing
|
|
4908
|
+
$ novedu-cli prompts ./activities/coding/my-coding.yaml --kind coding
|
|
2904
4909
|
|
|
2905
|
-
#
|
|
2906
|
-
$ novedu-cli
|
|
2907
|
-
if (options.kind !== void 0 && !
|
|
2908
|
-
console.error(`Invalid --kind "${options.kind}": expected ${
|
|
4910
|
+
# One question's grading prompt, straight out of the JSON dump
|
|
4911
|
+
$ novedu-cli prompts ./my-quiz.yaml --kind quiz --json | jq -r '.grading.questions[0].system'`).action(async (pathOrUrl, options) => {
|
|
4912
|
+
if (options.kind !== void 0 && !PROMPT_KINDS.includes(options.kind)) {
|
|
4913
|
+
console.error(`Invalid --kind "${options.kind}": expected ${PROMPT_KINDS.map((k) => `"${k}"`).join(", ")}.`);
|
|
2909
4914
|
process.exitCode = 1;
|
|
2910
4915
|
return;
|
|
2911
4916
|
}
|
|
2912
|
-
const
|
|
2913
|
-
if (
|
|
2914
|
-
|
|
2915
|
-
|
|
4917
|
+
const result = await runPrompts(pathOrUrl, options.kind ?? "tutor");
|
|
4918
|
+
if (!result.ok) {
|
|
4919
|
+
console.error(JSON.stringify({ errors: result.errors }, null, 2));
|
|
4920
|
+
process.exitCode = 1;
|
|
4921
|
+
return;
|
|
4922
|
+
}
|
|
4923
|
+
if (options.json) console.log(JSON.stringify(result.dump, null, 2));
|
|
4924
|
+
else console.log(formatPromptDump(result.dump, promptSections(result.dump), pathOrUrl));
|
|
4925
|
+
process.exitCode = 0;
|
|
2916
4926
|
});
|
|
2917
4927
|
}
|
|
2918
|
-
|
|
2919
|
-
|
|
2920
|
-
|
|
2921
|
-
|
|
2922
|
-
|
|
2923
|
-
|
|
2924
|
-
|
|
2925
|
-
|
|
2926
|
-
|
|
4928
|
+
//#endregion
|
|
4929
|
+
//#region src/commands/reports.ts
|
|
4930
|
+
const SERVER_OPTION = ["--server <url>", "Novedu server base URL (defaults to the NOVEDU_SERVER env var, then production)"];
|
|
4931
|
+
function registerReports(program) {
|
|
4932
|
+
const reports = program.command("reports").description("Triage student reports on the Novedu server");
|
|
4933
|
+
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) => {
|
|
4934
|
+
const params = new URLSearchParams();
|
|
4935
|
+
if (options.status) params.set("status", options.status);
|
|
4936
|
+
if (options.reaction) params.set("reaction", options.reaction);
|
|
4937
|
+
if (options.search) params.set("q", options.search);
|
|
4938
|
+
if (options.all) params.set("mine", "0");
|
|
4939
|
+
const query = params.toString();
|
|
4940
|
+
await runApiRequest({
|
|
4941
|
+
server: options.server,
|
|
4942
|
+
path: `/api/reports${query ? `?${query}` : ""}`
|
|
4943
|
+
});
|
|
4944
|
+
});
|
|
4945
|
+
reports.command("show <id>").description("Show one report; a chat report embeds its conversation transcript").option(...SERVER_OPTION).action(async (id, options) => {
|
|
4946
|
+
await runApiRequest({
|
|
4947
|
+
server: options.server,
|
|
4948
|
+
path: `/api/reports/${encodeURIComponent(id)}`
|
|
4949
|
+
});
|
|
4950
|
+
});
|
|
4951
|
+
reports.command("resolve <id...>").description("Resolve one or more reports by id (bulk, in a single request)").option(...SERVER_OPTION).action(async (ids, options) => {
|
|
4952
|
+
await runApiRequest({
|
|
4953
|
+
server: options.server,
|
|
4954
|
+
path: "/api/reports/resolve",
|
|
4955
|
+
method: "POST",
|
|
4956
|
+
body: { ids }
|
|
4957
|
+
});
|
|
4958
|
+
});
|
|
2927
4959
|
}
|
|
2928
4960
|
//#endregion
|
|
2929
4961
|
//#region src/commands/whoami.ts
|
|
@@ -2962,10 +4994,11 @@ function registerWhoami(program) {
|
|
|
2962
4994
|
}
|
|
2963
4995
|
//#endregion
|
|
2964
4996
|
//#region src/main.ts
|
|
2965
|
-
const { version } = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
|
|
2966
4997
|
const program = new Command();
|
|
2967
|
-
program.name("novedu-cli").description("Command-line companion for the Novedu chat app").version(
|
|
4998
|
+
program.name("novedu-cli").description("Command-line companion for the Novedu chat app").version(cliVersion());
|
|
2968
4999
|
registerValidate(program);
|
|
5000
|
+
registerPrompts(program);
|
|
5001
|
+
registerEval(program);
|
|
2969
5002
|
registerLogin(program);
|
|
2970
5003
|
registerLogout(program);
|
|
2971
5004
|
registerWhoami(program);
|