@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.
- package/dist/main.js +139 -6
- 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).
|
|
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
|
-
*
|
|
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 =
|
|
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`,
|
|
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)
|
|
1627
|
-
*
|
|
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.
|
|
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": {
|