@novedu/cli 0.22.0 → 0.24.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 +82 -25
- package/dist/main.js +837 -118
- package/package.json +2 -2
package/dist/main.js
CHANGED
|
@@ -284,9 +284,19 @@ async function runApiRequest(options) {
|
|
|
284
284
|
const LLM_PROVIDERS = ["SCCH", "Azure Foundry"];
|
|
285
285
|
const DEFAULT_PROVIDER = "SCCH";
|
|
286
286
|
const providerSchema = z.enum(LLM_PROVIDERS).default(DEFAULT_PROVIDER).meta({ description: "The LLM provider serving the model. For Azure Foundry, model is the deployment name." });
|
|
287
|
+
const REASONING_LEVELS = [
|
|
288
|
+
"minimal",
|
|
289
|
+
"low",
|
|
290
|
+
"medium",
|
|
291
|
+
"high"
|
|
292
|
+
];
|
|
293
|
+
const reasoningLevelSchema = z.enum(REASONING_LEVELS).optional().meta({ description: "Optional reasoning effort for reasoning models. Omit to let the model decide (the parameter is then not sent)." });
|
|
287
294
|
function parseLenientProvider(value) {
|
|
288
295
|
return value === "SCCH" || value === "Azure Foundry" ? value : void 0;
|
|
289
296
|
}
|
|
297
|
+
function parseLenientReasoningLevel(value) {
|
|
298
|
+
return typeof value === "string" && REASONING_LEVELS.includes(value) ? value : void 0;
|
|
299
|
+
}
|
|
290
300
|
//#endregion
|
|
291
301
|
//#region ../lib/registry-schema.ts
|
|
292
302
|
/** The fixed group names and the code module each one mints for. */
|
|
@@ -307,6 +317,7 @@ function timestampField(field) {
|
|
|
307
317
|
}, `${field} must not carry sub-second precision — the server stores whole seconds`);
|
|
308
318
|
}
|
|
309
319
|
const providerField = z.enum(LLM_PROVIDERS, { error: "must be \"SCCH\" or \"Azure Foundry\"" });
|
|
320
|
+
const reasoningField = z.enum(REASONING_LEVELS, { error: `must be one of ${REASONING_LEVELS.join(", ")}` });
|
|
310
321
|
/**
|
|
311
322
|
* One registry entry. Unknown extra properties are ACCEPTED and ignored so authors can
|
|
312
323
|
* annotate freely and a newer registry keeps working with an older CLI — which is why
|
|
@@ -320,8 +331,9 @@ const RegistryEntrySchema = z.looseObject({
|
|
|
320
331
|
note: z.string().trim().max(200, `note must be at most 200 characters`).optional().meta({ description: `Note shown in the codes list, at most 200 characters. No effect on behaviour.` }),
|
|
321
332
|
llm: z.looseObject({
|
|
322
333
|
provider: providerField.meta({ description: "LLM provider override for this code. Required when `llm` is present." }),
|
|
323
|
-
model: z.string().trim().min(1).max(256).meta({ description: "Model id (for Azure Foundry, the deployment name). Required when `llm` is present." })
|
|
324
|
-
|
|
334
|
+
model: z.string().trim().min(1).max(256).meta({ description: "Model id (for Azure Foundry, the deployment name). Required when `llm` is present." }),
|
|
335
|
+
reasoning: reasoningField.optional().meta({ description: "Optional reasoning effort for reasoning models, applied on top of the provider/model pair. Omit to let the model decide." })
|
|
336
|
+
}).optional().meta({ description: "Per-code LLM override replacing the activity YAML's own `llm:`. Provider and model must be given together; `reasoning` is optional on top of them." })
|
|
325
337
|
}).refine((entry) => entry.file === void 0 !== (entry.url === void 0), "give exactly one of `file` (relative to base-url) or `url` (absolute)").meta({
|
|
326
338
|
id: "registryEntry",
|
|
327
339
|
description: "One activity: where its YAML lives, plus the parameters its code is minted with."
|
|
@@ -451,7 +463,8 @@ function parseRegistry(text) {
|
|
|
451
463
|
note: entry.note ?? null,
|
|
452
464
|
llm: entry.llm ? {
|
|
453
465
|
provider: entry.llm.provider,
|
|
454
|
-
model: entry.llm.model
|
|
466
|
+
model: entry.llm.model,
|
|
467
|
+
...entry.llm.reasoning ? { reasoning: entry.llm.reasoning } : {}
|
|
455
468
|
} : null
|
|
456
469
|
});
|
|
457
470
|
}
|
|
@@ -521,7 +534,8 @@ function parseServerCodes(payload) {
|
|
|
521
534
|
validUntil: typeof value.validUntil === "string" ? value.validUntil : null,
|
|
522
535
|
llm: typeof llm === "object" && llm !== null ? {
|
|
523
536
|
provider: String(llm.provider ?? ""),
|
|
524
|
-
model: String(llm.model ?? "")
|
|
537
|
+
model: String(llm.model ?? ""),
|
|
538
|
+
reasoning: typeof llm.reasoning === "string" ? llm.reasoning : null
|
|
525
539
|
} : null,
|
|
526
540
|
createdAt: typeof value.createdAt === "string" ? value.createdAt : null
|
|
527
541
|
});
|
|
@@ -539,9 +553,16 @@ function sameInstant(a, b) {
|
|
|
539
553
|
const right = Date.parse(b);
|
|
540
554
|
return !Number.isNaN(left) && left === right;
|
|
541
555
|
}
|
|
556
|
+
/**
|
|
557
|
+
* The override compares WHOLE, reasoning level included: a code minted at a different
|
|
558
|
+
* effort serves different behavior, so it must not be reused. A differing level therefore
|
|
559
|
+
* fails the match and the entry mints a NEW code — sync never modifies an existing one
|
|
560
|
+
* (docs/registry.md). An absent level on either side compares as null, so an entry
|
|
561
|
+
* without `reasoning` keeps matching the codes minted before the field existed.
|
|
562
|
+
*/
|
|
542
563
|
function sameLlm(a, b) {
|
|
543
564
|
if (a === null || b === null) return a === b;
|
|
544
|
-
return a.provider === b.provider && a.model === b.model;
|
|
565
|
+
return a.provider === b.provider && a.model === b.model && (a.reasoning ?? null) === (b.reasoning ?? null);
|
|
545
566
|
}
|
|
546
567
|
/**
|
|
547
568
|
* The codes that ARE this entry: same activity URL, module and availability
|
|
@@ -846,7 +867,8 @@ async function readLock(lockPath) {
|
|
|
846
867
|
}
|
|
847
868
|
function registerCodes(program) {
|
|
848
869
|
const codes = program.command("codes").description("Manage activity codes on the Novedu server");
|
|
849
|
-
codes.command("create").description("Create a code for an activity YAML (validated server-side before storing)").requiredOption("--module <module>", "activity module: tutor, quiz, writing or coding").requiredOption("--file <url>", "public http(s) URL of the activity YAML").option("--start <iso>", "window start, ISO 8601 with explicit offset (e.g. 2026-07-07T08:00:00Z)").option("--end <iso>", "window end, ISO 8601 with explicit offset").option("--note <text>", "note shown in the codes list").option("--llm-provider <provider>", "LLM override provider (\"SCCH\" or \"Azure Foundry\"; needs --llm-model)").option("--llm-model <model>", "LLM override model id (needs --llm-provider)").option(...SERVER_OPTION$3).action(async (options) => {
|
|
870
|
+
codes.command("create").description("Create a code for an activity YAML (validated server-side before storing)").requiredOption("--module <module>", "activity module: tutor, quiz, writing or coding").requiredOption("--file <url>", "public http(s) URL of the activity YAML").option("--start <iso>", "window start, ISO 8601 with explicit offset (e.g. 2026-07-07T08:00:00Z)").option("--end <iso>", "window end, ISO 8601 with explicit offset").option("--note <text>", "note shown in the codes list").option("--llm-provider <provider>", "LLM override provider (\"SCCH\" or \"Azure Foundry\"; needs --llm-model)").option("--llm-model <model>", "LLM override model id (needs --llm-provider)").option("--llm-reasoning <level>", "LLM override reasoning effort (\"minimal\", \"low\", \"medium\" or \"high\"; needs the provider/model pair)").option(...SERVER_OPTION$3).action(async (options) => {
|
|
871
|
+
const llmGiven = options.llmProvider !== void 0 || options.llmModel !== void 0 || options.llmReasoning !== void 0;
|
|
850
872
|
await runApiRequest({
|
|
851
873
|
server: options.server,
|
|
852
874
|
path: "/api/codes",
|
|
@@ -857,10 +879,11 @@ function registerCodes(program) {
|
|
|
857
879
|
...options.start === void 0 ? {} : { validFrom: options.start },
|
|
858
880
|
...options.end === void 0 ? {} : { validUntil: options.end },
|
|
859
881
|
...options.note === void 0 ? {} : { note: options.note },
|
|
860
|
-
...
|
|
882
|
+
...llmGiven ? { llm: {
|
|
861
883
|
provider: options.llmProvider ?? "",
|
|
862
|
-
model: options.llmModel ?? ""
|
|
863
|
-
|
|
884
|
+
model: options.llmModel ?? "",
|
|
885
|
+
...options.llmReasoning === void 0 ? {} : { reasoning: options.llmReasoning }
|
|
886
|
+
} } : {}
|
|
864
887
|
}
|
|
865
888
|
});
|
|
866
889
|
});
|
|
@@ -890,10 +913,19 @@ const QUIZ_VERDICT_SCHEMA = z.object({
|
|
|
890
913
|
feedback: z.string()
|
|
891
914
|
});
|
|
892
915
|
const QUIZ_VERDICT_ENUM = QUIZ_VERDICT_SCHEMA.shape.result;
|
|
916
|
+
/** The three verdict literals, in the canonical best→worst order. */
|
|
917
|
+
const QUIZ_VERDICT_VALUES = QUIZ_VERDICT_ENUM.options;
|
|
918
|
+
//#endregion
|
|
919
|
+
//#region ../lib/tutor-tools/names.ts
|
|
920
|
+
const TUTOR_TOOL_NAMES = ["random_number"];
|
|
921
|
+
const tutorToolNameSchema = z.enum(TUTOR_TOOL_NAMES).meta({
|
|
922
|
+
id: "tutorToolName",
|
|
923
|
+
description: "Name of a built-in tutor tool."
|
|
924
|
+
});
|
|
893
925
|
//#endregion
|
|
894
926
|
//#region ../lib/eval-schema.ts
|
|
895
927
|
/** The three verdicts in canonical order (best → worst); the sort key for expected sets. */
|
|
896
|
-
const EVAL_VERDICTS =
|
|
928
|
+
const EVAL_VERDICTS = QUIZ_VERDICT_VALUES;
|
|
897
929
|
/**
|
|
898
930
|
* Eval ids share the flat namespace of report headers and `--out` files, so they stay
|
|
899
931
|
* URL- and YAML-plain: an alphanumeric start, then alphanumerics, `.`, `-` or `_`.
|
|
@@ -911,13 +943,72 @@ const EvalQuestionSchema = z.strictObject({
|
|
|
911
943
|
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
944
|
answers: z.array(EvalAnswerSchema).min(1).meta({ description: "The golden answers for this question — at least one." })
|
|
913
945
|
});
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
946
|
+
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." });
|
|
947
|
+
/** The QUIZ arm: golden answers replayed through the real grader. */
|
|
948
|
+
const QuizEvalYamlSchema = z.strictObject({
|
|
949
|
+
kind: z.literal("quiz").optional().meta({ description: "The eval kind. Omit it (or write \"quiz\") for a golden-answer eval of a quiz rubric." }),
|
|
950
|
+
id: idSchema,
|
|
917
951
|
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
952
|
questions: z.array(EvalQuestionSchema).min(1).meta({ description: "The evaluated questions — at least one, each with its golden answers." })
|
|
919
953
|
});
|
|
920
954
|
/**
|
|
955
|
+
* ONE turn of a scripted conversation: a single-key map naming its speaker. The two
|
|
956
|
+
* TEACHER-facing role names (`student` / `tutor`) are deliberate — the wire roles
|
|
957
|
+
* (`user` / `assistant`) are an implementation detail nobody should have to author.
|
|
958
|
+
*/
|
|
959
|
+
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." }) })]);
|
|
960
|
+
/** The last turn a conversation may end on: the student message the model must answer. */
|
|
961
|
+
function endsWithStudentTurn(turns) {
|
|
962
|
+
const last = turns.at(-1);
|
|
963
|
+
return last !== void 0 && "student" in last;
|
|
964
|
+
}
|
|
965
|
+
/**
|
|
966
|
+
* The tool names a case may REQUIRE, derived from the catalog's own name list
|
|
967
|
+
* (`lib/tutor-tools/names.ts`) rather than a mirrored literal set — so a tool added to the
|
|
968
|
+
* catalog is immediately requirable and a typo fails `validate` offline with a named enum
|
|
969
|
+
* error, no run and no tokens spent.
|
|
970
|
+
*
|
|
971
|
+
* Non-empty and UNIQUE: an empty list would say nothing (write no `required_tools` at
|
|
972
|
+
* all), and a repeated name is always an authoring slip — the check is "called at least
|
|
973
|
+
* once", so naming a tool twice cannot mean anything a single mention does not.
|
|
974
|
+
*/
|
|
975
|
+
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." });
|
|
976
|
+
/** ONE tutor case: a scripted conversation plus the teacher's optional expectations. */
|
|
977
|
+
const EvalConversationSchema = z.strictObject({
|
|
978
|
+
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." }),
|
|
979
|
+
required_tools: requiredToolsSchema.optional(),
|
|
980
|
+
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\")." }),
|
|
981
|
+
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." })
|
|
982
|
+
});
|
|
983
|
+
/** The TUTOR arm: conversations whose next tutor turn is generated and judged. */
|
|
984
|
+
const TutorEvalYamlSchema = z.strictObject({
|
|
985
|
+
kind: z.literal("tutor").meta({ description: "The eval kind. \"tutor\" evaluates a tutor's next response in a conversation." }),
|
|
986
|
+
id: idSchema,
|
|
987
|
+
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." }),
|
|
988
|
+
conversations: z.array(EvalConversationSchema).min(1).meta({ description: "The evaluated conversations — at least one; each one is a case." })
|
|
989
|
+
});
|
|
990
|
+
/**
|
|
991
|
+
* The whole eval file: quiz (the default, `kind` omissible) or tutor.
|
|
992
|
+
*
|
|
993
|
+
* A discriminated union rather than a loose one, so a `kind: tutor` file with a typo in
|
|
994
|
+
* `conversations` reports THAT problem instead of "no union member matched".
|
|
995
|
+
*/
|
|
996
|
+
const EvalYamlSchema = z.discriminatedUnion("kind", [QuizEvalYamlSchema, TutorEvalYamlSchema]);
|
|
997
|
+
/** The eval file's kind, with the quiz arm's omitted `kind` resolved to its default. */
|
|
998
|
+
function evalKindOf(evalFile) {
|
|
999
|
+
return evalFile.kind ?? "quiz";
|
|
1000
|
+
}
|
|
1001
|
+
/** A scripted turn as `{ role, text }` — the wire shape `POST /api/eval/respond` takes. */
|
|
1002
|
+
function turnToMessage(turn) {
|
|
1003
|
+
return "student" in turn ? {
|
|
1004
|
+
role: "user",
|
|
1005
|
+
text: turn.student
|
|
1006
|
+
} : {
|
|
1007
|
+
role: "assistant",
|
|
1008
|
+
text: turn.tutor
|
|
1009
|
+
};
|
|
1010
|
+
}
|
|
1011
|
+
/**
|
|
921
1012
|
* The canonical expected-verdict SET of one golden answer: a single verdict or a list,
|
|
922
1013
|
* deduped and sorted into `EVAL_VERDICTS` order. Canonical because the confusion
|
|
923
1014
|
* matrix keys its rows by this set — `correct|partial` must be one row no matter which
|
|
@@ -956,6 +1047,10 @@ function appendInstructions(existing, instructions) {
|
|
|
956
1047
|
* client sent no system message, a leading one carrying only the teacher's instructions
|
|
957
1048
|
* is added. Everything else (messages, tools, tool_choice, temperature, stream, …)
|
|
958
1049
|
* passes through verbatim, so client-side tools and streaming are all preserved.
|
|
1050
|
+
*
|
|
1051
|
+
* A `reasoning` level (the effective activity/code setting) is pinned exactly like
|
|
1052
|
+
* `model` — it OVERWRITES whatever the client sent as `reasoning_effort`. Without one
|
|
1053
|
+
* the client's own `reasoning_effort` passes through untouched, like any other parameter.
|
|
959
1054
|
*/
|
|
960
1055
|
function buildUpstreamChatBody(clientBody, opts) {
|
|
961
1056
|
const clientMessages = Array.isArray(clientBody.messages) ? clientBody.messages : [];
|
|
@@ -978,6 +1073,7 @@ function buildUpstreamChatBody(clientBody, opts) {
|
|
|
978
1073
|
model: opts.model,
|
|
979
1074
|
messages
|
|
980
1075
|
};
|
|
1076
|
+
if (opts.reasoning) upstream.reasoning_effort = opts.reasoning;
|
|
981
1077
|
if (clientBody.stream === true) upstream.stream_options = {
|
|
982
1078
|
...isRecord(clientBody.stream_options) ? clientBody.stream_options : {},
|
|
983
1079
|
include_usage: true
|
|
@@ -1040,6 +1136,11 @@ function parseCoding(content) {
|
|
|
1040
1136
|
ok: false,
|
|
1041
1137
|
message: "This coding activity uses an unsupported llm.provider (use \"SCCH\" or \"Azure Foundry\")."
|
|
1042
1138
|
};
|
|
1139
|
+
const reasoning = llm?.reasoning === void 0 ? void 0 : parseLenientReasoningLevel(llm.reasoning);
|
|
1140
|
+
if (llm?.reasoning !== void 0 && !reasoning) return {
|
|
1141
|
+
ok: false,
|
|
1142
|
+
message: "This coding activity uses an unsupported llm.reasoning (use \"minimal\", \"low\", \"medium\" or \"high\")."
|
|
1143
|
+
};
|
|
1043
1144
|
const instructions = asString$2(root.instructions);
|
|
1044
1145
|
if (!instructions) return {
|
|
1045
1146
|
ok: false,
|
|
@@ -1052,6 +1153,7 @@ function parseCoding(content) {
|
|
|
1052
1153
|
title: asString$2(root.title),
|
|
1053
1154
|
model,
|
|
1054
1155
|
provider,
|
|
1156
|
+
reasoning,
|
|
1055
1157
|
instructions,
|
|
1056
1158
|
fragmentBlock: readFragmentBlock(root)
|
|
1057
1159
|
}
|
|
@@ -2343,6 +2445,11 @@ function parseQuiz(content) {
|
|
|
2343
2445
|
ok: false,
|
|
2344
2446
|
message: "This quiz uses an unsupported llm.provider (use \"SCCH\" or \"Azure Foundry\")."
|
|
2345
2447
|
};
|
|
2448
|
+
const reasoning = llm?.reasoning === void 0 ? void 0 : parseLenientReasoningLevel(llm.reasoning);
|
|
2449
|
+
if (llm?.reasoning !== void 0 && !reasoning) return {
|
|
2450
|
+
ok: false,
|
|
2451
|
+
message: "This quiz uses an unsupported llm.reasoning (use \"minimal\", \"low\", \"medium\" or \"high\")."
|
|
2452
|
+
};
|
|
2346
2453
|
const quizFiles = Array.isArray(root.quiz_files) ? root.quiz_files : [];
|
|
2347
2454
|
const rawQuestions = Array.isArray(root.questions) ? root.questions : [];
|
|
2348
2455
|
if (rawQuestions.length === 0 && quizFiles.length === 0) return {
|
|
@@ -2385,6 +2492,7 @@ function parseQuiz(content) {
|
|
|
2385
2492
|
shuffle: asBool$1(root.shuffle, true),
|
|
2386
2493
|
model,
|
|
2387
2494
|
provider,
|
|
2495
|
+
reasoning,
|
|
2388
2496
|
questionCount,
|
|
2389
2497
|
imageInput: asBool$1(llm?.imageInput, false),
|
|
2390
2498
|
discussionInstructions: asString$1(root.discussion?.instructions),
|
|
@@ -2591,10 +2699,6 @@ async function loadQuizFrom(url, fetcher, opts = {}) {
|
|
|
2591
2699
|
};
|
|
2592
2700
|
}
|
|
2593
2701
|
}
|
|
2594
|
-
const tutorToolNameSchema = z.enum(["random_number"]).meta({
|
|
2595
|
-
id: "tutorToolName",
|
|
2596
|
-
description: "Name of a built-in tutor tool."
|
|
2597
|
-
});
|
|
2598
2702
|
//#endregion
|
|
2599
2703
|
//#region ../lib/tutors/schemas.ts
|
|
2600
2704
|
/**
|
|
@@ -2622,6 +2726,7 @@ const TutorSchema = z.strictObject({
|
|
|
2622
2726
|
llm: z.strictObject({
|
|
2623
2727
|
model: z.string().meta({ description: "Model used for this tutor." }),
|
|
2624
2728
|
provider: providerSchema,
|
|
2729
|
+
reasoning: reasoningLevelSchema,
|
|
2625
2730
|
imageInput: z.boolean().optional().meta({
|
|
2626
2731
|
default: true,
|
|
2627
2732
|
description: "Image uploads are enabled by default. Set to false to hide the upload UI for text-only tutors or non-vision-capable models."
|
|
@@ -2670,6 +2775,7 @@ async function loadAndBuildTutorPrompt(url, fetchImpl, opts = {}) {
|
|
|
2670
2775
|
prompt: assembled.prompt,
|
|
2671
2776
|
model: tutor.llm.model,
|
|
2672
2777
|
provider: tutor.llm.provider,
|
|
2778
|
+
reasoning: tutor.llm.reasoning,
|
|
2673
2779
|
imageInput: tutor.llm.imageInput ?? true,
|
|
2674
2780
|
tools: tutor.tools,
|
|
2675
2781
|
anonymous: tutor.anonymous ?? true,
|
|
@@ -2720,6 +2826,11 @@ function parseWriting(content) {
|
|
|
2720
2826
|
ok: false,
|
|
2721
2827
|
message: "This writing activity uses an unsupported llm.provider (use \"SCCH\" or \"Azure Foundry\")."
|
|
2722
2828
|
};
|
|
2829
|
+
const reasoning = llm?.reasoning === void 0 ? void 0 : parseLenientReasoningLevel(llm.reasoning);
|
|
2830
|
+
if (llm?.reasoning !== void 0 && !reasoning) return {
|
|
2831
|
+
ok: false,
|
|
2832
|
+
message: "This writing activity uses an unsupported llm.reasoning (use \"minimal\", \"low\", \"medium\" or \"high\")."
|
|
2833
|
+
};
|
|
2723
2834
|
const instructions = asString(root.instructions);
|
|
2724
2835
|
if (!instructions) return {
|
|
2725
2836
|
ok: false,
|
|
@@ -2735,6 +2846,7 @@ function parseWriting(content) {
|
|
|
2735
2846
|
anonymous: asBool(root.anonymous, false),
|
|
2736
2847
|
model,
|
|
2737
2848
|
provider,
|
|
2849
|
+
reasoning,
|
|
2738
2850
|
instructions,
|
|
2739
2851
|
fragmentBlock: readFragmentBlock(root),
|
|
2740
2852
|
placeholder: asString(root.placeholder)
|
|
@@ -2841,7 +2953,8 @@ const promptDumpers = {
|
|
|
2841
2953
|
id: result.id,
|
|
2842
2954
|
llm: {
|
|
2843
2955
|
provider: result.provider,
|
|
2844
|
-
model: result.model
|
|
2956
|
+
model: result.model,
|
|
2957
|
+
...result.reasoning ? { reasoning: result.reasoning } : {}
|
|
2845
2958
|
},
|
|
2846
2959
|
system: result.prompt,
|
|
2847
2960
|
tools: result.tools
|
|
@@ -2859,7 +2972,8 @@ const promptDumpers = {
|
|
|
2859
2972
|
id: quiz.id,
|
|
2860
2973
|
llm: {
|
|
2861
2974
|
provider: quiz.provider,
|
|
2862
|
-
model: quiz.model
|
|
2975
|
+
model: quiz.model,
|
|
2976
|
+
...quiz.reasoning ? { reasoning: quiz.reasoning } : {}
|
|
2863
2977
|
},
|
|
2864
2978
|
grading: {
|
|
2865
2979
|
userMessageTemplate: QUIZ_ANSWER_MESSAGE_TEMPLATE,
|
|
@@ -2899,7 +3013,8 @@ const promptDumpers = {
|
|
|
2899
3013
|
id: writing.id,
|
|
2900
3014
|
llm: {
|
|
2901
3015
|
provider: writing.provider,
|
|
2902
|
-
model: writing.model
|
|
3016
|
+
model: writing.model,
|
|
3017
|
+
...writing.reasoning ? { reasoning: writing.reasoning } : {}
|
|
2903
3018
|
},
|
|
2904
3019
|
system: writing.instructions
|
|
2905
3020
|
}
|
|
@@ -2921,7 +3036,8 @@ const promptDumpers = {
|
|
|
2921
3036
|
id: coding.id,
|
|
2922
3037
|
llm: {
|
|
2923
3038
|
provider: coding.provider,
|
|
2924
|
-
model: coding.model
|
|
3039
|
+
model: coding.model,
|
|
3040
|
+
...coding.reasoning ? { reasoning: coding.reasoning } : {}
|
|
2925
3041
|
},
|
|
2926
3042
|
system: coding.instructions,
|
|
2927
3043
|
upstreamSystemMessage: typeof system?.content === "string" ? system.content : ""
|
|
@@ -3024,6 +3140,7 @@ const QuizYamlSchema = z.strictObject({
|
|
|
3024
3140
|
llm: z.strictObject({
|
|
3025
3141
|
model: z.string().min(1).meta({ description: "The model that grades answers and drives the per-question discussion chat." }),
|
|
3026
3142
|
provider: providerSchema,
|
|
3143
|
+
reasoning: reasoningLevelSchema,
|
|
3027
3144
|
imageInput: z.boolean().optional().meta({
|
|
3028
3145
|
default: false,
|
|
3029
3146
|
description: "Default for all questions: students may attach photos (up to 3, 5 MB each) to their answers. The model must be vision-capable. A per-question imageInput overrides it."
|
|
@@ -3252,7 +3369,7 @@ function schemaErrors(issues, url) {
|
|
|
3252
3369
|
/**
|
|
3253
3370
|
* Check ONE eval file end to end. `fetcher` is the caller's network seam and
|
|
3254
3371
|
* `allowedSchemes` the usual SSRF gate (the CLI adds `file:` so an on-disk eval
|
|
3255
|
-
* resolves the
|
|
3372
|
+
* resolves the activity sitting next to it).
|
|
3256
3373
|
*/
|
|
3257
3374
|
async function loadAndCheckEval(url, fetchImpl, opts = {}) {
|
|
3258
3375
|
const allowedSchemes = opts.allowedSchemes;
|
|
@@ -3261,6 +3378,7 @@ async function loadAndCheckEval(url, fetchImpl, opts = {}) {
|
|
|
3261
3378
|
const parsed = EvalYamlSchema.safeParse(yaml.value);
|
|
3262
3379
|
if (!parsed.success) return fail(schemaErrors(parsed.error.issues, url));
|
|
3263
3380
|
const evalFile = parsed.data;
|
|
3381
|
+
const kind = evalKindOf(evalFile);
|
|
3264
3382
|
let targetUrl;
|
|
3265
3383
|
try {
|
|
3266
3384
|
targetUrl = new URL(evalFile.target, url).href;
|
|
@@ -3278,18 +3396,37 @@ async function loadAndCheckEval(url, fetchImpl, opts = {}) {
|
|
|
3278
3396
|
}
|
|
3279
3397
|
const warnings = [];
|
|
3280
3398
|
if (opts.strictTarget) {
|
|
3281
|
-
const strict = await
|
|
3399
|
+
const strict = kind === "tutor" ? await loadAndBuildTutorPrompt(targetUrl, fetchImpl, {
|
|
3400
|
+
allowedSchemes,
|
|
3401
|
+
validateLibraries: opts.validateLibraries ?? true
|
|
3402
|
+
}) : await loadAndCheckQuiz(targetUrl, fetchImpl, {
|
|
3282
3403
|
allowedSchemes,
|
|
3283
3404
|
validateLibraries: opts.validateLibraries ?? true
|
|
3284
3405
|
});
|
|
3285
3406
|
warnings.push(...strict.warnings);
|
|
3286
3407
|
if (!strict.ok) return fail(strict.errors, warnings);
|
|
3287
3408
|
}
|
|
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
|
-
|
|
3409
|
+
const dumped = await dumpPrompts(kind, targetUrl, fetchImpl, { allowedSchemes });
|
|
3410
|
+
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);
|
|
3411
|
+
const dump = dumped.dump;
|
|
3412
|
+
if (dump.kind !== kind) return fail([error("EVAL_TARGET_ERROR", `The target is not a ${kind}.`, { url: targetUrl })], warnings);
|
|
3413
|
+
if (dump.kind === "tutor" && evalFile.kind === "tutor") {
|
|
3414
|
+
const granted = new Set(dump.tools);
|
|
3415
|
+
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 })));
|
|
3416
|
+
if (ungranted.length > 0) return fail(ungranted, warnings);
|
|
3417
|
+
return {
|
|
3418
|
+
ok: true,
|
|
3419
|
+
kind: "tutor",
|
|
3420
|
+
evalFile,
|
|
3421
|
+
targetUrl,
|
|
3422
|
+
llm: dump.llm,
|
|
3423
|
+
tutorDump: dump,
|
|
3424
|
+
caseCount: evalFile.conversations.length,
|
|
3425
|
+
warnings
|
|
3426
|
+
};
|
|
3427
|
+
}
|
|
3428
|
+
if (dump.kind !== "quiz" || evalFile.kind === "tutor") return fail([error("EVAL_TARGET_ERROR", `The target is not a ${kind}.`, { url: targetUrl })], warnings);
|
|
3429
|
+
const known = new Set(dump.grading.questions.map((question) => question.id));
|
|
3293
3430
|
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
3431
|
questionId: question.question,
|
|
3295
3432
|
url: targetUrl
|
|
@@ -3302,9 +3439,11 @@ async function loadAndCheckEval(url, fetchImpl, opts = {}) {
|
|
|
3302
3439
|
})) : [];
|
|
3303
3440
|
return {
|
|
3304
3441
|
ok: true,
|
|
3442
|
+
kind: "quiz",
|
|
3305
3443
|
evalFile,
|
|
3306
3444
|
targetUrl,
|
|
3307
|
-
|
|
3445
|
+
llm: dump.llm,
|
|
3446
|
+
quizDump: dump,
|
|
3308
3447
|
quizQuestions,
|
|
3309
3448
|
caseCount: evalFile.questions.reduce((sum, question) => sum + question.answers.length, 0),
|
|
3310
3449
|
warnings
|
|
@@ -3394,6 +3533,106 @@ function buildFeedbackJudgeSubject(gradingSystem, answer, verdict, feedback) {
|
|
|
3394
3533
|
].join("\n");
|
|
3395
3534
|
}
|
|
3396
3535
|
//#endregion
|
|
3536
|
+
//#region ../lib/tutor-judge.ts
|
|
3537
|
+
/**
|
|
3538
|
+
* The TUTOR-response taxonomy. The judge may only name one of these, and the endpoint
|
|
3539
|
+
* constrains the model to whatever the CALLER sent (`judgmentSchema`) — which is what
|
|
3540
|
+
* keeps `/api/eval/judge` kind-agnostic.
|
|
3541
|
+
*
|
|
3542
|
+
* Deliberately NOT documented in code comments: every definition lives in
|
|
3543
|
+
* {@link TUTOR_JUDGE_SYSTEM}, where the model actually reads it, so the two can never
|
|
3544
|
+
* drift apart.
|
|
3545
|
+
*/
|
|
3546
|
+
const TUTOR_JUDGE_CRITERIA = [
|
|
3547
|
+
"ignores_instructions",
|
|
3548
|
+
"fails_expectations",
|
|
3549
|
+
"misstates_facts",
|
|
3550
|
+
"leaks_prompt"
|
|
3551
|
+
];
|
|
3552
|
+
/** The criterion that only exists when the teacher stated expectations for the case. */
|
|
3553
|
+
const EXPECTATIONS_CRITERION = "fails_expectations";
|
|
3554
|
+
/**
|
|
3555
|
+
* The criteria ONE case's judge call may use. A case without `grading_instructions`
|
|
3556
|
+
* drops `fails_expectations` entirely — the per-request enum makes that free, and it
|
|
3557
|
+
* removes by construction the failure mode of a judge inventing expectations nobody
|
|
3558
|
+
* stated.
|
|
3559
|
+
*/
|
|
3560
|
+
function tutorJudgeCriteria(hasGradingInstructions) {
|
|
3561
|
+
return TUTOR_JUDGE_CRITERIA.filter((criterion) => hasGradingInstructions || criterion !== EXPECTATIONS_CRITERION);
|
|
3562
|
+
}
|
|
3563
|
+
/**
|
|
3564
|
+
* The judge's system prompt. The three guardrails measured on the quiz judge before
|
|
3565
|
+
* shipping carry over verbatim in spirit:
|
|
3566
|
+
*
|
|
3567
|
+
* * "Do NOT judge pedagogical quality or style" — the tutor analogue of the quiz
|
|
3568
|
+
* judge's "do not judge the verdict": a judge that rates teaching craft produces
|
|
3569
|
+
* noise the report cannot act on. Compliance is the whole job.
|
|
3570
|
+
* * "be strict about real violations, but do not invent issues … when in doubt, the
|
|
3571
|
+
* response is ok" — without it, weak models flag matters of taste.
|
|
3572
|
+
* * an EMPTY `issues` array is the way to say "acceptable". There is deliberately no
|
|
3573
|
+
* `ok` boolean: weak judges set `ok: false` and then name no issue at all, which is
|
|
3574
|
+
* unreportable. Flagged ⇔ an issue was named.
|
|
3575
|
+
*/
|
|
3576
|
+
const TUTOR_JUDGE_SYSTEM = `You are auditing ONE response an AI TUTOR gave to a student.
|
|
3577
|
+
|
|
3578
|
+
You receive:
|
|
3579
|
+
- the complete system prompt the tutor was given (it contains the persona, the
|
|
3580
|
+
course rules and the scope the tutor must stay inside),
|
|
3581
|
+
- the conversation so far, turn by turn, ending with the student message the
|
|
3582
|
+
tutor had to answer,
|
|
3583
|
+
- the response the tutor generated for that last student message,
|
|
3584
|
+
- and, when the teacher wrote any, extra expectations for THIS case.
|
|
3585
|
+
|
|
3586
|
+
Judge ONLY the generated tutor response, on these criteria:
|
|
3587
|
+
|
|
3588
|
+
- "ignores_instructions": the response breaks an explicit rule of the tutor's
|
|
3589
|
+
own system prompt — e.g. it writes out the complete solution although the
|
|
3590
|
+
prompt forbids that, leaves the concepts the prompt limits it to, answers in
|
|
3591
|
+
a language the prompt does not allow, or ignores a stated formatting rule.
|
|
3592
|
+
- "fails_expectations": the response violates the teacher's extra expectations
|
|
3593
|
+
for this case, when such expectations were given.
|
|
3594
|
+
- "misstates_facts": the response asserts something that is factually wrong for
|
|
3595
|
+
the subject matter — bad code, a wrong term, an untrue claim.
|
|
3596
|
+
- "leaks_prompt": the response quotes or reveals its own instructions, or talks
|
|
3597
|
+
about "my rules", "my prompt", "the instructions I was given".
|
|
3598
|
+
|
|
3599
|
+
Do NOT judge pedagogical quality, tone, length or style — a response you would
|
|
3600
|
+
have written differently is NOT an issue. Judge COMPLIANCE with the system
|
|
3601
|
+
prompt and with the stated expectations, nothing else. Be strict about real
|
|
3602
|
+
violations, but do not invent issues. When in doubt, the response is ok.
|
|
3603
|
+
|
|
3604
|
+
Return one entry in "issues" per violation you found, and an EMPTY "issues"
|
|
3605
|
+
array when the response is acceptable. Answer with the JSON object only.`;
|
|
3606
|
+
/** `student:` / `tutor:` — the teacher-facing role labels, also used in the subject. */
|
|
3607
|
+
function turnLabel(turn) {
|
|
3608
|
+
return "student" in turn ? `student: ${turn.student}` : `tutor: ${turn.tutor}`;
|
|
3609
|
+
}
|
|
3610
|
+
/**
|
|
3611
|
+
* The judge's USER message: the inputs in labeled `===` blocks — the tutor's system
|
|
3612
|
+
* prompt (the standard), the scripted conversation, the response under judgment, the
|
|
3613
|
+
* tools the tutor actually reached for, and the teacher's expectations when the case
|
|
3614
|
+
* states any.
|
|
3615
|
+
*
|
|
3616
|
+
* Nothing is escaped — every part is DATA for the judge, not markup, and a course prompt
|
|
3617
|
+
* containing `===` or Markdown must reach the judge exactly as the tutor saw it.
|
|
3618
|
+
*/
|
|
3619
|
+
function buildTutorJudgeSubject(tutorSystem, conversation, response, options = {}) {
|
|
3620
|
+
const { gradingInstructions, tools, toolCalls } = options;
|
|
3621
|
+
const blocks = [
|
|
3622
|
+
"=== The system prompt the tutor was given ===",
|
|
3623
|
+
tutorSystem,
|
|
3624
|
+
"",
|
|
3625
|
+
"=== The conversation so far (the last turn is what the tutor answered) ===",
|
|
3626
|
+
conversation.map(turnLabel).join("\n\n"),
|
|
3627
|
+
"",
|
|
3628
|
+
"=== The tutor's generated response (JUDGE THIS) ===",
|
|
3629
|
+
response
|
|
3630
|
+
];
|
|
3631
|
+
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)");
|
|
3632
|
+
if (gradingInstructions) blocks.push("", "=== The teacher's expectations for this case ===", gradingInstructions);
|
|
3633
|
+
return blocks.join("\n");
|
|
3634
|
+
}
|
|
3635
|
+
//#endregion
|
|
3397
3636
|
//#region src/retry.ts
|
|
3398
3637
|
const defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
3399
3638
|
/**
|
|
@@ -3455,6 +3694,35 @@ function createJudgeBreaker() {
|
|
|
3455
3694
|
}
|
|
3456
3695
|
/** Consecutive fully-errored judge calls that mean "stop judging for the rest of the run". */
|
|
3457
3696
|
const JUDGE_BREAKER_LIMIT = 3;
|
|
3697
|
+
/**
|
|
3698
|
+
* The spec a run's calls are actually served with, out of the TARGET activity's own spec
|
|
3699
|
+
* and the run's two override flags. TWO independent axes (docs/cli-eval.md):
|
|
3700
|
+
*
|
|
3701
|
+
* - the PAIR (`--llm-provider`/`--llm-model`) replaces provider+model **wholesale**, so a
|
|
3702
|
+
* pair given without a level DROPS the file's level — the same bundle semantics a
|
|
3703
|
+
* per-code LLM override has (`effectiveLlm`, docs/ai-models.md);
|
|
3704
|
+
* - the LEVEL (`--llm-reasoning`) replaces only the effort, on top of whichever pair won,
|
|
3705
|
+
* which is what makes "the file's own model, at high effort" a one-flag run.
|
|
3706
|
+
*
|
|
3707
|
+
* The judge's flags reuse this with the EFFECTIVE grading spec as the activity, which is
|
|
3708
|
+
* why "no judge flag" means "judge exactly like the model under test", level included.
|
|
3709
|
+
*/
|
|
3710
|
+
function resolveEvalSpec(activity, pair, reasoning) {
|
|
3711
|
+
const base = pair ?? activity;
|
|
3712
|
+
return reasoning ? {
|
|
3713
|
+
provider: base.provider,
|
|
3714
|
+
model: base.model,
|
|
3715
|
+
reasoning
|
|
3716
|
+
} : base;
|
|
3717
|
+
}
|
|
3718
|
+
/** Do two specs describe the same call? Provider, model AND effort — all three matter. */
|
|
3719
|
+
function sameEvalSpec(a, b) {
|
|
3720
|
+
return a.provider === b.provider && a.model === b.model && a.reasoning === b.reasoning;
|
|
3721
|
+
}
|
|
3722
|
+
/** Narrow a case to the tutor arm. */
|
|
3723
|
+
function isTutorCase(evalCase) {
|
|
3724
|
+
return "conversation" in evalCase;
|
|
3725
|
+
}
|
|
3458
3726
|
/** Consecutive fully-errored cases that mean "the server is down, stop now". */
|
|
3459
3727
|
const CIRCUIT_BREAKER_LIMIT = 3;
|
|
3460
3728
|
/** Flatten questions × answers into cases, each carrying its grading prompt. */
|
|
@@ -3495,38 +3763,30 @@ function judgeErrorMessage(error) {
|
|
|
3495
3763
|
if (typeof error === "object" && error !== null && "message" in error) return String(error.message);
|
|
3496
3764
|
return JSON.stringify(error ?? null);
|
|
3497
3765
|
}
|
|
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) => {
|
|
3766
|
+
/**
|
|
3767
|
+
* The `judge` field of a repeat that produced NO judgment — spread onto every row that
|
|
3768
|
+
* is not a successful generation. Judging on ⇒ an explicit `null` (so a script reading
|
|
3769
|
+
* `judge === null` catches every unjudged repeat, not only the degraded ones); judging
|
|
3770
|
+
* off ⇒ nothing at all, since the whole run then carries no judge fields.
|
|
3771
|
+
*/
|
|
3772
|
+
function unjudgedFields(options) {
|
|
3773
|
+
return options.judge ? { judge: null } : {};
|
|
3774
|
+
}
|
|
3775
|
+
/**
|
|
3776
|
+
* The KIND-AGNOSTIC judge step: judge ONE repeat's output as a dependent step of that
|
|
3777
|
+
* repeat, retrying and feeding the run-wide degrade breaker. Each kind assembles its own
|
|
3778
|
+
* `system` / `subject` / `criteria` (quiz via `lib/quiz-feedback-judge.ts`, tutor via
|
|
3779
|
+
* `lib/tutor-judge.ts`) — the endpoint and this step never learn the kind.
|
|
3780
|
+
*
|
|
3781
|
+
* Returns the fields to merge onto the row: a judgment, or `judge: null` plus a
|
|
3782
|
+
* `judgeError` when the call failed, or a bare `judge: null` when the breaker had
|
|
3783
|
+
* already degraded the run.
|
|
3784
|
+
*/
|
|
3785
|
+
function createJudgeStep(options, breaker) {
|
|
3786
|
+
return async (request) => {
|
|
3523
3787
|
const judge = options.judge;
|
|
3524
3788
|
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
|
-
}), {
|
|
3789
|
+
const outcome = await withRetry(() => judge(request), {
|
|
3530
3790
|
attempts: options.retry?.attempts,
|
|
3531
3791
|
baseDelayMs: options.retry?.baseDelayMs,
|
|
3532
3792
|
sleep: options.retry?.sleep,
|
|
@@ -3549,6 +3809,33 @@ const evalRunners = { quiz: { async run(checked, options) {
|
|
|
3549
3809
|
judgeError: judgeErrorMessage(outcome.error)
|
|
3550
3810
|
};
|
|
3551
3811
|
};
|
|
3812
|
+
}
|
|
3813
|
+
const quizEvalRunner = { async run(rawChecked, options) {
|
|
3814
|
+
if (rawChecked.kind !== "quiz") throw new Error("The quiz eval runner needs a quiz eval file.");
|
|
3815
|
+
const checked = rawChecked;
|
|
3816
|
+
const grade = options.grade;
|
|
3817
|
+
if (!grade) throw new Error("The quiz eval runner needs a `grade` seam.");
|
|
3818
|
+
const repeats = Math.max(1, Math.floor(options.repeats ?? 1));
|
|
3819
|
+
const concurrency = Math.max(1, Math.floor(options.concurrency ?? 4));
|
|
3820
|
+
const planned = planCases(checked);
|
|
3821
|
+
const total = planned.length * repeats;
|
|
3822
|
+
const questionTexts = new Map(checked.quizQuestions.map((q) => [q.id, q.text]));
|
|
3823
|
+
const breaker = options.judgeBreaker ?? createJudgeBreaker();
|
|
3824
|
+
const judgeStep = createJudgeStep(options, breaker);
|
|
3825
|
+
let done = 0;
|
|
3826
|
+
let consecutiveErrored = 0;
|
|
3827
|
+
let aborted;
|
|
3828
|
+
const unjudged = unjudgedFields(options);
|
|
3829
|
+
/**
|
|
3830
|
+
* Judge ONE graded repeat's feedback, against the repeat's OWN verdict — never the
|
|
3831
|
+
* case majority: an outvoted repeat's feedback is consistent with the verdict it
|
|
3832
|
+
* actually got.
|
|
3833
|
+
*/
|
|
3834
|
+
const judgeRepeat = (system, answer, verdict, feedback) => judgeStep({
|
|
3835
|
+
system: FEEDBACK_JUDGE_SYSTEM,
|
|
3836
|
+
subject: buildFeedbackJudgeSubject(system, answer, verdict, feedback),
|
|
3837
|
+
criteria: FEEDBACK_JUDGE_CRITERIA
|
|
3838
|
+
});
|
|
3552
3839
|
const progress = () => {
|
|
3553
3840
|
done += 1;
|
|
3554
3841
|
options.onProgress?.({
|
|
@@ -3569,7 +3856,7 @@ const evalRunners = { quiz: { async run(checked, options) {
|
|
|
3569
3856
|
progress();
|
|
3570
3857
|
continue;
|
|
3571
3858
|
}
|
|
3572
|
-
const outcome = await withRetry(() =>
|
|
3859
|
+
const outcome = await withRetry(() => grade({
|
|
3573
3860
|
system: plan.system,
|
|
3574
3861
|
answer: plan.answer
|
|
3575
3862
|
}), {
|
|
@@ -3623,6 +3910,7 @@ const evalRunners = { quiz: { async run(checked, options) {
|
|
|
3623
3910
|
...winner ? { verdict: winner.verdict } : {},
|
|
3624
3911
|
unstable: new Set(graded).size > 1,
|
|
3625
3912
|
feedbackFlagged: rows.some((row) => (row.judge?.issues.length ?? 0) > 0),
|
|
3913
|
+
toolsFlagged: false,
|
|
3626
3914
|
repeats: rows
|
|
3627
3915
|
};
|
|
3628
3916
|
});
|
|
@@ -3639,6 +3927,7 @@ const evalRunners = { quiz: { async run(checked, options) {
|
|
|
3639
3927
|
skipped: results.filter((c) => c.status === "skipped").length,
|
|
3640
3928
|
unstable: results.filter((c) => c.unstable).length,
|
|
3641
3929
|
feedbackFlagged: results.filter((c) => c.feedbackFlagged).length,
|
|
3930
|
+
toolsFlagged: 0,
|
|
3642
3931
|
judgeErrored: results.reduce((sum, c) => sum + c.repeats.filter((row) => row.judgeError !== void 0).length, 0),
|
|
3643
3932
|
repeats,
|
|
3644
3933
|
calls: total,
|
|
@@ -3647,7 +3936,7 @@ const evalRunners = { quiz: { async run(checked, options) {
|
|
|
3647
3936
|
const confusionCounts = /* @__PURE__ */ new Map();
|
|
3648
3937
|
for (const result of results) {
|
|
3649
3938
|
if (!result.verdict) continue;
|
|
3650
|
-
const key = `${expectedKey(result.expected)}
|
|
3939
|
+
const key = `${expectedKey(result.expected)}\u0000${result.verdict}`;
|
|
3651
3940
|
confusionCounts.set(key, (confusionCounts.get(key) ?? 0) + 1);
|
|
3652
3941
|
}
|
|
3653
3942
|
const confusion = [...confusionCounts.entries()].map(([key, count]) => {
|
|
@@ -3662,6 +3951,7 @@ const evalRunners = { quiz: { async run(checked, options) {
|
|
|
3662
3951
|
const falseCorrectCount = strictCases.filter((result) => result.verdict === "correct").length;
|
|
3663
3952
|
return {
|
|
3664
3953
|
id: checked.evalFile.id,
|
|
3954
|
+
kind: "quiz",
|
|
3665
3955
|
target: checked.targetUrl,
|
|
3666
3956
|
llm: options.llm,
|
|
3667
3957
|
judging: !options.judge ? "off" : breaker.stopped ? "degraded" : "on",
|
|
@@ -3680,7 +3970,178 @@ const evalRunners = { quiz: { async run(checked, options) {
|
|
|
3680
3970
|
},
|
|
3681
3971
|
...aborted ? { aborted } : {}
|
|
3682
3972
|
};
|
|
3683
|
-
} }
|
|
3973
|
+
} };
|
|
3974
|
+
/** One planned case per conversation, in file order. */
|
|
3975
|
+
function planTutorCases(checked) {
|
|
3976
|
+
return checked.evalFile.conversations.map((conversation, index) => ({
|
|
3977
|
+
index,
|
|
3978
|
+
...conversation.title ? { title: conversation.title } : {},
|
|
3979
|
+
conversation: conversation.conversation,
|
|
3980
|
+
...conversation.grading_instructions ? { gradingInstructions: conversation.grading_instructions } : {},
|
|
3981
|
+
...conversation.required_tools ? { requiredTools: [...conversation.required_tools] } : {},
|
|
3982
|
+
messages: conversation.conversation.map(turnToMessage)
|
|
3983
|
+
}));
|
|
3984
|
+
}
|
|
3985
|
+
/**
|
|
3986
|
+
* The message a repeat carries when the case REQUIRES tools but the 200 answered without a
|
|
3987
|
+
* `toolCalls` field: a new CLI against a server too old to report them. Terminal and loud
|
|
3988
|
+
* — reporting "nothing missing" for a check that never ran would certify a tool
|
|
3989
|
+
* expectation nobody verified, which is worse than failing. The advisory `/api/version`
|
|
3990
|
+
* check cannot carry this: it only warns (never gates, never compares an ordering), while
|
|
3991
|
+
* this must fail the run's health.
|
|
3992
|
+
*/
|
|
3993
|
+
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.";
|
|
3994
|
+
/** The required tools this repeat never called, in the case's own order. */
|
|
3995
|
+
function missingToolsOf(required, called) {
|
|
3996
|
+
const seen = new Set(called);
|
|
3997
|
+
return required.filter((tool) => !seen.has(tool));
|
|
3998
|
+
}
|
|
3999
|
+
/** The seam: one runner per eval kind (mirrors `promptDumpers`). */
|
|
4000
|
+
const evalRunners = {
|
|
4001
|
+
quiz: quizEvalRunner,
|
|
4002
|
+
tutor: { async run(rawChecked, options) {
|
|
4003
|
+
if (rawChecked.kind !== "tutor") throw new Error("The tutor eval runner needs a tutor eval file.");
|
|
4004
|
+
const checked = rawChecked;
|
|
4005
|
+
const respond = options.respond;
|
|
4006
|
+
if (!respond) throw new Error("The tutor eval runner needs a `respond` seam.");
|
|
4007
|
+
const repeats = Math.max(1, Math.floor(options.repeats ?? 1));
|
|
4008
|
+
const concurrency = Math.max(1, Math.floor(options.concurrency ?? 4));
|
|
4009
|
+
const planned = planTutorCases(checked);
|
|
4010
|
+
const total = planned.length * repeats;
|
|
4011
|
+
const system = checked.tutorDump.system;
|
|
4012
|
+
const tools = checked.tutorDump.tools;
|
|
4013
|
+
const breaker = options.judgeBreaker ?? createJudgeBreaker();
|
|
4014
|
+
const judgeStep = createJudgeStep(options, breaker);
|
|
4015
|
+
let done = 0;
|
|
4016
|
+
let consecutiveErrored = 0;
|
|
4017
|
+
let aborted;
|
|
4018
|
+
const unjudged = unjudgedFields(options);
|
|
4019
|
+
const progress = () => {
|
|
4020
|
+
done += 1;
|
|
4021
|
+
options.onProgress?.({
|
|
4022
|
+
done,
|
|
4023
|
+
total
|
|
4024
|
+
});
|
|
4025
|
+
};
|
|
4026
|
+
const results = await mapWithConcurrency(planned, concurrency, async (plan) => {
|
|
4027
|
+
const rows = [];
|
|
4028
|
+
for (let repeatIndex = 0; repeatIndex < repeats; repeatIndex++) {
|
|
4029
|
+
if (aborted) break;
|
|
4030
|
+
const outcome = await withRetry(() => respond({
|
|
4031
|
+
system,
|
|
4032
|
+
tools,
|
|
4033
|
+
messages: plan.messages
|
|
4034
|
+
}), {
|
|
4035
|
+
attempts: options.retry?.attempts,
|
|
4036
|
+
baseDelayMs: options.retry?.baseDelayMs,
|
|
4037
|
+
sleep: options.retry?.sleep,
|
|
4038
|
+
shouldRetry: (value) => !value.ok && value.retryable && value.auth !== true
|
|
4039
|
+
});
|
|
4040
|
+
if (outcome.ok) {
|
|
4041
|
+
if (plan.requiredTools && outcome.toolCalls === void 0) {
|
|
4042
|
+
progress();
|
|
4043
|
+
rows.push({
|
|
4044
|
+
repeatIndex,
|
|
4045
|
+
error: { message: NO_TOOL_CALLS_REPORTED },
|
|
4046
|
+
...unjudged
|
|
4047
|
+
});
|
|
4048
|
+
break;
|
|
4049
|
+
}
|
|
4050
|
+
const missingTools = plan.requiredTools ? missingToolsOf(plan.requiredTools, outcome.toolCalls ?? []) : void 0;
|
|
4051
|
+
const judged = options.judge ? await judgeStep({
|
|
4052
|
+
system: TUTOR_JUDGE_SYSTEM,
|
|
4053
|
+
subject: buildTutorJudgeSubject(system, plan.conversation, outcome.text, {
|
|
4054
|
+
...plan.gradingInstructions ? { gradingInstructions: plan.gradingInstructions } : {},
|
|
4055
|
+
tools,
|
|
4056
|
+
...outcome.toolCalls ? { toolCalls: outcome.toolCalls } : {}
|
|
4057
|
+
}),
|
|
4058
|
+
criteria: tutorJudgeCriteria(plan.gradingInstructions !== void 0)
|
|
4059
|
+
}) : {};
|
|
4060
|
+
progress();
|
|
4061
|
+
rows.push({
|
|
4062
|
+
repeatIndex,
|
|
4063
|
+
text: outcome.text,
|
|
4064
|
+
...outcome.toolCalls ? { toolCalls: outcome.toolCalls } : {},
|
|
4065
|
+
...missingTools ? { missingTools } : {},
|
|
4066
|
+
...outcome.usage ? { usage: outcome.usage } : {},
|
|
4067
|
+
...judged
|
|
4068
|
+
});
|
|
4069
|
+
continue;
|
|
4070
|
+
}
|
|
4071
|
+
progress();
|
|
4072
|
+
rows.push({
|
|
4073
|
+
repeatIndex,
|
|
4074
|
+
error: outcome.error,
|
|
4075
|
+
...unjudged
|
|
4076
|
+
});
|
|
4077
|
+
if (outcome.auth) {
|
|
4078
|
+
aborted ??= {
|
|
4079
|
+
reason: "auth",
|
|
4080
|
+
message: "Authentication failed — the run was aborted. Run `novedu-cli login`."
|
|
4081
|
+
};
|
|
4082
|
+
break;
|
|
4083
|
+
}
|
|
4084
|
+
}
|
|
4085
|
+
const generated = rows.some((row) => row.text !== void 0);
|
|
4086
|
+
const status = rows.length === 0 ? "skipped" : generated ? "ok" : "errored";
|
|
4087
|
+
if (status === "errored") {
|
|
4088
|
+
consecutiveErrored += 1;
|
|
4089
|
+
if (consecutiveErrored >= CIRCUIT_BREAKER_LIMIT) aborted ??= {
|
|
4090
|
+
reason: "circuit-breaker",
|
|
4091
|
+
message: `${CIRCUIT_BREAKER_LIMIT} cases failed in a row — the run was aborted.`
|
|
4092
|
+
};
|
|
4093
|
+
} else if (status !== "skipped") consecutiveErrored = 0;
|
|
4094
|
+
return {
|
|
4095
|
+
index: plan.index,
|
|
4096
|
+
...plan.title ? { title: plan.title } : {},
|
|
4097
|
+
conversation: plan.conversation,
|
|
4098
|
+
...plan.gradingInstructions ? { gradingInstructions: plan.gradingInstructions } : {},
|
|
4099
|
+
...plan.requiredTools ? { requiredTools: plan.requiredTools } : {},
|
|
4100
|
+
status,
|
|
4101
|
+
unstable: false,
|
|
4102
|
+
feedbackFlagged: rows.some((row) => (row.judge?.issues.length ?? 0) > 0),
|
|
4103
|
+
toolsFlagged: rows.some((row) => (row.missingTools?.length ?? 0) > 0),
|
|
4104
|
+
repeats: rows
|
|
4105
|
+
};
|
|
4106
|
+
});
|
|
4107
|
+
const usage = { ...ZERO_USAGE };
|
|
4108
|
+
for (const result of results) for (const row of result.repeats) {
|
|
4109
|
+
addUsage(usage, row.usage);
|
|
4110
|
+
addUsage(usage, row.judge?.usage);
|
|
4111
|
+
}
|
|
4112
|
+
return {
|
|
4113
|
+
id: checked.evalFile.id,
|
|
4114
|
+
kind: "tutor",
|
|
4115
|
+
target: checked.targetUrl,
|
|
4116
|
+
llm: options.llm,
|
|
4117
|
+
judging: !options.judge ? "off" : breaker.stopped ? "degraded" : "on",
|
|
4118
|
+
totals: {
|
|
4119
|
+
cases: results.length,
|
|
4120
|
+
passed: 0,
|
|
4121
|
+
failed: 0,
|
|
4122
|
+
errored: results.filter((c) => c.status === "errored").length,
|
|
4123
|
+
skipped: results.filter((c) => c.status === "skipped").length,
|
|
4124
|
+
unstable: 0,
|
|
4125
|
+
feedbackFlagged: results.filter((c) => c.feedbackFlagged).length,
|
|
4126
|
+
toolsFlagged: results.filter((c) => c.toolsFlagged).length,
|
|
4127
|
+
judgeErrored: results.reduce((sum, c) => sum + c.repeats.filter((row) => row.judgeError !== void 0).length, 0),
|
|
4128
|
+
repeats,
|
|
4129
|
+
calls: total,
|
|
4130
|
+
usage
|
|
4131
|
+
},
|
|
4132
|
+
questions: [],
|
|
4133
|
+
confusion: [],
|
|
4134
|
+
falseCorrect: {
|
|
4135
|
+
count: 0,
|
|
4136
|
+
denominator: 0,
|
|
4137
|
+
rate: 0
|
|
4138
|
+
},
|
|
4139
|
+
mismatches: results.filter((result) => result.status === "errored"),
|
|
4140
|
+
cases: results,
|
|
4141
|
+
...aborted ? { aborted } : {}
|
|
4142
|
+
};
|
|
4143
|
+
} }
|
|
4144
|
+
};
|
|
3684
4145
|
/** Run ONE checked eval file — the single entry point the command uses. */
|
|
3685
4146
|
function runEval(kind, checked, options) {
|
|
3686
4147
|
return evalRunners[kind].run(checked, options);
|
|
@@ -3706,6 +4167,7 @@ function summarizeBatch(files) {
|
|
|
3706
4167
|
skipped: 0,
|
|
3707
4168
|
unstable: 0,
|
|
3708
4169
|
feedbackFlagged: 0,
|
|
4170
|
+
toolsFlagged: 0,
|
|
3709
4171
|
judgeErrored: 0,
|
|
3710
4172
|
usage: { ...ZERO_USAGE }
|
|
3711
4173
|
};
|
|
@@ -3718,12 +4180,14 @@ function summarizeBatch(files) {
|
|
|
3718
4180
|
totals.skipped += file.result.totals.skipped;
|
|
3719
4181
|
totals.unstable += file.result.totals.unstable;
|
|
3720
4182
|
totals.feedbackFlagged += file.result.totals.feedbackFlagged;
|
|
4183
|
+
totals.toolsFlagged += file.result.totals.toolsFlagged;
|
|
3721
4184
|
totals.judgeErrored += file.result.totals.judgeErrored;
|
|
3722
4185
|
addUsage(totals.usage, file.result.totals.usage);
|
|
3723
4186
|
}
|
|
3724
4187
|
return {
|
|
3725
4188
|
files: files.map((file) => ({
|
|
3726
4189
|
...file,
|
|
4190
|
+
...file.result ? { kind: file.result.kind } : {},
|
|
3727
4191
|
passed: filePassed(file)
|
|
3728
4192
|
})),
|
|
3729
4193
|
passed: batchPassed({ totals }),
|
|
@@ -3740,13 +4204,25 @@ function anyJudged(result) {
|
|
|
3740
4204
|
return result.cases.some((evalCase) => evalCase.repeats.some((repeat) => repeat.judge !== void 0 && repeat.judge !== null));
|
|
3741
4205
|
}
|
|
3742
4206
|
/**
|
|
4207
|
+
* Did this file's run CHECK tool calls at all — i.e. does any case declare
|
|
4208
|
+
* `required_tools`? The tool sibling of {@link anyJudged}, and the same rule: a run that
|
|
4209
|
+
* required nothing has not been found complete, so its `toolsFlagged` count is OMITTED
|
|
4210
|
+
* rather than printed as a reassuring `0`.
|
|
4211
|
+
*/
|
|
4212
|
+
function anyToolsRequired(result) {
|
|
4213
|
+
return result.cases.some((evalCase) => isTutorCase(evalCase) && evalCase.requiredTools !== void 0);
|
|
4214
|
+
}
|
|
4215
|
+
/**
|
|
3743
4216
|
* The CI gate: every file valid, and not a single failed, errored, or skipped CASE —
|
|
3744
4217
|
* an aborted (and therefore incomplete) run must never read as a pass. The single
|
|
3745
4218
|
* source of truth for the exit code AND for `EvalBatchResult.passed`.
|
|
3746
4219
|
*
|
|
3747
|
-
* `unstable`, `feedbackFlagged` and `judgeErrored` deliberately do NOT
|
|
3748
|
-
*
|
|
3749
|
-
*
|
|
4220
|
+
* `unstable`, `feedbackFlagged`, `toolsFlagged` and `judgeErrored` deliberately do NOT
|
|
4221
|
+
* appear here: all four are reported, none gates. (Gating is per-KIND policy, not a
|
|
4222
|
+
* property of judge
|
|
4223
|
+
* results — and BOTH shipped kinds are report-only. For the tutor kind that is the whole
|
|
4224
|
+
* policy: its exit code reflects RUN HEALTH only, so a flagged conversation changes
|
|
4225
|
+
* nothing, and the Markdown report is the deliverable — docs/cli-eval.md.)
|
|
3750
4226
|
*/
|
|
3751
4227
|
function batchPassed(batch) {
|
|
3752
4228
|
return batch.totals.invalid === 0 && batch.totals.failed === 0 && batch.totals.errored === 0 && batch.totals.skipped === 0;
|
|
@@ -3772,6 +4248,9 @@ const cliFetcher = async (url) => {
|
|
|
3772
4248
|
};
|
|
3773
4249
|
//#endregion
|
|
3774
4250
|
//#region src/format.ts
|
|
4251
|
+
function flaggedLabel(kind) {
|
|
4252
|
+
return kind === "tutor" ? "flagged responses" : "flagged feedback";
|
|
4253
|
+
}
|
|
3775
4254
|
const useColor = process.stdout.isTTY && !process.env.NO_COLOR;
|
|
3776
4255
|
const paint = (code, s) => useColor ? `\x1b[${code}m${s}\x1b[0m` : s;
|
|
3777
4256
|
const green = (s) => paint("32", s);
|
|
@@ -3920,17 +4399,19 @@ function formatCodingResult(result, source) {
|
|
|
3920
4399
|
return lines.join("\n");
|
|
3921
4400
|
}
|
|
3922
4401
|
/**
|
|
3923
|
-
* Renderer for
|
|
3924
|
-
*
|
|
3925
|
-
* the
|
|
4402
|
+
* Renderer for an eval check (`--kind eval`). An eval describes an activity it does not
|
|
4403
|
+
* contain, so the summary names the resolved target and the size of the run the file
|
|
4404
|
+
* would produce — in the units of its own kind.
|
|
3926
4405
|
*/
|
|
3927
4406
|
function formatEvalResult(result, source) {
|
|
3928
4407
|
if (!result.ok) return renderFailureAndWarnings(result, "eval", source);
|
|
3929
4408
|
const lines = [green(`✔ Valid eval`) + dim(` — ${source}`)];
|
|
3930
4409
|
lines.push(` id: ${result.evalFile.id}`);
|
|
4410
|
+
lines.push(` kind: ${result.kind}`);
|
|
3931
4411
|
lines.push(` target: ${result.targetUrl}`);
|
|
3932
|
-
lines.push(`
|
|
3933
|
-
lines.push(`
|
|
4412
|
+
if (result.kind === "tutor") lines.push(` conversations: ${result.caseCount}`);
|
|
4413
|
+
else lines.push(` questions: ${result.evalFile.questions.length} cases: ${result.caseCount}`);
|
|
4414
|
+
lines.push(` ${result.kind} model: ${llmSpecText(result.llm)}`);
|
|
3934
4415
|
if (result.warnings.length) {
|
|
3935
4416
|
lines.push("");
|
|
3936
4417
|
lines.push(yellow(`${result.warnings.length} warning(s):`));
|
|
@@ -3959,20 +4440,39 @@ function formatUsageLine(usage) {
|
|
|
3959
4440
|
const cached = usage.cachedInput ? ` (${formatTokenCount(usage.cachedInput)} cached)` : "";
|
|
3960
4441
|
return `tokens: ${formatTokenCount(usage.input)} in${cached} / ${formatTokenCount(usage.output)} out`;
|
|
3961
4442
|
}
|
|
3962
|
-
/**
|
|
4443
|
+
/** The first error message a case's repeats recorded, for a one-line mismatch row. */
|
|
4444
|
+
function firstErrorMessage(repeats, fallback) {
|
|
4445
|
+
const first = repeats.find((row) => row.error !== void 0)?.error;
|
|
4446
|
+
return typeof first === "object" && first !== null && "message" in first ? String(first.message) : fallback;
|
|
4447
|
+
}
|
|
4448
|
+
/**
|
|
4449
|
+
* One line per non-passing case: `question#index expected … got … "answer…"` for a quiz,
|
|
4450
|
+
* `#n title — error …` for a tutor conversation (which has no verdict to compare).
|
|
4451
|
+
*/
|
|
3963
4452
|
function mismatchLines(result) {
|
|
3964
4453
|
return result.mismatches.map((c) => {
|
|
4454
|
+
if (isTutorCase(c)) {
|
|
4455
|
+
const head = `#${c.index + 1}${c.title ? ` ${c.title}` : ""}`;
|
|
4456
|
+
return ` ${red("✗")} ${head} ${red("error")} ${dim(firstErrorMessage(c.repeats, "no response"))}`;
|
|
4457
|
+
}
|
|
3965
4458
|
const head = `${c.questionId}#${c.answerIndex}`;
|
|
3966
4459
|
const expected = c.expected.join("|");
|
|
3967
4460
|
if (c.status === "errored") {
|
|
3968
|
-
const
|
|
3969
|
-
const message = typeof first === "object" && first !== null && "message" in first ? String(first.message) : "no verdict";
|
|
4461
|
+
const message = firstErrorMessage(c.repeats, "no verdict");
|
|
3970
4462
|
return ` ${red("✗")} ${head} expected ${expected} got ${red("error")} ${dim(message)}`;
|
|
3971
4463
|
}
|
|
3972
4464
|
return ` ${red("✗")} ${head} expected ${expected} got ${red(c.verdict ?? "?")}` + dim(` "${snippet(c.answer)}"`);
|
|
3973
4465
|
});
|
|
3974
4466
|
}
|
|
3975
4467
|
/**
|
|
4468
|
+
* One llm spec as the reports name it: `SCCH / gemma-4`, with ` (reasoning: high)`
|
|
4469
|
+
* appended whenever an effort level applies — two runs of one model at different efforts
|
|
4470
|
+
* behave differently, so the level belongs in the header.
|
|
4471
|
+
*/
|
|
4472
|
+
function llmSpecText(spec) {
|
|
4473
|
+
return `${spec.provider} / ${spec.model}${spec.reasoning ? ` (reasoning: ${spec.reasoning})` : ""}`;
|
|
4474
|
+
}
|
|
4475
|
+
/**
|
|
3976
4476
|
* The human report for ONE eval run: header (id, target, the EFFECTIVE llm — rendered
|
|
3977
4477
|
* as `quiz-llm → override-llm` when `--llm-provider`/`--llm-model` was used, so a
|
|
3978
4478
|
* comparison report can never be mistaken for a baseline one), one line per
|
|
@@ -3985,11 +4485,13 @@ function formatEvalReport(result, source) {
|
|
|
3985
4485
|
` id: ${result.id}`,
|
|
3986
4486
|
` target: ${result.target}`
|
|
3987
4487
|
];
|
|
3988
|
-
const llm = result.llm.overrides ? `${result.llm.overrides
|
|
4488
|
+
const llm = result.llm.overrides ? `${llmSpecText(result.llm.overrides)} ${yellow("→")} ${llmSpecText(result.llm)} ${yellow("(override)")}` : llmSpecText(result.llm);
|
|
3989
4489
|
lines.push(` llm: ${llm}`);
|
|
3990
4490
|
const judge = result.llm.judge;
|
|
3991
|
-
if (judge && (judge.provider !== result.llm.provider || judge.model !== result.llm.model)) lines.push(` judge llm: ${judge
|
|
3992
|
-
|
|
4491
|
+
if (judge && (judge.provider !== result.llm.provider || judge.model !== result.llm.model || judge.reasoning !== result.llm.reasoning)) lines.push(` judge llm: ${llmSpecText(judge)}${judge.overridden ? ` ${yellow("(override)")}` : ""}`);
|
|
4492
|
+
const unit = result.kind === "tutor" ? "conversation" : "case";
|
|
4493
|
+
const generation = result.kind === "tutor" ? "generation" : "grading";
|
|
4494
|
+
lines.push(` ${unit}s: ${totals.cases} × ${totals.repeats} repeat(s) = ${totals.calls} ${generation} call(s)` + (result.judging === "off" ? "" : ` + ${totals.calls} judge call(s)`));
|
|
3993
4495
|
if (result.aborted) {
|
|
3994
4496
|
lines.push("");
|
|
3995
4497
|
lines.push(red(`Run aborted: ${result.aborted.message}`));
|
|
@@ -4004,7 +4506,7 @@ function formatEvalReport(result, source) {
|
|
|
4004
4506
|
lines.push(...mismatchLines(result));
|
|
4005
4507
|
}
|
|
4006
4508
|
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(`
|
|
4509
|
+
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
4510
|
const tokens = formatUsageLine(totals.usage);
|
|
4009
4511
|
if (tokens) lines.push(dim(` ${tokens}`));
|
|
4010
4512
|
if (result.confusion.length) {
|
|
@@ -4012,9 +4514,11 @@ function formatEvalReport(result, source) {
|
|
|
4012
4514
|
lines.push(" confusion (expected → got):");
|
|
4013
4515
|
for (const row of result.confusion) lines.push(` ${row.expected} → ${row.got}: ${row.count}`);
|
|
4014
4516
|
}
|
|
4015
|
-
|
|
4016
|
-
|
|
4017
|
-
|
|
4517
|
+
if (result.kind !== "tutor") {
|
|
4518
|
+
const { count, denominator, rate } = result.falseCorrect;
|
|
4519
|
+
lines.push("");
|
|
4520
|
+
lines.push(` false-correct: ${count}/${denominator}` + (denominator ? ` (${(rate * 100).toFixed(1)}%)` : ""));
|
|
4521
|
+
}
|
|
4018
4522
|
return lines.join("\n");
|
|
4019
4523
|
}
|
|
4020
4524
|
/**
|
|
@@ -4034,12 +4538,14 @@ function formatEvalBatchReport(batch) {
|
|
|
4034
4538
|
}
|
|
4035
4539
|
const t = file.result.totals;
|
|
4036
4540
|
const mark = t.failed === 0 && t.errored === 0 && t.skipped === 0 ? green("✔") : red("✗");
|
|
4037
|
-
|
|
4541
|
+
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`;
|
|
4542
|
+
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
4543
|
}
|
|
4039
4544
|
const g = batch.totals;
|
|
4040
4545
|
const judged = batch.files.some((file) => file.result && anyJudged(file.result));
|
|
4546
|
+
const toolChecked = batch.files.some((file) => file.result && anyToolsRequired(file.result));
|
|
4041
4547
|
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)`) : ""));
|
|
4548
|
+
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
4549
|
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
4550
|
const tokens = formatUsageLine(g.usage);
|
|
4045
4551
|
if (tokens) lines.push(dim(` ${tokens}`));
|
|
@@ -4074,7 +4580,7 @@ function shortSource$1(source) {
|
|
|
4074
4580
|
function formatPromptDump(dump, sections, source) {
|
|
4075
4581
|
const lines = [green(`✔ Prompts — ${dump.kind}`) + dim(` — ${source}`)];
|
|
4076
4582
|
lines.push(` id: ${dump.id}`);
|
|
4077
|
-
lines.push(` provider: ${dump.llm.provider} model: ${dump.llm.model}`);
|
|
4583
|
+
lines.push(` provider: ${dump.llm.provider} model: ${dump.llm.model}` + (dump.llm.reasoning ? ` reasoning: ${dump.llm.reasoning}` : ""));
|
|
4078
4584
|
if (dump.kind === "tutor" && dump.tools.length > 0) lines.push(` tools: ${dump.tools.join(", ")}`);
|
|
4079
4585
|
lines.push(` prompts: ${sections.length}`);
|
|
4080
4586
|
for (const section of sections) lines.push(` ${section.name}: ${section.text.length} chars`);
|
|
@@ -4110,10 +4616,18 @@ function inline(text) {
|
|
|
4110
4616
|
function quote(text) {
|
|
4111
4617
|
return text.replace(/\s+$/, "").split(/\r?\n/).map((line) => line ? `> ${line}` : ">").join("\n");
|
|
4112
4618
|
}
|
|
4619
|
+
/**
|
|
4620
|
+
* One spec: `SCCH / gemma-4`, and `Azure Foundry / gpt-5.6-terra (reasoning: high)` when
|
|
4621
|
+
* an effort level applies. The level is part of a run's identity — two runs of one model
|
|
4622
|
+
* at different efforts produce different behavior — so it must be readable off the report.
|
|
4623
|
+
*/
|
|
4624
|
+
function specText(spec) {
|
|
4625
|
+
return `${spec.provider} / ${spec.model}${spec.reasoning ? ` (reasoning: ${spec.reasoning})` : ""}`;
|
|
4626
|
+
}
|
|
4113
4627
|
/** `SCCH / gemma-4`, or `SCCH / gemma-4 → Azure Foundry / gpt-5-mini (override)`. */
|
|
4114
4628
|
function llmText(llm) {
|
|
4115
|
-
const effective =
|
|
4116
|
-
return llm.overrides ? `${llm.overrides
|
|
4629
|
+
const effective = specText(llm);
|
|
4630
|
+
return llm.overrides ? `${specText(llm.overrides)} → ${effective} (override)` : effective;
|
|
4117
4631
|
}
|
|
4118
4632
|
/**
|
|
4119
4633
|
* The judge's pair, but ONLY when it differs from the grading pair — a judge line that
|
|
@@ -4123,8 +4637,8 @@ function llmText(llm) {
|
|
|
4123
4637
|
function judgeLlmText(llm) {
|
|
4124
4638
|
const judge = llm.judge;
|
|
4125
4639
|
if (!judge) return void 0;
|
|
4126
|
-
if (judge.provider === llm.provider && judge.model === llm.model) return
|
|
4127
|
-
return `${judge
|
|
4640
|
+
if (judge.provider === llm.provider && judge.model === llm.model && judge.reasoning === llm.reasoning) return;
|
|
4641
|
+
return `${specText(judge)}${judge.overridden ? " (override)" : ""}`;
|
|
4128
4642
|
}
|
|
4129
4643
|
/** `15,420 / 12,300 / 2,810`, or an em dash when nothing was reported. */
|
|
4130
4644
|
function usageCell(usage) {
|
|
@@ -4196,17 +4710,18 @@ function overview(batch) {
|
|
|
4196
4710
|
continue;
|
|
4197
4711
|
}
|
|
4198
4712
|
const t = file.result.totals;
|
|
4713
|
+
const tutor = file.result.kind === "tutor";
|
|
4199
4714
|
lines.push(row([
|
|
4200
4715
|
`${file.passed ? "✅" : "❌"} ${name}`,
|
|
4201
4716
|
`\`${cell(file.result.id)}\``,
|
|
4202
4717
|
count(t.cases),
|
|
4203
|
-
count(t.passed),
|
|
4204
|
-
count(t.failed),
|
|
4718
|
+
tutor ? "—" : count(t.passed),
|
|
4719
|
+
tutor ? "—" : count(t.failed),
|
|
4205
4720
|
count(t.errored),
|
|
4206
4721
|
count(t.skipped),
|
|
4207
|
-
count(t.unstable),
|
|
4722
|
+
tutor ? "—" : count(t.unstable),
|
|
4208
4723
|
anyJudged(file.result) ? count(t.feedbackFlagged) : "—",
|
|
4209
|
-
falseCorrectCell(file.result),
|
|
4724
|
+
tutor ? "—" : falseCorrectCell(file.result),
|
|
4210
4725
|
usageCell(t.usage)
|
|
4211
4726
|
]));
|
|
4212
4727
|
}
|
|
@@ -4300,7 +4815,7 @@ function caseSection(evalCase, questionText) {
|
|
|
4300
4815
|
* the run failed on them. Empty when the file has no flags.
|
|
4301
4816
|
*/
|
|
4302
4817
|
function flaggedSection(result, questionText) {
|
|
4303
|
-
const flagged = result.cases.filter((evalCase) => evalCase.feedbackFlagged);
|
|
4818
|
+
const flagged = result.cases.filter((evalCase) => evalCase.feedbackFlagged && !isTutorCase(evalCase));
|
|
4304
4819
|
if (flagged.length === 0) return [];
|
|
4305
4820
|
const lines = ["### Flagged feedback", ""];
|
|
4306
4821
|
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 +4837,104 @@ function flaggedSection(result, questionText) {
|
|
|
4322
4837
|
}
|
|
4323
4838
|
return lines;
|
|
4324
4839
|
}
|
|
4840
|
+
/**
|
|
4841
|
+
* A tutor case's stable heading: the teacher's `title` when it has one, otherwise its
|
|
4842
|
+
* 1-based index plus an excerpt of the FIRST student line — enough to recognise the case
|
|
4843
|
+
* in a report without opening the eval file.
|
|
4844
|
+
*/
|
|
4845
|
+
function tutorCaseLabel(evalCase) {
|
|
4846
|
+
if (evalCase.title) return `#${evalCase.index + 1} ${cell(evalCase.title)}`;
|
|
4847
|
+
const firstStudent = evalCase.conversation.find((turn) => "student" in turn);
|
|
4848
|
+
const excerpt = firstStudent && "student" in firstStudent ? inline(firstStudent.student) : "";
|
|
4849
|
+
const short = excerpt.length > 60 ? `${excerpt.slice(0, 59)}…` : excerpt;
|
|
4850
|
+
return short ? `#${evalCase.index + 1} — ${short}` : `#${evalCase.index + 1}`;
|
|
4851
|
+
}
|
|
4852
|
+
/** The scripted conversation as one labeled, verbatim blockquote per turn. */
|
|
4853
|
+
function conversationBlock(evalCase) {
|
|
4854
|
+
const lines = ["**Conversation**", ""];
|
|
4855
|
+
for (const turn of evalCase.conversation) {
|
|
4856
|
+
const role = "student" in turn ? "student" : "tutor";
|
|
4857
|
+
const text = "student" in turn ? turn.student : turn.tutor;
|
|
4858
|
+
lines.push(`*${role}*`, "", quote(text), "");
|
|
4859
|
+
}
|
|
4860
|
+
return lines;
|
|
4861
|
+
}
|
|
4862
|
+
/** One ERRORED tutor case: what was asked, and why nothing came back. */
|
|
4863
|
+
function tutorErrorSection(evalCase) {
|
|
4864
|
+
const lines = [`### ${tutorCaseLabel(evalCase)} — error`, ""];
|
|
4865
|
+
lines.push(...conversationBlock(evalCase));
|
|
4866
|
+
const failure = evalCase.repeats.find((r) => r.error !== void 0);
|
|
4867
|
+
if (failure) lines.push("**Error**", "", quote(errorMessage(failure.error)), "");
|
|
4868
|
+
return lines;
|
|
4869
|
+
}
|
|
4870
|
+
/** `random_number, random_number` — a repeat's tool calls, or `(none)` when it made none. */
|
|
4871
|
+
function toolCallList(toolCalls) {
|
|
4872
|
+
return toolCalls && toolCalls.length > 0 ? toolCalls.map((name) => `\`${name}\``).join(", ") : "(none)";
|
|
4873
|
+
}
|
|
4874
|
+
/**
|
|
4875
|
+
* The tutor kind's "Missing tool calls" section: every case that declares `required_tools`
|
|
4876
|
+
* and had at least one repeat skip one. Per case the required list, what each offending
|
|
4877
|
+
* repeat actually called, and which tools were missing there.
|
|
4878
|
+
*
|
|
4879
|
+
* Its own section rather than a column, for the same reason "Flagged responses" is one:
|
|
4880
|
+
* these cases are `ok` and the run PASSED — a missing tool call is a note about the
|
|
4881
|
+
* tutor's behavior, never a failure. Cases whose tools all ran stay out entirely; the
|
|
4882
|
+
* `--json` report carries every repeat's `toolCalls` for anyone who wants them.
|
|
4883
|
+
*/
|
|
4884
|
+
function tutorMissingToolsSection(result) {
|
|
4885
|
+
const flagged = result.cases.filter((evalCase) => isTutorCase(evalCase) && evalCase.toolsFlagged);
|
|
4886
|
+
if (flagged.length === 0) return [];
|
|
4887
|
+
const lines = ["### Missing tool calls", ""];
|
|
4888
|
+
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._");
|
|
4889
|
+
lines.push("");
|
|
4890
|
+
for (const evalCase of flagged) {
|
|
4891
|
+
lines.push(`#### ${tutorCaseLabel(evalCase)}`);
|
|
4892
|
+
lines.push("");
|
|
4893
|
+
lines.push(`**Required** ${toolCallList(evalCase.requiredTools)}`);
|
|
4894
|
+
lines.push("");
|
|
4895
|
+
for (const repeat of evalCase.repeats) {
|
|
4896
|
+
const missing = repeat.missingTools ?? [];
|
|
4897
|
+
if (missing.length === 0) continue;
|
|
4898
|
+
lines.push(`- Repeat #${repeat.repeatIndex + 1} — missing ${toolCallList(missing)}; called ${toolCallList(repeat.toolCalls)}`);
|
|
4899
|
+
}
|
|
4900
|
+
lines.push("");
|
|
4901
|
+
}
|
|
4902
|
+
return lines;
|
|
4903
|
+
}
|
|
4904
|
+
/**
|
|
4905
|
+
* The tutor kind's "Flagged responses" section — the report's actual deliverable: per
|
|
4906
|
+
* flagged case the scripted conversation, the teacher's expectations when it states any,
|
|
4907
|
+
* and each flagged repeat's GENERATED RESPONSE verbatim followed by the judge's issues.
|
|
4908
|
+
*
|
|
4909
|
+
* Clean cases stay out entirely (their generated texts are in the `--json` report), and
|
|
4910
|
+
* a flag never means the run failed — the tutor kind is report-only.
|
|
4911
|
+
*/
|
|
4912
|
+
function tutorFlaggedSection(result) {
|
|
4913
|
+
const flagged = result.cases.filter((evalCase) => isTutorCase(evalCase) && evalCase.feedbackFlagged);
|
|
4914
|
+
if (flagged.length === 0) return [];
|
|
4915
|
+
const lines = ["### Flagged responses", ""];
|
|
4916
|
+
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._");
|
|
4917
|
+
lines.push("");
|
|
4918
|
+
for (const evalCase of flagged) {
|
|
4919
|
+
lines.push(`#### ${tutorCaseLabel(evalCase)}`);
|
|
4920
|
+
lines.push("");
|
|
4921
|
+
lines.push(...conversationBlock(evalCase));
|
|
4922
|
+
if (evalCase.gradingInstructions) lines.push("**Expectations for this case**", "", quote(evalCase.gradingInstructions), "");
|
|
4923
|
+
if (evalCase.requiredTools) lines.push(`**Required tools** ${toolCallList(evalCase.requiredTools)}`, "");
|
|
4924
|
+
for (const repeat of evalCase.repeats) {
|
|
4925
|
+
const issues = repeat.judge?.issues ?? [];
|
|
4926
|
+
if (issues.length === 0) continue;
|
|
4927
|
+
lines.push(`**Generated response — repeat #${repeat.repeatIndex + 1}**`);
|
|
4928
|
+
lines.push("");
|
|
4929
|
+
lines.push(quote(repeat.text ?? ""));
|
|
4930
|
+
lines.push("");
|
|
4931
|
+
if (repeat.toolCalls && (repeat.toolCalls.length > 0 || evalCase.requiredTools)) lines.push(`*tool calls: ${toolCallList(repeat.toolCalls)}*`, "");
|
|
4932
|
+
for (const issue of issues) lines.push(`- \`${cell(issue.criterion)}\` — ${inline(issue.note)}`);
|
|
4933
|
+
lines.push("");
|
|
4934
|
+
}
|
|
4935
|
+
}
|
|
4936
|
+
return lines;
|
|
4937
|
+
}
|
|
4325
4938
|
/** One file's details section, or `[]` when the file has nothing to report. */
|
|
4326
4939
|
function fileDetails(file) {
|
|
4327
4940
|
const name = shortSource(file.source);
|
|
@@ -4330,30 +4943,33 @@ function fileDetails(file) {
|
|
|
4330
4943
|
return [
|
|
4331
4944
|
`## ${cell(name)} — invalid`,
|
|
4332
4945
|
"",
|
|
4333
|
-
"This file was not
|
|
4946
|
+
"This file was not run; fix the problems below and run it again.",
|
|
4334
4947
|
"",
|
|
4335
4948
|
...errors.map((issue) => `- \`${cell(issue.code)}\` — ${inline(issue.message)}`),
|
|
4336
4949
|
""
|
|
4337
4950
|
];
|
|
4338
4951
|
}
|
|
4339
4952
|
const result = file.result;
|
|
4953
|
+
const tutor = result.kind === "tutor";
|
|
4340
4954
|
const detailed = result.cases.filter(needsDetail);
|
|
4341
4955
|
const skipped = result.totals.skipped;
|
|
4342
4956
|
const questionText = new Map(result.questions.map((question) => [question.id, question.text]));
|
|
4343
|
-
const flagged = flaggedSection(result, questionText);
|
|
4344
|
-
|
|
4957
|
+
const flagged = tutor ? tutorFlaggedSection(result) : flaggedSection(result, questionText);
|
|
4958
|
+
const missingTools = tutor ? tutorMissingToolsSection(result) : [];
|
|
4959
|
+
if (detailed.length === 0 && skipped === 0 && !result.aborted && flagged.length === 0 && missingTools.length === 0) return [];
|
|
4345
4960
|
const lines = [`## ${cell(name)} — \`${cell(result.id)}\``, ""];
|
|
4346
4961
|
if (result.aborted) {
|
|
4347
4962
|
lines.push("> [!WARNING]");
|
|
4348
4963
|
lines.push(`> The run was aborted: ${inline(result.aborted.message)}`);
|
|
4349
4964
|
lines.push("");
|
|
4350
4965
|
}
|
|
4351
|
-
for (const evalCase of detailed) lines.push(...caseSection(evalCase, questionText.get(evalCase.questionId)));
|
|
4966
|
+
for (const evalCase of detailed) lines.push(...isTutorCase(evalCase) ? tutorErrorSection(evalCase) : caseSection(evalCase, questionText.get(evalCase.questionId)));
|
|
4352
4967
|
if (skipped > 0) {
|
|
4353
4968
|
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.`);
|
|
4969
|
+
lines.push(`**${count(skipped)} ${tutor ? "conversation" : "case"}(s) were never attempted**${reason} — the run is incomplete, so it cannot pass.`);
|
|
4355
4970
|
lines.push("");
|
|
4356
4971
|
}
|
|
4972
|
+
lines.push(...missingTools);
|
|
4357
4973
|
lines.push(...flagged);
|
|
4358
4974
|
return lines;
|
|
4359
4975
|
}
|
|
@@ -4371,8 +4987,9 @@ function renderEvalMarkdownReport(batch, meta) {
|
|
|
4371
4987
|
const judges = [...new Set(batch.files.map((file) => file.result ? judgeLlmText(file.result.llm) : void 0).filter((text) => text !== void 0))];
|
|
4372
4988
|
for (const judge of judges) lines.push(`- **Feedback judge** ${judge}`);
|
|
4373
4989
|
lines.push(`- **Run** ${count(batch.totals.files)} file(s), ${count(batch.totals.cases)} case(s) × ${count(meta.repeats)} repeat(s), concurrency ${count(meta.concurrency)}`);
|
|
4990
|
+
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
4991
|
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
|
|
4992
|
+
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
4993
|
lines.push("");
|
|
4377
4994
|
if (batch.files.filter((file) => file.result?.aborted).length > 0) {
|
|
4378
4995
|
lines.push("> [!WARNING]");
|
|
@@ -4391,10 +5008,10 @@ function renderEvalMarkdownReport(batch, meta) {
|
|
|
4391
5008
|
lines.push("");
|
|
4392
5009
|
const details = batch.files.flatMap((file) => fileDetails(file));
|
|
4393
5010
|
if (details.length === 0) {
|
|
4394
|
-
lines.push("_Nothing else to report
|
|
5011
|
+
lines.push("_Nothing else to report. The `--json` report carries every case, including the clean ones._");
|
|
4395
5012
|
lines.push("");
|
|
4396
5013
|
} else {
|
|
4397
|
-
lines.push("_Below: only the mismatched, errored and unstable cases, plus
|
|
5014
|
+
lines.push("_Below: only the mismatched, errored and unstable cases, plus anything the judge flagged. Clean cases live in the `--json` report._");
|
|
4398
5015
|
lines.push("");
|
|
4399
5016
|
lines.push(...details);
|
|
4400
5017
|
}
|
|
@@ -4422,7 +5039,8 @@ const CodingYamlSchema = z.strictObject({
|
|
|
4422
5039
|
title: z.string().optional().meta({ description: "Optional label shown to the student on the /<code> connection page." }),
|
|
4423
5040
|
llm: z.strictObject({
|
|
4424
5041
|
model: z.string().min(1).meta({ description: "The model that answers. SERVER-ONLY and PINNED: the proxy always uses this model and ignores whatever model the coding agent sends." }),
|
|
4425
|
-
provider: providerSchema
|
|
5042
|
+
provider: providerSchema,
|
|
5043
|
+
reasoning: reasoningLevelSchema
|
|
4426
5044
|
}).meta({
|
|
4427
5045
|
id: "llm",
|
|
4428
5046
|
description: "The pinned model and provider that answer coding requests."
|
|
@@ -4499,7 +5117,8 @@ const WritingYamlSchema = z.strictObject({
|
|
|
4499
5117
|
}),
|
|
4500
5118
|
llm: z.strictObject({
|
|
4501
5119
|
model: z.string().min(1).meta({ description: "The model that drives the feedback chat." }),
|
|
4502
|
-
provider: providerSchema
|
|
5120
|
+
provider: providerSchema,
|
|
5121
|
+
reasoning: reasoningLevelSchema
|
|
4503
5122
|
}).meta({
|
|
4504
5123
|
id: "llm",
|
|
4505
5124
|
description: "The model and provider that back the writing coach."
|
|
@@ -4754,6 +5373,26 @@ function parsePair(flag, provider, model) {
|
|
|
4754
5373
|
};
|
|
4755
5374
|
}
|
|
4756
5375
|
/**
|
|
5376
|
+
* One reasoning-effort flag: absent, or one of the four known levels. Checked here rather
|
|
5377
|
+
* than left to the server for the same reason `parsePair` checks the provider — a typo
|
|
5378
|
+
* must cost nothing, not a whole run's worth of terminal 400s.
|
|
5379
|
+
*
|
|
5380
|
+
* Deliberately INDEPENDENT of its pair flag (unlike the pair's both-or-nothing rule): the
|
|
5381
|
+
* common comparison run is "same model, different effort", so `--llm-reasoning` alone is
|
|
5382
|
+
* a first-class invocation rather than a usage error.
|
|
5383
|
+
*/
|
|
5384
|
+
function parseReasoning(flag, value) {
|
|
5385
|
+
if (value === void 0) return { ok: true };
|
|
5386
|
+
if (!REASONING_LEVELS.includes(value)) return {
|
|
5387
|
+
ok: false,
|
|
5388
|
+
message: `Unknown --${flag} "${value}": expected ${REASONING_LEVELS.map((level) => `"${level}"`).join(", ")}.`
|
|
5389
|
+
};
|
|
5390
|
+
return {
|
|
5391
|
+
ok: true,
|
|
5392
|
+
reasoning: value
|
|
5393
|
+
};
|
|
5394
|
+
}
|
|
5395
|
+
/**
|
|
4757
5396
|
* The optional `usage: { input, cachedInput, output }` of a 200 response, defensively:
|
|
4758
5397
|
* anything that is not three finite numbers is simply absent (an older server, or one
|
|
4759
5398
|
* whose provider reports nothing, must never break a run).
|
|
@@ -4820,6 +5459,61 @@ function makeGradeFn(server, llm) {
|
|
|
4820
5459
|
};
|
|
4821
5460
|
}
|
|
4822
5461
|
/**
|
|
5462
|
+
* The `toolCalls: string[]` of a tutor 200, defensively: `undefined` when the field is
|
|
5463
|
+
* absent (a server too old to report tool calls — a distinction the runner MUST be able to
|
|
5464
|
+
* make), and non-string entries are dropped rather than breaking a run. Names only, in the
|
|
5465
|
+
* order the server sent them, duplicates kept.
|
|
5466
|
+
*/
|
|
5467
|
+
function parseToolCalls(value) {
|
|
5468
|
+
if (!Array.isArray(value)) return void 0;
|
|
5469
|
+
return value.filter((name) => typeof name === "string" && name !== "");
|
|
5470
|
+
}
|
|
5471
|
+
/**
|
|
5472
|
+
* The HTTP seam for ONE generated tutor turn, with the run's effective llm closed in —
|
|
5473
|
+
* the tutor kind's sibling of {@link makeGradeFn}, sharing its failure classification
|
|
5474
|
+
* exactly (5xx and network retryable, auth aborts the run, every other 4xx terminal).
|
|
5475
|
+
*/
|
|
5476
|
+
function makeRespondFn(server, llm) {
|
|
5477
|
+
return async ({ system, tools, messages }) => {
|
|
5478
|
+
const response = await performApiRequest({
|
|
5479
|
+
server,
|
|
5480
|
+
path: "/api/eval/respond",
|
|
5481
|
+
method: "POST",
|
|
5482
|
+
body: {
|
|
5483
|
+
llm,
|
|
5484
|
+
system,
|
|
5485
|
+
tools: [...tools],
|
|
5486
|
+
messages: messages.map((m) => ({ ...m }))
|
|
5487
|
+
},
|
|
5488
|
+
quiet: true
|
|
5489
|
+
});
|
|
5490
|
+
if (response.ok) {
|
|
5491
|
+
const payload = response.payload;
|
|
5492
|
+
if (typeof payload?.text === "string" && payload.text !== "") {
|
|
5493
|
+
const usage = parseUsage(payload?.usage);
|
|
5494
|
+
const toolCalls = parseToolCalls(payload?.toolCalls);
|
|
5495
|
+
return {
|
|
5496
|
+
ok: true,
|
|
5497
|
+
text: payload.text,
|
|
5498
|
+
...toolCalls ? { toolCalls } : {},
|
|
5499
|
+
...usage ? { usage } : {}
|
|
5500
|
+
};
|
|
5501
|
+
}
|
|
5502
|
+
return {
|
|
5503
|
+
ok: false,
|
|
5504
|
+
retryable: false,
|
|
5505
|
+
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." }
|
|
5506
|
+
};
|
|
5507
|
+
}
|
|
5508
|
+
return {
|
|
5509
|
+
ok: false,
|
|
5510
|
+
retryable: response.status === void 0 || response.status >= 500,
|
|
5511
|
+
...response.authFailed ? { auth: true } : {},
|
|
5512
|
+
error: response.error
|
|
5513
|
+
};
|
|
5514
|
+
};
|
|
5515
|
+
}
|
|
5516
|
+
/**
|
|
4823
5517
|
* The HTTP seam for ONE judge call, with the run's judge llm closed in. Mirrors
|
|
4824
5518
|
* {@link makeGradeFn}'s failure classification, minus the auth branch: a judge failure
|
|
4825
5519
|
* NEVER aborts the run — it degrades judging (see the runner's breaker) while the grading
|
|
@@ -4934,7 +5628,8 @@ function progressWriter(prefix) {
|
|
|
4934
5628
|
*/
|
|
4935
5629
|
function writeFileDone(label, result) {
|
|
4936
5630
|
const totals = result.totals;
|
|
4937
|
-
|
|
5631
|
+
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`;
|
|
5632
|
+
process.stderr.write(`${label}: ${counts}` + (totals.skipped ? `, ${totals.skipped} skipped` : "") + (anyJudged(result) ? `, ${totals.feedbackFlagged} flagged` : "") + "\n");
|
|
4938
5633
|
}
|
|
4939
5634
|
/**
|
|
4940
5635
|
* The command's core, exported for the unit tests. `seams` exists only so tests can
|
|
@@ -4947,14 +5642,24 @@ async function runEvalCommand(pathsOrUrls, options, seams = {}) {
|
|
|
4947
5642
|
failJson({ message: override.message });
|
|
4948
5643
|
return;
|
|
4949
5644
|
}
|
|
5645
|
+
const overrideReasoning = parseReasoning("llm-reasoning", options.llmReasoning);
|
|
5646
|
+
if (!overrideReasoning.ok) {
|
|
5647
|
+
failJson({ message: overrideReasoning.message });
|
|
5648
|
+
return;
|
|
5649
|
+
}
|
|
4950
5650
|
const judgeOverride = parsePair("judge-llm", options.judgeLlmProvider, options.judgeLlmModel);
|
|
4951
5651
|
if (!judgeOverride.ok) {
|
|
4952
5652
|
failJson({ message: judgeOverride.message });
|
|
4953
5653
|
return;
|
|
4954
5654
|
}
|
|
5655
|
+
const judgeOverrideReasoning = parseReasoning("judge-llm-reasoning", options.judgeLlmReasoning);
|
|
5656
|
+
if (!judgeOverrideReasoning.ok) {
|
|
5657
|
+
failJson({ message: judgeOverrideReasoning.message });
|
|
5658
|
+
return;
|
|
5659
|
+
}
|
|
4955
5660
|
const judging = options.judgeFeedback !== false;
|
|
4956
|
-
if (!judging && judgeOverride.llm) {
|
|
4957
|
-
failJson({ message: "--judge-llm-provider/--judge-llm-model cannot be combined with --no-judge-feedback: the first
|
|
5661
|
+
if (!judging && (judgeOverride.llm || judgeOverrideReasoning.reasoning)) {
|
|
5662
|
+
failJson({ message: "--judge-llm-provider/--judge-llm-model/--judge-llm-reasoning cannot be combined with --no-judge-feedback: the first configure the feedback judge, the second switches it off." });
|
|
4958
5663
|
return;
|
|
4959
5664
|
}
|
|
4960
5665
|
const expansion = expandSources(pathsOrUrls);
|
|
@@ -4999,9 +5704,14 @@ async function runEvalCommand(pathsOrUrls, options, seams = {}) {
|
|
|
4999
5704
|
return;
|
|
5000
5705
|
}
|
|
5001
5706
|
{
|
|
5002
|
-
const
|
|
5003
|
-
|
|
5004
|
-
|
|
5707
|
+
const scope = (unit, generation, cases) => {
|
|
5708
|
+
if (cases === 0) return;
|
|
5709
|
+
const calls = cases * repeats;
|
|
5710
|
+
process.stderr.write(`${cases} ${unit}(s) × ${repeats} repeat(s) = ${calls} ${generation}` + (judging ? ` + ${calls} judge call(s)\n` : " call(s)\n"));
|
|
5711
|
+
};
|
|
5712
|
+
const casesOf = (kind) => [...checked.values()].filter((file) => file.kind === kind).reduce((sum, file) => sum + file.caseCount, 0);
|
|
5713
|
+
scope("case", "grading", casesOf("quiz"));
|
|
5714
|
+
scope("conversation", "generation", casesOf("tutor"));
|
|
5005
5715
|
}
|
|
5006
5716
|
await warnOnVersionMismatch(options.server);
|
|
5007
5717
|
const judgeBreaker = createJudgeBreaker();
|
|
@@ -5013,24 +5723,26 @@ async function runEvalCommand(pathsOrUrls, options, seams = {}) {
|
|
|
5013
5723
|
fileIndex += 1;
|
|
5014
5724
|
const check = checked.get(file.source);
|
|
5015
5725
|
if (!check) continue;
|
|
5016
|
-
const
|
|
5017
|
-
provider: check.
|
|
5018
|
-
model: check.
|
|
5726
|
+
const activityLlm = {
|
|
5727
|
+
provider: check.llm.provider,
|
|
5728
|
+
model: check.llm.model,
|
|
5729
|
+
...check.llm.reasoning ? { reasoning: check.llm.reasoning } : {}
|
|
5019
5730
|
};
|
|
5020
|
-
const effective = override.llm
|
|
5021
|
-
const judgeLlm = judgeOverride.llm
|
|
5731
|
+
const effective = resolveEvalSpec(activityLlm, override.llm, overrideReasoning.reasoning);
|
|
5732
|
+
const judgeLlm = resolveEvalSpec(effective, judgeOverride.llm, judgeOverrideReasoning.reasoning);
|
|
5022
5733
|
const llm = {
|
|
5023
5734
|
...effective,
|
|
5024
|
-
...
|
|
5735
|
+
...sameEvalSpec(effective, activityLlm) ? {} : { overrides: activityLlm },
|
|
5025
5736
|
...judging ? { judge: {
|
|
5026
5737
|
...judgeLlm,
|
|
5027
|
-
overridden: judgeOverride.llm !== void 0
|
|
5738
|
+
overridden: judgeOverride.llm !== void 0 || judgeOverrideReasoning.reasoning !== void 0
|
|
5028
5739
|
} } : {}
|
|
5029
5740
|
};
|
|
5030
5741
|
const label = files.length > 1 ? `(${fileIndex}/${files.length}) ${check.evalFile.id}` : check.evalFile.id;
|
|
5031
5742
|
const prefix = files.length > 1 ? `${label}: ` : "";
|
|
5032
|
-
const result = await runEval(
|
|
5743
|
+
const result = await runEval(check.kind, check, {
|
|
5033
5744
|
grade: makeGradeFn(options.server, effective),
|
|
5745
|
+
respond: makeRespondFn(options.server, effective),
|
|
5034
5746
|
...judging ? { judge: makeJudgeFn(options.server, judgeLlm) } : {},
|
|
5035
5747
|
judgeBreaker,
|
|
5036
5748
|
onJudgeDegraded,
|
|
@@ -5069,12 +5781,16 @@ async function runEvalCommand(pathsOrUrls, options, seams = {}) {
|
|
|
5069
5781
|
process.exitCode = batchPassed(batch) ? 0 : 1;
|
|
5070
5782
|
}
|
|
5071
5783
|
function registerEval(program) {
|
|
5072
|
-
program.command("eval").description("
|
|
5784
|
+
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("--llm-reasoning <level>", "run at this reasoning effort (\"minimal\", \"low\", \"medium\" or \"high\"); on its own it keeps the activity's model").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("--judge-llm-reasoning <level>", "judge at this reasoning effort (\"minimal\", \"low\", \"medium\" or \"high\"); on its own it keeps the judge's model").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
5785
|
Examples:
|
|
5074
5786
|
# Evaluate one quiz's golden answers
|
|
5075
5787
|
$ novedu-cli eval ./0010-welcome-quiz.eval.yaml
|
|
5076
5788
|
|
|
5077
|
-
#
|
|
5789
|
+
# Check how a tutor answers a set of scripted conversations
|
|
5790
|
+
$ novedu-cli eval ./loops-tutor.eval.yaml
|
|
5791
|
+
|
|
5792
|
+
# A whole course part — quiz and tutor evals may be mixed
|
|
5793
|
+
# (quote the pattern so the CLI expands it, ** included)
|
|
5078
5794
|
$ novedu-cli eval "./part-1/**/*.eval.yaml"
|
|
5079
5795
|
|
|
5080
5796
|
# Measure grader stability: 3 runs per answer, majority verdict
|
|
@@ -5083,6 +5799,9 @@ Examples:
|
|
|
5083
5799
|
# How would this rubric perform on another model? (both flags, always together)
|
|
5084
5800
|
$ novedu-cli eval ./my-quiz.eval.yaml --llm-provider "Azure Foundry" --llm-model gpt-5-mini
|
|
5085
5801
|
|
|
5802
|
+
# Same model, more thinking: the level alone keeps the activity's provider/model
|
|
5803
|
+
$ novedu-cli eval ./my-quiz.eval.yaml --llm-reasoning high
|
|
5804
|
+
|
|
5086
5805
|
# A strong judge over the quiz's own grader — the recommended pairing
|
|
5087
5806
|
$ novedu-cli eval ./my-quiz.eval.yaml --judge-llm-provider "Azure Foundry" --judge-llm-model gpt-5.6-terra
|
|
5088
5807
|
|