@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.
Files changed (90) hide show
  1. package/BENCHMARKS.md +7 -1
  2. package/CHANGELOG.md +135 -0
  3. package/README.md +37 -24
  4. package/dist/index.cjs +4164 -1825
  5. package/dist/index.d.ts +265 -256
  6. package/dist/index.mjs +1 -1
  7. package/dist/types/cache/memory-cache.d.ts +4 -1
  8. package/dist/types/cache/redis-cache.d.ts +4 -1
  9. package/dist/types/cli/services/instrument.d.ts +14 -0
  10. package/dist/types/cli/services/rag-service.d.ts +140 -0
  11. package/dist/types/cli/shared/config-context.d.ts +9 -0
  12. package/dist/types/config/component-factory.d.ts +57 -0
  13. package/dist/types/config/enhanced-ragrc-schema.d.ts +648 -0
  14. package/dist/types/config/load-config.d.ts +18 -0
  15. package/dist/types/config/normalize-config.d.ts +9 -0
  16. package/dist/types/core/observability/event-logger.d.ts +211 -0
  17. package/dist/types/core/observability/metrics.d.ts +585 -0
  18. package/dist/types/core/performance/parallel-processor.d.ts +92 -0
  19. package/dist/types/core/plugin-registry.d.ts +5 -0
  20. package/dist/types/dag/core/execution-scheduler.d.ts +3 -1
  21. package/dist/types/dag/dag-engine.d.ts +15 -2
  22. package/dist/types/dev/dev-server.d.ts +136 -0
  23. package/dist/types/dev/hot-reload.d.ts +226 -0
  24. package/dist/types/ecosystem/plugin-analytics-dashboard.d.ts +141 -0
  25. package/dist/types/ecosystem/plugin-hub.d.ts +161 -0
  26. package/dist/types/ecosystem/plugin-installer.d.ts +40 -0
  27. package/dist/types/ecosystem/plugin-probe.d.ts +21 -0
  28. package/dist/types/ecosystem/plugin-scanner.d.ts +30 -0
  29. package/dist/types/ecosystem/tar-extract.d.ts +24 -0
  30. package/dist/types/enterprise/audit-logging.d.ts +217 -0
  31. package/dist/types/evaluate/compare.d.ts +140 -0
  32. package/dist/types/evaluate/dataset.d.ts +43 -0
  33. package/dist/types/evaluate/evaluator.d.ts +14 -0
  34. package/dist/types/evaluate/report.d.ts +56 -0
  35. package/dist/types/evaluate/synthetic.d.ts +64 -0
  36. package/dist/types/loader/csv-loader.d.ts +10 -0
  37. package/dist/types/loader/document-reader.d.ts +18 -0
  38. package/dist/types/loader/html-loader.d.ts +21 -0
  39. package/dist/types/loader/markdown-loader.d.ts +21 -0
  40. package/dist/types/retrieval/bm25-search.d.ts +4 -0
  41. package/dist/types/retrieval/file-vector-store.d.ts +4 -1
  42. package/dist/types/retrieval/hybrid-retriever.d.ts +4 -0
  43. package/dist/types/retrieval/pgvector-retriever.d.ts +123 -0
  44. package/dist/types/retrieval/qdrant-retriever.d.ts +106 -0
  45. package/dist/types/security/validator.d.ts +11 -1
  46. package/dist/types/testing.d.ts +2 -0
  47. package/dist/types/utils/batch-processor.d.ts +205 -0
  48. package/dist/types/utils/connection-pool.d.ts +158 -0
  49. package/dist/types/utils/error-formatter.d.ts +312 -0
  50. package/dist/types/utils/logger.d.ts +29 -4
  51. package/dist/types/utils/rate-limiter.d.ts +13 -8
  52. package/dist/types/utils/retry.d.ts +10 -0
  53. package/package.json +13 -3
  54. package/src/cache/memory-cache.js +4 -1
  55. package/src/cache/redis-cache.js +4 -1
  56. package/src/cli/enhanced-cli-commands.js +79 -0
  57. package/src/cli/handlers/ai-handler.js +2 -2
  58. package/src/cli/handlers/eval-handler.js +201 -0
  59. package/src/cli/handlers/generate-handler.js +103 -0
  60. package/src/cli/services/benchmark.js +2 -2
  61. package/src/cli/services/rag-service.js +59 -10
  62. package/src/cli/shared/cli-utils.js +40 -1
  63. package/src/config/component-factory.js +32 -0
  64. package/src/connectors/local-embedder.js +20 -13
  65. package/src/core/observability/event-logger.js +135 -1
  66. package/src/core/observability/metrics.js +223 -5
  67. package/src/core/performance/parallel-processor.js +2 -0
  68. package/src/core/plugin-registry.js +5 -1
  69. package/src/dag/core/execution-scheduler.js +2 -0
  70. package/src/dag/dag-engine.js +13 -0
  71. package/src/dev/dev-server.js +6 -0
  72. package/src/ecosystem/plugin-hub.js +4 -0
  73. package/src/enterprise/audit-logging.js +5 -0
  74. package/src/evaluate/compare.js +395 -0
  75. package/src/evaluate/dataset.js +207 -0
  76. package/src/evaluate/report.js +350 -0
  77. package/src/evaluate/synthetic.js +246 -0
  78. package/src/index.js +17 -0
  79. package/src/retrieval/bm25-search.js +4 -0
  80. package/src/retrieval/file-vector-store.js +4 -1
  81. package/src/retrieval/hybrid-retriever.js +4 -0
  82. package/src/retrieval/pgvector-retriever.js +420 -0
  83. package/src/retrieval/qdrant-retriever.js +361 -0
  84. package/src/security/validator.js +8 -0
  85. package/src/testing.js +2 -0
  86. package/src/utils/batch-processor.js +7 -3
  87. package/src/utils/error-formatter.js +48 -20
  88. package/src/utils/logger.js +31 -1
  89. package/src/utils/rate-limiter.js +9 -4
  90. 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, '&amp;')
210
+ .replace(/</g, '&lt;')
211
+ .replace(/>/g, '&gt;')
212
+ .replace(/"/g, '&quot;')
213
+ .replace(/'/g, '&#39;');
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
+ };