@bendyline/docblocks-cli 2.1.1 → 2.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bin.js +386 -92
- package/dist/eval/cli.d.ts +2 -0
- package/dist/eval/cli.js +1584 -0
- package/package.json +7 -7
package/dist/eval/cli.js
ADDED
|
@@ -0,0 +1,1584 @@
|
|
|
1
|
+
// src/eval/cli.ts
|
|
2
|
+
import path5 from "path";
|
|
3
|
+
import { Command } from "commander";
|
|
4
|
+
|
|
5
|
+
// src/eval/cases.ts
|
|
6
|
+
var EVAL_PROMPT_PROFILES = Object.freeze({
|
|
7
|
+
baseline: `Use the DocBlocks MCP server for authoring, validation, conversion, and saving. Do not create the Office artifact with shell commands or another document library. Return the exact Squisq-flavored Markdown supplied to the final convert_document call.`,
|
|
8
|
+
"content-first": `Use the DocBlocks MCP server for the complete workflow. Start with get_authoring_context, author a lossless content-first draft, validate it, then use compatible visual templates selectively and preview before the final conversion. Repair actionable diagnostics. Do not create the Office artifact with shell commands or another document library. Return the exact Squisq-flavored Markdown supplied to the final convert_document call.`
|
|
9
|
+
});
|
|
10
|
+
var MCP_CONTENT_EVAL_CASES = Object.freeze([
|
|
11
|
+
{
|
|
12
|
+
id: "quarterly-product-review-pptx",
|
|
13
|
+
title: "Quarterly product review deck",
|
|
14
|
+
suites: ["quick", "full"],
|
|
15
|
+
targetFormat: "pptx",
|
|
16
|
+
artifactFilename: "quarterly-product-review.pptx",
|
|
17
|
+
brief: `Create an executive-ready quarterly product review presentation for Northstar, a fictional B2B workflow product. The audience is the leadership team. Use 8 slides and make the story decision-oriented, not a data dump.
|
|
18
|
+
|
|
19
|
+
Use these facts exactly:
|
|
20
|
+
- Q2 active teams: 18,420, up 14% quarter over quarter.
|
|
21
|
+
- Weekly retained teams: 71%, up from 66% in Q1.
|
|
22
|
+
- Enterprise expansion revenue: $3.8M, 92% of the $4.1M target.
|
|
23
|
+
- Activation improved from 42% to 49% after guided setup shipped.
|
|
24
|
+
- Mobile weekly usage is 23%, below the 30% goal.
|
|
25
|
+
- Top support driver is permissions setup, representing 31% of tickets.
|
|
26
|
+
- The proposed Q3 choices are: fund permissions redesign, protect activation gains, and defer advanced mobile editing.
|
|
27
|
+
|
|
28
|
+
Include a clear opening thesis, a compact scorecard, insights rather than repeated metrics, the three Q3 choices with tradeoffs, and a final decision/owner slide.`,
|
|
29
|
+
rubric: [
|
|
30
|
+
"Builds a coherent executive narrative from performance to causes to decisions.",
|
|
31
|
+
"Preserves every supplied metric and does not invent unsupported business facts.",
|
|
32
|
+
"Makes the three Q3 choices, tradeoffs, owners, and decisions easy to scan.",
|
|
33
|
+
"Uses slide-appropriate density and varied Squisq templates without sacrificing content."
|
|
34
|
+
],
|
|
35
|
+
expectation: {
|
|
36
|
+
minItems: 8,
|
|
37
|
+
maxItems: 8,
|
|
38
|
+
minWords: 220,
|
|
39
|
+
maxWords: 650,
|
|
40
|
+
maxWordsPerSection: 95,
|
|
41
|
+
minVisualTemplates: 2,
|
|
42
|
+
requiredPhrases: ["18,420", "71%", "$3.8M", "49%", "23%", "31%", "permissions"]
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
id: "responsible-ai-training-pptx",
|
|
47
|
+
title: "Responsible AI manager training deck",
|
|
48
|
+
suites: ["full"],
|
|
49
|
+
targetFormat: "pptx",
|
|
50
|
+
artifactFilename: "responsible-ai-training.pptx",
|
|
51
|
+
brief: `Create a 9-slide manager training presentation called "Responsible AI in Everyday Work." The audience is nontechnical people managers. Teach a usable decision process, not generic inspiration.
|
|
52
|
+
|
|
53
|
+
Cover: a plain-language definition, an opening scenario, a four-question risk screen, examples of acceptable and unacceptable use, handling confidential data, reviewing model output, escalation triggers, a short practice scenario with answer, and a final one-week action plan. State clearly that humans remain accountable for decisions and that confidential customer or employee data must not be pasted into unapproved tools.`,
|
|
54
|
+
rubric: [
|
|
55
|
+
"Uses plain language and a practical teaching progression.",
|
|
56
|
+
"Contains an actionable four-question screen and realistic examples.",
|
|
57
|
+
"Makes confidentiality, human accountability, and escalation memorable.",
|
|
58
|
+
"Fits a live training deck with concise slides and useful visual variation."
|
|
59
|
+
],
|
|
60
|
+
expectation: {
|
|
61
|
+
minItems: 9,
|
|
62
|
+
maxItems: 9,
|
|
63
|
+
minWords: 240,
|
|
64
|
+
maxWords: 700,
|
|
65
|
+
maxWordsPerSection: 90,
|
|
66
|
+
minVisualTemplates: 2,
|
|
67
|
+
requiredPhrases: ["accountable", "confidential", "escalat", "four", "one-week"]
|
|
68
|
+
}
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
id: "build-vs-buy-decision-memo-docx",
|
|
72
|
+
title: "Build-versus-buy decision memo",
|
|
73
|
+
suites: ["quick", "full"],
|
|
74
|
+
targetFormat: "docx",
|
|
75
|
+
artifactFilename: "build-vs-buy-decision-memo.docx",
|
|
76
|
+
brief: `Write a professional decision memo for the COO recommending whether a fictional 420-person company should build or buy a contract-lifecycle-management system.
|
|
77
|
+
|
|
78
|
+
Use these facts exactly:
|
|
79
|
+
- Current manual work costs about 1,900 staff hours per quarter.
|
|
80
|
+
- A vendor solution costs $310,000 in year one and $190,000 annually afterward.
|
|
81
|
+
- An internal build is estimated at 8 engineer-months plus 2 ongoing engineer-months per year.
|
|
82
|
+
- Security review found no blocking issue for the vendor, but data residency needs a contractual addendum.
|
|
83
|
+
- Procurement can complete negotiation in six weeks.
|
|
84
|
+
- The operations team needs the first workflow live within ten weeks.
|
|
85
|
+
|
|
86
|
+
Recommend one path, explain the decision criteria and quantified tradeoffs, name risks and mitigations, give an implementation sequence, and end with the exact decision requested from the COO. Aim for roughly 1,000-1,400 words.`,
|
|
87
|
+
rubric: [
|
|
88
|
+
"Makes a clear recommendation supported by the supplied time, cost, and delivery facts.",
|
|
89
|
+
"Separates facts, assumptions, risks, mitigations, and decision request.",
|
|
90
|
+
"Provides a credible implementation sequence and accountable next steps.",
|
|
91
|
+
"Reads as an executive memo rather than a slide deck pasted into Word."
|
|
92
|
+
],
|
|
93
|
+
expectation: {
|
|
94
|
+
minItems: 6,
|
|
95
|
+
maxItems: 14,
|
|
96
|
+
minWords: 850,
|
|
97
|
+
maxWords: 1550,
|
|
98
|
+
maxWordsPerSection: 360,
|
|
99
|
+
minVisualTemplates: 0,
|
|
100
|
+
requiredPhrases: [
|
|
101
|
+
"1,900",
|
|
102
|
+
"$310,000",
|
|
103
|
+
"$190,000",
|
|
104
|
+
"8 engineer-months",
|
|
105
|
+
"six weeks",
|
|
106
|
+
"ten weeks"
|
|
107
|
+
]
|
|
108
|
+
}
|
|
109
|
+
},
|
|
110
|
+
{
|
|
111
|
+
id: "incident-response-playbook-docx",
|
|
112
|
+
title: "Incident response playbook",
|
|
113
|
+
suites: ["full"],
|
|
114
|
+
targetFormat: "docx",
|
|
115
|
+
artifactFilename: "incident-response-playbook.docx",
|
|
116
|
+
brief: `Create a concise operational playbook for responding to a suspected customer-data exposure at a SaaS company. It must be usable during an incident.
|
|
117
|
+
|
|
118
|
+
Include: purpose and scope, severity triggers, the first 15/30/60 minutes, roles for incident commander/security/legal/support, an evidence-preservation checklist, communication rules, a decision log template, containment and recovery gates, customer-notification decision criteria, and a post-incident checklist. Explicitly say not to delete or alter evidence and not to make unverified claims to customers. Use tables or checklists where they improve scanability. Aim for 1,200-1,800 words.`,
|
|
119
|
+
rubric: [
|
|
120
|
+
"Works as an executable playbook under time pressure.",
|
|
121
|
+
"Distinguishes containment, investigation, recovery, and communication decisions.",
|
|
122
|
+
"Protects evidence and prevents unverified external statements.",
|
|
123
|
+
"Uses document-native structure, tables, and checklists for rapid scanning."
|
|
124
|
+
],
|
|
125
|
+
expectation: {
|
|
126
|
+
minItems: 8,
|
|
127
|
+
maxItems: 20,
|
|
128
|
+
minWords: 1e3,
|
|
129
|
+
maxWords: 2e3,
|
|
130
|
+
maxWordsPerSection: 400,
|
|
131
|
+
minVisualTemplates: 0,
|
|
132
|
+
requiredPhrases: ["15", "30", "60", "incident commander", "evidence", "unverified"]
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
]);
|
|
136
|
+
function selectEvalCases(suiteOrIds) {
|
|
137
|
+
if (suiteOrIds === "quick" || suiteOrIds === "full") {
|
|
138
|
+
const suite = suiteOrIds;
|
|
139
|
+
return MCP_CONTENT_EVAL_CASES.filter((testCase) => testCase.suites.includes(suite));
|
|
140
|
+
}
|
|
141
|
+
const ids = new Set(
|
|
142
|
+
suiteOrIds.split(",").map((id) => id.trim()).filter(Boolean)
|
|
143
|
+
);
|
|
144
|
+
if (ids.size === 0) throw new Error("At least one eval case id is required");
|
|
145
|
+
const selected = MCP_CONTENT_EVAL_CASES.filter((testCase) => ids.has(testCase.id));
|
|
146
|
+
const missing = [...ids].filter((id) => !selected.some((testCase) => testCase.id === id));
|
|
147
|
+
if (missing.length > 0) throw new Error(`Unknown eval case ids: ${missing.join(", ")}`);
|
|
148
|
+
return selected;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// src/eval/report.ts
|
|
152
|
+
import { readFile, writeFile } from "fs/promises";
|
|
153
|
+
import path from "path";
|
|
154
|
+
function compareEvalRuns(baseline, candidate) {
|
|
155
|
+
assertPairedCases(baseline, candidate);
|
|
156
|
+
if (baseline.provenance.judgeMode !== candidate.provenance.judgeMode) {
|
|
157
|
+
throw new Error("Paired comparison requires the same judge mode");
|
|
158
|
+
}
|
|
159
|
+
const candidateById = new Map(candidate.cases.map((result) => [result.caseId, result]));
|
|
160
|
+
const cases = baseline.cases.map((baselineCase) => {
|
|
161
|
+
const candidateCase = candidateById.get(baselineCase.caseId);
|
|
162
|
+
if (!candidateCase) {
|
|
163
|
+
throw new Error(`Candidate run is missing baseline case ${baselineCase.caseId}`);
|
|
164
|
+
}
|
|
165
|
+
return {
|
|
166
|
+
caseId: baselineCase.caseId,
|
|
167
|
+
baselineScore: baselineCase.score,
|
|
168
|
+
candidateScore: candidateCase.score,
|
|
169
|
+
delta: round(candidateCase.score - baselineCase.score),
|
|
170
|
+
baselinePassed: baselineCase.passed,
|
|
171
|
+
candidatePassed: candidateCase.passed
|
|
172
|
+
};
|
|
173
|
+
});
|
|
174
|
+
const warnings = comparisonWarnings(baseline, candidate);
|
|
175
|
+
return {
|
|
176
|
+
baselineRunId: baseline.provenance.runId,
|
|
177
|
+
candidateRunId: candidate.provenance.runId,
|
|
178
|
+
baselineScore: baseline.score,
|
|
179
|
+
candidateScore: candidate.score,
|
|
180
|
+
delta: round(candidate.score - baseline.score),
|
|
181
|
+
regressions: cases.filter(
|
|
182
|
+
(result) => result.delta < 0 || result.baselinePassed && !result.candidatePassed
|
|
183
|
+
).length,
|
|
184
|
+
improvements: cases.filter(
|
|
185
|
+
(result) => result.delta > 0 || !result.baselinePassed && result.candidatePassed
|
|
186
|
+
).length,
|
|
187
|
+
warnings,
|
|
188
|
+
baselineMetrics: summarizeRunMetrics(baseline),
|
|
189
|
+
candidateMetrics: summarizeRunMetrics(candidate),
|
|
190
|
+
cases
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
function summarizeRunMetrics(run) {
|
|
194
|
+
return run.cases.reduce((metrics, result) => {
|
|
195
|
+
const generation = result.generationUsage;
|
|
196
|
+
const judge = result.judgeUsage;
|
|
197
|
+
const trace = result.mcpTraceMetrics;
|
|
198
|
+
return {
|
|
199
|
+
generationDurationMs: metrics.generationDurationMs + result.generationDurationMs,
|
|
200
|
+
judgeDurationMs: metrics.judgeDurationMs + (result.judgeDurationMs ?? 0),
|
|
201
|
+
generationInputTokens: metrics.generationInputTokens + (generation?.inputTokens ?? 0),
|
|
202
|
+
generationCachedInputTokens: metrics.generationCachedInputTokens + (generation?.cachedInputTokens ?? 0),
|
|
203
|
+
generationUncachedInputTokens: metrics.generationUncachedInputTokens + Math.max(0, (generation?.inputTokens ?? 0) - (generation?.cachedInputTokens ?? 0)),
|
|
204
|
+
generationOutputTokens: metrics.generationOutputTokens + (generation?.outputTokens ?? 0),
|
|
205
|
+
generationReasoningTokens: metrics.generationReasoningTokens + (generation?.reasoningOutputTokens ?? 0),
|
|
206
|
+
judgeInputTokens: metrics.judgeInputTokens + (judge?.inputTokens ?? 0),
|
|
207
|
+
judgeOutputTokens: metrics.judgeOutputTokens + (judge?.outputTokens ?? 0),
|
|
208
|
+
toolCallCount: metrics.toolCallCount + (trace?.toolCallCount ?? 0),
|
|
209
|
+
toolArgumentCharacters: metrics.toolArgumentCharacters + (trace?.toolArgumentCharacters ?? 0),
|
|
210
|
+
toolResultCharacters: metrics.toolResultCharacters + (trace?.toolResultCharacters ?? 0),
|
|
211
|
+
authoringContextResultCharacters: metrics.authoringContextResultCharacters + (trace?.authoringContextResultCharacters ?? 0),
|
|
212
|
+
authoringContextTextCharacters: metrics.authoringContextTextCharacters + (trace?.authoringContextTextCharacters ?? 0),
|
|
213
|
+
authoringContextStructuredCharacters: metrics.authoringContextStructuredCharacters + (trace?.authoringContextStructuredCharacters ?? 0)
|
|
214
|
+
};
|
|
215
|
+
}, emptyRunMetrics());
|
|
216
|
+
}
|
|
217
|
+
async function readEvalRun(runOrDirectory) {
|
|
218
|
+
const file = path.extname(runOrDirectory) === ".json" ? runOrDirectory : path.join(runOrDirectory, "run.json");
|
|
219
|
+
const parsed = JSON.parse(await readFile(file, "utf8"));
|
|
220
|
+
if (!isRecord(parsed) || !isRecord(parsed.provenance) || !Array.isArray(parsed.cases)) {
|
|
221
|
+
throw new Error(`${file} is not a DocBlocks MCP eval run`);
|
|
222
|
+
}
|
|
223
|
+
return parsed;
|
|
224
|
+
}
|
|
225
|
+
async function writeRunReport(directory, run) {
|
|
226
|
+
await Promise.all([
|
|
227
|
+
writeFile(path.join(directory, "run.json"), `${JSON.stringify(run, null, 2)}
|
|
228
|
+
`),
|
|
229
|
+
writeFile(path.join(directory, "summary.md"), runMarkdown(run))
|
|
230
|
+
]);
|
|
231
|
+
}
|
|
232
|
+
async function writeComparisonReport(outputDirectory, comparison) {
|
|
233
|
+
await Promise.all([
|
|
234
|
+
writeFile(
|
|
235
|
+
path.join(outputDirectory, "comparison.json"),
|
|
236
|
+
`${JSON.stringify(comparison, null, 2)}
|
|
237
|
+
`
|
|
238
|
+
),
|
|
239
|
+
writeFile(path.join(outputDirectory, "comparison.md"), comparisonMarkdown(comparison))
|
|
240
|
+
]);
|
|
241
|
+
}
|
|
242
|
+
function runMarkdown(run) {
|
|
243
|
+
const metrics = summarizeRunMetrics(run);
|
|
244
|
+
const rows = run.cases.map(
|
|
245
|
+
(result) => `| ${result.caseId} | ${result.targetFormat} | ${result.score.toFixed(1)} | ${result.artifactScore.toFixed(1)} | ${result.markdownScore.toFixed(1)} | ${result.llmScore?.toFixed(1) ?? "n/a"} | ${result.passed ? "pass" : "fail"} |`
|
|
246
|
+
);
|
|
247
|
+
const failures = run.cases.flatMap((result) => {
|
|
248
|
+
const failedChecks = [...result.artifactJudge.checks, ...result.markdownJudge.checks].filter(
|
|
249
|
+
(check) => check.required && !check.passed
|
|
250
|
+
);
|
|
251
|
+
return [
|
|
252
|
+
...result.failure ? [`- **${result.caseId}:** ${result.failure}`] : [],
|
|
253
|
+
...failedChecks.map((check) => `- **${result.caseId} / ${check.id}:** ${check.message}`),
|
|
254
|
+
...result.llmJudge?.weaknesses.map(
|
|
255
|
+
(weakness) => `- **${result.caseId} / LLM:** ${weakness}`
|
|
256
|
+
) ?? [],
|
|
257
|
+
...result.llmJudge?.unsupportedClaims.map(
|
|
258
|
+
(claim) => `- **${result.caseId} / unsupported claim:** ${claim}`
|
|
259
|
+
) ?? [],
|
|
260
|
+
...result.llmJudge?.missingRequestedElements.map(
|
|
261
|
+
(element) => `- **${result.caseId} / missing element:** ${element}`
|
|
262
|
+
) ?? []
|
|
263
|
+
];
|
|
264
|
+
});
|
|
265
|
+
return `# DocBlocks MCP content eval: ${run.provenance.label}
|
|
266
|
+
|
|
267
|
+
- Run: \`${run.provenance.runId}\`
|
|
268
|
+
- Score: **${run.score.toFixed(1)}**
|
|
269
|
+
- Result: **${run.passed ? "pass" : "fail"}**
|
|
270
|
+
- Suite/profile: \`${run.provenance.suite}\` / \`${run.provenance.profile}\`
|
|
271
|
+
- Judge mode/model: \`${run.provenance.judgeMode}\` / \`${run.provenance.model ?? "Codex default"}\`
|
|
272
|
+
- Git: \`${run.provenance.gitCommit ?? "unknown"}\`${run.provenance.gitDirty ? " (dirty)" : ""}
|
|
273
|
+
- CLI SHA-256: \`${run.provenance.cliSha256}\`
|
|
274
|
+
- Judge contract SHA-256: \`${run.provenance.judgeContractSha256}\`
|
|
275
|
+
|
|
276
|
+
## Efficiency
|
|
277
|
+
|
|
278
|
+
- Generation time: **${formatDuration(metrics.generationDurationMs)}**
|
|
279
|
+
- Generation input tokens: **${formatInteger(metrics.generationInputTokens)}** total / **${formatInteger(metrics.generationUncachedInputTokens)}** uncached
|
|
280
|
+
- Generation output tokens: **${formatInteger(metrics.generationOutputTokens)}**
|
|
281
|
+
- MCP calls/results: **${formatInteger(metrics.toolCallCount)}** / **${formatInteger(metrics.toolResultCharacters)} characters**
|
|
282
|
+
- Authoring-context result: **${formatInteger(metrics.authoringContextResultCharacters)} characters** (**${formatInteger(metrics.authoringContextTextCharacters)} text**, **${formatInteger(metrics.authoringContextStructuredCharacters)} structured**)
|
|
283
|
+
|
|
284
|
+
| Case | Format | Overall | OOXML | Markdown | LLM | Result |
|
|
285
|
+
| --- | --- | ---: | ---: | ---: | ---: | --- |
|
|
286
|
+
${rows.join("\n")}
|
|
287
|
+
|
|
288
|
+
## Findings
|
|
289
|
+
|
|
290
|
+
${failures.length > 0 ? failures.join("\n") : "- No required static failures or LLM weaknesses were reported."}
|
|
291
|
+
`;
|
|
292
|
+
}
|
|
293
|
+
function comparisonMarkdown(comparison) {
|
|
294
|
+
const rows = comparison.cases.map(
|
|
295
|
+
(result) => `| ${result.caseId} | ${result.baselineScore.toFixed(1)} | ${result.candidateScore.toFixed(1)} | ${formatDelta(result.delta)} | ${result.baselinePassed ? "pass" : "fail"} \u2192 ${result.candidatePassed ? "pass" : "fail"} |`
|
|
296
|
+
);
|
|
297
|
+
return `# DocBlocks MCP content eval comparison
|
|
298
|
+
|
|
299
|
+
- Baseline: \`${comparison.baselineRunId}\` \u2014 ${comparison.baselineScore.toFixed(1)}
|
|
300
|
+
- Candidate: \`${comparison.candidateRunId}\` \u2014 ${comparison.candidateScore.toFixed(1)}
|
|
301
|
+
- Aggregate delta: **${formatDelta(comparison.delta)}**
|
|
302
|
+
- Improved/regressed cases: **${comparison.improvements} / ${comparison.regressions}**
|
|
303
|
+
|
|
304
|
+
## Comparability
|
|
305
|
+
|
|
306
|
+
${comparison.warnings.length > 0 ? comparison.warnings.map((warning) => `- Warning: ${warning}`).join("\n") : "- Paired cases, judge mode, model, prompt profile, threshold, and Codex version match."}
|
|
307
|
+
|
|
308
|
+
## Efficiency
|
|
309
|
+
|
|
310
|
+
| Metric | Baseline | Candidate | Delta |
|
|
311
|
+
| --- | ---: | ---: | ---: |
|
|
312
|
+
| Generation time | ${formatDuration(comparison.baselineMetrics.generationDurationMs)} | ${formatDuration(comparison.candidateMetrics.generationDurationMs)} | ${formatSignedDuration(comparison.candidateMetrics.generationDurationMs - comparison.baselineMetrics.generationDurationMs)} |
|
|
313
|
+
| Input tokens | ${formatInteger(comparison.baselineMetrics.generationInputTokens)} | ${formatInteger(comparison.candidateMetrics.generationInputTokens)} | ${formatSignedInteger(comparison.candidateMetrics.generationInputTokens - comparison.baselineMetrics.generationInputTokens)} |
|
|
314
|
+
| Uncached input tokens | ${formatInteger(comparison.baselineMetrics.generationUncachedInputTokens)} | ${formatInteger(comparison.candidateMetrics.generationUncachedInputTokens)} | ${formatSignedInteger(comparison.candidateMetrics.generationUncachedInputTokens - comparison.baselineMetrics.generationUncachedInputTokens)} |
|
|
315
|
+
| Output tokens | ${formatInteger(comparison.baselineMetrics.generationOutputTokens)} | ${formatInteger(comparison.candidateMetrics.generationOutputTokens)} | ${formatSignedInteger(comparison.candidateMetrics.generationOutputTokens - comparison.baselineMetrics.generationOutputTokens)} |
|
|
316
|
+
| MCP result characters | ${formatInteger(comparison.baselineMetrics.toolResultCharacters)} | ${formatInteger(comparison.candidateMetrics.toolResultCharacters)} | ${formatSignedInteger(comparison.candidateMetrics.toolResultCharacters - comparison.baselineMetrics.toolResultCharacters)} |
|
|
317
|
+
| Authoring-context characters | ${formatInteger(comparison.baselineMetrics.authoringContextResultCharacters)} | ${formatInteger(comparison.candidateMetrics.authoringContextResultCharacters)} | ${formatSignedInteger(comparison.candidateMetrics.authoringContextResultCharacters - comparison.baselineMetrics.authoringContextResultCharacters)} |
|
|
318
|
+
|
|
319
|
+
| Case | Baseline | Candidate | Delta | Result |
|
|
320
|
+
| --- | ---: | ---: | ---: | --- |
|
|
321
|
+
${rows.join("\n")}
|
|
322
|
+
`;
|
|
323
|
+
}
|
|
324
|
+
function formatDelta(value) {
|
|
325
|
+
return `${value >= 0 ? "+" : ""}${value.toFixed(1)}`;
|
|
326
|
+
}
|
|
327
|
+
function assertPairedCases(baseline, candidate) {
|
|
328
|
+
const baselineIds = baseline.cases.map(({ caseId }) => caseId).sort();
|
|
329
|
+
const candidateIds = candidate.cases.map(({ caseId }) => caseId).sort();
|
|
330
|
+
if (JSON.stringify(baselineIds) !== JSON.stringify(candidateIds)) {
|
|
331
|
+
throw new Error("Paired comparison requires exactly the same case ids");
|
|
332
|
+
}
|
|
333
|
+
const candidateById = new Map(candidate.cases.map((result) => [result.caseId, result]));
|
|
334
|
+
for (const baselineCase of baseline.cases) {
|
|
335
|
+
if (candidateById.get(baselineCase.caseId)?.targetFormat !== baselineCase.targetFormat) {
|
|
336
|
+
throw new Error(`Paired case ${baselineCase.caseId} changed target format`);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
function comparisonWarnings(baseline, candidate) {
|
|
341
|
+
const warnings = [];
|
|
342
|
+
const pairs = [
|
|
343
|
+
["prompt profile", baseline.provenance.profile, candidate.provenance.profile],
|
|
344
|
+
[
|
|
345
|
+
"prompt-profile hash",
|
|
346
|
+
baseline.provenance.promptProfileSha256,
|
|
347
|
+
candidate.provenance.promptProfileSha256
|
|
348
|
+
],
|
|
349
|
+
[
|
|
350
|
+
"judge-contract hash",
|
|
351
|
+
baseline.provenance.judgeContractSha256,
|
|
352
|
+
candidate.provenance.judgeContractSha256
|
|
353
|
+
],
|
|
354
|
+
["model", baseline.provenance.model, candidate.provenance.model],
|
|
355
|
+
["threshold", baseline.provenance.threshold, candidate.provenance.threshold],
|
|
356
|
+
["Codex version", baseline.provenance.codexVersion, candidate.provenance.codexVersion]
|
|
357
|
+
];
|
|
358
|
+
for (const [label, baselineValue, candidateValue] of pairs) {
|
|
359
|
+
if (baselineValue !== candidateValue) {
|
|
360
|
+
warnings.push(
|
|
361
|
+
`${label} differs: ${JSON.stringify(baselineValue)} versus ${JSON.stringify(candidateValue)}`
|
|
362
|
+
);
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
if (baseline.provenance.model === null && candidate.provenance.model === null) {
|
|
366
|
+
warnings.push(
|
|
367
|
+
"both runs use the mutable Codex default model; pin --model for launch decisions"
|
|
368
|
+
);
|
|
369
|
+
}
|
|
370
|
+
return warnings;
|
|
371
|
+
}
|
|
372
|
+
function emptyRunMetrics() {
|
|
373
|
+
return {
|
|
374
|
+
generationDurationMs: 0,
|
|
375
|
+
judgeDurationMs: 0,
|
|
376
|
+
generationInputTokens: 0,
|
|
377
|
+
generationCachedInputTokens: 0,
|
|
378
|
+
generationUncachedInputTokens: 0,
|
|
379
|
+
generationOutputTokens: 0,
|
|
380
|
+
generationReasoningTokens: 0,
|
|
381
|
+
judgeInputTokens: 0,
|
|
382
|
+
judgeOutputTokens: 0,
|
|
383
|
+
toolCallCount: 0,
|
|
384
|
+
toolArgumentCharacters: 0,
|
|
385
|
+
toolResultCharacters: 0,
|
|
386
|
+
authoringContextResultCharacters: 0,
|
|
387
|
+
authoringContextTextCharacters: 0,
|
|
388
|
+
authoringContextStructuredCharacters: 0
|
|
389
|
+
};
|
|
390
|
+
}
|
|
391
|
+
function formatInteger(value) {
|
|
392
|
+
return Math.round(value).toLocaleString("en-US");
|
|
393
|
+
}
|
|
394
|
+
function formatSignedInteger(value) {
|
|
395
|
+
return `${value >= 0 ? "+" : ""}${formatInteger(value)}`;
|
|
396
|
+
}
|
|
397
|
+
function formatDuration(value) {
|
|
398
|
+
return `${(value / 1e3).toFixed(1)}s`;
|
|
399
|
+
}
|
|
400
|
+
function formatSignedDuration(value) {
|
|
401
|
+
return `${value >= 0 ? "+" : ""}${(value / 1e3).toFixed(1)}s`;
|
|
402
|
+
}
|
|
403
|
+
function round(value) {
|
|
404
|
+
return Math.round(value * 10) / 10;
|
|
405
|
+
}
|
|
406
|
+
function isRecord(value) {
|
|
407
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// src/eval/runner.ts
|
|
411
|
+
import { createHash } from "crypto";
|
|
412
|
+
import { mkdir, readFile as readFile4, writeFile as writeFile3 } from "fs/promises";
|
|
413
|
+
import path4 from "path";
|
|
414
|
+
|
|
415
|
+
// src/eval/artifact-judge.ts
|
|
416
|
+
import { readFile as readFile2 } from "fs/promises";
|
|
417
|
+
import JSZip from "jszip";
|
|
418
|
+
|
|
419
|
+
// src/eval/markdown-judge.ts
|
|
420
|
+
var PLACEHOLDER_PATTERN = /\b(?:todo|tbd|lorem ipsum|insert (?:text|chart|image)|placeholder)\b/i;
|
|
421
|
+
var HEADING_PATTERN = /^(#{1,6})\s+(.+)$/gm;
|
|
422
|
+
var TEMPLATE_PATTERN = /\{\[([A-Za-z0-9_-]+)/g;
|
|
423
|
+
function judgeMarkdown(markdown, testCase) {
|
|
424
|
+
const words = countWords(markdown);
|
|
425
|
+
const sections = markdownSections(markdown);
|
|
426
|
+
const headings = [...markdown.matchAll(HEADING_PATTERN)];
|
|
427
|
+
const templates = [...markdown.matchAll(TEMPLATE_PATTERN)].map((match) => match[1]);
|
|
428
|
+
const visualTemplates = templates.filter((template) => template !== "content");
|
|
429
|
+
const denseSections = sections.filter(
|
|
430
|
+
(section) => countWords(section.body) > testCase.expectation.maxWordsPerSection
|
|
431
|
+
);
|
|
432
|
+
const checks = [
|
|
433
|
+
rangeCheck(
|
|
434
|
+
"word-count",
|
|
435
|
+
words,
|
|
436
|
+
testCase.expectation.minWords,
|
|
437
|
+
testCase.expectation.maxWords,
|
|
438
|
+
true,
|
|
439
|
+
"Markdown word count"
|
|
440
|
+
),
|
|
441
|
+
rangeCheck(
|
|
442
|
+
"section-count",
|
|
443
|
+
headings.length,
|
|
444
|
+
testCase.expectation.minItems,
|
|
445
|
+
testCase.expectation.maxItems,
|
|
446
|
+
true,
|
|
447
|
+
testCase.targetFormat === "pptx" ? "Authored slide sections" : "Document sections"
|
|
448
|
+
),
|
|
449
|
+
{
|
|
450
|
+
id: "section-density",
|
|
451
|
+
passed: denseSections.length === 0,
|
|
452
|
+
required: true,
|
|
453
|
+
message: denseSections.length === 0 ? "Every section stays within the format-specific density budget" : `${denseSections.length} sections exceed ${testCase.expectation.maxWordsPerSection} words`,
|
|
454
|
+
observed: denseSections.length,
|
|
455
|
+
expected: 0
|
|
456
|
+
},
|
|
457
|
+
{
|
|
458
|
+
id: "visual-template-use",
|
|
459
|
+
passed: visualTemplates.length >= testCase.expectation.minVisualTemplates,
|
|
460
|
+
required: testCase.expectation.minVisualTemplates > 0,
|
|
461
|
+
message: `${visualTemplates.length} non-content Squisq template annotations found`,
|
|
462
|
+
observed: visualTemplates.length,
|
|
463
|
+
expected: testCase.expectation.minVisualTemplates
|
|
464
|
+
},
|
|
465
|
+
{
|
|
466
|
+
id: "no-placeholders",
|
|
467
|
+
passed: !PLACEHOLDER_PATTERN.test(markdown),
|
|
468
|
+
required: true,
|
|
469
|
+
message: PLACEHOLDER_PATTERN.test(markdown) ? "Draft contains placeholder language" : "Draft contains no placeholder language",
|
|
470
|
+
observed: PLACEHOLDER_PATTERN.test(markdown),
|
|
471
|
+
expected: false
|
|
472
|
+
},
|
|
473
|
+
...testCase.expectation.requiredPhrases.map((phrase) => ({
|
|
474
|
+
id: `required-phrase:${phrase.toLowerCase()}`,
|
|
475
|
+
passed: markdown.toLowerCase().includes(phrase.toLowerCase()),
|
|
476
|
+
required: true,
|
|
477
|
+
message: `Required brief fact ${JSON.stringify(phrase)} is ${markdown.toLowerCase().includes(phrase.toLowerCase()) ? "present" : "missing"}`,
|
|
478
|
+
observed: markdown.toLowerCase().includes(phrase.toLowerCase()),
|
|
479
|
+
expected: true
|
|
480
|
+
}))
|
|
481
|
+
];
|
|
482
|
+
return summarizeChecks(checks, {
|
|
483
|
+
wordCount: words,
|
|
484
|
+
headingCount: headings.length,
|
|
485
|
+
templateAnnotationCount: templates.length,
|
|
486
|
+
visualTemplateCount: visualTemplates.length,
|
|
487
|
+
denseSectionCount: denseSections.length
|
|
488
|
+
});
|
|
489
|
+
}
|
|
490
|
+
function countWords(value) {
|
|
491
|
+
return value.trim().match(/[\p{L}\p{N}][\p{L}\p{N}'’.-]*/gu)?.length ?? 0;
|
|
492
|
+
}
|
|
493
|
+
function markdownSections(markdown) {
|
|
494
|
+
const matches = [...markdown.matchAll(HEADING_PATTERN)];
|
|
495
|
+
return matches.map((match, index) => ({
|
|
496
|
+
heading: match[2],
|
|
497
|
+
body: markdown.slice((match.index ?? 0) + match[0].length, matches[index + 1]?.index)
|
|
498
|
+
}));
|
|
499
|
+
}
|
|
500
|
+
function rangeCheck(id, observed, minimum, maximum, required, label) {
|
|
501
|
+
const passed = observed >= minimum && observed <= maximum;
|
|
502
|
+
return {
|
|
503
|
+
id,
|
|
504
|
+
passed,
|
|
505
|
+
required,
|
|
506
|
+
message: `${label}: ${observed}; expected ${minimum}-${maximum}`,
|
|
507
|
+
observed,
|
|
508
|
+
expected: `${minimum}-${maximum}`
|
|
509
|
+
};
|
|
510
|
+
}
|
|
511
|
+
function summarizeChecks(checks, metrics) {
|
|
512
|
+
const score = checks.length === 0 ? 0 : Math.round(checks.filter((check) => check.passed).length / checks.length * 1e3) / 10;
|
|
513
|
+
return {
|
|
514
|
+
score,
|
|
515
|
+
passed: checks.every((check) => !check.required || check.passed),
|
|
516
|
+
checks,
|
|
517
|
+
metrics
|
|
518
|
+
};
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
// src/eval/artifact-judge.ts
|
|
522
|
+
async function judgeArtifact(artifactPath, testCase) {
|
|
523
|
+
let bytes;
|
|
524
|
+
try {
|
|
525
|
+
bytes = await readFile2(artifactPath);
|
|
526
|
+
} catch (caught) {
|
|
527
|
+
return missingArtifact(caught);
|
|
528
|
+
}
|
|
529
|
+
let zip;
|
|
530
|
+
try {
|
|
531
|
+
zip = await JSZip.loadAsync(bytes, { checkCRC32: true });
|
|
532
|
+
} catch (caught) {
|
|
533
|
+
const message = caught instanceof Error ? caught.message : String(caught);
|
|
534
|
+
return summarizeChecks(
|
|
535
|
+
[
|
|
536
|
+
{
|
|
537
|
+
id: "valid-zip",
|
|
538
|
+
passed: false,
|
|
539
|
+
required: true,
|
|
540
|
+
message: `Office artifact is not a valid ZIP package: ${message}`,
|
|
541
|
+
observed: false,
|
|
542
|
+
expected: true
|
|
543
|
+
}
|
|
544
|
+
],
|
|
545
|
+
{ byteSize: bytes.byteLength }
|
|
546
|
+
);
|
|
547
|
+
}
|
|
548
|
+
return testCase.targetFormat === "pptx" ? judgePptx(zip, bytes.byteLength, testCase) : judgeDocx(zip, bytes.byteLength, testCase);
|
|
549
|
+
}
|
|
550
|
+
async function judgePptx(zip, byteSize, testCase) {
|
|
551
|
+
const slidePaths = Object.keys(zip.files).filter((name) => /^ppt\/slides\/slide\d+\.xml$/.test(name)).sort(numericOfficePartSort);
|
|
552
|
+
const slideTexts = await Promise.all(
|
|
553
|
+
slidePaths.map(async (path6) => extractXmlText(await requiredZipText(zip, path6), "a:t"))
|
|
554
|
+
);
|
|
555
|
+
const emptySlides = slideTexts.filter((text) => countWords(text) < 2).length;
|
|
556
|
+
const overfullSlides = slideTexts.filter((text) => countWords(text) > 120).length;
|
|
557
|
+
const allText = slideTexts.join(" ");
|
|
558
|
+
const checks = [
|
|
559
|
+
packagePartCheck(zip, "[Content_Types].xml"),
|
|
560
|
+
packagePartCheck(zip, "ppt/presentation.xml"),
|
|
561
|
+
packagePartCheck(zip, "ppt/_rels/presentation.xml.rels"),
|
|
562
|
+
rangeCheck2(
|
|
563
|
+
"slide-count",
|
|
564
|
+
slidePaths.length,
|
|
565
|
+
testCase.expectation.minItems,
|
|
566
|
+
testCase.expectation.maxItems
|
|
567
|
+
),
|
|
568
|
+
{
|
|
569
|
+
id: "nonempty-slides",
|
|
570
|
+
passed: emptySlides === 0,
|
|
571
|
+
required: true,
|
|
572
|
+
message: `${emptySlides} slides contain fewer than two extractable words`,
|
|
573
|
+
observed: emptySlides,
|
|
574
|
+
expected: 0
|
|
575
|
+
},
|
|
576
|
+
{
|
|
577
|
+
id: "slide-density",
|
|
578
|
+
passed: overfullSlides === 0,
|
|
579
|
+
required: false,
|
|
580
|
+
message: `${overfullSlides} slides contain more than 120 extractable words`,
|
|
581
|
+
observed: overfullSlides,
|
|
582
|
+
expected: 0
|
|
583
|
+
},
|
|
584
|
+
{
|
|
585
|
+
id: "theme-part",
|
|
586
|
+
passed: Object.keys(zip.files).some((name) => /^ppt\/theme\/theme\d+\.xml$/.test(name)),
|
|
587
|
+
required: true,
|
|
588
|
+
message: "Presentation contains an Office theme part",
|
|
589
|
+
observed: Object.keys(zip.files).some((name) => /^ppt\/theme\/theme\d+\.xml$/.test(name)),
|
|
590
|
+
expected: true
|
|
591
|
+
}
|
|
592
|
+
];
|
|
593
|
+
return summarizeChecks(checks, {
|
|
594
|
+
byteSize,
|
|
595
|
+
packageEntries: Object.keys(zip.files).length,
|
|
596
|
+
slideCount: slidePaths.length,
|
|
597
|
+
emptySlides,
|
|
598
|
+
overfullSlides,
|
|
599
|
+
extractableWordCount: countWords(allText)
|
|
600
|
+
});
|
|
601
|
+
}
|
|
602
|
+
async function judgeDocx(zip, byteSize, testCase) {
|
|
603
|
+
const documentXml = await optionalZipText(zip, "word/document.xml");
|
|
604
|
+
const text = documentXml ? extractXmlText(documentXml, "w:t") : "";
|
|
605
|
+
const paragraphCount = documentXml?.match(/<w:p(?:\s|>)/g)?.length ?? 0;
|
|
606
|
+
const headingCount = documentXml?.match(/<w:pStyle\s+[^>]*w:val="Heading[1-9]"/g)?.length ?? 0;
|
|
607
|
+
const tableCount = documentXml?.match(/<w:tbl(?:\s|>)/g)?.length ?? 0;
|
|
608
|
+
const checks = [
|
|
609
|
+
packagePartCheck(zip, "[Content_Types].xml"),
|
|
610
|
+
packagePartCheck(zip, "word/document.xml"),
|
|
611
|
+
packagePartCheck(zip, "word/styles.xml"),
|
|
612
|
+
{
|
|
613
|
+
id: "extractable-content",
|
|
614
|
+
passed: countWords(text) >= Math.round(testCase.expectation.minWords * 0.75),
|
|
615
|
+
required: true,
|
|
616
|
+
message: `DOCX contains ${countWords(text)} extractable words`,
|
|
617
|
+
observed: countWords(text),
|
|
618
|
+
expected: `>=${Math.round(testCase.expectation.minWords * 0.75)}`
|
|
619
|
+
},
|
|
620
|
+
{
|
|
621
|
+
id: "paragraph-structure",
|
|
622
|
+
passed: paragraphCount >= testCase.expectation.minItems,
|
|
623
|
+
required: true,
|
|
624
|
+
message: `DOCX contains ${paragraphCount} paragraphs`,
|
|
625
|
+
observed: paragraphCount,
|
|
626
|
+
expected: `>=${testCase.expectation.minItems}`
|
|
627
|
+
},
|
|
628
|
+
{
|
|
629
|
+
id: "heading-structure",
|
|
630
|
+
passed: headingCount >= Math.min(3, testCase.expectation.minItems),
|
|
631
|
+
required: false,
|
|
632
|
+
message: `DOCX contains ${headingCount} native heading-style paragraphs`,
|
|
633
|
+
observed: headingCount,
|
|
634
|
+
expected: `>=${Math.min(3, testCase.expectation.minItems)}`
|
|
635
|
+
}
|
|
636
|
+
];
|
|
637
|
+
return summarizeChecks(checks, {
|
|
638
|
+
byteSize,
|
|
639
|
+
packageEntries: Object.keys(zip.files).length,
|
|
640
|
+
extractableWordCount: countWords(text),
|
|
641
|
+
paragraphCount,
|
|
642
|
+
headingCount,
|
|
643
|
+
tableCount
|
|
644
|
+
});
|
|
645
|
+
}
|
|
646
|
+
function packagePartCheck(zip, path6) {
|
|
647
|
+
const present = Boolean(zip.file(path6));
|
|
648
|
+
return {
|
|
649
|
+
id: `package-part:${path6}`,
|
|
650
|
+
passed: present,
|
|
651
|
+
required: true,
|
|
652
|
+
message: `${path6} is ${present ? "present" : "missing"}`,
|
|
653
|
+
observed: present,
|
|
654
|
+
expected: true
|
|
655
|
+
};
|
|
656
|
+
}
|
|
657
|
+
function rangeCheck2(id, observed, minimum, maximum) {
|
|
658
|
+
return {
|
|
659
|
+
id,
|
|
660
|
+
passed: observed >= minimum && observed <= maximum,
|
|
661
|
+
required: true,
|
|
662
|
+
message: `${id}: ${observed}; expected ${minimum}-${maximum}`,
|
|
663
|
+
observed,
|
|
664
|
+
expected: `${minimum}-${maximum}`
|
|
665
|
+
};
|
|
666
|
+
}
|
|
667
|
+
function numericOfficePartSort(left, right) {
|
|
668
|
+
return Number(left.match(/(\d+)\.xml$/)?.[1] ?? 0) - Number(right.match(/(\d+)\.xml$/)?.[1] ?? 0);
|
|
669
|
+
}
|
|
670
|
+
function extractXmlText(xml, tagName) {
|
|
671
|
+
const escaped = tagName.replace(":", "\\:");
|
|
672
|
+
const matches = xml.matchAll(
|
|
673
|
+
new RegExp(`<${escaped}(?:\\s[^>]*)?>([\\s\\S]*?)<\\/${escaped}>`, "g")
|
|
674
|
+
);
|
|
675
|
+
return [...matches].map((match) => decodeXml(match[1])).join(" ").replace(/\s+/g, " ").trim();
|
|
676
|
+
}
|
|
677
|
+
function decodeXml(value) {
|
|
678
|
+
return value.replace(/</g, "<").replace(/>/g, ">").replace(/"/g, '"').replace(/'/g, "'").replace(/&/g, "&");
|
|
679
|
+
}
|
|
680
|
+
async function requiredZipText(zip, path6) {
|
|
681
|
+
const value = await optionalZipText(zip, path6);
|
|
682
|
+
if (value === null) throw new Error(`Missing Office package part: ${path6}`);
|
|
683
|
+
return value;
|
|
684
|
+
}
|
|
685
|
+
async function optionalZipText(zip, path6) {
|
|
686
|
+
const file = zip.file(path6);
|
|
687
|
+
return file ? file.async("string") : null;
|
|
688
|
+
}
|
|
689
|
+
function missingArtifact(caught) {
|
|
690
|
+
const message = caught instanceof Error ? caught.message : String(caught);
|
|
691
|
+
return summarizeChecks(
|
|
692
|
+
[
|
|
693
|
+
{
|
|
694
|
+
id: "artifact-exists",
|
|
695
|
+
passed: false,
|
|
696
|
+
required: true,
|
|
697
|
+
message: `Artifact could not be read: ${message}`,
|
|
698
|
+
observed: false,
|
|
699
|
+
expected: true
|
|
700
|
+
}
|
|
701
|
+
],
|
|
702
|
+
{ byteSize: 0 }
|
|
703
|
+
);
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
// src/eval/codex-host.ts
|
|
707
|
+
import { readFile as readFile3, writeFile as writeFile2 } from "fs/promises";
|
|
708
|
+
import path3 from "path";
|
|
709
|
+
import { z } from "zod";
|
|
710
|
+
|
|
711
|
+
// src/eval/schemas.ts
|
|
712
|
+
function generationOutputSchema(testCase) {
|
|
713
|
+
return {
|
|
714
|
+
type: "object",
|
|
715
|
+
properties: {
|
|
716
|
+
artifactPath: { type: "string", const: testCase.artifactFilename },
|
|
717
|
+
targetFormat: { type: "string", const: testCase.targetFormat },
|
|
718
|
+
fidelity: { type: "string", minLength: 1, maxLength: 64 },
|
|
719
|
+
markdown: { type: "string", minLength: 1, maxLength: 5e5 },
|
|
720
|
+
summary: { type: "string", minLength: 1, maxLength: 2e3 },
|
|
721
|
+
validationRepairs: {
|
|
722
|
+
type: "array",
|
|
723
|
+
items: { type: "string", minLength: 1, maxLength: 1e3 },
|
|
724
|
+
maxItems: 50
|
|
725
|
+
}
|
|
726
|
+
},
|
|
727
|
+
required: [
|
|
728
|
+
"artifactPath",
|
|
729
|
+
"targetFormat",
|
|
730
|
+
"fidelity",
|
|
731
|
+
"markdown",
|
|
732
|
+
"summary",
|
|
733
|
+
"validationRepairs"
|
|
734
|
+
],
|
|
735
|
+
additionalProperties: false
|
|
736
|
+
};
|
|
737
|
+
}
|
|
738
|
+
var LLM_JUDGE_OUTPUT_SCHEMA = Object.freeze({
|
|
739
|
+
type: "object",
|
|
740
|
+
properties: {
|
|
741
|
+
scores: {
|
|
742
|
+
type: "object",
|
|
743
|
+
properties: {
|
|
744
|
+
briefAdherence: { type: "integer", minimum: 1, maximum: 5 },
|
|
745
|
+
contentQuality: { type: "integer", minimum: 1, maximum: 5 },
|
|
746
|
+
structure: { type: "integer", minimum: 1, maximum: 5 },
|
|
747
|
+
formatFitness: { type: "integer", minimum: 1, maximum: 5 },
|
|
748
|
+
squisqUsage: { type: "integer", minimum: 1, maximum: 5 },
|
|
749
|
+
overall: { type: "integer", minimum: 1, maximum: 5 }
|
|
750
|
+
},
|
|
751
|
+
required: [
|
|
752
|
+
"briefAdherence",
|
|
753
|
+
"contentQuality",
|
|
754
|
+
"structure",
|
|
755
|
+
"formatFitness",
|
|
756
|
+
"squisqUsage",
|
|
757
|
+
"overall"
|
|
758
|
+
],
|
|
759
|
+
additionalProperties: false
|
|
760
|
+
},
|
|
761
|
+
strengths: {
|
|
762
|
+
type: "array",
|
|
763
|
+
items: { type: "string", minLength: 1, maxLength: 1e3 },
|
|
764
|
+
minItems: 1,
|
|
765
|
+
maxItems: 8
|
|
766
|
+
},
|
|
767
|
+
weaknesses: {
|
|
768
|
+
type: "array",
|
|
769
|
+
items: { type: "string", minLength: 1, maxLength: 1e3 },
|
|
770
|
+
minItems: 1,
|
|
771
|
+
maxItems: 8
|
|
772
|
+
},
|
|
773
|
+
unsupportedClaims: {
|
|
774
|
+
type: "array",
|
|
775
|
+
items: { type: "string", minLength: 1, maxLength: 1e3 },
|
|
776
|
+
maxItems: 12
|
|
777
|
+
},
|
|
778
|
+
missingRequestedElements: {
|
|
779
|
+
type: "array",
|
|
780
|
+
items: { type: "string", minLength: 1, maxLength: 1e3 },
|
|
781
|
+
maxItems: 12
|
|
782
|
+
},
|
|
783
|
+
recommendation: { type: "string", minLength: 1, maxLength: 2e3 }
|
|
784
|
+
},
|
|
785
|
+
required: [
|
|
786
|
+
"scores",
|
|
787
|
+
"strengths",
|
|
788
|
+
"weaknesses",
|
|
789
|
+
"unsupportedClaims",
|
|
790
|
+
"missingRequestedElements",
|
|
791
|
+
"recommendation"
|
|
792
|
+
],
|
|
793
|
+
additionalProperties: false
|
|
794
|
+
});
|
|
795
|
+
|
|
796
|
+
// src/eval/subprocess.ts
|
|
797
|
+
import { spawn } from "child_process";
|
|
798
|
+
import { access } from "fs/promises";
|
|
799
|
+
import path2 from "path";
|
|
800
|
+
var MAX_CAPTURE_BYTES = 64 * 1024 * 1024;
|
|
801
|
+
async function resolveCodexCommand(command) {
|
|
802
|
+
if (process.platform !== "win32") return { command, prefixArgs: [] };
|
|
803
|
+
const normalized = command.toLowerCase();
|
|
804
|
+
if (normalized !== "codex" && normalized !== "codex.cmd" && !normalized.endsWith("codex.cmd")) {
|
|
805
|
+
return { command, prefixArgs: [] };
|
|
806
|
+
}
|
|
807
|
+
const npmRoot = path2.isAbsolute(command) ? path2.dirname(command) : process.env.APPDATA ? path2.join(process.env.APPDATA, "npm") : null;
|
|
808
|
+
if (!npmRoot) return { command, prefixArgs: [] };
|
|
809
|
+
const script = path2.join(npmRoot, "node_modules", "@openai", "codex", "bin", "codex.js");
|
|
810
|
+
try {
|
|
811
|
+
await access(script);
|
|
812
|
+
return { command: process.execPath, prefixArgs: [script] };
|
|
813
|
+
} catch {
|
|
814
|
+
return { command, prefixArgs: [] };
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
async function runCapturedProcess(options) {
|
|
818
|
+
const started = Date.now();
|
|
819
|
+
return new Promise((resolve, reject) => {
|
|
820
|
+
const child = spawn(options.command, [...options.args], {
|
|
821
|
+
cwd: options.cwd,
|
|
822
|
+
windowsHide: true,
|
|
823
|
+
stdio: ["pipe", "pipe", "pipe"]
|
|
824
|
+
});
|
|
825
|
+
const stdout = [];
|
|
826
|
+
const stderr = [];
|
|
827
|
+
let stdoutBytes = 0;
|
|
828
|
+
let stderrBytes = 0;
|
|
829
|
+
let captureError = null;
|
|
830
|
+
const timer = setTimeout(() => {
|
|
831
|
+
captureError = new Error(`Process timed out after ${options.timeoutMs}ms`);
|
|
832
|
+
child.kill();
|
|
833
|
+
}, options.timeoutMs);
|
|
834
|
+
child.stdout.on("data", (chunk) => {
|
|
835
|
+
stdoutBytes += chunk.byteLength;
|
|
836
|
+
if (stdoutBytes > MAX_CAPTURE_BYTES) {
|
|
837
|
+
captureError = new Error(`Process stdout exceeded ${MAX_CAPTURE_BYTES} bytes`);
|
|
838
|
+
child.kill();
|
|
839
|
+
return;
|
|
840
|
+
}
|
|
841
|
+
stdout.push(chunk);
|
|
842
|
+
});
|
|
843
|
+
child.stderr.on("data", (chunk) => {
|
|
844
|
+
stderrBytes += chunk.byteLength;
|
|
845
|
+
if (stderrBytes > MAX_CAPTURE_BYTES) {
|
|
846
|
+
captureError = new Error(`Process stderr exceeded ${MAX_CAPTURE_BYTES} bytes`);
|
|
847
|
+
child.kill();
|
|
848
|
+
return;
|
|
849
|
+
}
|
|
850
|
+
stderr.push(chunk);
|
|
851
|
+
});
|
|
852
|
+
child.once("error", (error) => {
|
|
853
|
+
clearTimeout(timer);
|
|
854
|
+
reject(error);
|
|
855
|
+
});
|
|
856
|
+
child.once("close", (code) => {
|
|
857
|
+
clearTimeout(timer);
|
|
858
|
+
if (captureError) {
|
|
859
|
+
reject(captureError);
|
|
860
|
+
return;
|
|
861
|
+
}
|
|
862
|
+
resolve({
|
|
863
|
+
command: options.command,
|
|
864
|
+
args: options.args,
|
|
865
|
+
exitCode: code ?? -1,
|
|
866
|
+
stdout: Buffer.concat(stdout).toString("utf8"),
|
|
867
|
+
stderr: Buffer.concat(stderr).toString("utf8"),
|
|
868
|
+
durationMs: Date.now() - started
|
|
869
|
+
});
|
|
870
|
+
});
|
|
871
|
+
child.stdin.end(options.stdin ?? "");
|
|
872
|
+
});
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
// src/eval/codex-host.ts
|
|
876
|
+
var LLM_JUDGE_CONTRACT_VERSION = 3;
|
|
877
|
+
var generationResultSchema = z.object({
|
|
878
|
+
artifactPath: z.string().min(1),
|
|
879
|
+
targetFormat: z.enum(["pptx", "docx"]),
|
|
880
|
+
fidelity: z.string().min(1),
|
|
881
|
+
markdown: z.string().min(1),
|
|
882
|
+
summary: z.string().min(1),
|
|
883
|
+
validationRepairs: z.array(z.string())
|
|
884
|
+
}).strict();
|
|
885
|
+
var llmJudgeResultSchema = z.object({
|
|
886
|
+
scores: z.object({
|
|
887
|
+
briefAdherence: z.number().int().min(1).max(5),
|
|
888
|
+
contentQuality: z.number().int().min(1).max(5),
|
|
889
|
+
structure: z.number().int().min(1).max(5),
|
|
890
|
+
formatFitness: z.number().int().min(1).max(5),
|
|
891
|
+
squisqUsage: z.number().int().min(1).max(5),
|
|
892
|
+
overall: z.number().int().min(1).max(5)
|
|
893
|
+
}).strict(),
|
|
894
|
+
strengths: z.array(z.string()).min(1),
|
|
895
|
+
weaknesses: z.array(z.string()).min(1),
|
|
896
|
+
unsupportedClaims: z.array(z.string()),
|
|
897
|
+
missingRequestedElements: z.array(z.string()),
|
|
898
|
+
recommendation: z.string().min(1)
|
|
899
|
+
}).strict();
|
|
900
|
+
async function runGeneration(options, testCase, caseDirectory) {
|
|
901
|
+
const outputSchemaPath = path3.join(caseDirectory, "generation-output.schema.json");
|
|
902
|
+
const finalPath = path3.join(caseDirectory, "generation-final.json");
|
|
903
|
+
await writeFile2(
|
|
904
|
+
outputSchemaPath,
|
|
905
|
+
`${JSON.stringify(generationOutputSchema(testCase), null, 2)}
|
|
906
|
+
`
|
|
907
|
+
);
|
|
908
|
+
const prompt = generationPrompt(testCase, options.profile);
|
|
909
|
+
await writeFile2(path3.join(caseDirectory, "generation-prompt.txt"), prompt);
|
|
910
|
+
const resolved = await resolveCodexCommand(options.codexCommand);
|
|
911
|
+
const args = [
|
|
912
|
+
...resolved.prefixArgs,
|
|
913
|
+
...buildCodexGenerationArgs(options, testCase, caseDirectory, outputSchemaPath, finalPath)
|
|
914
|
+
];
|
|
915
|
+
const capture = await runCapturedProcess({
|
|
916
|
+
command: resolved.command,
|
|
917
|
+
args,
|
|
918
|
+
cwd: options.repoRoot,
|
|
919
|
+
stdin: prompt,
|
|
920
|
+
timeoutMs: 15 * 60 * 1e3
|
|
921
|
+
});
|
|
922
|
+
await Promise.all([
|
|
923
|
+
writeFile2(path3.join(caseDirectory, "generation-trace.jsonl"), capture.stdout),
|
|
924
|
+
writeFile2(path3.join(caseDirectory, "generation-stderr.log"), capture.stderr)
|
|
925
|
+
]);
|
|
926
|
+
requireSuccessfulCodex(capture, "generation");
|
|
927
|
+
const parsed = generationResultSchema.parse(JSON.parse(await readFile3(finalPath, "utf8")));
|
|
928
|
+
if (parsed.artifactPath !== testCase.artifactFilename) {
|
|
929
|
+
throw new Error(
|
|
930
|
+
`Generation returned artifactPath ${JSON.stringify(parsed.artifactPath)}; expected ${JSON.stringify(testCase.artifactFilename)}`
|
|
931
|
+
);
|
|
932
|
+
}
|
|
933
|
+
if (parsed.targetFormat !== testCase.targetFormat) {
|
|
934
|
+
throw new Error(
|
|
935
|
+
`Generation returned targetFormat ${parsed.targetFormat}; expected ${testCase.targetFormat}`
|
|
936
|
+
);
|
|
937
|
+
}
|
|
938
|
+
return {
|
|
939
|
+
result: parsed,
|
|
940
|
+
process: capture,
|
|
941
|
+
traceMarkdown: extractTraceMarkdown(capture.stdout),
|
|
942
|
+
usage: extractTokenUsage(capture.stdout),
|
|
943
|
+
traceMetrics: extractMcpTraceMetrics(capture.stdout)
|
|
944
|
+
};
|
|
945
|
+
}
|
|
946
|
+
async function runLlmJudge(options, testCase, markdown, caseDirectory) {
|
|
947
|
+
const outputSchemaPath = path3.join(caseDirectory, "judge-output.schema.json");
|
|
948
|
+
const finalPath = path3.join(caseDirectory, "judge-final.json");
|
|
949
|
+
await writeFile2(outputSchemaPath, `${JSON.stringify(LLM_JUDGE_OUTPUT_SCHEMA, null, 2)}
|
|
950
|
+
`);
|
|
951
|
+
const prompt = judgePrompt(testCase, markdown);
|
|
952
|
+
await writeFile2(path3.join(caseDirectory, "judge-prompt.txt"), prompt);
|
|
953
|
+
const resolved = await resolveCodexCommand(options.codexCommand);
|
|
954
|
+
const args = [
|
|
955
|
+
...resolved.prefixArgs,
|
|
956
|
+
"exec",
|
|
957
|
+
"--ephemeral",
|
|
958
|
+
"--json",
|
|
959
|
+
"--sandbox",
|
|
960
|
+
"read-only",
|
|
961
|
+
"--skip-git-repo-check",
|
|
962
|
+
"--ignore-user-config",
|
|
963
|
+
"--ignore-rules",
|
|
964
|
+
"--color",
|
|
965
|
+
"never",
|
|
966
|
+
"--cd",
|
|
967
|
+
caseDirectory,
|
|
968
|
+
"--output-schema",
|
|
969
|
+
outputSchemaPath,
|
|
970
|
+
"--output-last-message",
|
|
971
|
+
finalPath,
|
|
972
|
+
...options.model ? ["--model", options.model] : [],
|
|
973
|
+
"-"
|
|
974
|
+
];
|
|
975
|
+
const capture = await runCapturedProcess({
|
|
976
|
+
command: resolved.command,
|
|
977
|
+
args,
|
|
978
|
+
cwd: options.repoRoot,
|
|
979
|
+
stdin: prompt,
|
|
980
|
+
timeoutMs: 10 * 60 * 1e3
|
|
981
|
+
});
|
|
982
|
+
await Promise.all([
|
|
983
|
+
writeFile2(path3.join(caseDirectory, "judge-trace.jsonl"), capture.stdout),
|
|
984
|
+
writeFile2(path3.join(caseDirectory, "judge-stderr.log"), capture.stderr)
|
|
985
|
+
]);
|
|
986
|
+
requireSuccessfulCodex(capture, "judge");
|
|
987
|
+
return {
|
|
988
|
+
result: llmJudgeResultSchema.parse(JSON.parse(await readFile3(finalPath, "utf8"))),
|
|
989
|
+
process: capture,
|
|
990
|
+
usage: extractTokenUsage(capture.stdout)
|
|
991
|
+
};
|
|
992
|
+
}
|
|
993
|
+
function buildCodexGenerationArgs(options, testCase, caseDirectory, outputSchemaPath, finalPath) {
|
|
994
|
+
const mcpArgs = [
|
|
995
|
+
options.cliEntry,
|
|
996
|
+
"mcp",
|
|
997
|
+
"--allow-write",
|
|
998
|
+
caseDirectory,
|
|
999
|
+
"--operation-timeout-ms",
|
|
1000
|
+
"180000",
|
|
1001
|
+
"--artifact-ttl-ms",
|
|
1002
|
+
"3600000"
|
|
1003
|
+
];
|
|
1004
|
+
return [
|
|
1005
|
+
"exec",
|
|
1006
|
+
"--ephemeral",
|
|
1007
|
+
"--json",
|
|
1008
|
+
"--sandbox",
|
|
1009
|
+
"read-only",
|
|
1010
|
+
"--skip-git-repo-check",
|
|
1011
|
+
"--ignore-user-config",
|
|
1012
|
+
"--ignore-rules",
|
|
1013
|
+
"--color",
|
|
1014
|
+
"never",
|
|
1015
|
+
"--cd",
|
|
1016
|
+
caseDirectory,
|
|
1017
|
+
"-c",
|
|
1018
|
+
`mcp_servers.docblocks.command=${tomlString(process.execPath)}`,
|
|
1019
|
+
"-c",
|
|
1020
|
+
`mcp_servers.docblocks.args=${tomlArray(mcpArgs)}`,
|
|
1021
|
+
"-c",
|
|
1022
|
+
`mcp_servers.docblocks.cwd=${tomlString(options.repoRoot)}`,
|
|
1023
|
+
"-c",
|
|
1024
|
+
"mcp_servers.docblocks.required=true",
|
|
1025
|
+
"-c",
|
|
1026
|
+
'mcp_servers.docblocks.default_tools_approval_mode="approve"',
|
|
1027
|
+
"-c",
|
|
1028
|
+
"mcp_servers.docblocks.startup_timeout_sec=30",
|
|
1029
|
+
"-c",
|
|
1030
|
+
"mcp_servers.docblocks.tool_timeout_sec=240",
|
|
1031
|
+
"--output-schema",
|
|
1032
|
+
outputSchemaPath,
|
|
1033
|
+
"--output-last-message",
|
|
1034
|
+
finalPath,
|
|
1035
|
+
...options.model ? ["--model", options.model] : [],
|
|
1036
|
+
"-"
|
|
1037
|
+
];
|
|
1038
|
+
}
|
|
1039
|
+
function generationPrompt(testCase, profile) {
|
|
1040
|
+
return `You are the authoring agent in a controlled DocBlocks MCP content evaluation.
|
|
1041
|
+
|
|
1042
|
+
${EVAL_PROMPT_PROFILES[profile]}
|
|
1043
|
+
|
|
1044
|
+
Required execution contract:
|
|
1045
|
+
1. Use the configured DocBlocks MCP server and its current instructions. Call list_roots before save_artifact and use the returned writable root id.
|
|
1046
|
+
2. Create one ${testCase.targetFormat.toUpperCase()} artifact using editable-native fidelity and save it as exactly ${JSON.stringify(testCase.artifactFilename)} at the writable root. Do not replace an existing file.
|
|
1047
|
+
3. Validate the authored source for ${testCase.targetFormat} before final conversion and address actionable errors.
|
|
1048
|
+
4. Your final structured response must include the exact Markdown used as the source of the final convert_document call. Do not paraphrase or truncate it.
|
|
1049
|
+
5. Complete the task autonomously. Do not ask questions.
|
|
1050
|
+
|
|
1051
|
+
Content brief:
|
|
1052
|
+
${testCase.brief}`;
|
|
1053
|
+
}
|
|
1054
|
+
function judgePrompt(testCase, markdown) {
|
|
1055
|
+
const deterministic = judgeMarkdown(markdown, testCase);
|
|
1056
|
+
return `Act as a strict, independent document-content evaluator. Judge only the supplied authored Markdown against the brief and rubric. Do not assume the Office artifact fixes missing, weak, or invented content. Treat Squisq template annotations as layout intent, not as evidence of quality by themselves.
|
|
1057
|
+
|
|
1058
|
+
Score each dimension from 1 (unusable) to 5 (excellent). The overall score must reflect the lowest material weakness, not merely average the other values. Be concise and actionable.
|
|
1059
|
+
|
|
1060
|
+
List every material claim that is not supported by the supplied brief in unsupportedClaims, quoting or closely identifying the claim. Calculations transparently derived from supplied numbers are supported; causal explanations, capabilities, commitments, owners, dates, benchmarks, and operational details are not facts unless supplied or clearly labeled as proposals or assumptions. List requested elements that are absent or materially incomplete in missingRequestedElements. Use an empty array when none are found.
|
|
1061
|
+
|
|
1062
|
+
The deterministic observations below are authoritative. Do not recount them or claim that an observed value falls outside a range when the numbers show otherwise. The ranges are the harness acceptance envelope; continue to apply any narrower explicit target in the brief.
|
|
1063
|
+
|
|
1064
|
+
Deterministic observations:
|
|
1065
|
+
- Word count: ${String(deterministic.metrics.wordCount)} (acceptance envelope ${testCase.expectation.minWords}-${testCase.expectation.maxWords})
|
|
1066
|
+
- Heading/section count: ${String(deterministic.metrics.headingCount)} (acceptance envelope ${testCase.expectation.minItems}-${testCase.expectation.maxItems})
|
|
1067
|
+
- Sections over ${testCase.expectation.maxWordsPerSection} words: ${String(deterministic.metrics.denseSectionCount)}
|
|
1068
|
+
- Non-content Squisq template annotations: ${String(deterministic.metrics.visualTemplateCount)} (minimum ${testCase.expectation.minVisualTemplates})
|
|
1069
|
+
- Deterministic acceptance: ${deterministic.passed ? "pass" : "fail"}
|
|
1070
|
+
|
|
1071
|
+
Target format: ${testCase.targetFormat}
|
|
1072
|
+
|
|
1073
|
+
Brief:
|
|
1074
|
+
${testCase.brief}
|
|
1075
|
+
|
|
1076
|
+
Rubric:
|
|
1077
|
+
${testCase.rubric.map((item, index) => `${index + 1}. ${item}`).join("\n")}
|
|
1078
|
+
|
|
1079
|
+
Authored Markdown:
|
|
1080
|
+
<document>
|
|
1081
|
+
${markdown}
|
|
1082
|
+
</document>`;
|
|
1083
|
+
}
|
|
1084
|
+
function extractTraceMarkdown(jsonl) {
|
|
1085
|
+
const candidates = [];
|
|
1086
|
+
for (const line of jsonl.split(/\r?\n/)) {
|
|
1087
|
+
if (!line.trim()) continue;
|
|
1088
|
+
let event;
|
|
1089
|
+
try {
|
|
1090
|
+
event = JSON.parse(line);
|
|
1091
|
+
} catch {
|
|
1092
|
+
continue;
|
|
1093
|
+
}
|
|
1094
|
+
collectToolMarkdown(event, candidates);
|
|
1095
|
+
}
|
|
1096
|
+
let selected = null;
|
|
1097
|
+
for (const candidate of candidates) {
|
|
1098
|
+
if (!selected || candidate.priority >= selected.priority) selected = candidate;
|
|
1099
|
+
}
|
|
1100
|
+
return selected?.markdown ?? null;
|
|
1101
|
+
}
|
|
1102
|
+
function extractTokenUsage(jsonl) {
|
|
1103
|
+
let usage = null;
|
|
1104
|
+
for (const line of jsonl.split(/\r?\n/)) {
|
|
1105
|
+
if (!line.trim()) continue;
|
|
1106
|
+
let value;
|
|
1107
|
+
try {
|
|
1108
|
+
value = JSON.parse(line);
|
|
1109
|
+
} catch {
|
|
1110
|
+
continue;
|
|
1111
|
+
}
|
|
1112
|
+
if (!isRecord2(value) || value.type !== "turn.completed" || !isRecord2(value.usage)) continue;
|
|
1113
|
+
const candidate = value.usage;
|
|
1114
|
+
if (typeof candidate.input_tokens === "number" && typeof candidate.cached_input_tokens === "number" && typeof candidate.output_tokens === "number" && typeof candidate.reasoning_output_tokens === "number") {
|
|
1115
|
+
usage = {
|
|
1116
|
+
inputTokens: candidate.input_tokens,
|
|
1117
|
+
cachedInputTokens: candidate.cached_input_tokens,
|
|
1118
|
+
outputTokens: candidate.output_tokens,
|
|
1119
|
+
reasoningOutputTokens: candidate.reasoning_output_tokens
|
|
1120
|
+
};
|
|
1121
|
+
}
|
|
1122
|
+
}
|
|
1123
|
+
return usage;
|
|
1124
|
+
}
|
|
1125
|
+
function extractMcpTraceMetrics(jsonl) {
|
|
1126
|
+
let toolCallCount = 0;
|
|
1127
|
+
let toolArgumentCharacters = 0;
|
|
1128
|
+
let toolResultCharacters = 0;
|
|
1129
|
+
let authoringContextResultCharacters = 0;
|
|
1130
|
+
let authoringContextTextCharacters = 0;
|
|
1131
|
+
let authoringContextStructuredCharacters = 0;
|
|
1132
|
+
for (const line of jsonl.split(/\r?\n/)) {
|
|
1133
|
+
if (!line.trim()) continue;
|
|
1134
|
+
let value;
|
|
1135
|
+
try {
|
|
1136
|
+
value = JSON.parse(line);
|
|
1137
|
+
} catch {
|
|
1138
|
+
continue;
|
|
1139
|
+
}
|
|
1140
|
+
if (!isRecord2(value) || value.type !== "item.completed" || !isRecord2(value.item)) continue;
|
|
1141
|
+
const item = value.item;
|
|
1142
|
+
if (item.type !== "mcp_tool_call" || typeof item.tool !== "string") continue;
|
|
1143
|
+
toolCallCount += 1;
|
|
1144
|
+
toolArgumentCharacters += serializedCharacters(item.arguments);
|
|
1145
|
+
const resultCharacters = serializedCharacters(item.result);
|
|
1146
|
+
toolResultCharacters += resultCharacters;
|
|
1147
|
+
if (!item.tool.endsWith("get_authoring_context")) continue;
|
|
1148
|
+
authoringContextResultCharacters += resultCharacters;
|
|
1149
|
+
if (isRecord2(item.result)) {
|
|
1150
|
+
authoringContextTextCharacters += serializedCharacters(item.result.content);
|
|
1151
|
+
authoringContextStructuredCharacters += serializedCharacters(
|
|
1152
|
+
item.result.structured_content ?? item.result.structuredContent
|
|
1153
|
+
);
|
|
1154
|
+
}
|
|
1155
|
+
}
|
|
1156
|
+
return {
|
|
1157
|
+
toolCallCount,
|
|
1158
|
+
toolArgumentCharacters,
|
|
1159
|
+
toolResultCharacters,
|
|
1160
|
+
authoringContextResultCharacters,
|
|
1161
|
+
authoringContextTextCharacters,
|
|
1162
|
+
authoringContextStructuredCharacters
|
|
1163
|
+
};
|
|
1164
|
+
}
|
|
1165
|
+
function collectToolMarkdown(value, candidates) {
|
|
1166
|
+
if (Array.isArray(value)) {
|
|
1167
|
+
for (const item of value) collectToolMarkdown(item, candidates);
|
|
1168
|
+
return;
|
|
1169
|
+
}
|
|
1170
|
+
if (!isRecord2(value)) return;
|
|
1171
|
+
const toolName = [value.tool, value.name, value.tool_name].find((candidate) => typeof candidate === "string")?.toString();
|
|
1172
|
+
if (toolName) {
|
|
1173
|
+
const priority = toolName.endsWith("convert_document") ? 3 : toolName.endsWith("create_document_bundle") ? 2 : toolName.endsWith("validate_document") ? 1 : 0;
|
|
1174
|
+
if (priority > 0) {
|
|
1175
|
+
for (const args of [value.arguments, value.args, value.input]) {
|
|
1176
|
+
const parsed = parsePossibleJson(args);
|
|
1177
|
+
const markdown = findMarkdown(parsed);
|
|
1178
|
+
if (markdown) candidates.push({ priority, markdown });
|
|
1179
|
+
}
|
|
1180
|
+
}
|
|
1181
|
+
}
|
|
1182
|
+
for (const child of Object.values(value)) collectToolMarkdown(child, candidates);
|
|
1183
|
+
}
|
|
1184
|
+
function findMarkdown(value) {
|
|
1185
|
+
if (Array.isArray(value)) {
|
|
1186
|
+
for (const item of value) {
|
|
1187
|
+
const found = findMarkdown(item);
|
|
1188
|
+
if (found) return found;
|
|
1189
|
+
}
|
|
1190
|
+
return null;
|
|
1191
|
+
}
|
|
1192
|
+
if (!isRecord2(value)) return null;
|
|
1193
|
+
if (typeof value.markdown === "string" && value.markdown.trim()) return value.markdown;
|
|
1194
|
+
for (const child of Object.values(value)) {
|
|
1195
|
+
const found = findMarkdown(child);
|
|
1196
|
+
if (found) return found;
|
|
1197
|
+
}
|
|
1198
|
+
return null;
|
|
1199
|
+
}
|
|
1200
|
+
function parsePossibleJson(value) {
|
|
1201
|
+
if (typeof value !== "string") return value;
|
|
1202
|
+
try {
|
|
1203
|
+
return JSON.parse(value);
|
|
1204
|
+
} catch {
|
|
1205
|
+
return value;
|
|
1206
|
+
}
|
|
1207
|
+
}
|
|
1208
|
+
function serializedCharacters(value) {
|
|
1209
|
+
if (value === void 0) return 0;
|
|
1210
|
+
const normalized = parsePossibleJson(value);
|
|
1211
|
+
const serialized = JSON.stringify(normalized);
|
|
1212
|
+
return serialized?.length ?? 0;
|
|
1213
|
+
}
|
|
1214
|
+
function tomlString(value) {
|
|
1215
|
+
return JSON.stringify(value);
|
|
1216
|
+
}
|
|
1217
|
+
function tomlArray(values) {
|
|
1218
|
+
return `[${values.map(tomlString).join(",")}]`;
|
|
1219
|
+
}
|
|
1220
|
+
function requireSuccessfulCodex(capture, stage) {
|
|
1221
|
+
if (capture.exitCode === 0) return;
|
|
1222
|
+
const tail = capture.stderr.slice(-4e3);
|
|
1223
|
+
throw new Error(`Codex ${stage} exited with code ${capture.exitCode}: ${tail}`);
|
|
1224
|
+
}
|
|
1225
|
+
function isRecord2(value) {
|
|
1226
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1229
|
+
// src/eval/runner.ts
|
|
1230
|
+
async function runEval(options) {
|
|
1231
|
+
const startedAt = /* @__PURE__ */ new Date();
|
|
1232
|
+
const runId = `${timestampId(startedAt)}-${sanitizeLabel(options.label)}`;
|
|
1233
|
+
const directory = path4.join(options.reportsDirectory, runId);
|
|
1234
|
+
await mkdir(options.reportsDirectory, { recursive: true });
|
|
1235
|
+
await mkdir(directory, { recursive: false });
|
|
1236
|
+
const codexVersion = await getCodexVersion(options.codexCommand, options.repoRoot);
|
|
1237
|
+
const git = await getGitProvenance(options.repoRoot);
|
|
1238
|
+
const cliSha256 = sha256(await readFile4(options.cliEntry));
|
|
1239
|
+
const profileSha256 = sha256(EVAL_PROMPT_PROFILES[options.profile]);
|
|
1240
|
+
const judgeContractSha256 = sha256(
|
|
1241
|
+
`${LLM_JUDGE_CONTRACT_VERSION}
|
|
1242
|
+
${JSON.stringify(LLM_JUDGE_OUTPUT_SCHEMA)}`
|
|
1243
|
+
);
|
|
1244
|
+
const host = {
|
|
1245
|
+
codexCommand: options.codexCommand,
|
|
1246
|
+
model: options.model,
|
|
1247
|
+
repoRoot: options.repoRoot,
|
|
1248
|
+
cliEntry: options.cliEntry,
|
|
1249
|
+
profile: options.profile
|
|
1250
|
+
};
|
|
1251
|
+
const results = [];
|
|
1252
|
+
for (const [index, testCase] of options.cases.entries()) {
|
|
1253
|
+
process.stdout.write(
|
|
1254
|
+
`[${index + 1}/${options.cases.length}] ${testCase.id}: generating ${testCase.targetFormat.toUpperCase()}
|
|
1255
|
+
`
|
|
1256
|
+
);
|
|
1257
|
+
const caseDirectory = path4.join(directory, "cases", testCase.id);
|
|
1258
|
+
await mkdir(caseDirectory, { recursive: true });
|
|
1259
|
+
const result = await runOneCase(host, options, testCase, caseDirectory);
|
|
1260
|
+
results.push(result);
|
|
1261
|
+
await writeFile3(
|
|
1262
|
+
path4.join(caseDirectory, "case-result.json"),
|
|
1263
|
+
`${JSON.stringify(result, null, 2)}
|
|
1264
|
+
`
|
|
1265
|
+
);
|
|
1266
|
+
process.stdout.write(
|
|
1267
|
+
`[${index + 1}/${options.cases.length}] ${testCase.id}: ${result.score.toFixed(1)} (${result.passed ? "pass" : "fail"})
|
|
1268
|
+
`
|
|
1269
|
+
);
|
|
1270
|
+
if (options.failFast && !result.passed) break;
|
|
1271
|
+
}
|
|
1272
|
+
const completedAt = /* @__PURE__ */ new Date();
|
|
1273
|
+
const provenance = {
|
|
1274
|
+
schemaVersion: 1,
|
|
1275
|
+
runId,
|
|
1276
|
+
label: options.label,
|
|
1277
|
+
startedAt: startedAt.toISOString(),
|
|
1278
|
+
completedAt: completedAt.toISOString(),
|
|
1279
|
+
suite: options.suite,
|
|
1280
|
+
profile: options.profile,
|
|
1281
|
+
judgeMode: options.judgeMode,
|
|
1282
|
+
threshold: options.threshold,
|
|
1283
|
+
model: options.model,
|
|
1284
|
+
codexCommand: options.codexCommand,
|
|
1285
|
+
codexVersion,
|
|
1286
|
+
nodeVersion: process.version,
|
|
1287
|
+
platform: `${process.platform}-${process.arch}`,
|
|
1288
|
+
gitCommit: git.commit,
|
|
1289
|
+
gitDirty: git.dirty,
|
|
1290
|
+
cliEntry: options.cliEntry,
|
|
1291
|
+
cliSha256,
|
|
1292
|
+
promptProfileSha256: profileSha256,
|
|
1293
|
+
judgeContractSha256
|
|
1294
|
+
};
|
|
1295
|
+
const run = {
|
|
1296
|
+
provenance,
|
|
1297
|
+
score: average(results.map((result) => result.score)),
|
|
1298
|
+
passed: results.length === options.cases.length && results.every((result) => result.passed),
|
|
1299
|
+
cases: results
|
|
1300
|
+
};
|
|
1301
|
+
await writeRunReport(directory, run);
|
|
1302
|
+
return { directory, run };
|
|
1303
|
+
}
|
|
1304
|
+
async function runOneCase(host, options, testCase, caseDirectory) {
|
|
1305
|
+
const expectedArtifactPath = path4.join(caseDirectory, testCase.artifactFilename);
|
|
1306
|
+
try {
|
|
1307
|
+
const generation = await runGeneration(host, testCase, caseDirectory);
|
|
1308
|
+
const traceMarkdown = generation.traceMarkdown;
|
|
1309
|
+
const markdown = traceMarkdown ?? generation.result.markdown;
|
|
1310
|
+
const traceMarkdownMatchesFinal = traceMarkdown ? normalizeMarkdown(traceMarkdown) === normalizeMarkdown(generation.result.markdown) : null;
|
|
1311
|
+
await writeFile3(path4.join(caseDirectory, "authored.md"), markdown);
|
|
1312
|
+
const artifactJudge = await judgeArtifact(expectedArtifactPath, testCase);
|
|
1313
|
+
const markdownJudge = judgeMarkdown(markdown, testCase);
|
|
1314
|
+
await Promise.all([
|
|
1315
|
+
writeFile3(
|
|
1316
|
+
path4.join(caseDirectory, "artifact-static-judge.json"),
|
|
1317
|
+
`${JSON.stringify(artifactJudge, null, 2)}
|
|
1318
|
+
`
|
|
1319
|
+
),
|
|
1320
|
+
writeFile3(
|
|
1321
|
+
path4.join(caseDirectory, "markdown-static-judge.json"),
|
|
1322
|
+
`${JSON.stringify(markdownJudge, null, 2)}
|
|
1323
|
+
`
|
|
1324
|
+
)
|
|
1325
|
+
]);
|
|
1326
|
+
let llmJudge = null;
|
|
1327
|
+
let llmScore = null;
|
|
1328
|
+
let judgeDurationMs = null;
|
|
1329
|
+
let judgeUsage = null;
|
|
1330
|
+
if (options.judgeMode === "both") {
|
|
1331
|
+
process.stdout.write(`[judge] ${testCase.id}: evaluating authored Markdown
|
|
1332
|
+
`);
|
|
1333
|
+
const judged = await runLlmJudge(host, testCase, markdown, caseDirectory);
|
|
1334
|
+
llmJudge = judged.result;
|
|
1335
|
+
llmScore = scoreLlmJudge(llmJudge);
|
|
1336
|
+
judgeDurationMs = judged.process.durationMs;
|
|
1337
|
+
judgeUsage = judged.usage;
|
|
1338
|
+
}
|
|
1339
|
+
const score = combinedScore(artifactJudge.score, markdownJudge.score, llmScore);
|
|
1340
|
+
const passed = artifactJudge.passed && markdownJudge.passed && score >= options.threshold && traceMarkdownMatchesFinal !== false;
|
|
1341
|
+
return {
|
|
1342
|
+
caseId: testCase.id,
|
|
1343
|
+
title: testCase.title,
|
|
1344
|
+
targetFormat: testCase.targetFormat,
|
|
1345
|
+
passed,
|
|
1346
|
+
score,
|
|
1347
|
+
artifactScore: artifactJudge.score,
|
|
1348
|
+
markdownScore: markdownJudge.score,
|
|
1349
|
+
llmScore,
|
|
1350
|
+
markdownSource: traceMarkdown ? "trace" : "final-response",
|
|
1351
|
+
traceMarkdownMatchesFinal,
|
|
1352
|
+
artifactPath: expectedArtifactPath,
|
|
1353
|
+
caseDirectory,
|
|
1354
|
+
generationDurationMs: generation.process.durationMs,
|
|
1355
|
+
judgeDurationMs,
|
|
1356
|
+
generationUsage: generation.usage,
|
|
1357
|
+
judgeUsage,
|
|
1358
|
+
mcpTraceMetrics: generation.traceMetrics,
|
|
1359
|
+
artifactJudge,
|
|
1360
|
+
markdownJudge,
|
|
1361
|
+
llmJudge,
|
|
1362
|
+
failure: traceMarkdownMatchesFinal === false ? "Markdown in the final response does not match the Markdown captured from the final conversion tool call" : null
|
|
1363
|
+
};
|
|
1364
|
+
} catch (caught) {
|
|
1365
|
+
const message = caught instanceof Error ? caught.stack ?? caught.message : String(caught);
|
|
1366
|
+
const failedJudge = failureJudge(message);
|
|
1367
|
+
return {
|
|
1368
|
+
caseId: testCase.id,
|
|
1369
|
+
title: testCase.title,
|
|
1370
|
+
targetFormat: testCase.targetFormat,
|
|
1371
|
+
passed: false,
|
|
1372
|
+
score: 0,
|
|
1373
|
+
artifactScore: 0,
|
|
1374
|
+
markdownScore: 0,
|
|
1375
|
+
llmScore: null,
|
|
1376
|
+
markdownSource: "final-response",
|
|
1377
|
+
traceMarkdownMatchesFinal: null,
|
|
1378
|
+
artifactPath: expectedArtifactPath,
|
|
1379
|
+
caseDirectory,
|
|
1380
|
+
generationDurationMs: 0,
|
|
1381
|
+
judgeDurationMs: null,
|
|
1382
|
+
generationUsage: null,
|
|
1383
|
+
judgeUsage: null,
|
|
1384
|
+
mcpTraceMetrics: null,
|
|
1385
|
+
artifactJudge: failedJudge,
|
|
1386
|
+
markdownJudge: failedJudge,
|
|
1387
|
+
llmJudge: null,
|
|
1388
|
+
failure: message
|
|
1389
|
+
};
|
|
1390
|
+
}
|
|
1391
|
+
}
|
|
1392
|
+
function combinedScore(artifactScore, markdownScore, llmScore) {
|
|
1393
|
+
const components = [
|
|
1394
|
+
{ score: artifactScore, weight: 0.35 },
|
|
1395
|
+
{ score: markdownScore, weight: 0.25 },
|
|
1396
|
+
...llmScore === null ? [] : [{ score: llmScore, weight: 0.4 }]
|
|
1397
|
+
];
|
|
1398
|
+
const totalWeight = components.reduce((sum, component) => sum + component.weight, 0);
|
|
1399
|
+
return round2(
|
|
1400
|
+
components.reduce((sum, component) => sum + component.score * component.weight, 0) / totalWeight
|
|
1401
|
+
);
|
|
1402
|
+
}
|
|
1403
|
+
function scoreLlmJudge(result) {
|
|
1404
|
+
const scores = result.scores;
|
|
1405
|
+
const weighted = scores.briefAdherence + scores.contentQuality + scores.structure + scores.formatFitness + scores.squisqUsage + scores.overall * 2;
|
|
1406
|
+
const qualitativeScore = weighted / 35 * 100;
|
|
1407
|
+
const groundingPenalty = Math.min(
|
|
1408
|
+
40,
|
|
1409
|
+
result.unsupportedClaims.length * 3 + result.missingRequestedElements.length * 5
|
|
1410
|
+
);
|
|
1411
|
+
return round2(Math.max(0, qualitativeScore - groundingPenalty));
|
|
1412
|
+
}
|
|
1413
|
+
async function getCodexVersion(command, cwd) {
|
|
1414
|
+
const resolved = await resolveCodexCommand(command);
|
|
1415
|
+
try {
|
|
1416
|
+
const capture = await runCapturedProcess({
|
|
1417
|
+
command: resolved.command,
|
|
1418
|
+
args: [...resolved.prefixArgs, "--version"],
|
|
1419
|
+
cwd,
|
|
1420
|
+
timeoutMs: 1e4
|
|
1421
|
+
});
|
|
1422
|
+
return capture.exitCode === 0 ? capture.stdout.trim() : "unknown";
|
|
1423
|
+
} catch {
|
|
1424
|
+
return "unknown";
|
|
1425
|
+
}
|
|
1426
|
+
}
|
|
1427
|
+
async function getGitProvenance(cwd) {
|
|
1428
|
+
try {
|
|
1429
|
+
const [commit, status] = await Promise.all([
|
|
1430
|
+
runCapturedProcess({
|
|
1431
|
+
command: "git",
|
|
1432
|
+
args: ["rev-parse", "HEAD"],
|
|
1433
|
+
cwd,
|
|
1434
|
+
timeoutMs: 1e4
|
|
1435
|
+
}),
|
|
1436
|
+
runCapturedProcess({
|
|
1437
|
+
command: "git",
|
|
1438
|
+
args: ["status", "--porcelain"],
|
|
1439
|
+
cwd,
|
|
1440
|
+
timeoutMs: 1e4
|
|
1441
|
+
})
|
|
1442
|
+
]);
|
|
1443
|
+
return {
|
|
1444
|
+
commit: commit.exitCode === 0 ? commit.stdout.trim() : null,
|
|
1445
|
+
dirty: status.exitCode === 0 ? status.stdout.trim().length > 0 : null
|
|
1446
|
+
};
|
|
1447
|
+
} catch {
|
|
1448
|
+
return { commit: null, dirty: null };
|
|
1449
|
+
}
|
|
1450
|
+
}
|
|
1451
|
+
function failureJudge(message) {
|
|
1452
|
+
return summarizeChecks(
|
|
1453
|
+
[
|
|
1454
|
+
{
|
|
1455
|
+
id: "harness-execution",
|
|
1456
|
+
passed: false,
|
|
1457
|
+
required: true,
|
|
1458
|
+
message,
|
|
1459
|
+
observed: false,
|
|
1460
|
+
expected: true
|
|
1461
|
+
}
|
|
1462
|
+
],
|
|
1463
|
+
{}
|
|
1464
|
+
);
|
|
1465
|
+
}
|
|
1466
|
+
function normalizeMarkdown(markdown) {
|
|
1467
|
+
return markdown.replace(/\r\n/g, "\n").trim();
|
|
1468
|
+
}
|
|
1469
|
+
function sha256(value) {
|
|
1470
|
+
return createHash("sha256").update(value).digest("hex");
|
|
1471
|
+
}
|
|
1472
|
+
function timestampId(date) {
|
|
1473
|
+
return date.toISOString().replace(/[-:.]/g, "");
|
|
1474
|
+
}
|
|
1475
|
+
function sanitizeLabel(label) {
|
|
1476
|
+
const sanitized = label.toLowerCase().replace(/[^a-z0-9_-]+/g, "-").replace(/^-+|-+$/g, "");
|
|
1477
|
+
if (!sanitized) throw new Error("Eval label must contain at least one letter or number");
|
|
1478
|
+
return sanitized.slice(0, 64);
|
|
1479
|
+
}
|
|
1480
|
+
function average(values) {
|
|
1481
|
+
return values.length === 0 ? 0 : round2(values.reduce((sum, value) => sum + value, 0) / values.length);
|
|
1482
|
+
}
|
|
1483
|
+
function round2(value) {
|
|
1484
|
+
return Math.round(value * 10) / 10;
|
|
1485
|
+
}
|
|
1486
|
+
|
|
1487
|
+
// src/eval/cli.ts
|
|
1488
|
+
var program = new Command().name("docblocks-mcp-eval").description(
|
|
1489
|
+
"Run reproducible Codex content evaluations against the locally built DocBlocks MCP"
|
|
1490
|
+
);
|
|
1491
|
+
program.command("list").description("List canonical content evaluation cases").action(() => {
|
|
1492
|
+
for (const testCase of MCP_CONTENT_EVAL_CASES) {
|
|
1493
|
+
process.stdout.write(
|
|
1494
|
+
`${testCase.id} ${testCase.targetFormat} ${testCase.suites.join(",")} ${testCase.title}
|
|
1495
|
+
`
|
|
1496
|
+
);
|
|
1497
|
+
}
|
|
1498
|
+
});
|
|
1499
|
+
program.command("run").description("Build one isolated eval run and preserve traces, artifacts, judges, and provenance").option("--suite <suite-or-ids>", "quick, full, or comma-separated case ids", "quick").option("--label <label>", "run label", "baseline").option("--profile <profile>", "baseline or content-first", "baseline").option("--judge <mode>", "static or both", "both").option("--threshold <score>", "passing score from 0 through 100", "70").option("--model <model>", "pin a Codex model; defaults to the Codex CLI default").option(
|
|
1500
|
+
"--codex-command <command>",
|
|
1501
|
+
"Codex executable",
|
|
1502
|
+
process.platform === "win32" ? "codex.cmd" : "codex"
|
|
1503
|
+
).option("--repo-root <directory>", "DocBlocks checkout", process.cwd()).option("--cli-entry <file>", "built DocBlocks CLI entry").option("--reports-dir <directory>", "eval report root").option("--fail-fast", "stop after the first failing case", false).option("--enforce", "exit nonzero when the run misses its threshold", false).action(async (raw) => {
|
|
1504
|
+
const repoRoot = path5.resolve(raw.repoRoot);
|
|
1505
|
+
const profile = requireProfile(raw.profile);
|
|
1506
|
+
const judgeMode = requireJudgeMode(raw.judge);
|
|
1507
|
+
const threshold = requireThreshold(raw.threshold);
|
|
1508
|
+
const cliEntry = path5.resolve(
|
|
1509
|
+
raw.cliEntry ?? path5.join(repoRoot, "packages", "cli", "dist", "bin.js")
|
|
1510
|
+
);
|
|
1511
|
+
const reportsDirectory = path5.resolve(
|
|
1512
|
+
raw.reportsDir ?? path5.join(repoRoot, "reports", "mcp-content-evals")
|
|
1513
|
+
);
|
|
1514
|
+
const result = await runEval({
|
|
1515
|
+
label: raw.label,
|
|
1516
|
+
suite: raw.suite,
|
|
1517
|
+
cases: selectEvalCases(raw.suite),
|
|
1518
|
+
profile,
|
|
1519
|
+
judgeMode,
|
|
1520
|
+
threshold,
|
|
1521
|
+
model: raw.model ?? null,
|
|
1522
|
+
codexCommand: raw.codexCommand,
|
|
1523
|
+
repoRoot,
|
|
1524
|
+
cliEntry,
|
|
1525
|
+
reportsDirectory,
|
|
1526
|
+
failFast: raw.failFast
|
|
1527
|
+
});
|
|
1528
|
+
process.stdout.write(`Run report: ${result.directory}
|
|
1529
|
+
`);
|
|
1530
|
+
process.stdout.write(
|
|
1531
|
+
`Aggregate score: ${result.run.score.toFixed(1)} (${result.run.passed ? "pass" : "fail"})
|
|
1532
|
+
`
|
|
1533
|
+
);
|
|
1534
|
+
if (raw.enforce && !result.run.passed) process.exitCode = 1;
|
|
1535
|
+
});
|
|
1536
|
+
program.command("compare").description("Compare paired cases from a baseline and candidate run").argument("<baseline>", "baseline run directory or run.json").argument("<candidate>", "candidate run directory or run.json").option("--output <directory>", "comparison output directory").option(
|
|
1537
|
+
"--enforce-no-regression",
|
|
1538
|
+
"exit nonzero when any paired case regresses or changes from pass to fail",
|
|
1539
|
+
false
|
|
1540
|
+
).action(async (baselinePath, candidatePath, raw) => {
|
|
1541
|
+
const [baseline, candidate] = await Promise.all([
|
|
1542
|
+
readEvalRun(path5.resolve(baselinePath)),
|
|
1543
|
+
readEvalRun(path5.resolve(candidatePath))
|
|
1544
|
+
]);
|
|
1545
|
+
const comparison = compareEvalRuns(baseline, candidate);
|
|
1546
|
+
const output = path5.resolve(
|
|
1547
|
+
raw.output ?? path5.join(
|
|
1548
|
+
path5.extname(candidatePath) === ".json" ? path5.dirname(candidatePath) : candidatePath,
|
|
1549
|
+
`comparison-vs-${baseline.provenance.runId}`
|
|
1550
|
+
)
|
|
1551
|
+
);
|
|
1552
|
+
await import("fs/promises").then(({ mkdir: mkdir2 }) => mkdir2(output, { recursive: true }));
|
|
1553
|
+
await writeComparisonReport(output, comparison);
|
|
1554
|
+
process.stdout.write(`Comparison report: ${output}
|
|
1555
|
+
`);
|
|
1556
|
+
process.stdout.write(
|
|
1557
|
+
`Aggregate delta: ${comparison.delta >= 0 ? "+" : ""}${comparison.delta.toFixed(1)}
|
|
1558
|
+
`
|
|
1559
|
+
);
|
|
1560
|
+
process.stdout.write(`Case regressions: ${comparison.regressions}
|
|
1561
|
+
`);
|
|
1562
|
+
if (raw.enforceNoRegression && comparison.regressions > 0) process.exitCode = 1;
|
|
1563
|
+
});
|
|
1564
|
+
function requireProfile(value) {
|
|
1565
|
+
if (value in EVAL_PROMPT_PROFILES) return value;
|
|
1566
|
+
throw new Error(`Unknown prompt profile ${JSON.stringify(value)}`);
|
|
1567
|
+
}
|
|
1568
|
+
function requireJudgeMode(value) {
|
|
1569
|
+
if (value === "static" || value === "both") return value;
|
|
1570
|
+
throw new Error(`Judge mode must be static or both; received ${JSON.stringify(value)}`);
|
|
1571
|
+
}
|
|
1572
|
+
function requireThreshold(value) {
|
|
1573
|
+
const parsed = Number(value);
|
|
1574
|
+
if (!Number.isFinite(parsed) || parsed < 0 || parsed > 100) {
|
|
1575
|
+
throw new Error(`Threshold must be between 0 and 100; received ${JSON.stringify(value)}`);
|
|
1576
|
+
}
|
|
1577
|
+
return parsed;
|
|
1578
|
+
}
|
|
1579
|
+
void program.parseAsync().catch((caught) => {
|
|
1580
|
+
const message = caught instanceof Error ? caught.stack ?? caught.message : String(caught);
|
|
1581
|
+
process.stderr.write(`${message}
|
|
1582
|
+
`);
|
|
1583
|
+
process.exitCode = 1;
|
|
1584
|
+
});
|