@fede0089/skill-eval 1.0.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/LICENSE +21 -0
- package/README.md +170 -0
- package/dist/commands/functional.js +225 -0
- package/dist/commands/rate.js +20 -0
- package/dist/commands/show.js +43 -0
- package/dist/commands/trigger.js +154 -0
- package/dist/commands/view.js +30 -0
- package/dist/core/agent-pool.js +40 -0
- package/dist/core/config.js +58 -0
- package/dist/core/environment.js +83 -0
- package/dist/core/errors.js +29 -0
- package/dist/core/eval-runner.js +306 -0
- package/dist/core/evaluator.js +242 -0
- package/dist/core/preflight.js +36 -0
- package/dist/core/reporters/html-reporter.js +354 -0
- package/dist/core/reporters/index.js +9 -0
- package/dist/core/reporters/json-reporter.js +7 -0
- package/dist/core/reporters/reporter.js +1 -0
- package/dist/core/runner.js +75 -0
- package/dist/core/runners/factory.js +18 -0
- package/dist/core/runners/gemini-cli.runner.js +138 -0
- package/dist/core/runners/index.js +3 -0
- package/dist/core/runners/runner.interface.js +1 -0
- package/dist/core/statistics.js +79 -0
- package/dist/core/trial-utils.js +58 -0
- package/dist/index.js +80 -0
- package/dist/reporters/html-reporter.js +384 -0
- package/dist/reporters/index.js +2 -0
- package/dist/reporters/json-reporter.js +10 -0
- package/dist/reporters/reporter.js +1 -0
- package/dist/runners/gemini-cli/index.js +1 -0
- package/dist/runners/gemini-cli/runner.js +231 -0
- package/dist/runners/index.js +2 -0
- package/dist/runners/registry.js +16 -0
- package/dist/runners/runner.interface.js +1 -0
- package/dist/types/index.js +1 -0
- package/dist/utils/eval-loader.js +66 -0
- package/dist/utils/exec.js +9 -0
- package/dist/utils/logger.js +80 -0
- package/dist/utils/ndjson.js +85 -0
- package/dist/utils/table-renderer.js +229 -0
- package/dist/utils/ui.js +166 -0
- package/package.json +49 -0
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
import { Logger } from '../utils/logger.js';
|
|
2
|
+
import { parseNdjsonEvents, parseStreamResult } from '../utils/ndjson.js';
|
|
3
|
+
/**
|
|
4
|
+
* Programmatic grader that checks if a skill was triggered by analyzing tool calls.
|
|
5
|
+
*/
|
|
6
|
+
export class TriggerGrader {
|
|
7
|
+
targetToolKeys;
|
|
8
|
+
dispatchToolName;
|
|
9
|
+
constructor(skillName, dispatchToolName) {
|
|
10
|
+
this.dispatchToolName = dispatchToolName;
|
|
11
|
+
this.targetToolKeys = [
|
|
12
|
+
skillName,
|
|
13
|
+
skillName.replace(/-/g, '_')
|
|
14
|
+
];
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Grades a trial based on whether the skill was triggered.
|
|
18
|
+
* New implementation: Parses JSON stream lines from raw_output.
|
|
19
|
+
*/
|
|
20
|
+
gradeTrigger(transcript) {
|
|
21
|
+
const rawOutput = transcript.raw_output || '';
|
|
22
|
+
const events = parseNdjsonEvents(rawOutput);
|
|
23
|
+
// 1. Look for tool_use event for the configured dispatch tool
|
|
24
|
+
let foundToolUse = false;
|
|
25
|
+
for (let i = 0; i < events.length; i++) {
|
|
26
|
+
const event = events[i];
|
|
27
|
+
if (event.type === 'tool_use' &&
|
|
28
|
+
event.tool_name === this.dispatchToolName &&
|
|
29
|
+
typeof event.parameters?.name === 'string' &&
|
|
30
|
+
this.targetToolKeys.some(key => event.parameters.name.toLowerCase() === key.toLowerCase())) {
|
|
31
|
+
foundToolUse = true;
|
|
32
|
+
const toolId = event.tool_id;
|
|
33
|
+
// 2. Look for subsequent tool_result with matching ID and status success
|
|
34
|
+
for (let j = i + 1; j < events.length; j++) {
|
|
35
|
+
const resultEvent = events[j];
|
|
36
|
+
if (resultEvent.type === 'tool_result' &&
|
|
37
|
+
resultEvent.tool_id === toolId &&
|
|
38
|
+
resultEvent.status === 'success') {
|
|
39
|
+
return true;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
// If we found a tool_use for the dispatch tool but no successful tool_result, do NOT fall back to legacy.
|
|
45
|
+
// However, if no JSON events were parsed at all, we fall back.
|
|
46
|
+
if (events.length > 0) {
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
// Fallback for non-JSON stream output (legacy support)
|
|
50
|
+
// 1. Check structured stats if available
|
|
51
|
+
if (transcript?.stats?.tools?.byName) {
|
|
52
|
+
const byName = transcript.stats.tools.byName;
|
|
53
|
+
const toolNames = Object.keys(byName);
|
|
54
|
+
const dispatchTools = [this.dispatchToolName];
|
|
55
|
+
for (const tool of dispatchTools) {
|
|
56
|
+
if (toolNames.includes(tool)) {
|
|
57
|
+
const metrics = byName[tool];
|
|
58
|
+
const calls = metrics.count ?? metrics.totalCalls ?? 0;
|
|
59
|
+
if (calls > 0)
|
|
60
|
+
return true;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
for (const expectedKey of this.targetToolKeys) {
|
|
64
|
+
const match = toolNames.find(t => t.includes(expectedKey) || expectedKey.includes(t));
|
|
65
|
+
if (match) {
|
|
66
|
+
const metrics = byName[match];
|
|
67
|
+
const calls = metrics.count ?? metrics.totalCalls ?? 0;
|
|
68
|
+
if (calls > 0)
|
|
69
|
+
return true;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
// 2. Fallback to parsing raw text output
|
|
74
|
+
const textToSearch = (transcript.raw_output || transcript.response || '').toLowerCase();
|
|
75
|
+
if (!textToSearch)
|
|
76
|
+
return false;
|
|
77
|
+
const dispatchTools = [this.dispatchToolName];
|
|
78
|
+
for (const tool of dispatchTools) {
|
|
79
|
+
if (textToSearch.includes(tool.toLowerCase())) {
|
|
80
|
+
Logger.debug(`Detected skill dispatch tool "${tool}" in plain text output.`);
|
|
81
|
+
return true;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
for (const expectedKey of this.targetToolKeys) {
|
|
85
|
+
if (textToSearch.includes(expectedKey.toLowerCase())) {
|
|
86
|
+
Logger.debug(`Detected target skill key "${expectedKey}" in plain text output.`);
|
|
87
|
+
return true;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Checks whether the skill was attempted (any tool_use event for activate_skill matching the skill name).
|
|
94
|
+
* Unlike gradeTrigger, does NOT require a successful tool_result — any attempt counts.
|
|
95
|
+
* Used to detect invalid baseline runs where the agent tried to invoke the restricted skill.
|
|
96
|
+
*/
|
|
97
|
+
detectSkillAttempt(transcript) {
|
|
98
|
+
const rawOutput = transcript.raw_output || '';
|
|
99
|
+
for (const event of parseNdjsonEvents(rawOutput)) {
|
|
100
|
+
if (event.type === 'tool_use' &&
|
|
101
|
+
event.tool_name === this.dispatchToolName &&
|
|
102
|
+
typeof event.parameters?.name === 'string' &&
|
|
103
|
+
this.targetToolKeys.some(k => event.parameters.name.toLowerCase() === k.toLowerCase()))
|
|
104
|
+
return true;
|
|
105
|
+
}
|
|
106
|
+
return false;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Within each JSON string literal, replace literal control characters that JSON.parse
|
|
111
|
+
* rejects as "Bad control character in string literal" (common in LLM-generated JSON).
|
|
112
|
+
* Structural whitespace between JSON keys/values is left untouched.
|
|
113
|
+
*/
|
|
114
|
+
function sanitizeJsonControlChars(json) {
|
|
115
|
+
return json.replace(/"(?:[^"\\]|\\.)*"/g, (match) => {
|
|
116
|
+
return match
|
|
117
|
+
.replace(/\n/g, '\\n')
|
|
118
|
+
.replace(/\r/g, '\\r')
|
|
119
|
+
.replace(/\t/g, '\\t')
|
|
120
|
+
.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, '');
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Model-based grader that uses an LLM Judge to verify functional assertions.
|
|
125
|
+
* The judgeRunner is injected so the grader uses the same agent backend as the evaluation,
|
|
126
|
+
* making it easy to swap the runner (e.g. gemini-cli → claude) without touching this class.
|
|
127
|
+
*/
|
|
128
|
+
export class ModelBasedGrader {
|
|
129
|
+
skillName;
|
|
130
|
+
judgeRunner;
|
|
131
|
+
constructor(skillName, judgeRunner) {
|
|
132
|
+
this.skillName = skillName;
|
|
133
|
+
this.judgeRunner = judgeRunner;
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Invokes an LLM Judge to evaluate if assertions are met.
|
|
137
|
+
*/
|
|
138
|
+
async gradeModelBased(prompt, transcript, assertions, workspaceContext, onLog, logPath, worktreePath) {
|
|
139
|
+
if (!assertions || assertions.length === 0) {
|
|
140
|
+
return [];
|
|
141
|
+
}
|
|
142
|
+
const judgePrompt = this.buildJudgePrompt(prompt, transcript.response || '', assertions, workspaceContext);
|
|
143
|
+
const judgeRawOutput = await this.judgeRunner.runPrompt(judgePrompt, worktreePath, onLog, logPath);
|
|
144
|
+
if (!judgeRawOutput) {
|
|
145
|
+
throw new Error('Judge agent failed: no output produced');
|
|
146
|
+
}
|
|
147
|
+
if (judgeRawOutput.error) {
|
|
148
|
+
throw new Error(`Judge agent failed: ${judgeRawOutput.error}`);
|
|
149
|
+
}
|
|
150
|
+
// Extract clean assistant text from the stream-json NDJSON output,
|
|
151
|
+
// using the same parser as the agent run to ensure consistent handling.
|
|
152
|
+
const judgeStreamResult = parseStreamResult(judgeRawOutput.response || '');
|
|
153
|
+
if (!judgeStreamResult || 'error' in judgeStreamResult) {
|
|
154
|
+
const errorMsg = judgeStreamResult && 'error' in judgeStreamResult
|
|
155
|
+
? judgeStreamResult.error
|
|
156
|
+
: 'No result event in judge output';
|
|
157
|
+
throw new Error(`Judge agent failed: ${errorMsg}`);
|
|
158
|
+
}
|
|
159
|
+
const judgeText = judgeStreamResult.response;
|
|
160
|
+
try {
|
|
161
|
+
const jsonMatch = judgeText.match(/\[[\s\S]*\]/);
|
|
162
|
+
const rawJson = jsonMatch ? jsonMatch[0] : judgeText;
|
|
163
|
+
const rawResults = JSON.parse(sanitizeJsonControlChars(rawJson));
|
|
164
|
+
return rawResults.map((r, i) => ({
|
|
165
|
+
assertion: r.assertion || r.expectation || assertions[i] || '',
|
|
166
|
+
passed: !!r.passed,
|
|
167
|
+
reason: r.reason || '',
|
|
168
|
+
graderType: 'model-based'
|
|
169
|
+
}));
|
|
170
|
+
}
|
|
171
|
+
catch (err) {
|
|
172
|
+
Logger.error(`Failed to parse Judge JSON response: ${err instanceof Error ? err.message : String(err)}`);
|
|
173
|
+
Logger.debug(`Raw Judge response: ${judgeRawOutput.response}`);
|
|
174
|
+
return assertions.map(a => ({
|
|
175
|
+
assertion: a,
|
|
176
|
+
passed: false,
|
|
177
|
+
reason: 'Judge agent response was not valid JSON.',
|
|
178
|
+
graderType: 'model-based'
|
|
179
|
+
}));
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
buildJudgePrompt(prompt, response, assertions, context) {
|
|
183
|
+
return `You are a Functional Quality Judge for AI Agent Skills.
|
|
184
|
+
Your task is to evaluate if a skill execution met its functional assertions.
|
|
185
|
+
|
|
186
|
+
Original Prompt: "${prompt}"
|
|
187
|
+
Agent Response: "${response}"
|
|
188
|
+
|
|
189
|
+
Workspace Context (Changes detected):
|
|
190
|
+
${context}
|
|
191
|
+
|
|
192
|
+
Assertions to evaluate:
|
|
193
|
+
${assertions.map((a, i) => `${i + 1}. ${a}`).join('\n')}
|
|
194
|
+
|
|
195
|
+
INSTRUCTIONS:
|
|
196
|
+
1. Analyze the Agent Response and the Workspace Context below.
|
|
197
|
+
You are running in the directory where the agent worked. If any assertion references file content or file existence, you MUST use the read_file tool to verify directly — do not rely solely on the agent's response text.
|
|
198
|
+
IMPORTANT: The Agent Response string might contain CLI system noise, telemetry errors, or tool status warnings (e.g., initialization logs) prepended or appended to the actual reply. You MUST ignore any system-level noise and evaluate the assertions STRICTLY against the agent's intended message and actions.
|
|
199
|
+
2. For each assertion, determine if it was met (passed: true) or not (passed: false).
|
|
200
|
+
3. Provide a brief reasoning for your judgment.
|
|
201
|
+
4. Output your evaluation ONLY as a JSON array of objects with the following structure:
|
|
202
|
+
[
|
|
203
|
+
{
|
|
204
|
+
"assertion": "the exact text of assertion 1",
|
|
205
|
+
"passed": true,
|
|
206
|
+
"reason": "why it passed or failed"
|
|
207
|
+
},
|
|
208
|
+
...
|
|
209
|
+
]
|
|
210
|
+
|
|
211
|
+
Do not include any other text in your response, only the JSON array.`;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
// Backwards compatibility aliases
|
|
215
|
+
export { TriggerGrader as Evaluator };
|
|
216
|
+
export { ModelBasedGrader as FunctionalEvaluator };
|
|
217
|
+
/**
|
|
218
|
+
* Validates a single programmatic assertion against the actual output.
|
|
219
|
+
* Supported types: contains, not_contains, regex, json.
|
|
220
|
+
*/
|
|
221
|
+
export function validateAssertion(assertion, actualOutput) {
|
|
222
|
+
switch (assertion.type) {
|
|
223
|
+
case 'contains':
|
|
224
|
+
return actualOutput.includes(assertion.value);
|
|
225
|
+
case 'not_contains':
|
|
226
|
+
return !actualOutput.includes(assertion.value);
|
|
227
|
+
case 'regex':
|
|
228
|
+
return new RegExp(assertion.value).test(actualOutput);
|
|
229
|
+
case 'json':
|
|
230
|
+
try {
|
|
231
|
+
JSON.parse(actualOutput);
|
|
232
|
+
return true;
|
|
233
|
+
}
|
|
234
|
+
catch {
|
|
235
|
+
return false;
|
|
236
|
+
}
|
|
237
|
+
default:
|
|
238
|
+
return false;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
// Backwards compatibility alias
|
|
242
|
+
export const validateExpectation = validateAssertion;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
import * as path from 'path';
|
|
3
|
+
import { executor } from '../utils/exec.js';
|
|
4
|
+
import { ConfigError, ExecutionError } from './errors.js';
|
|
5
|
+
import { RUNNER_REGISTRY } from '../runners/registry.js';
|
|
6
|
+
/**
|
|
7
|
+
* Validates that the environment is ready to run an evaluation before any
|
|
8
|
+
* worktrees are created or trials are started.
|
|
9
|
+
*
|
|
10
|
+
* Checks:
|
|
11
|
+
* 1. The agent binary is installed and on PATH.
|
|
12
|
+
* 2. The skill path exists and contains an `evals/` subdirectory.
|
|
13
|
+
*
|
|
14
|
+
* @throws ExecutionError if the agent binary is not found.
|
|
15
|
+
* @throws ConfigError if the skill path or its evals/ directory is missing.
|
|
16
|
+
*/
|
|
17
|
+
export function preflight(agent, workspace, skillPath) {
|
|
18
|
+
const binary = RUNNER_REGISTRY[agent]?.binary ?? agent;
|
|
19
|
+
try {
|
|
20
|
+
executor.execSync(`which ${binary}`, { stdio: 'ignore' });
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
throw new ExecutionError(`Agent binary '${binary}' not found on PATH. ` +
|
|
24
|
+
`Please install it before running an evaluation (agent: '${agent}').`);
|
|
25
|
+
}
|
|
26
|
+
const absoluteSkillPath = path.resolve(workspace, skillPath);
|
|
27
|
+
if (!fs.existsSync(absoluteSkillPath)) {
|
|
28
|
+
throw new ConfigError(`Skill path '${skillPath}' does not exist. ` +
|
|
29
|
+
`Provide the path to a directory containing a SKILL.md and an evals/ subdirectory.`);
|
|
30
|
+
}
|
|
31
|
+
const evalsDir = path.join(absoluteSkillPath, 'evals');
|
|
32
|
+
if (!fs.existsSync(evalsDir)) {
|
|
33
|
+
throw new ConfigError(`No 'evals/' directory found inside '${skillPath}'. ` +
|
|
34
|
+
`Create an evals/ directory with at least one JSON evaluation file.`);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { Logger } from '../../utils/logger.js';
|
|
4
|
+
export class HtmlReporter {
|
|
5
|
+
generate(report, runDir) {
|
|
6
|
+
const html = generateHtml(report);
|
|
7
|
+
const htmlPath = path.join(runDir, 'report.html');
|
|
8
|
+
fs.writeFileSync(htmlPath, html, 'utf-8');
|
|
9
|
+
Logger.write(`\n Report: file://${htmlPath}\n`);
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
// HTML generation (module-private)
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
function escapeHtml(s) {
|
|
16
|
+
return s
|
|
17
|
+
.replace(/&/g, '&')
|
|
18
|
+
.replace(/</g, '<')
|
|
19
|
+
.replace(/>/g, '>')
|
|
20
|
+
.replace(/"/g, '"')
|
|
21
|
+
.replace(/'/g, ''');
|
|
22
|
+
}
|
|
23
|
+
function formatPercent(val) {
|
|
24
|
+
return `${Math.round(val * 100)}%`;
|
|
25
|
+
}
|
|
26
|
+
function passColorClass(val) {
|
|
27
|
+
if (val >= 0.8)
|
|
28
|
+
return 'green';
|
|
29
|
+
if (val >= 0.5)
|
|
30
|
+
return 'amber';
|
|
31
|
+
return 'red';
|
|
32
|
+
}
|
|
33
|
+
function isFunctional(report) {
|
|
34
|
+
return report.metrics.withoutSkillScore !== undefined;
|
|
35
|
+
}
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
// Cards
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
function renderCard(label, value, colorClass) {
|
|
40
|
+
return `<div class="card"><div class="card-value ${colorClass}">${escapeHtml(value)}</div><div class="card-label">${escapeHtml(label)}</div></div>`;
|
|
41
|
+
}
|
|
42
|
+
function renderMetricsCards(report) {
|
|
43
|
+
const { metrics } = report;
|
|
44
|
+
const numTrials = metrics.numTrials ?? 1;
|
|
45
|
+
const cards = [];
|
|
46
|
+
if (isFunctional(report)) {
|
|
47
|
+
const bk = metrics.withoutSkillPassAtK ?? 0;
|
|
48
|
+
const tk = metrics.passAtK ?? 0;
|
|
49
|
+
const upliftRaw = parseInt(metrics.skillUplift ?? '0', 10);
|
|
50
|
+
const upliftClass = upliftRaw > 0 ? 'green' : upliftRaw < 0 ? 'red' : 'amber';
|
|
51
|
+
cards.push(renderCard('Without Skill p@1', formatPercent(bk), passColorClass(bk)));
|
|
52
|
+
if (numTrials > 1) {
|
|
53
|
+
const bn = metrics.withoutSkillPassAtN ?? 0;
|
|
54
|
+
cards.push(renderCard(`Without Skill p@${numTrials}`, formatPercent(bn), passColorClass(bn)));
|
|
55
|
+
}
|
|
56
|
+
cards.push(renderCard('With Skill p@1', formatPercent(tk), passColorClass(tk)));
|
|
57
|
+
if (numTrials > 1) {
|
|
58
|
+
const tn = metrics.passAtN ?? 0;
|
|
59
|
+
cards.push(renderCard(`With Skill p@${numTrials}`, formatPercent(tn), passColorClass(tn)));
|
|
60
|
+
}
|
|
61
|
+
cards.push(renderCard('Skill Uplift', escapeHtml(metrics.skillUplift ?? '0%'), upliftClass));
|
|
62
|
+
}
|
|
63
|
+
else {
|
|
64
|
+
const k = metrics.passAtK ?? 0;
|
|
65
|
+
cards.push(renderCard('pass@1', formatPercent(k), passColorClass(k)));
|
|
66
|
+
if (numTrials > 1) {
|
|
67
|
+
const n = metrics.passAtN ?? 0;
|
|
68
|
+
cards.push(renderCard(`pass@${numTrials}`, formatPercent(n), passColorClass(n)));
|
|
69
|
+
}
|
|
70
|
+
cards.push(renderCard('Tasks passed', `${metrics.passedCount}/${metrics.totalCount}`, passColorClass(metrics.passedCount / Math.max(metrics.totalCount, 1))));
|
|
71
|
+
}
|
|
72
|
+
return `<div class="cards">${cards.join('')}</div>`;
|
|
73
|
+
}
|
|
74
|
+
// ---------------------------------------------------------------------------
|
|
75
|
+
// Chart
|
|
76
|
+
// ---------------------------------------------------------------------------
|
|
77
|
+
function renderChart(report) {
|
|
78
|
+
const { results, metrics } = report;
|
|
79
|
+
const numTrials = metrics.numTrials ?? 1;
|
|
80
|
+
const labels = results.map(r => `Task #${r.taskId}`);
|
|
81
|
+
let datasets;
|
|
82
|
+
if (isFunctional(report)) {
|
|
83
|
+
const withoutSkillData = results.map(r => {
|
|
84
|
+
const bt = r.withoutSkillTrials ?? [];
|
|
85
|
+
return bt.length === 0 ? 0 : Math.round((bt.filter(t => t.trialPassed).length / bt.length) * 100);
|
|
86
|
+
});
|
|
87
|
+
const withSkillData = results.map(r => Math.round((r.trials.filter(t => t.trialPassed).length / Math.max(r.trials.length, 1)) * 100));
|
|
88
|
+
datasets = [
|
|
89
|
+
{ label: 'Without Skill p@1', data: withoutSkillData, backgroundColor: '#94a3b8', borderRadius: 4 },
|
|
90
|
+
{ label: 'With Skill p@1', data: withSkillData, backgroundColor: '#3b82f6', borderRadius: 4 },
|
|
91
|
+
];
|
|
92
|
+
}
|
|
93
|
+
else {
|
|
94
|
+
const passData = results.map(r => Math.round((r.trials.filter(t => t.trialPassed).length / Math.max(r.trials.length, 1)) * 100));
|
|
95
|
+
datasets = [{
|
|
96
|
+
label: numTrials > 1 ? 'pass@1' : 'Score',
|
|
97
|
+
data: passData,
|
|
98
|
+
backgroundColor: passData.map(v => v >= 80 ? '#22c55e' : v >= 50 ? '#f59e0b' : '#ef4444'),
|
|
99
|
+
borderRadius: 4,
|
|
100
|
+
}];
|
|
101
|
+
}
|
|
102
|
+
const chartData = JSON.stringify({ labels, datasets });
|
|
103
|
+
return `
|
|
104
|
+
<div class="chart-wrap">
|
|
105
|
+
<script type="application/json" id="chart-data">${chartData}</script>
|
|
106
|
+
<canvas id="eval-chart"></canvas>
|
|
107
|
+
</div>`;
|
|
108
|
+
}
|
|
109
|
+
// ---------------------------------------------------------------------------
|
|
110
|
+
// Trial details
|
|
111
|
+
// ---------------------------------------------------------------------------
|
|
112
|
+
function renderAssertions(assertions) {
|
|
113
|
+
if (assertions.length === 0)
|
|
114
|
+
return '<p class="muted">No assertions recorded.</p>';
|
|
115
|
+
return assertions.map(a => {
|
|
116
|
+
const icon = a.passed ? '✓' : '✗';
|
|
117
|
+
const cls = a.passed ? 'assert-pass' : 'assert-fail';
|
|
118
|
+
const grader = a.graderType ? `<span class="badge">${escapeHtml(a.graderType)}</span>` : '';
|
|
119
|
+
return `<div class="assertion ${cls}">
|
|
120
|
+
<span class="assert-icon">${icon}</span>
|
|
121
|
+
<div class="assert-body">
|
|
122
|
+
<div class="assert-text">${escapeHtml(a.assertion)}${grader}</div>
|
|
123
|
+
${a.reason ? `<div class="assert-reason">${escapeHtml(a.reason)}</div>` : ''}
|
|
124
|
+
</div>
|
|
125
|
+
</div>`;
|
|
126
|
+
}).join('');
|
|
127
|
+
}
|
|
128
|
+
function renderTrial(trial, prefix) {
|
|
129
|
+
const cls = trial.trialPassed ? 'trial-pass' : 'trial-fail';
|
|
130
|
+
const badge = trial.trialPassed
|
|
131
|
+
? '<span class="pill green">PASS</span>'
|
|
132
|
+
: '<span class="pill red">FAIL</span>';
|
|
133
|
+
return `<div class="trial ${cls}">
|
|
134
|
+
<div class="trial-header">${escapeHtml(prefix)} Trial ${trial.id} ${badge}</div>
|
|
135
|
+
<div class="trial-assertions">${renderAssertions(trial.assertionResults)}</div>
|
|
136
|
+
</div>`;
|
|
137
|
+
}
|
|
138
|
+
function renderTaskDetails(result, isFunctionalEval) {
|
|
139
|
+
const sections = [];
|
|
140
|
+
if (isFunctionalEval && result.withoutSkillTrials && result.withoutSkillTrials.length > 0) {
|
|
141
|
+
sections.push('<div class="trial-group-label">Without Skill</div>');
|
|
142
|
+
sections.push(...result.withoutSkillTrials.map(t => renderTrial(t, 'Without Skill')));
|
|
143
|
+
sections.push('<div class="trial-group-label">With Skill</div>');
|
|
144
|
+
}
|
|
145
|
+
sections.push(...result.trials.map(t => renderTrial(t, 'With Skill')));
|
|
146
|
+
return `<div class="task-details" id="details-${result.taskId}">${sections.join('')}</div>`;
|
|
147
|
+
}
|
|
148
|
+
// ---------------------------------------------------------------------------
|
|
149
|
+
// Task table
|
|
150
|
+
// ---------------------------------------------------------------------------
|
|
151
|
+
function renderTaskTable(report) {
|
|
152
|
+
const { results, metrics } = report;
|
|
153
|
+
const numTrials = metrics.numTrials ?? 1;
|
|
154
|
+
const functional = isFunctional(report);
|
|
155
|
+
const headerCells = functional
|
|
156
|
+
? ['#', 'Prompt', 'W/o p@1', 'W/ p@1', 'Details']
|
|
157
|
+
: numTrials > 1
|
|
158
|
+
? ['#', 'Prompt', 'pass@1', `pass@${numTrials}`, 'Details']
|
|
159
|
+
: ['#', 'Prompt', 'Status', 'Details'];
|
|
160
|
+
const headerRow = `<tr>${headerCells.map(h => `<th>${escapeHtml(h)}</th>`).join('')}</tr>`;
|
|
161
|
+
const rows = results.map(result => {
|
|
162
|
+
const prompt = escapeHtml(result.prompt);
|
|
163
|
+
const trials = result.trials;
|
|
164
|
+
const bt = result.withoutSkillTrials ?? [];
|
|
165
|
+
let statCells;
|
|
166
|
+
if (functional) {
|
|
167
|
+
const bp1 = bt.length ? Math.round((bt.filter(t => t.trialPassed).length / bt.length) * 100) : 0;
|
|
168
|
+
const tp1 = trials.length ? Math.round((trials.filter(t => t.trialPassed).length / trials.length) * 100) : 0;
|
|
169
|
+
statCells = `<td class="${passColorClass(bp1 / 100)}">${bp1}%</td><td class="${passColorClass(tp1 / 100)}">${tp1}%</td>`;
|
|
170
|
+
}
|
|
171
|
+
else if (numTrials > 1) {
|
|
172
|
+
const p1 = Math.round((trials.filter(t => t.trialPassed).length / Math.max(trials.length, 1)) * 100);
|
|
173
|
+
const passed = trials.filter(t => t.trialPassed).length;
|
|
174
|
+
const pn = trials.length > 0 ? Math.round((passed / trials.length) * 100) : 0;
|
|
175
|
+
statCells = `<td class="${passColorClass(p1 / 100)}">${p1}%</td><td class="${passColorClass(pn / 100)}">${pn}%</td>`;
|
|
176
|
+
}
|
|
177
|
+
else {
|
|
178
|
+
const passed = trials[0]?.trialPassed ?? false;
|
|
179
|
+
statCells = `<td class="${passed ? 'green' : 'red'}">${passed ? 'PASS' : 'FAIL'}</td>`;
|
|
180
|
+
}
|
|
181
|
+
const detailsBtn = `<button class="details-btn" data-target="details-${result.taskId}">▶</button>`;
|
|
182
|
+
const detailsRow = `<tr class="details-row"><td colspan="${headerCells.length}">${renderTaskDetails(result, functional)}</td></tr>`;
|
|
183
|
+
return `<tr><td>${result.taskId}</td><td class="prompt-cell">${prompt}</td>${statCells}<td>${detailsBtn}</td></tr>${detailsRow}`;
|
|
184
|
+
}).join('');
|
|
185
|
+
return `<div class="table-wrap"><table><thead>${headerRow}</thead><tbody>${rows}</tbody></table></div>`;
|
|
186
|
+
}
|
|
187
|
+
// ---------------------------------------------------------------------------
|
|
188
|
+
// Full document
|
|
189
|
+
// ---------------------------------------------------------------------------
|
|
190
|
+
export function generateHtml(report) {
|
|
191
|
+
const { skill_name, agent, timestamp, metrics } = report;
|
|
192
|
+
const functional = isFunctional(report);
|
|
193
|
+
const evalType = functional ? 'Functional' : 'Trigger';
|
|
194
|
+
const overallScore = metrics.passAtK ?? 0;
|
|
195
|
+
const statusClass = passColorClass(overallScore);
|
|
196
|
+
const formattedDate = new Date(timestamp).toLocaleString();
|
|
197
|
+
return `<!DOCTYPE html>
|
|
198
|
+
<html lang="en">
|
|
199
|
+
<head>
|
|
200
|
+
<meta charset="UTF-8">
|
|
201
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
202
|
+
<title>Skill Eval — ${escapeHtml(skill_name)}</title>
|
|
203
|
+
<script src="https://cdn.jsdelivr.net/npm/chart.js@4/dist/chart.umd.min.js"></script>
|
|
204
|
+
<style>
|
|
205
|
+
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
|
206
|
+
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #f8fafc; color: #1e293b; font-size: 14px; }
|
|
207
|
+
a { color: #3b82f6; }
|
|
208
|
+
|
|
209
|
+
/* Layout */
|
|
210
|
+
.container { max-width: 960px; margin: 0 auto; padding: 24px 16px 48px; }
|
|
211
|
+
|
|
212
|
+
/* Header */
|
|
213
|
+
.header { background: #1e293b; color: #f1f5f9; padding: 24px 28px; border-radius: 10px; margin-bottom: 24px; }
|
|
214
|
+
.header h1 { font-size: 22px; font-weight: 700; margin-bottom: 8px; }
|
|
215
|
+
.header-meta { display: flex; gap: 24px; flex-wrap: wrap; font-size: 13px; color: #94a3b8; }
|
|
216
|
+
.header-meta span b { color: #e2e8f0; }
|
|
217
|
+
.status-bar { height: 4px; border-radius: 2px; margin-top: 16px; }
|
|
218
|
+
.status-bar.green { background: #22c55e; }
|
|
219
|
+
.status-bar.amber { background: #f59e0b; }
|
|
220
|
+
.status-bar.red { background: #ef4444; }
|
|
221
|
+
|
|
222
|
+
/* Cards */
|
|
223
|
+
.cards { display: flex; gap: 12px; flex-wrap: wrap; margin-bottom: 24px; }
|
|
224
|
+
.card { background: #fff; border: 1px solid #e2e8f0; border-radius: 8px; padding: 16px 20px; min-width: 120px; flex: 1; }
|
|
225
|
+
.card-value { font-size: 28px; font-weight: 700; line-height: 1; margin-bottom: 4px; }
|
|
226
|
+
.card-label { font-size: 12px; color: #64748b; text-transform: uppercase; letter-spacing: 0.05em; }
|
|
227
|
+
|
|
228
|
+
/* Color utilities */
|
|
229
|
+
.green { color: #16a34a; }
|
|
230
|
+
.amber { color: #d97706; }
|
|
231
|
+
.red { color: #dc2626; }
|
|
232
|
+
|
|
233
|
+
/* Section */
|
|
234
|
+
.section { background: #fff; border: 1px solid #e2e8f0; border-radius: 8px; margin-bottom: 20px; overflow: hidden; }
|
|
235
|
+
.section-title { font-weight: 600; font-size: 13px; text-transform: uppercase; letter-spacing: 0.05em; color: #64748b; padding: 12px 16px; border-bottom: 1px solid #f1f5f9; }
|
|
236
|
+
|
|
237
|
+
/* Chart */
|
|
238
|
+
.chart-wrap { padding: 16px; }
|
|
239
|
+
#eval-chart { max-height: 300px; }
|
|
240
|
+
|
|
241
|
+
/* Table */
|
|
242
|
+
.table-wrap { overflow-x: auto; }
|
|
243
|
+
table { width: 100%; border-collapse: collapse; }
|
|
244
|
+
th { background: #f8fafc; font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; color: #64748b; padding: 10px 12px; text-align: left; border-bottom: 1px solid #e2e8f0; }
|
|
245
|
+
td { padding: 10px 12px; border-bottom: 1px solid #f1f5f9; vertical-align: top; }
|
|
246
|
+
tr:last-child td { border-bottom: none; }
|
|
247
|
+
.prompt-cell { max-width: 360px; word-break: break-word; color: #334155; }
|
|
248
|
+
|
|
249
|
+
/* Details */
|
|
250
|
+
.details-btn { background: none; border: 1px solid #e2e8f0; border-radius: 4px; cursor: pointer; padding: 2px 8px; font-size: 11px; color: #64748b; transition: background 0.15s; }
|
|
251
|
+
.details-btn:hover { background: #f1f5f9; }
|
|
252
|
+
.details-btn.open { color: #3b82f6; border-color: #3b82f6; }
|
|
253
|
+
.details-row > td { padding: 0; background: #f8fafc; }
|
|
254
|
+
.task-details { display: none; padding: 12px 16px; }
|
|
255
|
+
.task-details.visible { display: block; }
|
|
256
|
+
.trial-group-label { font-size: 11px; font-weight: 600; text-transform: uppercase; color: #94a3b8; letter-spacing: 0.05em; margin: 8px 0 4px; }
|
|
257
|
+
|
|
258
|
+
/* Trials */
|
|
259
|
+
.trial { border: 1px solid #e2e8f0; border-radius: 6px; margin-bottom: 8px; overflow: hidden; }
|
|
260
|
+
.trial-header { display: flex; align-items: center; gap: 8px; padding: 8px 12px; font-weight: 500; font-size: 13px; background: #f8fafc; }
|
|
261
|
+
.trial-pass .trial-header { border-left: 3px solid #22c55e; }
|
|
262
|
+
.trial-fail .trial-header { border-left: 3px solid #ef4444; }
|
|
263
|
+
.trial-assertions { padding: 8px 12px; display: flex; flex-direction: column; gap: 6px; }
|
|
264
|
+
|
|
265
|
+
/* Pills */
|
|
266
|
+
.pill { display: inline-block; font-size: 10px; font-weight: 700; padding: 2px 7px; border-radius: 99px; letter-spacing: 0.05em; }
|
|
267
|
+
.pill.green { background: #dcfce7; color: #15803d; }
|
|
268
|
+
.pill.red { background: #fee2e2; color: #b91c1c; }
|
|
269
|
+
|
|
270
|
+
/* Badge */
|
|
271
|
+
.badge { display: inline-block; font-size: 10px; font-weight: 500; padding: 1px 6px; border-radius: 4px; background: #e2e8f0; color: #475569; margin-left: 6px; vertical-align: middle; }
|
|
272
|
+
|
|
273
|
+
/* Assertions */
|
|
274
|
+
.assertion { display: flex; gap: 8px; }
|
|
275
|
+
.assert-icon { flex-shrink: 0; font-size: 14px; margin-top: 1px; }
|
|
276
|
+
.assert-pass .assert-icon { color: #16a34a; }
|
|
277
|
+
.assert-fail .assert-icon { color: #dc2626; }
|
|
278
|
+
.assert-body { flex: 1; min-width: 0; }
|
|
279
|
+
.assert-text { font-size: 13px; color: #1e293b; word-break: break-word; }
|
|
280
|
+
.assert-reason { font-size: 12px; color: #64748b; margin-top: 2px; word-break: break-word; }
|
|
281
|
+
.muted { color: #94a3b8; font-size: 13px; }
|
|
282
|
+
</style>
|
|
283
|
+
</head>
|
|
284
|
+
<body>
|
|
285
|
+
<div class="container">
|
|
286
|
+
|
|
287
|
+
<!-- Header -->
|
|
288
|
+
<div class="header">
|
|
289
|
+
<h1>${escapeHtml(skill_name)}</h1>
|
|
290
|
+
<div class="header-meta">
|
|
291
|
+
<span><b>Agent</b> ${escapeHtml(agent)}</span>
|
|
292
|
+
<span><b>Type</b> ${evalType}</span>
|
|
293
|
+
<span><b>Date</b> ${escapeHtml(formattedDate)}</span>
|
|
294
|
+
<span><b>Score</b> ${escapeHtml(metrics.withSkillScore)}</span>
|
|
295
|
+
</div>
|
|
296
|
+
<div class="status-bar ${statusClass}"></div>
|
|
297
|
+
</div>
|
|
298
|
+
|
|
299
|
+
<!-- Metric Cards -->
|
|
300
|
+
${renderMetricsCards(report)}
|
|
301
|
+
|
|
302
|
+
<!-- Chart -->
|
|
303
|
+
<div class="section">
|
|
304
|
+
<div class="section-title">Pass rate by task</div>
|
|
305
|
+
${renderChart(report)}
|
|
306
|
+
</div>
|
|
307
|
+
|
|
308
|
+
<!-- Task Table -->
|
|
309
|
+
<div class="section">
|
|
310
|
+
<div class="section-title">Task results</div>
|
|
311
|
+
${renderTaskTable(report)}
|
|
312
|
+
</div>
|
|
313
|
+
|
|
314
|
+
</div>
|
|
315
|
+
<script>
|
|
316
|
+
(function () {
|
|
317
|
+
// Chart
|
|
318
|
+
const rawData = document.getElementById('chart-data');
|
|
319
|
+
if (rawData) {
|
|
320
|
+
const data = JSON.parse(rawData.textContent || '{}');
|
|
321
|
+
const ctx = document.getElementById('eval-chart');
|
|
322
|
+
if (ctx) {
|
|
323
|
+
new Chart(ctx, {
|
|
324
|
+
type: 'bar',
|
|
325
|
+
data: data,
|
|
326
|
+
options: {
|
|
327
|
+
indexAxis: 'y',
|
|
328
|
+
responsive: true,
|
|
329
|
+
plugins: { legend: { display: ${functional ? 'true' : 'false'} } },
|
|
330
|
+
scales: {
|
|
331
|
+
x: { min: 0, max: 100, ticks: { callback: v => v + '%' }, grid: { color: '#f1f5f9' } },
|
|
332
|
+
y: { grid: { display: false } }
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// Accordion
|
|
340
|
+
document.querySelectorAll('.details-btn').forEach(function (btn) {
|
|
341
|
+
btn.addEventListener('click', function () {
|
|
342
|
+
const target = document.getElementById(btn.getAttribute('data-target'));
|
|
343
|
+
if (!target) return;
|
|
344
|
+
const isOpen = target.classList.contains('visible');
|
|
345
|
+
target.classList.toggle('visible', !isOpen);
|
|
346
|
+
btn.classList.toggle('open', !isOpen);
|
|
347
|
+
btn.textContent = isOpen ? '▶' : '▼';
|
|
348
|
+
});
|
|
349
|
+
});
|
|
350
|
+
}());
|
|
351
|
+
</script>
|
|
352
|
+
</body>
|
|
353
|
+
</html>`;
|
|
354
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { HtmlReporter } from './html-reporter.js';
|
|
2
|
+
import { JsonReporter } from './json-reporter.js';
|
|
3
|
+
export { HtmlReporter } from './html-reporter.js';
|
|
4
|
+
export { JsonReporter } from './json-reporter.js';
|
|
5
|
+
export function createReporter(format) {
|
|
6
|
+
if (format === 'html')
|
|
7
|
+
return new HtmlReporter();
|
|
8
|
+
return new JsonReporter();
|
|
9
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|