@novedu/cli 0.21.0 → 0.23.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +88 -13
- package/dist/main.js +1055 -84
- package/package.json +2 -2
package/dist/main.js
CHANGED
|
@@ -890,10 +890,19 @@ const QUIZ_VERDICT_SCHEMA = z.object({
|
|
|
890
890
|
feedback: z.string()
|
|
891
891
|
});
|
|
892
892
|
const QUIZ_VERDICT_ENUM = QUIZ_VERDICT_SCHEMA.shape.result;
|
|
893
|
+
/** The three verdict literals, in the canonical best→worst order. */
|
|
894
|
+
const QUIZ_VERDICT_VALUES = QUIZ_VERDICT_ENUM.options;
|
|
895
|
+
//#endregion
|
|
896
|
+
//#region ../lib/tutor-tools/names.ts
|
|
897
|
+
const TUTOR_TOOL_NAMES = ["random_number"];
|
|
898
|
+
const tutorToolNameSchema = z.enum(TUTOR_TOOL_NAMES).meta({
|
|
899
|
+
id: "tutorToolName",
|
|
900
|
+
description: "Name of a built-in tutor tool."
|
|
901
|
+
});
|
|
893
902
|
//#endregion
|
|
894
903
|
//#region ../lib/eval-schema.ts
|
|
895
904
|
/** The three verdicts in canonical order (best → worst); the sort key for expected sets. */
|
|
896
|
-
const EVAL_VERDICTS =
|
|
905
|
+
const EVAL_VERDICTS = QUIZ_VERDICT_VALUES;
|
|
897
906
|
/**
|
|
898
907
|
* Eval ids share the flat namespace of report headers and `--out` files, so they stay
|
|
899
908
|
* URL- and YAML-plain: an alphanumeric start, then alphanumerics, `.`, `-` or `_`.
|
|
@@ -911,13 +920,72 @@ const EvalQuestionSchema = z.strictObject({
|
|
|
911
920
|
question: z.string().min(1).meta({ description: "The question id in the target quiz. For a question imported through quiz_files this is the namespaced \"<alias>/<id>\" form." }),
|
|
912
921
|
answers: z.array(EvalAnswerSchema).min(1).meta({ description: "The golden answers for this question — at least one." })
|
|
913
922
|
});
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
923
|
+
const idSchema = z.string().regex(EVAL_ID_PATTERN).max(MAX_ID_LENGTH).meta({ description: "Stable identifier of this eval, shown in the run report. Letters, digits, dot, dash and underscore." });
|
|
924
|
+
/** The QUIZ arm: golden answers replayed through the real grader. */
|
|
925
|
+
const QuizEvalYamlSchema = z.strictObject({
|
|
926
|
+
kind: z.literal("quiz").optional().meta({ description: "The eval kind. Omit it (or write \"quiz\") for a golden-answer eval of a quiz rubric." }),
|
|
927
|
+
id: idSchema,
|
|
917
928
|
target: z.string().min(1).meta({ description: "The quiz YAML this eval grades against — a path relative to THIS file, or an absolute http(s) URL." }),
|
|
918
929
|
questions: z.array(EvalQuestionSchema).min(1).meta({ description: "The evaluated questions — at least one, each with its golden answers." })
|
|
919
930
|
});
|
|
920
931
|
/**
|
|
932
|
+
* ONE turn of a scripted conversation: a single-key map naming its speaker. The two
|
|
933
|
+
* TEACHER-facing role names (`student` / `tutor`) are deliberate — the wire roles
|
|
934
|
+
* (`user` / `assistant`) are an implementation detail nobody should have to author.
|
|
935
|
+
*/
|
|
936
|
+
const EvalConversationTurnSchema = z.union([z.strictObject({ student: z.string().min(1).meta({ description: "What the student says in this turn." }) }), z.strictObject({ tutor: z.string().min(1).meta({ description: "What the tutor already said in this turn — scripted by the teacher, not generated." }) })]);
|
|
937
|
+
/** The last turn a conversation may end on: the student message the model must answer. */
|
|
938
|
+
function endsWithStudentTurn(turns) {
|
|
939
|
+
const last = turns.at(-1);
|
|
940
|
+
return last !== void 0 && "student" in last;
|
|
941
|
+
}
|
|
942
|
+
/**
|
|
943
|
+
* The tool names a case may REQUIRE, derived from the catalog's own name list
|
|
944
|
+
* (`lib/tutor-tools/names.ts`) rather than a mirrored literal set — so a tool added to the
|
|
945
|
+
* catalog is immediately requirable and a typo fails `validate` offline with a named enum
|
|
946
|
+
* error, no run and no tokens spent.
|
|
947
|
+
*
|
|
948
|
+
* Non-empty and UNIQUE: an empty list would say nothing (write no `required_tools` at
|
|
949
|
+
* all), and a repeated name is always an authoring slip — the check is "called at least
|
|
950
|
+
* once", so naming a tool twice cannot mean anything a single mention does not.
|
|
951
|
+
*/
|
|
952
|
+
const requiredToolsSchema = z.array(z.enum(TUTOR_TOOL_NAMES)).min(1).refine((tools) => new Set(tools).size === tools.length, { message: "Each tool may be listed only once." }).meta({ description: "Optional list of built-in tool names the tutor must call AT LEAST ONCE while answering this case (e.g. [random_number]). Reported only — a missing tool call never fails the run. Tools beyond this list are always fine, and every name must be one the target tutor's own `tools:` grant contains." });
|
|
953
|
+
/** ONE tutor case: a scripted conversation plus the teacher's optional expectations. */
|
|
954
|
+
const EvalConversationSchema = z.strictObject({
|
|
955
|
+
title: z.string().min(1).max(MAX_ID_LENGTH).optional().meta({ description: "Optional short label for this case, used as its stable heading in the run report." }),
|
|
956
|
+
required_tools: requiredToolsSchema.optional(),
|
|
957
|
+
grading_instructions: z.string().min(1).optional().meta({ description: "Optional extra expectations for THIS case, judged alongside the tutor's own system prompt (e.g. \"the response must not contain a complete working loop\")." }),
|
|
958
|
+
conversation: z.array(EvalConversationTurnSchema).min(1).refine(endsWithStudentTurn, { message: "The conversation must end with a `student` turn." }).meta({ description: "The scripted exchange, in order: `student:` and `tutor:` turns. It must END with a `student:` turn — that is the message the model under test answers." })
|
|
959
|
+
});
|
|
960
|
+
/** The TUTOR arm: conversations whose next tutor turn is generated and judged. */
|
|
961
|
+
const TutorEvalYamlSchema = z.strictObject({
|
|
962
|
+
kind: z.literal("tutor").meta({ description: "The eval kind. \"tutor\" evaluates a tutor's next response in a conversation." }),
|
|
963
|
+
id: idSchema,
|
|
964
|
+
target: z.string().min(1).meta({ description: "The tutor YAML this eval runs against — a path relative to THIS file, or an absolute http(s) URL." }),
|
|
965
|
+
conversations: z.array(EvalConversationSchema).min(1).meta({ description: "The evaluated conversations — at least one; each one is a case." })
|
|
966
|
+
});
|
|
967
|
+
/**
|
|
968
|
+
* The whole eval file: quiz (the default, `kind` omissible) or tutor.
|
|
969
|
+
*
|
|
970
|
+
* A discriminated union rather than a loose one, so a `kind: tutor` file with a typo in
|
|
971
|
+
* `conversations` reports THAT problem instead of "no union member matched".
|
|
972
|
+
*/
|
|
973
|
+
const EvalYamlSchema = z.discriminatedUnion("kind", [QuizEvalYamlSchema, TutorEvalYamlSchema]);
|
|
974
|
+
/** The eval file's kind, with the quiz arm's omitted `kind` resolved to its default. */
|
|
975
|
+
function evalKindOf(evalFile) {
|
|
976
|
+
return evalFile.kind ?? "quiz";
|
|
977
|
+
}
|
|
978
|
+
/** A scripted turn as `{ role, text }` — the wire shape `POST /api/eval/respond` takes. */
|
|
979
|
+
function turnToMessage(turn) {
|
|
980
|
+
return "student" in turn ? {
|
|
981
|
+
role: "user",
|
|
982
|
+
text: turn.student
|
|
983
|
+
} : {
|
|
984
|
+
role: "assistant",
|
|
985
|
+
text: turn.tutor
|
|
986
|
+
};
|
|
987
|
+
}
|
|
988
|
+
/**
|
|
921
989
|
* The canonical expected-verdict SET of one golden answer: a single verdict or a list,
|
|
922
990
|
* deduped and sorted into `EVAL_VERDICTS` order. Canonical because the confusion
|
|
923
991
|
* matrix keys its rows by this set — `correct|partial` must be one row no matter which
|
|
@@ -2591,10 +2659,6 @@ async function loadQuizFrom(url, fetcher, opts = {}) {
|
|
|
2591
2659
|
};
|
|
2592
2660
|
}
|
|
2593
2661
|
}
|
|
2594
|
-
const tutorToolNameSchema = z.enum(["random_number"]).meta({
|
|
2595
|
-
id: "tutorToolName",
|
|
2596
|
-
description: "Name of a built-in tutor tool."
|
|
2597
|
-
});
|
|
2598
2662
|
//#endregion
|
|
2599
2663
|
//#region ../lib/tutors/schemas.ts
|
|
2600
2664
|
/**
|
|
@@ -3252,7 +3316,7 @@ function schemaErrors(issues, url) {
|
|
|
3252
3316
|
/**
|
|
3253
3317
|
* Check ONE eval file end to end. `fetcher` is the caller's network seam and
|
|
3254
3318
|
* `allowedSchemes` the usual SSRF gate (the CLI adds `file:` so an on-disk eval
|
|
3255
|
-
* resolves the
|
|
3319
|
+
* resolves the activity sitting next to it).
|
|
3256
3320
|
*/
|
|
3257
3321
|
async function loadAndCheckEval(url, fetchImpl, opts = {}) {
|
|
3258
3322
|
const allowedSchemes = opts.allowedSchemes;
|
|
@@ -3261,6 +3325,7 @@ async function loadAndCheckEval(url, fetchImpl, opts = {}) {
|
|
|
3261
3325
|
const parsed = EvalYamlSchema.safeParse(yaml.value);
|
|
3262
3326
|
if (!parsed.success) return fail(schemaErrors(parsed.error.issues, url));
|
|
3263
3327
|
const evalFile = parsed.data;
|
|
3328
|
+
const kind = evalKindOf(evalFile);
|
|
3264
3329
|
let targetUrl;
|
|
3265
3330
|
try {
|
|
3266
3331
|
targetUrl = new URL(evalFile.target, url).href;
|
|
@@ -3278,18 +3343,37 @@ async function loadAndCheckEval(url, fetchImpl, opts = {}) {
|
|
|
3278
3343
|
}
|
|
3279
3344
|
const warnings = [];
|
|
3280
3345
|
if (opts.strictTarget) {
|
|
3281
|
-
const strict = await
|
|
3346
|
+
const strict = kind === "tutor" ? await loadAndBuildTutorPrompt(targetUrl, fetchImpl, {
|
|
3347
|
+
allowedSchemes,
|
|
3348
|
+
validateLibraries: opts.validateLibraries ?? true
|
|
3349
|
+
}) : await loadAndCheckQuiz(targetUrl, fetchImpl, {
|
|
3282
3350
|
allowedSchemes,
|
|
3283
3351
|
validateLibraries: opts.validateLibraries ?? true
|
|
3284
3352
|
});
|
|
3285
3353
|
warnings.push(...strict.warnings);
|
|
3286
3354
|
if (!strict.ok) return fail(strict.errors, warnings);
|
|
3287
3355
|
}
|
|
3288
|
-
const dumped = await dumpPrompts(
|
|
3289
|
-
if (!dumped.ok) return fail(dumped.errors.map((e) => error("EVAL_TARGET_ERROR", `The target
|
|
3290
|
-
const
|
|
3291
|
-
if (
|
|
3292
|
-
|
|
3356
|
+
const dumped = await dumpPrompts(kind, targetUrl, fetchImpl, { allowedSchemes });
|
|
3357
|
+
if (!dumped.ok) return fail(dumped.errors.map((e) => error("EVAL_TARGET_ERROR", `The target ${kind} could not be loaded: ${e.message}`, { url: targetUrl })), warnings);
|
|
3358
|
+
const dump = dumped.dump;
|
|
3359
|
+
if (dump.kind !== kind) return fail([error("EVAL_TARGET_ERROR", `The target is not a ${kind}.`, { url: targetUrl })], warnings);
|
|
3360
|
+
if (dump.kind === "tutor" && evalFile.kind === "tutor") {
|
|
3361
|
+
const granted = new Set(dump.tools);
|
|
3362
|
+
const ungranted = evalFile.conversations.flatMap((conversation, index) => (conversation.required_tools ?? []).filter((tool) => !granted.has(tool)).map((tool) => error("EVAL_UNGRANTED_TOOL", `Conversation #${index + 1} requires the tool "${tool}", but the target tutor's tools are ${dump.tools.length ? dump.tools.map((name) => `"${name}"`).join(", ") : "(none)"}.`, { url: targetUrl })));
|
|
3363
|
+
if (ungranted.length > 0) return fail(ungranted, warnings);
|
|
3364
|
+
return {
|
|
3365
|
+
ok: true,
|
|
3366
|
+
kind: "tutor",
|
|
3367
|
+
evalFile,
|
|
3368
|
+
targetUrl,
|
|
3369
|
+
llm: dump.llm,
|
|
3370
|
+
tutorDump: dump,
|
|
3371
|
+
caseCount: evalFile.conversations.length,
|
|
3372
|
+
warnings
|
|
3373
|
+
};
|
|
3374
|
+
}
|
|
3375
|
+
if (dump.kind !== "quiz" || evalFile.kind === "tutor") return fail([error("EVAL_TARGET_ERROR", `The target is not a ${kind}.`, { url: targetUrl })], warnings);
|
|
3376
|
+
const known = new Set(dump.grading.questions.map((question) => question.id));
|
|
3293
3377
|
const unknown = evalFile.questions.filter((question) => !known.has(question.question)).map((question) => error("EVAL_UNKNOWN_QUESTION", `The target quiz has no question "${question.question}".`, {
|
|
3294
3378
|
questionId: question.question,
|
|
3295
3379
|
url: targetUrl
|
|
@@ -3302,15 +3386,200 @@ async function loadAndCheckEval(url, fetchImpl, opts = {}) {
|
|
|
3302
3386
|
})) : [];
|
|
3303
3387
|
return {
|
|
3304
3388
|
ok: true,
|
|
3389
|
+
kind: "quiz",
|
|
3305
3390
|
evalFile,
|
|
3306
3391
|
targetUrl,
|
|
3307
|
-
|
|
3392
|
+
llm: dump.llm,
|
|
3393
|
+
quizDump: dump,
|
|
3308
3394
|
quizQuestions,
|
|
3309
3395
|
caseCount: evalFile.questions.reduce((sum, question) => sum + question.answers.length, 0),
|
|
3310
3396
|
warnings
|
|
3311
3397
|
};
|
|
3312
3398
|
}
|
|
3313
3399
|
//#endregion
|
|
3400
|
+
//#region ../lib/quiz-feedback-judge.ts
|
|
3401
|
+
/**
|
|
3402
|
+
* The QUIZ-feedback taxonomy. The judge may only name one of these, and the endpoint
|
|
3403
|
+
* constrains the model to whatever the CALLER sent (see {@link judgmentSchema}) — which
|
|
3404
|
+
* is what keeps the route kind-agnostic for the eval kinds still to come.
|
|
3405
|
+
*
|
|
3406
|
+
* Deliberately NOT documented in code comments: every definition lives in
|
|
3407
|
+
* {@link FEEDBACK_JUDGE_SYSTEM}, where the model actually reads it, so the two can never
|
|
3408
|
+
* drift apart.
|
|
3409
|
+
*/
|
|
3410
|
+
const FEEDBACK_JUDGE_CRITERIA = [
|
|
3411
|
+
"contradicts_verdict",
|
|
3412
|
+
"misstates_facts",
|
|
3413
|
+
"ignores_instructions",
|
|
3414
|
+
"leaks_rubric"
|
|
3415
|
+
];
|
|
3416
|
+
/**
|
|
3417
|
+
* The judge's system prompt. Three properties are load-bearing and were validated
|
|
3418
|
+
* against ~100 real golden answers plus planted violations before shipping:
|
|
3419
|
+
*
|
|
3420
|
+
* * "Do NOT judge the verdict itself" — a different check (the eval's `expect`) owns
|
|
3421
|
+
* that; a judge that re-grades produces noise the report cannot act on.
|
|
3422
|
+
* * "be strict about real violations, but do not invent issues … when in doubt, the
|
|
3423
|
+
* feedback is ok" — without it, weak models flag matters of taste.
|
|
3424
|
+
* * an EMPTY `issues` array is the way to say "acceptable". There is deliberately no
|
|
3425
|
+
* `ok` boolean: weak judges set `ok: false` and then name no issue at all, which is
|
|
3426
|
+
* unreportable. Flagged ⇔ an issue was named.
|
|
3427
|
+
*/
|
|
3428
|
+
const FEEDBACK_JUDGE_SYSTEM = `You are auditing the FEEDBACK a quiz-grading assistant gave to a student.
|
|
3429
|
+
|
|
3430
|
+
You receive:
|
|
3431
|
+
- the complete system prompt the grader was given (it contains shared course
|
|
3432
|
+
rules, the question, and the grading criteria),
|
|
3433
|
+
- the student's answer,
|
|
3434
|
+
- the verdict the grader chose (correct / partial / incorrect),
|
|
3435
|
+
- the feedback text the grader wrote for the student.
|
|
3436
|
+
|
|
3437
|
+
Judge ONLY the feedback text, on these criteria:
|
|
3438
|
+
|
|
3439
|
+
- "contradicts_verdict": the feedback's message disagrees with the verdict —
|
|
3440
|
+
e.g. it celebrates the answer as right although the verdict is incorrect, or
|
|
3441
|
+
corrects an answer whose verdict is correct.
|
|
3442
|
+
- "misstates_facts": the feedback asserts something that the grading criteria
|
|
3443
|
+
in the system prompt contradict — factual errors about the subject matter.
|
|
3444
|
+
- "ignores_instructions": the feedback violates an explicit rule the system
|
|
3445
|
+
prompt states about feedback, e.g. it fails to state the correct answer even
|
|
3446
|
+
though the verdict is not correct and the prompt demands that, is written in
|
|
3447
|
+
a language the prompt does not allow, or is not addressed to the student.
|
|
3448
|
+
- "leaks_rubric": the feedback quotes the grading criteria verbatim, refers to
|
|
3449
|
+
the grading instructions ("my instructions say...", "the rubric requires..."),
|
|
3450
|
+
or reveals verdict boundaries the student is not supposed to see.
|
|
3451
|
+
|
|
3452
|
+
Do NOT judge the verdict itself — a different auditor covers that. Judge the
|
|
3453
|
+
feedback GIVEN the verdict. Be strict about real violations, but do not invent
|
|
3454
|
+
issues: stylistic taste, brevity, or a matter of tone that the system prompt
|
|
3455
|
+
does not regulate are NOT issues. When in doubt, the feedback is ok.
|
|
3456
|
+
|
|
3457
|
+
Return one entry in "issues" per violation you found, and an EMPTY "issues"
|
|
3458
|
+
array when the feedback is acceptable. Answer with the JSON object only.`;
|
|
3459
|
+
/**
|
|
3460
|
+
* The judge's USER message: the four inputs in labeled `===` blocks, the grader's system
|
|
3461
|
+
* prompt first and the feedback under judgment last (so the model reads the standard
|
|
3462
|
+
* before the thing it measures).
|
|
3463
|
+
*
|
|
3464
|
+
* Nothing is escaped — every part is DATA for the judge, not markup, and a course prompt
|
|
3465
|
+
* containing `===` or Markdown must reach the judge exactly as the grader saw it.
|
|
3466
|
+
*/
|
|
3467
|
+
function buildFeedbackJudgeSubject(gradingSystem, answer, verdict, feedback) {
|
|
3468
|
+
return [
|
|
3469
|
+
"=== The system prompt the grader was given ===",
|
|
3470
|
+
gradingSystem,
|
|
3471
|
+
"",
|
|
3472
|
+
"=== The student's answer ===",
|
|
3473
|
+
answer,
|
|
3474
|
+
"",
|
|
3475
|
+
"=== The grader's verdict ===",
|
|
3476
|
+
verdict,
|
|
3477
|
+
"",
|
|
3478
|
+
"=== The grader's feedback (JUDGE THIS) ===",
|
|
3479
|
+
feedback
|
|
3480
|
+
].join("\n");
|
|
3481
|
+
}
|
|
3482
|
+
//#endregion
|
|
3483
|
+
//#region ../lib/tutor-judge.ts
|
|
3484
|
+
/**
|
|
3485
|
+
* The TUTOR-response taxonomy. The judge may only name one of these, and the endpoint
|
|
3486
|
+
* constrains the model to whatever the CALLER sent (`judgmentSchema`) — which is what
|
|
3487
|
+
* keeps `/api/eval/judge` kind-agnostic.
|
|
3488
|
+
*
|
|
3489
|
+
* Deliberately NOT documented in code comments: every definition lives in
|
|
3490
|
+
* {@link TUTOR_JUDGE_SYSTEM}, where the model actually reads it, so the two can never
|
|
3491
|
+
* drift apart.
|
|
3492
|
+
*/
|
|
3493
|
+
const TUTOR_JUDGE_CRITERIA = [
|
|
3494
|
+
"ignores_instructions",
|
|
3495
|
+
"fails_expectations",
|
|
3496
|
+
"misstates_facts",
|
|
3497
|
+
"leaks_prompt"
|
|
3498
|
+
];
|
|
3499
|
+
/** The criterion that only exists when the teacher stated expectations for the case. */
|
|
3500
|
+
const EXPECTATIONS_CRITERION = "fails_expectations";
|
|
3501
|
+
/**
|
|
3502
|
+
* The criteria ONE case's judge call may use. A case without `grading_instructions`
|
|
3503
|
+
* drops `fails_expectations` entirely — the per-request enum makes that free, and it
|
|
3504
|
+
* removes by construction the failure mode of a judge inventing expectations nobody
|
|
3505
|
+
* stated.
|
|
3506
|
+
*/
|
|
3507
|
+
function tutorJudgeCriteria(hasGradingInstructions) {
|
|
3508
|
+
return TUTOR_JUDGE_CRITERIA.filter((criterion) => hasGradingInstructions || criterion !== EXPECTATIONS_CRITERION);
|
|
3509
|
+
}
|
|
3510
|
+
/**
|
|
3511
|
+
* The judge's system prompt. The three guardrails measured on the quiz judge before
|
|
3512
|
+
* shipping carry over verbatim in spirit:
|
|
3513
|
+
*
|
|
3514
|
+
* * "Do NOT judge pedagogical quality or style" — the tutor analogue of the quiz
|
|
3515
|
+
* judge's "do not judge the verdict": a judge that rates teaching craft produces
|
|
3516
|
+
* noise the report cannot act on. Compliance is the whole job.
|
|
3517
|
+
* * "be strict about real violations, but do not invent issues … when in doubt, the
|
|
3518
|
+
* response is ok" — without it, weak models flag matters of taste.
|
|
3519
|
+
* * an EMPTY `issues` array is the way to say "acceptable". There is deliberately no
|
|
3520
|
+
* `ok` boolean: weak judges set `ok: false` and then name no issue at all, which is
|
|
3521
|
+
* unreportable. Flagged ⇔ an issue was named.
|
|
3522
|
+
*/
|
|
3523
|
+
const TUTOR_JUDGE_SYSTEM = `You are auditing ONE response an AI TUTOR gave to a student.
|
|
3524
|
+
|
|
3525
|
+
You receive:
|
|
3526
|
+
- the complete system prompt the tutor was given (it contains the persona, the
|
|
3527
|
+
course rules and the scope the tutor must stay inside),
|
|
3528
|
+
- the conversation so far, turn by turn, ending with the student message the
|
|
3529
|
+
tutor had to answer,
|
|
3530
|
+
- the response the tutor generated for that last student message,
|
|
3531
|
+
- and, when the teacher wrote any, extra expectations for THIS case.
|
|
3532
|
+
|
|
3533
|
+
Judge ONLY the generated tutor response, on these criteria:
|
|
3534
|
+
|
|
3535
|
+
- "ignores_instructions": the response breaks an explicit rule of the tutor's
|
|
3536
|
+
own system prompt — e.g. it writes out the complete solution although the
|
|
3537
|
+
prompt forbids that, leaves the concepts the prompt limits it to, answers in
|
|
3538
|
+
a language the prompt does not allow, or ignores a stated formatting rule.
|
|
3539
|
+
- "fails_expectations": the response violates the teacher's extra expectations
|
|
3540
|
+
for this case, when such expectations were given.
|
|
3541
|
+
- "misstates_facts": the response asserts something that is factually wrong for
|
|
3542
|
+
the subject matter — bad code, a wrong term, an untrue claim.
|
|
3543
|
+
- "leaks_prompt": the response quotes or reveals its own instructions, or talks
|
|
3544
|
+
about "my rules", "my prompt", "the instructions I was given".
|
|
3545
|
+
|
|
3546
|
+
Do NOT judge pedagogical quality, tone, length or style — a response you would
|
|
3547
|
+
have written differently is NOT an issue. Judge COMPLIANCE with the system
|
|
3548
|
+
prompt and with the stated expectations, nothing else. Be strict about real
|
|
3549
|
+
violations, but do not invent issues. When in doubt, the response is ok.
|
|
3550
|
+
|
|
3551
|
+
Return one entry in "issues" per violation you found, and an EMPTY "issues"
|
|
3552
|
+
array when the response is acceptable. Answer with the JSON object only.`;
|
|
3553
|
+
/** `student:` / `tutor:` — the teacher-facing role labels, also used in the subject. */
|
|
3554
|
+
function turnLabel(turn) {
|
|
3555
|
+
return "student" in turn ? `student: ${turn.student}` : `tutor: ${turn.tutor}`;
|
|
3556
|
+
}
|
|
3557
|
+
/**
|
|
3558
|
+
* The judge's USER message: the inputs in labeled `===` blocks — the tutor's system
|
|
3559
|
+
* prompt (the standard), the scripted conversation, the response under judgment, the
|
|
3560
|
+
* tools the tutor actually reached for, and the teacher's expectations when the case
|
|
3561
|
+
* states any.
|
|
3562
|
+
*
|
|
3563
|
+
* Nothing is escaped — every part is DATA for the judge, not markup, and a course prompt
|
|
3564
|
+
* containing `===` or Markdown must reach the judge exactly as the tutor saw it.
|
|
3565
|
+
*/
|
|
3566
|
+
function buildTutorJudgeSubject(tutorSystem, conversation, response, options = {}) {
|
|
3567
|
+
const { gradingInstructions, tools, toolCalls } = options;
|
|
3568
|
+
const blocks = [
|
|
3569
|
+
"=== The system prompt the tutor was given ===",
|
|
3570
|
+
tutorSystem,
|
|
3571
|
+
"",
|
|
3572
|
+
"=== The conversation so far (the last turn is what the tutor answered) ===",
|
|
3573
|
+
conversation.map(turnLabel).join("\n\n"),
|
|
3574
|
+
"",
|
|
3575
|
+
"=== The tutor's generated response (JUDGE THIS) ===",
|
|
3576
|
+
response
|
|
3577
|
+
];
|
|
3578
|
+
if (tools !== void 0 && tools.length > 0 && toolCalls !== void 0) blocks.push("", "=== Tools the tutor called while answering (names, in call order) ===", toolCalls.length > 0 ? toolCalls.join("\n") : "(none)");
|
|
3579
|
+
if (gradingInstructions) blocks.push("", "=== The teacher's expectations for this case ===", gradingInstructions);
|
|
3580
|
+
return blocks.join("\n");
|
|
3581
|
+
}
|
|
3582
|
+
//#endregion
|
|
3314
3583
|
//#region src/retry.ts
|
|
3315
3584
|
const defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
3316
3585
|
/**
|
|
@@ -3364,6 +3633,18 @@ function addUsage(total, usage) {
|
|
|
3364
3633
|
total.cachedInput += usage.cachedInput;
|
|
3365
3634
|
total.output += usage.output;
|
|
3366
3635
|
}
|
|
3636
|
+
function createJudgeBreaker() {
|
|
3637
|
+
return {
|
|
3638
|
+
consecutiveErrors: 0,
|
|
3639
|
+
stopped: false
|
|
3640
|
+
};
|
|
3641
|
+
}
|
|
3642
|
+
/** Consecutive fully-errored judge calls that mean "stop judging for the rest of the run". */
|
|
3643
|
+
const JUDGE_BREAKER_LIMIT = 3;
|
|
3644
|
+
/** Narrow a case to the tutor arm. */
|
|
3645
|
+
function isTutorCase(evalCase) {
|
|
3646
|
+
return "conversation" in evalCase;
|
|
3647
|
+
}
|
|
3367
3648
|
/** Consecutive fully-errored cases that mean "the server is down, stop now". */
|
|
3368
3649
|
const CIRCUIT_BREAKER_LIMIT = 3;
|
|
3369
3650
|
/** Flatten questions × answers into cases, each carrying its grading prompt. */
|
|
@@ -3398,16 +3679,85 @@ function majority(graded, expected) {
|
|
|
3398
3679
|
passed: tied.every((verdict) => expected.includes(verdict))
|
|
3399
3680
|
};
|
|
3400
3681
|
}
|
|
3401
|
-
/** The
|
|
3402
|
-
|
|
3682
|
+
/** The one-line message a failed judge call leaves on its repeat row. */
|
|
3683
|
+
function judgeErrorMessage(error) {
|
|
3684
|
+
if (typeof error === "string") return error;
|
|
3685
|
+
if (typeof error === "object" && error !== null && "message" in error) return String(error.message);
|
|
3686
|
+
return JSON.stringify(error ?? null);
|
|
3687
|
+
}
|
|
3688
|
+
/**
|
|
3689
|
+
* The `judge` field of a repeat that produced NO judgment — spread onto every row that
|
|
3690
|
+
* is not a successful generation. Judging on ⇒ an explicit `null` (so a script reading
|
|
3691
|
+
* `judge === null` catches every unjudged repeat, not only the degraded ones); judging
|
|
3692
|
+
* off ⇒ nothing at all, since the whole run then carries no judge fields.
|
|
3693
|
+
*/
|
|
3694
|
+
function unjudgedFields(options) {
|
|
3695
|
+
return options.judge ? { judge: null } : {};
|
|
3696
|
+
}
|
|
3697
|
+
/**
|
|
3698
|
+
* The KIND-AGNOSTIC judge step: judge ONE repeat's output as a dependent step of that
|
|
3699
|
+
* repeat, retrying and feeding the run-wide degrade breaker. Each kind assembles its own
|
|
3700
|
+
* `system` / `subject` / `criteria` (quiz via `lib/quiz-feedback-judge.ts`, tutor via
|
|
3701
|
+
* `lib/tutor-judge.ts`) — the endpoint and this step never learn the kind.
|
|
3702
|
+
*
|
|
3703
|
+
* Returns the fields to merge onto the row: a judgment, or `judge: null` plus a
|
|
3704
|
+
* `judgeError` when the call failed, or a bare `judge: null` when the breaker had
|
|
3705
|
+
* already degraded the run.
|
|
3706
|
+
*/
|
|
3707
|
+
function createJudgeStep(options, breaker) {
|
|
3708
|
+
return async (request) => {
|
|
3709
|
+
const judge = options.judge;
|
|
3710
|
+
if (!judge || breaker.stopped) return { judge: null };
|
|
3711
|
+
const outcome = await withRetry(() => judge(request), {
|
|
3712
|
+
attempts: options.retry?.attempts,
|
|
3713
|
+
baseDelayMs: options.retry?.baseDelayMs,
|
|
3714
|
+
sleep: options.retry?.sleep,
|
|
3715
|
+
shouldRetry: (value) => !value.ok && value.retryable && !breaker.stopped
|
|
3716
|
+
});
|
|
3717
|
+
if (outcome.ok) {
|
|
3718
|
+
breaker.consecutiveErrors = 0;
|
|
3719
|
+
return { judge: {
|
|
3720
|
+
issues: outcome.issues,
|
|
3721
|
+
...outcome.usage ? { usage: outcome.usage } : {}
|
|
3722
|
+
} };
|
|
3723
|
+
}
|
|
3724
|
+
breaker.consecutiveErrors += 1;
|
|
3725
|
+
if (!breaker.stopped && breaker.consecutiveErrors >= JUDGE_BREAKER_LIMIT) {
|
|
3726
|
+
breaker.stopped = true;
|
|
3727
|
+
options.onJudgeDegraded?.();
|
|
3728
|
+
}
|
|
3729
|
+
return {
|
|
3730
|
+
judge: null,
|
|
3731
|
+
judgeError: judgeErrorMessage(outcome.error)
|
|
3732
|
+
};
|
|
3733
|
+
};
|
|
3734
|
+
}
|
|
3735
|
+
const quizEvalRunner = { async run(rawChecked, options) {
|
|
3736
|
+
if (rawChecked.kind !== "quiz") throw new Error("The quiz eval runner needs a quiz eval file.");
|
|
3737
|
+
const checked = rawChecked;
|
|
3738
|
+
const grade = options.grade;
|
|
3739
|
+
if (!grade) throw new Error("The quiz eval runner needs a `grade` seam.");
|
|
3403
3740
|
const repeats = Math.max(1, Math.floor(options.repeats ?? 1));
|
|
3404
3741
|
const concurrency = Math.max(1, Math.floor(options.concurrency ?? 4));
|
|
3405
3742
|
const planned = planCases(checked);
|
|
3406
3743
|
const total = planned.length * repeats;
|
|
3407
3744
|
const questionTexts = new Map(checked.quizQuestions.map((q) => [q.id, q.text]));
|
|
3745
|
+
const breaker = options.judgeBreaker ?? createJudgeBreaker();
|
|
3746
|
+
const judgeStep = createJudgeStep(options, breaker);
|
|
3408
3747
|
let done = 0;
|
|
3409
3748
|
let consecutiveErrored = 0;
|
|
3410
3749
|
let aborted;
|
|
3750
|
+
const unjudged = unjudgedFields(options);
|
|
3751
|
+
/**
|
|
3752
|
+
* Judge ONE graded repeat's feedback, against the repeat's OWN verdict — never the
|
|
3753
|
+
* case majority: an outvoted repeat's feedback is consistent with the verdict it
|
|
3754
|
+
* actually got.
|
|
3755
|
+
*/
|
|
3756
|
+
const judgeRepeat = (system, answer, verdict, feedback) => judgeStep({
|
|
3757
|
+
system: FEEDBACK_JUDGE_SYSTEM,
|
|
3758
|
+
subject: buildFeedbackJudgeSubject(system, answer, verdict, feedback),
|
|
3759
|
+
criteria: FEEDBACK_JUDGE_CRITERIA
|
|
3760
|
+
});
|
|
3411
3761
|
const progress = () => {
|
|
3412
3762
|
done += 1;
|
|
3413
3763
|
options.onProgress?.({
|
|
@@ -3422,12 +3772,13 @@ const evalRunners = { quiz: { async run(checked, options) {
|
|
|
3422
3772
|
if (plan.system === void 0) {
|
|
3423
3773
|
rows.push({
|
|
3424
3774
|
repeatIndex,
|
|
3425
|
-
error: { message: `The quiz has no question "${plan.questionId}".` }
|
|
3775
|
+
error: { message: `The quiz has no question "${plan.questionId}".` },
|
|
3776
|
+
...unjudged
|
|
3426
3777
|
});
|
|
3427
3778
|
progress();
|
|
3428
3779
|
continue;
|
|
3429
3780
|
}
|
|
3430
|
-
const outcome = await withRetry(() =>
|
|
3781
|
+
const outcome = await withRetry(() => grade({
|
|
3431
3782
|
system: plan.system,
|
|
3432
3783
|
answer: plan.answer
|
|
3433
3784
|
}), {
|
|
@@ -3436,19 +3787,23 @@ const evalRunners = { quiz: { async run(checked, options) {
|
|
|
3436
3787
|
sleep: options.retry?.sleep,
|
|
3437
3788
|
shouldRetry: (value) => !value.ok && value.retryable && value.auth !== true
|
|
3438
3789
|
});
|
|
3439
|
-
progress();
|
|
3440
3790
|
if (outcome.ok) {
|
|
3791
|
+
const judged = options.judge ? await judgeRepeat(plan.system, plan.answer, outcome.verdict, outcome.feedback) : {};
|
|
3792
|
+
progress();
|
|
3441
3793
|
rows.push({
|
|
3442
3794
|
repeatIndex,
|
|
3443
3795
|
got: outcome.verdict,
|
|
3444
3796
|
feedback: outcome.feedback,
|
|
3445
|
-
...outcome.usage ? { usage: outcome.usage } : {}
|
|
3797
|
+
...outcome.usage ? { usage: outcome.usage } : {},
|
|
3798
|
+
...judged
|
|
3446
3799
|
});
|
|
3447
3800
|
continue;
|
|
3448
3801
|
}
|
|
3802
|
+
progress();
|
|
3449
3803
|
rows.push({
|
|
3450
3804
|
repeatIndex,
|
|
3451
|
-
error: outcome.error
|
|
3805
|
+
error: outcome.error,
|
|
3806
|
+
...unjudged
|
|
3452
3807
|
});
|
|
3453
3808
|
if (outcome.auth) {
|
|
3454
3809
|
aborted ??= {
|
|
@@ -3476,11 +3831,16 @@ const evalRunners = { quiz: { async run(checked, options) {
|
|
|
3476
3831
|
status,
|
|
3477
3832
|
...winner ? { verdict: winner.verdict } : {},
|
|
3478
3833
|
unstable: new Set(graded).size > 1,
|
|
3834
|
+
feedbackFlagged: rows.some((row) => (row.judge?.issues.length ?? 0) > 0),
|
|
3835
|
+
toolsFlagged: false,
|
|
3479
3836
|
repeats: rows
|
|
3480
3837
|
};
|
|
3481
3838
|
});
|
|
3482
3839
|
const usage = { ...ZERO_USAGE };
|
|
3483
|
-
for (const result of results) for (const row of result.repeats)
|
|
3840
|
+
for (const result of results) for (const row of result.repeats) {
|
|
3841
|
+
addUsage(usage, row.usage);
|
|
3842
|
+
addUsage(usage, row.judge?.usage);
|
|
3843
|
+
}
|
|
3484
3844
|
const totals = {
|
|
3485
3845
|
cases: results.length,
|
|
3486
3846
|
passed: results.filter((c) => c.status === "passed").length,
|
|
@@ -3488,6 +3848,9 @@ const evalRunners = { quiz: { async run(checked, options) {
|
|
|
3488
3848
|
errored: results.filter((c) => c.status === "errored").length,
|
|
3489
3849
|
skipped: results.filter((c) => c.status === "skipped").length,
|
|
3490
3850
|
unstable: results.filter((c) => c.unstable).length,
|
|
3851
|
+
feedbackFlagged: results.filter((c) => c.feedbackFlagged).length,
|
|
3852
|
+
toolsFlagged: 0,
|
|
3853
|
+
judgeErrored: results.reduce((sum, c) => sum + c.repeats.filter((row) => row.judgeError !== void 0).length, 0),
|
|
3491
3854
|
repeats,
|
|
3492
3855
|
calls: total,
|
|
3493
3856
|
usage
|
|
@@ -3495,7 +3858,7 @@ const evalRunners = { quiz: { async run(checked, options) {
|
|
|
3495
3858
|
const confusionCounts = /* @__PURE__ */ new Map();
|
|
3496
3859
|
for (const result of results) {
|
|
3497
3860
|
if (!result.verdict) continue;
|
|
3498
|
-
const key = `${expectedKey(result.expected)}
|
|
3861
|
+
const key = `${expectedKey(result.expected)}\u0000${result.verdict}`;
|
|
3499
3862
|
confusionCounts.set(key, (confusionCounts.get(key) ?? 0) + 1);
|
|
3500
3863
|
}
|
|
3501
3864
|
const confusion = [...confusionCounts.entries()].map(([key, count]) => {
|
|
@@ -3510,8 +3873,10 @@ const evalRunners = { quiz: { async run(checked, options) {
|
|
|
3510
3873
|
const falseCorrectCount = strictCases.filter((result) => result.verdict === "correct").length;
|
|
3511
3874
|
return {
|
|
3512
3875
|
id: checked.evalFile.id,
|
|
3876
|
+
kind: "quiz",
|
|
3513
3877
|
target: checked.targetUrl,
|
|
3514
3878
|
llm: options.llm,
|
|
3879
|
+
judging: !options.judge ? "off" : breaker.stopped ? "degraded" : "on",
|
|
3515
3880
|
totals,
|
|
3516
3881
|
questions: [...new Set(checked.evalFile.questions.map((question) => question.question))].map((id) => ({
|
|
3517
3882
|
id,
|
|
@@ -3527,7 +3892,178 @@ const evalRunners = { quiz: { async run(checked, options) {
|
|
|
3527
3892
|
},
|
|
3528
3893
|
...aborted ? { aborted } : {}
|
|
3529
3894
|
};
|
|
3530
|
-
} }
|
|
3895
|
+
} };
|
|
3896
|
+
/** One planned case per conversation, in file order. */
|
|
3897
|
+
function planTutorCases(checked) {
|
|
3898
|
+
return checked.evalFile.conversations.map((conversation, index) => ({
|
|
3899
|
+
index,
|
|
3900
|
+
...conversation.title ? { title: conversation.title } : {},
|
|
3901
|
+
conversation: conversation.conversation,
|
|
3902
|
+
...conversation.grading_instructions ? { gradingInstructions: conversation.grading_instructions } : {},
|
|
3903
|
+
...conversation.required_tools ? { requiredTools: [...conversation.required_tools] } : {},
|
|
3904
|
+
messages: conversation.conversation.map(turnToMessage)
|
|
3905
|
+
}));
|
|
3906
|
+
}
|
|
3907
|
+
/**
|
|
3908
|
+
* The message a repeat carries when the case REQUIRES tools but the 200 answered without a
|
|
3909
|
+
* `toolCalls` field: a new CLI against a server too old to report them. Terminal and loud
|
|
3910
|
+
* — reporting "nothing missing" for a check that never ran would certify a tool
|
|
3911
|
+
* expectation nobody verified, which is worse than failing. The advisory `/api/version`
|
|
3912
|
+
* check cannot carry this: it only warns (never gates, never compares an ordering), while
|
|
3913
|
+
* this must fail the run's health.
|
|
3914
|
+
*/
|
|
3915
|
+
const NO_TOOL_CALLS_REPORTED = "This case declares `required_tools`, but the server's answer carried no tool calls — it is too old to report them, so the requirement could not be checked. Update the Novedu server, or remove `required_tools` from this case.";
|
|
3916
|
+
/** The required tools this repeat never called, in the case's own order. */
|
|
3917
|
+
function missingToolsOf(required, called) {
|
|
3918
|
+
const seen = new Set(called);
|
|
3919
|
+
return required.filter((tool) => !seen.has(tool));
|
|
3920
|
+
}
|
|
3921
|
+
/** The seam: one runner per eval kind (mirrors `promptDumpers`). */
|
|
3922
|
+
const evalRunners = {
|
|
3923
|
+
quiz: quizEvalRunner,
|
|
3924
|
+
tutor: { async run(rawChecked, options) {
|
|
3925
|
+
if (rawChecked.kind !== "tutor") throw new Error("The tutor eval runner needs a tutor eval file.");
|
|
3926
|
+
const checked = rawChecked;
|
|
3927
|
+
const respond = options.respond;
|
|
3928
|
+
if (!respond) throw new Error("The tutor eval runner needs a `respond` seam.");
|
|
3929
|
+
const repeats = Math.max(1, Math.floor(options.repeats ?? 1));
|
|
3930
|
+
const concurrency = Math.max(1, Math.floor(options.concurrency ?? 4));
|
|
3931
|
+
const planned = planTutorCases(checked);
|
|
3932
|
+
const total = planned.length * repeats;
|
|
3933
|
+
const system = checked.tutorDump.system;
|
|
3934
|
+
const tools = checked.tutorDump.tools;
|
|
3935
|
+
const breaker = options.judgeBreaker ?? createJudgeBreaker();
|
|
3936
|
+
const judgeStep = createJudgeStep(options, breaker);
|
|
3937
|
+
let done = 0;
|
|
3938
|
+
let consecutiveErrored = 0;
|
|
3939
|
+
let aborted;
|
|
3940
|
+
const unjudged = unjudgedFields(options);
|
|
3941
|
+
const progress = () => {
|
|
3942
|
+
done += 1;
|
|
3943
|
+
options.onProgress?.({
|
|
3944
|
+
done,
|
|
3945
|
+
total
|
|
3946
|
+
});
|
|
3947
|
+
};
|
|
3948
|
+
const results = await mapWithConcurrency(planned, concurrency, async (plan) => {
|
|
3949
|
+
const rows = [];
|
|
3950
|
+
for (let repeatIndex = 0; repeatIndex < repeats; repeatIndex++) {
|
|
3951
|
+
if (aborted) break;
|
|
3952
|
+
const outcome = await withRetry(() => respond({
|
|
3953
|
+
system,
|
|
3954
|
+
tools,
|
|
3955
|
+
messages: plan.messages
|
|
3956
|
+
}), {
|
|
3957
|
+
attempts: options.retry?.attempts,
|
|
3958
|
+
baseDelayMs: options.retry?.baseDelayMs,
|
|
3959
|
+
sleep: options.retry?.sleep,
|
|
3960
|
+
shouldRetry: (value) => !value.ok && value.retryable && value.auth !== true
|
|
3961
|
+
});
|
|
3962
|
+
if (outcome.ok) {
|
|
3963
|
+
if (plan.requiredTools && outcome.toolCalls === void 0) {
|
|
3964
|
+
progress();
|
|
3965
|
+
rows.push({
|
|
3966
|
+
repeatIndex,
|
|
3967
|
+
error: { message: NO_TOOL_CALLS_REPORTED },
|
|
3968
|
+
...unjudged
|
|
3969
|
+
});
|
|
3970
|
+
break;
|
|
3971
|
+
}
|
|
3972
|
+
const missingTools = plan.requiredTools ? missingToolsOf(plan.requiredTools, outcome.toolCalls ?? []) : void 0;
|
|
3973
|
+
const judged = options.judge ? await judgeStep({
|
|
3974
|
+
system: TUTOR_JUDGE_SYSTEM,
|
|
3975
|
+
subject: buildTutorJudgeSubject(system, plan.conversation, outcome.text, {
|
|
3976
|
+
...plan.gradingInstructions ? { gradingInstructions: plan.gradingInstructions } : {},
|
|
3977
|
+
tools,
|
|
3978
|
+
...outcome.toolCalls ? { toolCalls: outcome.toolCalls } : {}
|
|
3979
|
+
}),
|
|
3980
|
+
criteria: tutorJudgeCriteria(plan.gradingInstructions !== void 0)
|
|
3981
|
+
}) : {};
|
|
3982
|
+
progress();
|
|
3983
|
+
rows.push({
|
|
3984
|
+
repeatIndex,
|
|
3985
|
+
text: outcome.text,
|
|
3986
|
+
...outcome.toolCalls ? { toolCalls: outcome.toolCalls } : {},
|
|
3987
|
+
...missingTools ? { missingTools } : {},
|
|
3988
|
+
...outcome.usage ? { usage: outcome.usage } : {},
|
|
3989
|
+
...judged
|
|
3990
|
+
});
|
|
3991
|
+
continue;
|
|
3992
|
+
}
|
|
3993
|
+
progress();
|
|
3994
|
+
rows.push({
|
|
3995
|
+
repeatIndex,
|
|
3996
|
+
error: outcome.error,
|
|
3997
|
+
...unjudged
|
|
3998
|
+
});
|
|
3999
|
+
if (outcome.auth) {
|
|
4000
|
+
aborted ??= {
|
|
4001
|
+
reason: "auth",
|
|
4002
|
+
message: "Authentication failed — the run was aborted. Run `novedu-cli login`."
|
|
4003
|
+
};
|
|
4004
|
+
break;
|
|
4005
|
+
}
|
|
4006
|
+
}
|
|
4007
|
+
const generated = rows.some((row) => row.text !== void 0);
|
|
4008
|
+
const status = rows.length === 0 ? "skipped" : generated ? "ok" : "errored";
|
|
4009
|
+
if (status === "errored") {
|
|
4010
|
+
consecutiveErrored += 1;
|
|
4011
|
+
if (consecutiveErrored >= CIRCUIT_BREAKER_LIMIT) aborted ??= {
|
|
4012
|
+
reason: "circuit-breaker",
|
|
4013
|
+
message: `${CIRCUIT_BREAKER_LIMIT} cases failed in a row — the run was aborted.`
|
|
4014
|
+
};
|
|
4015
|
+
} else if (status !== "skipped") consecutiveErrored = 0;
|
|
4016
|
+
return {
|
|
4017
|
+
index: plan.index,
|
|
4018
|
+
...plan.title ? { title: plan.title } : {},
|
|
4019
|
+
conversation: plan.conversation,
|
|
4020
|
+
...plan.gradingInstructions ? { gradingInstructions: plan.gradingInstructions } : {},
|
|
4021
|
+
...plan.requiredTools ? { requiredTools: plan.requiredTools } : {},
|
|
4022
|
+
status,
|
|
4023
|
+
unstable: false,
|
|
4024
|
+
feedbackFlagged: rows.some((row) => (row.judge?.issues.length ?? 0) > 0),
|
|
4025
|
+
toolsFlagged: rows.some((row) => (row.missingTools?.length ?? 0) > 0),
|
|
4026
|
+
repeats: rows
|
|
4027
|
+
};
|
|
4028
|
+
});
|
|
4029
|
+
const usage = { ...ZERO_USAGE };
|
|
4030
|
+
for (const result of results) for (const row of result.repeats) {
|
|
4031
|
+
addUsage(usage, row.usage);
|
|
4032
|
+
addUsage(usage, row.judge?.usage);
|
|
4033
|
+
}
|
|
4034
|
+
return {
|
|
4035
|
+
id: checked.evalFile.id,
|
|
4036
|
+
kind: "tutor",
|
|
4037
|
+
target: checked.targetUrl,
|
|
4038
|
+
llm: options.llm,
|
|
4039
|
+
judging: !options.judge ? "off" : breaker.stopped ? "degraded" : "on",
|
|
4040
|
+
totals: {
|
|
4041
|
+
cases: results.length,
|
|
4042
|
+
passed: 0,
|
|
4043
|
+
failed: 0,
|
|
4044
|
+
errored: results.filter((c) => c.status === "errored").length,
|
|
4045
|
+
skipped: results.filter((c) => c.status === "skipped").length,
|
|
4046
|
+
unstable: 0,
|
|
4047
|
+
feedbackFlagged: results.filter((c) => c.feedbackFlagged).length,
|
|
4048
|
+
toolsFlagged: results.filter((c) => c.toolsFlagged).length,
|
|
4049
|
+
judgeErrored: results.reduce((sum, c) => sum + c.repeats.filter((row) => row.judgeError !== void 0).length, 0),
|
|
4050
|
+
repeats,
|
|
4051
|
+
calls: total,
|
|
4052
|
+
usage
|
|
4053
|
+
},
|
|
4054
|
+
questions: [],
|
|
4055
|
+
confusion: [],
|
|
4056
|
+
falseCorrect: {
|
|
4057
|
+
count: 0,
|
|
4058
|
+
denominator: 0,
|
|
4059
|
+
rate: 0
|
|
4060
|
+
},
|
|
4061
|
+
mismatches: results.filter((result) => result.status === "errored"),
|
|
4062
|
+
cases: results,
|
|
4063
|
+
...aborted ? { aborted } : {}
|
|
4064
|
+
};
|
|
4065
|
+
} }
|
|
4066
|
+
};
|
|
3531
4067
|
/** Run ONE checked eval file — the single entry point the command uses. */
|
|
3532
4068
|
function runEval(kind, checked, options) {
|
|
3533
4069
|
return evalRunners[kind].run(checked, options);
|
|
@@ -3552,6 +4088,9 @@ function summarizeBatch(files) {
|
|
|
3552
4088
|
errored: 0,
|
|
3553
4089
|
skipped: 0,
|
|
3554
4090
|
unstable: 0,
|
|
4091
|
+
feedbackFlagged: 0,
|
|
4092
|
+
toolsFlagged: 0,
|
|
4093
|
+
judgeErrored: 0,
|
|
3555
4094
|
usage: { ...ZERO_USAGE }
|
|
3556
4095
|
};
|
|
3557
4096
|
for (const file of files) {
|
|
@@ -3562,11 +4101,15 @@ function summarizeBatch(files) {
|
|
|
3562
4101
|
totals.errored += file.result.totals.errored;
|
|
3563
4102
|
totals.skipped += file.result.totals.skipped;
|
|
3564
4103
|
totals.unstable += file.result.totals.unstable;
|
|
4104
|
+
totals.feedbackFlagged += file.result.totals.feedbackFlagged;
|
|
4105
|
+
totals.toolsFlagged += file.result.totals.toolsFlagged;
|
|
4106
|
+
totals.judgeErrored += file.result.totals.judgeErrored;
|
|
3565
4107
|
addUsage(totals.usage, file.result.totals.usage);
|
|
3566
4108
|
}
|
|
3567
4109
|
return {
|
|
3568
4110
|
files: files.map((file) => ({
|
|
3569
4111
|
...file,
|
|
4112
|
+
...file.result ? { kind: file.result.kind } : {},
|
|
3570
4113
|
passed: filePassed(file)
|
|
3571
4114
|
})),
|
|
3572
4115
|
passed: batchPassed({ totals }),
|
|
@@ -3574,9 +4117,34 @@ function summarizeBatch(files) {
|
|
|
3574
4117
|
};
|
|
3575
4118
|
}
|
|
3576
4119
|
/**
|
|
4120
|
+
* Did this file's run produce ANY judgment? The ONE rule every renderer derives its
|
|
4121
|
+
* flagged count's visibility from: a file that judged nothing — judging off, or every
|
|
4122
|
+
* case run after the breaker degraded the run — has NOT been found clean, so its flagged
|
|
4123
|
+
* count renders as "not checked" (an em dash, an omitted segment), never as a `0`.
|
|
4124
|
+
*/
|
|
4125
|
+
function anyJudged(result) {
|
|
4126
|
+
return result.cases.some((evalCase) => evalCase.repeats.some((repeat) => repeat.judge !== void 0 && repeat.judge !== null));
|
|
4127
|
+
}
|
|
4128
|
+
/**
|
|
4129
|
+
* Did this file's run CHECK tool calls at all — i.e. does any case declare
|
|
4130
|
+
* `required_tools`? The tool sibling of {@link anyJudged}, and the same rule: a run that
|
|
4131
|
+
* required nothing has not been found complete, so its `toolsFlagged` count is OMITTED
|
|
4132
|
+
* rather than printed as a reassuring `0`.
|
|
4133
|
+
*/
|
|
4134
|
+
function anyToolsRequired(result) {
|
|
4135
|
+
return result.cases.some((evalCase) => isTutorCase(evalCase) && evalCase.requiredTools !== void 0);
|
|
4136
|
+
}
|
|
4137
|
+
/**
|
|
3577
4138
|
* The CI gate: every file valid, and not a single failed, errored, or skipped CASE —
|
|
3578
4139
|
* an aborted (and therefore incomplete) run must never read as a pass. The single
|
|
3579
4140
|
* source of truth for the exit code AND for `EvalBatchResult.passed`.
|
|
4141
|
+
*
|
|
4142
|
+
* `unstable`, `feedbackFlagged`, `toolsFlagged` and `judgeErrored` deliberately do NOT
|
|
4143
|
+
* appear here: all four are reported, none gates. (Gating is per-KIND policy, not a
|
|
4144
|
+
* property of judge
|
|
4145
|
+
* results — and BOTH shipped kinds are report-only. For the tutor kind that is the whole
|
|
4146
|
+
* policy: its exit code reflects RUN HEALTH only, so a flagged conversation changes
|
|
4147
|
+
* nothing, and the Markdown report is the deliverable — docs/cli-eval.md.)
|
|
3580
4148
|
*/
|
|
3581
4149
|
function batchPassed(batch) {
|
|
3582
4150
|
return batch.totals.invalid === 0 && batch.totals.failed === 0 && batch.totals.errored === 0 && batch.totals.skipped === 0;
|
|
@@ -3602,6 +4170,9 @@ const cliFetcher = async (url) => {
|
|
|
3602
4170
|
};
|
|
3603
4171
|
//#endregion
|
|
3604
4172
|
//#region src/format.ts
|
|
4173
|
+
function flaggedLabel(kind) {
|
|
4174
|
+
return kind === "tutor" ? "flagged responses" : "flagged feedback";
|
|
4175
|
+
}
|
|
3605
4176
|
const useColor = process.stdout.isTTY && !process.env.NO_COLOR;
|
|
3606
4177
|
const paint = (code, s) => useColor ? `\x1b[${code}m${s}\x1b[0m` : s;
|
|
3607
4178
|
const green = (s) => paint("32", s);
|
|
@@ -3750,17 +4321,19 @@ function formatCodingResult(result, source) {
|
|
|
3750
4321
|
return lines.join("\n");
|
|
3751
4322
|
}
|
|
3752
4323
|
/**
|
|
3753
|
-
* Renderer for
|
|
3754
|
-
*
|
|
3755
|
-
* the
|
|
4324
|
+
* Renderer for an eval check (`--kind eval`). An eval describes an activity it does not
|
|
4325
|
+
* contain, so the summary names the resolved target and the size of the run the file
|
|
4326
|
+
* would produce — in the units of its own kind.
|
|
3756
4327
|
*/
|
|
3757
4328
|
function formatEvalResult(result, source) {
|
|
3758
4329
|
if (!result.ok) return renderFailureAndWarnings(result, "eval", source);
|
|
3759
4330
|
const lines = [green(`✔ Valid eval`) + dim(` — ${source}`)];
|
|
3760
4331
|
lines.push(` id: ${result.evalFile.id}`);
|
|
4332
|
+
lines.push(` kind: ${result.kind}`);
|
|
3761
4333
|
lines.push(` target: ${result.targetUrl}`);
|
|
3762
|
-
lines.push(`
|
|
3763
|
-
lines.push(`
|
|
4334
|
+
if (result.kind === "tutor") lines.push(` conversations: ${result.caseCount}`);
|
|
4335
|
+
else lines.push(` questions: ${result.evalFile.questions.length} cases: ${result.caseCount}`);
|
|
4336
|
+
lines.push(` ${result.kind} model: ${result.llm.provider} / ${result.llm.model}`);
|
|
3764
4337
|
if (result.warnings.length) {
|
|
3765
4338
|
lines.push("");
|
|
3766
4339
|
lines.push(yellow(`${result.warnings.length} warning(s):`));
|
|
@@ -3789,14 +4362,25 @@ function formatUsageLine(usage) {
|
|
|
3789
4362
|
const cached = usage.cachedInput ? ` (${formatTokenCount(usage.cachedInput)} cached)` : "";
|
|
3790
4363
|
return `tokens: ${formatTokenCount(usage.input)} in${cached} / ${formatTokenCount(usage.output)} out`;
|
|
3791
4364
|
}
|
|
3792
|
-
/**
|
|
4365
|
+
/** The first error message a case's repeats recorded, for a one-line mismatch row. */
|
|
4366
|
+
function firstErrorMessage(repeats, fallback) {
|
|
4367
|
+
const first = repeats.find((row) => row.error !== void 0)?.error;
|
|
4368
|
+
return typeof first === "object" && first !== null && "message" in first ? String(first.message) : fallback;
|
|
4369
|
+
}
|
|
4370
|
+
/**
|
|
4371
|
+
* One line per non-passing case: `question#index expected … got … "answer…"` for a quiz,
|
|
4372
|
+
* `#n title — error …` for a tutor conversation (which has no verdict to compare).
|
|
4373
|
+
*/
|
|
3793
4374
|
function mismatchLines(result) {
|
|
3794
4375
|
return result.mismatches.map((c) => {
|
|
4376
|
+
if (isTutorCase(c)) {
|
|
4377
|
+
const head = `#${c.index + 1}${c.title ? ` ${c.title}` : ""}`;
|
|
4378
|
+
return ` ${red("✗")} ${head} ${red("error")} ${dim(firstErrorMessage(c.repeats, "no response"))}`;
|
|
4379
|
+
}
|
|
3795
4380
|
const head = `${c.questionId}#${c.answerIndex}`;
|
|
3796
4381
|
const expected = c.expected.join("|");
|
|
3797
4382
|
if (c.status === "errored") {
|
|
3798
|
-
const
|
|
3799
|
-
const message = typeof first === "object" && first !== null && "message" in first ? String(first.message) : "no verdict";
|
|
4383
|
+
const message = firstErrorMessage(c.repeats, "no verdict");
|
|
3800
4384
|
return ` ${red("✗")} ${head} expected ${expected} got ${red("error")} ${dim(message)}`;
|
|
3801
4385
|
}
|
|
3802
4386
|
return ` ${red("✗")} ${head} expected ${expected} got ${red(c.verdict ?? "?")}` + dim(` "${snippet(c.answer)}"`);
|
|
@@ -3817,18 +4401,26 @@ function formatEvalReport(result, source) {
|
|
|
3817
4401
|
];
|
|
3818
4402
|
const llm = result.llm.overrides ? `${result.llm.overrides.provider} / ${result.llm.overrides.model} ${yellow("→")} ${result.llm.provider} / ${result.llm.model} ${yellow("(override)")}` : `${result.llm.provider} / ${result.llm.model}`;
|
|
3819
4403
|
lines.push(` llm: ${llm}`);
|
|
3820
|
-
|
|
4404
|
+
const judge = result.llm.judge;
|
|
4405
|
+
if (judge && (judge.provider !== result.llm.provider || judge.model !== result.llm.model)) lines.push(` judge llm: ${judge.provider} / ${judge.model}${judge.overridden ? ` ${yellow("(override)")}` : ""}`);
|
|
4406
|
+
const unit = result.kind === "tutor" ? "conversation" : "case";
|
|
4407
|
+
const generation = result.kind === "tutor" ? "generation" : "grading";
|
|
4408
|
+
lines.push(` ${unit}s: ${totals.cases} × ${totals.repeats} repeat(s) = ${totals.calls} ${generation} call(s)` + (result.judging === "off" ? "" : ` + ${totals.calls} judge call(s)`));
|
|
3821
4409
|
if (result.aborted) {
|
|
3822
4410
|
lines.push("");
|
|
3823
4411
|
lines.push(red(`Run aborted: ${result.aborted.message}`));
|
|
3824
4412
|
}
|
|
4413
|
+
if (result.judging === "degraded") {
|
|
4414
|
+
lines.push("");
|
|
4415
|
+
lines.push(yellow("Feedback judging stopped after repeated judge failures — grading was unaffected."));
|
|
4416
|
+
}
|
|
3825
4417
|
if (result.mismatches.length) {
|
|
3826
4418
|
lines.push("");
|
|
3827
4419
|
lines.push(red(`${result.mismatches.length} mismatch(es):`));
|
|
3828
4420
|
lines.push(...mismatchLines(result));
|
|
3829
4421
|
}
|
|
3830
4422
|
lines.push("");
|
|
3831
|
-
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}`) : ""));
|
|
4423
|
+
lines.push((result.kind === "tutor" ? ` ok: ${totals.cases - totals.errored - totals.skipped} errored: ${totals.errored}` : ` passed: ${totals.passed} failed: ${totals.failed} errored: ${totals.errored}`) + (totals.skipped ? red(` skipped: ${totals.skipped} (run aborted)`) : "") + (totals.unstable ? dim(` unstable: ${totals.unstable}`) : "") + (!anyJudged(result) ? "" : totals.feedbackFlagged ? yellow(` ${flaggedLabel(result.kind)}: ${totals.feedbackFlagged}`) : dim(` ${flaggedLabel(result.kind)}: 0`)) + (!anyToolsRequired(result) ? "" : totals.toolsFlagged ? yellow(` missing tool calls: ${totals.toolsFlagged}`) : dim(" missing tool calls: 0")) + (totals.judgeErrored ? dim(` judge errors: ${totals.judgeErrored}`) : ""));
|
|
3832
4424
|
const tokens = formatUsageLine(totals.usage);
|
|
3833
4425
|
if (tokens) lines.push(dim(` ${tokens}`));
|
|
3834
4426
|
if (result.confusion.length) {
|
|
@@ -3836,9 +4428,11 @@ function formatEvalReport(result, source) {
|
|
|
3836
4428
|
lines.push(" confusion (expected → got):");
|
|
3837
4429
|
for (const row of result.confusion) lines.push(` ${row.expected} → ${row.got}: ${row.count}`);
|
|
3838
4430
|
}
|
|
3839
|
-
|
|
3840
|
-
|
|
3841
|
-
|
|
4431
|
+
if (result.kind !== "tutor") {
|
|
4432
|
+
const { count, denominator, rate } = result.falseCorrect;
|
|
4433
|
+
lines.push("");
|
|
4434
|
+
lines.push(` false-correct: ${count}/${denominator}` + (denominator ? ` (${(rate * 100).toFixed(1)}%)` : ""));
|
|
4435
|
+
}
|
|
3842
4436
|
return lines.join("\n");
|
|
3843
4437
|
}
|
|
3844
4438
|
/**
|
|
@@ -3858,11 +4452,15 @@ function formatEvalBatchReport(batch) {
|
|
|
3858
4452
|
}
|
|
3859
4453
|
const t = file.result.totals;
|
|
3860
4454
|
const mark = t.failed === 0 && t.errored === 0 && t.skipped === 0 ? green("✔") : red("✗");
|
|
3861
|
-
|
|
4455
|
+
const counts = file.result.kind === "tutor" ? `${t.cases} conversation(s), ${t.cases - t.errored - t.skipped} ok, ${t.errored} errored` : `${t.cases} case(s), ${t.passed} passed, ${t.failed} failed, ${t.errored} errored`;
|
|
4456
|
+
lines.push(` ${mark} ${name}: ${counts}` + (t.skipped ? red(`, ${t.skipped} skipped`) : "") + (t.unstable ? dim(`, ${t.unstable} unstable`) : "") + (!anyJudged(file.result) ? "" : t.feedbackFlagged ? yellow(`, ${t.feedbackFlagged} flagged`) : dim(", 0 flagged")) + (!anyToolsRequired(file.result) ? "" : t.toolsFlagged ? yellow(`, ${t.toolsFlagged} missing tool calls`) : dim(", 0 missing tool calls")));
|
|
3862
4457
|
}
|
|
3863
4458
|
const g = batch.totals;
|
|
4459
|
+
const judged = batch.files.some((file) => file.result && anyJudged(file.result));
|
|
4460
|
+
const toolChecked = batch.files.some((file) => file.result && anyToolsRequired(file.result));
|
|
3864
4461
|
lines.push("");
|
|
3865
|
-
lines.push(` TOTAL: ${g.cases} case(s), ${g.passed} passed, ${g.failed} failed, ${g.errored} errored` + (g.skipped ? red(`, ${g.skipped} skipped`) : "") + (g.invalid ? red(`, ${g.invalid} invalid file(s)`) : ""));
|
|
4462
|
+
lines.push(` TOTAL: ${g.cases} case(s), ${g.passed} passed, ${g.failed} failed, ${g.errored} errored` + (g.skipped ? red(`, ${g.skipped} skipped`) : "") + (judged ? g.feedbackFlagged ? yellow(`, ${g.feedbackFlagged} flagged`) : dim(", 0 flagged") : "") + (toolChecked ? g.toolsFlagged ? yellow(`, ${g.toolsFlagged} missing tool calls`) : dim(", 0 missing tool calls") : "") + (g.invalid ? red(`, ${g.invalid} invalid file(s)`) : ""));
|
|
4463
|
+
if (batch.files.some((file) => file.result?.judging === "degraded")) lines.push(yellow(" Feedback judging stopped mid-run after repeated judge failures — files without a flagged count were graded but not judged."));
|
|
3866
4464
|
const tokens = formatUsageLine(g.usage);
|
|
3867
4465
|
if (tokens) lines.push(dim(` ${tokens}`));
|
|
3868
4466
|
for (const file of batch.files) {
|
|
@@ -3937,6 +4535,17 @@ function llmText(llm) {
|
|
|
3937
4535
|
const effective = `${llm.provider} / ${llm.model}`;
|
|
3938
4536
|
return llm.overrides ? `${llm.overrides.provider} / ${llm.overrides.model} → ${effective} (override)` : effective;
|
|
3939
4537
|
}
|
|
4538
|
+
/**
|
|
4539
|
+
* The judge's pair, but ONLY when it differs from the grading pair — a judge line that
|
|
4540
|
+
* merely repeats the grader would be noise, while a differing one is essential: judge
|
|
4541
|
+
* strictness varies by model, so two reports are comparable only when it matches.
|
|
4542
|
+
*/
|
|
4543
|
+
function judgeLlmText(llm) {
|
|
4544
|
+
const judge = llm.judge;
|
|
4545
|
+
if (!judge) return void 0;
|
|
4546
|
+
if (judge.provider === llm.provider && judge.model === llm.model) return void 0;
|
|
4547
|
+
return `${judge.provider} / ${judge.model}${judge.overridden ? " (override)" : ""}`;
|
|
4548
|
+
}
|
|
3940
4549
|
/** `15,420 / 12,300 / 2,810`, or an em dash when nothing was reported. */
|
|
3941
4550
|
function usageCell(usage) {
|
|
3942
4551
|
if (usage.input === 0 && usage.cachedInput === 0 && usage.output === 0) return "—";
|
|
@@ -3965,6 +4574,7 @@ const OVERVIEW_HEADER = [
|
|
|
3965
4574
|
"Errored",
|
|
3966
4575
|
"Skipped",
|
|
3967
4576
|
"Unstable",
|
|
4577
|
+
"Flagged",
|
|
3968
4578
|
"False-correct",
|
|
3969
4579
|
"Tokens (in / cached / out)"
|
|
3970
4580
|
];
|
|
@@ -3984,6 +4594,7 @@ function overview(batch) {
|
|
|
3984
4594
|
"---:",
|
|
3985
4595
|
"---:",
|
|
3986
4596
|
"---:",
|
|
4597
|
+
"---:",
|
|
3987
4598
|
"---:"
|
|
3988
4599
|
])];
|
|
3989
4600
|
for (const file of batch.files) {
|
|
@@ -3999,26 +4610,30 @@ function overview(batch) {
|
|
|
3999
4610
|
"—",
|
|
4000
4611
|
"—",
|
|
4001
4612
|
"—",
|
|
4613
|
+
"—",
|
|
4002
4614
|
"—"
|
|
4003
4615
|
]));
|
|
4004
4616
|
continue;
|
|
4005
4617
|
}
|
|
4006
4618
|
const t = file.result.totals;
|
|
4619
|
+
const tutor = file.result.kind === "tutor";
|
|
4007
4620
|
lines.push(row([
|
|
4008
4621
|
`${file.passed ? "✅" : "❌"} ${name}`,
|
|
4009
4622
|
`\`${cell(file.result.id)}\``,
|
|
4010
4623
|
count(t.cases),
|
|
4011
|
-
count(t.passed),
|
|
4012
|
-
count(t.failed),
|
|
4624
|
+
tutor ? "—" : count(t.passed),
|
|
4625
|
+
tutor ? "—" : count(t.failed),
|
|
4013
4626
|
count(t.errored),
|
|
4014
4627
|
count(t.skipped),
|
|
4015
|
-
count(t.unstable),
|
|
4016
|
-
|
|
4628
|
+
tutor ? "—" : count(t.unstable),
|
|
4629
|
+
anyJudged(file.result) ? count(t.feedbackFlagged) : "—",
|
|
4630
|
+
tutor ? "—" : falseCorrectCell(file.result),
|
|
4017
4631
|
usageCell(t.usage)
|
|
4018
4632
|
]));
|
|
4019
4633
|
}
|
|
4020
4634
|
if (batch.files.length > 1) {
|
|
4021
4635
|
const g = batch.totals;
|
|
4636
|
+
const judged = batch.files.some((file) => file.result && anyJudged(file.result));
|
|
4022
4637
|
lines.push(row([
|
|
4023
4638
|
"**TOTAL**",
|
|
4024
4639
|
g.invalid ? `${count(g.invalid)} invalid` : "",
|
|
@@ -4028,6 +4643,7 @@ function overview(batch) {
|
|
|
4028
4643
|
`**${count(g.errored)}**`,
|
|
4029
4644
|
`**${count(g.skipped)}**`,
|
|
4030
4645
|
`**${count(g.unstable)}**`,
|
|
4646
|
+
judged ? `**${count(g.feedbackFlagged)}**` : "—",
|
|
4031
4647
|
"",
|
|
4032
4648
|
`**${usageCell(g.usage)}**`
|
|
4033
4649
|
]));
|
|
@@ -4048,6 +4664,13 @@ function verdictSummary(evalCase) {
|
|
|
4048
4664
|
function needsDetail(evalCase) {
|
|
4049
4665
|
return evalCase.status === "failed" || evalCase.status === "errored" || evalCase.unstable;
|
|
4050
4666
|
}
|
|
4667
|
+
/** The "**Question** / **Golden answer**" intro every case detail section opens with. */
|
|
4668
|
+
function questionAndAnswer(evalCase, questionText) {
|
|
4669
|
+
const lines = [];
|
|
4670
|
+
if (questionText) lines.push("**Question**", "", quote(questionText), "");
|
|
4671
|
+
lines.push("**Golden answer**", "", quote(evalCase.answer), "");
|
|
4672
|
+
return lines;
|
|
4673
|
+
}
|
|
4051
4674
|
/**
|
|
4052
4675
|
* One case's section: the question it belongs to, the golden answer, and what the
|
|
4053
4676
|
* grader said — plus every repeat when they disagreed (the `--repeats` signal is
|
|
@@ -4058,16 +4681,7 @@ function caseSection(evalCase, questionText) {
|
|
|
4058
4681
|
const unstable = evalCase.unstable ? " *(unstable)*" : "";
|
|
4059
4682
|
lines.push(`### \`${evalCase.questionId}\` #${evalCase.answerIndex} — ${verdictSummary(evalCase)}${unstable}`);
|
|
4060
4683
|
lines.push("");
|
|
4061
|
-
|
|
4062
|
-
lines.push("**Question**");
|
|
4063
|
-
lines.push("");
|
|
4064
|
-
lines.push(quote(questionText));
|
|
4065
|
-
lines.push("");
|
|
4066
|
-
}
|
|
4067
|
-
lines.push("**Golden answer**");
|
|
4068
|
-
lines.push("");
|
|
4069
|
-
lines.push(quote(evalCase.answer));
|
|
4070
|
-
lines.push("");
|
|
4684
|
+
lines.push(...questionAndAnswer(evalCase, questionText));
|
|
4071
4685
|
const graded = evalCase.repeats.filter((r) => r.got !== void 0);
|
|
4072
4686
|
const disagreed = new Set(graded.map((r) => r.got)).size > 1;
|
|
4073
4687
|
if (evalCase.repeats.length > 1 && (disagreed || graded.length !== evalCase.repeats.length)) {
|
|
@@ -4097,6 +4711,136 @@ function caseSection(evalCase, questionText) {
|
|
|
4097
4711
|
}
|
|
4098
4712
|
return lines;
|
|
4099
4713
|
}
|
|
4714
|
+
/**
|
|
4715
|
+
* The "Flagged feedback" section: what the LLM judge found wrong with the TEXT the
|
|
4716
|
+
* grader wrote, per case, with each flagged repeat's verdict and feedback quoted verbatim
|
|
4717
|
+
* and the judge's issues as `criterion — note` items.
|
|
4718
|
+
*
|
|
4719
|
+
* Separate from the verdict sections on purpose — these cases usually PASSED (the verdict
|
|
4720
|
+
* was right, the wording was not), and mixing them into the mismatch list would suggest
|
|
4721
|
+
* the run failed on them. Empty when the file has no flags.
|
|
4722
|
+
*/
|
|
4723
|
+
function flaggedSection(result, questionText) {
|
|
4724
|
+
const flagged = result.cases.filter((evalCase) => evalCase.feedbackFlagged && !isTutorCase(evalCase));
|
|
4725
|
+
if (flagged.length === 0) return [];
|
|
4726
|
+
const lines = ["### Flagged feedback", ""];
|
|
4727
|
+
lines.push("_An LLM judge audited each feedback text against the very grading prompt it was written under. Reported only — flagged feedback never fails a run._");
|
|
4728
|
+
lines.push("");
|
|
4729
|
+
for (const evalCase of flagged) {
|
|
4730
|
+
lines.push(`#### \`${evalCase.questionId}\` #${evalCase.answerIndex}`);
|
|
4731
|
+
lines.push("");
|
|
4732
|
+
lines.push(...questionAndAnswer(evalCase, questionText.get(evalCase.questionId)));
|
|
4733
|
+
for (const repeat of evalCase.repeats) {
|
|
4734
|
+
const issues = repeat.judge?.issues ?? [];
|
|
4735
|
+
if (issues.length === 0) continue;
|
|
4736
|
+
lines.push(`**Repeat #${repeat.repeatIndex + 1} — \`${repeat.got ?? "?"}\`**`);
|
|
4737
|
+
lines.push("");
|
|
4738
|
+
lines.push(quote(repeat.feedback ?? ""));
|
|
4739
|
+
lines.push("");
|
|
4740
|
+
for (const issue of issues) lines.push(`- \`${cell(issue.criterion)}\` — ${inline(issue.note)}`);
|
|
4741
|
+
lines.push("");
|
|
4742
|
+
}
|
|
4743
|
+
}
|
|
4744
|
+
return lines;
|
|
4745
|
+
}
|
|
4746
|
+
/**
|
|
4747
|
+
* A tutor case's stable heading: the teacher's `title` when it has one, otherwise its
|
|
4748
|
+
* 1-based index plus an excerpt of the FIRST student line — enough to recognise the case
|
|
4749
|
+
* in a report without opening the eval file.
|
|
4750
|
+
*/
|
|
4751
|
+
function tutorCaseLabel(evalCase) {
|
|
4752
|
+
if (evalCase.title) return `#${evalCase.index + 1} ${cell(evalCase.title)}`;
|
|
4753
|
+
const firstStudent = evalCase.conversation.find((turn) => "student" in turn);
|
|
4754
|
+
const excerpt = firstStudent && "student" in firstStudent ? inline(firstStudent.student) : "";
|
|
4755
|
+
const short = excerpt.length > 60 ? `${excerpt.slice(0, 59)}…` : excerpt;
|
|
4756
|
+
return short ? `#${evalCase.index + 1} — ${short}` : `#${evalCase.index + 1}`;
|
|
4757
|
+
}
|
|
4758
|
+
/** The scripted conversation as one labeled, verbatim blockquote per turn. */
|
|
4759
|
+
function conversationBlock(evalCase) {
|
|
4760
|
+
const lines = ["**Conversation**", ""];
|
|
4761
|
+
for (const turn of evalCase.conversation) {
|
|
4762
|
+
const role = "student" in turn ? "student" : "tutor";
|
|
4763
|
+
const text = "student" in turn ? turn.student : turn.tutor;
|
|
4764
|
+
lines.push(`*${role}*`, "", quote(text), "");
|
|
4765
|
+
}
|
|
4766
|
+
return lines;
|
|
4767
|
+
}
|
|
4768
|
+
/** One ERRORED tutor case: what was asked, and why nothing came back. */
|
|
4769
|
+
function tutorErrorSection(evalCase) {
|
|
4770
|
+
const lines = [`### ${tutorCaseLabel(evalCase)} — error`, ""];
|
|
4771
|
+
lines.push(...conversationBlock(evalCase));
|
|
4772
|
+
const failure = evalCase.repeats.find((r) => r.error !== void 0);
|
|
4773
|
+
if (failure) lines.push("**Error**", "", quote(errorMessage(failure.error)), "");
|
|
4774
|
+
return lines;
|
|
4775
|
+
}
|
|
4776
|
+
/** `random_number, random_number` — a repeat's tool calls, or `(none)` when it made none. */
|
|
4777
|
+
function toolCallList(toolCalls) {
|
|
4778
|
+
return toolCalls && toolCalls.length > 0 ? toolCalls.map((name) => `\`${name}\``).join(", ") : "(none)";
|
|
4779
|
+
}
|
|
4780
|
+
/**
|
|
4781
|
+
* The tutor kind's "Missing tool calls" section: every case that declares `required_tools`
|
|
4782
|
+
* and had at least one repeat skip one. Per case the required list, what each offending
|
|
4783
|
+
* repeat actually called, and which tools were missing there.
|
|
4784
|
+
*
|
|
4785
|
+
* Its own section rather than a column, for the same reason "Flagged responses" is one:
|
|
4786
|
+
* these cases are `ok` and the run PASSED — a missing tool call is a note about the
|
|
4787
|
+
* tutor's behavior, never a failure. Cases whose tools all ran stay out entirely; the
|
|
4788
|
+
* `--json` report carries every repeat's `toolCalls` for anyone who wants them.
|
|
4789
|
+
*/
|
|
4790
|
+
function tutorMissingToolsSection(result) {
|
|
4791
|
+
const flagged = result.cases.filter((evalCase) => isTutorCase(evalCase) && evalCase.toolsFlagged);
|
|
4792
|
+
if (flagged.length === 0) return [];
|
|
4793
|
+
const lines = ["### Missing tool calls", ""];
|
|
4794
|
+
lines.push("_These cases require a tool the tutor did not call in every run. Reported only — a missing tool call never fails a run, and tools beyond the required ones are fine._");
|
|
4795
|
+
lines.push("");
|
|
4796
|
+
for (const evalCase of flagged) {
|
|
4797
|
+
lines.push(`#### ${tutorCaseLabel(evalCase)}`);
|
|
4798
|
+
lines.push("");
|
|
4799
|
+
lines.push(`**Required** ${toolCallList(evalCase.requiredTools)}`);
|
|
4800
|
+
lines.push("");
|
|
4801
|
+
for (const repeat of evalCase.repeats) {
|
|
4802
|
+
const missing = repeat.missingTools ?? [];
|
|
4803
|
+
if (missing.length === 0) continue;
|
|
4804
|
+
lines.push(`- Repeat #${repeat.repeatIndex + 1} — missing ${toolCallList(missing)}; called ${toolCallList(repeat.toolCalls)}`);
|
|
4805
|
+
}
|
|
4806
|
+
lines.push("");
|
|
4807
|
+
}
|
|
4808
|
+
return lines;
|
|
4809
|
+
}
|
|
4810
|
+
/**
|
|
4811
|
+
* The tutor kind's "Flagged responses" section — the report's actual deliverable: per
|
|
4812
|
+
* flagged case the scripted conversation, the teacher's expectations when it states any,
|
|
4813
|
+
* and each flagged repeat's GENERATED RESPONSE verbatim followed by the judge's issues.
|
|
4814
|
+
*
|
|
4815
|
+
* Clean cases stay out entirely (their generated texts are in the `--json` report), and
|
|
4816
|
+
* a flag never means the run failed — the tutor kind is report-only.
|
|
4817
|
+
*/
|
|
4818
|
+
function tutorFlaggedSection(result) {
|
|
4819
|
+
const flagged = result.cases.filter((evalCase) => isTutorCase(evalCase) && evalCase.feedbackFlagged);
|
|
4820
|
+
if (flagged.length === 0) return [];
|
|
4821
|
+
const lines = ["### Flagged responses", ""];
|
|
4822
|
+
lines.push("_An LLM judge audited each generated response against the tutor's own system prompt and, where the case states any, the teacher's expectations. Reported only — a flagged response never fails a run._");
|
|
4823
|
+
lines.push("");
|
|
4824
|
+
for (const evalCase of flagged) {
|
|
4825
|
+
lines.push(`#### ${tutorCaseLabel(evalCase)}`);
|
|
4826
|
+
lines.push("");
|
|
4827
|
+
lines.push(...conversationBlock(evalCase));
|
|
4828
|
+
if (evalCase.gradingInstructions) lines.push("**Expectations for this case**", "", quote(evalCase.gradingInstructions), "");
|
|
4829
|
+
if (evalCase.requiredTools) lines.push(`**Required tools** ${toolCallList(evalCase.requiredTools)}`, "");
|
|
4830
|
+
for (const repeat of evalCase.repeats) {
|
|
4831
|
+
const issues = repeat.judge?.issues ?? [];
|
|
4832
|
+
if (issues.length === 0) continue;
|
|
4833
|
+
lines.push(`**Generated response — repeat #${repeat.repeatIndex + 1}**`);
|
|
4834
|
+
lines.push("");
|
|
4835
|
+
lines.push(quote(repeat.text ?? ""));
|
|
4836
|
+
lines.push("");
|
|
4837
|
+
if (repeat.toolCalls && (repeat.toolCalls.length > 0 || evalCase.requiredTools)) lines.push(`*tool calls: ${toolCallList(repeat.toolCalls)}*`, "");
|
|
4838
|
+
for (const issue of issues) lines.push(`- \`${cell(issue.criterion)}\` — ${inline(issue.note)}`);
|
|
4839
|
+
lines.push("");
|
|
4840
|
+
}
|
|
4841
|
+
}
|
|
4842
|
+
return lines;
|
|
4843
|
+
}
|
|
4100
4844
|
/** One file's details section, or `[]` when the file has nothing to report. */
|
|
4101
4845
|
function fileDetails(file) {
|
|
4102
4846
|
const name = shortSource(file.source);
|
|
@@ -4105,29 +4849,34 @@ function fileDetails(file) {
|
|
|
4105
4849
|
return [
|
|
4106
4850
|
`## ${cell(name)} — invalid`,
|
|
4107
4851
|
"",
|
|
4108
|
-
"This file was not
|
|
4852
|
+
"This file was not run; fix the problems below and run it again.",
|
|
4109
4853
|
"",
|
|
4110
4854
|
...errors.map((issue) => `- \`${cell(issue.code)}\` — ${inline(issue.message)}`),
|
|
4111
4855
|
""
|
|
4112
4856
|
];
|
|
4113
4857
|
}
|
|
4114
4858
|
const result = file.result;
|
|
4859
|
+
const tutor = result.kind === "tutor";
|
|
4115
4860
|
const detailed = result.cases.filter(needsDetail);
|
|
4116
4861
|
const skipped = result.totals.skipped;
|
|
4117
|
-
if (detailed.length === 0 && skipped === 0 && !result.aborted) return [];
|
|
4118
4862
|
const questionText = new Map(result.questions.map((question) => [question.id, question.text]));
|
|
4863
|
+
const flagged = tutor ? tutorFlaggedSection(result) : flaggedSection(result, questionText);
|
|
4864
|
+
const missingTools = tutor ? tutorMissingToolsSection(result) : [];
|
|
4865
|
+
if (detailed.length === 0 && skipped === 0 && !result.aborted && flagged.length === 0 && missingTools.length === 0) return [];
|
|
4119
4866
|
const lines = [`## ${cell(name)} — \`${cell(result.id)}\``, ""];
|
|
4120
4867
|
if (result.aborted) {
|
|
4121
4868
|
lines.push("> [!WARNING]");
|
|
4122
4869
|
lines.push(`> The run was aborted: ${inline(result.aborted.message)}`);
|
|
4123
4870
|
lines.push("");
|
|
4124
4871
|
}
|
|
4125
|
-
for (const evalCase of detailed) lines.push(...caseSection(evalCase, questionText.get(evalCase.questionId)));
|
|
4872
|
+
for (const evalCase of detailed) lines.push(...isTutorCase(evalCase) ? tutorErrorSection(evalCase) : caseSection(evalCase, questionText.get(evalCase.questionId)));
|
|
4126
4873
|
if (skipped > 0) {
|
|
4127
4874
|
const reason = result.aborted ? ` (${inline(result.aborted.message)})` : "";
|
|
4128
|
-
lines.push(`**${count(skipped)} case(s) were never attempted**${reason} — the run is incomplete, so it cannot pass.`);
|
|
4875
|
+
lines.push(`**${count(skipped)} ${tutor ? "conversation" : "case"}(s) were never attempted**${reason} — the run is incomplete, so it cannot pass.`);
|
|
4129
4876
|
lines.push("");
|
|
4130
4877
|
}
|
|
4878
|
+
lines.push(...missingTools);
|
|
4879
|
+
lines.push(...flagged);
|
|
4131
4880
|
return lines;
|
|
4132
4881
|
}
|
|
4133
4882
|
/**
|
|
@@ -4141,25 +4890,34 @@ function renderEvalMarkdownReport(batch, meta) {
|
|
|
4141
4890
|
lines.push(`- **Generated** ${timestamp(meta.generatedAt)} · novedu-cli ${meta.cliVersion}`);
|
|
4142
4891
|
const llms = [...new Set(batch.files.filter((f) => f.result).map((f) => llmText(f.result.llm)))];
|
|
4143
4892
|
for (const llm of llms) lines.push(`- **LLM** ${llm}`);
|
|
4893
|
+
const judges = [...new Set(batch.files.map((file) => file.result ? judgeLlmText(file.result.llm) : void 0).filter((text) => text !== void 0))];
|
|
4894
|
+
for (const judge of judges) lines.push(`- **Feedback judge** ${judge}`);
|
|
4144
4895
|
lines.push(`- **Run** ${count(batch.totals.files)} file(s), ${count(batch.totals.cases)} case(s) × ${count(meta.repeats)} repeat(s), concurrency ${count(meta.concurrency)}`);
|
|
4896
|
+
if (batch.files.some((file) => file.result && anyToolsRequired(file.result))) lines.push(`- **Missing tool calls** ${count(batch.totals.toolsFlagged)} case(s) did not call a required tool in every run — reported only, never a failure`);
|
|
4145
4897
|
const tokens = batch.totals.usage;
|
|
4146
|
-
if (tokens.input || tokens.cachedInput || tokens.output) lines.push(`- **Tokens** ${count(tokens.input)} in (${count(tokens.cachedInput)} cached) / ${count(tokens.output)} out — successful
|
|
4898
|
+
if (tokens.input || tokens.cachedInput || tokens.output) lines.push(`- **Tokens** ${count(tokens.input)} in (${count(tokens.cachedInput)} cached) / ${count(tokens.output)} out — successful calls only, so a lower bound`);
|
|
4147
4899
|
lines.push("");
|
|
4148
4900
|
if (batch.files.filter((file) => file.result?.aborted).length > 0) {
|
|
4149
4901
|
lines.push("> [!WARNING]");
|
|
4150
4902
|
lines.push(`> The run was ABORTED — ${count(batch.totals.skipped)} case(s) were never graded, so this report is incomplete.`);
|
|
4151
4903
|
lines.push("");
|
|
4152
4904
|
}
|
|
4905
|
+
const degradedAt = batch.files.find((file) => file.result?.judging === "degraded");
|
|
4906
|
+
if (degradedAt?.result) {
|
|
4907
|
+
lines.push("> [!WARNING]");
|
|
4908
|
+
lines.push(`> Feedback judging STOPPED during \`${cell(shortSource(degradedAt.source))}\` after 3 judge calls failed in a row — everything from there on was graded but NOT judged, and shows an em dash rather than a count in the Flagged column. Grading was unaffected.`);
|
|
4909
|
+
lines.push("");
|
|
4910
|
+
}
|
|
4153
4911
|
lines.push("## Overview");
|
|
4154
4912
|
lines.push("");
|
|
4155
4913
|
lines.push(...overview(batch));
|
|
4156
4914
|
lines.push("");
|
|
4157
4915
|
const details = batch.files.flatMap((file) => fileDetails(file));
|
|
4158
4916
|
if (details.length === 0) {
|
|
4159
|
-
lines.push("_Nothing else to report
|
|
4917
|
+
lines.push("_Nothing else to report. The `--json` report carries every case, including the clean ones._");
|
|
4160
4918
|
lines.push("");
|
|
4161
4919
|
} else {
|
|
4162
|
-
lines.push("_Below: only the mismatched, errored and unstable cases.
|
|
4920
|
+
lines.push("_Below: only the mismatched, errored and unstable cases, plus anything the judge flagged. Clean cases live in the `--json` report._");
|
|
4163
4921
|
lines.push("");
|
|
4164
4922
|
lines.push(...details);
|
|
4165
4923
|
}
|
|
@@ -4494,23 +5252,27 @@ function expandSources(args) {
|
|
|
4494
5252
|
duplicates
|
|
4495
5253
|
};
|
|
4496
5254
|
}
|
|
4497
|
-
/**
|
|
4498
|
-
|
|
4499
|
-
|
|
4500
|
-
|
|
4501
|
-
|
|
5255
|
+
/**
|
|
5256
|
+
* One provider/model pair from the flags: strictly BOTH-OR-NOTHING (the `effectiveLlm`
|
|
5257
|
+
* rule, docs/ai-models.md) with the provider checked against the known list. Shared by
|
|
5258
|
+
* `--llm-*` (the grading override) and `--judge-llm-*` (the judge's own pair), so the two
|
|
5259
|
+
* can never drift in wording or in strictness.
|
|
5260
|
+
*/
|
|
5261
|
+
function parsePair(flag, provider, model) {
|
|
5262
|
+
if (provider === void 0 && model === void 0) return { ok: true };
|
|
5263
|
+
if (provider === void 0 || model === void 0) return {
|
|
4502
5264
|
ok: false,
|
|
4503
|
-
message:
|
|
5265
|
+
message: `Pass --${flag}-provider and --${flag}-model together, or neither.`
|
|
4504
5266
|
};
|
|
4505
|
-
if (!LLM_PROVIDERS.includes(
|
|
5267
|
+
if (!LLM_PROVIDERS.includes(provider)) return {
|
|
4506
5268
|
ok: false,
|
|
4507
|
-
message: `Unknown
|
|
5269
|
+
message: `Unknown --${flag}-provider "${provider}": expected ${LLM_PROVIDERS.map((p) => `"${p}"`).join(" or ")}.`
|
|
4508
5270
|
};
|
|
4509
5271
|
return {
|
|
4510
5272
|
ok: true,
|
|
4511
5273
|
llm: {
|
|
4512
|
-
provider
|
|
4513
|
-
model
|
|
5274
|
+
provider,
|
|
5275
|
+
model
|
|
4514
5276
|
}
|
|
4515
5277
|
};
|
|
4516
5278
|
}
|
|
@@ -4580,6 +5342,156 @@ function makeGradeFn(server, llm) {
|
|
|
4580
5342
|
};
|
|
4581
5343
|
};
|
|
4582
5344
|
}
|
|
5345
|
+
/**
|
|
5346
|
+
* The `toolCalls: string[]` of a tutor 200, defensively: `undefined` when the field is
|
|
5347
|
+
* absent (a server too old to report tool calls — a distinction the runner MUST be able to
|
|
5348
|
+
* make), and non-string entries are dropped rather than breaking a run. Names only, in the
|
|
5349
|
+
* order the server sent them, duplicates kept.
|
|
5350
|
+
*/
|
|
5351
|
+
function parseToolCalls(value) {
|
|
5352
|
+
if (!Array.isArray(value)) return void 0;
|
|
5353
|
+
return value.filter((name) => typeof name === "string" && name !== "");
|
|
5354
|
+
}
|
|
5355
|
+
/**
|
|
5356
|
+
* The HTTP seam for ONE generated tutor turn, with the run's effective llm closed in —
|
|
5357
|
+
* the tutor kind's sibling of {@link makeGradeFn}, sharing its failure classification
|
|
5358
|
+
* exactly (5xx and network retryable, auth aborts the run, every other 4xx terminal).
|
|
5359
|
+
*/
|
|
5360
|
+
function makeRespondFn(server, llm) {
|
|
5361
|
+
return async ({ system, tools, messages }) => {
|
|
5362
|
+
const response = await performApiRequest({
|
|
5363
|
+
server,
|
|
5364
|
+
path: "/api/eval/respond",
|
|
5365
|
+
method: "POST",
|
|
5366
|
+
body: {
|
|
5367
|
+
llm,
|
|
5368
|
+
system,
|
|
5369
|
+
tools: [...tools],
|
|
5370
|
+
messages: messages.map((m) => ({ ...m }))
|
|
5371
|
+
},
|
|
5372
|
+
quiet: true
|
|
5373
|
+
});
|
|
5374
|
+
if (response.ok) {
|
|
5375
|
+
const payload = response.payload;
|
|
5376
|
+
if (typeof payload?.text === "string" && payload.text !== "") {
|
|
5377
|
+
const usage = parseUsage(payload?.usage);
|
|
5378
|
+
const toolCalls = parseToolCalls(payload?.toolCalls);
|
|
5379
|
+
return {
|
|
5380
|
+
ok: true,
|
|
5381
|
+
text: payload.text,
|
|
5382
|
+
...toolCalls ? { toolCalls } : {},
|
|
5383
|
+
...usage ? { usage } : {}
|
|
5384
|
+
};
|
|
5385
|
+
}
|
|
5386
|
+
return {
|
|
5387
|
+
ok: false,
|
|
5388
|
+
retryable: false,
|
|
5389
|
+
error: { message: "The server's response is not a generated tutor turn — it may not offer /api/eval/respond at all (does it run a Novedu version with tutor evals?). Check the target server, e.g. --server http://localhost:3000." }
|
|
5390
|
+
};
|
|
5391
|
+
}
|
|
5392
|
+
return {
|
|
5393
|
+
ok: false,
|
|
5394
|
+
retryable: response.status === void 0 || response.status >= 500,
|
|
5395
|
+
...response.authFailed ? { auth: true } : {},
|
|
5396
|
+
error: response.error
|
|
5397
|
+
};
|
|
5398
|
+
};
|
|
5399
|
+
}
|
|
5400
|
+
/**
|
|
5401
|
+
* The HTTP seam for ONE judge call, with the run's judge llm closed in. Mirrors
|
|
5402
|
+
* {@link makeGradeFn}'s failure classification, minus the auth branch: a judge failure
|
|
5403
|
+
* NEVER aborts the run — it degrades judging (see the runner's breaker) while the grading
|
|
5404
|
+
* half finishes untouched.
|
|
5405
|
+
*/
|
|
5406
|
+
function makeJudgeFn(server, llm) {
|
|
5407
|
+
return async ({ system, subject, criteria }) => {
|
|
5408
|
+
const response = await performApiRequest({
|
|
5409
|
+
server,
|
|
5410
|
+
path: "/api/eval/judge",
|
|
5411
|
+
method: "POST",
|
|
5412
|
+
body: {
|
|
5413
|
+
llm,
|
|
5414
|
+
system,
|
|
5415
|
+
subject,
|
|
5416
|
+
criteria: [...criteria]
|
|
5417
|
+
},
|
|
5418
|
+
quiet: true
|
|
5419
|
+
});
|
|
5420
|
+
if (response.ok) {
|
|
5421
|
+
const payload = response.payload;
|
|
5422
|
+
if (Array.isArray(payload?.issues)) {
|
|
5423
|
+
const issues = payload.issues.flatMap((entry) => {
|
|
5424
|
+
const { criterion, note } = entry ?? {};
|
|
5425
|
+
return typeof criterion === "string" ? [{
|
|
5426
|
+
criterion,
|
|
5427
|
+
note: typeof note === "string" ? note : ""
|
|
5428
|
+
}] : [];
|
|
5429
|
+
});
|
|
5430
|
+
const usage = parseUsage(payload?.usage);
|
|
5431
|
+
return {
|
|
5432
|
+
ok: true,
|
|
5433
|
+
issues,
|
|
5434
|
+
...usage ? { usage } : {}
|
|
5435
|
+
};
|
|
5436
|
+
}
|
|
5437
|
+
return {
|
|
5438
|
+
ok: false,
|
|
5439
|
+
retryable: false,
|
|
5440
|
+
error: { message: "The server's response is not a feedback judgment — it may not offer /api/eval/judge at all (does it run a Novedu version with the feedback judge?). Re-run with --no-judge-feedback to grade without judging." }
|
|
5441
|
+
};
|
|
5442
|
+
}
|
|
5443
|
+
return {
|
|
5444
|
+
ok: false,
|
|
5445
|
+
retryable: response.status === void 0 || response.status >= 500,
|
|
5446
|
+
error: response.error
|
|
5447
|
+
};
|
|
5448
|
+
};
|
|
5449
|
+
}
|
|
5450
|
+
/** Budget for the one version probe — a hung check must never hold up a run. */
|
|
5451
|
+
const VERSION_CHECK_TIMEOUT_MS = 5e3;
|
|
5452
|
+
/**
|
|
5453
|
+
* Warn when this CLI was not built from the same commit as the server it is about to
|
|
5454
|
+
* grade against. `eval` assembles every grading system prompt LOCALLY, from the `lib/**`
|
|
5455
|
+
* prompt builders frozen into this published CLI — so a stale binary can certify prompts
|
|
5456
|
+
* the server's activities no longer send. CLI and server live in one repo, which makes
|
|
5457
|
+
* the server's `cliVersion` (from `GET /api/version`, public and unauthenticated) exactly
|
|
5458
|
+
* the CLI release matching its bundled code.
|
|
5459
|
+
*
|
|
5460
|
+
* Deliberately EVAL-ONLY (prompt drift corrupts nothing else) and strictly advisory: one
|
|
5461
|
+
* fetch, no retry, never an abort, never an exit code, and never a byte on stdout — the
|
|
5462
|
+
* JSON output contract owns that stream. Unlike progress it prints off a TTY too: a CI
|
|
5463
|
+
* log is precisely where this warning has to survive. Absence is NOT silently forgiven —
|
|
5464
|
+
* an unreachable, non-JSON, non-2xx or `cliVersion`-less answer says so, because "could
|
|
5465
|
+
* not check" and "checked, fine" must not look the same.
|
|
5466
|
+
*/
|
|
5467
|
+
async function warnOnVersionMismatch(server) {
|
|
5468
|
+
const local = cliVersion();
|
|
5469
|
+
const unverifiable = (reason) => {
|
|
5470
|
+
process.stderr.write(`Warning: could not verify that this CLI (${local}) matches the server's — ${reason}. Locally assembled grading prompts may differ from what that server's activities run.
|
|
5471
|
+
`);
|
|
5472
|
+
};
|
|
5473
|
+
const base = resolveServerUrl(server);
|
|
5474
|
+
let payload;
|
|
5475
|
+
try {
|
|
5476
|
+
const response = await fetch(new URL("/api/version", base), { signal: AbortSignal.timeout(VERSION_CHECK_TIMEOUT_MS) });
|
|
5477
|
+
if (!response.ok) {
|
|
5478
|
+
unverifiable(`${base} answered HTTP ${response.status}`);
|
|
5479
|
+
return;
|
|
5480
|
+
}
|
|
5481
|
+
payload = await response.json();
|
|
5482
|
+
} catch (error) {
|
|
5483
|
+
unverifiable(`${base} did not answer (${error instanceof Error ? error.message : error})`);
|
|
5484
|
+
return;
|
|
5485
|
+
}
|
|
5486
|
+
const remote = payload?.cliVersion;
|
|
5487
|
+
if (typeof remote !== "string" || remote === "") {
|
|
5488
|
+
unverifiable(`${base} reports no CLI version (does it run a Novedu version that has one?)`);
|
|
5489
|
+
return;
|
|
5490
|
+
}
|
|
5491
|
+
if (remote === local) return;
|
|
5492
|
+
process.stderr.write(`Warning: this CLI is ${local} but the server was built with CLI ${remote} — locally assembled grading prompts may differ from what that server's activities run. Update: npm i -g @novedu/cli
|
|
5493
|
+
`);
|
|
5494
|
+
}
|
|
4583
5495
|
/** stderr progress, suppressed off a TTY so CI logs stay readable. */
|
|
4584
5496
|
function progressWriter(prefix) {
|
|
4585
5497
|
if (!process.stderr.isTTY) return void 0;
|
|
@@ -4588,16 +5500,42 @@ function progressWriter(prefix) {
|
|
|
4588
5500
|
};
|
|
4589
5501
|
}
|
|
4590
5502
|
/**
|
|
5503
|
+
* The off-a-TTY replacement for the spinner: ONE newline-terminated line per finished
|
|
5504
|
+
* file. The `\r` counter above is suppressed when stderr is redirected (it would fill a
|
|
5505
|
+
* log with carriage-return noise), which otherwise left a long batch printing nothing at
|
|
5506
|
+
* all between the scope banner and the final report — indistinguishable from a hang, and
|
|
5507
|
+
* an easy way to talk yourself into killing a healthy run. Coarse and greppable is
|
|
5508
|
+
* enough: it proves liveness and says which file the run reached.
|
|
5509
|
+
*
|
|
5510
|
+
* Deliberately no timings — a per-file duration invites extrapolating an ETA that the
|
|
5511
|
+
* model, the provider's load and `--concurrency` make unreliable.
|
|
5512
|
+
*/
|
|
5513
|
+
function writeFileDone(label, result) {
|
|
5514
|
+
const totals = result.totals;
|
|
5515
|
+
const counts = result.kind === "tutor" ? `${totals.cases} conversation(s), ${totals.cases - totals.errored - totals.skipped} ok, ${totals.errored} errored` : `${totals.cases} case(s), ${totals.passed} passed, ${totals.failed} failed, ${totals.errored} errored`;
|
|
5516
|
+
process.stderr.write(`${label}: ${counts}` + (totals.skipped ? `, ${totals.skipped} skipped` : "") + (anyJudged(result) ? `, ${totals.feedbackFlagged} flagged` : "") + "\n");
|
|
5517
|
+
}
|
|
5518
|
+
/**
|
|
4591
5519
|
* The command's core, exported for the unit tests. `seams` exists only so tests can
|
|
4592
5520
|
* shrink the retry backoff — the CLI itself never passes it (PoC parity: 4 attempts,
|
|
4593
5521
|
* 5 s linear).
|
|
4594
5522
|
*/
|
|
4595
5523
|
async function runEvalCommand(pathsOrUrls, options, seams = {}) {
|
|
4596
|
-
const override =
|
|
5524
|
+
const override = parsePair("llm", options.llmProvider, options.llmModel);
|
|
4597
5525
|
if (!override.ok) {
|
|
4598
5526
|
failJson({ message: override.message });
|
|
4599
5527
|
return;
|
|
4600
5528
|
}
|
|
5529
|
+
const judgeOverride = parsePair("judge-llm", options.judgeLlmProvider, options.judgeLlmModel);
|
|
5530
|
+
if (!judgeOverride.ok) {
|
|
5531
|
+
failJson({ message: judgeOverride.message });
|
|
5532
|
+
return;
|
|
5533
|
+
}
|
|
5534
|
+
const judging = options.judgeFeedback !== false;
|
|
5535
|
+
if (!judging && judgeOverride.llm) {
|
|
5536
|
+
failJson({ message: "--judge-llm-provider/--judge-llm-model cannot be combined with --no-judge-feedback: the first configures the feedback judge, the second switches it off." });
|
|
5537
|
+
return;
|
|
5538
|
+
}
|
|
4601
5539
|
const expansion = expandSources(pathsOrUrls);
|
|
4602
5540
|
if (!expansion.ok) {
|
|
4603
5541
|
failJson({ message: expansion.message });
|
|
@@ -4640,33 +5578,56 @@ async function runEvalCommand(pathsOrUrls, options, seams = {}) {
|
|
|
4640
5578
|
return;
|
|
4641
5579
|
}
|
|
4642
5580
|
{
|
|
4643
|
-
const
|
|
4644
|
-
|
|
5581
|
+
const scope = (unit, generation, cases) => {
|
|
5582
|
+
if (cases === 0) return;
|
|
5583
|
+
const calls = cases * repeats;
|
|
5584
|
+
process.stderr.write(`${cases} ${unit}(s) × ${repeats} repeat(s) = ${calls} ${generation}` + (judging ? ` + ${calls} judge call(s)\n` : " call(s)\n"));
|
|
5585
|
+
};
|
|
5586
|
+
const casesOf = (kind) => [...checked.values()].filter((file) => file.kind === kind).reduce((sum, file) => sum + file.caseCount, 0);
|
|
5587
|
+
scope("case", "grading", casesOf("quiz"));
|
|
5588
|
+
scope("conversation", "generation", casesOf("tutor"));
|
|
4645
5589
|
}
|
|
5590
|
+
await warnOnVersionMismatch(options.server);
|
|
5591
|
+
const judgeBreaker = createJudgeBreaker();
|
|
5592
|
+
const onJudgeDegraded = () => {
|
|
5593
|
+
process.stderr.write("Warning: feedback judging was stopped after 3 judge calls failed in a row — the rest of this run is graded but NOT judged. Grading is unaffected.\n");
|
|
5594
|
+
};
|
|
4646
5595
|
let fileIndex = 0;
|
|
4647
5596
|
for (const file of files) {
|
|
4648
5597
|
fileIndex += 1;
|
|
4649
5598
|
const check = checked.get(file.source);
|
|
4650
5599
|
if (!check) continue;
|
|
4651
|
-
const
|
|
4652
|
-
provider: check.
|
|
4653
|
-
model: check.
|
|
5600
|
+
const activityLlm = {
|
|
5601
|
+
provider: check.llm.provider,
|
|
5602
|
+
model: check.llm.model
|
|
4654
5603
|
};
|
|
4655
|
-
const effective = override.llm ??
|
|
5604
|
+
const effective = override.llm ?? activityLlm;
|
|
5605
|
+
const judgeLlm = judgeOverride.llm ?? effective;
|
|
4656
5606
|
const llm = {
|
|
4657
5607
|
...effective,
|
|
4658
|
-
...override.llm ? { overrides:
|
|
5608
|
+
...override.llm ? { overrides: activityLlm } : {},
|
|
5609
|
+
...judging ? { judge: {
|
|
5610
|
+
...judgeLlm,
|
|
5611
|
+
overridden: judgeOverride.llm !== void 0
|
|
5612
|
+
} } : {}
|
|
4659
5613
|
};
|
|
4660
|
-
const
|
|
4661
|
-
|
|
5614
|
+
const label = files.length > 1 ? `(${fileIndex}/${files.length}) ${check.evalFile.id}` : check.evalFile.id;
|
|
5615
|
+
const prefix = files.length > 1 ? `${label}: ` : "";
|
|
5616
|
+
const result = await runEval(check.kind, check, {
|
|
4662
5617
|
grade: makeGradeFn(options.server, effective),
|
|
5618
|
+
respond: makeRespondFn(options.server, effective),
|
|
5619
|
+
...judging ? { judge: makeJudgeFn(options.server, judgeLlm) } : {},
|
|
5620
|
+
judgeBreaker,
|
|
5621
|
+
onJudgeDegraded,
|
|
4663
5622
|
concurrency,
|
|
4664
5623
|
repeats,
|
|
4665
5624
|
llm,
|
|
4666
5625
|
onProgress: progressWriter(prefix),
|
|
4667
5626
|
...seams.retry ? { retry: seams.retry } : {}
|
|
4668
5627
|
});
|
|
5628
|
+
file.result = result;
|
|
4669
5629
|
if (process.stderr.isTTY) process.stderr.write("\n");
|
|
5630
|
+
else writeFileDone(label, result);
|
|
4670
5631
|
}
|
|
4671
5632
|
const batch = summarizeBatch(files);
|
|
4672
5633
|
const payload = JSON.stringify(batch, null, 2);
|
|
@@ -4693,12 +5654,16 @@ async function runEvalCommand(pathsOrUrls, options, seams = {}) {
|
|
|
4693
5654
|
process.exitCode = batchPassed(batch) ? 0 : 1;
|
|
4694
5655
|
}
|
|
4695
5656
|
function registerEval(program) {
|
|
4696
|
-
program.command("eval").description("
|
|
5657
|
+
program.command("eval").description("Run an eval file (quiz golden answers, or tutor conversations) against the real activity path and report the result").argument("<evalPathOrUrl...>", "one or more eval YAML files (paths, http(s)/file URLs, or a quoted glob pattern)").option("--server <url>", "Novedu server base URL (defaults to the NOVEDU_SERVER env var, then production)").option("--concurrency <n>", "cases in flight per file", String(CONCURRENCY_DEFAULT)).option("--repeats <n>", "run every case N times (quiz: take the majority verdict)", "1").option("--llm-provider <provider>", "run with this provider instead of the activity's (\"SCCH\" or \"Azure Foundry\"; needs --llm-model)").option("--llm-model <model>", "run with this model instead of the activity's (needs --llm-provider)").option("--no-judge-feedback", "skip the LLM audit of what the model wrote (halves the LLM calls)").option("--judge-llm-provider <provider>", "judge with this provider (\"SCCH\" or \"Azure Foundry\"; needs --judge-llm-model)").option("--judge-llm-model <model>", "judge with this model instead of the one under test (needs --judge-llm-provider)").option("--json", "print the machine-readable batch report on stdout").option("--out <file>", "additionally write the machine-readable batch report to a file").option("--report <file>", "additionally write a readable Markdown report to a file").addHelpText("after", `
|
|
4697
5658
|
Examples:
|
|
4698
5659
|
# Evaluate one quiz's golden answers
|
|
4699
5660
|
$ novedu-cli eval ./0010-welcome-quiz.eval.yaml
|
|
4700
5661
|
|
|
4701
|
-
#
|
|
5662
|
+
# Check how a tutor answers a set of scripted conversations
|
|
5663
|
+
$ novedu-cli eval ./loops-tutor.eval.yaml
|
|
5664
|
+
|
|
5665
|
+
# A whole course part — quiz and tutor evals may be mixed
|
|
5666
|
+
# (quote the pattern so the CLI expands it, ** included)
|
|
4702
5667
|
$ novedu-cli eval "./part-1/**/*.eval.yaml"
|
|
4703
5668
|
|
|
4704
5669
|
# Measure grader stability: 3 runs per answer, majority verdict
|
|
@@ -4707,6 +5672,12 @@ Examples:
|
|
|
4707
5672
|
# How would this rubric perform on another model? (both flags, always together)
|
|
4708
5673
|
$ novedu-cli eval ./my-quiz.eval.yaml --llm-provider "Azure Foundry" --llm-model gpt-5-mini
|
|
4709
5674
|
|
|
5675
|
+
# A strong judge over the quiz's own grader — the recommended pairing
|
|
5676
|
+
$ novedu-cli eval ./my-quiz.eval.yaml --judge-llm-provider "Azure Foundry" --judge-llm-model gpt-5.6-terra
|
|
5677
|
+
|
|
5678
|
+
# Half the LLM calls: check the verdicts only, skip the feedback audit
|
|
5679
|
+
$ novedu-cli eval ./my-quiz.eval.yaml --no-judge-feedback
|
|
5680
|
+
|
|
4710
5681
|
# Machine-readable, for CI
|
|
4711
5682
|
$ novedu-cli eval ./my-quiz.eval.yaml --json --out eval-report.json
|
|
4712
5683
|
|