skillfid 0.1.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/src/report.js ADDED
@@ -0,0 +1,233 @@
1
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+
4
+ import { loadDataset } from './dataset.js';
5
+ import { readJson, readJsonl } from './files.js';
6
+
7
+ const PERFECT_SCORE_TOLERANCE = 1e-9;
8
+ const REPORT_DATA_MARKER = '__SKILLFID_REPORT_DATA__';
9
+ const TEMPLATE_PATH = new URL('../templates/evaluation-report.html', import.meta.url);
10
+
11
+ export async function generateEvaluationReport({ runPath, datasetPath, outputPath, title = 'Skill evaluation' }) {
12
+ const runRoot = path.resolve(runPath);
13
+ const [dataset, knowledge, manifest, summary, answers, judgments, diagnoses, template] = await Promise.all([
14
+ loadDataset(datasetPath),
15
+ readJsonl(path.join(path.resolve(datasetPath), 'knowledge.jsonl')),
16
+ readJson(path.join(runRoot, 'manifest.json')),
17
+ readJson(path.join(runRoot, 'summary.json')),
18
+ readJsonl(path.join(runRoot, 'answers.jsonl')),
19
+ readJsonl(path.join(runRoot, 'judgments.jsonl')),
20
+ readJsonl(path.join(runRoot, 'diagnoses.jsonl')),
21
+ readFile(TEMPLATE_PATH, 'utf8'),
22
+ ]);
23
+ const model = buildReportModel({ title, manifest, summary, dataset, knowledge, answers, judgments, diagnoses });
24
+ const destination = path.resolve(outputPath ?? path.join(runRoot, 'report.html'));
25
+ await mkdir(path.dirname(destination), { recursive: true });
26
+ await writeFile(destination, renderReport(template, model), 'utf8');
27
+ return destination;
28
+ }
29
+
30
+ export function renderReport(template, model) {
31
+ const occurrences = template.split(REPORT_DATA_MARKER).length - 1;
32
+ if (occurrences !== 1) throw new Error(`Report template must contain exactly one ${REPORT_DATA_MARKER} marker`);
33
+ const payload = JSON.stringify(model).replaceAll('<', '\\u003c').replaceAll('>', '\\u003e').replaceAll('&', '\\u0026');
34
+ return template.replace(REPORT_DATA_MARKER, payload);
35
+ }
36
+
37
+ export function buildReportModel({ title, manifest, summary, dataset, knowledge, answers, judgments, diagnoses }) {
38
+ if (manifest.datasetId !== dataset.datasetId || summary.datasetId !== dataset.datasetId) {
39
+ throw new Error(`Evaluation run dataset ${manifest.datasetId} does not match dataset ${dataset.datasetId}`);
40
+ }
41
+
42
+ const answersByTest = groupBy(answers, (answer) => answer.testId);
43
+ const judgmentsByTest = groupBy(judgments.filter((judgment) => judgment.condition === 'skill'), (judgment) => judgment.testId);
44
+ const judgmentsByTrial = new Map(judgments.map((judgment) => [trialKey(judgment), judgment]));
45
+ const diagnosesByTest = new Map(diagnoses.map((diagnosis) => [diagnosis.testId, diagnosis]));
46
+ const evidenceById = new Map(dataset.evidence.map((evidence) => [evidence.evidenceId, evidence]));
47
+ const tests = dataset.questions.map((question) => {
48
+ const testAnswers = answersByTest.get(question.testId) ?? [];
49
+ const baselineAnswers = testAnswers.filter((answer) => answer.condition === 'closedBook');
50
+ const skillAnswers = testAnswers.filter((answer) => answer.condition === 'skill');
51
+ const baselineScore = average(baselineAnswers.map((answer) => answer.score));
52
+ const skillScore = average(skillAnswers.map((answer) => answer.score));
53
+ const diagnosis = diagnosesByTest.get(question.testId);
54
+ const diagnosisCategory = diagnosis?.category === 'unknown' && new Set(skillAnswers.map((answer) => answer.score)).size > 1 ? 'answer_variability' : diagnosis?.category ?? 'none';
55
+ const failedRationales = unique((judgmentsByTest.get(question.testId) ?? []).flatMap((judgment) => judgment.criterionResults.filter((criterion) => criterion.score < 1).map((criterion) => criterion.rationale)));
56
+ const sourceEvidence = question.evidenceIds.map((evidenceId) => evidenceById.get(evidenceId)).filter(Boolean);
57
+ const shownEvidence = sourceEvidence.slice(0, 3).map((evidence) => `${evidence.documentId}: “${evidence.quote}”`);
58
+ if (sourceEvidence.length > shownEvidence.length) shownEvidence.push(`Plus ${sourceEvidence.length - shownEvidence.length} more evidence record${sourceEvidence.length - shownEvidence.length === 1 ? '' : 's'}.`);
59
+ return {
60
+ testId: question.testId,
61
+ question: question.question,
62
+ questionType: question.questionType,
63
+ status: scoreStatus(skillScore),
64
+ baseline: formatScore(baselineScore),
65
+ skill: formatScore(skillScore),
66
+ cause: diagnosisCategory,
67
+ criterion: question.rubric.map((criterion) => criterion.criterion).join(' '),
68
+ rationale: failedRationales.join(' ') || 'All rubric criteria passed.',
69
+ source: shownEvidence.join(' '),
70
+ fix: diagnosis?.fixTargets?.length ? diagnosis.fixTargets.map((target) => `${target.file}: ${target.recommendation}`).join(' ') : 'No change recommended.',
71
+ trials: {
72
+ closedBook: trialDetails(baselineAnswers, judgmentsByTrial),
73
+ skill: trialDetails(skillAnswers, judgmentsByTrial),
74
+ },
75
+ diagnosis: diagnosis ? {
76
+ confidence: diagnosis.confidence,
77
+ evidence: diagnosis.evidence ?? [],
78
+ diagnosticReruns: (diagnosis.diagnosticReruns ?? []).map((rerun) => ({ ...rerun, score: formatScore(rerun.score) })),
79
+ } : undefined,
80
+ };
81
+ });
82
+ const recommendations = aggregateRecommendations(diagnoses);
83
+ const distribution = {
84
+ perfect: tests.filter((test) => test.status === 'perfect').length,
85
+ strong: tests.filter((test) => test.status === 'strong').length,
86
+ mixed: tests.filter((test) => test.status === 'mixed').length,
87
+ weak: tests.filter((test) => test.status === 'weak').length,
88
+ zero: tests.filter((test) => test.status === 'zero').length,
89
+ };
90
+ const actionableDiagnoses = diagnoses.filter((diagnosis) => diagnosis.fixTargets?.length);
91
+ const documentCount = Object.keys(dataset.documents).length;
92
+ const skillTrialScores = conditionTrialScores(answers, 'skill');
93
+
94
+ return {
95
+ title,
96
+ createdAt: formatDate(manifest.createdAt),
97
+ runId: manifest.runId,
98
+ datasetId: dataset.datasetId,
99
+ baselineId: manifest.baselineId,
100
+ evaluatorVersion: manifest.evaluatorVersion,
101
+ model: manifest.model,
102
+ judgeModel: manifest.judgeModel,
103
+ trialsPerQuestion: manifest.trialsPerQuestion,
104
+ scores: {
105
+ closedBook: formatScore(summary.conditionScores.closedBook),
106
+ skill: formatScore(summary.conditionScores.skill),
107
+ uplift: formatPercentagePoints(summary.skillUplift, true),
108
+ skillTrials: skillTrialScores.map((score) => formatScore(score)),
109
+ },
110
+ verdict: buildVerdict(summary, { actionableDiagnoses: actionableDiagnoses.length, targetFiles: recommendations.length, skillTrialScores }),
111
+ counts: {
112
+ documents: documentCount,
113
+ knowledge: knowledge.length,
114
+ questions: dataset.questions.length,
115
+ judgments: judgments.length,
116
+ fullyCorrect: distribution.perfect,
117
+ diagnosedFailures: summary.diagnosedFailures,
118
+ actionableDiagnoses: actionableDiagnoses.length,
119
+ targetFiles: recommendations.length,
120
+ },
121
+ distribution,
122
+ recommendations,
123
+ tests,
124
+ };
125
+ }
126
+
127
+ function aggregateRecommendations(diagnoses) {
128
+ const grouped = new Map();
129
+ for (const diagnosis of diagnoses) {
130
+ for (const target of diagnosis.fixTargets ?? []) {
131
+ const existing = grouped.get(target.file) ?? { file: target.file, recommendations: new Map(), categories: new Map(), testIds: new Set() };
132
+ const recommendationTests = existing.recommendations.get(target.recommendation) ?? new Set();
133
+ recommendationTests.add(diagnosis.testId);
134
+ existing.recommendations.set(target.recommendation, recommendationTests);
135
+ existing.testIds.add(diagnosis.testId);
136
+ const categoryTests = existing.categories.get(diagnosis.category) ?? new Set();
137
+ categoryTests.add(diagnosis.testId);
138
+ existing.categories.set(diagnosis.category, categoryTests);
139
+ grouped.set(target.file, existing);
140
+ }
141
+ }
142
+ return [...grouped.values()]
143
+ .map((item) => ({
144
+ file: item.file,
145
+ changes: [...item.recommendations.entries()]
146
+ .map(([recommendation, testIds]) => ({ recommendation, affectedTests: testIds.size }))
147
+ .sort((left, right) => right.affectedTests - left.affectedTests || left.recommendation.localeCompare(right.recommendation, 'en')),
148
+ category: item.categories.size === 1 ? [...item.categories.keys()][0] : 'multiple',
149
+ affectedTests: item.testIds.size,
150
+ }))
151
+ .sort((left, right) => right.affectedTests - left.affectedTests || left.file.localeCompare(right.file, 'en'));
152
+ }
153
+
154
+ function buildVerdict(summary, { actionableDiagnoses, targetFiles, skillTrialScores }) {
155
+ const label = actionableDiagnoses ? `${actionableDiagnoses} actionable diagnoses across ${targetFiles} target file${targetFiles === 1 ? '' : 's'}` : 'No concrete file changes diagnosed';
156
+ const uplift = formatPercentagePoints(summary.skillUplift, true).replace(' pp', ' percentage points');
157
+ const trialRange = skillTrialScores.length > 1 ? ` Skill trial aggregates ranged from ${formatScore(Math.min(...skillTrialScores))} to ${formatScore(Math.max(...skillTrialScores))}.` : '';
158
+ const detail = `${uplift} over the model alone.${trialRange} ${summary.diagnosedFailures} question${summary.diagnosedFailures === 1 ? '' : 's'} were below perfect across the measured trials; ${actionableDiagnoses} produced concrete fix targets.`;
159
+ return { label, headline: 'Measured outcome for this benchmark', detail };
160
+ }
161
+
162
+ function scoreStatus(score) {
163
+ if (Math.abs(score - 1) <= PERFECT_SCORE_TOLERANCE) return 'perfect';
164
+ if (score >= 0.9) return 'strong';
165
+ if (score >= 0.7) return 'mixed';
166
+ return score > 0 ? 'weak' : 'zero';
167
+ }
168
+
169
+ function average(values) {
170
+ return values.length ? values.reduce((sum, value) => sum + value, 0) / values.length : 0;
171
+ }
172
+
173
+ function formatScore(score, signed = false) {
174
+ if (score === 0 || Math.abs(score) === 1) return `${signed && score > 0 ? '+' : ''}${score * 100}%`;
175
+ const percentage = score * 100;
176
+ let precision = 3;
177
+ let formatted = percentage.toFixed(precision);
178
+ while (Number(formatted) === Math.sign(score) * 100 && precision < 10) {
179
+ precision += 1;
180
+ formatted = percentage.toFixed(precision);
181
+ }
182
+ formatted = formatted.replace(/\.0+$|(\.\d*?)0+$/, '$1');
183
+ return `${signed && score > 0 ? '+' : ''}${formatted}%`;
184
+ }
185
+
186
+ function formatPercentagePoints(score, signed = false) {
187
+ return `${formatScore(score, signed).slice(0, -1)} pp`;
188
+ }
189
+
190
+ function conditionTrialScores(answers, condition) {
191
+ return [...groupBy(answers.filter((answer) => answer.condition === condition), (answer) => answer.trial).entries()]
192
+ .sort(([left], [right]) => Number(left) - Number(right))
193
+ .map(([, trialAnswers]) => average(trialAnswers.map((answer) => answer.score)));
194
+ }
195
+
196
+ function trialDetails(answers, judgmentsByTrial) {
197
+ return [...answers]
198
+ .sort((left, right) => Number(left.trial ?? 0) - Number(right.trial ?? 0))
199
+ .map((answer, index) => {
200
+ const judgment = judgmentsByTrial.get(trialKey(answer));
201
+ return {
202
+ trial: answer.trial ?? index,
203
+ score: formatScore(answer.score),
204
+ answer: answer.answer ?? '',
205
+ failedCriteria: judgment?.criterionResults?.filter((criterion) => criterion.score < 1).map((criterion) => criterion.rationale) ?? [],
206
+ };
207
+ });
208
+ }
209
+
210
+ function trialKey(record) {
211
+ return `${record.testId}:${record.trial ?? 0}:${record.condition}`;
212
+ }
213
+
214
+ function formatDate(value) {
215
+ const date = new Date(value);
216
+ if (Number.isNaN(date.valueOf())) return value;
217
+ return new Intl.DateTimeFormat('en', { day: 'numeric', month: 'long', year: 'numeric', timeZone: 'UTC' }).format(date);
218
+ }
219
+
220
+ function groupBy(values, key) {
221
+ const grouped = new Map();
222
+ for (const value of values) {
223
+ const id = key(value);
224
+ const entries = grouped.get(id) ?? [];
225
+ entries.push(value);
226
+ grouped.set(id, entries);
227
+ }
228
+ return grouped;
229
+ }
230
+
231
+ function unique(values) {
232
+ return [...new Set(values)];
233
+ }
@@ -0,0 +1,35 @@
1
+ import { InvalidStructuredResponse, parseJsonObject } from './json.js';
2
+
3
+ export { InvalidStructuredResponse, parseJsonObject };
4
+
5
+ export async function runStructured({ runner, workspace, prompt, validator, maxAttempts = 2 }) {
6
+ if (!Number.isInteger(maxAttempts) || maxAttempts < 1) {
7
+ throw new Error('maxAttempts must be at least 1');
8
+ }
9
+
10
+ let currentPrompt = prompt;
11
+ let lastError;
12
+ for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
13
+ const response = await runner.run(workspace, currentPrompt);
14
+ try {
15
+ return validator(response.answer);
16
+ } catch (error) {
17
+ lastError = error;
18
+ if (attempt < maxAttempts) {
19
+ currentPrompt = [
20
+ prompt,
21
+ '',
22
+ 'Your previous response was invalid. Repair it while preserving all valid content:',
23
+ error.message,
24
+ '',
25
+ 'PREVIOUS RESPONSE:',
26
+ response.answer,
27
+ 'END PREVIOUS RESPONSE',
28
+ '',
29
+ 'Return exactly one corrected JSON object. Do not omit valid items. Do not use Markdown fences or add text before or after the object.',
30
+ ].join('\n');
31
+ }
32
+ }
33
+ }
34
+ throw new InvalidStructuredResponse(`Model did not return a valid structured response after ${maxAttempts} attempts: ${lastError?.message}`);
35
+ }
@@ -0,0 +1,33 @@
1
+ import { stableStringify } from './json.js';
2
+ import { InvalidStructuredResponse, parseJsonObject } from './structured.js';
3
+
4
+ const BOOLEAN_FIELDS = ['answerable', 'referenceSupported', 'criteriaSupported', 'mappedItemsRequired', 'requiresCorpusKnowledge', 'unambiguous', 'answerLeakage'];
5
+
6
+ export function buildVerificationPrompt(question, evidence, source) {
7
+ const evidenceById = new Map(evidence.map((item) => [item.evidenceId, item.quote]));
8
+ const payload = {
9
+ question: question.question,
10
+ rubric: question.rubric.map(({ criterion, knowledgeItemIds }) => ({ criterion, knowledgeItemIds })),
11
+ evidence: question.evidenceIds.map((id) => evidenceById.get(id)),
12
+ };
13
+ if (source !== undefined) payload.source = source;
14
+ return [
15
+ 'Independently verify this extracted evaluation question against the supplied source. Return only one JSON object with boolean fields answerable, referenceSupported, criteriaSupported, mappedItemsRequired, requiresCorpusKnowledge, unambiguous, and answerLeakage, plus a non-empty reason string. answerLeakage is true when the question reveals its answer. The supplied source and evidence are corpus knowledge. requiresCorpusKnowledge is true when they are needed to answer correctly; it is false only when the answer follows from the question wording or generic domain conventions without consulting the supplied source.',
16
+ 'Set every boolean independently, then ensure the reason explains every failing boolean and does not contradict the boolean values. Judge answerability and rubric support against the complete source. Use mapped evidence to judge whether mappedItemsRequired is accurate. Do not repair the question or use outside knowledge.',
17
+ '',
18
+ 'INPUT:',
19
+ stableStringify(payload),
20
+ ].join('\n');
21
+ }
22
+
23
+ export function parseVerificationResponse(response) {
24
+ const data = parseJsonObject(response);
25
+ for (const field of BOOLEAN_FIELDS) {
26
+ if (typeof data[field] !== 'boolean') throw new InvalidStructuredResponse(`${field} must be a boolean`);
27
+ }
28
+ if (typeof data.reason !== 'string' || !data.reason.trim()) throw new InvalidStructuredResponse('reason must be a non-empty string');
29
+ const result = Object.fromEntries(BOOLEAN_FIELDS.map((field) => [field, data[field]]));
30
+ result.reason = data.reason.trim();
31
+ result.passed = result.answerable && result.referenceSupported && result.criteriaSupported && result.mappedItemsRequired && result.requiresCorpusKnowledge && result.unambiguous && !result.answerLeakage;
32
+ return result;
33
+ }