@novedu/cli 0.14.0 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/dist/main.js +139 -6
  2. package/package.json +1 -1
package/dist/main.js CHANGED
@@ -1517,6 +1517,25 @@ async function loadAndCheckCoding(url, fetchImpl, opts = {}) {
1517
1517
  }
1518
1518
  //#endregion
1519
1519
  //#region ../lib/quiz-schema.ts
1520
+ /**
1521
+ * A live quiz include: alias + URL, mirroring `FragmentFileRefSchema` (same URL
1522
+ * contract). The alias prefixes every imported question id as `"<alias>/<id>"`, so
1523
+ * on top of the no-dot rule it may not contain a `/` either. Aliases live in their
1524
+ * OWN namespace (they never appear in `{{…}}` markers — only in question ids).
1525
+ */
1526
+ const QuizFileRefSchema = z.strictObject({
1527
+ id: z.string().regex(/^[^./]+$/, { message: "Alias must not contain a dot or a slash" }).meta({
1528
+ pattern: "^[^./]+$",
1529
+ description: "Local alias for this included quiz. Prefixes every imported question id as \"<alias>/<id>\", so it may not contain a dot or a slash."
1530
+ }),
1531
+ url: FragmentFileRefSchema.shape.url.meta({
1532
+ pattern: "^(https?://|(?![A-Za-z][A-Za-z0-9+.-]*:).+)$",
1533
+ description: "HTTP(S) URL or relative path to the included quiz file."
1534
+ })
1535
+ }).meta({
1536
+ id: "quizFileRef",
1537
+ description: "A reference to another quiz file whose questions are included live."
1538
+ });
1520
1539
  /** An optional content image attached to a question (carries no secret). */
