@novedu/cli 0.8.0 → 0.9.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 +250 -125
- 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,12 +602,11 @@ 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.
|
|
@@ -607,7 +615,7 @@ const FragmentUrlRef = z.string().min(1).refine((u) => !/^[a-z][a-z0-9+.-]*:/i.t
|
|
|
607
615
|
/**
|
|
608
616
|
* A declared property is a string, a boolean, or an array of strings. Each may carry an
|
|
609
617
|
* optional `default`, typed to match its `type` (a string default on a boolean property
|
|
610
|
-
* is a schema error). When the
|
|
618
|
+
* is a schema error). When the activity omits the variable, the default is used; supplying
|
|
611
619
|
* a value overrides it. See `consistency.ts` for where defaults are injected.
|
|
612
620
|
*/
|
|
613
621
|
const PropertySchema = z.discriminatedUnion("type", [
|
|
@@ -663,35 +671,8 @@ const FragmentRefSchema = z.strictObject({
|
|
|
663
671
|
bind: z.record(z.string(), z.string()).optional(),
|
|
664
672
|
required: z.boolean().optional()
|
|
665
673
|
});
|
|
666
|
-
/**
|
|
667
|
-
* An example question offered to students on the welcome screen: the `title` is
|
|
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
|
-
})
|
|
692
|
-
});
|
|
693
674
|
//#endregion
|
|
694
|
-
//#region ../lib/
|
|
675
|
+
//#region ../lib/prompt-fragments/fragment.ts
|
|
695
676
|
/**
|
|
696
677
|
* A placeholder value for a declared input, shaped to its type so the template
|
|
697
678
|
* actually exercises it: a string renders, a boolean drives `{{#if}}`, an array
|
|
@@ -794,17 +775,17 @@ function resolveRelativeUrl(ref, baseUrl) {
|
|
|
794
775
|
return new URL(ref, baseUrl).href;
|
|
795
776
|
}
|
|
796
777
|
//#endregion
|
|
797
|
-
//#region ../lib/
|
|
778
|
+
//#region ../lib/prompt-fragments/load.ts
|
|
798
779
|
/**
|
|
799
780
|
* 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
|
|
781
|
+
* as-is; anything else is treated as relative to the activity URL — standard URL resolution
|
|
782
|
+
* drops the activity's filename and appends the relative path (so `my-fragments.yaml`
|
|
802
783
|
* next to `.../tutors/my-tutor.yaml` becomes `.../tutors/my-fragments.yaml`,
|
|
803
784
|
* and `./` / `../` segments work too). Throws if a relative ref is unparseable; the schema
|
|
804
785
|
* already guarantees the only inputs here are http(s) URLs or relative paths.
|
|
805
786
|
*/
|
|
806
|
-
function resolveFragmentUrl(ref,
|
|
807
|
-
return resolveRelativeUrl(ref,
|
|
787
|
+
function resolveFragmentUrl(ref, baseUrl) {
|
|
788
|
+
return resolveRelativeUrl(ref, baseUrl);
|
|
808
789
|
}
|
|
809
790
|
async function fetchText(url, fetchImpl) {
|
|
810
791
|
try {
|
|
@@ -829,23 +810,33 @@ async function fetchText(url, fetchImpl) {
|
|
|
829
810
|
}
|
|
830
811
|
const DEFAULT_ALLOWED_SCHEMES = ["http:", "https:"];
|
|
831
812
|
/**
|
|
832
|
-
* The
|
|
833
|
-
* fetch
|
|
834
|
-
*
|
|
835
|
-
*
|
|
836
|
-
* schemes identically.
|
|
813
|
+
* The SSRF scheme gate shared by the top-level activity load (`loadYaml`) and every
|
|
814
|
+
* fragment-file fetch (`assembleFragmentPrompt`): a URL is allowed only if its scheme
|
|
815
|
+
* is in `allowedSchemes` (default http(s); the CLI adds `file:` for on-disk validation).
|
|
816
|
+
* Returns the structured error to surface, or `null` when the scheme is allowed.
|
|
837
817
|
*/
|
|
838
|
-
|
|
839
|
-
const allowedSchemes = opts.allowedSchemes ?? DEFAULT_ALLOWED_SCHEMES;
|
|
818
|
+
function schemeGate(url, allowedSchemes) {
|
|
840
819
|
let scheme;
|
|
841
820
|
try {
|
|
842
821
|
scheme = new URL(url).protocol;
|
|
843
822
|
} catch {
|
|
844
823
|
scheme = "";
|
|
845
824
|
}
|
|
846
|
-
if (
|
|
825
|
+
if (allowedSchemes.includes(scheme)) return null;
|
|
826
|
+
return error("INVALID_URL", `Provide a valid ${allowedSchemes.map((s) => s.replace(/:$/, "")).join("/")} URL`, { url });
|
|
827
|
+
}
|
|
828
|
+
/**
|
|
829
|
+
* The shared front of every load: enforce the URL scheme allow-list (SSRF guard),
|
|
830
|
+
* fetch the document, and parse it as YAML — returning the parsed-but-not-yet-schema-
|
|
831
|
+
* validated value or the first structured error. Reused by the activity builders, the
|
|
832
|
+
* standalone fragment checker, and the quiz/writing/coding validators so they all gate
|
|
833
|
+
* schemes identically.
|
|
834
|
+
*/
|
|
835
|
+
async function loadYaml(url, fetchImpl, opts = {}) {
|
|
836
|
+
const schemeError = schemeGate(url, opts.allowedSchemes ?? DEFAULT_ALLOWED_SCHEMES);
|
|
837
|
+
if (schemeError) return {
|
|
847
838
|
ok: false,
|
|
848
|
-
error:
|
|
839
|
+
error: schemeError
|
|
849
840
|
};
|
|
850
841
|
const fetched = await fetchText(url, fetchImpl);
|
|
851
842
|
if (!fetched.ok) return {
|
|
@@ -862,25 +853,30 @@ async function loadYaml(url, fetchImpl, opts = {}) {
|
|
|
862
853
|
value: parsed.value
|
|
863
854
|
};
|
|
864
855
|
}
|
|
865
|
-
|
|
856
|
+
/**
|
|
857
|
+
* Resolve a document-level fragment block to a finished prompt string: fetch every
|
|
858
|
+
* declared fragment file in parallel (relative refs resolved against `baseUrl`),
|
|
859
|
+
* schema-validate each, (optionally) run the thorough whole-library check, check
|
|
860
|
+
* consistency, and assemble the priority-ordered plan followed by the optional
|
|
861
|
+
* `trailingInstructions`.
|
|
862
|
+
*
|
|
863
|
+
* The single seam every activity kind shares — the sole owner of the fetch → validate
|
|
864
|
+
* → consistency → assemble pipeline. Consumers concatenate their own frame only when
|
|
865
|
+
* they pass no `trailingInstructions` (a fragment-only preamble); tutors pass their
|
|
866
|
+
* `tutor_instructions` and get a complete prompt.
|
|
867
|
+
*/
|
|
868
|
+
async function assembleFragmentPrompt(block, baseUrl, fetchImpl, opts = {}, trailingInstructions) {
|
|
866
869
|
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],
|
|
870
|
+
const allowedSchemes = opts.allowedSchemes ?? DEFAULT_ALLOWED_SCHEMES;
|
|
871
|
+
if (block.fragment_files.length === 0 && block.fragments.length === 0) return {
|
|
872
|
+
ok: true,
|
|
873
|
+
prompt: assembleSystemPrompt([], trailingInstructions),
|
|
877
874
|
warnings
|
|
878
875
|
};
|
|
879
|
-
const
|
|
880
|
-
const settled = await Promise.all(tutor.prompt.fragment_files.map(async (ref) => {
|
|
876
|
+
const settled = await Promise.all(block.fragment_files.map(async (ref) => {
|
|
881
877
|
let fragmentUrl;
|
|
882
878
|
try {
|
|
883
|
-
fragmentUrl = resolveFragmentUrl(ref.url,
|
|
879
|
+
fragmentUrl = resolveFragmentUrl(ref.url, baseUrl);
|
|
884
880
|
} catch {
|
|
885
881
|
return {
|
|
886
882
|
alias: ref.id,
|
|
@@ -890,6 +886,14 @@ async function loadAndBuildTutorPrompt(url, fetchImpl, opts = {}) {
|
|
|
890
886
|
})
|
|
891
887
|
};
|
|
892
888
|
}
|
|
889
|
+
const schemeError = schemeGate(fragmentUrl, allowedSchemes);
|
|
890
|
+
if (schemeError) return {
|
|
891
|
+
alias: ref.id,
|
|
892
|
+
error: {
|
|
893
|
+
...schemeError,
|
|
894
|
+
fileAlias: ref.id
|
|
895
|
+
}
|
|
896
|
+
};
|
|
893
897
|
const fetched = await fetchText(fragmentUrl, fetchImpl);
|
|
894
898
|
if (!fetched.ok) return {
|
|
895
899
|
alias: ref.id,
|
|
@@ -936,7 +940,7 @@ async function loadAndBuildTutorPrompt(url, fetchImpl, opts = {}) {
|
|
|
936
940
|
libraryErrors.push(...checked.errors);
|
|
937
941
|
warnings.push(...checked.warnings);
|
|
938
942
|
}
|
|
939
|
-
const consistency = checkConsistency(
|
|
943
|
+
const consistency = checkConsistency(block, fragmentFilesByAlias);
|
|
940
944
|
warnings.push(...consistency.warnings);
|
|
941
945
|
const preAssemblyErrors = [...libraryErrors, ...consistency.errors];
|
|
942
946
|
if (preAssemblyErrors.length > 0) return {
|
|
@@ -947,14 +951,7 @@ async function loadAndBuildTutorPrompt(url, fetchImpl, opts = {}) {
|
|
|
947
951
|
try {
|
|
948
952
|
return {
|
|
949
953
|
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 ?? [],
|
|
954
|
+
prompt: assembleSystemPrompt(consistency.plan, trailingInstructions),
|
|
958
955
|
warnings
|
|
959
956
|
};
|
|
960
957
|
} catch (e) {
|
|
@@ -968,9 +965,9 @@ async function loadAndBuildTutorPrompt(url, fetchImpl, opts = {}) {
|
|
|
968
965
|
/**
|
|
969
966
|
* Validate a fragment FILE on its own (the `--kind fragment` / "Fragment library"
|
|
970
967
|
* path): scheme-gate + fetch + parse, then the pure `checkFragmentFileValue`. A
|
|
971
|
-
* fragment library is self-contained, so — unlike
|
|
968
|
+
* fragment library is self-contained, so — unlike an activity — there are no further
|
|
972
969
|
* files to fetch. The caller already knows it asked for a fragment, so this returns
|
|
973
|
-
* a `FragmentCheckResult` directly (no
|
|
970
|
+
* a `FragmentCheckResult` directly (no activity `BuildResult`, no kind discriminator).
|
|
974
971
|
*/
|
|
975
972
|
async function loadAndCheckFragmentFile(url, fetchImpl, opts = {}) {
|
|
976
973
|
const yaml = await loadYaml(url, fetchImpl, opts);
|
|
@@ -981,6 +978,7 @@ async function loadAndCheckFragmentFile(url, fetchImpl, opts = {}) {
|
|
|
981
978
|
};
|
|
982
979
|
return checkFragmentFileValue(yaml.value, url);
|
|
983
980
|
}
|
|
981
|
+
const providerSchema = z.enum(["SCCH", "Azure Foundry"]).default("SCCH");
|
|
984
982
|
//#endregion
|
|
985
983
|
//#region ../lib/coding-schema.ts
|
|
986
984
|
const CodingYamlSchema = z.strictObject({
|
|
@@ -991,23 +989,18 @@ const CodingYamlSchema = z.strictObject({
|
|
|
991
989
|
model: z.string().min(1),
|
|
992
990
|
provider: providerSchema
|
|
993
991
|
}),
|
|
992
|
+
fragment_files: z.array(FragmentFileRefSchema).default([]),
|
|
993
|
+
fragments: z.array(FragmentRefSchema).default([]),
|
|
994
994
|
instructions: z.string().min(1)
|
|
995
995
|
});
|
|
996
996
|
//#endregion
|
|
997
997
|
//#region ../lib/coding-validate.ts
|
|
998
998
|
/**
|
|
999
|
-
*
|
|
1000
|
-
*
|
|
1001
|
-
*
|
|
999
|
+
* Extract metadata from an already-schema-validated coding value. Split from
|
|
1000
|
+
* `checkCodingValue` so `loadAndCheckCoding` can reuse the single `validate` it already
|
|
1001
|
+
* ran (no second parse of the same document against the same schema).
|
|
1002
1002
|
*/
|
|
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;
|
|
1003
|
+
function checkCodingParsed(coding) {
|
|
1011
1004
|
return {
|
|
1012
1005
|
ok: true,
|
|
1013
1006
|
codingId: coding.id,
|
|
@@ -1029,7 +1022,31 @@ async function loadAndCheckCoding(url, fetchImpl, opts = {}) {
|
|
|
1029
1022
|
errors: [yaml.error],
|
|
1030
1023
|
warnings: []
|
|
1031
1024
|
};
|
|
1032
|
-
|
|
1025
|
+
const valid = validate(yaml.value, CodingYamlSchema, "CODING_SCHEMA_ERROR", url);
|
|
1026
|
+
if (!valid.ok) return {
|
|
1027
|
+
ok: false,
|
|
1028
|
+
errors: [valid.error],
|
|
1029
|
+
warnings: []
|
|
1030
|
+
};
|
|
1031
|
+
const checked = checkCodingParsed(valid.data);
|
|
1032
|
+
if (!checked.ok) return checked;
|
|
1033
|
+
const assembled = await assembleFragmentPrompt({
|
|
1034
|
+
fragment_files: valid.data.fragment_files,
|
|
1035
|
+
fragments: valid.data.fragments
|
|
1036
|
+
}, url, fetchImpl, {
|
|
1037
|
+
allowedSchemes: opts.allowedSchemes,
|
|
1038
|
+
validateLibraries: opts.validateLibraries ?? true
|
|
1039
|
+
});
|
|
1040
|
+
const warnings = [...checked.warnings, ...assembled.warnings];
|
|
1041
|
+
if (!assembled.ok) return {
|
|
1042
|
+
ok: false,
|
|
1043
|
+
errors: assembled.errors,
|
|
1044
|
+
warnings
|
|
1045
|
+
};
|
|
1046
|
+
return {
|
|
1047
|
+
...checked,
|
|
1048
|
+
warnings
|
|
1049
|
+
};
|
|
1033
1050
|
}
|
|
1034
1051
|
//#endregion
|
|
1035
1052
|
//#region ../lib/quiz-schema.ts
|
|
@@ -1050,7 +1067,8 @@ const QuizQuestionSchema = z.strictObject({
|
|
|
1050
1067
|
title: z.string().optional(),
|
|
1051
1068
|
question: z.string().min(1),
|
|
1052
1069
|
evaluation: z.string().min(1),
|
|
1053
|
-
image: ImageRefSchema.optional()
|
|
1070
|
+
image: ImageRefSchema.optional(),
|
|
1071
|
+
imageInput: z.boolean().optional()
|
|
1054
1072
|
});
|
|
1055
1073
|
const QuizYamlSchema = z.strictObject({
|
|
1056
1074
|
id: z.string().min(1),
|
|
@@ -1061,9 +1079,12 @@ const QuizYamlSchema = z.strictObject({
|
|
|
1061
1079
|
shuffle: z.boolean().optional(),
|
|
1062
1080
|
llm: z.strictObject({
|
|
1063
1081
|
model: z.string().min(1),
|
|
1064
|
-
provider: providerSchema
|
|
1082
|
+
provider: providerSchema,
|
|
1083
|
+
imageInput: z.boolean().optional()
|
|
1065
1084
|
}),
|
|
1066
1085
|
discussion: z.strictObject({ instructions: z.string().min(1) }).optional(),
|
|
1086
|
+
fragment_files: z.array(FragmentFileRefSchema).default([]),
|
|
1087
|
+
fragments: z.array(FragmentRefSchema).default([]),
|
|
1067
1088
|
questions: z.array(QuizQuestionSchema).min(1)
|
|
1068
1089
|
});
|
|
1069
1090
|
//#endregion
|
|
@@ -1084,18 +1105,11 @@ function findDuplicateQuestionIds(quiz) {
|
|
|
1084
1105
|
return errors;
|
|
1085
1106
|
}
|
|
1086
1107
|
/**
|
|
1087
|
-
*
|
|
1088
|
-
*
|
|
1089
|
-
*
|
|
1108
|
+
* Check an already-schema-validated quiz: unique question ids → metadata. Split from
|
|
1109
|
+
* `checkQuizValue` so `loadAndCheckQuiz` can reuse the single `validate` it already ran
|
|
1110
|
+
* (no second parse of the same document against the same schema).
|
|
1090
1111
|
*/
|
|
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;
|
|
1112
|
+
function checkQuizParsed(quiz) {
|
|
1099
1113
|
const errors = findDuplicateQuestionIds(quiz);
|
|
1100
1114
|
if (errors.length > 0) return {
|
|
1101
1115
|
ok: false,
|
|
@@ -1114,9 +1128,11 @@ function checkQuizValue(parsed, url) {
|
|
|
1114
1128
|
};
|
|
1115
1129
|
}
|
|
1116
1130
|
/**
|
|
1117
|
-
* Validate a quiz FILE: scheme-gate + fetch + parse (shared `loadYaml`),
|
|
1118
|
-
*
|
|
1119
|
-
*
|
|
1131
|
+
* Validate a quiz FILE: scheme-gate + fetch + parse (shared `loadYaml`), the pure
|
|
1132
|
+
* `checkQuizValue`, then the document-level fragment block's authoring gate — fetch
|
|
1133
|
+
* every referenced library, run the THOROUGH whole-library check, consistency, and an
|
|
1134
|
+
* assembly dry-run (the strict-Handlebars backstop). The web app passes the default
|
|
1135
|
+
* http(s)-only schemes; the CLI adds `file:` so a local quiz YAML on disk validates too.
|
|
1120
1136
|
*/
|
|
1121
1137
|
async function loadAndCheckQuiz(url, fetchImpl, opts = {}) {
|
|
1122
1138
|
const yaml = await loadYaml(url, fetchImpl, opts);
|
|
@@ -1125,7 +1141,97 @@ async function loadAndCheckQuiz(url, fetchImpl, opts = {}) {
|
|
|
1125
1141
|
errors: [yaml.error],
|
|
1126
1142
|
warnings: []
|
|
1127
1143
|
};
|
|
1128
|
-
|
|
1144
|
+
const valid = validate(yaml.value, QuizYamlSchema, "QUIZ_SCHEMA_ERROR", url);
|
|
1145
|
+
if (!valid.ok) return {
|
|
1146
|
+
ok: false,
|
|
1147
|
+
errors: [valid.error],
|
|
1148
|
+
warnings: []
|
|
1149
|
+
};
|
|
1150
|
+
const checked = checkQuizParsed(valid.data);
|
|
1151
|
+
if (!checked.ok) return checked;
|
|
1152
|
+
const assembled = await assembleFragmentPrompt({
|
|
1153
|
+
fragment_files: valid.data.fragment_files,
|
|
1154
|
+
fragments: valid.data.fragments
|
|
1155
|
+
}, url, fetchImpl, {
|
|
1156
|
+
allowedSchemes: opts.allowedSchemes,
|
|
1157
|
+
validateLibraries: opts.validateLibraries ?? true
|
|
1158
|
+
});
|
|
1159
|
+
const warnings = [...checked.warnings, ...assembled.warnings];
|
|
1160
|
+
if (!assembled.ok) return {
|
|
1161
|
+
ok: false,
|
|
1162
|
+
errors: assembled.errors,
|
|
1163
|
+
warnings
|
|
1164
|
+
};
|
|
1165
|
+
return {
|
|
1166
|
+
...checked,
|
|
1167
|
+
warnings
|
|
1168
|
+
};
|
|
1169
|
+
}
|
|
1170
|
+
//#endregion
|
|
1171
|
+
//#region ../lib/tutors/schemas.ts
|
|
1172
|
+
/**
|
|
1173
|
+
* An example question offered to students on the welcome screen: the `title` is
|
|
1174
|
+
* the clickable label, the `question` is the full text placed into the chat
|
|
1175
|
+
* input on click. Tutors may define any number; the UI samples at most 5.
|
|
1176
|
+
*/
|
|
1177
|
+
const ExampleQuestionSchema = z.strictObject({
|
|
1178
|
+
title: z.string().min(1),
|
|
1179
|
+
question: z.string().min(1)
|
|
1180
|
+
});
|
|
1181
|
+
const TutorSchema = z.strictObject({
|
|
1182
|
+
id: z.string(),
|
|
1183
|
+
name: z.string(),
|
|
1184
|
+
title: z.string().optional(),
|
|
1185
|
+
description: z.string(),
|
|
1186
|
+
exampleQuestions: z.array(ExampleQuestionSchema).optional(),
|
|
1187
|
+
anonymous: z.boolean().optional(),
|
|
1188
|
+
llm: z.strictObject({
|
|
1189
|
+
model: z.string(),
|
|
1190
|
+
provider: providerSchema,
|
|
1191
|
+
imageInput: z.boolean().optional()
|
|
1192
|
+
}),
|
|
1193
|
+
prompt: z.strictObject({
|
|
1194
|
+
fragment_files: z.array(FragmentFileRefSchema).default([]),
|
|
1195
|
+
fragments: z.array(FragmentRefSchema).default([]),
|
|
1196
|
+
tutor_instructions: z.string()
|
|
1197
|
+
})
|
|
1198
|
+
});
|
|
1199
|
+
//#endregion
|
|
1200
|
+
//#region ../lib/tutors/load.ts
|
|
1201
|
+
async function loadAndBuildTutorPrompt(url, fetchImpl, opts = {}) {
|
|
1202
|
+
const warnings = [];
|
|
1203
|
+
const tutorYaml = await loadYaml(url, fetchImpl, opts);
|
|
1204
|
+
if (!tutorYaml.ok) return {
|
|
1205
|
+
ok: false,
|
|
1206
|
+
errors: [tutorYaml.error],
|
|
1207
|
+
warnings
|
|
1208
|
+
};
|
|
1209
|
+
const tutorValid = validate(tutorYaml.value, TutorSchema, "TUTOR_SCHEMA_ERROR", url);
|
|
1210
|
+
if (!tutorValid.ok) return {
|
|
1211
|
+
ok: false,
|
|
1212
|
+
errors: [tutorValid.error],
|
|
1213
|
+
warnings
|
|
1214
|
+
};
|
|
1215
|
+
const tutor = tutorValid.data;
|
|
1216
|
+
const assembled = await assembleFragmentPrompt(tutor.prompt, url, fetchImpl, opts, tutor.prompt.tutor_instructions);
|
|
1217
|
+
warnings.push(...assembled.warnings);
|
|
1218
|
+
if (!assembled.ok) return {
|
|
1219
|
+
ok: false,
|
|
1220
|
+
errors: assembled.errors,
|
|
1221
|
+
warnings
|
|
1222
|
+
};
|
|
1223
|
+
return {
|
|
1224
|
+
ok: true,
|
|
1225
|
+
prompt: assembled.prompt,
|
|
1226
|
+
model: tutor.llm.model,
|
|
1227
|
+
provider: tutor.llm.provider,
|
|
1228
|
+
imageInput: tutor.llm.imageInput ?? true,
|
|
1229
|
+
anonymous: tutor.anonymous ?? true,
|
|
1230
|
+
title: tutor.title,
|
|
1231
|
+
description: tutor.description,
|
|
1232
|
+
exampleQuestions: tutor.exampleQuestions ?? [],
|
|
1233
|
+
warnings
|
|
1234
|
+
};
|
|
1129
1235
|
}
|
|
1130
1236
|
//#endregion
|
|
1131
1237
|
//#region ../lib/writing-schema.ts
|
|
@@ -1139,6 +1245,8 @@ const WritingYamlSchema = z.strictObject({
|
|
|
1139
1245
|
model: z.string().min(1),
|
|
1140
1246
|
provider: providerSchema
|
|
1141
1247
|
}),
|
|
1248
|
+
fragment_files: z.array(FragmentFileRefSchema).default([]),
|
|
1249
|
+
fragments: z.array(FragmentRefSchema).default([]),
|
|
1142
1250
|
instructions: z.string().min(1),
|
|
1143
1251
|
placeholder: z.string().optional()
|
|
1144
1252
|
});
|
|
@@ -1147,18 +1255,11 @@ const WritingYamlSchema = z.strictObject({
|
|
|
1147
1255
|
/** Writing DIVERGES from tutor/quiz: it defaults to attributed (`anonymous: false`). */
|
|
1148
1256
|
const DEFAULT_ANONYMOUS = false;
|
|
1149
1257
|
/**
|
|
1150
|
-
*
|
|
1151
|
-
*
|
|
1152
|
-
*
|
|
1258
|
+
* Extract metadata from an already-schema-validated writing value. Split from
|
|
1259
|
+
* `checkWritingValue` so `loadAndCheckWriting` can reuse the single `validate` it already
|
|
1260
|
+
* ran (no second parse of the same document against the same schema).
|
|
1153
1261
|
*/
|
|
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;
|
|
1262
|
+
function checkWritingParsed(writing) {
|
|
1162
1263
|
return {
|
|
1163
1264
|
ok: true,
|
|
1164
1265
|
writingId: writing.id,
|
|
@@ -1181,7 +1282,31 @@ async function loadAndCheckWriting(url, fetchImpl, opts = {}) {
|
|
|
1181
1282
|
errors: [yaml.error],
|
|
1182
1283
|
warnings: []
|
|
1183
1284
|
};
|
|
1184
|
-
|
|
1285
|
+
const valid = validate(yaml.value, WritingYamlSchema, "WRITING_SCHEMA_ERROR", url);
|
|
1286
|
+
if (!valid.ok) return {
|
|
1287
|
+
ok: false,
|
|
1288
|
+
errors: [valid.error],
|
|
1289
|
+
warnings: []
|
|
1290
|
+
};
|
|
1291
|
+
const checked = checkWritingParsed(valid.data);
|
|
1292
|
+
if (!checked.ok) return checked;
|
|
1293
|
+
const assembled = await assembleFragmentPrompt({
|
|
1294
|
+
fragment_files: valid.data.fragment_files,
|
|
1295
|
+
fragments: valid.data.fragments
|
|
1296
|
+
}, url, fetchImpl, {
|
|
1297
|
+
allowedSchemes: opts.allowedSchemes,
|
|
1298
|
+
validateLibraries: opts.validateLibraries ?? true
|
|
1299
|
+
});
|
|
1300
|
+
const warnings = [...checked.warnings, ...assembled.warnings];
|
|
1301
|
+
if (!assembled.ok) return {
|
|
1302
|
+
ok: false,
|
|
1303
|
+
errors: assembled.errors,
|
|
1304
|
+
warnings
|
|
1305
|
+
};
|
|
1306
|
+
return {
|
|
1307
|
+
...checked,
|
|
1308
|
+
warnings
|
|
1309
|
+
};
|
|
1185
1310
|
}
|
|
1186
1311
|
//#endregion
|
|
1187
1312
|
//#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.9.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
|
}
|