@novedu/cli 0.13.0 → 0.15.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/dist/main.js +414 -38
- package/package.json +1 -1
package/dist/main.js
CHANGED
|
@@ -442,6 +442,41 @@ function formatZodIssues(zodIssues) {
|
|
|
442
442
|
return out;
|
|
443
443
|
}
|
|
444
444
|
//#endregion
|
|
445
|
+
//#region ../lib/prompt-fragments/text-files.ts
|
|
446
|
+
/**
|
|
447
|
+
* Split a body into logical lines for range slicing. `body.split("\n")` yields a
|
|
448
|
+
* trailing empty element for a body that ends in a newline (`"a\n"` → `["a", ""]`); we
|
|
449
|
+
* drop exactly ONE such trailing element so a conventional newline-terminated file
|
|
450
|
+
* reports its real line count (`"a\n"` is one line, not two). An empty body is zero
|
|
451
|
+
* lines. NB: this is ONLY for line-range math — a no-range `{{file}}` renders the body
|
|
452
|
+
* byte-for-byte (see `load.ts`), trailing newline and all.
|
|
453
|
+
*/
|
|
454
|
+
function toLines(body) {
|
|
455
|
+
if (body === "") return [];
|
|
456
|
+
const lines = body.split("\n");
|
|
457
|
+
if (lines[lines.length - 1] === "") lines.pop();
|
|
458
|
+
return lines;
|
|
459
|
+
}
|
|
460
|
+
/** The number of logical lines in `body` (a newline-terminated final line is not double-counted). */
|
|
461
|
+
function countLines(body) {
|
|
462
|
+
return toLines(body).length;
|
|
463
|
+
}
|
|
464
|
+
/**
|
|
465
|
+
* Slice `body` to the 1-based inclusive line range `[from, to]` and re-join with `"\n"`,
|
|
466
|
+
* appending nothing. `from` alone means "line `from` to end of file"; `to` alone means
|
|
467
|
+
* "line 1 to `to`". `to` beyond EOF CLAMPS to end-of-file (native `Array.slice`
|
|
468
|
+
* behaviour) — the runtime's graceful-degradation contract when a source file was
|
|
469
|
+
* shortened after validation. The `from` lower bound is enforced by the caller
|
|
470
|
+
* (`checkPlacements` errors on `from` beyond EOF), because an empty splice would
|
|
471
|
+
* silently drop material.
|
|
472
|
+
*/
|
|
473
|
+
function sliceLines(body, from, to) {
|
|
474
|
+
const lines = toLines(body);
|
|
475
|
+
const start = from !== void 0 ? from - 1 : 0;
|
|
476
|
+
const end = to !== void 0 ? to : lines.length;
|
|
477
|
+
return lines.slice(start, end).join("\n");
|
|
478
|
+
}
|
|
479
|
+
//#endregion
|
|
445
480
|
//#region ../lib/prompt-fragments/consistency.ts
|
|
446
481
|
/** Compare a supplied value against its declared property type. Returns null when it matches. */
|
|
447
482
|
function typeMismatch(prop, value) {
|
|
@@ -570,18 +605,33 @@ function mergeVariables(alias, fragment, args, errors, warnings) {
|
|
|
570
605
|
return merged;
|
|
571
606
|
}
|
|
572
607
|
/**
|
|
573
|
-
* Cross-check every inline placement against the declared libraries:
|
|
574
|
-
* aliases,
|
|
575
|
-
*
|
|
576
|
-
*
|
|
577
|
-
*
|
|
608
|
+
* Cross-check every inline placement against the declared libraries AND text files:
|
|
609
|
+
* duplicate aliases (fragment libraries, text files, and collisions ACROSS the two —
|
|
610
|
+
* one shared alias namespace), duplicate fragment ids within a file, each `{{fragment}}`
|
|
611
|
+
* placement's `alias.id` resolution + variable validation (via `resolveAndMerge`), each
|
|
612
|
+
* `{{file}}` placement's alias resolution + line-range bounds, and a warning for any
|
|
613
|
+
* declared library / text file no placement ever uses. Errors block the build; the rest
|
|
614
|
+
* are warnings. Order-independent — placements carry their own textual position.
|
|
615
|
+
*
|
|
616
|
+
* `textFilesByAlias` maps a declared text-file alias to its FETCHED body (needed for the
|
|
617
|
+
* range bounds check); `strict` is the authoring-strictness flag — when set (authoring
|
|
618
|
+
* validators / CLI), a `to` beyond EOF is an error too, whereas at runtime (`strict:
|
|
619
|
+
* false`) the render layer clamps `to` to EOF instead. A `from` beyond EOF is ALWAYS an
|
|
620
|
+
* error (an empty splice would silently drop material). The new params default so the
|
|
621
|
+
* legacy fragment-only call sites (and their tests) need no change; the one real caller
|
|
622
|
+
* (`load.ts`) always passes the text side explicitly.
|
|
578
623
|
*/
|
|
579
|
-
function checkPlacements(placements, filesByAlias, fileRefs) {
|
|
624
|
+
function checkPlacements(placements, filesByAlias, fileRefs, textFilesByAlias = /* @__PURE__ */ new Map(), textFileRefs = [], strict = false) {
|
|
580
625
|
const errors = [];
|
|
581
626
|
const warnings = [];
|
|
582
627
|
const aliasCounts = /* @__PURE__ */ new Map();
|
|
583
628
|
for (const ref of fileRefs) aliasCounts.set(ref.id, (aliasCounts.get(ref.id) ?? 0) + 1);
|
|
584
629
|
for (const [alias, count] of aliasCounts) if (count > 1) errors.push(error("DUPLICATE_FRAGMENT_FILE_ALIAS", `Fragment-file alias "${alias}" is declared ${count} times`, { fileAlias: alias }));
|
|
630
|
+
const fragmentAliases = new Set(fileRefs.map((r) => r.id));
|
|
631
|
+
const textAliasCounts = /* @__PURE__ */ new Map();
|
|
632
|
+
for (const ref of textFileRefs) textAliasCounts.set(ref.id, (textAliasCounts.get(ref.id) ?? 0) + 1);
|
|
633
|
+
for (const [alias, count] of textAliasCounts) if (count > 1) errors.push(error("DUPLICATE_TEXT_FILE_ALIAS", `Text-file alias "${alias}" is declared ${count} times`, { fileAlias: alias }));
|
|
634
|
+
else if (fragmentAliases.has(alias)) errors.push(error("DUPLICATE_TEXT_FILE_ALIAS", `Alias "${alias}" is declared as both a fragment library and a text file`, { fileAlias: alias }));
|
|
585
635
|
for (const [alias, file] of filesByAlias) {
|
|
586
636
|
const seen = /* @__PURE__ */ new Set();
|
|
587
637
|
for (const frag of file.fragments) {
|
|
@@ -596,18 +646,47 @@ function checkPlacements(placements, filesByAlias, fileRefs) {
|
|
|
596
646
|
}
|
|
597
647
|
}
|
|
598
648
|
const usedAliases = /* @__PURE__ */ new Set();
|
|
649
|
+
const usedTextAliases = /* @__PURE__ */ new Set();
|
|
599
650
|
for (const placement of placements) {
|
|
651
|
+
if (placement.kind === "file") {
|
|
652
|
+
checkFilePlacement(placement, textFilesByAlias, strict, errors);
|
|
653
|
+
usedTextAliases.add(placement.ref);
|
|
654
|
+
continue;
|
|
655
|
+
}
|
|
600
656
|
usedAliases.add(splitFragmentRef(placement.ref).alias);
|
|
601
657
|
const resolved = resolveAndMerge(placement.ref, placement.args, filesByAlias);
|
|
602
658
|
errors.push(...resolved.errors);
|
|
603
659
|
warnings.push(...resolved.warnings);
|
|
604
660
|
}
|
|
605
661
|
for (const ref of fileRefs) if (!usedAliases.has(ref.id)) warnings.push(warning("UNUSED_FRAGMENT_FILE", `Fragment library "${ref.id}" is declared but no {{fragment}} marker uses it`, { fileAlias: ref.id }));
|
|
662
|
+
for (const ref of textFileRefs) if (!usedTextAliases.has(ref.id)) warnings.push(warning("UNUSED_TEXT_FILE", `Text file "${ref.id}" is declared but no {{file}} marker embeds it`, { fileAlias: ref.id }));
|
|
606
663
|
return {
|
|
607
664
|
errors,
|
|
608
665
|
warnings
|
|
609
666
|
};
|
|
610
667
|
}
|
|
668
|
+
/**
|
|
669
|
+
* Validate one `{{file}}` placement: the alias must be a declared text file, and its
|
|
670
|
+
* line range must fit. `from` beyond EOF is ALWAYS an error (an empty splice would
|
|
671
|
+
* silently drop material); `to` beyond EOF is an error ONLY under `strict` (authoring) —
|
|
672
|
+
* at runtime the render layer clamps `to` to EOF so a shortened source degrades gracefully.
|
|
673
|
+
*/
|
|
674
|
+
function checkFilePlacement(placement, textFilesByAlias, strict, errors) {
|
|
675
|
+
const body = textFilesByAlias.get(placement.ref);
|
|
676
|
+
if (body === void 0) {
|
|
677
|
+
errors.push(error("UNKNOWN_TEXT_FILE_ALIAS", `File marker "${placement.ref}" uses unknown text-file alias`, { fileAlias: placement.ref }));
|
|
678
|
+
return;
|
|
679
|
+
}
|
|
680
|
+
const lineCount = countLines(body);
|
|
681
|
+
if (placement.from !== void 0 && placement.from > lineCount) errors.push(error("TEXT_FILE_RANGE_OUT_OF_BOUNDS", `File "${placement.ref}" has ${lineCount} line(s), but from=${placement.from} is past the end`, {
|
|
682
|
+
fileAlias: placement.ref,
|
|
683
|
+
line: placement.line
|
|
684
|
+
}));
|
|
685
|
+
if (strict && placement.to !== void 0 && placement.to > lineCount) errors.push(error("TEXT_FILE_RANGE_OUT_OF_BOUNDS", `File "${placement.ref}" has ${lineCount} line(s), but to=${placement.to} is past the end`, {
|
|
686
|
+
fileAlias: placement.ref,
|
|
687
|
+
line: placement.line
|
|
688
|
+
}));
|
|
689
|
+
}
|
|
611
690
|
//#endregion
|
|
612
691
|
//#region ../lib/prompt-fragments/fetcher.ts
|
|
613
692
|
const DEFAULT_TIMEOUT_MS = 1e4;
|
|
@@ -755,6 +834,28 @@ const FragmentFileRefSchema = z.strictObject({
|
|
|
755
834
|
id: "fragmentFileRef",
|
|
756
835
|
description: "A reference to a fragment library by alias + URL."
|
|
757
836
|
});
|
|
837
|
+
/**
|
|
838
|
+
* A plain-text file reference: an `id` alias + a `url`, mirroring `FragmentFileRefSchema`
|
|
839
|
+
* exactly (same no-dot alias regex, same http(s)-or-relative `FragmentUrlRef`). Unlike a
|
|
840
|
+
* fragment library, a text file is arbitrary content (markdown course material, a
|
|
841
|
+
* sample-solution source file) fetched over HTTP(S) and spliced VERBATIM into the host
|
|
842
|
+
* text by an inline `{{file "alias"}}` marker — there is nothing to select inside it, so
|
|
843
|
+
* the alias carries no dot. Text-file and fragment-file aliases share ONE namespace (an
|
|
844
|
+
* `id` may not collide across the two lists), so every marker alias resolves unambiguously.
|
|
845
|
+
*/
|
|
846
|
+
const TextFileRefSchema = z.strictObject({
|
|
847
|
+
id: z.string().regex(/^[^.]+$/, { message: "Alias must not contain a dot" }).meta({
|
|
848
|
+
pattern: "^[^.]+$",
|
|
849
|
+
description: "Local alias for this text file, used in `{{file \"alias\"}}`. Must not contain a dot."
|
|
850
|
+
}),
|
|
851
|
+
url: FragmentUrlRef.meta({
|
|
852
|
+
pattern: "^(https?://|(?![A-Za-z][A-Za-z0-9+.-]*:).+)$",
|
|
853
|
+
description: "HTTP(S) URL or relative path to the plain-text file."
|
|
854
|
+
})
|
|
855
|
+
}).meta({
|
|
856
|
+
id: "textFileRef",
|
|
857
|
+
description: "A reference to a plain-text file (markdown / source) by alias + URL."
|
|
858
|
+
});
|
|
758
859
|
//#endregion
|
|
759
860
|
//#region ../lib/prompt-fragments/fragment.ts
|
|
760
861
|
/**
|
|
@@ -850,8 +951,10 @@ function checkFragmentFileValue(parsed, url) {
|
|
|
850
951
|
//#region ../lib/prompt-fragments/host-template.ts
|
|
851
952
|
const FRAGMENT_HELPER = "fragment";
|
|
852
953
|
const ARRAY_HELPER = "array";
|
|
954
|
+
const FILE_HELPER = "file";
|
|
853
955
|
const isFragmentNode = (node) => node.path?.original === FRAGMENT_HELPER;
|
|
854
|
-
|
|
956
|
+
const isFileNode = (node) => node.path?.original === FILE_HELPER;
|
|
957
|
+
/** A structural `{{fragment}}` error stamped with the node's 1-based position. */
|
|
855
958
|
function markerInvalid(loc, detail) {
|
|
856
959
|
return error("FRAGMENT_MARKER_INVALID", `${detail} (line ${loc.start.line})`, {
|
|
857
960
|
line: loc.start.line,
|
|
@@ -859,6 +962,18 @@ function markerInvalid(loc, detail) {
|
|
|
859
962
|
});
|
|
860
963
|
}
|
|
861
964
|
/**
|
|
965
|
+
* A structural `{{file}}` error stamped with the node's 1-based position. Every
|
|
966
|
+
* `{{file}}` violation collapses to this ONE code (there is deliberately no separate
|
|
967
|
+
* not-literal variant, unlike `{{fragment}}`): a file marker either parses into a valid
|
|
968
|
+
* `alias`/`from`/`to` shape or fails closed here.
|
|
969
|
+
*/
|
|
970
|
+
function fileMarkerInvalid(loc, detail) {
|
|
971
|
+
return error("TEXT_FILE_MARKER_INVALID", `${detail} (line ${loc.start.line})`, {
|
|
972
|
+
line: loc.start.line,
|
|
973
|
+
column: loc.start.column + 1
|
|
974
|
+
});
|
|
975
|
+
}
|
|
976
|
+
/**
|
|
862
977
|
* Read a hash-pair value into a supported literal. The supported set IS the contract:
|
|
863
978
|
* a string, a boolean, or an `(array "…" …)` of string literals. Anything else — a
|
|
864
979
|
* number, a path reference, a nested `(fragment …)`, an array with a non-string
|
|
@@ -903,25 +1018,75 @@ function extractInlineMarker(node, out) {
|
|
|
903
1018
|
args[pair.key] = read.value;
|
|
904
1019
|
}
|
|
905
1020
|
out.placements.push({
|
|
1021
|
+
kind: "fragment",
|
|
906
1022
|
ref: first.value,
|
|
907
1023
|
args,
|
|
908
1024
|
line: node.loc.start.line,
|
|
909
1025
|
column: node.loc.start.column + 1
|
|
910
1026
|
});
|
|
911
1027
|
}
|
|
912
|
-
/**
|
|
1028
|
+
/**
|
|
1029
|
+
* Extract one inline `{{file "alias" from=N to=M}}` mustache's alias + optional range.
|
|
1030
|
+
* Structural contract (all TEXT_FILE_MARKER_INVALID, recording no placement on failure):
|
|
1031
|
+
* the first positional must be a quoted string literal, there is exactly one positional,
|
|
1032
|
+
* hash keys are restricted to `from` / `to`, each value must be an integer NumberLiteral
|
|
1033
|
+
* >= 1, and when both are present `from <= to`. A `from`/`to` beyond the file's actual
|
|
1034
|
+
* line count is NOT checked here (that needs the fetched body — see `checkPlacements`).
|
|
1035
|
+
*/
|
|
1036
|
+
function extractFileMarker(node, out) {
|
|
1037
|
+
const first = node.params?.[0];
|
|
1038
|
+
if (first?.type !== "StringLiteral") {
|
|
1039
|
+
out.errors.push(fileMarkerInvalid(node.loc, "A {{file}} marker needs a quoted \"alias\" reference"));
|
|
1040
|
+
return;
|
|
1041
|
+
}
|
|
1042
|
+
if ((node.params?.length ?? 0) > 1) {
|
|
1043
|
+
out.errors.push(fileMarkerInvalid(node.loc, "A {{file}} marker takes exactly one \"alias\" reference"));
|
|
1044
|
+
return;
|
|
1045
|
+
}
|
|
1046
|
+
let from;
|
|
1047
|
+
let to;
|
|
1048
|
+
for (const pair of node.hash?.pairs ?? []) {
|
|
1049
|
+
if (pair.key !== "from" && pair.key !== "to") {
|
|
1050
|
+
out.errors.push(fileMarkerInvalid(node.loc, `A {{file}} marker accepts only from= / to=, not "${pair.key}"`));
|
|
1051
|
+
return;
|
|
1052
|
+
}
|
|
1053
|
+
if (pair.value.type !== "NumberLiteral" || !Number.isInteger(pair.value.value) || pair.value.value < 1) {
|
|
1054
|
+
out.errors.push(fileMarkerInvalid(node.loc, `"${pair.key}" must be an integer line number >= 1`));
|
|
1055
|
+
return;
|
|
1056
|
+
}
|
|
1057
|
+
if (pair.key === "from") from = pair.value.value;
|
|
1058
|
+
else to = pair.value.value;
|
|
1059
|
+
}
|
|
1060
|
+
if (from !== void 0 && to !== void 0 && from > to) {
|
|
1061
|
+
out.errors.push(fileMarkerInvalid(node.loc, `"from" (${from}) must not be greater than "to" (${to})`));
|
|
1062
|
+
return;
|
|
1063
|
+
}
|
|
1064
|
+
out.placements.push({
|
|
1065
|
+
kind: "file",
|
|
1066
|
+
ref: first.value,
|
|
1067
|
+
args: {},
|
|
1068
|
+
...from !== void 0 ? { from } : {},
|
|
1069
|
+
...to !== void 0 ? { to } : {},
|
|
1070
|
+
line: node.loc.start.line,
|
|
1071
|
+
column: node.loc.start.column + 1
|
|
1072
|
+
});
|
|
1073
|
+
}
|
|
1074
|
+
/** Reject a `fragment` / `file` helper used anywhere but a simple inline mustache. */
|
|
913
1075
|
function visitExpression(node, out) {
|
|
914
1076
|
if (node.type !== "SubExpression") return;
|
|
915
1077
|
const sub = node;
|
|
916
1078
|
if (isFragmentNode(sub)) out.errors.push(markerInvalid(sub.loc, "{{fragment}} must be a standalone inline marker, not a subexpression"));
|
|
1079
|
+
else if (isFileNode(sub)) out.errors.push(fileMarkerInvalid(sub.loc, "{{file}} must be a standalone inline marker, not a subexpression"));
|
|
917
1080
|
for (const p of sub.params ?? []) visitExpression(p, out);
|
|
918
1081
|
for (const pair of sub.hash?.pairs ?? []) visitExpression(pair.value, out);
|
|
919
1082
|
}
|
|
920
|
-
/** Collect every `{{fragment}}` marker in a program body (recursing into blocks). */
|
|
1083
|
+
/** Collect every `{{fragment}}` / `{{file}}` marker in a program body (recursing into blocks). */
|
|
921
1084
|
function collectPlacements(program, out) {
|
|
922
1085
|
for (const node of program.body) {
|
|
923
1086
|
if (isFragmentNode(node)) if (node.type === "MustacheStatement") extractInlineMarker(node, out);
|
|
924
1087
|
else out.errors.push(markerInvalid(node.loc, "{{fragment}} must be a simple inline marker, not a block {{#fragment}}…{{/fragment}}"));
|
|
1088
|
+
else if (isFileNode(node)) if (node.type === "MustacheStatement") extractFileMarker(node, out);
|
|
1089
|
+
else out.errors.push(fileMarkerInvalid(node.loc, "{{file}} must be a simple inline marker, not a block {{#file}}…{{/file}}"));
|
|
925
1090
|
else {
|
|
926
1091
|
for (const p of node.params ?? []) visitExpression(p, out);
|
|
927
1092
|
for (const pair of node.hash?.pairs ?? []) visitExpression(pair.value, out);
|
|
@@ -935,8 +1100,9 @@ function collectPlacements(program, out) {
|
|
|
935
1100
|
* order, WITHOUT rendering. A whole-template syntax error (a malformed marker, an
|
|
936
1101
|
* unescaped literal `{{`) becomes a single `HOST_TEMPLATE_PARSE_ERROR`; its position
|
|
937
1102
|
* is regexed out of Handlebars' message (`Parse error on line N:` — the parser
|
|
938
|
-
* carries no structured position fields). A well-formed marker whose
|
|
939
|
-
* a quoted string literal becomes a per-marker `FRAGMENT_REF_NOT_LITERAL
|
|
1103
|
+
* carries no structured position fields). A well-formed `{{fragment}}` marker whose
|
|
1104
|
+
* reference is not a quoted string literal becomes a per-marker `FRAGMENT_REF_NOT_LITERAL`;
|
|
1105
|
+
* a structurally wrong `{{file}}` marker becomes a per-marker `TEXT_FILE_MARKER_INVALID`.
|
|
940
1106
|
*/
|
|
941
1107
|
function parseHostPlacements(text) {
|
|
942
1108
|
const result = {
|
|
@@ -955,8 +1121,8 @@ function parseHostPlacements(text) {
|
|
|
955
1121
|
collectPlacements(program, result);
|
|
956
1122
|
return result;
|
|
957
1123
|
}
|
|
958
|
-
/** Build the isolated Handlebars instance carrying ONLY the `fragment` + `array` helpers. */
|
|
959
|
-
function createHostInstance(resolver) {
|
|
1124
|
+
/** Build the isolated Handlebars instance carrying ONLY the `fragment` + `array` + `file` helpers. */
|
|
1125
|
+
function createHostInstance(resolver, fileResolver) {
|
|
960
1126
|
const hb = Handlebars.create();
|
|
961
1127
|
hb.registerHelper(FRAGMENT_HELPER, (...args) => {
|
|
962
1128
|
const ref = args.length >= 2 ? args[0] : void 0;
|
|
@@ -967,17 +1133,28 @@ function createHostInstance(resolver) {
|
|
|
967
1133
|
hb.registerHelper(ARRAY_HELPER, (...args) => {
|
|
968
1134
|
return args.slice(0, -1);
|
|
969
1135
|
});
|
|
1136
|
+
hb.registerHelper(FILE_HELPER, (...args) => {
|
|
1137
|
+
const alias = args.length >= 2 ? args[0] : void 0;
|
|
1138
|
+
if (typeof alias !== "string") throw new Error("a {{file}} marker needs a quoted \"alias\" reference");
|
|
1139
|
+
const options = args[args.length - 1];
|
|
1140
|
+
return fileResolver(alias, options?.hash?.from, options?.hash?.to);
|
|
1141
|
+
});
|
|
970
1142
|
return hb;
|
|
971
1143
|
}
|
|
972
1144
|
/**
|
|
973
1145
|
* Compile + render the host text with the isolated instance under
|
|
974
1146
|
* `{ strict: true, noEscape: true }` — `strict` so any stray `{{…}}` that is not a
|
|
975
|
-
* `fragment` / `array` marker fails closed instead of rendering empty; `noEscape`
|
|
976
|
-
* the prompt text (ASCII diagrams, quotes) passes through verbatim. Each
|
|
977
|
-
* replaced by its `resolver` result
|
|
1147
|
+
* `fragment` / `array` / `file` marker fails closed instead of rendering empty; `noEscape`
|
|
1148
|
+
* so the prompt text (ASCII diagrams, quotes) passes through verbatim. Each `{{fragment}}`
|
|
1149
|
+
* placement is replaced by its `resolver` result, each `{{file}}` by its `fileResolver`
|
|
1150
|
+
* result. May throw — the caller wraps it as ASSEMBLY_ERROR. `fileResolver` defaults to a
|
|
1151
|
+
* thrower: a template with no `{{file}}` markers never invokes it, but one that does
|
|
1152
|
+
* without a resolver fails closed rather than rendering wrong.
|
|
978
1153
|
*/
|
|
979
|
-
function renderHostTemplate(text, resolver) {
|
|
980
|
-
|
|
1154
|
+
function renderHostTemplate(text, resolver, fileResolver = () => {
|
|
1155
|
+
throw new Error("a {{file}} marker was rendered without a file resolver");
|
|
1156
|
+
}) {
|
|
1157
|
+
return createHostInstance(resolver, fileResolver).compile(text, {
|
|
981
1158
|
strict: true,
|
|
982
1159
|
noEscape: true
|
|
983
1160
|
})({});
|
|
@@ -996,6 +1173,8 @@ function resolveRelativeUrl(ref, baseUrl) {
|
|
|
996
1173
|
}
|
|
997
1174
|
//#endregion
|
|
998
1175
|
//#region ../lib/prompt-fragments/load.ts
|
|
1176
|
+
/** The 200 KB (UTF-8 bytes) per-file cap on an embedded `text_files:` body — fail closed above it. */
|
|
1177
|
+
const MAX_TEXT_FILE_BYTES = 200 * 1024;
|
|
999
1178
|
/**
|
|
1000
1179
|
* Resolve a fragment-file reference to an absolute URL. An absolute http(s) ref is used
|
|
1001
1180
|
* as-is; anything else is treated as relative to the activity URL — standard URL resolution
|
|
@@ -1085,19 +1264,22 @@ async function loadYaml(url, fetchImpl, opts = {}) {
|
|
|
1085
1264
|
* → check → render pipeline. `hostText` IS the template: fragments appear only where
|
|
1086
1265
|
* the author placed a marker; there is no ordering concept and no prepend fallback.
|
|
1087
1266
|
*
|
|
1088
|
-
* TEMPLATE-SEMANTICS OPT-IN: an activity that declares
|
|
1089
|
-
* compiled — its host text returns byte-verbatim (protecting plain
|
|
1090
|
-
* authoring tutors whose prose contains sample markers as teaching
|
|
1267
|
+
* TEMPLATE-SEMANTICS OPT-IN: an activity that declares NEITHER `fragment_files:` NOR
|
|
1268
|
+
* `text_files:` is NEVER compiled — its host text returns byte-verbatim (protecting plain
|
|
1269
|
+
* activities and the authoring tutors whose prose contains sample markers as teaching
|
|
1270
|
+
* content). Text files are fetched in parallel with the fragment libraries; each is kept
|
|
1271
|
+
* as a RAW string (no YAML parse) and spliced verbatim at render — never re-compiled as
|
|
1272
|
+
* Handlebars, so a literal `{{` in course material can never execute.
|
|
1091
1273
|
*/
|
|
1092
1274
|
async function assembleFragmentPrompt(block, baseUrl, fetchImpl, opts = {}, hostText = "") {
|
|
1093
1275
|
const warnings = [];
|
|
1094
1276
|
const allowedSchemes = opts.allowedSchemes ?? DEFAULT_ALLOWED_SCHEMES;
|
|
1095
|
-
if (block.fragment_files.length === 0) return {
|
|
1277
|
+
if (block.fragment_files.length === 0 && block.text_files.length === 0) return {
|
|
1096
1278
|
ok: true,
|
|
1097
1279
|
prompt: hostText,
|
|
1098
1280
|
warnings
|
|
1099
1281
|
};
|
|
1100
|
-
const
|
|
1282
|
+
const fragmentSettledPromise = Promise.all(block.fragment_files.map(async (ref) => {
|
|
1101
1283
|
let fragmentUrl;
|
|
1102
1284
|
try {
|
|
1103
1285
|
fragmentUrl = resolveFragmentUrl(ref.url, baseUrl);
|
|
@@ -1142,14 +1324,57 @@ async function assembleFragmentPrompt(block, baseUrl, fetchImpl, opts = {}, host
|
|
|
1142
1324
|
url: fragmentUrl
|
|
1143
1325
|
};
|
|
1144
1326
|
}));
|
|
1327
|
+
const textSettledPromise = Promise.all(block.text_files.map(async (ref) => {
|
|
1328
|
+
let fileUrl;
|
|
1329
|
+
try {
|
|
1330
|
+
fileUrl = resolveFragmentUrl(ref.url, baseUrl);
|
|
1331
|
+
} catch {
|
|
1332
|
+
return {
|
|
1333
|
+
alias: ref.id,
|
|
1334
|
+
error: error("INVALID_URL", `Invalid text-file URL: ${ref.url}`, {
|
|
1335
|
+
url: ref.url,
|
|
1336
|
+
fileAlias: ref.id
|
|
1337
|
+
})
|
|
1338
|
+
};
|
|
1339
|
+
}
|
|
1340
|
+
const schemeError = schemeGate(fileUrl, allowedSchemes);
|
|
1341
|
+
if (schemeError) return {
|
|
1342
|
+
alias: ref.id,
|
|
1343
|
+
error: {
|
|
1344
|
+
...schemeError,
|
|
1345
|
+
fileAlias: ref.id
|
|
1346
|
+
}
|
|
1347
|
+
};
|
|
1348
|
+
const fetched = await fetchText(fileUrl, fetchImpl);
|
|
1349
|
+
if (!fetched.ok) return {
|
|
1350
|
+
alias: ref.id,
|
|
1351
|
+
error: fetched.error
|
|
1352
|
+
};
|
|
1353
|
+
const byteLength = new TextEncoder().encode(fetched.text).length;
|
|
1354
|
+
if (byteLength > MAX_TEXT_FILE_BYTES) return {
|
|
1355
|
+
alias: ref.id,
|
|
1356
|
+
error: error("TEXT_FILE_TOO_LARGE", `Text file "${ref.id}" is ${byteLength} bytes, over the ${MAX_TEXT_FILE_BYTES}-byte limit`, {
|
|
1357
|
+
url: fileUrl,
|
|
1358
|
+
fileAlias: ref.id
|
|
1359
|
+
})
|
|
1360
|
+
};
|
|
1361
|
+
return {
|
|
1362
|
+
alias: ref.id,
|
|
1363
|
+
body: fetched.text
|
|
1364
|
+
};
|
|
1365
|
+
}));
|
|
1366
|
+
const [fragmentSettled, textSettled] = await Promise.all([fragmentSettledPromise, textSettledPromise]);
|
|
1145
1367
|
const fragmentFilesByAlias = /* @__PURE__ */ new Map();
|
|
1146
1368
|
const fragmentUrlByAlias = /* @__PURE__ */ new Map();
|
|
1369
|
+
const textFilesByAlias = /* @__PURE__ */ new Map();
|
|
1147
1370
|
const fileErrors = [];
|
|
1148
|
-
for (const result of
|
|
1371
|
+
for (const result of fragmentSettled) if ("error" in result) fileErrors.push(result.error);
|
|
1149
1372
|
else {
|
|
1150
1373
|
fragmentFilesByAlias.set(result.alias, result.file);
|
|
1151
1374
|
fragmentUrlByAlias.set(result.alias, result.url);
|
|
1152
1375
|
}
|
|
1376
|
+
for (const result of textSettled) if ("error" in result) fileErrors.push(result.error);
|
|
1377
|
+
else textFilesByAlias.set(result.alias, result.body);
|
|
1153
1378
|
if (fileErrors.length > 0) return {
|
|
1154
1379
|
ok: false,
|
|
1155
1380
|
errors: fileErrors,
|
|
@@ -1170,7 +1395,7 @@ async function assembleFragmentPrompt(block, baseUrl, fetchImpl, opts = {}, host
|
|
|
1170
1395
|
errors: [...libraryErrors, ...parsed.errors],
|
|
1171
1396
|
warnings
|
|
1172
1397
|
};
|
|
1173
|
-
const placementCheck = checkPlacements(parsed.placements, fragmentFilesByAlias, block.fragment_files);
|
|
1398
|
+
const placementCheck = checkPlacements(parsed.placements, fragmentFilesByAlias, block.fragment_files, textFilesByAlias, block.text_files, opts.validateLibraries ?? false);
|
|
1174
1399
|
warnings.push(...placementCheck.warnings);
|
|
1175
1400
|
const preRenderErrors = [...libraryErrors, ...placementCheck.errors];
|
|
1176
1401
|
if (preRenderErrors.length > 0) return {
|
|
@@ -1185,6 +1410,11 @@ async function assembleFragmentPrompt(block, baseUrl, fetchImpl, opts = {}, host
|
|
|
1185
1410
|
const resolved = resolveAndMerge(ref, args, fragmentFilesByAlias);
|
|
1186
1411
|
if (resolved.content === null || resolved.errors.length > 0) throw new Error(`Fragment "${ref}" could not be resolved`);
|
|
1187
1412
|
return renderFragmentContent(resolved.content, resolved.variables);
|
|
1413
|
+
}, (alias, from, to) => {
|
|
1414
|
+
const body = textFilesByAlias.get(alias);
|
|
1415
|
+
if (body === void 0) throw new Error(`Text file "${alias}" was not prefetched`);
|
|
1416
|
+
if (from === void 0 && to === void 0) return body;
|
|
1417
|
+
return sliceLines(body, from, to);
|
|
1188
1418
|
}),
|
|
1189
1419
|
warnings
|
|
1190
1420
|
};
|
|
@@ -1227,7 +1457,8 @@ const CodingYamlSchema = z.strictObject({
|
|
|
1227
1457
|
description: "The pinned model and provider that answer coding requests."
|
|
1228
1458
|
}),
|
|
1229
1459
|
fragment_files: z.array(FragmentFileRefSchema).default([]).meta({ description: "Optional fragment libraries this activity pulls shared prompt fragments from." }),
|
|
1230
|
-
|
|
1460
|
+
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." }),
|
|
1461
|
+
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 \\{{)." })
|
|
1231
1462
|
});
|
|
1232
1463
|
//#endregion
|
|
1233
1464
|
//#region ../lib/coding-validate.ts
|
|
@@ -1266,7 +1497,10 @@ async function loadAndCheckCoding(url, fetchImpl, opts = {}) {
|
|
|
1266
1497
|
};
|
|
1267
1498
|
const checked = checkCodingParsed(valid.data);
|
|
1268
1499
|
if (!checked.ok) return checked;
|
|
1269
|
-
const assembled = await assembleFragmentPrompt({
|
|
1500
|
+
const assembled = await assembleFragmentPrompt({
|
|
1501
|
+
fragment_files: valid.data.fragment_files,
|
|
1502
|
+
text_files: valid.data.text_files
|
|
1503
|
+
}, url, fetchImpl, {
|
|
1270
1504
|
allowedSchemes: opts.allowedSchemes,
|
|
1271
1505
|
validateLibraries: opts.validateLibraries ?? true
|
|
1272
1506
|
}, valid.data.instructions);
|
|
@@ -1283,6 +1517,25 @@ async function loadAndCheckCoding(url, fetchImpl, opts = {}) {
|
|
|
1283
1517
|
}
|
|
1284
1518
|
//#endregion
|
|
1285
1519
|
//#region ../lib/quiz-schema.ts
|
|
1520
|
+
/**
|
|
1521
|
+
* A live quiz include: alias + URL, mirroring `FragmentFileRefSchema` (same URL
|
|
1522
|
+
* contract). The alias prefixes every imported question id as `"<alias>/<id>"`, so
|
|
1523
|
+
* on top of the no-dot rule it may not contain a `/` either. Aliases live in their
|
|
1524
|
+
* OWN namespace (they never appear in `{{…}}` markers — only in question ids).
|
|
1525
|
+
*/
|
|
1526
|
+
const QuizFileRefSchema = z.strictObject({
|
|
1527
|
+
id: z.string().regex(/^[^./]+$/, { message: "Alias must not contain a dot or a slash" }).meta({
|
|
1528
|
+
pattern: "^[^./]+$",
|
|
1529
|
+
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."
|
|
1530
|
+
}),
|
|
1531
|
+
url: FragmentFileRefSchema.shape.url.meta({
|
|
1532
|
+
pattern: "^(https?://|(?![A-Za-z][A-Za-z0-9+.-]*:).+)$",
|
|
1533
|
+
description: "HTTP(S) URL or relative path to the included quiz file."
|
|
1534
|
+
})
|
|
1535
|
+
}).meta({
|
|
1536
|
+
id: "quizFileRef",
|
|
1537
|
+
description: "A reference to another quiz file whose questions are included live."
|
|
1538
|
+
});
|
|
1286
1539
|
/** An optional content image attached to a question (carries no secret). */
|
|
1287
1540
|
const ImageRefSchema = z.strictObject({
|
|
1288
1541
|
hosted: z.boolean().optional().meta({
|
|
@@ -1325,6 +1578,8 @@ const QuizYamlSchema = z.strictObject({
|
|
|
1325
1578
|
default: true,
|
|
1326
1579
|
description: "Present questions in a random order per attempt. Set to false to keep the authored order."
|
|
1327
1580
|
}),
|
|
1581
|
+
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." }),
|
|
1582
|
+
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." }),
|
|
1328
1583
|
llm: z.strictObject({
|
|
1329
1584
|
model: z.string().min(1).meta({ description: "The model that grades answers and drives the per-question discussion chat." }),
|
|
1330
1585
|
provider: providerSchema,
|
|
@@ -1341,8 +1596,9 @@ const QuizYamlSchema = z.strictObject({
|
|
|
1341
1596
|
description: "Optional guidance for the per-question follow-up discussion chat."
|
|
1342
1597
|
}),
|
|
1343
1598
|
fragment_files: z.array(FragmentFileRefSchema).default([]).meta({ description: "Optional fragment libraries this quiz pulls shared prompt fragments from." }),
|
|
1344
|
-
|
|
1345
|
-
|
|
1599
|
+
text_files: z.array(TextFileRefSchema).default([]).meta({ description: "Optional plain-text files (markdown / source) embedded verbatim into instructions via {{file \"alias\"}} markers." }),
|
|
1600
|
+
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 \\{{)." }),
|
|
1601
|
+
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." })
|
|
1346
1602
|
});
|
|
1347
1603
|
//#endregion
|
|
1348
1604
|
//#region ../lib/quiz-validate.ts
|
|
@@ -1362,12 +1618,39 @@ function findDuplicateQuestionIds(quiz) {
|
|
|
1362
1618
|
return errors;
|
|
1363
1619
|
}
|
|
1364
1620
|
/**
|
|
1365
|
-
*
|
|
1621
|
+
* Own question ids containing `/` — reserved as the namespace delimiter for
|
|
1622
|
+
* questions imported via `quiz_files` (`"<alias>/<id>"`), so an own id can never
|
|
1623
|
+
* collide with (or masquerade as) an imported one.
|
|
1624
|
+
*/
|
|
1625
|
+
function findReservedSlashIds(quiz) {
|
|
1626
|
+
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 }));
|
|
1627
|
+
}
|
|
1628
|
+
/** Include aliases declared on more than one `quiz_files` entry. */
|
|
1629
|
+
function findDuplicateIncludeAliases(quiz) {
|
|
1630
|
+
const errors = [];
|
|
1631
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1632
|
+
for (const ref of quiz.quiz_files) {
|
|
1633
|
+
if (seen.has(ref.id)) {
|
|
1634
|
+
errors.push(error("DUPLICATE_QUIZ_INCLUDE_ALIAS", `Included-quiz alias "${ref.id}" is declared more than once`, { fileAlias: ref.id }));
|
|
1635
|
+
continue;
|
|
1636
|
+
}
|
|
1637
|
+
seen.add(ref.id);
|
|
1638
|
+
}
|
|
1639
|
+
return errors;
|
|
1640
|
+
}
|
|
1641
|
+
/**
|
|
1642
|
+
* Check an already-schema-validated quiz: unique question ids, no reserved `/` ids,
|
|
1643
|
+
* unique include aliases, and a non-empty (potential) pool → metadata. Split from
|
|
1366
1644
|
* `checkQuizValue` so `loadAndCheckQuiz` can reuse the single `validate` it already ran
|
|
1367
1645
|
* (no second parse of the same document against the same schema).
|
|
1368
1646
|
*/
|
|
1369
1647
|
function checkQuizParsed(quiz) {
|
|
1370
|
-
const errors =
|
|
1648
|
+
const errors = [
|
|
1649
|
+
...findDuplicateQuestionIds(quiz),
|
|
1650
|
+
...findReservedSlashIds(quiz),
|
|
1651
|
+
...findDuplicateIncludeAliases(quiz)
|
|
1652
|
+
];
|
|
1653
|
+
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"));
|
|
1371
1654
|
if (errors.length > 0) return {
|
|
1372
1655
|
ok: false,
|
|
1373
1656
|
errors,
|
|
@@ -1384,12 +1667,83 @@ function checkQuizParsed(quiz) {
|
|
|
1384
1667
|
warnings: []
|
|
1385
1668
|
};
|
|
1386
1669
|
}
|
|
1670
|
+
/** Wrap an include's nested failures into ONE error carrying the alias + URL. */
|
|
1671
|
+
function includeUnreadable(alias, url, nested) {
|
|
1672
|
+
return error("QUIZ_INCLUDE_UNREADABLE", `Included quiz "${alias}" is not usable: ${nested.map((e) => e.message).join("; ")}`, url === void 0 ? { fileAlias: alias } : {
|
|
1673
|
+
fileAlias: alias,
|
|
1674
|
+
url
|
|
1675
|
+
});
|
|
1676
|
+
}
|
|
1677
|
+
/**
|
|
1678
|
+
* Deep-check ONE `quiz_files` include: resolve + fetch + parse + the FULL strict
|
|
1679
|
+
* quiz check (schema, consistency passes, its own fragment-block authoring gate),
|
|
1680
|
+
* plus the one-level rule (`QUIZ_INCLUDE_NESTED`). Every other failure is wrapped
|
|
1681
|
+
* as `QUIZ_INCLUDE_UNREADABLE` with the alias + resolved URL.
|
|
1682
|
+
*/
|
|
1683
|
+
async function checkInclude(ref, baseUrl, fetchImpl, opts) {
|
|
1684
|
+
let includeUrl;
|
|
1685
|
+
try {
|
|
1686
|
+
includeUrl = resolveFragmentUrl(ref.url, baseUrl);
|
|
1687
|
+
} catch {
|
|
1688
|
+
return {
|
|
1689
|
+
ok: false,
|
|
1690
|
+
errors: [includeUnreadable(ref.id, void 0, [error("INVALID_URL", `Invalid include URL: ${ref.url}`)])],
|
|
1691
|
+
warnings: []
|
|
1692
|
+
};
|
|
1693
|
+
}
|
|
1694
|
+
const yaml = await loadYaml(includeUrl, fetchImpl, opts);
|
|
1695
|
+
if (!yaml.ok) return {
|
|
1696
|
+
ok: false,
|
|
1697
|
+
errors: [includeUnreadable(ref.id, includeUrl, [yaml.error])],
|
|
1698
|
+
warnings: []
|
|
1699
|
+
};
|
|
1700
|
+
const valid = validate(yaml.value, QuizYamlSchema, "QUIZ_SCHEMA_ERROR", includeUrl);
|
|
1701
|
+
if (!valid.ok) return {
|
|
1702
|
+
ok: false,
|
|
1703
|
+
errors: [includeUnreadable(ref.id, includeUrl, [valid.error])],
|
|
1704
|
+
warnings: []
|
|
1705
|
+
};
|
|
1706
|
+
if (valid.data.quiz_files.length > 0) return {
|
|
1707
|
+
ok: false,
|
|
1708
|
+
errors: [error("QUIZ_INCLUDE_NESTED", `Included quiz "${ref.id}" itself declares quiz_files — includes are one level deep`, {
|
|
1709
|
+
fileAlias: ref.id,
|
|
1710
|
+
url: includeUrl
|
|
1711
|
+
})],
|
|
1712
|
+
warnings: []
|
|
1713
|
+
};
|
|
1714
|
+
const checked = checkQuizParsed(valid.data);
|
|
1715
|
+
if (!checked.ok) return {
|
|
1716
|
+
ok: false,
|
|
1717
|
+
errors: [includeUnreadable(ref.id, includeUrl, checked.errors)],
|
|
1718
|
+
warnings: checked.warnings
|
|
1719
|
+
};
|
|
1720
|
+
const assembled = await assembleFragmentPrompt({
|
|
1721
|
+
fragment_files: valid.data.fragment_files,
|
|
1722
|
+
text_files: valid.data.text_files
|
|
1723
|
+
}, includeUrl, fetchImpl, {
|
|
1724
|
+
allowedSchemes: opts.allowedSchemes,
|
|
1725
|
+
validateLibraries: opts.validateLibraries ?? true
|
|
1726
|
+
}, valid.data.instructions ?? "");
|
|
1727
|
+
if (!assembled.ok) return {
|
|
1728
|
+
ok: false,
|
|
1729
|
+
errors: [includeUnreadable(ref.id, includeUrl, assembled.errors)],
|
|
1730
|
+
warnings: assembled.warnings
|
|
1731
|
+
};
|
|
1732
|
+
return {
|
|
1733
|
+
ok: true,
|
|
1734
|
+
questionCount: valid.data.questions.length,
|
|
1735
|
+
warnings: assembled.warnings
|
|
1736
|
+
};
|
|
1737
|
+
}
|
|
1387
1738
|
/**
|
|
1388
1739
|
* Validate a quiz FILE: scheme-gate + fetch + parse (shared `loadYaml`), the pure
|
|
1389
|
-
* `checkQuizValue`,
|
|
1740
|
+
* `checkQuizValue`, the document-level fragment block's authoring gate — fetch
|
|
1390
1741
|
* every referenced library, run the THOROUGH whole-library check, consistency, and an
|
|
1391
|
-
* assembly dry-run (the strict-Handlebars backstop)
|
|
1392
|
-
*
|
|
1742
|
+
* assembly dry-run (the strict-Handlebars backstop) — and a DEEP check of every
|
|
1743
|
+
* `quiz_files` include. On success `questionCount` is the RESOLVED pool size
|
|
1744
|
+
* (own + imported), so the `/files` save UI and code-create metadata reflect the
|
|
1745
|
+
* real exam size. The web app passes the default http(s)-only schemes; the CLI adds
|
|
1746
|
+
* `file:` so a local quiz YAML on disk validates too.
|
|
1393
1747
|
*/
|
|
1394
1748
|
async function loadAndCheckQuiz(url, fetchImpl, opts = {}) {
|
|
1395
1749
|
const yaml = await loadYaml(url, fetchImpl, opts);
|
|
@@ -1406,7 +1760,10 @@ async function loadAndCheckQuiz(url, fetchImpl, opts = {}) {
|
|
|
1406
1760
|
};
|
|
1407
1761
|
const checked = checkQuizParsed(valid.data);
|
|
1408
1762
|
if (!checked.ok) return checked;
|
|
1409
|
-
const assembled = await assembleFragmentPrompt({
|
|
1763
|
+
const assembled = await assembleFragmentPrompt({
|
|
1764
|
+
fragment_files: valid.data.fragment_files,
|
|
1765
|
+
text_files: valid.data.text_files
|
|
1766
|
+
}, url, fetchImpl, {
|
|
1410
1767
|
allowedSchemes: opts.allowedSchemes,
|
|
1411
1768
|
validateLibraries: opts.validateLibraries ?? true
|
|
1412
1769
|
}, valid.data.instructions ?? "");
|
|
@@ -1416,8 +1773,22 @@ async function loadAndCheckQuiz(url, fetchImpl, opts = {}) {
|
|
|
1416
1773
|
errors: assembled.errors,
|
|
1417
1774
|
warnings
|
|
1418
1775
|
};
|
|
1776
|
+
const includes = await Promise.all(valid.data.quiz_files.map((ref) => checkInclude(ref, url, fetchImpl, opts)));
|
|
1777
|
+
const includeErrors = [];
|
|
1778
|
+
let importedCount = 0;
|
|
1779
|
+
for (const include of includes) {
|
|
1780
|
+
warnings.push(...include.warnings);
|
|
1781
|
+
if (include.ok) importedCount += include.questionCount;
|
|
1782
|
+
else includeErrors.push(...include.errors);
|
|
1783
|
+
}
|
|
1784
|
+
if (includeErrors.length > 0) return {
|
|
1785
|
+
ok: false,
|
|
1786
|
+
errors: includeErrors,
|
|
1787
|
+
warnings
|
|
1788
|
+
};
|
|
1419
1789
|
return {
|
|
1420
1790
|
...checked,
|
|
1791
|
+
questionCount: checked.questionCount + importedCount,
|
|
1421
1792
|
warnings
|
|
1422
1793
|
};
|
|
1423
1794
|
}
|
|
@@ -1458,7 +1829,8 @@ const TutorSchema = z.strictObject({
|
|
|
1458
1829
|
}),
|
|
1459
1830
|
prompt: z.strictObject({
|
|
1460
1831
|
fragment_files: z.array(FragmentFileRefSchema).default([]).meta({ description: "Optional fragment libraries used by this tutor." }),
|
|
1461
|
-
|
|
1832
|
+
text_files: z.array(TextFileRefSchema).default([]).meta({ description: "Optional plain-text files (markdown / source) embedded verbatim via {{file \"alias\"}} markers." }),
|
|
1833
|
+
tutor_instructions: z.string().meta({ description: "The tutor's system prompt. When any fragment_files or text_files are declared this is a Handlebars template: place fragments inline with {{fragment \"alias.id\" key=\"v\"}} markers and embed text files with {{file \"alias\"}} (optionally {{file \"alias\" from=10 to=40}} for a line range; escape a literal {{ as \\{{). For single-file tutors it is the whole prompt." })
|
|
1462
1834
|
}).meta({
|
|
1463
1835
|
id: "prompt",
|
|
1464
1836
|
description: "The tutor system prompt: a host template with inline fragment markers."
|
|
@@ -1520,7 +1892,8 @@ const WritingYamlSchema = z.strictObject({
|
|
|
1520
1892
|
description: "The model and provider that back the writing coach."
|
|
1521
1893
|
}),
|
|
1522
1894
|
fragment_files: z.array(FragmentFileRefSchema).default([]).meta({ description: "Optional fragment libraries this activity pulls shared prompt fragments from." }),
|
|
1523
|
-
|
|
1895
|
+
text_files: z.array(TextFileRefSchema).default([]).meta({ description: "Optional plain-text files (markdown / source) embedded verbatim into instructions via {{file \"alias\"}} markers." }),
|
|
1896
|
+
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 \\{{)." }),
|
|
1524
1897
|
placeholder: z.string().optional().meta({ description: "Optional starter text prefilled into the editor. Empty for a blank page." })
|
|
1525
1898
|
});
|
|
1526
1899
|
//#endregion
|
|
@@ -1563,7 +1936,10 @@ async function loadAndCheckWriting(url, fetchImpl, opts = {}) {
|
|
|
1563
1936
|
};
|
|
1564
1937
|
const checked = checkWritingParsed(valid.data);
|
|
1565
1938
|
if (!checked.ok) return checked;
|
|
1566
|
-
const assembled = await assembleFragmentPrompt({
|
|
1939
|
+
const assembled = await assembleFragmentPrompt({
|
|
1940
|
+
fragment_files: valid.data.fragment_files,
|
|
1941
|
+
text_files: valid.data.text_files
|
|
1942
|
+
}, url, fetchImpl, {
|
|
1567
1943
|
allowedSchemes: opts.allowedSchemes,
|
|
1568
1944
|
validateLibraries: opts.validateLibraries ?? true
|
|
1569
1945
|
}, valid.data.instructions);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@novedu/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.15.0",
|
|
4
4
|
"description": "Command-line companion for the Novedu chat app. Validates tutor, fragment, quiz, writing and coding YAML definitions; signs in with Entra ID and manages codes and app-hosted files over the app's API.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"repository": {
|