1521
1540
  const ImageRefSchema = z.strictObject({
1522
1541
  hosted: z.boolean().optional().meta({
@@ -1559,6 +1578,8 @@ const QuizYamlSchema = z.strictObject({
1559
1578
  default: true,
1560
1579
  description: "Present questions in a random order per attempt. Set to false to keep the authored order."
1561
1580
  }),
1581
+ question_count: z.number().int().min(1).optional().meta({ description: "How many questions one attempt asks (default: every question exactly once). May exceed the pool size — then questions repeat (drill mode). With shuffle off, fewer than the pool means the first N in authored order." }),
1582
+ quiz_files: z.array(QuizFileRefSchema).default([]).meta({ description: "Other quiz files whose questions are ALL included live into this quiz (a compound/final quiz). One level deep — an included quiz may not itself declare quiz_files." }),
1562
1583
  llm: z.strictObject({
1563
1584
  model: z.string().min(1).meta({ description: "The model that grades answers and drives the per-question discussion chat." }),
1564
1585
  provider: providerSchema,
@@ -1577,7 +1598,7 @@ const QuizYamlSchema = z.strictObject({
1577
1598
  fragment_files: z.array(FragmentFileRefSchema).default([]).meta({ description: "Optional fragment libraries this quiz pulls shared prompt fragments from." }),
1578
1599
  text_files: z.array(TextFileRefSchema).default([]).meta({ description: "Optional plain-text files (markdown / source) embedded verbatim into instructions via {{file \"alias\"}} markers." }),
1579
1600
  instructions: z.string().optional().meta({ description: "Optional quiz-level preamble prepended to BOTH the grader prompt and the discussion chat. When any fragment_files or text_files are declared, place fragments inline with {{fragment \"alias.id\" …}} and embed text files with {{file \"alias\"}} (optionally {{file \"alias\" from=10 to=40}} for a line range; escape a literal {{ as \\{{)." }),
1580
- questions: z.array(QuizQuestionSchema).min(1).meta({ description: "The quiz questions. Each is open-ended and graded by the LLM via its evaluation prompt." })
1601
+ questions: z.array(QuizQuestionSchema).default([]).meta({ description: "The quiz questions. Each is open-ended and graded by the LLM via its evaluation prompt. May be omitted when quiz_files supplies the questions." })
1581
1602
  });
1582
1603
  //#endregion
1583
1604
  //#region ../lib/quiz-validate.ts
@@ -1597,12 +1618,39 @@ function findDuplicateQuestionIds(quiz) {
1597
1618
  return errors;
1598
1619
  }
1599
1620
  /**
1600
- * Check an already-schema-validated quiz: unique question ids metadata. Split from
1621
+ * Own question ids containing `/` reserved as the namespace delimiter for
1622
+ * questions imported via `quiz_files` (`"<alias>/<id>"`), so an own id can never
1623
+ * collide with (or masquerade as) an imported one.
1624
+ */
1625
+ function findReservedSlashIds(quiz) {
1626
+ return quiz.questions.filter((question) => question.id.includes("/")).map((question) => error("QUIZ_QUESTION_ID_RESERVED_SLASH", `Question id "${question.id}" contains "/" — reserved for questions imported via quiz_files`, { questionId: question.id }));
1627
+ }
1628
+ /** Include aliases declared on more than one `quiz_files` entry. */
1629
+ function findDuplicateIncludeAliases(quiz) {
1630
+ const errors = [];
1631
+ const seen = /* @__PURE__ */ new Set();
1632
+ for (const ref of quiz.quiz_files) {
1633
+ if (seen.has(ref.id)) {
1634
+ errors.push(error("DUPLICATE_QUIZ_INCLUDE_ALIAS", `Included-quiz alias "${ref.id}" is declared more than once`, { fileAlias: ref.id }));
1635
+ continue;
1636
+ }
1637
+ seen.add(ref.id);
1638
+ }
1639
+ return errors;
1640
+ }
1641
+ /**
1642
+ * Check an already-schema-validated quiz: unique question ids, no reserved `/` ids,
1643
+ * unique include aliases, and a non-empty (potential) pool → metadata. Split from
1601
1644
  * `checkQuizValue` so `loadAndCheckQuiz` can reuse the single `validate` it already ran
1602
1645
  * (no second parse of the same document against the same schema).
1603
1646
  */
1604
1647
  function checkQuizParsed(quiz) {
1605
- const errors = findDuplicateQuestionIds(quiz);
1648
+ const errors = [
1649
+ ...findDuplicateQuestionIds(quiz),
1650
+ ...findReservedSlashIds(quiz),
1651
+ ...findDuplicateIncludeAliases(quiz)
1652
+ ];
1653
+ if (quiz.questions.length === 0 && quiz.quiz_files.length === 0) errors.push(error("QUIZ_NO_QUESTIONS", "This quiz has no questions and no quiz_files includes"));
1606
1654
  if (errors.length > 0) return {
1607
1655
  ok: false,
1608
1656
  errors,
@@ -1619,12 +1667,83 @@ function checkQuizParsed(quiz) {
1619
1667
  warnings: []
1620
1668
  };
1621
1669
  }
1670
+ /** Wrap an include's nested failures into ONE error carrying the alias + URL. */
1671
+ function includeUnreadable(alias, url, nested) {
1672
+ return error("QUIZ_INCLUDE_UNREADABLE", `Included quiz "${alias}" is not usable: ${nested.map((e) => e.message).join("; ")}`, url === void 0 ? { fileAlias: alias } : {
1673
+ fileAlias: alias,
1674
+ url
1675
+ });
1676
+ }
1677
+ /**
1678
+ * Deep-check ONE `quiz_files` include: resolve + fetch + parse + the FULL strict
1679
+ * quiz check (schema, consistency passes, its own fragment-block authoring gate),
1680
+ * plus the one-level rule (`QUIZ_INCLUDE_NESTED`). Every other failure is wrapped
1681
+ * as `QUIZ_INCLUDE_UNREADABLE` with the alias + resolved URL.
1682
+ */
1683
+ async function checkInclude(ref, baseUrl, fetchImpl, opts) {
1684
+ let includeUrl;
1685
+ try {
1686
+ includeUrl = resolveFragmentUrl(ref.url, baseUrl);
1687
+ } catch {
1688
+ return {
1689
+ ok: false,
1690
+ errors: [includeUnreadable(ref.id, void 0, [error("INVALID_URL", `Invalid include URL: ${ref.url}`)])],
1691
+ warnings: []
1692
+ };
1693
+ }
1694
+ const yaml = await loadYaml(includeUrl, fetchImpl, opts);
1695
+ if (!yaml.ok) return {
1696
+ ok: false,
1697
+ errors: [includeUnreadable(ref.id, includeUrl, [yaml.error])],
1698
+ warnings: []
1699
+ };
1700
+ const valid = validate(yaml.value, QuizYamlSchema, "QUIZ_SCHEMA_ERROR", includeUrl);
1701
+ if (!valid.ok) return {
1702
+ ok: false,
1703
+ errors: [includeUnreadable(ref.id, includeUrl, [valid.error])],
1704
+ warnings: []
1705
+ };
1706
+ if (valid.data.quiz_files.length > 0) return {
1707
+ ok: false,
1708
+ errors: [error("QUIZ_INCLUDE_NESTED", `Included quiz "${ref.id}" itself declares quiz_files — includes are one level deep`, {
1709
+ fileAlias: ref.id,
1710
+ url: includeUrl
1711
+ })],
1712
+ warnings: []
1713
+ };
1714
+ const checked = checkQuizParsed(valid.data);
1715
+ if (!checked.ok) return {
1716
+ ok: false,
1717
+ errors: [includeUnreadable(ref.id, includeUrl, checked.errors)],
1718
+ warnings: checked.warnings
1719
+ };
1720
+ const assembled = await assembleFragmentPrompt({
1721
+ fragment_files: valid.data.fragment_files,
1722
+ text_files: valid.data.text_files
1723
+ }, includeUrl, fetchImpl, {
1724
+ allowedSchemes: opts.allowedSchemes,
1725
+ validateLibraries: opts.validateLibraries ?? true
1726
+ }, valid.data.instructions ?? "");
1727
+ if (!assembled.ok) return {
1728
+ ok: false,
1729
+ errors: [includeUnreadable(ref.id, includeUrl, assembled.errors)],
1730
+ warnings: assembled.warnings
1731
+ };
1732
+ return {
1733
+ ok: true,
1734
+ questionCount: valid.data.questions.length,
1735
+ warnings: assembled.warnings
1736
+ };
1737
+ }
1622
1738
  /**
1623
1739
  * Validate a quiz FILE: scheme-gate + fetch + parse (shared `loadYaml`), the pure
1624
- * `checkQuizValue`, then the document-level fragment block's authoring gate — fetch
1740
+ * `checkQuizValue`, the document-level fragment block's authoring gate — fetch
1625
1741
  * every referenced library, run the THOROUGH whole-library check, consistency, and an
1626
- * assembly dry-run (the strict-Handlebars backstop). The web app passes the default
1627
- * http(s)-only schemes; the CLI adds `file:` so a local quiz YAML on disk validates too.
1742
+ * assembly dry-run (the strict-Handlebars backstop) and a DEEP check of every
1743
+ * `quiz_files` include. On success `questionCount` is the RESOLVED pool size
1744
+ * (own + imported), so the `/files` save UI and code-create metadata reflect the
1745
+ * real exam size. The web app passes the default http(s)-only schemes; the CLI adds
1746
+ * `file:` so a local quiz YAML on disk validates too.
1628
1747
  */
1629
1748
  async function loadAndCheckQuiz(url, fetchImpl, opts = {}) {
1630
1749
  const yaml = await loadYaml(url, fetchImpl, opts);
@@ -1654,8 +1773,22 @@ async function loadAndCheckQuiz(url, fetchImpl, opts = {}) {
1654
1773
  errors: assembled.errors,
1655
1774
  warnings
1656
1775
  };
1776
+ const includes = await Promise.all(valid.data.quiz_files.map((ref) => checkInclude(ref, url, fetchImpl, opts)));
1777
+ const includeErrors = [];
1778
+ let importedCount = 0;
1779
+ for (const include of includes) {
1780
+ warnings.push(...include.warnings);
1781
+ if (include.ok) importedCount += include.questionCount;
1782
+ else includeErrors.push(...include.errors);
1783
+ }
1784
+ if (includeErrors.length > 0) return {
1785
+ ok: false,
1786
+ errors: includeErrors,
1787
+ warnings
1788
+ };
1657
1789
  return {
1658
1790
  ...checked,
1791
+ questionCount: checked.questionCount + importedCount,
1659
1792
  warnings
1660
1793
  };
1661
1794
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@novedu/cli",
3
- "version": "0.14.0",
3
+ "version": "0.15.0",
4
4
  "description": "Command-line companion for the Novedu chat app. Validates tutor, fragment, quiz, writing and coding YAML definitions; signs in with Entra ID and manages codes and app-hosted files over the app's API.",
5
5
  "type": "module",
6
6
  "repository": {