@novedu/cli 0.12.0 → 0.13.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/README.md +119 -25
- package/dist/main.js +321 -171
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,43 +1,46 @@
|
|
|
1
1
|
# @novedu/cli
|
|
2
2
|
|
|
3
3
|
Command-line companion for the Novedu chat app (installed command: `novedu-cli`;
|
|
4
|
-
requires Node >= 20). It
|
|
5
|
-
**fragment libraries**, **quizzes**, **writing activities**, and **coding
|
|
6
|
-
activities** — and signs in to Microsoft Entra ID (`login` / `logout` / `whoami`)
|
|
7
|
-
to call the app's protected APIs; more commands will follow. Validating a tutor
|
|
8
|
-
also fully validates every fragment library it references; pass `--kind` to
|
|
9
|
-
validate any other kind on its own.
|
|
4
|
+
requires Node >= 20). It covers two jobs:
|
|
10
5
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
6
|
+
- **Validate activity YAML** — tutors, fragment libraries, quizzes, writing
|
|
7
|
+
activities, and coding activities — with the app's exact validation pipeline,
|
|
8
|
+
offline and without signing in.
|
|
9
|
+
- **Manage the app as a teacher** — sign in with Microsoft Entra ID, then mint
|
|
10
|
+
activity codes, upload app-hosted YAML files, and triage student reports,
|
|
11
|
+
straight from the terminal (or from a coding agent, see below).
|
|
14
12
|
|
|
15
|
-
|
|
13
|
+
No install needed:
|
|
16
14
|
|
|
17
15
|
```bash
|
|
18
|
-
|
|
16
|
+
npx @novedu/cli --help
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Validating activities: `validate`
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
# Validate a local tutor (relative fragment_files resolve against the file's location)
|
|
19
23
|
npx @novedu/cli validate ./activities/examples/sorting-algorithms/sorting-tutor.yaml
|
|
20
24
|
|
|
21
|
-
# Validate a published
|
|
25
|
+
# Validate a published activity by URL
|
|
22
26
|
npx @novedu/cli validate https://raw.githubusercontent.com/Teaching-HTL-Leonding/novedu-chat-mvp/refs/heads/main/activities/examples/sorting-algorithms/sorting-tutor.yaml
|
|
23
27
|
|
|
24
|
-
#
|
|
28
|
+
# Other kinds: fragment library, quiz, writing activity, coding activity
|
|
25
29
|
npx @novedu/cli validate ./activities/examples/shared/general-fragments.yaml --kind fragment
|
|
26
|
-
|
|
27
|
-
# Validate a quiz, a writing activity, or a coding activity
|
|
28
30
|
npx @novedu/cli validate ./activities/examples/sorting-algorithms/sorting-quiz.yaml --kind quiz
|
|
29
|
-
npx @novedu/cli validate ./activities/examples/review-writing/restaurant-review-letter.yaml --kind writing
|
|
30
|
-
npx @novedu/cli validate ./activities/examples/sorting-algorithms/sorting-visualizer.yaml --kind coding
|
|
31
31
|
|
|
32
32
|
# Machine-readable output (the raw validation result)
|
|
33
|
-
npx @novedu/cli validate ./
|
|
33
|
+
npx @novedu/cli validate ./my-quiz.yaml --kind quiz --json
|
|
34
34
|
```
|
|
35
35
|
|
|
36
|
-
`--kind` accepts `tutor` (default), `fragment`, `quiz`, `writing`, or
|
|
37
|
-
is caller-declared, not auto-detected.
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
36
|
+
- `--kind` accepts `tutor` (default), `fragment`, `quiz`, `writing`, or
|
|
37
|
+
`coding`; it is caller-declared, not auto-detected.
|
|
38
|
+
- The CLI reuses the app's exact validation pipeline (`lib/prompt-fragments`,
|
|
39
|
+
`lib/tutors`, `lib/quiz-validate`, `lib/writing-validate`,
|
|
40
|
+
`lib/coding-validate`), so an activity that passes here is the same one the
|
|
41
|
+
app accepts — no separate, drifting rules. Validating a tutor also fully
|
|
42
|
+
validates every fragment library it references.
|
|
43
|
+
- Exit code `0` = valid, `1` = errors found — usable as a pre-commit / CI gate.
|
|
41
44
|
|
|
42
45
|
## Authentication
|
|
43
46
|
|
|
@@ -61,6 +64,9 @@ npx @novedu/cli logout # remove the cached credentials from this machine
|
|
|
61
64
|
file `0600`). The cache holds a refresh token, so after the one sign-in every
|
|
62
65
|
command runs non-interactively; treat the file like a credential. `logout`
|
|
63
66
|
is purely local — issued tokens expire on their own (~1 h).
|
|
67
|
+
- `whoami` proves the full round-trip and shows your display name, user id, and
|
|
68
|
+
whether the account is a teacher (`Teacher: yes/no`) — the management
|
|
69
|
+
commands below need a teacher account.
|
|
64
70
|
- The server defaults to the production app; override per command with
|
|
65
71
|
`--server <url>` or the `NOVEDU_SERVER` env var (e.g.
|
|
66
72
|
`http://localhost:3000` for development). Other deployments of the app can
|
|
@@ -69,6 +75,94 @@ npx @novedu/cli logout # remove the cached credentials from this machine
|
|
|
69
75
|
- Not signed in (or the cached token expired for good)? Commands exit 1 with
|
|
70
76
|
`Not signed in — run "novedu-cli login".`
|
|
71
77
|
|
|
78
|
+
## Managing codes & files (teacher account required)
|
|
79
|
+
|
|
80
|
+
The `codes` and `files` groups call the app's API as the signed-in teacher. The
|
|
81
|
+
server runs the identical validation pipeline as the web forms and is
|
|
82
|
+
authoritative — the CLI sends your input as-is and relays the server's answer.
|
|
83
|
+
|
|
84
|
+
```
|
|
85
|
+
codes create --module <tutor|quiz|writing|coding> --file <url>
|
|
86
|
+
[--start <iso>] [--end <iso>] [--note <text>]
|
|
87
|
+
[--llm-provider <p> --llm-model <m>]
|
|
88
|
+
codes list [--search <q>] [--module <m>] [--all]
|
|
89
|
+
files upload <name> [--kind <tutor|fragment|quiz|writing|coding>]
|
|
90
|
+
(--file <path> | reads stdin)
|
|
91
|
+
files list [--search <q>] [--all]
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
- **Output is JSON only.** Success: the API's objects verbatim on stdout, exit
|
|
95
|
+
0 (pipe into `jq`). Failure: JSON on stderr — `{ message }` or
|
|
96
|
+
`{ errors: [...] }` with the full structured validation detail — and exit 1.
|
|
97
|
+
- `codes create` mints a shareable code for an activity YAML at a public URL
|
|
98
|
+
(or an app-hosted `…/api/files/<name>` URL); the YAML is validated
|
|
99
|
+
server-side before the code is stored, and the response includes the
|
|
100
|
+
shareable `url`. `--start`/`--end` must be ISO 8601 **with an explicit
|
|
101
|
+
offset or `Z`** (e.g. `2026-07-07T08:00:00Z`); the
|
|
102
|
+
`--llm-provider`/`--llm-model` override pair is both-or-nothing.
|
|
103
|
+
- `files upload <name>` is an **upsert**: creating a new file requires
|
|
104
|
+
`--kind`; an existing file's kind is frozen at create time (a contradicting
|
|
105
|
+
`--kind` fails with 409). The YAML comes from `--file <path>` or stdin.
|
|
106
|
+
Every hosted file is public at the `url` the list returns — no download
|
|
107
|
+
command needed.
|
|
108
|
+
- Both `list` commands default to **only your own** codes/files (like the web
|
|
109
|
+
lists); `--all` widens to every teacher's, `--search` is a contains-filter.
|
|
110
|
+
|
|
111
|
+
Example — host a quiz and share it:
|
|
112
|
+
|
|
113
|
+
```bash
|
|
114
|
+
npx @novedu/cli files upload sorting-quiz --kind quiz --file ./sorting-quiz.yaml
|
|
115
|
+
# { "name": "sorting-quiz", "kind": "quiz", "url": "https://…/api/files/sorting-quiz", "action": "created" }
|
|
116
|
+
|
|
117
|
+
npx @novedu/cli codes create --module quiz \
|
|
118
|
+
--file https://…/api/files/sorting-quiz \
|
|
119
|
+
--start 2026-07-07T08:00:00Z --note "3A Monday"
|
|
120
|
+
# { "code": "…", "url": "https://…/<code>", … } — hand the url to students
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
## Triaging student reports (teacher account required)
|
|
124
|
+
|
|
125
|
+
Students can flag an AI interaction — a chat or a graded quiz answer — with a
|
|
126
|
+
reaction and an optional note. The `reports` group reads and resolves those
|
|
127
|
+
flags; same JSON stdout/stderr contract as above.
|
|
128
|
+
|
|
129
|
+
```
|
|
130
|
+
reports list [--status <open|resolved|all>] [--reaction <good|omg|bad|holysh>]
|
|
131
|
+
[--search <q>] [--all]
|
|
132
|
+
reports show <id>
|
|
133
|
+
reports resolve <id...>
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
- `reports list` defaults to **open reports on your own codes** (like the web
|
|
137
|
+
inbox), most urgent first; `--all` widens to every teacher's codes.
|
|
138
|
+
- `reports show <id>` prints one report in full. A **chat** report embeds the
|
|
139
|
+
conversation transcript as a `messages` array; a **quiz-answer** report
|
|
140
|
+
already carries its question / answer / feedback snapshot inline.
|
|
141
|
+
- `reports resolve <id...>` resolves one or more reports in a single request;
|
|
142
|
+
unknown or already-resolved ids are silently ignored.
|
|
143
|
+
- The CLI deliberately cannot file, reopen, or delete a report — those stay in
|
|
144
|
+
the web `/reports` inbox.
|
|
145
|
+
|
|
146
|
+
The typical loop for turning a report into a better activity:
|
|
147
|
+
|
|
148
|
+
```bash
|
|
149
|
+
npx @novedu/cli reports list --reaction holysh # find the urgent flags
|
|
150
|
+
npx @novedu/cli reports show 3f2c… # read the report + transcript
|
|
151
|
+
# fix the activity YAML, then check it offline:
|
|
152
|
+
npx @novedu/cli validate ./sorting-quiz.yaml --kind quiz
|
|
153
|
+
npx @novedu/cli files upload sorting-quiz --file ./sorting-quiz.yaml
|
|
154
|
+
npx @novedu/cli reports resolve 3f2c… # existing codes already serve the fix
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
## Using the CLI from a coding agent
|
|
158
|
+
|
|
159
|
+
The app repo ships a Claude Code skill that teaches coding agents the full CLI
|
|
160
|
+
workflow — validation, the sign-in hand-off, code/file management, and the
|
|
161
|
+
report-triage loop:
|
|
162
|
+
[`.claude/skills/novedu-tutor-cli/SKILL.md`](https://github.com/Teaching-HTL-Leonding/novedu-chat-mvp/blob/main/.claude/skills/novedu-tutor-cli/SKILL.md)
|
|
163
|
+
(mirrored at `.agents/skills/novedu-tutor-cli/`). Agents working inside that
|
|
164
|
+
repo pick it up automatically.
|
|
165
|
+
|
|
72
166
|
## Development
|
|
73
167
|
|
|
74
168
|
The CLI lives in the app repo as an npm workspace.
|
|
@@ -79,5 +173,5 @@ npm run cli:build # bundle to cli/dist via ts
|
|
|
79
173
|
npm run test:cli # build + integration tests (local & live URLs)
|
|
80
174
|
```
|
|
81
175
|
|
|
82
|
-
The fast in-process unit
|
|
83
|
-
|
|
176
|
+
The fast in-process unit tests (`cli/src/commands/*.unit.test.ts`) run in CI;
|
|
177
|
+
the integration tests hit the network and are local-only.
|
package/dist/main.js
CHANGED
|
@@ -396,25 +396,13 @@ const COMPILE_OPTIONS = {
|
|
|
396
396
|
noEscape: true
|
|
397
397
|
};
|
|
398
398
|
/**
|
|
399
|
-
*
|
|
400
|
-
*
|
|
401
|
-
*
|
|
402
|
-
*
|
|
403
|
-
* May throw if a template references a missing variable.
|
|
404
|
-
*
|
|
405
|
-
* `trailingInstructions` is optional so a consumer can assemble a fragment-only
|
|
406
|
-
* PREAMBLE (quiz / writing / coding) and concatenate its own frame afterwards. An
|
|
407
|
-
* empty plan with no trailing text renders to the empty string (so an activity that
|
|
408
|
-
* declares no fragments gets no stray whitespace); every non-empty result ends in a
|
|
409
|
-
* single trailing newline, byte-identical to the historic tutor output.
|
|
399
|
+
* Compile and render a single fragment's `content` with the merged variables. Shared
|
|
400
|
+
* by the host-template `fragment` helper (real render) so the produced text is
|
|
401
|
+
* byte-identical to what the standalone fragment check exercises. May throw if the
|
|
402
|
+
* template references a variable not in `variables` (strict mode).
|
|
410
403
|
*/
|
|
411
|
-
function
|
|
412
|
-
|
|
413
|
-
return Handlebars.compile(fragment.content, COMPILE_OPTIONS)(fragment.variables).trimEnd();
|
|
414
|
-
});
|
|
415
|
-
if (trailingInstructions !== void 0) parts.push(trailingInstructions.trimEnd());
|
|
416
|
-
if (parts.length === 0) return "";
|
|
417
|
-
return `${parts.join("\n\n")}\n`;
|
|
404
|
+
function renderFragmentContent(content, variables) {
|
|
405
|
+
return Handlebars.compile(content, COMPILE_OPTIONS)(variables);
|
|
418
406
|
}
|
|
419
407
|
//#endregion
|
|
420
408
|
//#region ../lib/prompt-fragments/errors.ts
|
|
@@ -473,118 +461,151 @@ function typeMismatch(prop, value) {
|
|
|
473
461
|
};
|
|
474
462
|
}
|
|
475
463
|
}
|
|
476
|
-
|
|
464
|
+
/** Split an inline reference at the FIRST dot: aliases cannot contain dots, ids may. */
|
|
465
|
+
function splitFragmentRef(ref) {
|
|
466
|
+
const dot = ref.indexOf(".");
|
|
467
|
+
if (dot === -1) return {
|
|
468
|
+
alias: ref,
|
|
469
|
+
fragmentId: ""
|
|
470
|
+
};
|
|
471
|
+
return {
|
|
472
|
+
alias: ref.slice(0, dot),
|
|
473
|
+
fragmentId: ref.slice(dot + 1)
|
|
474
|
+
};
|
|
475
|
+
}
|
|
476
|
+
/**
|
|
477
|
+
* Resolve one `"alias.id"` reference against the fetched libraries and validate its
|
|
478
|
+
* inline args against the fragment's `input_schema` — required present, types correct,
|
|
479
|
+
* undeclared flagged, optional defaults filled in (a supplied value always wins). The
|
|
480
|
+
* same required/type/undeclared/defaults machinery that ran per document-level ref
|
|
481
|
+
* before, now per placement.
|
|
482
|
+
*/
|
|
483
|
+
function resolveAndMerge(ref, args, filesByAlias) {
|
|
477
484
|
const errors = [];
|
|
478
485
|
const warnings = [];
|
|
479
|
-
const
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
}
|
|
493
|
-
byId.set(frag.id, frag);
|
|
494
|
-
}
|
|
495
|
-
fragmentIndex.set(alias, byId);
|
|
486
|
+
const { alias, fragmentId } = splitFragmentRef(ref);
|
|
487
|
+
const file = filesByAlias.get(alias);
|
|
488
|
+
if (!file) {
|
|
489
|
+
errors.push(error("UNKNOWN_FRAGMENT_FILE_ALIAS", `Fragment marker "${ref}" uses unknown file alias "${alias}"`, {
|
|
490
|
+
fileAlias: alias,
|
|
491
|
+
fragmentId
|
|
492
|
+
}));
|
|
493
|
+
return {
|
|
494
|
+
errors,
|
|
495
|
+
warnings,
|
|
496
|
+
variables: args,
|
|
497
|
+
content: null
|
|
498
|
+
};
|
|
496
499
|
}
|
|
497
|
-
const
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
500
|
+
const fragment = file.fragments.find((f) => f.id === fragmentId);
|
|
501
|
+
if (!fragment) {
|
|
502
|
+
errors.push(error("FRAGMENT_NOT_FOUND", `Fragment "${fragmentId}" not found in file "${alias}"`, {
|
|
503
|
+
fileAlias: alias,
|
|
504
|
+
fragmentId
|
|
505
|
+
}));
|
|
506
|
+
return {
|
|
507
|
+
errors,
|
|
508
|
+
warnings,
|
|
509
|
+
variables: args,
|
|
510
|
+
content: null
|
|
511
|
+
};
|
|
512
|
+
}
|
|
513
|
+
return {
|
|
514
|
+
errors,
|
|
515
|
+
warnings,
|
|
516
|
+
variables: mergeVariables(alias, fragment, args, errors, warnings),
|
|
517
|
+
content: fragment.content
|
|
518
|
+
};
|
|
519
|
+
}
|
|
520
|
+
/** Validate `args` against `fragment.input_schema` and merge in optional defaults. */
|
|
521
|
+
function mergeVariables(alias, fragment, args, errors, warnings) {
|
|
522
|
+
const merged = { ...args };
|
|
523
|
+
const schema = fragment.input_schema;
|
|
524
|
+
const id = fragment.id;
|
|
525
|
+
if (!schema) {
|
|
526
|
+
for (const name of Object.keys(args)) warnings.push(warning("UNDECLARED_VARIABLE", `Variable "${name}" supplied to "${id}", which declares no input schema`, {
|
|
527
|
+
fileAlias: alias,
|
|
528
|
+
fragmentId: id,
|
|
529
|
+
variable: name
|
|
504
530
|
}));
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
531
|
+
return merged;
|
|
532
|
+
}
|
|
533
|
+
for (const name of schema.required) if (!(name in args)) errors.push(error("MISSING_REQUIRED_VARIABLE", `Fragment "${id}" requires variable "${name}", which is not supplied`, {
|
|
534
|
+
fileAlias: alias,
|
|
535
|
+
fragmentId: id,
|
|
536
|
+
variable: name
|
|
537
|
+
}));
|
|
538
|
+
for (const [name, value] of Object.entries(args)) {
|
|
539
|
+
const prop = schema.properties[name];
|
|
540
|
+
if (!prop) {
|
|
541
|
+
warnings.push(warning("UNDECLARED_VARIABLE", `Variable "${name}" supplied to "${id}" is not declared in its input schema`, {
|
|
542
|
+
fileAlias: alias,
|
|
543
|
+
fragmentId: id,
|
|
544
|
+
variable: name
|
|
511
545
|
}));
|
|
512
546
|
continue;
|
|
513
547
|
}
|
|
514
|
-
const
|
|
515
|
-
if (
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
548
|
+
const mismatch = typeMismatch(prop, value);
|
|
549
|
+
if (mismatch) errors.push(error("VARIABLE_TYPE_MISMATCH", `Variable "${name}" of "${id}" should be ${mismatch.expected} but got ${mismatch.actual}`, {
|
|
550
|
+
fileAlias: alias,
|
|
551
|
+
fragmentId: id,
|
|
552
|
+
variable: name,
|
|
553
|
+
expectedType: mismatch.expected,
|
|
554
|
+
actualType: mismatch.actual
|
|
555
|
+
}));
|
|
556
|
+
}
|
|
557
|
+
const requiredSet = new Set(schema.required);
|
|
558
|
+
for (const [name, prop] of Object.entries(schema.properties)) {
|
|
559
|
+
if (prop.default === void 0) continue;
|
|
560
|
+
if (requiredSet.has(name)) {
|
|
561
|
+
warnings.push(warning("REQUIRED_PROPERTY_HAS_DEFAULT", `Variable "${name}" of "${id}" is required, so its default is never used`, {
|
|
562
|
+
fileAlias: alias,
|
|
563
|
+
fragmentId: id,
|
|
564
|
+
variable: name
|
|
519
565
|
}));
|
|
520
566
|
continue;
|
|
521
567
|
}
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
expectedType: mismatch.expected,
|
|
547
|
-
actualType: mismatch.actual
|
|
568
|
+
if (!(name in merged)) merged[name] = prop.default;
|
|
569
|
+
}
|
|
570
|
+
return merged;
|
|
571
|
+
}
|
|
572
|
+
/**
|
|
573
|
+
* Cross-check every inline placement against the declared libraries: duplicate
|
|
574
|
+
* aliases, duplicate fragment ids within a file, each placement's `alias.id`
|
|
575
|
+
* resolution + variable validation (via `resolveAndMerge`), and a warning for any
|
|
576
|
+
* declared library no placement ever uses. Errors block the build; the rest are
|
|
577
|
+
* warnings. Order-independent — placements carry their own textual position.
|
|
578
|
+
*/
|
|
579
|
+
function checkPlacements(placements, filesByAlias, fileRefs) {
|
|
580
|
+
const errors = [];
|
|
581
|
+
const warnings = [];
|
|
582
|
+
const aliasCounts = /* @__PURE__ */ new Map();
|
|
583
|
+
for (const ref of fileRefs) aliasCounts.set(ref.id, (aliasCounts.get(ref.id) ?? 0) + 1);
|
|
584
|
+
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 }));
|
|
585
|
+
for (const [alias, file] of filesByAlias) {
|
|
586
|
+
const seen = /* @__PURE__ */ new Set();
|
|
587
|
+
for (const frag of file.fragments) {
|
|
588
|
+
if (seen.has(frag.id)) {
|
|
589
|
+
errors.push(error("DUPLICATE_FRAGMENT_ID_IN_FILE", `Fragment "${frag.id}" is declared more than once in file "${alias}"`, {
|
|
590
|
+
fileAlias: alias,
|
|
591
|
+
fragmentId: frag.id
|
|
548
592
|
}));
|
|
593
|
+
continue;
|
|
549
594
|
}
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
if (prop.default === void 0) continue;
|
|
553
|
-
if (requiredSet.has(name)) {
|
|
554
|
-
warnings.push(warning("REQUIRED_PROPERTY_HAS_DEFAULT", `Variable "${name}" of "${ref.id}" is required, so its default is never used`, {
|
|
555
|
-
fileAlias: ref.file,
|
|
556
|
-
fragmentId: ref.id,
|
|
557
|
-
variable: name
|
|
558
|
-
}));
|
|
559
|
-
continue;
|
|
560
|
-
}
|
|
561
|
-
if (!(name in merged)) merged[name] = prop.default;
|
|
562
|
-
}
|
|
563
|
-
} else for (const name of Object.keys(variables)) warnings.push(warning("UNDECLARED_VARIABLE", `Variable "${name}" supplied to "${ref.id}", which declares no input schema`, {
|
|
564
|
-
fileAlias: ref.file,
|
|
565
|
-
fragmentId: ref.id,
|
|
566
|
-
variable: name
|
|
567
|
-
}));
|
|
568
|
-
resolved.push({
|
|
569
|
-
fileAlias: ref.file,
|
|
570
|
-
fragmentId: ref.id,
|
|
571
|
-
priority: fragment.priority,
|
|
572
|
-
content: fragment.content,
|
|
573
|
-
variables: merged
|
|
574
|
-
});
|
|
595
|
+
seen.add(frag.id);
|
|
596
|
+
}
|
|
575
597
|
}
|
|
576
|
-
const
|
|
577
|
-
const
|
|
578
|
-
|
|
579
|
-
const
|
|
580
|
-
|
|
581
|
-
|
|
598
|
+
const usedAliases = /* @__PURE__ */ new Set();
|
|
599
|
+
for (const placement of placements) {
|
|
600
|
+
usedAliases.add(splitFragmentRef(placement.ref).alias);
|
|
601
|
+
const resolved = resolveAndMerge(placement.ref, placement.args, filesByAlias);
|
|
602
|
+
errors.push(...resolved.errors);
|
|
603
|
+
warnings.push(...resolved.warnings);
|
|
582
604
|
}
|
|
583
|
-
for (const
|
|
605
|
+
for (const ref of fileRefs) if (!usedAliases.has(ref.id)) warnings.push(warning("UNUSED_FRAGMENT_FILE", `Fragment library "${ref.id}" is declared but no {{fragment}} marker uses it`, { fileAlias: ref.id }));
|
|
584
606
|
return {
|
|
585
607
|
errors,
|
|
586
|
-
warnings
|
|
587
|
-
plan
|
|
608
|
+
warnings
|
|
588
609
|
};
|
|
589
610
|
}
|
|
590
611
|
//#endregion
|
|
@@ -704,8 +725,7 @@ const ClassificationSchema = z.strictObject({
|
|
|
704
725
|
});
|
|
705
726
|
const FragmentSchema = z.strictObject({
|
|
706
727
|
id: z.string().meta({ description: "Unique fragment id within this library." }),
|
|
707
|
-
version: z.number().meta({ description: "
|
|
708
|
-
priority: z.number().meta({ description: "Assembly order. Lower priorities appear earlier." }),
|
|
728
|
+
version: z.number().optional().meta({ description: "Optional fragment version number." }),
|
|
709
729
|
input_schema: InputSchema.optional(),
|
|
710
730
|
classification: ClassificationSchema.optional(),
|
|
711
731
|
content: z.string().meta({ description: "Prompt text as a Handlebars template." })
|
|
@@ -717,8 +737,7 @@ const FragmentFileSchema = z.strictObject({
|
|
|
717
737
|
id: z.string().meta({ description: "Machine-readable id of this fragment library." }),
|
|
718
738
|
fragments: z.array(FragmentSchema).min(1).meta({ description: "The reusable fragments this library provides (at least one)." })
|
|
719
739
|
});
|
|
720
|
-
|
|
721
|
-
const VariableValueSchema = z.union([
|
|
740
|
+
z.union([
|
|
722
741
|
z.string(),
|
|
723
742
|
z.boolean(),
|
|
724
743
|
z.array(z.string())
|
|
@@ -727,22 +746,15 @@ const VariableValueSchema = z.union([
|
|
|
727
746
|
description: "A literal variable value: a string, a boolean, or an array of strings."
|
|
728
747
|
});
|
|
729
748
|
const FragmentFileRefSchema = z.strictObject({
|
|
730
|
-
id: z.string().
|
|
749
|
+
id: z.string().regex(/^[^.]+$/, { message: "Alias must not contain a dot" }).meta({
|
|
750
|
+
pattern: "^[^.]+$",
|
|
751
|
+
description: "Local alias for this library, used before the dot in `{{fragment \"alias.id\"}}`. Must not contain a dot."
|
|
752
|
+
}),
|
|
731
753
|
url: FragmentUrlRef
|
|
732
754
|
}).meta({
|
|
733
755
|
id: "fragmentFileRef",
|
|
734
756
|
description: "A reference to a fragment library by alias + URL."
|
|
735
757
|
});
|
|
736
|
-
const FragmentRefSchema = z.strictObject({
|
|
737
|
-
file: z.string().meta({ description: "The alias of the fragment library this fragment is drawn from." }),
|
|
738
|
-
id: z.string().meta({ description: "Fragment id inside the referenced library." }),
|
|
739
|
-
variables: z.record(z.string(), VariableValueSchema).optional().meta({ description: "Literal values passed into the fragment template." }),
|
|
740
|
-
bind: z.record(z.string(), z.string()).optional().meta({ description: "Accepted for compatibility but ignored by the current assembler." }),
|
|
741
|
-
required: z.boolean().optional().meta({ description: "Marker for important fragments. Accepted but not currently enforced." })
|
|
742
|
-
}).meta({
|
|
743
|
-
id: "fragmentRef",
|
|
744
|
-
description: "Selects one fragment from a referenced library."
|
|
745
|
-
});
|
|
746
758
|
//#endregion
|
|
747
759
|
//#region ../lib/prompt-fragments/fragment.ts
|
|
748
760
|
/**
|
|
@@ -793,8 +805,8 @@ function checkFragmentTemplates(file, opts = {}) {
|
|
|
793
805
|
}
|
|
794
806
|
/**
|
|
795
807
|
* Fragment ids declared more than once within a single file. The standalone
|
|
796
|
-
* validator runs this directly; the
|
|
797
|
-
* `
|
|
808
|
+
* validator runs this directly; the activity path gets the same check from
|
|
809
|
+
* `checkPlacements` (so the whole-library pass must NOT repeat it).
|
|
798
810
|
*/
|
|
799
811
|
function findDuplicateFragmentIds(file) {
|
|
800
812
|
const errors = [];
|
|
@@ -835,6 +847,142 @@ function checkFragmentFileValue(parsed, url) {
|
|
|
835
847
|
};
|
|
836
848
|
}
|
|
837
849
|
//#endregion
|
|
850
|
+
//#region ../lib/prompt-fragments/host-template.ts
|
|
851
|
+
const FRAGMENT_HELPER = "fragment";
|
|
852
|
+
const ARRAY_HELPER = "array";
|
|
853
|
+
const isFragmentNode = (node) => node.path?.original === FRAGMENT_HELPER;
|
|
854
|
+
/** A structural error stamped with the node's 1-based position. */
|
|
855
|
+
function markerInvalid(loc, detail) {
|
|
856
|
+
return error("FRAGMENT_MARKER_INVALID", `${detail} (line ${loc.start.line})`, {
|
|
857
|
+
line: loc.start.line,
|
|
858
|
+
column: loc.start.column + 1
|
|
859
|
+
});
|
|
860
|
+
}
|
|
861
|
+
/**
|
|
862
|
+
* Read a hash-pair value into a supported literal. The supported set IS the contract:
|
|
863
|
+
* a string, a boolean, or an `(array "…" …)` of string literals. Anything else — a
|
|
864
|
+
* number, a path reference, a nested `(fragment …)`, an array with a non-string
|
|
865
|
+
* element — is a hard error, because at RENDER time the real Handlebars runtime hands
|
|
866
|
+
* the helper the raw value regardless, so silently dropping it here would let the
|
|
867
|
+
* placement validate against different args than it renders with (fail-open drift).
|
|
868
|
+
*/
|
|
869
|
+
function readHashValue(key, node, loc) {
|
|
870
|
+
switch (node.type) {
|
|
871
|
+
case "StringLiteral": return { value: node.value };
|
|
872
|
+
case "BooleanLiteral": return { value: node.value };
|
|
873
|
+
case "SubExpression": {
|
|
874
|
+
const sub = node;
|
|
875
|
+
if (sub.path.original !== ARRAY_HELPER) return { error: markerInvalid(loc, `Argument "${key}" must be a string, boolean, or (array …)`) };
|
|
876
|
+
if (!sub.params.every((p) => p.type === "StringLiteral")) return { error: markerInvalid(loc, `Every element of (array …) for "${key}" must be a string`) };
|
|
877
|
+
return { value: sub.params.map((p) => p.value) };
|
|
878
|
+
}
|
|
879
|
+
default: return { error: markerInvalid(loc, `Argument "${key}" must be a string, boolean, or (array …)`) };
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
/** Extract one inline `{{fragment "alias.id" …}}` mustache's ref + validated args. */
|
|
883
|
+
function extractInlineMarker(node, out) {
|
|
884
|
+
const first = node.params?.[0];
|
|
885
|
+
if (first?.type !== "StringLiteral") {
|
|
886
|
+
out.errors.push(error("FRAGMENT_REF_NOT_LITERAL", `A {{fragment}} marker at line ${node.loc.start.line} needs a quoted "alias.id" reference`, {
|
|
887
|
+
line: node.loc.start.line,
|
|
888
|
+
column: node.loc.start.column + 1
|
|
889
|
+
}));
|
|
890
|
+
return;
|
|
891
|
+
}
|
|
892
|
+
if ((node.params?.length ?? 0) > 1) {
|
|
893
|
+
out.errors.push(markerInvalid(node.loc, "A {{fragment}} marker takes exactly one \"alias.id\" reference"));
|
|
894
|
+
return;
|
|
895
|
+
}
|
|
896
|
+
const args = {};
|
|
897
|
+
for (const pair of node.hash?.pairs ?? []) {
|
|
898
|
+
const read = readHashValue(pair.key, pair.value, node.loc);
|
|
899
|
+
if ("error" in read) {
|
|
900
|
+
out.errors.push(read.error);
|
|
901
|
+
return;
|
|
902
|
+
}
|
|
903
|
+
args[pair.key] = read.value;
|
|
904
|
+
}
|
|
905
|
+
out.placements.push({
|
|
906
|
+
ref: first.value,
|
|
907
|
+
args,
|
|
908
|
+
line: node.loc.start.line,
|
|
909
|
+
column: node.loc.start.column + 1
|
|
910
|
+
});
|
|
911
|
+
}
|
|
912
|
+
/** Reject a `fragment` helper used anywhere but a simple inline mustache. */
|
|
913
|
+
function visitExpression(node, out) {
|
|
914
|
+
if (node.type !== "SubExpression") return;
|
|
915
|
+
const sub = node;
|
|
916
|
+
if (isFragmentNode(sub)) out.errors.push(markerInvalid(sub.loc, "{{fragment}} must be a standalone inline marker, not a subexpression"));
|
|
917
|
+
for (const p of sub.params ?? []) visitExpression(p, out);
|
|
918
|
+
for (const pair of sub.hash?.pairs ?? []) visitExpression(pair.value, out);
|
|
919
|
+
}
|
|
920
|
+
/** Collect every `{{fragment}}` marker in a program body (recursing into blocks). */
|
|
921
|
+
function collectPlacements(program, out) {
|
|
922
|
+
for (const node of program.body) {
|
|
923
|
+
if (isFragmentNode(node)) if (node.type === "MustacheStatement") extractInlineMarker(node, out);
|
|
924
|
+
else out.errors.push(markerInvalid(node.loc, "{{fragment}} must be a simple inline marker, not a block {{#fragment}}…{{/fragment}}"));
|
|
925
|
+
else {
|
|
926
|
+
for (const p of node.params ?? []) visitExpression(p, out);
|
|
927
|
+
for (const pair of node.hash?.pairs ?? []) visitExpression(pair.value, out);
|
|
928
|
+
}
|
|
929
|
+
if (node.program) collectPlacements(node.program, out);
|
|
930
|
+
if (node.inverse) collectPlacements(node.inverse, out);
|
|
931
|
+
}
|
|
932
|
+
}
|
|
933
|
+
/**
|
|
934
|
+
* Parse the host text and extract every inline `{{fragment}}` placement in textual
|
|
935
|
+
* order, WITHOUT rendering. A whole-template syntax error (a malformed marker, an
|
|
936
|
+
* unescaped literal `{{`) becomes a single `HOST_TEMPLATE_PARSE_ERROR`; its position
|
|
937
|
+
* is regexed out of Handlebars' message (`Parse error on line N:` — the parser
|
|
938
|
+
* carries no structured position fields). A well-formed marker whose reference is not
|
|
939
|
+
* a quoted string literal becomes a per-marker `FRAGMENT_REF_NOT_LITERAL`.
|
|
940
|
+
*/
|
|
941
|
+
function parseHostPlacements(text) {
|
|
942
|
+
const result = {
|
|
943
|
+
placements: [],
|
|
944
|
+
errors: []
|
|
945
|
+
};
|
|
946
|
+
let program;
|
|
947
|
+
try {
|
|
948
|
+
program = Handlebars.parse(text);
|
|
949
|
+
} catch (e) {
|
|
950
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
951
|
+
const lineMatch = message.match(/line (\d+)/i);
|
|
952
|
+
result.errors.push(error("HOST_TEMPLATE_PARSE_ERROR", `Host text is not a valid template: ${message}`, { ...lineMatch ? { line: Number(lineMatch[1]) } : {} }));
|
|
953
|
+
return result;
|
|
954
|
+
}
|
|
955
|
+
collectPlacements(program, result);
|
|
956
|
+
return result;
|
|
957
|
+
}
|
|
958
|
+
/** Build the isolated Handlebars instance carrying ONLY the `fragment` + `array` helpers. */
|
|
959
|
+
function createHostInstance(resolver) {
|
|
960
|
+
const hb = Handlebars.create();
|
|
961
|
+
hb.registerHelper(FRAGMENT_HELPER, (...args) => {
|
|
962
|
+
const ref = args.length >= 2 ? args[0] : void 0;
|
|
963
|
+
if (typeof ref !== "string") throw new Error("a {{fragment}} marker needs a quoted \"alias.id\" reference");
|
|
964
|
+
const options = args[args.length - 1];
|
|
965
|
+
return resolver(ref, options?.hash ?? {});
|
|
966
|
+
});
|
|
967
|
+
hb.registerHelper(ARRAY_HELPER, (...args) => {
|
|
968
|
+
return args.slice(0, -1);
|
|
969
|
+
});
|
|
970
|
+
return hb;
|
|
971
|
+
}
|
|
972
|
+
/**
|
|
973
|
+
* Compile + render the host text with the isolated instance under
|
|
974
|
+
* `{ strict: true, noEscape: true }` — `strict` so any stray `{{…}}` that is not a
|
|
975
|
+
* `fragment` / `array` marker fails closed instead of rendering empty; `noEscape` so
|
|
976
|
+
* the prompt text (ASCII diagrams, quotes) passes through verbatim. Each placement is
|
|
977
|
+
* replaced by its `resolver` result. May throw — the caller wraps it as ASSEMBLY_ERROR.
|
|
978
|
+
*/
|
|
979
|
+
function renderHostTemplate(text, resolver) {
|
|
980
|
+
return createHostInstance(resolver).compile(text, {
|
|
981
|
+
strict: true,
|
|
982
|
+
noEscape: true
|
|
983
|
+
})({});
|
|
984
|
+
}
|
|
985
|
+
//#endregion
|
|
838
986
|
//#region ../lib/relative-url.ts
|
|
839
987
|
/**
|
|
840
988
|
* Resolve a reference to an absolute URL. An absolute http(s) ref is used as-is;
|
|
@@ -926,23 +1074,27 @@ async function loadYaml(url, fetchImpl, opts = {}) {
|
|
|
926
1074
|
};
|
|
927
1075
|
}
|
|
928
1076
|
/**
|
|
929
|
-
*
|
|
930
|
-
*
|
|
931
|
-
*
|
|
932
|
-
*
|
|
933
|
-
*
|
|
1077
|
+
* Render an activity's host text (`tutor_instructions` / `instructions`) into a
|
|
1078
|
+
* finished prompt string, inserting each inline `{{fragment "alias.id" …}}` marker in
|
|
1079
|
+
* place. Fetches every declared fragment library in parallel (relative refs resolved
|
|
1080
|
+
* against `baseUrl`), schema-validates each, (optionally) runs the thorough
|
|
1081
|
+
* whole-library check, extracts + checks the placements, then compiles + renders the
|
|
1082
|
+
* host template under strict Handlebars.
|
|
934
1083
|
*
|
|
935
1084
|
* The single seam every activity kind shares — the sole owner of the fetch → validate
|
|
936
|
-
* →
|
|
937
|
-
*
|
|
938
|
-
*
|
|
1085
|
+
* → check → render pipeline. `hostText` IS the template: fragments appear only where
|
|
1086
|
+
* the author placed a marker; there is no ordering concept and no prepend fallback.
|
|
1087
|
+
*
|
|
1088
|
+
* TEMPLATE-SEMANTICS OPT-IN: an activity that declares no `fragment_files:` is NEVER
|
|
1089
|
+
* compiled — its host text returns byte-verbatim (protecting plain activities and the
|
|
1090
|
+
* authoring tutors whose prose contains sample markers as teaching content).
|
|
939
1091
|
*/
|
|
940
|
-
async function assembleFragmentPrompt(block, baseUrl, fetchImpl, opts = {},
|
|
1092
|
+
async function assembleFragmentPrompt(block, baseUrl, fetchImpl, opts = {}, hostText = "") {
|
|
941
1093
|
const warnings = [];
|
|
942
1094
|
const allowedSchemes = opts.allowedSchemes ?? DEFAULT_ALLOWED_SCHEMES;
|
|
943
|
-
if (block.fragment_files.length === 0
|
|
1095
|
+
if (block.fragment_files.length === 0) return {
|
|
944
1096
|
ok: true,
|
|
945
|
-
prompt:
|
|
1097
|
+
prompt: hostText,
|
|
946
1098
|
warnings
|
|
947
1099
|
};
|
|
948
1100
|
const settled = await Promise.all(block.fragment_files.map(async (ref) => {
|
|
@@ -1012,18 +1164,28 @@ async function assembleFragmentPrompt(block, baseUrl, fetchImpl, opts = {}, trai
|
|
|
1012
1164
|
libraryErrors.push(...checked.errors);
|
|
1013
1165
|
warnings.push(...checked.warnings);
|
|
1014
1166
|
}
|
|
1015
|
-
const
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1167
|
+
const parsed = parseHostPlacements(hostText);
|
|
1168
|
+
if (parsed.errors.length > 0) return {
|
|
1169
|
+
ok: false,
|
|
1170
|
+
errors: [...libraryErrors, ...parsed.errors],
|
|
1171
|
+
warnings
|
|
1172
|
+
};
|
|
1173
|
+
const placementCheck = checkPlacements(parsed.placements, fragmentFilesByAlias, block.fragment_files);
|
|
1174
|
+
warnings.push(...placementCheck.warnings);
|
|
1175
|
+
const preRenderErrors = [...libraryErrors, ...placementCheck.errors];
|
|
1176
|
+
if (preRenderErrors.length > 0) return {
|
|
1019
1177
|
ok: false,
|
|
1020
|
-
errors:
|
|
1178
|
+
errors: preRenderErrors,
|
|
1021
1179
|
warnings
|
|
1022
1180
|
};
|
|
1023
1181
|
try {
|
|
1024
1182
|
return {
|
|
1025
1183
|
ok: true,
|
|
1026
|
-
prompt:
|
|
1184
|
+
prompt: renderHostTemplate(hostText, (ref, args) => {
|
|
1185
|
+
const resolved = resolveAndMerge(ref, args, fragmentFilesByAlias);
|
|
1186
|
+
if (resolved.content === null || resolved.errors.length > 0) throw new Error(`Fragment "${ref}" could not be resolved`);
|
|
1187
|
+
return renderFragmentContent(resolved.content, resolved.variables);
|
|
1188
|
+
}),
|
|
1027
1189
|
warnings
|
|
1028
1190
|
};
|
|
1029
1191
|
} catch (e) {
|
|
@@ -1065,8 +1227,7 @@ const CodingYamlSchema = z.strictObject({
|
|
|
1065
1227
|
description: "The pinned model and provider that answer coding requests."
|
|
1066
1228
|
}),
|
|
1067
1229
|
fragment_files: z.array(FragmentFileRefSchema).default([]).meta({ description: "Optional fragment libraries this activity pulls shared prompt fragments from." }),
|
|
1068
|
-
|
|
1069
|
-
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." })
|
|
1230
|
+
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. When any fragment_files are declared it is a Handlebars template: place fragments inline with {{fragment \"alias.id\" …}} markers (escape a literal {{ as \\{{)." })
|
|
1070
1231
|
});
|
|
1071
1232
|
//#endregion
|
|
1072
1233
|
//#region ../lib/coding-validate.ts
|
|
@@ -1105,13 +1266,10 @@ async function loadAndCheckCoding(url, fetchImpl, opts = {}) {
|
|
|
1105
1266
|
};
|
|
1106
1267
|
const checked = checkCodingParsed(valid.data);
|
|
1107
1268
|
if (!checked.ok) return checked;
|
|
1108
|
-
const assembled = await assembleFragmentPrompt({
|
|
1109
|
-
fragment_files: valid.data.fragment_files,
|
|
1110
|
-
fragments: valid.data.fragments
|
|
1111
|
-
}, url, fetchImpl, {
|
|
1269
|
+
const assembled = await assembleFragmentPrompt({ fragment_files: valid.data.fragment_files }, url, fetchImpl, {
|
|
1112
1270
|
allowedSchemes: opts.allowedSchemes,
|
|
1113
1271
|
validateLibraries: opts.validateLibraries ?? true
|
|
1114
|
-
});
|
|
1272
|
+
}, valid.data.instructions);
|
|
1115
1273
|
const warnings = [...checked.warnings, ...assembled.warnings];
|
|
1116
1274
|
if (!assembled.ok) return {
|
|
1117
1275
|
ok: false,
|
|
@@ -1183,7 +1341,7 @@ const QuizYamlSchema = z.strictObject({
|
|
|
1183
1341
|
description: "Optional guidance for the per-question follow-up discussion chat."
|
|
1184
1342
|
}),
|
|
1185
1343
|
fragment_files: z.array(FragmentFileRefSchema).default([]).meta({ description: "Optional fragment libraries this quiz pulls shared prompt fragments from." }),
|
|
1186
|
-
|
|
1344
|
+
instructions: z.string().optional().meta({ description: "Optional quiz-level preamble prepended to BOTH the grader prompt and the discussion chat. When any fragment_files are declared, place fragments inline here with {{fragment \"alias.id\" …}} markers (escape a literal {{ as \\{{)." }),
|
|
1187
1345
|
questions: z.array(QuizQuestionSchema).min(1).meta({ description: "The quiz questions. Each is open-ended and graded by the LLM via its evaluation prompt." })
|
|
1188
1346
|
});
|
|
1189
1347
|
//#endregion
|
|
@@ -1248,13 +1406,10 @@ async function loadAndCheckQuiz(url, fetchImpl, opts = {}) {
|
|
|
1248
1406
|
};
|
|
1249
1407
|
const checked = checkQuizParsed(valid.data);
|
|
1250
1408
|
if (!checked.ok) return checked;
|
|
1251
|
-
const assembled = await assembleFragmentPrompt({
|
|
1252
|
-
fragment_files: valid.data.fragment_files,
|
|
1253
|
-
fragments: valid.data.fragments
|
|
1254
|
-
}, url, fetchImpl, {
|
|
1409
|
+
const assembled = await assembleFragmentPrompt({ fragment_files: valid.data.fragment_files }, url, fetchImpl, {
|
|
1255
1410
|
allowedSchemes: opts.allowedSchemes,
|
|
1256
1411
|
validateLibraries: opts.validateLibraries ?? true
|
|
1257
|
-
});
|
|
1412
|
+
}, valid.data.instructions ?? "");
|
|
1258
1413
|
const warnings = [...checked.warnings, ...assembled.warnings];
|
|
1259
1414
|
if (!assembled.ok) return {
|
|
1260
1415
|
ok: false,
|
|
@@ -1303,11 +1458,10 @@ const TutorSchema = z.strictObject({
|
|
|
1303
1458
|
}),
|
|
1304
1459
|
prompt: z.strictObject({
|
|
1305
1460
|
fragment_files: z.array(FragmentFileRefSchema).default([]).meta({ description: "Optional fragment libraries used by this tutor." }),
|
|
1306
|
-
|
|
1307
|
-
tutor_instructions: z.string().meta({ description: "Final tutor-specific system-prompt instructions. For single-file tutors, this can be the whole prompt." })
|
|
1461
|
+
tutor_instructions: z.string().meta({ description: "The tutor's system prompt. When any fragment_files are declared this is a Handlebars template: place fragments inline with {{fragment \"alias.id\" key=\"v\"}} markers (escape a literal {{ as \\{{). For single-file tutors it is the whole prompt." })
|
|
1308
1462
|
}).meta({
|
|
1309
1463
|
id: "prompt",
|
|
1310
|
-
description: "The
|
|
1464
|
+
description: "The tutor system prompt: a host template with inline fragment markers."
|
|
1311
1465
|
})
|
|
1312
1466
|
});
|
|
1313
1467
|
//#endregion
|
|
@@ -1366,8 +1520,7 @@ const WritingYamlSchema = z.strictObject({
|
|
|
1366
1520
|
description: "The model and provider that back the writing coach."
|
|
1367
1521
|
}),
|
|
1368
1522
|
fragment_files: z.array(FragmentFileRefSchema).default([]).meta({ description: "Optional fragment libraries this activity pulls shared prompt fragments from." }),
|
|
1369
|
-
|
|
1370
|
-
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." }),
|
|
1523
|
+
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. When any fragment_files are declared it is a Handlebars template: place fragments inline with {{fragment \"alias.id\" …}} markers (escape a literal {{ as \\{{)." }),
|
|
1371
1524
|
placeholder: z.string().optional().meta({ description: "Optional starter text prefilled into the editor. Empty for a blank page." })
|
|
1372
1525
|
});
|
|
1373
1526
|
//#endregion
|
|
@@ -1410,13 +1563,10 @@ async function loadAndCheckWriting(url, fetchImpl, opts = {}) {
|
|
|
1410
1563
|
};
|
|
1411
1564
|
const checked = checkWritingParsed(valid.data);
|
|
1412
1565
|
if (!checked.ok) return checked;
|
|
1413
|
-
const assembled = await assembleFragmentPrompt({
|
|
1414
|
-
fragment_files: valid.data.fragment_files,
|
|
1415
|
-
fragments: valid.data.fragments
|
|
1416
|
-
}, url, fetchImpl, {
|
|
1566
|
+
const assembled = await assembleFragmentPrompt({ fragment_files: valid.data.fragment_files }, url, fetchImpl, {
|
|
1417
1567
|
allowedSchemes: opts.allowedSchemes,
|
|
1418
1568
|
validateLibraries: opts.validateLibraries ?? true
|
|
1419
|
-
});
|
|
1569
|
+
}, valid.data.instructions);
|
|
1420
1570
|
const warnings = [...checked.warnings, ...assembled.warnings];
|
|
1421
1571
|
if (!assembled.ok) return {
|
|
1422
1572
|
ok: false,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@novedu/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.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": {
|