@devilsdev/rag-pipeline-utils 2.6.1 → 2.7.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/BENCHMARKS.md +7 -1
- package/CHANGELOG.md +135 -0
- package/README.md +37 -24
- package/dist/index.cjs +4164 -1825
- package/dist/index.d.ts +265 -256
- package/dist/index.mjs +1 -1
- package/dist/types/cache/memory-cache.d.ts +4 -1
- package/dist/types/cache/redis-cache.d.ts +4 -1
- package/dist/types/cli/services/instrument.d.ts +14 -0
- package/dist/types/cli/services/rag-service.d.ts +140 -0
- package/dist/types/cli/shared/config-context.d.ts +9 -0
- package/dist/types/config/component-factory.d.ts +57 -0
- package/dist/types/config/enhanced-ragrc-schema.d.ts +648 -0
- package/dist/types/config/load-config.d.ts +18 -0
- package/dist/types/config/normalize-config.d.ts +9 -0
- package/dist/types/core/observability/event-logger.d.ts +211 -0
- package/dist/types/core/observability/metrics.d.ts +585 -0
- package/dist/types/core/performance/parallel-processor.d.ts +92 -0
- package/dist/types/core/plugin-registry.d.ts +5 -0
- package/dist/types/dag/core/execution-scheduler.d.ts +3 -1
- package/dist/types/dag/dag-engine.d.ts +15 -2
- package/dist/types/dev/dev-server.d.ts +136 -0
- package/dist/types/dev/hot-reload.d.ts +226 -0
- package/dist/types/ecosystem/plugin-analytics-dashboard.d.ts +141 -0
- package/dist/types/ecosystem/plugin-hub.d.ts +161 -0
- package/dist/types/ecosystem/plugin-installer.d.ts +40 -0
- package/dist/types/ecosystem/plugin-probe.d.ts +21 -0
- package/dist/types/ecosystem/plugin-scanner.d.ts +30 -0
- package/dist/types/ecosystem/tar-extract.d.ts +24 -0
- package/dist/types/enterprise/audit-logging.d.ts +217 -0
- package/dist/types/evaluate/compare.d.ts +140 -0
- package/dist/types/evaluate/dataset.d.ts +43 -0
- package/dist/types/evaluate/evaluator.d.ts +14 -0
- package/dist/types/evaluate/report.d.ts +56 -0
- package/dist/types/evaluate/synthetic.d.ts +64 -0
- package/dist/types/loader/csv-loader.d.ts +10 -0
- package/dist/types/loader/document-reader.d.ts +18 -0
- package/dist/types/loader/html-loader.d.ts +21 -0
- package/dist/types/loader/markdown-loader.d.ts +21 -0
- package/dist/types/retrieval/bm25-search.d.ts +4 -0
- package/dist/types/retrieval/file-vector-store.d.ts +4 -1
- package/dist/types/retrieval/hybrid-retriever.d.ts +4 -0
- package/dist/types/retrieval/pgvector-retriever.d.ts +123 -0
- package/dist/types/retrieval/qdrant-retriever.d.ts +106 -0
- package/dist/types/security/validator.d.ts +11 -1
- package/dist/types/testing.d.ts +2 -0
- package/dist/types/utils/batch-processor.d.ts +205 -0
- package/dist/types/utils/connection-pool.d.ts +158 -0
- package/dist/types/utils/error-formatter.d.ts +312 -0
- package/dist/types/utils/logger.d.ts +29 -4
- package/dist/types/utils/rate-limiter.d.ts +13 -8
- package/dist/types/utils/retry.d.ts +10 -0
- package/package.json +13 -3
- package/src/cache/memory-cache.js +4 -1
- package/src/cache/redis-cache.js +4 -1
- package/src/cli/enhanced-cli-commands.js +79 -0
- package/src/cli/handlers/ai-handler.js +2 -2
- package/src/cli/handlers/eval-handler.js +201 -0
- package/src/cli/handlers/generate-handler.js +103 -0
- package/src/cli/services/benchmark.js +2 -2
- package/src/cli/services/rag-service.js +59 -10
- package/src/cli/shared/cli-utils.js +40 -1
- package/src/config/component-factory.js +32 -0
- package/src/connectors/local-embedder.js +20 -13
- package/src/core/observability/event-logger.js +135 -1
- package/src/core/observability/metrics.js +223 -5
- package/src/core/performance/parallel-processor.js +2 -0
- package/src/core/plugin-registry.js +5 -1
- package/src/dag/core/execution-scheduler.js +2 -0
- package/src/dag/dag-engine.js +13 -0
- package/src/dev/dev-server.js +6 -0
- package/src/ecosystem/plugin-hub.js +4 -0
- package/src/enterprise/audit-logging.js +5 -0
- package/src/evaluate/compare.js +395 -0
- package/src/evaluate/dataset.js +207 -0
- package/src/evaluate/report.js +350 -0
- package/src/evaluate/synthetic.js +246 -0
- package/src/index.js +17 -0
- package/src/retrieval/bm25-search.js +4 -0
- package/src/retrieval/file-vector-store.js +4 -1
- package/src/retrieval/hybrid-retriever.js +4 -0
- package/src/retrieval/pgvector-retriever.js +420 -0
- package/src/retrieval/qdrant-retriever.js +361 -0
- package/src/security/validator.js +8 -0
- package/src/testing.js +2 -0
- package/src/utils/batch-processor.js +7 -3
- package/src/utils/error-formatter.js +48 -20
- package/src/utils/logger.js +31 -1
- package/src/utils/rate-limiter.js +9 -4
- package/src/utils/retry.js +10 -1
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The evaluation dataset format, shared by validateDataset and the tools
|
|
5
|
+
* built on it (compareConfigurations, `rag-pipeline eval`, the rag-eval
|
|
6
|
+
* Action, generateSyntheticDataset).
|
|
7
|
+
*
|
|
8
|
+
* A dataset is an array of cases, or `{ cases: [...] }`. A case is:
|
|
9
|
+
*
|
|
10
|
+
* prompt (required) the question; `question` is accepted as an alias
|
|
11
|
+
* expected (optional) the reference answer; `referenceAnswer` is an alias
|
|
12
|
+
* sourceIds (optional) ids of the documents that should be retrieved,
|
|
13
|
+
* matching the `id` of retrieval results
|
|
14
|
+
* id (optional) a stable case id; defaults to `case-<n>`
|
|
15
|
+
* tags (optional) strings for grouping results
|
|
16
|
+
*
|
|
17
|
+
* Other fields are kept as they are. `{ prompt, expected }`, the shape
|
|
18
|
+
* evaluateRagDataset reads, is valid unchanged.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
const ALIASES = {
|
|
22
|
+
prompt: ['prompt', 'question'],
|
|
23
|
+
expected: ['expected', 'referenceAnswer'],
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const isNonEmptyString = (value) =>
|
|
27
|
+
typeof value === 'string' && value.trim().length > 0;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Reads a field that may be given under an alias. Reports an error when two
|
|
31
|
+
* spellings disagree.
|
|
32
|
+
*/
|
|
33
|
+
function readAliased(raw, field, issue) {
|
|
34
|
+
const present = ALIASES[field].filter((name) => raw[name] !== undefined);
|
|
35
|
+
if (present.length === 0) return undefined;
|
|
36
|
+
const value = raw[present[0]];
|
|
37
|
+
for (const other of present.slice(1)) {
|
|
38
|
+
if (raw[other] !== value) {
|
|
39
|
+
issue(
|
|
40
|
+
'error',
|
|
41
|
+
field,
|
|
42
|
+
`"${present[0]}" and "${other}" are both set and differ; use one`,
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return { value, name: present[0] };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Checks an evaluation dataset before anything runs. It never throws: every
|
|
51
|
+
* problem is reported, with the case it is in.
|
|
52
|
+
*
|
|
53
|
+
* @param {Array<Object>|{cases: Array<Object>}} dataset - Cases, or an object with `cases`
|
|
54
|
+
* @param {Object} [options]
|
|
55
|
+
* @param {boolean} [options.requireExpected=false] - Every case needs `expected`
|
|
56
|
+
* @param {boolean} [options.requireSourceIds=false] - Every case needs `sourceIds`
|
|
57
|
+
* @returns {{
|
|
58
|
+
* valid: boolean,
|
|
59
|
+
* errors: Array<{ index: number|null, id: string|null, field: string|null, message: string }>,
|
|
60
|
+
* warnings: Array<{ index: number|null, id: string|null, field: string|null, message: string }>,
|
|
61
|
+
* cases: Array<{ id: string, prompt: string, expected?: string, sourceIds?: string[], tags?: string[] }>
|
|
62
|
+
* }} `cases` holds the normalised cases (aliases resolved, ids filled in)
|
|
63
|
+
* when `valid` is true, and is empty otherwise
|
|
64
|
+
*/
|
|
65
|
+
function validateDataset(dataset, options = {}) {
|
|
66
|
+
const { requireExpected = false, requireSourceIds = false } = options;
|
|
67
|
+
const errors = [];
|
|
68
|
+
const warnings = [];
|
|
69
|
+
const fail = (message) => ({
|
|
70
|
+
valid: false,
|
|
71
|
+
errors: [{ index: null, id: null, field: null, message }],
|
|
72
|
+
warnings,
|
|
73
|
+
cases: [],
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
let list = dataset;
|
|
77
|
+
if (list && !Array.isArray(list) && typeof list === 'object') {
|
|
78
|
+
if (!Array.isArray(list.cases)) {
|
|
79
|
+
return fail(
|
|
80
|
+
'Expected an array of cases or an object with a "cases" array',
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
list = list.cases;
|
|
84
|
+
}
|
|
85
|
+
if (!Array.isArray(list)) {
|
|
86
|
+
return fail('Expected an array of cases or an object with a "cases" array');
|
|
87
|
+
}
|
|
88
|
+
if (list.length === 0) return fail('The dataset has no cases');
|
|
89
|
+
|
|
90
|
+
const cases = [];
|
|
91
|
+
const seenIds = new Map();
|
|
92
|
+
const seenPrompts = new Map();
|
|
93
|
+
|
|
94
|
+
list.forEach((raw, index) => {
|
|
95
|
+
const label = `Case ${index + 1}`;
|
|
96
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
|
|
97
|
+
errors.push({
|
|
98
|
+
index,
|
|
99
|
+
id: null,
|
|
100
|
+
field: null,
|
|
101
|
+
message: `${label} is not an object`,
|
|
102
|
+
});
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const id =
|
|
107
|
+
raw.id === undefined || raw.id === null ? `case-${index + 1}` : raw.id;
|
|
108
|
+
const caseId = typeof id === 'number' ? String(id) : id;
|
|
109
|
+
const issue = (level, field, message) =>
|
|
110
|
+
(level === 'error' ? errors : warnings).push({
|
|
111
|
+
index,
|
|
112
|
+
id: typeof caseId === 'string' ? caseId : null,
|
|
113
|
+
field,
|
|
114
|
+
message: `${label}: ${message}`,
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
if (!isNonEmptyString(caseId)) {
|
|
118
|
+
issue('error', 'id', '"id" must be a non-empty string or a number');
|
|
119
|
+
} else if (seenIds.has(caseId)) {
|
|
120
|
+
issue(
|
|
121
|
+
'error',
|
|
122
|
+
'id',
|
|
123
|
+
`id "${caseId}" is also used by case ${seenIds.get(caseId) + 1}`,
|
|
124
|
+
);
|
|
125
|
+
} else {
|
|
126
|
+
seenIds.set(caseId, index);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const prompt = readAliased(raw, 'prompt', issue);
|
|
130
|
+
if (!prompt) {
|
|
131
|
+
issue('error', 'prompt', 'missing "prompt" (or "question")');
|
|
132
|
+
} else if (!isNonEmptyString(prompt.value)) {
|
|
133
|
+
issue('error', 'prompt', `"${prompt.name}" must be a non-empty string`);
|
|
134
|
+
} else {
|
|
135
|
+
const key = prompt.value.trim().toLowerCase();
|
|
136
|
+
if (seenPrompts.has(key)) {
|
|
137
|
+
issue(
|
|
138
|
+
'warning',
|
|
139
|
+
'prompt',
|
|
140
|
+
`same question as case ${seenPrompts.get(key) + 1}`,
|
|
141
|
+
);
|
|
142
|
+
} else {
|
|
143
|
+
seenPrompts.set(key, index);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const expected = readAliased(raw, 'expected', issue);
|
|
148
|
+
if (expected && !isNonEmptyString(expected.value)) {
|
|
149
|
+
issue(
|
|
150
|
+
'error',
|
|
151
|
+
'expected',
|
|
152
|
+
`"${expected.name}" must be a non-empty string`,
|
|
153
|
+
);
|
|
154
|
+
} else if (!expected && requireExpected) {
|
|
155
|
+
issue('error', 'expected', 'missing "expected" (or "referenceAnswer")');
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
let sourceIds;
|
|
159
|
+
if (raw.sourceIds !== undefined) {
|
|
160
|
+
const valid =
|
|
161
|
+
Array.isArray(raw.sourceIds) &&
|
|
162
|
+
raw.sourceIds.every(
|
|
163
|
+
(s) => isNonEmptyString(s) || (typeof s === 'number' && isFinite(s)),
|
|
164
|
+
);
|
|
165
|
+
if (!valid) {
|
|
166
|
+
issue(
|
|
167
|
+
'error',
|
|
168
|
+
'sourceIds',
|
|
169
|
+
'"sourceIds" must be an array of document ids (strings or numbers)',
|
|
170
|
+
);
|
|
171
|
+
} else if (raw.sourceIds.length === 0) {
|
|
172
|
+
issue(
|
|
173
|
+
'warning',
|
|
174
|
+
'sourceIds',
|
|
175
|
+
'"sourceIds" is empty, so retrieval is not checked for this case',
|
|
176
|
+
);
|
|
177
|
+
} else {
|
|
178
|
+
sourceIds = [...new Set(raw.sourceIds.map(String))];
|
|
179
|
+
if (sourceIds.length < raw.sourceIds.length) {
|
|
180
|
+
issue('warning', 'sourceIds', '"sourceIds" lists an id twice');
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
} else if (requireSourceIds) {
|
|
184
|
+
issue('error', 'sourceIds', 'missing "sourceIds"');
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if (
|
|
188
|
+
raw.tags !== undefined &&
|
|
189
|
+
!(Array.isArray(raw.tags) && raw.tags.every(isNonEmptyString))
|
|
190
|
+
) {
|
|
191
|
+
issue('error', 'tags', '"tags" must be an array of strings');
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const normalised = { ...raw, id: caseId };
|
|
195
|
+
delete normalised.question;
|
|
196
|
+
delete normalised.referenceAnswer;
|
|
197
|
+
if (prompt) normalised.prompt = prompt.value;
|
|
198
|
+
if (expected) normalised.expected = expected.value;
|
|
199
|
+
if (sourceIds) normalised.sourceIds = sourceIds;
|
|
200
|
+
cases.push(normalised);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
const valid = errors.length === 0;
|
|
204
|
+
return { valid, errors, warnings, cases: valid ? cases : [] };
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
module.exports = { validateDataset };
|
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Pass/fail verdict and Markdown/HTML rendering for an evaluation run
|
|
5
|
+
* (runEvaluation) and, optionally, its comparison with a baseline
|
|
6
|
+
* (compareConfigurations). The Markdown form is what the rag-eval Action
|
|
7
|
+
* posts on a pull request; the HTML form is a standalone page.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const { EVALUATION_METRICS } = require('./compare');
|
|
11
|
+
|
|
12
|
+
const LABELS = {
|
|
13
|
+
faithfulness: 'Faithfulness',
|
|
14
|
+
relevance: 'Relevance',
|
|
15
|
+
contextPrecision: 'Context precision',
|
|
16
|
+
contextRecall: 'Context recall',
|
|
17
|
+
groundedness: 'Groundedness',
|
|
18
|
+
citationCoverage: 'Citation coverage',
|
|
19
|
+
sourceRecall: 'Source recall',
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/** Marks the comment so the Action can find and update it. */
|
|
23
|
+
const MARKER = '<!-- rag-eval-report -->';
|
|
24
|
+
const MAX_LISTED = 20;
|
|
25
|
+
|
|
26
|
+
const label = (metric) => LABELS[metric] || metric;
|
|
27
|
+
const fixed = (value) =>
|
|
28
|
+
typeof value === 'number' && Number.isFinite(value) ? value.toFixed(3) : '-';
|
|
29
|
+
const signed = (value) =>
|
|
30
|
+
typeof value === 'number' && Number.isFinite(value)
|
|
31
|
+
? `${value > 0 ? '+' : ''}${value.toFixed(3)}`
|
|
32
|
+
: '-';
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Decides whether a run passes: no case failed, every metric in `min` is at
|
|
36
|
+
* or above its minimum, and the comparison (if any) found no regression.
|
|
37
|
+
*
|
|
38
|
+
* @param {Object} input
|
|
39
|
+
* @param {Object} input.report - A runEvaluation() report
|
|
40
|
+
* @param {Object} [input.comparison] - A compareConfigurations() result
|
|
41
|
+
* @param {Object<string, number>} [input.min] - Minimum mean per metric, e.g. `{ faithfulness: 0.8 }`
|
|
42
|
+
* @returns {{
|
|
43
|
+
* passed: boolean,
|
|
44
|
+
* reasons: string[],
|
|
45
|
+
* thresholds: Array<{ metric: string, min: number, actual: number|null, passed: boolean }>
|
|
46
|
+
* }}
|
|
47
|
+
*/
|
|
48
|
+
function evaluationVerdict({ report, comparison, min = {} } = {}) {
|
|
49
|
+
if (!report || !report.summary) {
|
|
50
|
+
throw new TypeError('evaluationVerdict needs a runEvaluation() report');
|
|
51
|
+
}
|
|
52
|
+
const reasons = [];
|
|
53
|
+
const { summary } = report;
|
|
54
|
+
if (summary.failed > 0) {
|
|
55
|
+
reasons.push(
|
|
56
|
+
`${summary.failed} of ${summary.cases} case${summary.cases === 1 ? '' : 's'} failed`,
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
const thresholds = Object.entries(min).map(([metric, minimum]) => {
|
|
60
|
+
const entry = summary.metrics[metric];
|
|
61
|
+
const actual = entry ? entry.mean : null;
|
|
62
|
+
const passed = actual !== null && actual >= minimum;
|
|
63
|
+
if (!passed) {
|
|
64
|
+
reasons.push(
|
|
65
|
+
actual === null
|
|
66
|
+
? `${label(metric)} was not measured (minimum ${minimum})`
|
|
67
|
+
: `${label(metric)} ${fixed(actual)} is below the minimum ${minimum}`,
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
return { metric, min: minimum, actual, passed };
|
|
71
|
+
});
|
|
72
|
+
if (comparison) {
|
|
73
|
+
for (const [metric, m] of Object.entries(comparison.metrics)) {
|
|
74
|
+
if (m.regressed) {
|
|
75
|
+
reasons.push(
|
|
76
|
+
`${label(metric)} dropped ${signed(m.delta)} from the baseline`,
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
const newFailures = comparison.regressions.filter((r) => !r.metric).length;
|
|
81
|
+
if (newFailures > 0) {
|
|
82
|
+
reasons.push(
|
|
83
|
+
`${newFailures} case${newFailures === 1 ? '' : 's'} that passed on the baseline now fail${newFailures === 1 ? 's' : ''}`,
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return { passed: reasons.length === 0, reasons, thresholds };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Rows for the metrics table: every metric either run measured. */
|
|
91
|
+
function metricRows({ report, comparison, min = {} }) {
|
|
92
|
+
const own = report.summary.metrics;
|
|
93
|
+
const base = comparison && comparison.baseline.summary.metrics;
|
|
94
|
+
const names = EVALUATION_METRICS.filter(
|
|
95
|
+
(name) => own[name] || (base && base[name]) || min[name] !== undefined,
|
|
96
|
+
);
|
|
97
|
+
return names.map((name) => {
|
|
98
|
+
const cmp = comparison && comparison.metrics[name];
|
|
99
|
+
return {
|
|
100
|
+
metric: name,
|
|
101
|
+
label: label(name),
|
|
102
|
+
value: own[name] ? own[name].mean : null,
|
|
103
|
+
count: own[name] ? own[name].count : 0,
|
|
104
|
+
baseline: base && base[name] ? base[name].mean : null,
|
|
105
|
+
delta: cmp ? cmp.delta : null,
|
|
106
|
+
regressed: Boolean(cmp && cmp.regressed),
|
|
107
|
+
min: min[name],
|
|
108
|
+
belowMin:
|
|
109
|
+
min[name] !== undefined && !(own[name] && own[name].mean >= min[name]),
|
|
110
|
+
};
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function caseLines(input) {
|
|
115
|
+
const lines = [];
|
|
116
|
+
const { report, comparison } = input;
|
|
117
|
+
if (comparison) {
|
|
118
|
+
for (const r of comparison.regressions) {
|
|
119
|
+
lines.push({
|
|
120
|
+
kind: 'regression',
|
|
121
|
+
id: r.id,
|
|
122
|
+
prompt: r.prompt,
|
|
123
|
+
text: r.metric
|
|
124
|
+
? `${label(r.metric)} ${fixed(r.baseline)} → ${fixed(r.candidate)}`
|
|
125
|
+
: r.reason,
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
const listed = new Set(lines.map((l) => l.id));
|
|
130
|
+
for (const c of report.cases) {
|
|
131
|
+
if (!c.success && !listed.has(c.id)) {
|
|
132
|
+
lines.push({
|
|
133
|
+
kind: 'failure',
|
|
134
|
+
id: c.id,
|
|
135
|
+
prompt: c.prompt,
|
|
136
|
+
text: `failed: ${c.error}`,
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return lines;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const mdCell = (text) =>
|
|
144
|
+
String(text).replace(/\|/g, '\\|').replace(/\r?\n/g, ' ');
|
|
145
|
+
|
|
146
|
+
function renderMarkdown(input, verdict, title) {
|
|
147
|
+
const { report, comparison } = input;
|
|
148
|
+
const rows = metricRows(input);
|
|
149
|
+
const out = [MARKER, ''];
|
|
150
|
+
const status = verdict.passed ? '✅ Passed' : '❌ Failed';
|
|
151
|
+
out.push(`### ${title}: ${status}`, '');
|
|
152
|
+
out.push(
|
|
153
|
+
`${report.summary.cases} case${report.summary.cases === 1 ? '' : 's'}, ${report.summary.failed} failed${comparison ? ', compared with the baseline' : ''}.`,
|
|
154
|
+
'',
|
|
155
|
+
);
|
|
156
|
+
if (!verdict.passed) {
|
|
157
|
+
for (const reason of verdict.reasons) out.push(`- ${reason}`);
|
|
158
|
+
out.push('');
|
|
159
|
+
}
|
|
160
|
+
if (rows.length > 0) {
|
|
161
|
+
const header = ['Metric', comparison ? 'This run' : 'Mean'];
|
|
162
|
+
if (comparison) header.push('Baseline', 'Change');
|
|
163
|
+
if (rows.some((r) => r.min !== undefined)) header.push('Minimum');
|
|
164
|
+
out.push(`| ${header.join(' | ')} |`);
|
|
165
|
+
out.push(
|
|
166
|
+
`| ${header.map((h, i) => (i === 0 ? '---' : '---:')).join(' | ')} |`,
|
|
167
|
+
);
|
|
168
|
+
for (const r of rows) {
|
|
169
|
+
const cells = [r.label, fixed(r.value)];
|
|
170
|
+
if (comparison) {
|
|
171
|
+
cells.push(
|
|
172
|
+
fixed(r.baseline),
|
|
173
|
+
`${signed(r.delta)}${r.regressed ? ' ⚠️' : ''}`,
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
if (header.includes('Minimum')) {
|
|
177
|
+
cells.push(
|
|
178
|
+
r.min === undefined ? '' : `${r.min}${r.belowMin ? ' ❌' : ''}`,
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
out.push(`| ${cells.map(mdCell).join(' | ')} |`);
|
|
182
|
+
}
|
|
183
|
+
out.push('');
|
|
184
|
+
}
|
|
185
|
+
const lines = caseLines(input);
|
|
186
|
+
if (lines.length > 0) {
|
|
187
|
+
out.push(
|
|
188
|
+
`<details${verdict.passed ? '' : ' open'}><summary>Cases that got worse or failed (${lines.length})</summary>`,
|
|
189
|
+
'',
|
|
190
|
+
);
|
|
191
|
+
for (const l of lines.slice(0, MAX_LISTED)) {
|
|
192
|
+
out.push(`- \`${mdCell(l.id)}\` ${mdCell(l.prompt)}: ${mdCell(l.text)}`);
|
|
193
|
+
}
|
|
194
|
+
if (lines.length > MAX_LISTED) {
|
|
195
|
+
out.push(`- …and ${lines.length - MAX_LISTED} more`);
|
|
196
|
+
}
|
|
197
|
+
out.push('', '</details>', '');
|
|
198
|
+
}
|
|
199
|
+
if (comparison && comparison.warnings.length > 0) {
|
|
200
|
+
out.push('<details open><summary>Warnings</summary>', '');
|
|
201
|
+
for (const w of comparison.warnings) out.push(`- ${mdCell(w)}`);
|
|
202
|
+
out.push('', '</details>', '');
|
|
203
|
+
}
|
|
204
|
+
return out.join('\n');
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const esc = (text) =>
|
|
208
|
+
String(text)
|
|
209
|
+
.replace(/&/g, '&')
|
|
210
|
+
.replace(/</g, '<')
|
|
211
|
+
.replace(/>/g, '>')
|
|
212
|
+
.replace(/"/g, '"')
|
|
213
|
+
.replace(/'/g, ''');
|
|
214
|
+
|
|
215
|
+
const STYLE = `
|
|
216
|
+
:root{--bg:#fff;--fg:#1b1f24;--muted:#57606a;--line:#d0d7de;--ok:#1a7f37;--bad:#cf222e;--warn:#9a6700;--chip:#f6f8fa}
|
|
217
|
+
@media (prefers-color-scheme:dark){:root{--bg:#0d1117;--fg:#e6edf3;--muted:#8d96a0;--line:#30363d;--ok:#3fb950;--bad:#f85149;--warn:#d29922;--chip:#161b22}}
|
|
218
|
+
*{box-sizing:border-box}body{margin:0;padding:24px 16px;background:var(--bg);color:var(--fg);font:15px/1.5 system-ui,-apple-system,"Segoe UI",sans-serif}
|
|
219
|
+
main{max-width:1100px;margin:0 auto}h1{font-size:22px;margin:0 0 4px}h2{font-size:17px;margin:28px 0 8px}
|
|
220
|
+
.muted{color:var(--muted)}.status{display:inline-block;padding:2px 10px;border-radius:999px;font-weight:600;font-size:13px}
|
|
221
|
+
.pass{color:var(--ok);border:1px solid var(--ok)}.fail{color:var(--bad);border:1px solid var(--bad)}
|
|
222
|
+
ul.reasons{margin:8px 0;padding-left:20px}.scroll{overflow-x:auto}
|
|
223
|
+
table{border-collapse:collapse;width:100%;font-size:14px}th,td{border-bottom:1px solid var(--line);padding:6px 8px;text-align:left;vertical-align:top}
|
|
224
|
+
th{color:var(--muted);font-weight:600}td.num,th.num{text-align:right;font-variant-numeric:tabular-nums}
|
|
225
|
+
.bad{color:var(--bad);font-weight:600}.warn{color:var(--warn)}.answer{max-width:420px;color:var(--muted)}
|
|
226
|
+
code{background:var(--chip);padding:1px 5px;border-radius:4px;font-size:13px}`;
|
|
227
|
+
|
|
228
|
+
function renderHtml(input, verdict, title) {
|
|
229
|
+
const { report, comparison } = input;
|
|
230
|
+
const rows = metricRows(input);
|
|
231
|
+
const metricsShown = rows.map((r) => r.metric);
|
|
232
|
+
const hasMin = rows.some((r) => r.min !== undefined);
|
|
233
|
+
const parts = [];
|
|
234
|
+
parts.push(`<!doctype html><html lang="en"><head><meta charset="utf-8">`);
|
|
235
|
+
parts.push(
|
|
236
|
+
`<meta name="viewport" content="width=device-width,initial-scale=1">`,
|
|
237
|
+
);
|
|
238
|
+
parts.push(
|
|
239
|
+
`<title>${esc(title)}</title><style>${STYLE}</style></head><body><main>`,
|
|
240
|
+
);
|
|
241
|
+
parts.push(`<h1>${esc(title)}</h1>`);
|
|
242
|
+
parts.push(
|
|
243
|
+
`<p><span class="status ${verdict.passed ? 'pass' : 'fail'}">${verdict.passed ? 'Passed' : 'Failed'}</span> <span class="muted">${report.summary.cases} cases, ${report.summary.failed} failed · ${esc(report.createdAt || '')}${comparison ? ' · compared with the baseline' : ''}</span></p>`,
|
|
244
|
+
);
|
|
245
|
+
if (!verdict.passed) {
|
|
246
|
+
parts.push(
|
|
247
|
+
`<ul class="reasons">${verdict.reasons.map((r) => `<li>${esc(r)}</li>`).join('')}</ul>`,
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
if (rows.length > 0) {
|
|
252
|
+
parts.push(
|
|
253
|
+
'<h2>Metrics</h2><div class="scroll"><table><thead><tr><th>Metric</th>',
|
|
254
|
+
);
|
|
255
|
+
parts.push(
|
|
256
|
+
`<th class="num">${comparison ? 'This run' : 'Mean'}</th><th class="num">Cases</th>`,
|
|
257
|
+
);
|
|
258
|
+
if (comparison)
|
|
259
|
+
parts.push('<th class="num">Baseline</th><th class="num">Change</th>');
|
|
260
|
+
parts.push(
|
|
261
|
+
`${hasMin ? '<th class="num">Minimum</th>' : ''}</tr></thead><tbody>`,
|
|
262
|
+
);
|
|
263
|
+
for (const r of rows) {
|
|
264
|
+
parts.push(
|
|
265
|
+
`<tr><td>${esc(r.label)}</td><td class="num${r.belowMin ? ' bad' : ''}">${fixed(r.value)}</td><td class="num">${r.count}</td>`,
|
|
266
|
+
);
|
|
267
|
+
if (comparison) {
|
|
268
|
+
parts.push(
|
|
269
|
+
`<td class="num">${fixed(r.baseline)}</td><td class="num${r.regressed ? ' bad' : ''}">${signed(r.delta)}</td>`,
|
|
270
|
+
);
|
|
271
|
+
}
|
|
272
|
+
parts.push(
|
|
273
|
+
`${hasMin ? `<td class="num">${r.min === undefined ? '' : esc(r.min)}</td>` : ''}</tr>`,
|
|
274
|
+
);
|
|
275
|
+
}
|
|
276
|
+
parts.push('</tbody></table></div>');
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const lines = caseLines(input);
|
|
280
|
+
if (lines.length > 0) {
|
|
281
|
+
parts.push(
|
|
282
|
+
'<h2>Cases that got worse or failed</h2><div class="scroll"><table><thead><tr><th>Case</th><th>Question</th><th>What changed</th></tr></thead><tbody>',
|
|
283
|
+
);
|
|
284
|
+
for (const l of lines) {
|
|
285
|
+
parts.push(
|
|
286
|
+
`<tr><td><code>${esc(l.id)}</code></td><td>${esc(l.prompt)}</td><td class="${l.kind === 'failure' ? 'bad' : 'warn'}">${esc(l.text)}</td></tr>`,
|
|
287
|
+
);
|
|
288
|
+
}
|
|
289
|
+
parts.push('</tbody></table></div>');
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
parts.push(
|
|
293
|
+
'<h2>All cases</h2><div class="scroll"><table><thead><tr><th>Case</th><th>Question</th>',
|
|
294
|
+
);
|
|
295
|
+
for (const name of metricsShown)
|
|
296
|
+
parts.push(`<th class="num">${esc(label(name))}</th>`);
|
|
297
|
+
parts.push('<th>Answer</th></tr></thead><tbody>');
|
|
298
|
+
for (const c of report.cases) {
|
|
299
|
+
parts.push(
|
|
300
|
+
`<tr><td><code>${esc(c.id)}</code></td><td>${esc(c.prompt)}</td>`,
|
|
301
|
+
);
|
|
302
|
+
for (const name of metricsShown) {
|
|
303
|
+
parts.push(
|
|
304
|
+
`<td class="num">${c.success ? fixed(c.scores[name]) : ''}</td>`,
|
|
305
|
+
);
|
|
306
|
+
}
|
|
307
|
+
const answer = c.success
|
|
308
|
+
? c.answer === null
|
|
309
|
+
? '<span class="muted">retrieval only</span>'
|
|
310
|
+
: esc(c.answer.length > 300 ? `${c.answer.slice(0, 300)}…` : c.answer)
|
|
311
|
+
: `<span class="bad">${esc(c.error)}</span>`;
|
|
312
|
+
parts.push(`<td class="answer">${answer}</td></tr>`);
|
|
313
|
+
}
|
|
314
|
+
parts.push('</tbody></table></div>');
|
|
315
|
+
|
|
316
|
+
if (comparison && comparison.warnings.length > 0) {
|
|
317
|
+
parts.push(
|
|
318
|
+
`<h2>Warnings</h2><ul>${comparison.warnings.map((w) => `<li>${esc(w)}</li>`).join('')}</ul>`,
|
|
319
|
+
);
|
|
320
|
+
}
|
|
321
|
+
parts.push('</main></body></html>');
|
|
322
|
+
return parts.join('');
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Renders an evaluation run, and its comparison with a baseline if given,
|
|
327
|
+
* as Markdown (for a pull request comment) or a standalone HTML page.
|
|
328
|
+
*
|
|
329
|
+
* @param {Object} input
|
|
330
|
+
* @param {Object} input.report - A runEvaluation() report
|
|
331
|
+
* @param {Object} [input.comparison] - A compareConfigurations() result
|
|
332
|
+
* @param {Object<string, number>} [input.min] - Minimum mean per metric
|
|
333
|
+
* @param {Object} [options]
|
|
334
|
+
* @param {'markdown'|'html'} [options.format='markdown']
|
|
335
|
+
* @param {string} [options.title='RAG evaluation']
|
|
336
|
+
* @returns {string}
|
|
337
|
+
*/
|
|
338
|
+
function renderEvaluationReport(input, options = {}) {
|
|
339
|
+
const { format = 'markdown', title = 'RAG evaluation' } = options;
|
|
340
|
+
const verdict = evaluationVerdict(input);
|
|
341
|
+
if (format === 'markdown') return renderMarkdown(input, verdict, title);
|
|
342
|
+
if (format === 'html') return renderHtml(input, verdict, title);
|
|
343
|
+
throw new TypeError(`format must be "markdown" or "html", not "${format}"`);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
module.exports = {
|
|
347
|
+
evaluationVerdict,
|
|
348
|
+
renderEvaluationReport,
|
|
349
|
+
REPORT_MARKER: MARKER,
|
|
350
|
+
};
|