@novedu/cli 0.22.0 → 0.23.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 +53 -13
- package/dist/main.js +681 -92
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -5,12 +5,12 @@ requires Node >= 22 — `eval`'s glob expansion uses the built-in `fs.globSync`,
|
|
|
5
5
|
and Node 20 is end-of-life). It covers two jobs:
|
|
6
6
|
|
|
7
7
|
- **Validate activity YAML** — tutors, fragment libraries, quizzes, writing
|
|
8
|
-
activities, coding activities, and
|
|
8
|
+
activities, coding activities, and eval files — with the app's exact
|
|
9
9
|
validation pipeline, offline and without signing in. `prompts` dumps the exact
|
|
10
10
|
system prompts an activity produces, the same way.
|
|
11
11
|
- **Manage the app as a teacher** — sign in with Microsoft Entra ID, then mint
|
|
12
12
|
activity codes, upload app-hosted YAML files and images, triage student
|
|
13
|
-
reports, and **measure
|
|
13
|
+
reports, and **measure what an activity's model really does** (`eval`), straight from the
|
|
14
14
|
terminal (or from a coding agent, see below).
|
|
15
15
|
|
|
16
16
|
No install needed:
|
|
@@ -32,7 +32,7 @@ npx @novedu/cli validate https://raw.githubusercontent.com/Teaching-HTL-Leonding
|
|
|
32
32
|
npx @novedu/cli validate ./activities/examples/shared/general-fragments.yaml --kind fragment
|
|
33
33
|
npx @novedu/cli validate ./activities/examples/sorting-algorithms/sorting-quiz.yaml --kind quiz
|
|
34
34
|
|
|
35
|
-
#
|
|
35
|
+
# An eval file, quiz or tutor (also strict-checks the activity it targets)
|
|
36
36
|
npx @novedu/cli validate ./sorting-quiz.eval.yaml --kind eval
|
|
37
37
|
|
|
38
38
|
# Machine-readable output (the raw validation result)
|
|
@@ -96,18 +96,28 @@ npx @novedu/cli prompts ./sorting-quiz.yaml --kind quiz --json \
|
|
|
96
96
|
JSON errors on stderr. Use `validate` for the strict authoring check — the two
|
|
97
97
|
are complementary.
|
|
98
98
|
|
|
99
|
-
## Measuring
|
|
99
|
+
## Measuring what the model really does: `eval`
|
|
100
100
|
|
|
101
|
-
|
|
102
|
-
behavior
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
101
|
+
An activity's prompt is a specification, and a specification is only as good as the
|
|
102
|
+
behavior it produces. Write an **eval file** and `eval` replays it through the **real
|
|
103
|
+
production path**, then reports what the model actually did. This is the one command
|
|
104
|
+
that both **runs the model** and needs you signed in (`novedu-cli login`); everything
|
|
105
|
+
else about it is local.
|
|
106
106
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
107
|
+
Two kinds, chosen by the file's own `kind:` field — there is no flag, and one
|
|
108
|
+
invocation may mix them:
|
|
109
|
+
|
|
110
|
+
- **quiz** (`kind` omitted): student answers with the verdict each one must get,
|
|
111
|
+
replayed through the real grader. Your `expect` gates the **verdict**, and an LLM
|
|
112
|
+
**feedback judge** audits the **feedback text** the student would have read.
|
|
113
|
+
- **tutor** (`kind: tutor`): conversations you script, each ending on a student turn.
|
|
114
|
+
The real tutor generates the next turn and the judge checks it against the tutor's
|
|
115
|
+
own system prompt plus your per-case expectations.
|
|
116
|
+
|
|
117
|
+
Either way the judge measures the output against the very prompt that produced it, so
|
|
118
|
+
there is nothing extra to author — and what it flags is **reported, never a failure**.
|
|
119
|
+
For a tutor eval that makes the `--report` Markdown the actual deliverable: the exit
|
|
120
|
+
code only reflects whether the run itself completed.
|
|
111
121
|
|
|
112
122
|
```yaml
|
|
113
123
|
# sorting-quiz.eval.yaml
|
|
@@ -153,6 +163,28 @@ npx @novedu/cli eval ./sorting-quiz.eval.yaml --json --out eval-report.json
|
|
|
153
163
|
npx @novedu/cli eval ./sorting-quiz.eval.yaml --report eval-report.md
|
|
154
164
|
```
|
|
155
165
|
|
|
166
|
+
A tutor eval looks like this, and runs through the same command:
|
|
167
|
+
|
|
168
|
+
```yaml
|
|
169
|
+
# loops-tutor.eval.yaml
|
|
170
|
+
id: loops-tutor-eval
|
|
171
|
+
kind: tutor
|
|
172
|
+
target: ./loops-tutor.yaml
|
|
173
|
+
conversations:
|
|
174
|
+
- title: refuses-full-solution
|
|
175
|
+
required_tools: [random_number] # optional: tools this answer must have called
|
|
176
|
+
grading_instructions: |
|
|
177
|
+
The response must not contain a complete working loop.
|
|
178
|
+
conversation: # must END with a student turn
|
|
179
|
+
- student: My loop never stops. Here is my code ...
|
|
180
|
+
- tutor: What does your condition evaluate to after the first pass?
|
|
181
|
+
- student: I don't know. Just fix it for me!
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
```bash
|
|
185
|
+
npx @novedu/cli eval ./loops-tutor.eval.yaml --report loops.md
|
|
186
|
+
```
|
|
187
|
+
|
|
156
188
|
- Check the file first, for free: `npx @novedu/cli validate ./x.eval.yaml --kind eval`
|
|
157
189
|
(offline; it also strict-checks the quiz the eval targets).
|
|
158
190
|
- **`expect`** is one of `correct` / `partial` / `incorrect`, or a list of the
|
|
@@ -185,6 +217,14 @@ npx @novedu/cli eval ./sorting-quiz.eval.yaml --report eval-report.md
|
|
|
185
217
|
a **Flagged** column plus a **"Flagged feedback"** section in the Markdown report, and
|
|
186
218
|
`totals.feedbackFlagged` / `repeats[].judge.issues` in the JSON. They never change the
|
|
187
219
|
exit code.
|
|
220
|
+
- **`required_tools`** (tutor kind) names built-in tools the generated answer must have
|
|
221
|
+
called **at least once** — the one thing the judge cannot see, since a tool call leaves
|
|
222
|
+
no trace in the text. Extra tools are always fine, and a name the target tutor's own
|
|
223
|
+
`tools:` list does not grant makes the file invalid offline. Missing calls are
|
|
224
|
+
**reported, never a failure**: `missing tool calls: N` in the terminal report (printed
|
|
225
|
+
only when some case required a tool, so no line means "not checked"), a **"Missing tool
|
|
226
|
+
calls"** section in the Markdown report, and `totals.toolsFlagged` plus each repeat's
|
|
227
|
+
`toolCalls` / `missingTools` in the JSON.
|
|
188
228
|
- **Choosing the judge.** By default the judge runs on the same model as the grader.
|
|
189
229
|
`--judge-llm-provider` + `--judge-llm-model` (both or neither) point it at another one,
|
|
190
230
|
which is the **recommended** setup: a strong judge over a smaller grader finds real
|
package/dist/main.js
CHANGED
|
@@ -890,10 +890,19 @@ const QUIZ_VERDICT_SCHEMA = z.object({
|
|
|
890
890
|
feedback: z.string()
|
|
891
891
|
});
|
|
892
892
|
const QUIZ_VERDICT_ENUM = QUIZ_VERDICT_SCHEMA.shape.result;
|
|
893
|
+
/** The three verdict literals, in the canonical best→worst order. */
|
|
894
|
+
const QUIZ_VERDICT_VALUES = QUIZ_VERDICT_ENUM.options;
|
|
895
|
+
//#endregion
|
|
896
|
+
//#region ../lib/tutor-tools/names.ts
|
|
897
|
+
const TUTOR_TOOL_NAMES = ["random_number"];
|
|
898
|
+
const tutorToolNameSchema = z.enum(TUTOR_TOOL_NAMES).meta({
|
|
899
|
+
id: "tutorToolName",
|
|
900
|
+
description: "Name of a built-in tutor tool."
|
|
901
|
+
});
|
|
893
902
|
//#endregion
|
|
894
903
|
//#region ../lib/eval-schema.ts
|
|
895
904
|
/** The three verdicts in canonical order (best → worst); the sort key for expected sets. */
|
|
896
|
-
const EVAL_VERDICTS =
|
|
905
|
+
const EVAL_VERDICTS = QUIZ_VERDICT_VALUES;
|
|
897
906
|
/**
|
|
898
907
|
* Eval ids share the flat namespace of report headers and `--out` files, so they stay
|
|
899
908
|
* URL- and YAML-plain: an alphanumeric start, then alphanumerics, `.`, `-` or `_`.
|
|
@@ -911,13 +920,72 @@ const EvalQuestionSchema = z.strictObject({
|
|
|
911
920
|
question: z.string().min(1).meta({ description: "The question id in the target quiz. For a question imported through quiz_files this is the namespaced \"<alias>/<id>\" form." }),
|
|
912
921
|
answers: z.array(EvalAnswerSchema).min(1).meta({ description: "The golden answers for this question — at least one." })
|
|
913
922
|
});
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
923
|
+
const idSchema = z.string().regex(EVAL_ID_PATTERN).max(MAX_ID_LENGTH).meta({ description: "Stable identifier of this eval, shown in the run report. Letters, digits, dot, dash and underscore." });
|
|
924
|
+
/** The QUIZ arm: golden answers replayed through the real grader. */
|
|
925
|
+
const QuizEvalYamlSchema = z.strictObject({
|
|
926
|
+
kind: z.literal("quiz").optional().meta({ description: "The eval kind. Omit it (or write \"quiz\") for a golden-answer eval of a quiz rubric." }),
|
|
927
|
+
id: idSchema,
|
|
917
928
|
target: z.string().min(1).meta({ description: "The quiz YAML this eval grades against — a path relative to THIS file, or an absolute http(s) URL." }),
|
|
918
929
|
questions: z.array(EvalQuestionSchema).min(1).meta({ description: "The evaluated questions — at least one, each with its golden answers." })
|
|
919
930
|
});
|
|
920
931
|
/**
|
|
932
|
+
* ONE turn of a scripted conversation: a single-key map naming its speaker. The two
|
|
933
|
+
* TEACHER-facing role names (`student` / `tutor`) are deliberate — the wire roles
|
|
934
|
+
* (`user` / `assistant`) are an implementation detail nobody should have to author.
|
|
935
|
+
*/
|
|
936
|
+
const EvalConversationTurnSchema = z.union([z.strictObject({ student: z.string().min(1).meta({ description: "What the student says in this turn." }) }), z.strictObject({ tutor: z.string().min(1).meta({ description: "What the tutor already said in this turn — scripted by the teacher, not generated." }) })]);
|
|
937
|
+
/** The last turn a conversation may end on: the student message the model must answer. */
|
|
938
|
+
function endsWithStudentTurn(turns) {
|
|
939
|
+
const last = turns.at(-1);
|
|
940
|
+
return last !== void 0 && "student" in last;
|
|
941
|
+
}
|
|
942
|
+
/**
|
|
943
|
+
* The tool names a case may REQUIRE, derived from the catalog's own name list
|
|
944
|
+
* (`lib/tutor-tools/names.ts`) rather than a mirrored literal set — so a tool added to the
|
|
945
|
+
* catalog is immediately requirable and a typo fails `validate` offline with a named enum
|
|
946
|
+
* error, no run and no tokens spent.
|
|
947
|
+
*
|
|
948
|
+
* Non-empty and UNIQUE: an empty list would say nothing (write no `required_tools` at
|
|
949
|
+
* all), and a repeated name is always an authoring slip — the check is "called at least
|
|
950
|
+
* once", so naming a tool twice cannot mean anything a single mention does not.
|
|
951
|
+
*/
|
|
952
|
+
const requiredToolsSchema = z.array(z.enum(TUTOR_TOOL_NAMES)).min(1).refine((tools) => new Set(tools).size === tools.length, { message: "Each tool may be listed only once." }).meta({ description: "Optional list of built-in tool names the tutor must call AT LEAST ONCE while answering this case (e.g. [random_number]). Reported only — a missing tool call never fails the run. Tools beyond this list are always fine, and every name must be one the target tutor's own `tools:` grant contains." });
|
|
953
|
+
/** ONE tutor case: a scripted conversation plus the teacher's optional expectations. */
|
|
954
|
+
const EvalConversationSchema = z.strictObject({
|
|
955
|
+
title: z.string().min(1).max(MAX_ID_LENGTH).optional().meta({ description: "Optional short label for this case, used as its stable heading in the run report." }),
|
|
956
|
+
required_tools: requiredToolsSchema.optional(),
|
|
957
|
+
grading_instructions: z.string().min(1).optional().meta({ description: "Optional extra expectations for THIS case, judged alongside the tutor's own system prompt (e.g. \"the response must not contain a complete working loop\")." }),
|
|
958
|
+
conversation: z.array(EvalConversationTurnSchema).min(1).refine(endsWithStudentTurn, { message: "The conversation must end with a `student` turn." }).meta({ description: "The scripted exchange, in order: `student:` and `tutor:` turns. It must END with a `student:` turn — that is the message the model under test answers." })
|
|
959
|
+
});
|
|
960
|
+
/** The TUTOR arm: conversations whose next tutor turn is generated and judged. */
|
|
961
|
+
const TutorEvalYamlSchema = z.strictObject({
|
|
962
|
+
kind: z.literal("tutor").meta({ description: "The eval kind. \"tutor\" evaluates a tutor's next response in a conversation." }),
|
|
963
|
+
id: idSchema,
|
|
964
|
+
target: z.string().min(1).meta({ description: "The tutor YAML this eval runs against — a path relative to THIS file, or an absolute http(s) URL." }),
|
|
965
|
+
conversations: z.array(EvalConversationSchema).min(1).meta({ description: "The evaluated conversations — at least one; each one is a case." })
|
|
966
|
+
});
|
|
967
|
+
/**
|
|
968
|
+
* The whole eval file: quiz (the default, `kind` omissible) or tutor.
|
|
969
|
+
*
|
|
970
|
+
* A discriminated union rather than a loose one, so a `kind: tutor` file with a typo in
|
|
971
|
+
* `conversations` reports THAT problem instead of "no union member matched".
|
|
972
|
+
*/
|
|
973
|
+
const EvalYamlSchema = z.discriminatedUnion("kind", [QuizEvalYamlSchema, TutorEvalYamlSchema]);
|
|
974
|
+
/** The eval file's kind, with the quiz arm's omitted `kind` resolved to its default. */
|
|
975
|
+
function evalKindOf(evalFile) {
|
|
976
|
+
return evalFile.kind ?? "quiz";
|
|
977
|
+
}
|
|
978
|
+
/** A scripted turn as `{ role, text }` — the wire shape `POST /api/eval/respond` takes. */
|
|
979
|
+
function turnToMessage(turn) {
|
|
980
|
+
return "student" in turn ? {
|
|
981
|
+
role: "user",
|
|
982
|
+
text: turn.student
|
|
983
|
+
} : {
|
|
984
|
+
role: "assistant",
|
|
985
|
+
text: turn.tutor
|
|
986
|
+
};
|
|
987
|
+
}
|
|
988
|
+
/**
|
|
921
989
|
* The canonical expected-verdict SET of one golden answer: a single verdict or a list,
|
|
922
990
|
* deduped and sorted into `EVAL_VERDICTS` order. Canonical because the confusion
|
|
923
991
|
* matrix keys its rows by this set — `correct|partial` must be one row no matter which
|
|
@@ -2591,10 +2659,6 @@ async function loadQuizFrom(url, fetcher, opts = {}) {
|
|
|
2591
2659
|
};
|
|
2592
2660
|
}
|
|
2593
2661
|
}
|
|
2594
|
-
const tutorToolNameSchema = z.enum(["random_number"]).meta({
|
|
2595
|
-
id: "tutorToolName",
|
|
2596
|
-
description: "Name of a built-in tutor tool."
|
|
2597
|
-
});
|
|
2598
2662
|
//#endregion
|
|
2599
2663
|
//#region ../lib/tutors/schemas.ts
|
|
2600
2664
|
/**
|
|
@@ -3252,7 +3316,7 @@ function schemaErrors(issues, url) {
|
|
|
3252
3316
|
/**
|
|
3253
3317
|
* Check ONE eval file end to end. `fetcher` is the caller's network seam and
|
|
3254
3318
|
* `allowedSchemes` the usual SSRF gate (the CLI adds `file:` so an on-disk eval
|
|
3255
|
-
* resolves the
|
|
3319
|
+
* resolves the activity sitting next to it).
|
|
3256
3320
|
*/
|
|
3257
3321
|
async function loadAndCheckEval(url, fetchImpl, opts = {}) {
|
|
3258
3322
|
const allowedSchemes = opts.allowedSchemes;
|
|
@@ -3261,6 +3325,7 @@ async function loadAndCheckEval(url, fetchImpl, opts = {}) {
|
|
|
3261
3325
|
const parsed = EvalYamlSchema.safeParse(yaml.value);
|
|
3262
3326
|
if (!parsed.success) return fail(schemaErrors(parsed.error.issues, url));
|
|
3263
3327
|
const evalFile = parsed.data;
|
|
3328
|
+
const kind = evalKindOf(evalFile);
|
|
3264
3329
|
let targetUrl;
|
|
3265
3330
|
try {
|
|
3266
3331
|
targetUrl = new URL(evalFile.target, url).href;
|
|
@@ -3278,18 +3343,37 @@ async function loadAndCheckEval(url, fetchImpl, opts = {}) {
|
|
|
3278
3343
|
}
|
|
3279
3344
|
const warnings = [];
|
|
3280
3345
|
if (opts.strictTarget) {
|
|
3281
|
-
const strict = await
|
|
3346
|
+
const strict = kind === "tutor" ? await loadAndBuildTutorPrompt(targetUrl, fetchImpl, {
|
|
3347
|
+
allowedSchemes,
|
|
3348
|
+
validateLibraries: opts.validateLibraries ?? true
|
|
3349
|
+
}) : await loadAndCheckQuiz(targetUrl, fetchImpl, {
|
|
3282
3350
|
allowedSchemes,
|
|
3283
3351
|
validateLibraries: opts.validateLibraries ?? true
|
|
3284
3352
|
});
|
|
3285
3353
|
warnings.push(...strict.warnings);
|
|
3286
3354
|
if (!strict.ok) return fail(strict.errors, warnings);
|
|
3287
3355
|
}
|
|
3288
|
-
const dumped = await dumpPrompts(
|
|
3289
|
-
if (!dumped.ok) return fail(dumped.errors.map((e) => error("EVAL_TARGET_ERROR", `The target
|
|
3290
|
-
const
|
|
3291
|
-
if (
|
|
3292
|
-
|
|
3356
|
+
const dumped = await dumpPrompts(kind, targetUrl, fetchImpl, { allowedSchemes });
|
|
3357
|
+
if (!dumped.ok) return fail(dumped.errors.map((e) => error("EVAL_TARGET_ERROR", `The target ${kind} could not be loaded: ${e.message}`, { url: targetUrl })), warnings);
|
|
3358
|
+
const dump = dumped.dump;
|
|
3359
|
+
if (dump.kind !== kind) return fail([error("EVAL_TARGET_ERROR", `The target is not a ${kind}.`, { url: targetUrl })], warnings);
|
|
3360
|
+
if (dump.kind === "tutor" && evalFile.kind === "tutor") {
|
|
3361
|
+
const granted = new Set(dump.tools);
|
|
3362
|
+
const ungranted = evalFile.conversations.flatMap((conversation, index) => (conversation.required_tools ?? []).filter((tool) => !granted.has(tool)).map((tool) => error("EVAL_UNGRANTED_TOOL", `Conversation #${index + 1} requires the tool "${tool}", but the target tutor's tools are ${dump.tools.length ? dump.tools.map((name) => `"${name}"`).join(", ") : "(none)"}.`, { url: targetUrl })));
|
|
3363
|
+
if (ungranted.length > 0) return fail(ungranted, warnings);
|
|
3364
|
+
return {
|
|
3365
|
+
ok: true,
|
|
3366
|
+
kind: "tutor",
|
|
3367
|
+
evalFile,
|
|
3368
|
+
targetUrl,
|
|
3369
|
+
llm: dump.llm,
|
|
3370
|
+
tutorDump: dump,
|
|
3371
|
+
caseCount: evalFile.conversations.length,
|
|
3372
|
+
warnings
|
|
3373
|
+
};
|
|
3374
|
+
}
|
|
3375
|
+
if (dump.kind !== "quiz" || evalFile.kind === "tutor") return fail([error("EVAL_TARGET_ERROR", `The target is not a ${kind}.`, { url: targetUrl })], warnings);
|
|
3376
|
+
const known = new Set(dump.grading.questions.map((question) => question.id));
|
|
3293
3377
|
const unknown = evalFile.questions.filter((question) => !known.has(question.question)).map((question) => error("EVAL_UNKNOWN_QUESTION", `The target quiz has no question "${question.question}".`, {
|
|
3294
3378
|
questionId: question.question,
|
|
3295
3379
|
url: targetUrl
|
|
@@ -3302,9 +3386,11 @@ async function loadAndCheckEval(url, fetchImpl, opts = {}) {
|
|
|
3302
3386
|
})) : [];
|
|
3303
3387
|
return {
|
|
3304
3388
|
ok: true,
|
|
3389
|
+
kind: "quiz",
|
|
3305
3390
|
evalFile,
|
|
3306
3391
|
targetUrl,
|
|
3307
|
-
|
|
3392
|
+
llm: dump.llm,
|
|
3393
|
+
quizDump: dump,
|
|
3308
3394
|
quizQuestions,
|
|
3309
3395
|
caseCount: evalFile.questions.reduce((sum, question) => sum + question.answers.length, 0),
|
|
3310
3396
|
warnings
|
|
@@ -3394,6 +3480,106 @@ function buildFeedbackJudgeSubject(gradingSystem, answer, verdict, feedback) {
|
|
|
3394
3480
|
].join("\n");
|
|
3395
3481
|
}
|
|
3396
3482
|
//#endregion
|
|
3483
|
+
//#region ../lib/tutor-judge.ts
|
|
3484
|
+
/**
|
|
3485
|
+
* The TUTOR-response taxonomy. The judge may only name one of these, and the endpoint
|
|
3486
|
+
* constrains the model to whatever the CALLER sent (`judgmentSchema`) — which is what
|
|
3487
|
+
* keeps `/api/eval/judge` kind-agnostic.
|
|
3488
|
+
*
|
|
3489
|
+
* Deliberately NOT documented in code comments: every definition lives in
|
|
3490
|
+
* {@link TUTOR_JUDGE_SYSTEM}, where the model actually reads it, so the two can never
|
|
3491
|
+
* drift apart.
|
|
3492
|
+
*/
|
|
3493
|
+
const TUTOR_JUDGE_CRITERIA = [
|
|
3494
|
+
"ignores_instructions",
|
|
3495
|
+
"fails_expectations",
|
|
3496
|
+
"misstates_facts",
|
|
3497
|
+
"leaks_prompt"
|
|
3498
|
+
];
|
|
3499
|
+
/** The criterion that only exists when the teacher stated expectations for the case. */
|
|
3500
|
+
const EXPECTATIONS_CRITERION = "fails_expectations";
|
|
3501
|
+
/**
|
|
3502
|
+
* The criteria ONE case's judge call may use. A case without `grading_instructions`
|
|
3503
|
+
* drops `fails_expectations` entirely — the per-request enum makes that free, and it
|
|
3504
|
+
* removes by construction the failure mode of a judge inventing expectations nobody
|
|
3505
|
+
* stated.
|
|
3506
|
+
*/
|
|
3507
|
+
function tutorJudgeCriteria(hasGradingInstructions) {
|
|
3508
|
+
return TUTOR_JUDGE_CRITERIA.filter((criterion) => hasGradingInstructions || criterion !== EXPECTATIONS_CRITERION);
|
|
3509
|
+
}
|
|
3510
|
+
/**
|
|
3511
|
+
* The judge's system prompt. The three guardrails measured on the quiz judge before
|
|
3512
|
+
* shipping carry over verbatim in spirit:
|
|
3513
|
+
*
|
|
3514
|
+
* * "Do NOT judge pedagogical quality or style" — the tutor analogue of the quiz
|
|
3515
|
+
* judge's "do not judge the verdict": a judge that rates teaching craft produces
|
|
3516
|
+
* noise the report cannot act on. Compliance is the whole job.
|
|
3517
|
+
* * "be strict about real violations, but do not invent issues … when in doubt, the
|
|
3518
|
+
* response is ok" — without it, weak models flag matters of taste.
|
|
3519
|
+
* * an EMPTY `issues` array is the way to say "acceptable". There is deliberately no
|
|
3520
|
+
* `ok` boolean: weak judges set `ok: false` and then name no issue at all, which is
|
|
3521
|
+
* unreportable. Flagged ⇔ an issue was named.
|
|
3522
|
+
*/
|
|
3523
|
+
const TUTOR_JUDGE_SYSTEM = `You are auditing ONE response an AI TUTOR gave to a student.
|
|
3524
|
+
|
|
3525
|
+
You receive:
|
|
3526
|
+
- the complete system prompt the tutor was given (it contains the persona, the
|
|
3527
|
+
course rules and the scope the tutor must stay inside),
|
|
3528
|
+
- the conversation so far, turn by turn, ending with the student message the
|
|
3529
|
+
tutor had to answer,
|
|
3530
|
+
- the response the tutor generated for that last student message,
|
|
3531
|
+
- and, when the teacher wrote any, extra expectations for THIS case.
|
|
3532
|
+
|
|
3533
|
+
Judge ONLY the generated tutor response, on these criteria:
|
|
3534
|
+
|
|
3535
|
+
- "ignores_instructions": the response breaks an explicit rule of the tutor's
|
|
3536
|
+
own system prompt — e.g. it writes out the complete solution although the
|
|
3537
|
+
prompt forbids that, leaves the concepts the prompt limits it to, answers in
|
|
3538
|
+
a language the prompt does not allow, or ignores a stated formatting rule.
|
|
3539
|
+
- "fails_expectations": the response violates the teacher's extra expectations
|
|
3540
|
+
for this case, when such expectations were given.
|
|
3541
|
+
- "misstates_facts": the response asserts something that is factually wrong for
|
|
3542
|
+
the subject matter — bad code, a wrong term, an untrue claim.
|
|
3543
|
+
- "leaks_prompt": the response quotes or reveals its own instructions, or talks
|
|
3544
|
+
about "my rules", "my prompt", "the instructions I was given".
|
|
3545
|
+
|
|
3546
|
+
Do NOT judge pedagogical quality, tone, length or style — a response you would
|
|
3547
|
+
have written differently is NOT an issue. Judge COMPLIANCE with the system
|
|
3548
|
+
prompt and with the stated expectations, nothing else. Be strict about real
|
|
3549
|
+
violations, but do not invent issues. When in doubt, the response is ok.
|
|
3550
|
+
|
|
3551
|
+
Return one entry in "issues" per violation you found, and an EMPTY "issues"
|
|
3552
|
+
array when the response is acceptable. Answer with the JSON object only.`;
|
|
3553
|
+
/** `student:` / `tutor:` — the teacher-facing role labels, also used in the subject. */
|
|
3554
|
+
function turnLabel(turn) {
|
|
3555
|
+
return "student" in turn ? `student: ${turn.student}` : `tutor: ${turn.tutor}`;
|
|
3556
|
+
}
|
|
3557
|
+
/**
|
|
3558
|
+
* The judge's USER message: the inputs in labeled `===` blocks — the tutor's system
|
|
3559
|
+
* prompt (the standard), the scripted conversation, the response under judgment, the
|
|
3560
|
+
* tools the tutor actually reached for, and the teacher's expectations when the case
|
|
3561
|
+
* states any.
|
|
3562
|
+
*
|
|
3563
|
+
* Nothing is escaped — every part is DATA for the judge, not markup, and a course prompt
|
|
3564
|
+
* containing `===` or Markdown must reach the judge exactly as the tutor saw it.
|
|
3565
|
+
*/
|
|
3566
|
+
function buildTutorJudgeSubject(tutorSystem, conversation, response, options = {}) {
|
|
3567
|
+
const { gradingInstructions, tools, toolCalls } = options;
|
|
3568
|
+
const blocks = [
|
|
3569
|
+
"=== The system prompt the tutor was given ===",
|
|
3570
|
+
tutorSystem,
|
|
3571
|
+
"",
|
|
3572
|
+
"=== The conversation so far (the last turn is what the tutor answered) ===",
|
|
3573
|
+
conversation.map(turnLabel).join("\n\n"),
|
|
3574
|
+
"",
|
|
3575
|
+
"=== The tutor's generated response (JUDGE THIS) ===",
|
|
3576
|
+
response
|
|
3577
|
+
];
|
|
3578
|
+
if (tools !== void 0 && tools.length > 0 && toolCalls !== void 0) blocks.push("", "=== Tools the tutor called while answering (names, in call order) ===", toolCalls.length > 0 ? toolCalls.join("\n") : "(none)");
|
|
3579
|
+
if (gradingInstructions) blocks.push("", "=== The teacher's expectations for this case ===", gradingInstructions);
|
|
3580
|
+
return blocks.join("\n");
|
|
3581
|
+
}
|
|
3582
|
+
//#endregion
|
|
3397
3583
|
//#region src/retry.ts
|
|
3398
3584
|
const defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
3399
3585
|
/**
|
|
@@ -3455,6 +3641,10 @@ function createJudgeBreaker() {
|
|
|
3455
3641
|
}
|
|
3456
3642
|
/** Consecutive fully-errored judge calls that mean "stop judging for the rest of the run". */
|
|
3457
3643
|
const JUDGE_BREAKER_LIMIT = 3;
|
|
3644
|
+
/** Narrow a case to the tutor arm. */
|
|
3645
|
+
function isTutorCase(evalCase) {
|
|
3646
|
+
return "conversation" in evalCase;
|
|
3647
|
+
}
|
|
3458
3648
|
/** Consecutive fully-errored cases that mean "the server is down, stop now". */
|
|
3459
3649
|
const CIRCUIT_BREAKER_LIMIT = 3;
|
|
3460
3650
|
/** Flatten questions × answers into cases, each carrying its grading prompt. */
|
|
@@ -3495,38 +3685,30 @@ function judgeErrorMessage(error) {
|
|
|
3495
3685
|
if (typeof error === "object" && error !== null && "message" in error) return String(error.message);
|
|
3496
3686
|
return JSON.stringify(error ?? null);
|
|
3497
3687
|
}
|
|
3498
|
-
/**
|
|
3499
|
-
|
|
3500
|
-
|
|
3501
|
-
|
|
3502
|
-
|
|
3503
|
-
|
|
3504
|
-
|
|
3505
|
-
|
|
3506
|
-
|
|
3507
|
-
|
|
3508
|
-
|
|
3509
|
-
|
|
3510
|
-
|
|
3511
|
-
|
|
3512
|
-
|
|
3513
|
-
|
|
3514
|
-
|
|
3515
|
-
|
|
3516
|
-
|
|
3517
|
-
|
|
3518
|
-
|
|
3519
|
-
* merge onto the row: a judgment, or `judge: null` plus a `judgeError` when the call
|
|
3520
|
-
* failed, or a bare `judge: null` when the breaker had already degraded the run.
|
|
3521
|
-
*/
|
|
3522
|
-
const judgeRepeat = async (system, answer, verdict, feedback) => {
|
|
3688
|
+
/**
|
|
3689
|
+
* The `judge` field of a repeat that produced NO judgment — spread onto every row that
|
|
3690
|
+
* is not a successful generation. Judging on ⇒ an explicit `null` (so a script reading
|
|
3691
|
+
* `judge === null` catches every unjudged repeat, not only the degraded ones); judging
|
|
3692
|
+
* off ⇒ nothing at all, since the whole run then carries no judge fields.
|
|
3693
|
+
*/
|
|
3694
|
+
function unjudgedFields(options) {
|
|
3695
|
+
return options.judge ? { judge: null } : {};
|
|
3696
|
+
}
|
|
3697
|
+
/**
|
|
3698
|
+
* The KIND-AGNOSTIC judge step: judge ONE repeat's output as a dependent step of that
|
|
3699
|
+
* repeat, retrying and feeding the run-wide degrade breaker. Each kind assembles its own
|
|
3700
|
+
* `system` / `subject` / `criteria` (quiz via `lib/quiz-feedback-judge.ts`, tutor via
|
|
3701
|
+
* `lib/tutor-judge.ts`) — the endpoint and this step never learn the kind.
|
|
3702
|
+
*
|
|
3703
|
+
* Returns the fields to merge onto the row: a judgment, or `judge: null` plus a
|
|
3704
|
+
* `judgeError` when the call failed, or a bare `judge: null` when the breaker had
|
|
3705
|
+
* already degraded the run.
|
|
3706
|
+
*/
|
|
3707
|
+
function createJudgeStep(options, breaker) {
|
|
3708
|
+
return async (request) => {
|
|
3523
3709
|
const judge = options.judge;
|
|
3524
3710
|
if (!judge || breaker.stopped) return { judge: null };
|
|
3525
|
-
const outcome = await withRetry(() => judge({
|
|
3526
|
-
system: FEEDBACK_JUDGE_SYSTEM,
|
|
3527
|
-
subject: buildFeedbackJudgeSubject(system, answer, verdict, feedback),
|
|
3528
|
-
criteria: FEEDBACK_JUDGE_CRITERIA
|
|
3529
|
-
}), {
|
|
3711
|
+
const outcome = await withRetry(() => judge(request), {
|
|
3530
3712
|
attempts: options.retry?.attempts,
|
|
3531
3713
|
baseDelayMs: options.retry?.baseDelayMs,
|
|
3532
3714
|
sleep: options.retry?.sleep,
|
|
@@ -3549,6 +3731,33 @@ const evalRunners = { quiz: { async run(checked, options) {
|
|
|
3549
3731
|
judgeError: judgeErrorMessage(outcome.error)
|
|
3550
3732
|
};
|
|
3551
3733
|
};
|
|
3734
|
+
}
|
|
3735
|
+
const quizEvalRunner = { async run(rawChecked, options) {
|
|
3736
|
+
if (rawChecked.kind !== "quiz") throw new Error("The quiz eval runner needs a quiz eval file.");
|
|
3737
|
+
const checked = rawChecked;
|
|
3738
|
+
const grade = options.grade;
|
|
3739
|
+
if (!grade) throw new Error("The quiz eval runner needs a `grade` seam.");
|
|
3740
|
+
const repeats = Math.max(1, Math.floor(options.repeats ?? 1));
|
|
3741
|
+
const concurrency = Math.max(1, Math.floor(options.concurrency ?? 4));
|
|
3742
|
+
const planned = planCases(checked);
|
|
3743
|
+
const total = planned.length * repeats;
|
|
3744
|
+
const questionTexts = new Map(checked.quizQuestions.map((q) => [q.id, q.text]));
|
|
3745
|
+
const breaker = options.judgeBreaker ?? createJudgeBreaker();
|
|
3746
|
+
const judgeStep = createJudgeStep(options, breaker);
|
|
3747
|
+
let done = 0;
|
|
3748
|
+
let consecutiveErrored = 0;
|
|
3749
|
+
let aborted;
|
|
3750
|
+
const unjudged = unjudgedFields(options);
|
|
3751
|
+
/**
|
|
3752
|
+
* Judge ONE graded repeat's feedback, against the repeat's OWN verdict — never the
|
|
3753
|
+
* case majority: an outvoted repeat's feedback is consistent with the verdict it
|
|
3754
|
+
* actually got.
|
|
3755
|
+
*/
|
|
3756
|
+
const judgeRepeat = (system, answer, verdict, feedback) => judgeStep({
|
|
3757
|
+
system: FEEDBACK_JUDGE_SYSTEM,
|
|
3758
|
+
subject: buildFeedbackJudgeSubject(system, answer, verdict, feedback),
|
|
3759
|
+
criteria: FEEDBACK_JUDGE_CRITERIA
|
|
3760
|
+
});
|
|
3552
3761
|
const progress = () => {
|
|
3553
3762
|
done += 1;
|
|
3554
3763
|
options.onProgress?.({
|
|
@@ -3569,7 +3778,7 @@ const evalRunners = { quiz: { async run(checked, options) {
|
|
|
3569
3778
|
progress();
|
|
3570
3779
|
continue;
|
|
3571
3780
|
}
|
|
3572
|
-
const outcome = await withRetry(() =>
|
|
3781
|
+
const outcome = await withRetry(() => grade({
|
|
3573
3782
|
system: plan.system,
|
|
3574
3783
|
answer: plan.answer
|
|
3575
3784
|
}), {
|
|
@@ -3623,6 +3832,7 @@ const evalRunners = { quiz: { async run(checked, options) {
|
|
|
3623
3832
|
...winner ? { verdict: winner.verdict } : {},
|
|
3624
3833
|
unstable: new Set(graded).size > 1,
|
|
3625
3834
|
feedbackFlagged: rows.some((row) => (row.judge?.issues.length ?? 0) > 0),
|
|
3835
|
+
toolsFlagged: false,
|
|
3626
3836
|
repeats: rows
|
|
3627
3837
|
};
|
|
3628
3838
|
});
|
|
@@ -3639,6 +3849,7 @@ const evalRunners = { quiz: { async run(checked, options) {
|
|
|
3639
3849
|
skipped: results.filter((c) => c.status === "skipped").length,
|
|
3640
3850
|
unstable: results.filter((c) => c.unstable).length,
|
|
3641
3851
|
feedbackFlagged: results.filter((c) => c.feedbackFlagged).length,
|
|
3852
|
+
toolsFlagged: 0,
|
|
3642
3853
|
judgeErrored: results.reduce((sum, c) => sum + c.repeats.filter((row) => row.judgeError !== void 0).length, 0),
|
|
3643
3854
|
repeats,
|
|
3644
3855
|
calls: total,
|
|
@@ -3647,7 +3858,7 @@ const evalRunners = { quiz: { async run(checked, options) {
|
|
|
3647
3858
|
const confusionCounts = /* @__PURE__ */ new Map();
|
|
3648
3859
|
for (const result of results) {
|
|
3649
3860
|
if (!result.verdict) continue;
|
|
3650
|
-
const key = `${expectedKey(result.expected)}
|
|
3861
|
+
const key = `${expectedKey(result.expected)}\u0000${result.verdict}`;
|
|
3651
3862
|
confusionCounts.set(key, (confusionCounts.get(key) ?? 0) + 1);
|
|
3652
3863
|
}
|
|
3653
3864
|
const confusion = [...confusionCounts.entries()].map(([key, count]) => {
|
|
@@ -3662,6 +3873,7 @@ const evalRunners = { quiz: { async run(checked, options) {
|
|
|
3662
3873
|
const falseCorrectCount = strictCases.filter((result) => result.verdict === "correct").length;
|
|
3663
3874
|
return {
|
|
3664
3875
|
id: checked.evalFile.id,
|
|
3876
|
+
kind: "quiz",
|
|
3665
3877
|
target: checked.targetUrl,
|
|
3666
3878
|
llm: options.llm,
|
|
3667
3879
|
judging: !options.judge ? "off" : breaker.stopped ? "degraded" : "on",
|
|
@@ -3680,7 +3892,178 @@ const evalRunners = { quiz: { async run(checked, options) {
|
|
|
3680
3892
|
},
|
|
3681
3893
|
...aborted ? { aborted } : {}
|
|
3682
3894
|
};
|
|
3683
|
-
} }
|
|
3895
|
+
} };
|
|
3896
|
+
/** One planned case per conversation, in file order. */
|
|
3897
|
+
function planTutorCases(checked) {
|
|
3898
|
+
return checked.evalFile.conversations.map((conversation, index) => ({
|
|
3899
|
+
index,
|
|
3900
|
+
...conversation.title ? { title: conversation.title } : {},
|
|
3901
|
+
conversation: conversation.conversation,
|
|
3902
|
+
...conversation.grading_instructions ? { gradingInstructions: conversation.grading_instructions } : {},
|
|
3903
|
+
...conversation.required_tools ? { requiredTools: [...conversation.required_tools] } : {},
|
|
3904
|
+
messages: conversation.conversation.map(turnToMessage)
|
|
3905
|
+
}));
|
|
3906
|
+
}
|
|
3907
|
+
/**
|
|
3908
|
+
* The message a repeat carries when the case REQUIRES tools but the 200 answered without a
|
|
3909
|
+
* `toolCalls` field: a new CLI against a server too old to report them. Terminal and loud
|
|
3910
|
+
* — reporting "nothing missing" for a check that never ran would certify a tool
|
|
3911
|
+
* expectation nobody verified, which is worse than failing. The advisory `/api/version`
|
|
3912
|
+
* check cannot carry this: it only warns (never gates, never compares an ordering), while
|
|
3913
|
+
* this must fail the run's health.
|
|
3914
|
+
*/
|
|
3915
|
+
const NO_TOOL_CALLS_REPORTED = "This case declares `required_tools`, but the server's answer carried no tool calls — it is too old to report them, so the requirement could not be checked. Update the Novedu server, or remove `required_tools` from this case.";
|
|
3916
|
+
/** The required tools this repeat never called, in the case's own order. */
|
|
3917
|
+
function missingToolsOf(required, called) {
|
|
3918
|
+
const seen = new Set(called);
|
|
3919
|
+
return required.filter((tool) => !seen.has(tool));
|
|
3920
|
+
}
|
|
3921
|
+
/** The seam: one runner per eval kind (mirrors `promptDumpers`). */
|
|
3922
|
+
const evalRunners = {
|
|
3923
|
+
quiz: quizEvalRunner,
|
|
3924
|
+
tutor: { async run(rawChecked, options) {
|
|
3925
|
+
if (rawChecked.kind !== "tutor") throw new Error("The tutor eval runner needs a tutor eval file.");
|
|
3926
|
+
const checked = rawChecked;
|
|
3927
|
+
const respond = options.respond;
|
|
3928
|
+
if (!respond) throw new Error("The tutor eval runner needs a `respond` seam.");
|
|
3929
|
+
const repeats = Math.max(1, Math.floor(options.repeats ?? 1));
|
|
3930
|
+
const concurrency = Math.max(1, Math.floor(options.concurrency ?? 4));
|
|
3931
|
+
const planned = planTutorCases(checked);
|
|
3932
|
+
const total = planned.length * repeats;
|
|
3933
|
+
const system = checked.tutorDump.system;
|
|
3934
|
+
const tools = checked.tutorDump.tools;
|
|
3935
|
+
const breaker = options.judgeBreaker ?? createJudgeBreaker();
|
|
3936
|
+
const judgeStep = createJudgeStep(options, breaker);
|
|
3937
|
+
let done = 0;
|
|
3938
|
+
let consecutiveErrored = 0;
|
|
3939
|
+
let aborted;
|
|
3940
|
+
const unjudged = unjudgedFields(options);
|
|
3941
|
+
const progress = () => {
|
|
3942
|
+
done += 1;
|
|
3943
|
+
options.onProgress?.({
|
|
3944
|
+
done,
|
|
3945
|
+
total
|
|
3946
|
+
});
|
|
3947
|
+
};
|
|
3948
|
+
const results = await mapWithConcurrency(planned, concurrency, async (plan) => {
|
|
3949
|
+
const rows = [];
|
|
3950
|
+
for (let repeatIndex = 0; repeatIndex < repeats; repeatIndex++) {
|
|
3951
|
+
if (aborted) break;
|
|
3952
|
+
const outcome = await withRetry(() => respond({
|
|
3953
|
+
system,
|
|
3954
|
+
tools,
|
|
3955
|
+
messages: plan.messages
|
|
3956
|
+
}), {
|
|
3957
|
+
attempts: options.retry?.attempts,
|
|
3958
|
+
baseDelayMs: options.retry?.baseDelayMs,
|
|
3959
|
+
sleep: options.retry?.sleep,
|
|
3960
|
+
shouldRetry: (value) => !value.ok && value.retryable && value.auth !== true
|
|
3961
|
+
});
|
|
3962
|
+
if (outcome.ok) {
|
|
3963
|
+
if (plan.requiredTools && outcome.toolCalls === void 0) {
|
|
3964
|
+
progress();
|
|
3965
|
+
rows.push({
|
|
3966
|
+
repeatIndex,
|
|
3967
|
+
error: { message: NO_TOOL_CALLS_REPORTED },
|
|
3968
|
+
...unjudged
|
|
3969
|
+
});
|
|
3970
|
+
break;
|
|
3971
|
+
}
|
|
3972
|
+
const missingTools = plan.requiredTools ? missingToolsOf(plan.requiredTools, outcome.toolCalls ?? []) : void 0;
|
|
3973
|
+
const judged = options.judge ? await judgeStep({
|
|
3974
|
+
system: TUTOR_JUDGE_SYSTEM,
|
|
3975
|
+
subject: buildTutorJudgeSubject(system, plan.conversation, outcome.text, {
|
|
3976
|
+
...plan.gradingInstructions ? { gradingInstructions: plan.gradingInstructions } : {},
|
|
3977
|
+
tools,
|
|
3978
|
+
...outcome.toolCalls ? { toolCalls: outcome.toolCalls } : {}
|
|
3979
|
+
}),
|
|
3980
|
+
criteria: tutorJudgeCriteria(plan.gradingInstructions !== void 0)
|
|
3981
|
+
}) : {};
|
|
3982
|
+
progress();
|
|
3983
|
+
rows.push({
|
|
3984
|
+
repeatIndex,
|
|
3985
|
+
text: outcome.text,
|
|
3986
|
+
...outcome.toolCalls ? { toolCalls: outcome.toolCalls } : {},
|
|
3987
|
+
...missingTools ? { missingTools } : {},
|
|
3988
|
+
...outcome.usage ? { usage: outcome.usage } : {},
|
|
3989
|
+
...judged
|
|
3990
|
+
});
|
|
3991
|
+
continue;
|
|
3992
|
+
}
|
|
3993
|
+
progress();
|
|
3994
|
+
rows.push({
|
|
3995
|
+
repeatIndex,
|
|
3996
|
+
error: outcome.error,
|
|
3997
|
+
...unjudged
|
|
3998
|
+
});
|
|
3999
|
+
if (outcome.auth) {
|
|
4000
|
+
aborted ??= {
|
|
4001
|
+
reason: "auth",
|
|
4002
|
+
message: "Authentication failed — the run was aborted. Run `novedu-cli login`."
|
|
4003
|
+
};
|
|
4004
|
+
break;
|
|
4005
|
+
}
|
|
4006
|
+
}
|
|
4007
|
+
const generated = rows.some((row) => row.text !== void 0);
|
|
4008
|
+
const status = rows.length === 0 ? "skipped" : generated ? "ok" : "errored";
|
|
4009
|
+
if (status === "errored") {
|
|
4010
|
+
consecutiveErrored += 1;
|
|
4011
|
+
if (consecutiveErrored >= CIRCUIT_BREAKER_LIMIT) aborted ??= {
|
|
4012
|
+
reason: "circuit-breaker",
|
|
4013
|
+
message: `${CIRCUIT_BREAKER_LIMIT} cases failed in a row — the run was aborted.`
|
|
4014
|
+
};
|
|
4015
|
+
} else if (status !== "skipped") consecutiveErrored = 0;
|
|
4016
|
+
return {
|
|
4017
|
+
index: plan.index,
|
|
4018
|
+
...plan.title ? { title: plan.title } : {},
|
|
4019
|
+
conversation: plan.conversation,
|
|
4020
|
+
...plan.gradingInstructions ? { gradingInstructions: plan.gradingInstructions } : {},
|
|
4021
|
+
...plan.requiredTools ? { requiredTools: plan.requiredTools } : {},
|
|
4022
|
+
status,
|
|
4023
|
+
unstable: false,
|
|
4024
|
+
feedbackFlagged: rows.some((row) => (row.judge?.issues.length ?? 0) > 0),
|
|
4025
|
+
toolsFlagged: rows.some((row) => (row.missingTools?.length ?? 0) > 0),
|
|
4026
|
+
repeats: rows
|
|
4027
|
+
};
|
|
4028
|
+
});
|
|
4029
|
+
const usage = { ...ZERO_USAGE };
|
|
4030
|
+
for (const result of results) for (const row of result.repeats) {
|
|
4031
|
+
addUsage(usage, row.usage);
|
|
4032
|
+
addUsage(usage, row.judge?.usage);
|
|
4033
|
+
}
|
|
4034
|
+
return {
|
|
4035
|
+
id: checked.evalFile.id,
|
|
4036
|
+
kind: "tutor",
|
|
4037
|
+
target: checked.targetUrl,
|
|
4038
|
+
llm: options.llm,
|
|
4039
|
+
judging: !options.judge ? "off" : breaker.stopped ? "degraded" : "on",
|
|
4040
|
+
totals: {
|
|
4041
|
+
cases: results.length,
|
|
4042
|
+
passed: 0,
|
|
4043
|
+
failed: 0,
|
|
4044
|
+
errored: results.filter((c) => c.status === "errored").length,
|
|
4045
|
+
skipped: results.filter((c) => c.status === "skipped").length,
|
|
4046
|
+
unstable: 0,
|
|
4047
|
+
feedbackFlagged: results.filter((c) => c.feedbackFlagged).length,
|
|
4048
|
+
toolsFlagged: results.filter((c) => c.toolsFlagged).length,
|
|
4049
|
+
judgeErrored: results.reduce((sum, c) => sum + c.repeats.filter((row) => row.judgeError !== void 0).length, 0),
|
|
4050
|
+
repeats,
|
|
4051
|
+
calls: total,
|
|
4052
|
+
usage
|
|
4053
|
+
},
|
|
4054
|
+
questions: [],
|
|
4055
|
+
confusion: [],
|
|
4056
|
+
falseCorrect: {
|
|
4057
|
+
count: 0,
|
|
4058
|
+
denominator: 0,
|
|
4059
|
+
rate: 0
|
|
4060
|
+
},
|
|
4061
|
+
mismatches: results.filter((result) => result.status === "errored"),
|
|
4062
|
+
cases: results,
|
|
4063
|
+
...aborted ? { aborted } : {}
|
|
4064
|
+
};
|
|
4065
|
+
} }
|
|
4066
|
+
};
|
|
3684
4067
|
/** Run ONE checked eval file — the single entry point the command uses. */
|
|
3685
4068
|
function runEval(kind, checked, options) {
|
|
3686
4069
|
return evalRunners[kind].run(checked, options);
|
|
@@ -3706,6 +4089,7 @@ function summarizeBatch(files) {
|
|
|
3706
4089
|
skipped: 0,
|
|
3707
4090
|
unstable: 0,
|
|
3708
4091
|
feedbackFlagged: 0,
|
|
4092
|
+
toolsFlagged: 0,
|
|
3709
4093
|
judgeErrored: 0,
|
|
3710
4094
|
usage: { ...ZERO_USAGE }
|
|
3711
4095
|
};
|
|
@@ -3718,12 +4102,14 @@ function summarizeBatch(files) {
|
|
|
3718
4102
|
totals.skipped += file.result.totals.skipped;
|
|
3719
4103
|
totals.unstable += file.result.totals.unstable;
|
|
3720
4104
|
totals.feedbackFlagged += file.result.totals.feedbackFlagged;
|
|
4105
|
+
totals.toolsFlagged += file.result.totals.toolsFlagged;
|
|
3721
4106
|
totals.judgeErrored += file.result.totals.judgeErrored;
|
|
3722
4107
|
addUsage(totals.usage, file.result.totals.usage);
|
|
3723
4108
|
}
|
|
3724
4109
|
return {
|
|
3725
4110
|
files: files.map((file) => ({
|
|
3726
4111
|
...file,
|
|
4112
|
+
...file.result ? { kind: file.result.kind } : {},
|
|
3727
4113
|
passed: filePassed(file)
|
|
3728
4114
|
})),
|
|
3729
4115
|
passed: batchPassed({ totals }),
|
|
@@ -3740,13 +4126,25 @@ function anyJudged(result) {
|
|
|
3740
4126
|
return result.cases.some((evalCase) => evalCase.repeats.some((repeat) => repeat.judge !== void 0 && repeat.judge !== null));
|
|
3741
4127
|
}
|
|
3742
4128
|
/**
|
|
4129
|
+
* Did this file's run CHECK tool calls at all — i.e. does any case declare
|
|
4130
|
+
* `required_tools`? The tool sibling of {@link anyJudged}, and the same rule: a run that
|
|
4131
|
+
* required nothing has not been found complete, so its `toolsFlagged` count is OMITTED
|
|
4132
|
+
* rather than printed as a reassuring `0`.
|
|
4133
|
+
*/
|
|
4134
|
+
function anyToolsRequired(result) {
|
|
4135
|
+
return result.cases.some((evalCase) => isTutorCase(evalCase) && evalCase.requiredTools !== void 0);
|
|
4136
|
+
}
|
|
4137
|
+
/**
|
|
3743
4138
|
* The CI gate: every file valid, and not a single failed, errored, or skipped CASE —
|
|
3744
4139
|
* an aborted (and therefore incomplete) run must never read as a pass. The single
|
|
3745
4140
|
* source of truth for the exit code AND for `EvalBatchResult.passed`.
|
|
3746
4141
|
*
|
|
3747
|
-
* `unstable`, `feedbackFlagged` and `judgeErrored` deliberately do NOT
|
|
3748
|
-
*
|
|
3749
|
-
*
|
|
4142
|
+
* `unstable`, `feedbackFlagged`, `toolsFlagged` and `judgeErrored` deliberately do NOT
|
|
4143
|
+
* appear here: all four are reported, none gates. (Gating is per-KIND policy, not a
|
|
4144
|
+
* property of judge
|
|
4145
|
+
* results — and BOTH shipped kinds are report-only. For the tutor kind that is the whole
|
|
4146
|
+
* policy: its exit code reflects RUN HEALTH only, so a flagged conversation changes
|
|
4147
|
+
* nothing, and the Markdown report is the deliverable — docs/cli-eval.md.)
|
|
3750
4148
|
*/
|
|
3751
4149
|
function batchPassed(batch) {
|
|
3752
4150
|
return batch.totals.invalid === 0 && batch.totals.failed === 0 && batch.totals.errored === 0 && batch.totals.skipped === 0;
|
|
@@ -3772,6 +4170,9 @@ const cliFetcher = async (url) => {
|
|
|
3772
4170
|
};
|
|
3773
4171
|
//#endregion
|
|
3774
4172
|
//#region src/format.ts
|
|
4173
|
+
function flaggedLabel(kind) {
|
|
4174
|
+
return kind === "tutor" ? "flagged responses" : "flagged feedback";
|
|
4175
|
+
}
|
|
3775
4176
|
const useColor = process.stdout.isTTY && !process.env.NO_COLOR;
|
|
3776
4177
|
const paint = (code, s) => useColor ? `\x1b[${code}m${s}\x1b[0m` : s;
|
|
3777
4178
|
const green = (s) => paint("32", s);
|
|
@@ -3920,17 +4321,19 @@ function formatCodingResult(result, source) {
|
|
|
3920
4321
|
return lines.join("\n");
|
|
3921
4322
|
}
|
|
3922
4323
|
/**
|
|
3923
|
-
* Renderer for
|
|
3924
|
-
*
|
|
3925
|
-
* the
|
|
4324
|
+
* Renderer for an eval check (`--kind eval`). An eval describes an activity it does not
|
|
4325
|
+
* contain, so the summary names the resolved target and the size of the run the file
|
|
4326
|
+
* would produce — in the units of its own kind.
|
|
3926
4327
|
*/
|
|
3927
4328
|
function formatEvalResult(result, source) {
|
|
3928
4329
|
if (!result.ok) return renderFailureAndWarnings(result, "eval", source);
|
|
3929
4330
|
const lines = [green(`✔ Valid eval`) + dim(` — ${source}`)];
|
|
3930
4331
|
lines.push(` id: ${result.evalFile.id}`);
|
|
4332
|
+
lines.push(` kind: ${result.kind}`);
|
|
3931
4333
|
lines.push(` target: ${result.targetUrl}`);
|
|
3932
|
-
lines.push(`
|
|
3933
|
-
lines.push(`
|
|
4334
|
+
if (result.kind === "tutor") lines.push(` conversations: ${result.caseCount}`);
|
|
4335
|
+
else lines.push(` questions: ${result.evalFile.questions.length} cases: ${result.caseCount}`);
|
|
4336
|
+
lines.push(` ${result.kind} model: ${result.llm.provider} / ${result.llm.model}`);
|
|
3934
4337
|
if (result.warnings.length) {
|
|
3935
4338
|
lines.push("");
|
|
3936
4339
|
lines.push(yellow(`${result.warnings.length} warning(s):`));
|
|
@@ -3959,14 +4362,25 @@ function formatUsageLine(usage) {
|
|
|
3959
4362
|
const cached = usage.cachedInput ? ` (${formatTokenCount(usage.cachedInput)} cached)` : "";
|
|
3960
4363
|
return `tokens: ${formatTokenCount(usage.input)} in${cached} / ${formatTokenCount(usage.output)} out`;
|
|
3961
4364
|
}
|
|
3962
|
-
/**
|
|
4365
|
+
/** The first error message a case's repeats recorded, for a one-line mismatch row. */
|
|
4366
|
+
function firstErrorMessage(repeats, fallback) {
|
|
4367
|
+
const first = repeats.find((row) => row.error !== void 0)?.error;
|
|
4368
|
+
return typeof first === "object" && first !== null && "message" in first ? String(first.message) : fallback;
|
|
4369
|
+
}
|
|
4370
|
+
/**
|
|
4371
|
+
* One line per non-passing case: `question#index expected … got … "answer…"` for a quiz,
|
|
4372
|
+
* `#n title — error …` for a tutor conversation (which has no verdict to compare).
|
|
4373
|
+
*/
|
|
3963
4374
|
function mismatchLines(result) {
|
|
3964
4375
|
return result.mismatches.map((c) => {
|
|
4376
|
+
if (isTutorCase(c)) {
|
|
4377
|
+
const head = `#${c.index + 1}${c.title ? ` ${c.title}` : ""}`;
|
|
4378
|
+
return ` ${red("✗")} ${head} ${red("error")} ${dim(firstErrorMessage(c.repeats, "no response"))}`;
|
|
4379
|
+
}
|
|
3965
4380
|
const head = `${c.questionId}#${c.answerIndex}`;
|
|
3966
4381
|
const expected = c.expected.join("|");
|
|
3967
4382
|
if (c.status === "errored") {
|
|
3968
|
-
const
|
|
3969
|
-
const message = typeof first === "object" && first !== null && "message" in first ? String(first.message) : "no verdict";
|
|
4383
|
+
const message = firstErrorMessage(c.repeats, "no verdict");
|
|
3970
4384
|
return ` ${red("✗")} ${head} expected ${expected} got ${red("error")} ${dim(message)}`;
|
|
3971
4385
|
}
|
|
3972
4386
|
return ` ${red("✗")} ${head} expected ${expected} got ${red(c.verdict ?? "?")}` + dim(` "${snippet(c.answer)}"`);
|
|
@@ -3989,7 +4403,9 @@ function formatEvalReport(result, source) {
|
|
|
3989
4403
|
lines.push(` llm: ${llm}`);
|
|
3990
4404
|
const judge = result.llm.judge;
|
|
3991
4405
|
if (judge && (judge.provider !== result.llm.provider || judge.model !== result.llm.model)) lines.push(` judge llm: ${judge.provider} / ${judge.model}${judge.overridden ? ` ${yellow("(override)")}` : ""}`);
|
|
3992
|
-
|
|
4406
|
+
const unit = result.kind === "tutor" ? "conversation" : "case";
|
|
4407
|
+
const generation = result.kind === "tutor" ? "generation" : "grading";
|
|
4408
|
+
lines.push(` ${unit}s: ${totals.cases} × ${totals.repeats} repeat(s) = ${totals.calls} ${generation} call(s)` + (result.judging === "off" ? "" : ` + ${totals.calls} judge call(s)`));
|
|
3993
4409
|
if (result.aborted) {
|
|
3994
4410
|
lines.push("");
|
|
3995
4411
|
lines.push(red(`Run aborted: ${result.aborted.message}`));
|
|
@@ -4004,7 +4420,7 @@ function formatEvalReport(result, source) {
|
|
|
4004
4420
|
lines.push(...mismatchLines(result));
|
|
4005
4421
|
}
|
|
4006
4422
|
lines.push("");
|
|
4007
|
-
lines.push(` passed: ${totals.passed} failed: ${totals.failed} errored: ${totals.errored}` + (totals.skipped ? red(` skipped: ${totals.skipped} (run aborted)`) : "") + (totals.unstable ? dim(` unstable: ${totals.unstable}`) : "") + (!anyJudged(result) ? "" : totals.feedbackFlagged ? yellow(`
|
|
4423
|
+
lines.push((result.kind === "tutor" ? ` ok: ${totals.cases - totals.errored - totals.skipped} errored: ${totals.errored}` : ` passed: ${totals.passed} failed: ${totals.failed} errored: ${totals.errored}`) + (totals.skipped ? red(` skipped: ${totals.skipped} (run aborted)`) : "") + (totals.unstable ? dim(` unstable: ${totals.unstable}`) : "") + (!anyJudged(result) ? "" : totals.feedbackFlagged ? yellow(` ${flaggedLabel(result.kind)}: ${totals.feedbackFlagged}`) : dim(` ${flaggedLabel(result.kind)}: 0`)) + (!anyToolsRequired(result) ? "" : totals.toolsFlagged ? yellow(` missing tool calls: ${totals.toolsFlagged}`) : dim(" missing tool calls: 0")) + (totals.judgeErrored ? dim(` judge errors: ${totals.judgeErrored}`) : ""));
|
|
4008
4424
|
const tokens = formatUsageLine(totals.usage);
|
|
4009
4425
|
if (tokens) lines.push(dim(` ${tokens}`));
|
|
4010
4426
|
if (result.confusion.length) {
|
|
@@ -4012,9 +4428,11 @@ function formatEvalReport(result, source) {
|
|
|
4012
4428
|
lines.push(" confusion (expected → got):");
|
|
4013
4429
|
for (const row of result.confusion) lines.push(` ${row.expected} → ${row.got}: ${row.count}`);
|
|
4014
4430
|
}
|
|
4015
|
-
|
|
4016
|
-
|
|
4017
|
-
|
|
4431
|
+
if (result.kind !== "tutor") {
|
|
4432
|
+
const { count, denominator, rate } = result.falseCorrect;
|
|
4433
|
+
lines.push("");
|
|
4434
|
+
lines.push(` false-correct: ${count}/${denominator}` + (denominator ? ` (${(rate * 100).toFixed(1)}%)` : ""));
|
|
4435
|
+
}
|
|
4018
4436
|
return lines.join("\n");
|
|
4019
4437
|
}
|
|
4020
4438
|
/**
|
|
@@ -4034,12 +4452,14 @@ function formatEvalBatchReport(batch) {
|
|
|
4034
4452
|
}
|
|
4035
4453
|
const t = file.result.totals;
|
|
4036
4454
|
const mark = t.failed === 0 && t.errored === 0 && t.skipped === 0 ? green("✔") : red("✗");
|
|
4037
|
-
|
|
4455
|
+
const counts = file.result.kind === "tutor" ? `${t.cases} conversation(s), ${t.cases - t.errored - t.skipped} ok, ${t.errored} errored` : `${t.cases} case(s), ${t.passed} passed, ${t.failed} failed, ${t.errored} errored`;
|
|
4456
|
+
lines.push(` ${mark} ${name}: ${counts}` + (t.skipped ? red(`, ${t.skipped} skipped`) : "") + (t.unstable ? dim(`, ${t.unstable} unstable`) : "") + (!anyJudged(file.result) ? "" : t.feedbackFlagged ? yellow(`, ${t.feedbackFlagged} flagged`) : dim(", 0 flagged")) + (!anyToolsRequired(file.result) ? "" : t.toolsFlagged ? yellow(`, ${t.toolsFlagged} missing tool calls`) : dim(", 0 missing tool calls")));
|
|
4038
4457
|
}
|
|
4039
4458
|
const g = batch.totals;
|
|
4040
4459
|
const judged = batch.files.some((file) => file.result && anyJudged(file.result));
|
|
4460
|
+
const toolChecked = batch.files.some((file) => file.result && anyToolsRequired(file.result));
|
|
4041
4461
|
lines.push("");
|
|
4042
|
-
lines.push(` TOTAL: ${g.cases} case(s), ${g.passed} passed, ${g.failed} failed, ${g.errored} errored` + (g.skipped ? red(`, ${g.skipped} skipped`) : "") + (judged ? g.feedbackFlagged ? yellow(`, ${g.feedbackFlagged} flagged`) : dim(", 0 flagged") : "") + (g.invalid ? red(`, ${g.invalid} invalid file(s)`) : ""));
|
|
4462
|
+
lines.push(` TOTAL: ${g.cases} case(s), ${g.passed} passed, ${g.failed} failed, ${g.errored} errored` + (g.skipped ? red(`, ${g.skipped} skipped`) : "") + (judged ? g.feedbackFlagged ? yellow(`, ${g.feedbackFlagged} flagged`) : dim(", 0 flagged") : "") + (toolChecked ? g.toolsFlagged ? yellow(`, ${g.toolsFlagged} missing tool calls`) : dim(", 0 missing tool calls") : "") + (g.invalid ? red(`, ${g.invalid} invalid file(s)`) : ""));
|
|
4043
4463
|
if (batch.files.some((file) => file.result?.judging === "degraded")) lines.push(yellow(" Feedback judging stopped mid-run after repeated judge failures — files without a flagged count were graded but not judged."));
|
|
4044
4464
|
const tokens = formatUsageLine(g.usage);
|
|
4045
4465
|
if (tokens) lines.push(dim(` ${tokens}`));
|
|
@@ -4196,17 +4616,18 @@ function overview(batch) {
|
|
|
4196
4616
|
continue;
|
|
4197
4617
|
}
|
|
4198
4618
|
const t = file.result.totals;
|
|
4619
|
+
const tutor = file.result.kind === "tutor";
|
|
4199
4620
|
lines.push(row([
|
|
4200
4621
|
`${file.passed ? "✅" : "❌"} ${name}`,
|
|
4201
4622
|
`\`${cell(file.result.id)}\``,
|
|
4202
4623
|
count(t.cases),
|
|
4203
|
-
count(t.passed),
|
|
4204
|
-
count(t.failed),
|
|
4624
|
+
tutor ? "—" : count(t.passed),
|
|
4625
|
+
tutor ? "—" : count(t.failed),
|
|
4205
4626
|
count(t.errored),
|
|
4206
4627
|
count(t.skipped),
|
|
4207
|
-
count(t.unstable),
|
|
4628
|
+
tutor ? "—" : count(t.unstable),
|
|
4208
4629
|
anyJudged(file.result) ? count(t.feedbackFlagged) : "—",
|
|
4209
|
-
falseCorrectCell(file.result),
|
|
4630
|
+
tutor ? "—" : falseCorrectCell(file.result),
|
|
4210
4631
|
usageCell(t.usage)
|
|
4211
4632
|
]));
|
|
4212
4633
|
}
|
|
@@ -4300,7 +4721,7 @@ function caseSection(evalCase, questionText) {
|
|
|
4300
4721
|
* the run failed on them. Empty when the file has no flags.
|
|
4301
4722
|
*/
|
|
4302
4723
|
function flaggedSection(result, questionText) {
|
|
4303
|
-
const flagged = result.cases.filter((evalCase) => evalCase.feedbackFlagged);
|
|
4724
|
+
const flagged = result.cases.filter((evalCase) => evalCase.feedbackFlagged && !isTutorCase(evalCase));
|
|
4304
4725
|
if (flagged.length === 0) return [];
|
|
4305
4726
|
const lines = ["### Flagged feedback", ""];
|
|
4306
4727
|
lines.push("_An LLM judge audited each feedback text against the very grading prompt it was written under. Reported only — flagged feedback never fails a run._");
|
|
@@ -4322,6 +4743,104 @@ function flaggedSection(result, questionText) {
|
|
|
4322
4743
|
}
|
|
4323
4744
|
return lines;
|
|
4324
4745
|
}
|
|
4746
|
+
/**
|
|
4747
|
+
* A tutor case's stable heading: the teacher's `title` when it has one, otherwise its
|
|
4748
|
+
* 1-based index plus an excerpt of the FIRST student line — enough to recognise the case
|
|
4749
|
+
* in a report without opening the eval file.
|
|
4750
|
+
*/
|
|
4751
|
+
function tutorCaseLabel(evalCase) {
|
|
4752
|
+
if (evalCase.title) return `#${evalCase.index + 1} ${cell(evalCase.title)}`;
|
|
4753
|
+
const firstStudent = evalCase.conversation.find((turn) => "student" in turn);
|
|
4754
|
+
const excerpt = firstStudent && "student" in firstStudent ? inline(firstStudent.student) : "";
|
|
4755
|
+
const short = excerpt.length > 60 ? `${excerpt.slice(0, 59)}…` : excerpt;
|
|
4756
|
+
return short ? `#${evalCase.index + 1} — ${short}` : `#${evalCase.index + 1}`;
|
|
4757
|
+
}
|
|
4758
|
+
/** The scripted conversation as one labeled, verbatim blockquote per turn. */
|
|
4759
|
+
function conversationBlock(evalCase) {
|
|
4760
|
+
const lines = ["**Conversation**", ""];
|
|
4761
|
+
for (const turn of evalCase.conversation) {
|
|
4762
|
+
const role = "student" in turn ? "student" : "tutor";
|
|
4763
|
+
const text = "student" in turn ? turn.student : turn.tutor;
|
|
4764
|
+
lines.push(`*${role}*`, "", quote(text), "");
|
|
4765
|
+
}
|
|
4766
|
+
return lines;
|
|
4767
|
+
}
|
|
4768
|
+
/** One ERRORED tutor case: what was asked, and why nothing came back. */
|
|
4769
|
+
function tutorErrorSection(evalCase) {
|
|
4770
|
+
const lines = [`### ${tutorCaseLabel(evalCase)} — error`, ""];
|
|
4771
|
+
lines.push(...conversationBlock(evalCase));
|
|
4772
|
+
const failure = evalCase.repeats.find((r) => r.error !== void 0);
|
|
4773
|
+
if (failure) lines.push("**Error**", "", quote(errorMessage(failure.error)), "");
|
|
4774
|
+
return lines;
|
|
4775
|
+
}
|
|
4776
|
+
/** `random_number, random_number` — a repeat's tool calls, or `(none)` when it made none. */
|
|
4777
|
+
function toolCallList(toolCalls) {
|
|
4778
|
+
return toolCalls && toolCalls.length > 0 ? toolCalls.map((name) => `\`${name}\``).join(", ") : "(none)";
|
|
4779
|
+
}
|
|
4780
|
+
/**
|
|
4781
|
+
* The tutor kind's "Missing tool calls" section: every case that declares `required_tools`
|
|
4782
|
+
* and had at least one repeat skip one. Per case the required list, what each offending
|
|
4783
|
+
* repeat actually called, and which tools were missing there.
|
|
4784
|
+
*
|
|
4785
|
+
* Its own section rather than a column, for the same reason "Flagged responses" is one:
|
|
4786
|
+
* these cases are `ok` and the run PASSED — a missing tool call is a note about the
|
|
4787
|
+
* tutor's behavior, never a failure. Cases whose tools all ran stay out entirely; the
|
|
4788
|
+
* `--json` report carries every repeat's `toolCalls` for anyone who wants them.
|
|
4789
|
+
*/
|
|
4790
|
+
function tutorMissingToolsSection(result) {
|
|
4791
|
+
const flagged = result.cases.filter((evalCase) => isTutorCase(evalCase) && evalCase.toolsFlagged);
|
|
4792
|
+
if (flagged.length === 0) return [];
|
|
4793
|
+
const lines = ["### Missing tool calls", ""];
|
|
4794
|
+
lines.push("_These cases require a tool the tutor did not call in every run. Reported only — a missing tool call never fails a run, and tools beyond the required ones are fine._");
|
|
4795
|
+
lines.push("");
|
|
4796
|
+
for (const evalCase of flagged) {
|
|
4797
|
+
lines.push(`#### ${tutorCaseLabel(evalCase)}`);
|
|
4798
|
+
lines.push("");
|
|
4799
|
+
lines.push(`**Required** ${toolCallList(evalCase.requiredTools)}`);
|
|
4800
|
+
lines.push("");
|
|
4801
|
+
for (const repeat of evalCase.repeats) {
|
|
4802
|
+
const missing = repeat.missingTools ?? [];
|
|
4803
|
+
if (missing.length === 0) continue;
|
|
4804
|
+
lines.push(`- Repeat #${repeat.repeatIndex + 1} — missing ${toolCallList(missing)}; called ${toolCallList(repeat.toolCalls)}`);
|
|
4805
|
+
}
|
|
4806
|
+
lines.push("");
|
|
4807
|
+
}
|
|
4808
|
+
return lines;
|
|
4809
|
+
}
|
|
4810
|
+
/**
|
|
4811
|
+
* The tutor kind's "Flagged responses" section — the report's actual deliverable: per
|
|
4812
|
+
* flagged case the scripted conversation, the teacher's expectations when it states any,
|
|
4813
|
+
* and each flagged repeat's GENERATED RESPONSE verbatim followed by the judge's issues.
|
|
4814
|
+
*
|
|
4815
|
+
* Clean cases stay out entirely (their generated texts are in the `--json` report), and
|
|
4816
|
+
* a flag never means the run failed — the tutor kind is report-only.
|
|
4817
|
+
*/
|
|
4818
|
+
function tutorFlaggedSection(result) {
|
|
4819
|
+
const flagged = result.cases.filter((evalCase) => isTutorCase(evalCase) && evalCase.feedbackFlagged);
|
|
4820
|
+
if (flagged.length === 0) return [];
|
|
4821
|
+
const lines = ["### Flagged responses", ""];
|
|
4822
|
+
lines.push("_An LLM judge audited each generated response against the tutor's own system prompt and, where the case states any, the teacher's expectations. Reported only — a flagged response never fails a run._");
|
|
4823
|
+
lines.push("");
|
|
4824
|
+
for (const evalCase of flagged) {
|
|
4825
|
+
lines.push(`#### ${tutorCaseLabel(evalCase)}`);
|
|
4826
|
+
lines.push("");
|
|
4827
|
+
lines.push(...conversationBlock(evalCase));
|
|
4828
|
+
if (evalCase.gradingInstructions) lines.push("**Expectations for this case**", "", quote(evalCase.gradingInstructions), "");
|
|
4829
|
+
if (evalCase.requiredTools) lines.push(`**Required tools** ${toolCallList(evalCase.requiredTools)}`, "");
|
|
4830
|
+
for (const repeat of evalCase.repeats) {
|
|
4831
|
+
const issues = repeat.judge?.issues ?? [];
|
|
4832
|
+
if (issues.length === 0) continue;
|
|
4833
|
+
lines.push(`**Generated response — repeat #${repeat.repeatIndex + 1}**`);
|
|
4834
|
+
lines.push("");
|
|
4835
|
+
lines.push(quote(repeat.text ?? ""));
|
|
4836
|
+
lines.push("");
|
|
4837
|
+
if (repeat.toolCalls && (repeat.toolCalls.length > 0 || evalCase.requiredTools)) lines.push(`*tool calls: ${toolCallList(repeat.toolCalls)}*`, "");
|
|
4838
|
+
for (const issue of issues) lines.push(`- \`${cell(issue.criterion)}\` — ${inline(issue.note)}`);
|
|
4839
|
+
lines.push("");
|
|
4840
|
+
}
|
|
4841
|
+
}
|
|
4842
|
+
return lines;
|
|
4843
|
+
}
|
|
4325
4844
|
/** One file's details section, or `[]` when the file has nothing to report. */
|
|
4326
4845
|
function fileDetails(file) {
|
|
4327
4846
|
const name = shortSource(file.source);
|
|
@@ -4330,30 +4849,33 @@ function fileDetails(file) {
|
|
|
4330
4849
|
return [
|
|
4331
4850
|
`## ${cell(name)} — invalid`,
|
|
4332
4851
|
"",
|
|
4333
|
-
"This file was not
|
|
4852
|
+
"This file was not run; fix the problems below and run it again.",
|
|
4334
4853
|
"",
|
|
4335
4854
|
...errors.map((issue) => `- \`${cell(issue.code)}\` — ${inline(issue.message)}`),
|
|
4336
4855
|
""
|
|
4337
4856
|
];
|
|
4338
4857
|
}
|
|
4339
4858
|
const result = file.result;
|
|
4859
|
+
const tutor = result.kind === "tutor";
|
|
4340
4860
|
const detailed = result.cases.filter(needsDetail);
|
|
4341
4861
|
const skipped = result.totals.skipped;
|
|
4342
4862
|
const questionText = new Map(result.questions.map((question) => [question.id, question.text]));
|
|
4343
|
-
const flagged = flaggedSection(result, questionText);
|
|
4344
|
-
|
|
4863
|
+
const flagged = tutor ? tutorFlaggedSection(result) : flaggedSection(result, questionText);
|
|
4864
|
+
const missingTools = tutor ? tutorMissingToolsSection(result) : [];
|
|
4865
|
+
if (detailed.length === 0 && skipped === 0 && !result.aborted && flagged.length === 0 && missingTools.length === 0) return [];
|
|
4345
4866
|
const lines = [`## ${cell(name)} — \`${cell(result.id)}\``, ""];
|
|
4346
4867
|
if (result.aborted) {
|
|
4347
4868
|
lines.push("> [!WARNING]");
|
|
4348
4869
|
lines.push(`> The run was aborted: ${inline(result.aborted.message)}`);
|
|
4349
4870
|
lines.push("");
|
|
4350
4871
|
}
|
|
4351
|
-
for (const evalCase of detailed) lines.push(...caseSection(evalCase, questionText.get(evalCase.questionId)));
|
|
4872
|
+
for (const evalCase of detailed) lines.push(...isTutorCase(evalCase) ? tutorErrorSection(evalCase) : caseSection(evalCase, questionText.get(evalCase.questionId)));
|
|
4352
4873
|
if (skipped > 0) {
|
|
4353
4874
|
const reason = result.aborted ? ` (${inline(result.aborted.message)})` : "";
|
|
4354
|
-
lines.push(`**${count(skipped)} case(s) were never attempted**${reason} — the run is incomplete, so it cannot pass.`);
|
|
4875
|
+
lines.push(`**${count(skipped)} ${tutor ? "conversation" : "case"}(s) were never attempted**${reason} — the run is incomplete, so it cannot pass.`);
|
|
4355
4876
|
lines.push("");
|
|
4356
4877
|
}
|
|
4878
|
+
lines.push(...missingTools);
|
|
4357
4879
|
lines.push(...flagged);
|
|
4358
4880
|
return lines;
|
|
4359
4881
|
}
|
|
@@ -4371,8 +4893,9 @@ function renderEvalMarkdownReport(batch, meta) {
|
|
|
4371
4893
|
const judges = [...new Set(batch.files.map((file) => file.result ? judgeLlmText(file.result.llm) : void 0).filter((text) => text !== void 0))];
|
|
4372
4894
|
for (const judge of judges) lines.push(`- **Feedback judge** ${judge}`);
|
|
4373
4895
|
lines.push(`- **Run** ${count(batch.totals.files)} file(s), ${count(batch.totals.cases)} case(s) × ${count(meta.repeats)} repeat(s), concurrency ${count(meta.concurrency)}`);
|
|
4896
|
+
if (batch.files.some((file) => file.result && anyToolsRequired(file.result))) lines.push(`- **Missing tool calls** ${count(batch.totals.toolsFlagged)} case(s) did not call a required tool in every run — reported only, never a failure`);
|
|
4374
4897
|
const tokens = batch.totals.usage;
|
|
4375
|
-
if (tokens.input || tokens.cachedInput || tokens.output) lines.push(`- **Tokens** ${count(tokens.input)} in (${count(tokens.cachedInput)} cached) / ${count(tokens.output)} out — successful
|
|
4898
|
+
if (tokens.input || tokens.cachedInput || tokens.output) lines.push(`- **Tokens** ${count(tokens.input)} in (${count(tokens.cachedInput)} cached) / ${count(tokens.output)} out — successful calls only, so a lower bound`);
|
|
4376
4899
|
lines.push("");
|
|
4377
4900
|
if (batch.files.filter((file) => file.result?.aborted).length > 0) {
|
|
4378
4901
|
lines.push("> [!WARNING]");
|
|
@@ -4391,10 +4914,10 @@ function renderEvalMarkdownReport(batch, meta) {
|
|
|
4391
4914
|
lines.push("");
|
|
4392
4915
|
const details = batch.files.flatMap((file) => fileDetails(file));
|
|
4393
4916
|
if (details.length === 0) {
|
|
4394
|
-
lines.push("_Nothing else to report
|
|
4917
|
+
lines.push("_Nothing else to report. The `--json` report carries every case, including the clean ones._");
|
|
4395
4918
|
lines.push("");
|
|
4396
4919
|
} else {
|
|
4397
|
-
lines.push("_Below: only the mismatched, errored and unstable cases, plus
|
|
4920
|
+
lines.push("_Below: only the mismatched, errored and unstable cases, plus anything the judge flagged. Clean cases live in the `--json` report._");
|
|
4398
4921
|
lines.push("");
|
|
4399
4922
|
lines.push(...details);
|
|
4400
4923
|
}
|
|
@@ -4820,6 +5343,61 @@ function makeGradeFn(server, llm) {
|
|
|
4820
5343
|
};
|
|
4821
5344
|
}
|
|
4822
5345
|
/**
|
|
5346
|
+
* The `toolCalls: string[]` of a tutor 200, defensively: `undefined` when the field is
|
|
5347
|
+
* absent (a server too old to report tool calls — a distinction the runner MUST be able to
|
|
5348
|
+
* make), and non-string entries are dropped rather than breaking a run. Names only, in the
|
|
5349
|
+
* order the server sent them, duplicates kept.
|
|
5350
|
+
*/
|
|
5351
|
+
function parseToolCalls(value) {
|
|
5352
|
+
if (!Array.isArray(value)) return void 0;
|
|
5353
|
+
return value.filter((name) => typeof name === "string" && name !== "");
|
|
5354
|
+
}
|
|
5355
|
+
/**
|
|
5356
|
+
* The HTTP seam for ONE generated tutor turn, with the run's effective llm closed in —
|
|
5357
|
+
* the tutor kind's sibling of {@link makeGradeFn}, sharing its failure classification
|
|
5358
|
+
* exactly (5xx and network retryable, auth aborts the run, every other 4xx terminal).
|
|
5359
|
+
*/
|
|
5360
|
+
function makeRespondFn(server, llm) {
|
|
5361
|
+
return async ({ system, tools, messages }) => {
|
|
5362
|
+
const response = await performApiRequest({
|
|
5363
|
+
server,
|
|
5364
|
+
path: "/api/eval/respond",
|
|
5365
|
+
method: "POST",
|
|
5366
|
+
body: {
|
|
5367
|
+
llm,
|
|
5368
|
+
system,
|
|
5369
|
+
tools: [...tools],
|
|
5370
|
+
messages: messages.map((m) => ({ ...m }))
|
|
5371
|
+
},
|
|
5372
|
+
quiet: true
|
|
5373
|
+
});
|
|
5374
|
+
if (response.ok) {
|
|
5375
|
+
const payload = response.payload;
|
|
5376
|
+
if (typeof payload?.text === "string" && payload.text !== "") {
|
|
5377
|
+
const usage = parseUsage(payload?.usage);
|
|
5378
|
+
const toolCalls = parseToolCalls(payload?.toolCalls);
|
|
5379
|
+
return {
|
|
5380
|
+
ok: true,
|
|
5381
|
+
text: payload.text,
|
|
5382
|
+
...toolCalls ? { toolCalls } : {},
|
|
5383
|
+
...usage ? { usage } : {}
|
|
5384
|
+
};
|
|
5385
|
+
}
|
|
5386
|
+
return {
|
|
5387
|
+
ok: false,
|
|
5388
|
+
retryable: false,
|
|
5389
|
+
error: { message: "The server's response is not a generated tutor turn — it may not offer /api/eval/respond at all (does it run a Novedu version with tutor evals?). Check the target server, e.g. --server http://localhost:3000." }
|
|
5390
|
+
};
|
|
5391
|
+
}
|
|
5392
|
+
return {
|
|
5393
|
+
ok: false,
|
|
5394
|
+
retryable: response.status === void 0 || response.status >= 500,
|
|
5395
|
+
...response.authFailed ? { auth: true } : {},
|
|
5396
|
+
error: response.error
|
|
5397
|
+
};
|
|
5398
|
+
};
|
|
5399
|
+
}
|
|
5400
|
+
/**
|
|
4823
5401
|
* The HTTP seam for ONE judge call, with the run's judge llm closed in. Mirrors
|
|
4824
5402
|
* {@link makeGradeFn}'s failure classification, minus the auth branch: a judge failure
|
|
4825
5403
|
* NEVER aborts the run — it degrades judging (see the runner's breaker) while the grading
|
|
@@ -4934,7 +5512,8 @@ function progressWriter(prefix) {
|
|
|
4934
5512
|
*/
|
|
4935
5513
|
function writeFileDone(label, result) {
|
|
4936
5514
|
const totals = result.totals;
|
|
4937
|
-
|
|
5515
|
+
const counts = result.kind === "tutor" ? `${totals.cases} conversation(s), ${totals.cases - totals.errored - totals.skipped} ok, ${totals.errored} errored` : `${totals.cases} case(s), ${totals.passed} passed, ${totals.failed} failed, ${totals.errored} errored`;
|
|
5516
|
+
process.stderr.write(`${label}: ${counts}` + (totals.skipped ? `, ${totals.skipped} skipped` : "") + (anyJudged(result) ? `, ${totals.feedbackFlagged} flagged` : "") + "\n");
|
|
4938
5517
|
}
|
|
4939
5518
|
/**
|
|
4940
5519
|
* The command's core, exported for the unit tests. `seams` exists only so tests can
|
|
@@ -4999,9 +5578,14 @@ async function runEvalCommand(pathsOrUrls, options, seams = {}) {
|
|
|
4999
5578
|
return;
|
|
5000
5579
|
}
|
|
5001
5580
|
{
|
|
5002
|
-
const
|
|
5003
|
-
|
|
5004
|
-
|
|
5581
|
+
const scope = (unit, generation, cases) => {
|
|
5582
|
+
if (cases === 0) return;
|
|
5583
|
+
const calls = cases * repeats;
|
|
5584
|
+
process.stderr.write(`${cases} ${unit}(s) × ${repeats} repeat(s) = ${calls} ${generation}` + (judging ? ` + ${calls} judge call(s)\n` : " call(s)\n"));
|
|
5585
|
+
};
|
|
5586
|
+
const casesOf = (kind) => [...checked.values()].filter((file) => file.kind === kind).reduce((sum, file) => sum + file.caseCount, 0);
|
|
5587
|
+
scope("case", "grading", casesOf("quiz"));
|
|
5588
|
+
scope("conversation", "generation", casesOf("tutor"));
|
|
5005
5589
|
}
|
|
5006
5590
|
await warnOnVersionMismatch(options.server);
|
|
5007
5591
|
const judgeBreaker = createJudgeBreaker();
|
|
@@ -5013,15 +5597,15 @@ async function runEvalCommand(pathsOrUrls, options, seams = {}) {
|
|
|
5013
5597
|
fileIndex += 1;
|
|
5014
5598
|
const check = checked.get(file.source);
|
|
5015
5599
|
if (!check) continue;
|
|
5016
|
-
const
|
|
5017
|
-
provider: check.
|
|
5018
|
-
model: check.
|
|
5600
|
+
const activityLlm = {
|
|
5601
|
+
provider: check.llm.provider,
|
|
5602
|
+
model: check.llm.model
|
|
5019
5603
|
};
|
|
5020
|
-
const effective = override.llm ??
|
|
5604
|
+
const effective = override.llm ?? activityLlm;
|
|
5021
5605
|
const judgeLlm = judgeOverride.llm ?? effective;
|
|
5022
5606
|
const llm = {
|
|
5023
5607
|
...effective,
|
|
5024
|
-
...override.llm ? { overrides:
|
|
5608
|
+
...override.llm ? { overrides: activityLlm } : {},
|
|
5025
5609
|
...judging ? { judge: {
|
|
5026
5610
|
...judgeLlm,
|
|
5027
5611
|
overridden: judgeOverride.llm !== void 0
|
|
@@ -5029,8 +5613,9 @@ async function runEvalCommand(pathsOrUrls, options, seams = {}) {
|
|
|
5029
5613
|
};
|
|
5030
5614
|
const label = files.length > 1 ? `(${fileIndex}/${files.length}) ${check.evalFile.id}` : check.evalFile.id;
|
|
5031
5615
|
const prefix = files.length > 1 ? `${label}: ` : "";
|
|
5032
|
-
const result = await runEval(
|
|
5616
|
+
const result = await runEval(check.kind, check, {
|
|
5033
5617
|
grade: makeGradeFn(options.server, effective),
|
|
5618
|
+
respond: makeRespondFn(options.server, effective),
|
|
5034
5619
|
...judging ? { judge: makeJudgeFn(options.server, judgeLlm) } : {},
|
|
5035
5620
|
judgeBreaker,
|
|
5036
5621
|
onJudgeDegraded,
|
|
@@ -5069,12 +5654,16 @@ async function runEvalCommand(pathsOrUrls, options, seams = {}) {
|
|
|
5069
5654
|
process.exitCode = batchPassed(batch) ? 0 : 1;
|
|
5070
5655
|
}
|
|
5071
5656
|
function registerEval(program) {
|
|
5072
|
-
program.command("eval").description("
|
|
5657
|
+
program.command("eval").description("Run an eval file (quiz golden answers, or tutor conversations) against the real activity path and report the result").argument("<evalPathOrUrl...>", "one or more eval YAML files (paths, http(s)/file URLs, or a quoted glob pattern)").option("--server <url>", "Novedu server base URL (defaults to the NOVEDU_SERVER env var, then production)").option("--concurrency <n>", "cases in flight per file", String(CONCURRENCY_DEFAULT)).option("--repeats <n>", "run every case N times (quiz: take the majority verdict)", "1").option("--llm-provider <provider>", "run with this provider instead of the activity's (\"SCCH\" or \"Azure Foundry\"; needs --llm-model)").option("--llm-model <model>", "run with this model instead of the activity's (needs --llm-provider)").option("--no-judge-feedback", "skip the LLM audit of what the model wrote (halves the LLM calls)").option("--judge-llm-provider <provider>", "judge with this provider (\"SCCH\" or \"Azure Foundry\"; needs --judge-llm-model)").option("--judge-llm-model <model>", "judge with this model instead of the one under test (needs --judge-llm-provider)").option("--json", "print the machine-readable batch report on stdout").option("--out <file>", "additionally write the machine-readable batch report to a file").option("--report <file>", "additionally write a readable Markdown report to a file").addHelpText("after", `
|
|
5073
5658
|
Examples:
|
|
5074
5659
|
# Evaluate one quiz's golden answers
|
|
5075
5660
|
$ novedu-cli eval ./0010-welcome-quiz.eval.yaml
|
|
5076
5661
|
|
|
5077
|
-
#
|
|
5662
|
+
# Check how a tutor answers a set of scripted conversations
|
|
5663
|
+
$ novedu-cli eval ./loops-tutor.eval.yaml
|
|
5664
|
+
|
|
5665
|
+
# A whole course part — quiz and tutor evals may be mixed
|
|
5666
|
+
# (quote the pattern so the CLI expands it, ** included)
|
|
5078
5667
|
$ novedu-cli eval "./part-1/**/*.eval.yaml"
|
|
5079
5668
|
|
|
5080
5669
|
# Measure grader stability: 3 runs per answer, majority verdict
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@novedu/cli",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Command-line companion for the Novedu chat app. Validates tutor, fragment, quiz, writing, coding and eval YAML definitions, dumps the exact LLM prompts an activity produces,
|
|
3
|
+
"version": "0.23.0",
|
|
4
|
+
"description": "Command-line companion for the Novedu chat app. Validates tutor, fragment, quiz, writing, coding and eval YAML definitions, dumps the exact LLM prompts an activity produces, evaluates a quiz's grading rubric against golden answers and replays scripted conversations against a tutor; signs in with Entra ID and manages codes, app-hosted files and images over the app's API.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"repository": {
|
|
7
7
|
"type": "git",
|