@novedu/cli 0.12.1 → 0.13.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 +321 -171
- package/package.json +1 -1
package/dist/main.js
CHANGED
|
@@ -396,25 +396,13 @@ const COMPILE_OPTIONS = {
|
|
|
396
396
|
noEscape: true
|
|
397
397
|
};
|
|
398
398
|
/**
|
|
399
|
-
*
|
|
400
|
-
*
|
|
401
|
-
*
|
|
402
|
-
*
|
|
403
|
-
* May throw if a template references a missing variable.
|
|
404
|
-
*
|
|
405
|
-
* `trailingInstructions` is optional so a consumer can assemble a fragment-only
|
|
406
|
-
* PREAMBLE (quiz / writing / coding) and concatenate its own frame afterwards. An
|
|
407
|
-
* empty plan with no trailing text renders to the empty string (so an activity that
|
|
408
|
-
* declares no fragments gets no stray whitespace); every non-empty result ends in a
|
|
409
|
-
* single trailing newline, byte-identical to the historic tutor output.
|
|
399
|
+
* Compile and render a single fragment's `content` with the merged variables. Shared
|
|
400
|
+
* by the host-template `fragment` helper (real render) so the produced text is
|
|
401
|
+
* byte-identical to what the standalone fragment check exercises. May throw if the
|
|
402
|
+
* template references a variable not in `variables` (strict mode).
|
|
410
403
|
*/
|
|
411
|
-
function
|
|
412
|
-
|
|
413
|
-
return Handlebars.compile(fragment.content, COMPILE_OPTIONS)(fragment.variables).trimEnd();
|
|
414
|
-
});
|
|
415
|
-
if (trailingInstructions !== void 0) parts.push(trailingInstructions.trimEnd());
|
|
416
|
-
if (parts.length === 0) return "";
|
|
417
|
-
return `${parts.join("\n\n")}\n`;
|
|
404
|
+
function renderFragmentContent(content, variables) {
|
|
405
|
+
return Handlebars.compile(content, COMPILE_OPTIONS)(variables);
|
|
418
406
|
}
|
|
419
407
|
//#endregion
|
|
420
408
|
//#region ../lib/prompt-fragments/errors.ts
|
|
@@ -473,118 +461,151 @@ function typeMismatch(prop, value) {
|
|
|
473
461
|
};
|
|
474
462
|
}
|
|
475
463
|
}
|
|
476
|
-
|
|
464
|
+
/** Split an inline reference at the FIRST dot: aliases cannot contain dots, ids may. */
|
|
465
|
+
function splitFragmentRef(ref) {
|
|
466
|
+
const dot = ref.indexOf(".");
|
|
467
|
+
if (dot === -1) return {
|
|
468
|
+
alias: ref,
|
|
469
|
+
fragmentId: ""
|
|
470
|
+
};
|
|
471
|
+
return {
|
|
472
|
+
alias: ref.slice(0, dot),
|
|
473
|
+
fragmentId: ref.slice(dot + 1)
|
|
474
|
+
};
|
|
475
|
+
}
|
|
476
|
+
/**
|
|
477
|
+
* Resolve one `"alias.id"` reference against the fetched libraries and validate its
|
|
478
|
+
* inline args against the fragment's `input_schema` — required present, types correct,
|
|
479
|
+
* undeclared flagged, optional defaults filled in (a supplied value always wins). The
|
|
480
|
+
* same required/type/undeclared/defaults machinery that ran per document-level ref
|
|
481
|
+
* before, now per placement.
|
|
482
|
+
*/
|
|
483
|
+
function resolveAndMerge(ref, args, filesByAlias) {
|
|
477
484
|
const errors = [];
|
|
478
485
|
const warnings = [];
|
|
479
|
-
const
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
}
|
|
493
|
-
byId.set(frag.id, frag);
|
|
494
|
-
}
|
|
495
|
-
fragmentIndex.set(alias, byId);
|
|
486
|
+
const { alias, fragmentId } = splitFragmentRef(ref);
|
|
487
|
+
const file = filesByAlias.get(alias);
|
|
488
|
+
if (!file) {
|
|
489
|
+
errors.push(error("UNKNOWN_FRAGMENT_FILE_ALIAS", `Fragment marker "${ref}" uses unknown file alias "${alias}"`, {
|
|
490
|
+
fileAlias: alias,
|
|
491
|
+
fragmentId
|
|
492
|
+
}));
|
|
493
|
+
return {
|
|
494
|
+
errors,
|
|
495
|
+
warnings,
|
|
496
|
+
variables: args,
|
|
497
|
+
content: null
|
|
498
|
+
};
|
|
496
499
|
}
|
|
497
|
-
const
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
500
|
+
const fragment = file.fragments.find((f) => f.id === fragmentId);
|
|
501
|
+
if (!fragment) {
|
|
502
|
+
errors.push(error("FRAGMENT_NOT_FOUND", `Fragment "${fragmentId}" not found in file "${alias}"`, {
|
|
503
|
+
fileAlias: alias,
|
|
504
|
+
fragmentId
|
|
505
|
+
}));
|
|
506
|
+
return {
|
|
507
|
+
errors,
|
|
508
|
+
warnings,
|
|
509
|
+
variables: args,
|
|
510
|
+
content: null
|
|
511
|
+
};
|
|
512
|
+
}
|
|
513
|
+
return {
|
|
514
|
+
errors,
|
|
515
|
+
warnings,
|
|
516
|
+
variables: mergeVariables(alias, fragment, args, errors, warnings),
|
|
517
|
+
content: fragment.content
|
|
518
|
+
};
|
|
519
|
+
}
|
|
520
|
+
/** Validate `args` against `fragment.input_schema` and merge in optional defaults. */
|
|
521
|
+
function mergeVariables(alias, fragment, args, errors, warnings) {
|
|
522
|
+
const merged = { ...args };
|
|
523
|
+
const schema = fragment.input_schema;
|
|
524
|
+
const id = fragment.id;
|
|
525
|
+
if (!schema) {
|
|
526
|
+
for (const name of Object.keys(args)) warnings.push(warning("UNDECLARED_VARIABLE", `Variable "${name}" supplied to "${id}", which declares no input schema`, {
|
|
527
|
+
fileAlias: alias,
|
|
528
|
+
fragmentId: id,
|
|
529
|
+
variable: name
|
|
504
530
|
}));
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
531
|
+
return merged;
|
|
532
|
+
}
|
|
533
|
+
for (const name of schema.required) if (!(name in args)) errors.push(error("MISSING_REQUIRED_VARIABLE", `Fragment "${id}" requires variable "${name}", which is not supplied`, {
|
|
534
|
+
fileAlias: alias,
|
|
535
|
+
fragmentId: id,
|
|
536
|
+
variable: name
|
|
537
|
+
}));
|
|
538
|
+
for (const [name, value] of Object.entries(args)) {
|
|
539
|
+
const prop = schema.properties[name];
|
|
540
|
+
if (!prop) {
|
|
541
|
+
warnings.push(warning("UNDECLARED_VARIABLE", `Variable "${name}" supplied to "${id}" is not declared in its input schema`, {
|
|
542
|
+
fileAlias: alias,
|
|
543
|
+
fragmentId: id,
|
|
544
|
+
variable: name
|
|
511
545
|
}));
|
|
512
546
|
continue;
|
|
513
547
|
}
|
|
514
|
-
const
|
|
515
|
-
if (
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
548
|
+
const mismatch = typeMismatch(prop, value);
|
|
549
|
+
if (mismatch) errors.push(error("VARIABLE_TYPE_MISMATCH", `Variable "${name}" of "${id}" should be ${mismatch.expected} but got ${mismatch.actual}`, {
|
|
550
|
+
fileAlias: alias,
|
|
551
|
+
fragmentId: id,
|
|
552
|
+
variable: name,
|
|
553
|
+
expectedType: mismatch.expected,
|
|
554
|
+
actualType: mismatch.actual
|
|
555
|
+
}));
|
|
556
|
+
}
|
|
557
|
+
const requiredSet = new Set(schema.required);
|
|
558
|
+
for (const [name, prop] of Object.entries(schema.properties)) {
|
|
559
|
+
if (prop.default === void 0) continue;
|
|
560
|
+
if (requiredSet.has(name)) {
|
|
561
|
+
warnings.push(warning("REQUIRED_PROPERTY_HAS_DEFAULT", `Variable "${name}" of "${id}" is required, so its default is never used`, {
|
|
562
|
+
fileAlias: alias,
|
|
563
|
+
fragmentId: id,
|
|
564
|
+
variable: name
|
|
519
565
|
}));
|
|
520
566
|
continue;
|
|
521
567
|
}
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
expectedType: mismatch.expected,
|
|
547
|
-
actualType: mismatch.actual
|
|
568
|
+
if (!(name in merged)) merged[name] = prop.default;
|
|
569
|
+
}
|
|
570
|
+
return merged;
|
|
571
|
+
}
|
|
572
|
+
/**
|
|
573
|
+
* Cross-check every inline placement against the declared libraries: duplicate
|
|
574
|
+
* aliases, duplicate fragment ids within a file, each placement's `alias.id`
|
|
575
|
+
* resolution + variable validation (via `resolveAndMerge`), and a warning for any
|
|
576
|
+
* declared library no placement ever uses. Errors block the build; the rest are
|
|
577
|
+
* warnings. Order-independent — placements carry their own textual position.
|
|
578
|
+
*/
|
|
579
|
+
function checkPlacements(placements, filesByAlias, fileRefs) {
|
|
580
|
+
const errors = [];
|
|
581
|
+
const warnings = [];
|
|
582
|
+
const aliasCounts = /* @__PURE__ */ new Map();
|
|
583
|
+
for (const ref of fileRefs) aliasCounts.set(ref.id, (aliasCounts.get(ref.id) ?? 0) + 1);
|
|
584
|
+
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 }));
|
|
585
|
+
for (const [alias, file] of filesByAlias) {
|
|
586
|
+
const seen = /* @__PURE__ */ new Set();
|
|
587
|
+
for (const frag of file.fragments) {
|
|
588
|
+
if (seen.has(frag.id)) {
|
|
589
|
+
errors.push(error("DUPLICATE_FRAGMENT_ID_IN_FILE", `Fragment "${frag.id}" is declared more than once in file "${alias}"`, {
|
|
590
|
+
fileAlias: alias,
|
|
591
|
+
fragmentId: frag.id
|
|
548
592
|
}));
|
|
593
|
+
continue;
|
|
549
594
|
}
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
if (prop.default === void 0) continue;
|
|
553
|
-
if (requiredSet.has(name)) {
|
|
554
|
-
warnings.push(warning("REQUIRED_PROPERTY_HAS_DEFAULT", `Variable "${name}" of "${ref.id}" is required, so its default is never used`, {
|
|
555
|
-
fileAlias: ref.file,
|
|
556
|
-
fragmentId: ref.id,
|
|
557
|
-
variable: name
|
|
558
|
-
}));
|
|
559
|
-
continue;
|
|
560
|
-
}
|
|
561
|
-
if (!(name in merged)) merged[name] = prop.default;
|
|
562
|
-
}
|
|
563
|
-
} else for (const name of Object.keys(variables)) warnings.push(warning("UNDECLARED_VARIABLE", `Variable "${name}" supplied to "${ref.id}", which declares no input schema`, {
|
|
564
|
-
fileAlias: ref.file,
|
|
565
|
-
fragmentId: ref.id,
|
|
566
|
-
variable: name
|
|
567
|
-
}));
|
|
568
|
-
resolved.push({
|
|
569
|
-
fileAlias: ref.file,
|
|
570
|
-
fragmentId: ref.id,
|
|
571
|
-
priority: fragment.priority,
|
|
572
|
-
content: fragment.content,
|
|
573
|
-
variables: merged
|
|
574
|
-
});
|
|
595
|
+
seen.add(frag.id);
|
|
596
|
+
}
|
|
575
597
|
}
|
|
576
|
-
const
|
|
577
|
-
const
|
|
578
|
-
|
|
579
|
-
const
|
|
580
|
-
|
|
581
|
-
|
|
598
|
+
const usedAliases = /* @__PURE__ */ new Set();
|
|
599
|
+
for (const placement of placements) {
|
|
600
|
+
usedAliases.add(splitFragmentRef(placement.ref).alias);
|
|
601
|
+
const resolved = resolveAndMerge(placement.ref, placement.args, filesByAlias);
|
|
602
|
+
errors.push(...resolved.errors);
|
|
603
|
+
warnings.push(...resolved.warnings);
|
|
582
604
|
}
|
|
583
|
-
for (const
|
|
605
|
+
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 }));
|
|
584
606
|
return {
|
|
585
607
|
errors,
|
|
586
|
-
warnings
|
|
587
|
-
plan
|
|
608
|
+
warnings
|
|
588
609
|
};
|
|
589
610
|
}
|
|
590
611
|
//#endregion
|
|
@@ -704,8 +725,7 @@ const ClassificationSchema = z.strictObject({
|
|
|
704
725
|
});
|
|
705
726
|
const FragmentSchema = z.strictObject({
|
|
706
727
|
id: z.string().meta({ description: "Unique fragment id within this library." }),
|
|
707
|
-
version: z.number().meta({ description: "
|
|
708
|
-
priority: z.number().meta({ description: "Assembly order. Lower priorities appear earlier." }),
|
|
728
|
+
version: z.number().optional().meta({ description: "Optional fragment version number." }),
|
|
709
729
|
input_schema: InputSchema.optional(),
|
|
710
730
|
classification: ClassificationSchema.optional(),
|
|
711
731
|
content: z.string().meta({ description: "Prompt text as a Handlebars template." })
|
|
@@ -717,8 +737,7 @@ const FragmentFileSchema = z.strictObject({
|
|
|
717
737
|
id: z.string().meta({ description: "Machine-readable id of this fragment library." }),
|
|
718
738
|
fragments: z.array(FragmentSchema).min(1).meta({ description: "The reusable fragments this library provides (at least one)." })
|
|
719
739
|
});
|
|
720
|
-
|
|
721
|
-
const VariableValueSchema = z.union([
|
|
740
|
+
z.union([
|
|
722
741
|
z.string(),
|
|
723
742
|
z.boolean(),
|
|
724
743
|
z.array(z.string())
|
|
@@ -727,22 +746,15 @@ const VariableValueSchema = z.union([
|
|
|
727
746
|
description: "A literal variable value: a string, a boolean, or an array of strings."
|
|
728
747
|
});
|
|
729
748
|
const FragmentFileRefSchema = z.strictObject({
|
|
730
|
-
id: z.string().
|
|
749
|
+
id: z.string().regex(/^[^.]+$/, { message: "Alias must not contain a dot" }).meta({
|
|
750
|
+
pattern: "^[^.]+$",
|
|
751
|
+
description: "Local alias for this library, used before the dot in `{{fragment \"alias.id\"}}`. Must not contain a dot."
|
|
752
|
+
}),
|
|
731
753
|
url: FragmentUrlRef
|
|
732
754
|
}).meta({
|
|
733
755
|
id: "fragmentFileRef",
|
|
734
756
|
description: "A reference to a fragment library by alias + URL."
|
|
735
757
|
});
|
|
736
|
-
const FragmentRefSchema = z.strictObject({
|
|
737
|
-
file: z.string().meta({ description: "The alias of the fragment library this fragment is drawn from." }),
|
|
738
|
-
id: z.string().meta({ description: "Fragment id inside the referenced library." }),
|
|
739
|
-
variables: z.record(z.string(), VariableValueSchema).optional().meta({ description: "Literal values passed into the fragment template." }),
|
|
740
|
-
bind: z.record(z.string(), z.string()).optional().meta({ description: "Accepted for compatibility but ignored by the current assembler." }),
|
|
741
|
-
required: z.boolean().optional().meta({ description: "Marker for important fragments. Accepted but not currently enforced." })
|
|
742
|
-
}).meta({
|
|
743
|
-
id: "fragmentRef",
|
|
744
|
-
description: "Selects one fragment from a referenced library."
|
|
745
|
-
});
|
|
746
758
|
//#endregion
|
|
747
759
|
//#region ../lib/prompt-fragments/fragment.ts
|
|
748
760
|
/**
|
|
@@ -793,8 +805,8 @@ function checkFragmentTemplates(file, opts = {}) {
|
|
|
793
805
|
}
|
|
794
806
|
/**
|
|
795
807
|
* Fragment ids declared more than once within a single file. The standalone
|
|
796
|
-
* validator runs this directly; the
|
|
797
|
-
* `
|
|
808
|
+
* validator runs this directly; the activity path gets the same check from
|
|
809
|
+
* `checkPlacements` (so the whole-library pass must NOT repeat it).
|
|
798
810
|
*/
|
|
799
811
|
function findDuplicateFragmentIds(file) {
|
|
800
812
|
const errors = [];
|
|
@@ -835,6 +847,142 @@ function checkFragmentFileValue(parsed, url) {
|
|
|
835
847
|
};
|
|
836
848
|
}
|
|
837
849
|
//#endregion
|
|
850
|
+
//#region ../lib/prompt-fragments/host-template.ts
|
|
851
|
+
const FRAGMENT_HELPER = "fragment";
|
|
852
|
+
const ARRAY_HELPER = "array";
|
|
853
|
+
const isFragmentNode = (node) => node.path?.original === FRAGMENT_HELPER;
|
|
854
|
+
/** A structural error stamped with the node's 1-based position. */
|
|
855
|
+
function markerInvalid(loc, detail) {
|
|
856
|
+
return error("FRAGMENT_MARKER_INVALID", `${detail} (line ${loc.start.line})`, {
|
|
857
|
+
line: loc.start.line,
|
|
858
|
+
column: loc.start.column + 1
|
|
859
|
+
});
|
|
860
|
+
}
|
|
861
|
+
/**
|
|
862
|
+
* Read a hash-pair value into a supported literal. The supported set IS the contract:
|
|
863
|
+
* a string, a boolean, or an `(array "…" …)` of string literals. Anything else — a
|
|
864
|
+
* number, a path reference, a nested `(fragment …)`, an array with a non-string
|
|
865
|
+
* element — is a hard error, because at RENDER time the real Handlebars runtime hands
|
|
866
|
+
* the helper the raw value regardless, so silently dropping it here would let the
|
|
867
|
+
* placement validate against different args than it renders with (fail-open drift).
|
|
868
|
+
*/
|
|
869
|
+
function readHashValue(key, node, loc) {
|
|
870
|
+
switch (node.type) {
|
|
871
|
+
case "StringLiteral": return { value: node.value };
|
|
872
|
+
case "BooleanLiteral": return { value: node.value };
|
|
873
|
+
case "SubExpression": {
|
|
874
|
+
const sub = node;
|
|
875
|
+
if (sub.path.original !== ARRAY_HELPER) return { error: markerInvalid(loc, `Argument "${key}" must be a string, boolean, or (array …)`) };
|
|
876
|
+
if (!sub.params.every((p) => p.type === "StringLiteral")) return { error: markerInvalid(loc, `Every element of (array …) for "${key}" must be a string`) };
|
|
877
|
+
return { value: sub.params.map((p) => p.value) };
|
|
878
|
+
}
|
|
879
|
+
default: return { error: markerInvalid(loc, `Argument "${key}" must be a string, boolean, or (array …)`) };
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
/** Extract one inline `{{fragment "alias.id" …}}` mustache's ref + validated args. */
|
|
883
|
+
function extractInlineMarker(node, out) {
|
|
884
|
+
const first = node.params?.[0];
|
|
885
|
+
if (first?.type !== "StringLiteral") {
|
|
886
|
+
out.errors.push(error("FRAGMENT_REF_NOT_LITERAL", `A {{fragment}} marker at line ${node.loc.start.line} needs a quoted "alias.id" reference`, {
|
|
887
|
+
line: node.loc.start.line,
|
|
888
|
+
column: node.loc.start.column + 1
|
|
889
|
+
}));
|
|
890
|
+
return;
|
|
891
|
+
}
|
|
892
|
+
if ((node.params?.length ?? 0) > 1) {
|
|
893
|
+
out.errors.push(markerInvalid(node.loc, "A {{fragment}} marker takes exactly one \"alias.id\" reference"));
|
|
894
|
+
return;
|
|
895
|
+
}
|
|
896
|
+
const args = {};
|
|
897
|
+
for (const pair of node.hash?.pairs ?? []) {
|
|
898
|
+
const read = readHashValue(pair.key, pair.value, node.loc);
|
|
899
|
+
if ("error" in read) {
|
|
900
|
+
out.errors.push(read.error);
|
|
901
|
+
return;
|
|
902
|
+
}
|
|
903
|
+
args[pair.key] = read.value;
|
|
904
|
+
}
|
|
905
|
+
out.placements.push({
|
|
906
|
+
ref: first.value,
|
|
907
|
+
args,
|
|
908
|
+
line: node.loc.start.line,
|
|
909
|
+
column: node.loc.start.column + 1
|
|
910
|
+
});
|
|
911
|
+
}
|
|
912
|
+
/** Reject a `fragment` helper used anywhere but a simple inline mustache. */
|
|
913
|
+
function visitExpression(node, out) {
|
|
914
|
+
if (node.type !== "SubExpression") return;
|
|
915
|
+
const sub = node;
|
|
916
|
+
if (isFragmentNode(sub)) out.errors.push(markerInvalid(sub.loc, "{{fragment}} must be a standalone inline marker, not a subexpression"));
|
|
917
|
+
for (const p of sub.params ?? []) visitExpression(p, out);
|
|
918
|
+
for (const pair of sub.hash?.pairs ?? []) visitExpression(pair.value, out);
|
|
919
|
+
}
|
|
920
|
+
/** Collect every `{{fragment}}` marker in a program body (recursing into blocks). */
|
|
921
|
+
function collectPlacements(program, out) {
|
|
922
|
+
for (const node of program.body) {
|
|
923
|
+
if (isFragmentNode(node)) if (node.type === "MustacheStatement") extractInlineMarker(node, out);
|
|
924
|
+
else out.errors.push(markerInvalid(node.loc, "{{fragment}} must be a simple inline marker, not a block {{#fragment}}…{{/fragment}}"));
|
|
925
|
+
else {
|
|
926
|
+
for (const p of node.params ?? []) visitExpression(p, out);
|
|
927
|
+
for (const pair of node.hash?.pairs ?? []) visitExpression(pair.value, out);
|
|
928
|
+
}
|
|
929
|
+
if (node.program) collectPlacements(node.program, out);
|
|
930
|
+
if (node.inverse) collectPlacements(node.inverse, out);
|
|
931
|
+
}
|
|
932
|
+
}
|
|
933
|
+
/**
|
|
934
|
+
* Parse the host text and extract every inline `{{fragment}}` placement in textual
|
|
935
|
+
* order, WITHOUT rendering. A whole-template syntax error (a malformed marker, an
|
|
936
|
+
* unescaped literal `{{`) becomes a single `HOST_TEMPLATE_PARSE_ERROR`; its position
|
|
937
|
+
* is regexed out of Handlebars' message (`Parse error on line N:` — the parser
|
|
938
|
+
* carries no structured position fields). A well-formed marker whose reference is not
|
|
939
|
+
* a quoted string literal becomes a per-marker `FRAGMENT_REF_NOT_LITERAL`.
|
|
940
|
+
*/
|
|
941
|
+
function parseHostPlacements(text) {
|
|
942
|
+
const result = {
|
|
943
|
+
placements: [],
|
|
944
|
+
errors: []
|
|
945
|
+
};
|
|
946
|
+
let program;
|
|
947
|
+
try {
|
|
948
|
+
program = Handlebars.parse(text);
|
|
949
|
+
} catch (e) {
|
|
950
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
951
|
+
const lineMatch = message.match(/line (\d+)/i);
|
|
952
|
+
result.errors.push(error("HOST_TEMPLATE_PARSE_ERROR", `Host text is not a valid template: ${message}`, { ...lineMatch ? { line: Number(lineMatch[1]) } : {} }));
|
|
953
|
+
return result;
|
|
954
|
+
}
|
|
955
|
+
collectPlacements(program, result);
|
|
956
|
+
return result;
|
|
957
|
+
}
|
|
958
|
+
/** Build the isolated Handlebars instance carrying ONLY the `fragment` + `array` helpers. */
|
|
959
|
+
function createHostInstance(resolver) {
|
|
960
|
+
const hb = Handlebars.create();
|
|
961
|
+
hb.registerHelper(FRAGMENT_HELPER, (...args) => {
|
|
962
|
+
const ref = args.length >= 2 ? args[0] : void 0;
|
|
963
|
+
if (typeof ref !== "string") throw new Error("a {{fragment}} marker needs a quoted \"alias.id\" reference");
|
|
964
|
+
const options = args[args.length - 1];
|
|
965
|
+
return resolver(ref, options?.hash ?? {});
|
|
966
|
+
});
|
|
967
|
+
hb.registerHelper(ARRAY_HELPER, (...args) => {
|
|
968
|
+
return args.slice(0, -1);
|
|
969
|
+
});
|
|
970
|
+
return hb;
|
|
971
|
+
}
|
|
972
|
+
/**
|
|
973
|
+
* Compile + render the host text with the isolated instance under
|
|
974
|
+
* `{ strict: true, noEscape: true }` — `strict` so any stray `{{…}}` that is not a
|
|
975
|
+
* `fragment` / `array` marker fails closed instead of rendering empty; `noEscape` so
|
|
976
|
+
* the prompt text (ASCII diagrams, quotes) passes through verbatim. Each placement is
|
|
977
|
+
* replaced by its `resolver` result. May throw — the caller wraps it as ASSEMBLY_ERROR.
|
|
978
|
+
*/
|
|
979
|
+
function renderHostTemplate(text, resolver) {
|
|
980
|
+
return createHostInstance(resolver).compile(text, {
|
|
981
|
+
strict: true,
|
|
982
|
+
noEscape: true
|
|
983
|
+
})({});
|
|
984
|
+
}
|
|
985
|
+
//#endregion
|
|
838
986
|
//#region ../lib/relative-url.ts
|
|
839
987
|
/**
|
|
840
988
|
* Resolve a reference to an absolute URL. An absolute http(s) ref is used as-is;
|
|
@@ -926,23 +1074,27 @@ async function loadYaml(url, fetchImpl, opts = {}) {
|
|
|
926
1074
|
};
|
|
927
1075
|
}
|
|
928
1076
|
/**
|
|
929
|
-
*
|
|
930
|
-
*
|
|
931
|
-
*
|
|
932
|
-
*
|
|
933
|
-
*
|
|
1077
|
+
* Render an activity's host text (`tutor_instructions` / `instructions`) into a
|
|
1078
|
+
* finished prompt string, inserting each inline `{{fragment "alias.id" …}}` marker in
|
|
1079
|
+
* place. Fetches every declared fragment library in parallel (relative refs resolved
|
|
1080
|
+
* against `baseUrl`), schema-validates each, (optionally) runs the thorough
|
|
1081
|
+
* whole-library check, extracts + checks the placements, then compiles + renders the
|
|
1082
|
+
* host template under strict Handlebars.
|
|
934
1083
|
*
|
|
935
1084
|
* The single seam every activity kind shares — the sole owner of the fetch → validate
|
|
936
|
-
* →
|
|
937
|
-
*
|
|
938
|
-
*
|
|
1085
|
+
* → check → render pipeline. `hostText` IS the template: fragments appear only where
|
|
1086
|
+
* the author placed a marker; there is no ordering concept and no prepend fallback.
|
|
1087
|
+
*
|
|
1088
|
+
* TEMPLATE-SEMANTICS OPT-IN: an activity that declares no `fragment_files:` is NEVER
|
|
1089
|
+
* compiled — its host text returns byte-verbatim (protecting plain activities and the
|
|
1090
|
+
* authoring tutors whose prose contains sample markers as teaching content).
|
|
939
1091
|
*/
|
|
940
|
-
async function assembleFragmentPrompt(block, baseUrl, fetchImpl, opts = {},
|
|
1092
|
+
async function assembleFragmentPrompt(block, baseUrl, fetchImpl, opts = {}, hostText = "") {
|
|
941
1093
|
const warnings = [];
|
|
942
1094
|
const allowedSchemes = opts.allowedSchemes ?? DEFAULT_ALLOWED_SCHEMES;
|
|
943
|
-
if (block.fragment_files.length === 0
|
|
1095
|
+
if (block.fragment_files.length === 0) return {
|
|
944
1096
|
ok: true,
|
|
945
|
-
prompt:
|
|
1097
|
+
prompt: hostText,
|
|
946
1098
|
warnings
|
|
947
1099
|
};
|
|
948
1100
|
const settled = await Promise.all(block.fragment_files.map(async (ref) => {
|
|
@@ -1012,18 +1164,28 @@ async function assembleFragmentPrompt(block, baseUrl, fetchImpl, opts = {}, trai
|
|
|
1012
1164
|
libraryErrors.push(...checked.errors);
|
|
1013
1165
|
warnings.push(...checked.warnings);
|
|
1014
1166
|
}
|
|
1015
|
-
const
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1167
|
+
const parsed = parseHostPlacements(hostText);
|
|
1168
|
+
if (parsed.errors.length > 0) return {
|
|
1169
|
+
ok: false,
|
|
1170
|
+
errors: [...libraryErrors, ...parsed.errors],
|
|
1171
|
+
warnings
|
|
1172
|
+
};
|
|
1173
|
+
const placementCheck = checkPlacements(parsed.placements, fragmentFilesByAlias, block.fragment_files);
|
|
1174
|
+
warnings.push(...placementCheck.warnings);
|
|
1175
|
+
const preRenderErrors = [...libraryErrors, ...placementCheck.errors];
|
|
1176
|
+
if (preRenderErrors.length > 0) return {
|
|
1019
1177
|
ok: false,
|
|
1020
|
-
errors:
|
|
1178
|
+
errors: preRenderErrors,
|
|
1021
1179
|
warnings
|
|
1022
1180
|
};
|
|
1023
1181
|
try {
|
|
1024
1182
|
return {
|
|
1025
1183
|
ok: true,
|
|
1026
|
-
prompt:
|
|
1184
|
+
prompt: renderHostTemplate(hostText, (ref, args) => {
|
|
1185
|
+
const resolved = resolveAndMerge(ref, args, fragmentFilesByAlias);
|
|
1186
|
+
if (resolved.content === null || resolved.errors.length > 0) throw new Error(`Fragment "${ref}" could not be resolved`);
|
|
1187
|
+
return renderFragmentContent(resolved.content, resolved.variables);
|
|
1188
|
+
}),
|
|
1027
1189
|
warnings
|
|
1028
1190
|
};
|
|
1029
1191
|
} catch (e) {
|
|
@@ -1065,8 +1227,7 @@ const CodingYamlSchema = z.strictObject({
|
|
|
1065
1227
|
description: "The pinned model and provider that answer coding requests."
|
|
1066
1228
|
}),
|
|
1067
1229
|
fragment_files: z.array(FragmentFileRefSchema).default([]).meta({ description: "Optional fragment libraries this activity pulls shared prompt fragments from." }),
|
|
1068
|
-
|
|
1069
|
-
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." })
|
|
1230
|
+
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 are declared it is a Handlebars template: place fragments inline with {{fragment \"alias.id\" …}} markers (escape a literal {{ as \\{{)." })
|
|
1070
1231
|
});
|
|
1071
1232
|
//#endregion
|
|
1072
1233
|
//#region ../lib/coding-validate.ts
|
|
@@ -1105,13 +1266,10 @@ async function loadAndCheckCoding(url, fetchImpl, opts = {}) {
|
|
|
1105
1266
|
};
|
|
1106
1267
|
const checked = checkCodingParsed(valid.data);
|
|
1107
1268
|
if (!checked.ok) return checked;
|
|
1108
|
-
const assembled = await assembleFragmentPrompt({
|
|
1109
|
-
fragment_files: valid.data.fragment_files,
|
|
1110
|
-
fragments: valid.data.fragments
|
|
1111
|
-
}, url, fetchImpl, {
|
|
1269
|
+
const assembled = await assembleFragmentPrompt({ fragment_files: valid.data.fragment_files }, url, fetchImpl, {
|
|
1112
1270
|
allowedSchemes: opts.allowedSchemes,
|
|
1113
1271
|
validateLibraries: opts.validateLibraries ?? true
|
|
1114
|
-
});
|
|
1272
|
+
}, valid.data.instructions);
|
|
1115
1273
|
const warnings = [...checked.warnings, ...assembled.warnings];
|
|
1116
1274
|
if (!assembled.ok) return {
|
|
1117
1275
|
ok: false,
|
|
@@ -1183,7 +1341,7 @@ const QuizYamlSchema = z.strictObject({
|
|
|
1183
1341
|
description: "Optional guidance for the per-question follow-up discussion chat."
|
|
1184
1342
|
}),
|
|
1185
1343
|
fragment_files: z.array(FragmentFileRefSchema).default([]).meta({ description: "Optional fragment libraries this quiz pulls shared prompt fragments from." }),
|
|
1186
|
-
|
|
1344
|
+
instructions: z.string().optional().meta({ description: "Optional quiz-level preamble prepended to BOTH the grader prompt and the discussion chat. When any fragment_files are declared, place fragments inline here with {{fragment \"alias.id\" …}} markers (escape a literal {{ as \\{{)." }),
|
|
1187
1345
|
questions: z.array(QuizQuestionSchema).min(1).meta({ description: "The quiz questions. Each is open-ended and graded by the LLM via its evaluation prompt." })
|
|
1188
1346
|
});
|
|
1189
1347
|
//#endregion
|
|
@@ -1248,13 +1406,10 @@ async function loadAndCheckQuiz(url, fetchImpl, opts = {}) {
|
|
|
1248
1406
|
};
|
|
1249
1407
|
const checked = checkQuizParsed(valid.data);
|
|
1250
1408
|
if (!checked.ok) return checked;
|
|
1251
|
-
const assembled = await assembleFragmentPrompt({
|
|
1252
|
-
fragment_files: valid.data.fragment_files,
|
|
1253
|
-
fragments: valid.data.fragments
|
|
1254
|
-
}, url, fetchImpl, {
|
|
1409
|
+
const assembled = await assembleFragmentPrompt({ fragment_files: valid.data.fragment_files }, url, fetchImpl, {
|
|
1255
1410
|
allowedSchemes: opts.allowedSchemes,
|
|
1256
1411
|
validateLibraries: opts.validateLibraries ?? true
|
|
1257
|
-
});
|
|
1412
|
+
}, valid.data.instructions ?? "");
|
|
1258
1413
|
const warnings = [...checked.warnings, ...assembled.warnings];
|
|
1259
1414
|
if (!assembled.ok) return {
|
|
1260
1415
|
ok: false,
|
|
@@ -1303,11 +1458,10 @@ const TutorSchema = z.strictObject({
|
|
|
1303
1458
|
}),
|
|
1304
1459
|
prompt: z.strictObject({
|
|
1305
1460
|
fragment_files: z.array(FragmentFileRefSchema).default([]).meta({ description: "Optional fragment libraries used by this tutor." }),
|
|
1306
|
-
|
|
1307
|
-
tutor_instructions: z.string().meta({ description: "Final tutor-specific system-prompt instructions. For single-file tutors, this can be the whole prompt." })
|
|
1461
|
+
tutor_instructions: z.string().meta({ description: "The tutor's system prompt. When any fragment_files are declared this is a Handlebars template: place fragments inline with {{fragment \"alias.id\" key=\"v\"}} markers (escape a literal {{ as \\{{). For single-file tutors it is the whole prompt." })
|
|
1308
1462
|
}).meta({
|
|
1309
1463
|
id: "prompt",
|
|
1310
|
-
description: "The
|
|
1464
|
+
description: "The tutor system prompt: a host template with inline fragment markers."
|
|
1311
1465
|
})
|
|
1312
1466
|
});
|
|
1313
1467
|
//#endregion
|
|
@@ -1366,8 +1520,7 @@ const WritingYamlSchema = z.strictObject({
|
|
|
1366
1520
|
description: "The model and provider that back the writing coach."
|
|
1367
1521
|
}),
|
|
1368
1522
|
fragment_files: z.array(FragmentFileRefSchema).default([]).meta({ description: "Optional fragment libraries this activity pulls shared prompt fragments from." }),
|
|
1369
|
-
|
|
1370
|
-
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." }),
|
|
1523
|
+
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 are declared it is a Handlebars template: place fragments inline with {{fragment \"alias.id\" …}} markers (escape a literal {{ as \\{{)." }),
|
|
1371
1524
|
placeholder: z.string().optional().meta({ description: "Optional starter text prefilled into the editor. Empty for a blank page." })
|
|
1372
1525
|
});
|
|
1373
1526
|
//#endregion
|
|
@@ -1410,13 +1563,10 @@ async function loadAndCheckWriting(url, fetchImpl, opts = {}) {
|
|
|
1410
1563
|
};
|
|
1411
1564
|
const checked = checkWritingParsed(valid.data);
|
|
1412
1565
|
if (!checked.ok) return checked;
|
|
1413
|
-
const assembled = await assembleFragmentPrompt({
|
|
1414
|
-
fragment_files: valid.data.fragment_files,
|
|
1415
|
-
fragments: valid.data.fragments
|
|
1416
|
-
}, url, fetchImpl, {
|
|
1566
|
+
const assembled = await assembleFragmentPrompt({ fragment_files: valid.data.fragment_files }, url, fetchImpl, {
|
|
1417
1567
|
allowedSchemes: opts.allowedSchemes,
|
|
1418
1568
|
validateLibraries: opts.validateLibraries ?? true
|
|
1419
|
-
});
|
|
1569
|
+
}, valid.data.instructions);
|
|
1420
1570
|
const warnings = [...checked.warnings, ...assembled.warnings];
|
|
1421
1571
|
if (!assembled.ok) return {
|
|
1422
1572
|
ok: false,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@novedu/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.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": {
|