@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.
Files changed (2) hide show
  1. package/dist/main.js +250 -125
  2. 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/tutors/assemble.ts
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 tutor-specific
368
- * instructions last (they carry no priority, so "after everything" is the only
369
- * deterministic position). May throw if a template references a missing variable.
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, tutor) {
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(tutor.prompt.tutor_instructions.trimEnd());
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/tutors/errors.ts
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/tutors/consistency.ts
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(tutor, fragmentFilesByAlias) {
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 tutor.prompt.fragment_files) aliasCounts.set(ref.id, (aliasCounts.get(ref.id) ?? 0) + 1);
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 tutor.prompt.fragments) {
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/tutors/fetcher.ts
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/tutors/parse.ts
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/tutors/schemas.ts
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 tutor YAML's own URL. We reject any *other* absolute
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 tutor omits the variable, the default is used; supplying
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/tutors/fragment.ts
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/tutors/load.ts
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 tutor URL — standard URL resolution
801
- * drops the tutor's filename and appends the relative path (so `my-fragments.yaml`
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, tutorUrl) {
807
- return resolveRelativeUrl(ref, tutorUrl);
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 shared front of every load: enforce the URL scheme allow-list (SSRF guard),
833
- * fetch the document, and parse it as YAML returning the parsed-but-not-yet-schema-
834
- * validated value or the first structured error. Reused by the tutor builder, the
835
- * standalone fragment checker, and the quiz/writing validators so they all gate
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
- async function loadYaml(url, fetchImpl, opts = {}) {
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 (!allowedSchemes.includes(scheme)) return {
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: error("INVALID_URL", `Provide a valid ${allowedSchemes.map((s) => s.replace(/:$/, "")).join("/")} URL`, { url })
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
- async function loadAndBuildTutorPrompt(url, fetchImpl, opts = {}) {
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 tutorYaml = await loadYaml(url, fetchImpl, opts);
868
- if (!tutorYaml.ok) return {
869
- ok: false,
870
- errors: [tutorYaml.error],
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 tutor = tutorValid.data;
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, 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(tutor, fragmentFilesByAlias);
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, tutor),
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 a tutor — there are no further
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 tutor `BuildResult`, no kind discriminator).
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
- * Validate an already-parsed coding value against its schema, then extract metadata.
1000
- * Pure (the parsed value is passed in); `loadAndCheckCoding` wraps it with fetch +
1001
- * YAML parse.
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 checkCodingValue(parsed, url) {
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
- return checkCodingValue(yaml.value, url);
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
- * Validate an already-parsed quiz value: schema → unique question ids → metadata.
1088
- * Pure (the parsed value is passed in); `loadAndCheckQuiz` wraps it with fetch +
1089
- * YAML parse.
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 checkQuizValue(parsed, url) {
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`), then the
1118
- * pure `checkQuizValue`. The web app passes the default http(s)-only schemes; the
1119
- * CLI adds `file:` so a local quiz YAML on disk validates too.
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
- return checkQuizValue(yaml.value, url);
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
- * Validate an already-parsed writing value against its schema, then extract metadata.
1151
- * Pure (the parsed value is passed in); `loadAndCheckWriting` wraps it with fetch +
1152
- * YAML parse.
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 checkWritingValue(parsed, url) {
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
- return checkWritingValue(yaml.value, url);
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.8.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.3.1",
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.3"
35
+ "tsdown": "^0.22.4"
36
36
  }
37
37
  }