@novedu/cli 0.8.0 → 0.10.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 +401 -188
- package/package.json +3 -3
package/dist/main.js
CHANGED
|
@@ -358,25 +358,34 @@ Purely local — already-issued access tokens stay valid until they expire
|
|
|
358
358
|
});
|
|
359
359
|
}
|
|
360
360
|
//#endregion
|
|
361
|
-
//#region ../lib/
|
|
361
|
+
//#region ../lib/prompt-fragments/assemble.ts
|
|
362
362
|
const COMPILE_OPTIONS = {
|
|
363
363
|
strict: true,
|
|
364
364
|
noEscape: true
|
|
365
365
|
};
|
|
366
366
|
/**
|
|
367
|
-
* Render each fragment in priority order and append the
|
|
368
|
-
* instructions last (they carry no priority, so "after everything" is the
|
|
369
|
-
* deterministic position
|
|
367
|
+
* Render each fragment in priority order and, when provided, append the caller's
|
|
368
|
+
* trailing instructions last (they carry no priority, so "after everything" is the
|
|
369
|
+
* only deterministic position — the exact role `tutor_instructions` plays for a
|
|
370
|
+
* tutor, and the activity frame / `instructions` play for quiz / writing / coding).
|
|
371
|
+
* May throw if a template references a missing variable.
|
|
372
|
+
*
|
|
373
|
+
* `trailingInstructions` is optional so a consumer can assemble a fragment-only
|
|
374
|
+
* PREAMBLE (quiz / writing / coding) and concatenate its own frame afterwards. An
|
|
375
|
+
* empty plan with no trailing text renders to the empty string (so an activity that
|
|
376
|
+
* declares no fragments gets no stray whitespace); every non-empty result ends in a
|
|
377
|
+
* single trailing newline, byte-identical to the historic tutor output.
|
|
370
378
|
*/
|
|
371
|
-
function assembleSystemPrompt(plan,
|
|
379
|
+
function assembleSystemPrompt(plan, trailingInstructions) {
|
|
372
380
|
const parts = plan.map((fragment) => {
|
|
373
381
|
return Handlebars.compile(fragment.content, COMPILE_OPTIONS)(fragment.variables).trimEnd();
|
|
374
382
|
});
|
|
375
|
-
parts.push(
|
|
383
|
+
if (trailingInstructions !== void 0) parts.push(trailingInstructions.trimEnd());
|
|
384
|
+
if (parts.length === 0) return "";
|
|
376
385
|
return `${parts.join("\n\n")}\n`;
|
|
377
386
|
}
|
|
378
387
|
//#endregion
|
|
379
|
-
//#region ../lib/
|
|
388
|
+
//#region ../lib/prompt-fragments/errors.ts
|
|
380
389
|
/** Small helper to build an error object tersely at call sites. */
|
|
381
390
|
function error(code, message, extra = {}) {
|
|
382
391
|
return {
|
|
@@ -413,7 +422,7 @@ function formatZodIssues(zodIssues) {
|
|
|
413
422
|
return out;
|
|
414
423
|
}
|
|
415
424
|
//#endregion
|
|
416
|
-
//#region ../lib/
|
|
425
|
+
//#region ../lib/prompt-fragments/consistency.ts
|
|
417
426
|
/** Compare a supplied value against its declared property type. Returns null when it matches. */
|
|
418
427
|
function typeMismatch(prop, value) {
|
|
419
428
|
const actual = Array.isArray(value) ? "array" : typeof value;
|
|
@@ -432,11 +441,11 @@ function typeMismatch(prop, value) {
|
|
|
432
441
|
};
|
|
433
442
|
}
|
|
434
443
|
}
|
|
435
|
-
function checkConsistency(
|
|
444
|
+
function checkConsistency(block, fragmentFilesByAlias) {
|
|
436
445
|
const errors = [];
|
|
437
446
|
const warnings = [];
|
|
438
447
|
const aliasCounts = /* @__PURE__ */ new Map();
|
|
439
|
-
for (const ref of
|
|
448
|
+
for (const ref of block.fragment_files) aliasCounts.set(ref.id, (aliasCounts.get(ref.id) ?? 0) + 1);
|
|
440
449
|
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 }));
|
|
441
450
|
const fragmentIndex = /* @__PURE__ */ new Map();
|
|
442
451
|
for (const [alias, file] of fragmentFilesByAlias) {
|
|
@@ -455,7 +464,7 @@ function checkConsistency(tutor, fragmentFilesByAlias) {
|
|
|
455
464
|
}
|
|
456
465
|
const resolved = [];
|
|
457
466
|
const seenRefs = /* @__PURE__ */ new Set();
|
|
458
|
-
for (const ref of
|
|
467
|
+
for (const ref of block.fragments) {
|
|
459
468
|
const refKey = `${ref.file}::${ref.id}`;
|
|
460
469
|
if (seenRefs.has(refKey)) warnings.push(warning("DUPLICATE_FRAGMENT_REFERENCE", `Fragment "${ref.id}" from "${ref.file}" is referenced more than once`, {
|
|
461
470
|
fileAlias: ref.file,
|
|
@@ -547,7 +556,7 @@ function checkConsistency(tutor, fragmentFilesByAlias) {
|
|
|
547
556
|
};
|
|
548
557
|
}
|
|
549
558
|
//#endregion
|
|
550
|
-
//#region ../lib/
|
|
559
|
+
//#region ../lib/prompt-fragments/fetcher.ts
|
|
551
560
|
const DEFAULT_TIMEOUT_MS = 1e4;
|
|
552
561
|
/** Production fetcher: global `fetch` with an abort-based timeout so a slow host can't hang the request. */
|
|
553
562
|
const defaultFetcher = async (url) => {
|
|
@@ -563,7 +572,7 @@ const defaultFetcher = async (url) => {
|
|
|
563
572
|
}
|
|
564
573
|
};
|
|
565
574
|
//#endregion
|
|
566
|
-
//#region ../lib/
|
|
575
|
+
//#region ../lib/prompt-fragments/parse.ts
|
|
567
576
|
/** Parse a YAML document, mapping syntax errors to a structured `YAML_PARSE_ERROR`. */
|
|
568
577
|
function parseYaml(text, url) {
|
|
569
578
|
try {
|
|
@@ -593,105 +602,117 @@ function validate(value, schema, code, url) {
|
|
|
593
602
|
})
|
|
594
603
|
};
|
|
595
604
|
}
|
|
596
|
-
const providerSchema = z.enum(["SCCH", "Azure Foundry"]).default("SCCH");
|
|
597
605
|
//#endregion
|
|
598
|
-
//#region ../lib/
|
|
606
|
+
//#region ../lib/prompt-fragments/schemas.ts
|
|
599
607
|
/**
|
|
600
608
|
* A fragment-file reference: either an absolute http(s) URL or a relative path that
|
|
601
|
-
* `load.ts` resolves against the
|
|
609
|
+
* `load.ts` resolves against the activity YAML's own URL. We reject any *other* absolute
|
|
602
610
|
* scheme (`ftp:`, `mailto:`, …) so a typo can't smuggle in a non-http(s) target — the
|
|
603
611
|
* refine reads as "if it carries a URI scheme at all, that scheme must be http(s)".
|
|
604
612
|
* Strings without a scheme (relative paths) pass through and are resolved at load time.
|
|
613
|
+
*
|
|
614
|
+
* The `.refine()` is the RUNTIME enforcement. `z.toJSONSchema()` cannot derive a pattern
|
|
615
|
+
* from a refinement, so the equivalent editor constraint is re-expressed via `.meta({ pattern })`
|
|
616
|
+
* — the two MUST be kept consistent (both mean "http(s) URL or a scheme-less relative path").
|
|
605
617
|
*/
|
|
606
|
-
const FragmentUrlRef = z.string().min(1).refine((u) => !/^[a-z][a-z0-9+.-]*:/i.test(u) || /^https?:\/\//i.test(u), { message: "Must be an http(s) URL or a relative path" })
|
|
618
|
+
const FragmentUrlRef = z.string().min(1).refine((u) => !/^[a-z][a-z0-9+.-]*:/i.test(u) || /^https?:\/\//i.test(u), { message: "Must be an http(s) URL or a relative path" }).meta({
|
|
619
|
+
pattern: "^(https?://|(?![A-Za-z][A-Za-z0-9+.-]*:).+)$",
|
|
620
|
+
description: "HTTP(S) URL or relative path to the fragment library."
|
|
621
|
+
});
|
|
607
622
|
/**
|
|
608
623
|
* A declared property is a string, a boolean, or an array of strings. Each may carry an
|
|
609
624
|
* optional `default`, typed to match its `type` (a string default on a boolean property
|
|
610
|
-
* is a schema error). When the
|
|
625
|
+
* is a schema error). When the activity omits the variable, the default is used; supplying
|
|
611
626
|
* a value overrides it. See `consistency.ts` for where defaults are injected.
|
|
612
627
|
*/
|
|
628
|
+
const StringPropertySchema = z.strictObject({
|
|
629
|
+
type: z.literal("string").meta({ description: "Declares a string variable." }),
|
|
630
|
+
default: z.string().optional().meta({ description: "Default string used when the activity omits this variable." })
|
|
631
|
+
}).meta({
|
|
632
|
+
id: "stringProperty",
|
|
633
|
+
description: "A string variable, with an optional default."
|
|
634
|
+
});
|
|
635
|
+
const BooleanPropertySchema = z.strictObject({
|
|
636
|
+
type: z.literal("boolean").meta({ description: "Declares a boolean variable." }),
|
|
637
|
+
default: z.boolean().optional().meta({ description: "Default boolean used when the activity omits this variable." })
|
|
638
|
+
}).meta({
|
|
639
|
+
id: "booleanProperty",
|
|
640
|
+
description: "A boolean variable, with an optional default."
|
|
641
|
+
});
|
|
642
|
+
const StringArrayPropertySchema = z.strictObject({
|
|
643
|
+
type: z.literal("array").meta({ description: "Declares an array-of-strings variable." }),
|
|
644
|
+
items: z.strictObject({ type: z.literal("string").meta({ description: "Array elements are strings." }) }).meta({ description: "The element type of the array (always string)." }),
|
|
645
|
+
default: z.array(z.string()).optional().meta({ description: "Default string array used when the activity omits this variable." })
|
|
646
|
+
}).meta({
|
|
647
|
+
id: "stringArrayProperty",
|
|
648
|
+
description: "An array-of-strings variable, with an optional default."
|
|
649
|
+
});
|
|
613
650
|
const PropertySchema = z.discriminatedUnion("type", [
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
}),
|
|
622
|
-
z.strictObject({
|
|
623
|
-
type: z.literal("array"),
|
|
624
|
-
items: z.strictObject({ type: z.literal("string") }),
|
|
625
|
-
default: z.array(z.string()).optional()
|
|
626
|
-
})
|
|
627
|
-
]);
|
|
651
|
+
StringPropertySchema,
|
|
652
|
+
BooleanPropertySchema,
|
|
653
|
+
StringArrayPropertySchema
|
|
654
|
+
]).meta({
|
|
655
|
+
id: "inputProperty",
|
|
656
|
+
description: "A declared variable: a string, a boolean, or an array of strings, each with an optional matching default."
|
|
657
|
+
});
|
|
628
658
|
const InputSchema = z.strictObject({
|
|
629
|
-
type: z.literal("object"),
|
|
630
|
-
required: z.array(z.string()).default([]),
|
|
631
|
-
properties: z.record(z.string(), PropertySchema).default({})
|
|
659
|
+
type: z.literal("object").meta({ description: "Always \"object\"." }),
|
|
660
|
+
required: z.array(z.string()).default([]).meta({ description: "Names of the variables that must be supplied." }),
|
|
661
|
+
properties: z.record(z.string(), PropertySchema).default({}).meta({ description: "The declared variables, keyed by variable name." })
|
|
662
|
+
}).meta({
|
|
663
|
+
id: "inputSchema",
|
|
664
|
+
description: "A constrained mini JSON-schema declaring the variables a fragment accepts."
|
|
632
665
|
});
|
|
633
666
|
const ClassificationSchema = z.strictObject({
|
|
634
|
-
type: z.string(),
|
|
635
|
-
override_allowed: z.boolean().optional()
|
|
667
|
+
type: z.string().meta({ description: "Classification label for this fragment." }),
|
|
668
|
+
override_allowed: z.boolean().optional().meta({ description: "Whether an activity may override this classification." })
|
|
669
|
+
}).meta({
|
|
670
|
+
id: "classification",
|
|
671
|
+
description: "Optional classification metadata for a fragment."
|
|
636
672
|
});
|
|
637
673
|
const FragmentSchema = z.strictObject({
|
|
638
|
-
id: z.string(),
|
|
639
|
-
version: z.number(),
|
|
640
|
-
priority: z.number(),
|
|
674
|
+
id: z.string().meta({ description: "Unique fragment id within this library." }),
|
|
675
|
+
version: z.number().meta({ description: "Fragment version number." }),
|
|
676
|
+
priority: z.number().meta({ description: "Assembly order. Lower priorities appear earlier." }),
|
|
641
677
|
input_schema: InputSchema.optional(),
|
|
642
678
|
classification: ClassificationSchema.optional(),
|
|
643
|
-
content: z.string()
|
|
679
|
+
content: z.string().meta({ description: "Prompt text as a Handlebars template." })
|
|
680
|
+
}).meta({
|
|
681
|
+
id: "fragment",
|
|
682
|
+
description: "One reusable, parameterized prompt fragment."
|
|
644
683
|
});
|
|
645
684
|
const FragmentFileSchema = z.strictObject({
|
|
646
|
-
id: z.string(),
|
|
647
|
-
fragments: z.array(FragmentSchema).min(1)
|
|
685
|
+
id: z.string().meta({ description: "Machine-readable id of this fragment library." }),
|
|
686
|
+
fragments: z.array(FragmentSchema).min(1).meta({ description: "The reusable fragments this library provides (at least one)." })
|
|
648
687
|
});
|
|
649
688
|
/** A supplied variable value mirrors what `input_schema` can declare. */
|
|
650
689
|
const VariableValueSchema = z.union([
|
|
651
690
|
z.string(),
|
|
652
691
|
z.boolean(),
|
|
653
692
|
z.array(z.string())
|
|
654
|
-
])
|
|
693
|
+
]).meta({
|
|
694
|
+
id: "variableValue",
|
|
695
|
+
description: "A literal variable value: a string, a boolean, or an array of strings."
|
|
696
|
+
});
|
|
655
697
|
const FragmentFileRefSchema = z.strictObject({
|
|
656
|
-
id: z.string(),
|
|
698
|
+
id: z.string().meta({ description: "Local alias for this library, referenced by each fragment's `file`." }),
|
|
657
699
|
url: FragmentUrlRef
|
|
700
|
+
}).meta({
|
|
701
|
+
id: "fragmentFileRef",
|
|
702
|
+
description: "A reference to a fragment library by alias + URL."
|
|
658
703
|
});
|
|
659
704
|
const FragmentRefSchema = z.strictObject({
|
|
660
|
-
file: z.string(),
|
|
661
|
-
id: z.string(),
|
|
662
|
-
variables: z.record(z.string(), VariableValueSchema).optional(),
|
|
663
|
-
bind: z.record(z.string(), z.string()).optional(),
|
|
664
|
-
required: z.boolean().optional()
|
|
665
|
-
})
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
* the clickable label, the `question` is the full text placed into the chat
|
|
669
|
-
* input on click. Tutors may define any number; the UI samples at most 5.
|
|
670
|
-
*/
|
|
671
|
-
const ExampleQuestionSchema = z.strictObject({
|
|
672
|
-
title: z.string().min(1),
|
|
673
|
-
question: z.string().min(1)
|
|
674
|
-
});
|
|
675
|
-
const TutorSchema = z.strictObject({
|
|
676
|
-
id: z.string(),
|
|
677
|
-
name: z.string(),
|
|
678
|
-
title: z.string().optional(),
|
|
679
|
-
description: z.string(),
|
|
680
|
-
exampleQuestions: z.array(ExampleQuestionSchema).optional(),
|
|
681
|
-
anonymous: z.boolean().optional(),
|
|
682
|
-
llm: z.strictObject({
|
|
683
|
-
model: z.string(),
|
|
684
|
-
provider: providerSchema,
|
|
685
|
-
imageInput: z.boolean().optional()
|
|
686
|
-
}),
|
|
687
|
-
prompt: z.strictObject({
|
|
688
|
-
fragment_files: z.array(FragmentFileRefSchema).default([]),
|
|
689
|
-
fragments: z.array(FragmentRefSchema).default([]),
|
|
690
|
-
tutor_instructions: z.string()
|
|
691
|
-
})
|
|
705
|
+
file: z.string().meta({ description: "The alias of the fragment library this fragment is drawn from." }),
|
|
706
|
+
id: z.string().meta({ description: "Fragment id inside the referenced library." }),
|
|
707
|
+
variables: z.record(z.string(), VariableValueSchema).optional().meta({ description: "Literal values passed into the fragment template." }),
|
|
708
|
+
bind: z.record(z.string(), z.string()).optional().meta({ description: "Accepted for compatibility but ignored by the current assembler." }),
|
|
709
|
+
required: z.boolean().optional().meta({ description: "Marker for important fragments. Accepted but not currently enforced." })
|
|
710
|
+
}).meta({
|
|
711
|
+
id: "fragmentRef",
|
|
712
|
+
description: "Selects one fragment from a referenced library."
|
|
692
713
|
});
|
|
693
714
|
//#endregion
|
|
694
|
-
//#region ../lib/
|
|
715
|
+
//#region ../lib/prompt-fragments/fragment.ts
|
|
695
716
|
/**
|
|
696
717
|
* A placeholder value for a declared input, shaped to its type so the template
|
|
697
718
|
* actually exercises it: a string renders, a boolean drives `{{#if}}`, an array
|
|
@@ -794,17 +815,17 @@ function resolveRelativeUrl(ref, baseUrl) {
|
|
|
794
815
|
return new URL(ref, baseUrl).href;
|
|
795
816
|
}
|
|
796
817
|
//#endregion
|
|
797
|
-
//#region ../lib/
|
|
818
|
+
//#region ../lib/prompt-fragments/load.ts
|
|
798
819
|
/**
|
|
799
820
|
* Resolve a fragment-file reference to an absolute URL. An absolute http(s) ref is used
|
|
800
|
-
* as-is; anything else is treated as relative to the
|
|
801
|
-
* drops the
|
|
821
|
+
* as-is; anything else is treated as relative to the activity URL — standard URL resolution
|
|
822
|
+
* drops the activity's filename and appends the relative path (so `my-fragments.yaml`
|
|
802
823
|
* next to `.../tutors/my-tutor.yaml` becomes `.../tutors/my-fragments.yaml`,
|
|
803
824
|
* and `./` / `../` segments work too). Throws if a relative ref is unparseable; the schema
|
|
804
825
|
* already guarantees the only inputs here are http(s) URLs or relative paths.
|
|
805
826
|
*/
|
|
806
|
-
function resolveFragmentUrl(ref,
|
|
807
|
-
return resolveRelativeUrl(ref,
|
|
827
|
+
function resolveFragmentUrl(ref, baseUrl) {
|
|
828
|
+
return resolveRelativeUrl(ref, baseUrl);
|
|
808
829
|
}
|
|
809
830
|
async function fetchText(url, fetchImpl) {
|
|
810
831
|
try {
|
|
@@ -829,23 +850,33 @@ async function fetchText(url, fetchImpl) {
|
|
|
829
850
|
}
|
|
830
851
|
const DEFAULT_ALLOWED_SCHEMES = ["http:", "https:"];
|
|
831
852
|
/**
|
|
832
|
-
* The
|
|
833
|
-
* fetch
|
|
834
|
-
*
|
|
835
|
-
*
|
|
836
|
-
* schemes identically.
|
|
853
|
+
* The SSRF scheme gate shared by the top-level activity load (`loadYaml`) and every
|
|
854
|
+
* fragment-file fetch (`assembleFragmentPrompt`): a URL is allowed only if its scheme
|
|
855
|
+
* is in `allowedSchemes` (default http(s); the CLI adds `file:` for on-disk validation).
|
|
856
|
+
* Returns the structured error to surface, or `null` when the scheme is allowed.
|
|
837
857
|
*/
|
|
838
|
-
|
|
839
|
-
const allowedSchemes = opts.allowedSchemes ?? DEFAULT_ALLOWED_SCHEMES;
|
|
858
|
+
function schemeGate(url, allowedSchemes) {
|
|
840
859
|
let scheme;
|
|
841
860
|
try {
|
|
842
861
|
scheme = new URL(url).protocol;
|
|
843
862
|
} catch {
|
|
844
863
|
scheme = "";
|
|
845
864
|
}
|
|
846
|
-
if (
|
|
865
|
+
if (allowedSchemes.includes(scheme)) return null;
|
|
866
|
+
return error("INVALID_URL", `Provide a valid ${allowedSchemes.map((s) => s.replace(/:$/, "")).join("/")} URL`, { url });
|
|
867
|
+
}
|
|
868
|
+
/**
|
|
869
|
+
* The shared front of every load: enforce the URL scheme allow-list (SSRF guard),
|
|
870
|
+
* fetch the document, and parse it as YAML — returning the parsed-but-not-yet-schema-
|
|
871
|
+
* validated value or the first structured error. Reused by the activity builders, the
|
|
872
|
+
* standalone fragment checker, and the quiz/writing/coding validators so they all gate
|
|
873
|
+
* schemes identically.
|
|
874
|
+
*/
|
|
875
|
+
async function loadYaml(url, fetchImpl, opts = {}) {
|
|
876
|
+
const schemeError = schemeGate(url, opts.allowedSchemes ?? DEFAULT_ALLOWED_SCHEMES);
|
|
877
|
+
if (schemeError) return {
|
|
847
878
|
ok: false,
|
|
848
|
-
error:
|
|
879
|
+
error: schemeError
|
|
849
880
|
};
|
|
850
881
|
const fetched = await fetchText(url, fetchImpl);
|
|
851
882
|
if (!fetched.ok) return {
|
|
@@ -862,25 +893,30 @@ async function loadYaml(url, fetchImpl, opts = {}) {
|
|
|
862
893
|
value: parsed.value
|
|
863
894
|
};
|
|
864
895
|
}
|
|
865
|
-
|
|
896
|
+
/**
|
|
897
|
+
* Resolve a document-level fragment block to a finished prompt string: fetch every
|
|
898
|
+
* declared fragment file in parallel (relative refs resolved against `baseUrl`),
|
|
899
|
+
* schema-validate each, (optionally) run the thorough whole-library check, check
|
|
900
|
+
* consistency, and assemble the priority-ordered plan followed by the optional
|
|
901
|
+
* `trailingInstructions`.
|
|
902
|
+
*
|
|
903
|
+
* The single seam every activity kind shares — the sole owner of the fetch → validate
|
|
904
|
+
* → consistency → assemble pipeline. Consumers concatenate their own frame only when
|
|
905
|
+
* they pass no `trailingInstructions` (a fragment-only preamble); tutors pass their
|
|
906
|
+
* `tutor_instructions` and get a complete prompt.
|
|
907
|
+
*/
|
|
908
|
+
async function assembleFragmentPrompt(block, baseUrl, fetchImpl, opts = {}, trailingInstructions) {
|
|
866
909
|
const warnings = [];
|
|
867
|
-
const
|
|
868
|
-
if (
|
|
869
|
-
ok:
|
|
870
|
-
|
|
871
|
-
warnings
|
|
872
|
-
};
|
|
873
|
-
const tutorValid = validate(tutorYaml.value, TutorSchema, "TUTOR_SCHEMA_ERROR", url);
|
|
874
|
-
if (!tutorValid.ok) return {
|
|
875
|
-
ok: false,
|
|
876
|
-
errors: [tutorValid.error],
|
|
910
|
+
const allowedSchemes = opts.allowedSchemes ?? DEFAULT_ALLOWED_SCHEMES;
|
|
911
|
+
if (block.fragment_files.length === 0 && block.fragments.length === 0) return {
|
|
912
|
+
ok: true,
|
|
913
|
+
prompt: assembleSystemPrompt([], trailingInstructions),
|
|
877
914
|
warnings
|
|
878
915
|
};
|
|
879
|
-
const
|
|
880
|
-
const settled = await Promise.all(tutor.prompt.fragment_files.map(async (ref) => {
|
|
916
|
+
const settled = await Promise.all(block.fragment_files.map(async (ref) => {
|
|
881
917
|
let fragmentUrl;
|
|
882
918
|
try {
|
|
883
|
-
fragmentUrl = resolveFragmentUrl(ref.url,
|
|
919
|
+
fragmentUrl = resolveFragmentUrl(ref.url, baseUrl);
|
|
884
920
|
} catch {
|
|
885
921
|
return {
|
|
886
922
|
alias: ref.id,
|
|
@@ -890,6 +926,14 @@ async function loadAndBuildTutorPrompt(url, fetchImpl, opts = {}) {
|
|
|
890
926
|
})
|
|
891
927
|
};
|
|
892
928
|
}
|
|
929
|
+
const schemeError = schemeGate(fragmentUrl, allowedSchemes);
|
|
930
|
+
if (schemeError) return {
|
|
931
|
+
alias: ref.id,
|
|
932
|
+
error: {
|
|
933
|
+
...schemeError,
|
|
934
|
+
fileAlias: ref.id
|
|
935
|
+
}
|
|
936
|
+
};
|
|
893
937
|
const fetched = await fetchText(fragmentUrl, fetchImpl);
|
|
894
938
|
if (!fetched.ok) return {
|
|
895
939
|
alias: ref.id,
|
|
@@ -936,7 +980,7 @@ async function loadAndBuildTutorPrompt(url, fetchImpl, opts = {}) {
|
|
|
936
980
|
libraryErrors.push(...checked.errors);
|
|
937
981
|
warnings.push(...checked.warnings);
|
|
938
982
|
}
|
|
939
|
-
const consistency = checkConsistency(
|
|
983
|
+
const consistency = checkConsistency(block, fragmentFilesByAlias);
|
|
940
984
|
warnings.push(...consistency.warnings);
|
|
941
985
|
const preAssemblyErrors = [...libraryErrors, ...consistency.errors];
|
|
942
986
|
if (preAssemblyErrors.length > 0) return {
|
|
@@ -947,14 +991,7 @@ async function loadAndBuildTutorPrompt(url, fetchImpl, opts = {}) {
|
|
|
947
991
|
try {
|
|
948
992
|
return {
|
|
949
993
|
ok: true,
|
|
950
|
-
prompt: assembleSystemPrompt(consistency.plan,
|
|
951
|
-
model: tutor.llm.model,
|
|
952
|
-
provider: tutor.llm.provider,
|
|
953
|
-
imageInput: tutor.llm.imageInput ?? true,
|
|
954
|
-
anonymous: tutor.anonymous ?? true,
|
|
955
|
-
title: tutor.title,
|
|
956
|
-
description: tutor.description,
|
|
957
|
-
exampleQuestions: tutor.exampleQuestions ?? [],
|
|
994
|
+
prompt: assembleSystemPrompt(consistency.plan, trailingInstructions),
|
|
958
995
|
warnings
|
|
959
996
|
};
|
|
960
997
|
} catch (e) {
|
|
@@ -968,9 +1005,9 @@ async function loadAndBuildTutorPrompt(url, fetchImpl, opts = {}) {
|
|
|
968
1005
|
/**
|
|
969
1006
|
* Validate a fragment FILE on its own (the `--kind fragment` / "Fragment library"
|
|
970
1007
|
* path): scheme-gate + fetch + parse, then the pure `checkFragmentFileValue`. A
|
|
971
|
-
* fragment library is self-contained, so — unlike
|
|
1008
|
+
* fragment library is self-contained, so — unlike an activity — there are no further
|
|
972
1009
|
* files to fetch. The caller already knows it asked for a fragment, so this returns
|
|
973
|
-
* a `FragmentCheckResult` directly (no
|
|
1010
|
+
* a `FragmentCheckResult` directly (no activity `BuildResult`, no kind discriminator).
|
|
974
1011
|
*/
|
|
975
1012
|
async function loadAndCheckFragmentFile(url, fetchImpl, opts = {}) {
|
|
976
1013
|
const yaml = await loadYaml(url, fetchImpl, opts);
|
|
@@ -981,33 +1018,32 @@ async function loadAndCheckFragmentFile(url, fetchImpl, opts = {}) {
|
|
|
981
1018
|
};
|
|
982
1019
|
return checkFragmentFileValue(yaml.value, url);
|
|
983
1020
|
}
|
|
1021
|
+
const providerSchema = z.enum(["SCCH", "Azure Foundry"]).default("SCCH").meta({ description: "The LLM provider serving the model. For Azure Foundry, model is the deployment name." });
|
|
984
1022
|
//#endregion
|
|
985
1023
|
//#region ../lib/coding-schema.ts
|
|
986
1024
|
const CodingYamlSchema = z.strictObject({
|
|
987
|
-
id: z.string().min(1),
|
|
988
|
-
name: z.string().optional(),
|
|
989
|
-
title: z.string().optional(),
|
|
1025
|
+
id: z.string().min(1).meta({ description: "Short machine-readable activity id, e.g. beginner-typescript." }),
|
|
1026
|
+
name: z.string().optional().meta({ description: "Optional human-readable label (not shown to the student)." }),
|
|
1027
|
+
title: z.string().optional().meta({ description: "Optional label shown to the student on the /<code> connection page." }),
|
|
990
1028
|
llm: z.strictObject({
|
|
991
|
-
model: z.string().min(1),
|
|
1029
|
+
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." }),
|
|
992
1030
|
provider: providerSchema
|
|
1031
|
+
}).meta({
|
|
1032
|
+
id: "llm",
|
|
1033
|
+
description: "The pinned model and provider that answer coding requests."
|
|
993
1034
|
}),
|
|
994
|
-
|
|
1035
|
+
fragment_files: z.array(FragmentFileRefSchema).default([]).meta({ description: "Optional fragment libraries this activity pulls shared prompt fragments from." }),
|
|
1036
|
+
fragments: z.array(FragmentRefSchema).default([]).meta({ description: "Optional fragments selected from fragment_files. Assembled in priority order and prepended AHEAD of instructions." }),
|
|
1037
|
+
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." })
|
|
995
1038
|
});
|
|
996
1039
|
//#endregion
|
|
997
1040
|
//#region ../lib/coding-validate.ts
|
|
998
1041
|
/**
|
|
999
|
-
*
|
|
1000
|
-
*
|
|
1001
|
-
*
|
|
1042
|
+
* Extract metadata from an already-schema-validated coding value. Split from
|
|
1043
|
+
* `checkCodingValue` so `loadAndCheckCoding` can reuse the single `validate` it already
|
|
1044
|
+
* ran (no second parse of the same document against the same schema).
|
|
1002
1045
|
*/
|
|
1003
|
-
function
|
|
1004
|
-
const valid = validate(parsed, CodingYamlSchema, "CODING_SCHEMA_ERROR", url);
|
|
1005
|
-
if (!valid.ok) return {
|
|
1006
|
-
ok: false,
|
|
1007
|
-
errors: [valid.error],
|
|
1008
|
-
warnings: []
|
|
1009
|
-
};
|
|
1010
|
-
const coding = valid.data;
|
|
1046
|
+
function checkCodingParsed(coding) {
|
|
1011
1047
|
return {
|
|
1012
1048
|
ok: true,
|
|
1013
1049
|
codingId: coding.id,
|
|
@@ -1029,16 +1065,46 @@ async function loadAndCheckCoding(url, fetchImpl, opts = {}) {
|
|
|
1029
1065
|
errors: [yaml.error],
|
|
1030
1066
|
warnings: []
|
|
1031
1067
|
};
|
|
1032
|
-
|
|
1068
|
+
const valid = validate(yaml.value, CodingYamlSchema, "CODING_SCHEMA_ERROR", url);
|
|
1069
|
+
if (!valid.ok) return {
|
|
1070
|
+
ok: false,
|
|
1071
|
+
errors: [valid.error],
|
|
1072
|
+
warnings: []
|
|
1073
|
+
};
|
|
1074
|
+
const checked = checkCodingParsed(valid.data);
|
|
1075
|
+
if (!checked.ok) return checked;
|
|
1076
|
+
const assembled = await assembleFragmentPrompt({
|
|
1077
|
+
fragment_files: valid.data.fragment_files,
|
|
1078
|
+
fragments: valid.data.fragments
|
|
1079
|
+
}, url, fetchImpl, {
|
|
1080
|
+
allowedSchemes: opts.allowedSchemes,
|
|
1081
|
+
validateLibraries: opts.validateLibraries ?? true
|
|
1082
|
+
});
|
|
1083
|
+
const warnings = [...checked.warnings, ...assembled.warnings];
|
|
1084
|
+
if (!assembled.ok) return {
|
|
1085
|
+
ok: false,
|
|
1086
|
+
errors: assembled.errors,
|
|
1087
|
+
warnings
|
|
1088
|
+
};
|
|
1089
|
+
return {
|
|
1090
|
+
...checked,
|
|
1091
|
+
warnings
|
|
1092
|
+
};
|
|
1033
1093
|
}
|
|
1034
1094
|
//#endregion
|
|
1035
1095
|
//#region ../lib/quiz-schema.ts
|
|
1036
1096
|
/** An optional content image attached to a question (carries no secret). */
|
|
1037
1097
|
const ImageRefSchema = z.strictObject({
|
|
1038
|
-
hosted: z.boolean().optional()
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1098
|
+
hosted: z.boolean().optional().meta({
|
|
1099
|
+
default: false,
|
|
1100
|
+
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."
|
|
1101
|
+
}),
|
|
1102
|
+
src: z.string().min(1).meta({ description: "The hosted image name (when hosted) or the image URL / relative path." }),
|
|
1103
|
+
alt: z.string().optional().meta({ description: "Accessible description shown if the image cannot be loaded." }),
|
|
1104
|
+
credit: z.string().optional().meta({ description: "Optional attribution (\"Content Credentials\") shown small below the image." })
|
|
1105
|
+
}).meta({
|
|
1106
|
+
id: "image",
|
|
1107
|
+
description: "An optional content image attached to a question."
|
|
1042
1108
|
});
|
|
1043
1109
|
/**
|
|
1044
1110
|
* One question. `id` keys the per-question stats (must be unique — see
|
|
@@ -1046,25 +1112,47 @@ const ImageRefSchema = z.strictObject({
|
|
|
1046
1112
|
* `evaluation` is the server-only grading prompt.
|
|
1047
1113
|
*/
|
|
1048
1114
|
const QuizQuestionSchema = z.strictObject({
|
|
1049
|
-
id: z.string().min(1),
|
|
1050
|
-
title: z.string().optional(),
|
|
1051
|
-
question: z.string().min(1),
|
|
1052
|
-
evaluation: z.string().min(1),
|
|
1053
|
-
image: ImageRefSchema.optional()
|
|
1115
|
+
id: z.string().min(1).meta({ description: "Stable question id, unique within the quiz (the per-question stats key)." }),
|
|
1116
|
+
title: z.string().optional().meta({ description: "Optional short label for the stats table and progress display." }),
|
|
1117
|
+
question: z.string().min(1).meta({ description: "The Markdown shown to the student." }),
|
|
1118
|
+
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." }),
|
|
1119
|
+
image: ImageRefSchema.optional(),
|
|
1120
|
+
imageInput: z.boolean().optional().meta({ description: "Overrides the quiz-level llm.imageInput for this question only (photo answers on/off)." })
|
|
1121
|
+
}).meta({
|
|
1122
|
+
id: "question",
|
|
1123
|
+
description: "One open-ended, LLM-graded quiz question."
|
|
1054
1124
|
});
|
|
1055
1125
|
const QuizYamlSchema = z.strictObject({
|
|
1056
|
-
id: z.string().min(1),
|
|
1057
|
-
name: z.string().optional(),
|
|
1058
|
-
title: z.string().optional(),
|
|
1059
|
-
description: z.string().optional(),
|
|
1060
|
-
anonymous: z.boolean().optional()
|
|
1061
|
-
|
|
1126
|
+
id: z.string().min(1).meta({ description: "Short machine-readable quiz id, e.g. countries-basics. Used as the per-quiz identity." }),
|
|
1127
|
+
name: z.string().optional().meta({ description: "Optional human-readable quiz title (used as a label)." }),
|
|
1128
|
+
title: z.string().optional().meta({ description: "Optional greeting shown to students on the welcome screen instead of the default message." }),
|
|
1129
|
+
description: z.string().optional().meta({ description: "Optional description shown to students below the welcome greeting (Markdown)." }),
|
|
1130
|
+
anonymous: z.boolean().optional().meta({
|
|
1131
|
+
default: true,
|
|
1132
|
+
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."
|
|
1133
|
+
}),
|
|
1134
|
+
shuffle: z.boolean().optional().meta({
|
|
1135
|
+
default: true,
|
|
1136
|
+
description: "Present questions in a random order per attempt. Set to false to keep the authored order."
|
|
1137
|
+
}),
|
|
1062
1138
|
llm: z.strictObject({
|
|
1063
|
-
model: z.string().min(1),
|
|
1064
|
-
provider: providerSchema
|
|
1139
|
+
model: z.string().min(1).meta({ description: "The model that grades answers and drives the per-question discussion chat." }),
|
|
1140
|
+
provider: providerSchema,
|
|
1141
|
+
imageInput: z.boolean().optional().meta({
|
|
1142
|
+
default: false,
|
|
1143
|
+
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."
|
|
1144
|
+
})
|
|
1145
|
+
}).meta({
|
|
1146
|
+
id: "llm",
|
|
1147
|
+
description: "The single model + provider that grades and discusses answers."
|
|
1148
|
+
}),
|
|
1149
|
+
discussion: z.strictObject({ instructions: z.string().min(1).meta({ description: "Optional guidance appended to the per-question follow-up discussion chat's system prompt." }) }).optional().meta({
|
|
1150
|
+
id: "discussion",
|
|
1151
|
+
description: "Optional guidance for the per-question follow-up discussion chat."
|
|
1065
1152
|
}),
|
|
1066
|
-
|
|
1067
|
-
|
|
1153
|
+
fragment_files: z.array(FragmentFileRefSchema).default([]).meta({ description: "Optional fragment libraries this quiz pulls shared prompt fragments from." }),
|
|
1154
|
+
fragments: z.array(FragmentRefSchema).default([]).meta({ description: "Optional fragments selected from fragment_files. Assembled in priority order and prepended to BOTH the grader prompt and the discussion chat's system prompt." }),
|
|
1155
|
+
questions: z.array(QuizQuestionSchema).min(1).meta({ description: "The quiz questions. Each is open-ended and graded by the LLM via its evaluation prompt." })
|
|
1068
1156
|
});
|
|
1069
1157
|
//#endregion
|
|
1070
1158
|
//#region ../lib/quiz-validate.ts
|
|
@@ -1084,18 +1172,11 @@ function findDuplicateQuestionIds(quiz) {
|
|
|
1084
1172
|
return errors;
|
|
1085
1173
|
}
|
|
1086
1174
|
/**
|
|
1087
|
-
*
|
|
1088
|
-
*
|
|
1089
|
-
*
|
|
1175
|
+
* Check an already-schema-validated quiz: unique question ids → metadata. Split from
|
|
1176
|
+
* `checkQuizValue` so `loadAndCheckQuiz` can reuse the single `validate` it already ran
|
|
1177
|
+
* (no second parse of the same document against the same schema).
|
|
1090
1178
|
*/
|
|
1091
|
-
function
|
|
1092
|
-
const valid = validate(parsed, QuizYamlSchema, "QUIZ_SCHEMA_ERROR", url);
|
|
1093
|
-
if (!valid.ok) return {
|
|
1094
|
-
ok: false,
|
|
1095
|
-
errors: [valid.error],
|
|
1096
|
-
warnings: []
|
|
1097
|
-
};
|
|
1098
|
-
const quiz = valid.data;
|
|
1179
|
+
function checkQuizParsed(quiz) {
|
|
1099
1180
|
const errors = findDuplicateQuestionIds(quiz);
|
|
1100
1181
|
if (errors.length > 0) return {
|
|
1101
1182
|
ok: false,
|
|
@@ -1114,9 +1195,11 @@ function checkQuizValue(parsed, url) {
|
|
|
1114
1195
|
};
|
|
1115
1196
|
}
|
|
1116
1197
|
/**
|
|
1117
|
-
* Validate a quiz FILE: scheme-gate + fetch + parse (shared `loadYaml`),
|
|
1118
|
-
*
|
|
1119
|
-
*
|
|
1198
|
+
* Validate a quiz FILE: scheme-gate + fetch + parse (shared `loadYaml`), the pure
|
|
1199
|
+
* `checkQuizValue`, then the document-level fragment block's authoring gate — fetch
|
|
1200
|
+
* every referenced library, run the THOROUGH whole-library check, consistency, and an
|
|
1201
|
+
* assembly dry-run (the strict-Handlebars backstop). The web app passes the default
|
|
1202
|
+
* http(s)-only schemes; the CLI adds `file:` so a local quiz YAML on disk validates too.
|
|
1120
1203
|
*/
|
|
1121
1204
|
async function loadAndCheckQuiz(url, fetchImpl, opts = {}) {
|
|
1122
1205
|
const yaml = await loadYaml(url, fetchImpl, opts);
|
|
@@ -1125,40 +1208,146 @@ async function loadAndCheckQuiz(url, fetchImpl, opts = {}) {
|
|
|
1125
1208
|
errors: [yaml.error],
|
|
1126
1209
|
warnings: []
|
|
1127
1210
|
};
|
|
1128
|
-
|
|
1211
|
+
const valid = validate(yaml.value, QuizYamlSchema, "QUIZ_SCHEMA_ERROR", url);
|
|
1212
|
+
if (!valid.ok) return {
|
|
1213
|
+
ok: false,
|
|
1214
|
+
errors: [valid.error],
|
|
1215
|
+
warnings: []
|
|
1216
|
+
};
|
|
1217
|
+
const checked = checkQuizParsed(valid.data);
|
|
1218
|
+
if (!checked.ok) return checked;
|
|
1219
|
+
const assembled = await assembleFragmentPrompt({
|
|
1220
|
+
fragment_files: valid.data.fragment_files,
|
|
1221
|
+
fragments: valid.data.fragments
|
|
1222
|
+
}, url, fetchImpl, {
|
|
1223
|
+
allowedSchemes: opts.allowedSchemes,
|
|
1224
|
+
validateLibraries: opts.validateLibraries ?? true
|
|
1225
|
+
});
|
|
1226
|
+
const warnings = [...checked.warnings, ...assembled.warnings];
|
|
1227
|
+
if (!assembled.ok) return {
|
|
1228
|
+
ok: false,
|
|
1229
|
+
errors: assembled.errors,
|
|
1230
|
+
warnings
|
|
1231
|
+
};
|
|
1232
|
+
return {
|
|
1233
|
+
...checked,
|
|
1234
|
+
warnings
|
|
1235
|
+
};
|
|
1236
|
+
}
|
|
1237
|
+
//#endregion
|
|
1238
|
+
//#region ../lib/tutors/schemas.ts
|
|
1239
|
+
/**
|
|
1240
|
+
* An example question offered to students on the welcome screen: the `title` is
|
|
1241
|
+
* the clickable label, the `question` is the full text placed into the chat
|
|
1242
|
+
* input on click. Tutors may define any number; the UI samples at most 5.
|
|
1243
|
+
*/
|
|
1244
|
+
const ExampleQuestionSchema = z.strictObject({
|
|
1245
|
+
title: z.string().min(1).meta({ description: "Short clickable label shown on the welcome screen." }),
|
|
1246
|
+
question: z.string().min(1).meta({ description: "Full question text. Shown as a tooltip and placed into the chat input on click." })
|
|
1247
|
+
}).meta({
|
|
1248
|
+
id: "exampleQuestion",
|
|
1249
|
+
description: "An example question shown on the welcome screen."
|
|
1250
|
+
});
|
|
1251
|
+
const TutorSchema = z.strictObject({
|
|
1252
|
+
id: z.string().meta({ description: "Short machine-readable tutor id, e.g. fractions-de." }),
|
|
1253
|
+
name: z.string().meta({ description: "Human-readable tutor title." }),
|
|
1254
|
+
title: z.string().optional().meta({ description: "Optional greeting shown to students on the empty chat instead of the default welcome message." }),
|
|
1255
|
+
description: z.string().meta({ description: "Short description of what this tutor does. Shown to students below the welcome greeting." }),
|
|
1256
|
+
exampleQuestions: z.array(ExampleQuestionSchema).optional().meta({ description: "Optional example questions shown to students below the description on the empty chat. Clicking one puts the question text into the chat input. At most 5 are shown; with more, a random 5 are picked per page load." }),
|
|
1257
|
+
anonymous: z.boolean().optional().meta({
|
|
1258
|
+
default: true,
|
|
1259
|
+
description: "Chats are anonymous by default: no link between the signed-in student and their chat is stored. Set to false to record which student each chat belongs to."
|
|
1260
|
+
}),
|
|
1261
|
+
llm: z.strictObject({
|
|
1262
|
+
model: z.string().meta({ description: "Model used for this tutor." }),
|
|
1263
|
+
provider: providerSchema,
|
|
1264
|
+
imageInput: z.boolean().optional().meta({
|
|
1265
|
+
default: true,
|
|
1266
|
+
description: "Image uploads are enabled by default. Set to false to hide the upload UI for text-only tutors or non-vision-capable models."
|
|
1267
|
+
})
|
|
1268
|
+
}).meta({
|
|
1269
|
+
id: "llm",
|
|
1270
|
+
description: "The model and provider that back this tutor."
|
|
1271
|
+
}),
|
|
1272
|
+
prompt: z.strictObject({
|
|
1273
|
+
fragment_files: z.array(FragmentFileRefSchema).default([]).meta({ description: "Optional fragment libraries used by this tutor." }),
|
|
1274
|
+
fragments: z.array(FragmentRefSchema).default([]).meta({ description: "Optional fragments selected from fragment_files." }),
|
|
1275
|
+
tutor_instructions: z.string().meta({ description: "Final tutor-specific system-prompt instructions. For single-file tutors, this can be the whole prompt." })
|
|
1276
|
+
}).meta({
|
|
1277
|
+
id: "prompt",
|
|
1278
|
+
description: "The assembled system prompt: fragments plus tutor instructions."
|
|
1279
|
+
})
|
|
1280
|
+
});
|
|
1281
|
+
//#endregion
|
|
1282
|
+
//#region ../lib/tutors/load.ts
|
|
1283
|
+
async function loadAndBuildTutorPrompt(url, fetchImpl, opts = {}) {
|
|
1284
|
+
const warnings = [];
|
|
1285
|
+
const tutorYaml = await loadYaml(url, fetchImpl, opts);
|
|
1286
|
+
if (!tutorYaml.ok) return {
|
|
1287
|
+
ok: false,
|
|
1288
|
+
errors: [tutorYaml.error],
|
|
1289
|
+
warnings
|
|
1290
|
+
};
|
|
1291
|
+
const tutorValid = validate(tutorYaml.value, TutorSchema, "TUTOR_SCHEMA_ERROR", url);
|
|
1292
|
+
if (!tutorValid.ok) return {
|
|
1293
|
+
ok: false,
|
|
1294
|
+
errors: [tutorValid.error],
|
|
1295
|
+
warnings
|
|
1296
|
+
};
|
|
1297
|
+
const tutor = tutorValid.data;
|
|
1298
|
+
const assembled = await assembleFragmentPrompt(tutor.prompt, url, fetchImpl, opts, tutor.prompt.tutor_instructions);
|
|
1299
|
+
warnings.push(...assembled.warnings);
|
|
1300
|
+
if (!assembled.ok) return {
|
|
1301
|
+
ok: false,
|
|
1302
|
+
errors: assembled.errors,
|
|
1303
|
+
warnings
|
|
1304
|
+
};
|
|
1305
|
+
return {
|
|
1306
|
+
ok: true,
|
|
1307
|
+
prompt: assembled.prompt,
|
|
1308
|
+
model: tutor.llm.model,
|
|
1309
|
+
provider: tutor.llm.provider,
|
|
1310
|
+
imageInput: tutor.llm.imageInput ?? true,
|
|
1311
|
+
anonymous: tutor.anonymous ?? true,
|
|
1312
|
+
title: tutor.title,
|
|
1313
|
+
description: tutor.description,
|
|
1314
|
+
exampleQuestions: tutor.exampleQuestions ?? [],
|
|
1315
|
+
warnings
|
|
1316
|
+
};
|
|
1129
1317
|
}
|
|
1130
1318
|
//#endregion
|
|
1131
1319
|
//#region ../lib/writing-schema.ts
|
|
1132
1320
|
const WritingYamlSchema = z.strictObject({
|
|
1133
|
-
id: z.string().min(1),
|
|
1134
|
-
name: z.string().optional(),
|
|
1135
|
-
title: z.string().optional(),
|
|
1136
|
-
description: z.string().optional(),
|
|
1137
|
-
anonymous: z.boolean().optional()
|
|
1321
|
+
id: z.string().min(1).meta({ description: "Short machine-readable activity id, e.g. human-animal-short-story." }),
|
|
1322
|
+
name: z.string().optional().meta({ description: "Optional human-readable title (used as a label)." }),
|
|
1323
|
+
title: z.string().optional().meta({ description: "Optional greeting shown to students on the welcome screen instead of the default message." }),
|
|
1324
|
+
description: z.string().optional().meta({ description: "Optional description shown to students below the welcome greeting (Markdown)." }),
|
|
1325
|
+
anonymous: z.boolean().optional().meta({
|
|
1326
|
+
default: false,
|
|
1327
|
+
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."
|
|
1328
|
+
}),
|
|
1138
1329
|
llm: z.strictObject({
|
|
1139
|
-
model: z.string().min(1),
|
|
1330
|
+
model: z.string().min(1).meta({ description: "The model that drives the feedback chat." }),
|
|
1140
1331
|
provider: providerSchema
|
|
1332
|
+
}).meta({
|
|
1333
|
+
id: "llm",
|
|
1334
|
+
description: "The model and provider that back the writing coach."
|
|
1141
1335
|
}),
|
|
1142
|
-
|
|
1143
|
-
|
|
1336
|
+
fragment_files: z.array(FragmentFileRefSchema).default([]).meta({ description: "Optional fragment libraries this activity pulls shared prompt fragments from." }),
|
|
1337
|
+
fragments: z.array(FragmentRefSchema).default([]).meta({ description: "Optional fragments selected from fragment_files. Assembled in priority order and prepended AHEAD of instructions." }),
|
|
1338
|
+
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." }),
|
|
1339
|
+
placeholder: z.string().optional().meta({ description: "Optional starter text prefilled into the editor. Empty for a blank page." })
|
|
1144
1340
|
});
|
|
1145
1341
|
//#endregion
|
|
1146
1342
|
//#region ../lib/writing-validate.ts
|
|
1147
1343
|
/** Writing DIVERGES from tutor/quiz: it defaults to attributed (`anonymous: false`). */
|
|
1148
1344
|
const DEFAULT_ANONYMOUS = false;
|
|
1149
1345
|
/**
|
|
1150
|
-
*
|
|
1151
|
-
*
|
|
1152
|
-
*
|
|
1346
|
+
* Extract metadata from an already-schema-validated writing value. Split from
|
|
1347
|
+
* `checkWritingValue` so `loadAndCheckWriting` can reuse the single `validate` it already
|
|
1348
|
+
* ran (no second parse of the same document against the same schema).
|
|
1153
1349
|
*/
|
|
1154
|
-
function
|
|
1155
|
-
const valid = validate(parsed, WritingYamlSchema, "WRITING_SCHEMA_ERROR", url);
|
|
1156
|
-
if (!valid.ok) return {
|
|
1157
|
-
ok: false,
|
|
1158
|
-
errors: [valid.error],
|
|
1159
|
-
warnings: []
|
|
1160
|
-
};
|
|
1161
|
-
const writing = valid.data;
|
|
1350
|
+
function checkWritingParsed(writing) {
|
|
1162
1351
|
return {
|
|
1163
1352
|
ok: true,
|
|
1164
1353
|
writingId: writing.id,
|
|
@@ -1181,7 +1370,31 @@ async function loadAndCheckWriting(url, fetchImpl, opts = {}) {
|
|
|
1181
1370
|
errors: [yaml.error],
|
|
1182
1371
|
warnings: []
|
|
1183
1372
|
};
|
|
1184
|
-
|
|
1373
|
+
const valid = validate(yaml.value, WritingYamlSchema, "WRITING_SCHEMA_ERROR", url);
|
|
1374
|
+
if (!valid.ok) return {
|
|
1375
|
+
ok: false,
|
|
1376
|
+
errors: [valid.error],
|
|
1377
|
+
warnings: []
|
|
1378
|
+
};
|
|
1379
|
+
const checked = checkWritingParsed(valid.data);
|
|
1380
|
+
if (!checked.ok) return checked;
|
|
1381
|
+
const assembled = await assembleFragmentPrompt({
|
|
1382
|
+
fragment_files: valid.data.fragment_files,
|
|
1383
|
+
fragments: valid.data.fragments
|
|
1384
|
+
}, url, fetchImpl, {
|
|
1385
|
+
allowedSchemes: opts.allowedSchemes,
|
|
1386
|
+
validateLibraries: opts.validateLibraries ?? true
|
|
1387
|
+
});
|
|
1388
|
+
const warnings = [...checked.warnings, ...assembled.warnings];
|
|
1389
|
+
if (!assembled.ok) return {
|
|
1390
|
+
ok: false,
|
|
1391
|
+
errors: assembled.errors,
|
|
1392
|
+
warnings
|
|
1393
|
+
};
|
|
1394
|
+
return {
|
|
1395
|
+
...checked,
|
|
1396
|
+
warnings
|
|
1397
|
+
};
|
|
1185
1398
|
}
|
|
1186
1399
|
//#endregion
|
|
1187
1400
|
//#region src/file-fetcher.ts
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@novedu/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.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": {
|
|
@@ -25,13 +25,13 @@
|
|
|
25
25
|
"prepublishOnly": "npm run build"
|
|
26
26
|
},
|
|
27
27
|
"dependencies": {
|
|
28
|
-
"@azure/msal-node": "^5.
|
|
28
|
+
"@azure/msal-node": "^5.4.0",
|
|
29
29
|
"commander": "^15.0.0",
|
|
30
30
|
"handlebars": "^4.7.9",
|
|
31
31
|
"yaml": "^2.9.0",
|
|
32
32
|
"zod": "^4.4.3"
|
|
33
33
|
},
|
|
34
34
|
"devDependencies": {
|
|
35
|
-
"tsdown": "^0.22.
|
|
35
|
+
"tsdown": "^0.22.4"
|
|
36
36
|
}
|
|
37
37
|
}
|