@polderlabs/bizar 4.9.0 → 5.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/bizar-dash/dist/assets/icons-CFqu2M-c.js +656 -0
- package/bizar-dash/dist/assets/icons-CFqu2M-c.js.map +1 -0
- package/bizar-dash/dist/assets/{index-DU61awG3.js → index-DmpSFPJY.js} +1 -1
- package/bizar-dash/dist/assets/{index-DU61awG3.js.map → index-DmpSFPJY.js.map} +1 -1
- package/bizar-dash/dist/assets/main-Dl8yY5_H.js +16 -0
- package/bizar-dash/dist/assets/main-Dl8yY5_H.js.map +1 -0
- package/bizar-dash/dist/assets/{main-DfmIfOUS.css → main-ZAfGKENE.css} +1 -1
- package/bizar-dash/dist/assets/markdown-DIquRulQ.js +29 -0
- package/bizar-dash/dist/assets/markdown-DIquRulQ.js.map +1 -0
- package/bizar-dash/dist/assets/mobile-C2gysFOZ.js +2 -0
- package/bizar-dash/dist/assets/mobile-C2gysFOZ.js.map +1 -0
- package/bizar-dash/dist/assets/mobile-DHXXbn1A.js +1 -0
- package/bizar-dash/dist/assets/{mobile-CL5uUQEC.js.map → mobile-DHXXbn1A.js.map} +1 -1
- package/bizar-dash/dist/assets/react-vendor-DZRUXSPQ.js +40 -0
- package/bizar-dash/dist/assets/react-vendor-DZRUXSPQ.js.map +1 -0
- package/bizar-dash/dist/index.html +6 -3
- package/bizar-dash/dist/mobile.html +5 -2
- package/bizar-dash/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -1
- package/bizar-dash/skills/eval/SKILL.md +237 -0
- package/bizar-dash/src/server/api.mjs +28 -0
- package/bizar-dash/src/server/auth.mjs +155 -1
- package/bizar-dash/src/server/eval-store.mjs +226 -0
- package/bizar-dash/src/server/eval.mjs +347 -0
- package/bizar-dash/src/server/ocr.mjs +55 -0
- package/bizar-dash/src/server/plugins/registry.mjs +363 -0
- package/bizar-dash/src/server/plugins/sandbox.mjs +655 -0
- package/bizar-dash/src/server/plugins/store.mjs +659 -0
- package/bizar-dash/src/server/routes/clipboard.mjs +173 -0
- package/bizar-dash/src/server/routes/eval.mjs +147 -0
- package/bizar-dash/src/server/routes/ocr.mjs +182 -0
- package/bizar-dash/src/server/routes/plugins.mjs +220 -0
- package/bizar-dash/src/server/routes/users.mjs +84 -0
- package/bizar-dash/src/server/routes/voice.mjs +131 -0
- package/bizar-dash/src/server/routes/workspaces.mjs +204 -0
- package/bizar-dash/src/server/voice-store.mjs +202 -0
- package/bizar-dash/src/server/voice-transcribe.mjs +72 -0
- package/bizar-dash/src/server/workspaces.mjs +626 -0
- package/bizar-dash/src/web/components/InviteDialog.tsx +205 -0
- package/bizar-dash/src/web/components/ScreenshotCapture.tsx +138 -0
- package/bizar-dash/src/web/components/ScreenshotOCR.tsx +42 -0
- package/bizar-dash/src/web/components/VoiceNotesPanel.tsx +182 -0
- package/bizar-dash/src/web/components/VoiceRecorder.tsx +104 -0
- package/bizar-dash/src/web/components/WorkspaceSelector.tsx +158 -0
- package/bizar-dash/src/web/styles/memory.css +82 -0
- package/bizar-dash/src/web/views/Memory.tsx +17 -2
- package/bizar-dash/src/web/views/Settings.tsx +3 -0
- package/bizar-dash/src/web/views/Workspace.tsx +294 -0
- package/bizar-dash/src/web/views/memory/FromScreenshotPanel.tsx +23 -0
- package/bizar-dash/src/web/views/memory/VaultFromClipboardPanel.tsx +101 -0
- package/bizar-dash/src/web/views/settings/WorkspacesSection.tsx +189 -0
- package/bizar-dash/tests/bundle-analysis.test.mjs +5 -2
- package/bizar-dash/tests/clipboard.test.mjs +147 -0
- package/bizar-dash/tests/components/screenshot-ocr.test.tsx +75 -0
- package/bizar-dash/tests/components/workspace-selector.test.tsx +73 -0
- package/bizar-dash/tests/deploy-templates.test.mjs +100 -0
- package/bizar-dash/tests/eval/fixtures.test.mjs +141 -0
- package/bizar-dash/tests/eval/report.test.mjs +284 -0
- package/bizar-dash/tests/eval/runner.test.mjs +471 -0
- package/bizar-dash/tests/ocr.test.mjs +87 -0
- package/bizar-dash/tests/plugins-registry.test.mjs +387 -0
- package/bizar-dash/tests/plugins-sandbox.test.mjs +374 -0
- package/bizar-dash/tests/plugins-store.test.mjs +455 -0
- package/bizar-dash/tests/users.test.mjs +108 -0
- package/bizar-dash/tests/voice-recorder.test.tsx +95 -0
- package/bizar-dash/tests/voice-store.test.mjs +148 -0
- package/bizar-dash/tests/voice-transcribe.test.mjs +87 -0
- package/bizar-dash/tests/workspaces.test.mjs +527 -0
- package/cli/bin.mjs +72 -2
- package/cli/commands/clip.mjs +146 -0
- package/cli/commands/deploy/cloudflare.mjs +250 -0
- package/cli/commands/deploy/docker.mjs +221 -0
- package/cli/commands/deploy/fly.mjs +161 -0
- package/cli/commands/deploy/vercel.mjs +225 -0
- package/cli/commands/deploy.mjs +240 -0
- package/cli/commands/eval.mjs +378 -0
- package/cli/commands/marketplace.mjs +64 -0
- package/cli/commands/ocr.mjs +165 -0
- package/cli/commands/plugin.mjs +358 -0
- package/cli/commands/voice.mjs +211 -0
- package/cli/commands/workspace.mjs +247 -0
- package/package.json +12 -8
- package/templates/deploy/cloudflare/README.md +32 -0
- package/templates/deploy/cloudflare/functions-index.template.js +15 -0
- package/templates/deploy/cloudflare/wrangler.toml.template +9 -0
- package/templates/deploy/docker/.env.template +16 -0
- package/templates/deploy/docker/README.md +58 -0
- package/templates/deploy/docker/docker-compose.template.yml +23 -0
- package/templates/deploy/fly/README.md +35 -0
- package/templates/deploy/fly/fly.toml.template +28 -0
- package/templates/deploy/vercel/README.md +29 -0
- package/templates/deploy/vercel/api-index.template.js +18 -0
- package/templates/deploy/vercel/vercel.json.template +16 -0
- package/templates/eval-fixtures/README.md +58 -0
- package/templates/eval-fixtures/code-search-basic.json +28 -0
- package/templates/eval-fixtures/latency-bounds.json +16 -0
- package/templates/eval-fixtures/regression-suite.json +79 -0
- package/templates/eval-fixtures/response-format.json +30 -0
- package/templates/eval-fixtures/tool-call-correctness.json +24 -0
- package/templates/plugin-template/README.md +121 -0
- package/templates/plugin-template/index.js +66 -0
- package/templates/plugin-template/plugin.json +42 -0
- package/templates/plugin-template/tests/plugin.test.js +83 -0
- package/bizar-dash/dist/assets/main-DaC1Lc6q.js +0 -366
- package/bizar-dash/dist/assets/main-DaC1Lc6q.js.map +0 -1
- package/bizar-dash/dist/assets/mobile-CL5uUQEC.js +0 -1
- package/bizar-dash/dist/assets/mobile-D5WTWvuh.js +0 -338
- package/bizar-dash/dist/assets/mobile-D5WTWvuh.js.map +0 -1
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* src/server/eval-store.mjs
|
|
3
|
+
*
|
|
4
|
+
* v5.0.0 — Persistent storage for eval runs and results.
|
|
5
|
+
*
|
|
6
|
+
* Storage: ~/.local/share/bizar/eval/
|
|
7
|
+
*
|
|
8
|
+
* Index: ~/.local/share/bizar/eval/.index.json
|
|
9
|
+
* { "runs": [{ id, startedAt, finishedAt, suitePath, total, passed, failed }] }
|
|
10
|
+
*
|
|
11
|
+
* Per-run: ~/.local/share/bizar/eval/<run-id>.json
|
|
12
|
+
* Full run object with results array.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, unlinkSync, writeFileSync, renameSync, statSync } from 'node:fs';
|
|
16
|
+
import { join, basename } from 'node:path';
|
|
17
|
+
import { homedir } from 'node:os';
|
|
18
|
+
|
|
19
|
+
// Allow test override
|
|
20
|
+
const STORE_HOME = process.env.BIZAR_STORE_HOME
|
|
21
|
+
? process.env.BIZAR_STORE_HOME
|
|
22
|
+
: join(homedir(), '.local', 'share', 'bizar');
|
|
23
|
+
const EVAL_DIR = join(STORE_HOME, 'eval');
|
|
24
|
+
const INDEX_FILE = join(EVAL_DIR, '.index.json');
|
|
25
|
+
|
|
26
|
+
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
27
|
+
|
|
28
|
+
function ensureDir(dir) {
|
|
29
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function atomicWriteJson(filePath, data) {
|
|
33
|
+
const tmp = `${filePath}.tmp.${process.pid}`;
|
|
34
|
+
writeFileSync(tmp, JSON.stringify(data, null, 2) + '\n', 'utf8');
|
|
35
|
+
renameSync(tmp, filePath);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function safeReadJSON(file, fallback = null) {
|
|
39
|
+
try {
|
|
40
|
+
if (!existsSync(file)) return fallback;
|
|
41
|
+
const text = readFileSync(file, 'utf8');
|
|
42
|
+
if (!text.trim()) return fallback;
|
|
43
|
+
return JSON.parse(text);
|
|
44
|
+
} catch {
|
|
45
|
+
return fallback;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function loadIndex() {
|
|
50
|
+
return safeReadJSON(INDEX_FILE, { runs: [] });
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function saveIndex(index) {
|
|
54
|
+
ensureDir(EVAL_DIR);
|
|
55
|
+
atomicWriteJson(INDEX_FILE, index);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// ── Public API ────────────────────────────────────────────────────────────────
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Save a completed run to disk.
|
|
62
|
+
*
|
|
63
|
+
* @param {object} run
|
|
64
|
+
* @param {string} run.id
|
|
65
|
+
* @param {string} run.startedAt
|
|
66
|
+
* @param {string} run.finishedAt
|
|
67
|
+
* @param {string} run.suitePath
|
|
68
|
+
* @param {number} run.total
|
|
69
|
+
* @param {number} run.passed
|
|
70
|
+
* @param {number} run.failed
|
|
71
|
+
* @param {object[]} run.results
|
|
72
|
+
* @returns {{ ok: boolean, path: string }}
|
|
73
|
+
*/
|
|
74
|
+
export async function saveRun(run) {
|
|
75
|
+
ensureDir(EVAL_DIR);
|
|
76
|
+
|
|
77
|
+
// Write the run file
|
|
78
|
+
const runPath = join(EVAL_DIR, `${run.id}.json`);
|
|
79
|
+
atomicWriteJson(runPath, run);
|
|
80
|
+
|
|
81
|
+
// Update the index
|
|
82
|
+
const index = loadIndex();
|
|
83
|
+
const existingIdx = index.runs.findIndex((r) => r.id === run.id);
|
|
84
|
+
const indexEntry = {
|
|
85
|
+
id: run.id,
|
|
86
|
+
startedAt: run.startedAt,
|
|
87
|
+
finishedAt: run.finishedAt,
|
|
88
|
+
suitePath: run.suitePath,
|
|
89
|
+
total: run.total,
|
|
90
|
+
passed: run.passed,
|
|
91
|
+
failed: run.failed,
|
|
92
|
+
};
|
|
93
|
+
if (existingIdx >= 0) {
|
|
94
|
+
index.runs[existingIdx] = indexEntry;
|
|
95
|
+
} else {
|
|
96
|
+
index.runs.unshift(indexEntry); // newest first
|
|
97
|
+
}
|
|
98
|
+
saveIndex(index);
|
|
99
|
+
|
|
100
|
+
return { ok: true, path: runPath };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* List recent runs, newest first.
|
|
105
|
+
*
|
|
106
|
+
* @param {{ limit?: number }} opts
|
|
107
|
+
* @returns {Promise<object[]>}
|
|
108
|
+
*/
|
|
109
|
+
export async function listRuns({ limit = 20 } = {}) {
|
|
110
|
+
const index = loadIndex();
|
|
111
|
+
return index.runs.slice(0, Math.max(1, limit));
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Get a full run by id.
|
|
116
|
+
*
|
|
117
|
+
* @param {string} id
|
|
118
|
+
* @returns {Promise<object | null>}
|
|
119
|
+
*/
|
|
120
|
+
export async function getRun(id) {
|
|
121
|
+
if (!id) return null;
|
|
122
|
+
const runPath = join(EVAL_DIR, `${id}.json`);
|
|
123
|
+
return safeReadJSON(runPath, null);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Compare two runs and categorize fixtures by diff.
|
|
128
|
+
*
|
|
129
|
+
* @param {string} id1
|
|
130
|
+
* @param {string} id2
|
|
131
|
+
* @returns {Promise<{ improved: object[], regressed: object[], unchanged: object[] }>}
|
|
132
|
+
*/
|
|
133
|
+
export async function compareRuns(id1, id2) {
|
|
134
|
+
const [run1, run2] = await Promise.all([getRun(id1), getRun(id2)]);
|
|
135
|
+
|
|
136
|
+
if (!run1 || !run2) {
|
|
137
|
+
return { improved: [], regressed: [], unchanged: [], error: 'run not found' };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Build a map of fixtureId -> result for each run
|
|
141
|
+
const map1 = new Map(run1.results.map((r) => [r.fixtureId, r]));
|
|
142
|
+
const map2 = new Map(run2.results.map((r) => [r.fixtureId, r]));
|
|
143
|
+
|
|
144
|
+
/** @type {object[]} */
|
|
145
|
+
const improved = [];
|
|
146
|
+
/** @type {object[]} */
|
|
147
|
+
const regressed = [];
|
|
148
|
+
/** @type {object[]} */
|
|
149
|
+
const unchanged = [];
|
|
150
|
+
|
|
151
|
+
// Check all fixtures from run1
|
|
152
|
+
for (const [fixtureId, result1] of map1) {
|
|
153
|
+
const result2 = map2.get(fixtureId);
|
|
154
|
+
if (!result2) {
|
|
155
|
+
// Fixture disappeared
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const entry = {
|
|
160
|
+
fixtureId,
|
|
161
|
+
run1Ok: result1.ok,
|
|
162
|
+
run2Ok: result2.ok,
|
|
163
|
+
run1LatencyMs: result1.latencyMs,
|
|
164
|
+
run2LatencyMs: result2.latencyMs,
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
if (result1.ok && !result2.ok) {
|
|
168
|
+
// Was passing, now failing = regressed
|
|
169
|
+
regressed.push({ ...entry, kind: 'regressed' });
|
|
170
|
+
} else if (!result1.ok && result2.ok) {
|
|
171
|
+
// Was failing, now passing = improved
|
|
172
|
+
improved.push({ ...entry, kind: 'improved' });
|
|
173
|
+
} else {
|
|
174
|
+
// Same result
|
|
175
|
+
unchanged.push({ ...entry, kind: 'unchanged' });
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return { improved, regressed, unchanged };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Delete a run by id.
|
|
184
|
+
*
|
|
185
|
+
* @param {string} id
|
|
186
|
+
* @returns {{ ok: boolean }}
|
|
187
|
+
*/
|
|
188
|
+
export async function deleteRun(id) {
|
|
189
|
+
if (!id) return { ok: false };
|
|
190
|
+
const runPath = join(EVAL_DIR, `${id}.json`);
|
|
191
|
+
try {
|
|
192
|
+
if (existsSync(runPath)) unlinkSync(runPath);
|
|
193
|
+
} catch { /* ignore */ }
|
|
194
|
+
|
|
195
|
+
const index = loadIndex();
|
|
196
|
+
index.runs = index.runs.filter((r) => r.id !== id);
|
|
197
|
+
saveIndex(index);
|
|
198
|
+
|
|
199
|
+
return { ok: true };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Build a unique run id.
|
|
204
|
+
*
|
|
205
|
+
* @returns {string}
|
|
206
|
+
*/
|
|
207
|
+
export function buildRunId() {
|
|
208
|
+
const ts = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
|
|
209
|
+
const rand = Math.random().toString(36).slice(2, 6);
|
|
210
|
+
return `run_${ts}_${rand}`;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// ── Test reset ────────────────────────────────────────────────────────────────
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Wipe all eval data. For test isolation only.
|
|
217
|
+
*/
|
|
218
|
+
export function __resetStoreForTests() {
|
|
219
|
+
try {
|
|
220
|
+
const index = loadIndex();
|
|
221
|
+
for (const r of index.runs) {
|
|
222
|
+
try { unlinkSync(join(EVAL_DIR, `${r.id}.json`)); } catch { /* ignore */ }
|
|
223
|
+
}
|
|
224
|
+
try { unlinkSync(INDEX_FILE); } catch { /* ignore */ }
|
|
225
|
+
} catch { /* ignore */ }
|
|
226
|
+
}
|
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* src/server/eval.mjs
|
|
3
|
+
*
|
|
4
|
+
* v5.0.0 — Eval runner for evaluating AI agent outputs against golden fixtures.
|
|
5
|
+
*
|
|
6
|
+
* Supports:
|
|
7
|
+
* - contains/notContains substring checks
|
|
8
|
+
* - regex pattern matching
|
|
9
|
+
* - JSON schema validation
|
|
10
|
+
* - maxTokens bounds
|
|
11
|
+
* - maxLatencyMs bounds
|
|
12
|
+
* - Parallel fixture execution with configurable concurrency
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { readFileSync, readdirSync } from 'node:fs';
|
|
16
|
+
import { join, extname } from 'node:path';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* @typedef {object} Fixture
|
|
20
|
+
* @property {string} id
|
|
21
|
+
* @property {string} name
|
|
22
|
+
* @property {string} description
|
|
23
|
+
* @property {string} agent
|
|
24
|
+
* @property {string} prompt
|
|
25
|
+
* @property {FixtureExpectations} expected
|
|
26
|
+
* @property {string[]} [tags]
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* @typedef {object} FixtureExpectations
|
|
31
|
+
* @property {string[]} [contains]
|
|
32
|
+
* @property {string[]} [notContains]
|
|
33
|
+
* @property {string[]} [regex]
|
|
34
|
+
* @property {object} [jsonSchema]
|
|
35
|
+
* @property {number} [maxTokens]
|
|
36
|
+
* @property {number} [maxLatencyMs]
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* @typedef {object} CheckResult
|
|
41
|
+
* @property {string} kind
|
|
42
|
+
* @property {boolean} ok
|
|
43
|
+
* @property {string} [message]
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* @typedef {object} EvalResult
|
|
48
|
+
* @property {string} fixtureId
|
|
49
|
+
* @property {boolean} ok
|
|
50
|
+
* @property {CheckResult[]} checks
|
|
51
|
+
* @property {number} latencyMs
|
|
52
|
+
* @property {string} content
|
|
53
|
+
* @property {{ inputTokens: number, outputTokens: number, totalTokens: number }} usage
|
|
54
|
+
*/
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* @typedef {object} SuiteResult
|
|
58
|
+
* @property {string} suitePath
|
|
59
|
+
* @property {number} total
|
|
60
|
+
* @property {number} passed
|
|
61
|
+
* @property {number} failed
|
|
62
|
+
* @property {EvalResult[]} results
|
|
63
|
+
*/
|
|
64
|
+
|
|
65
|
+
// ── Check implementations ──────────────────────────────────────────────────────
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* @param {string} content
|
|
69
|
+
* @param {string[]} needles
|
|
70
|
+
* @returns {CheckResult[]}
|
|
71
|
+
*/
|
|
72
|
+
function checkContains(content, needles) {
|
|
73
|
+
return needles.map((needle) => {
|
|
74
|
+
const ok = content.includes(needle);
|
|
75
|
+
return {
|
|
76
|
+
kind: 'contains',
|
|
77
|
+
ok,
|
|
78
|
+
message: ok ? undefined : `expected to contain: "${needle}"`,
|
|
79
|
+
};
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* @param {string} content
|
|
85
|
+
* @param {string[]} needles
|
|
86
|
+
* @returns {CheckResult[]}
|
|
87
|
+
*/
|
|
88
|
+
function checkNotContains(content, needles) {
|
|
89
|
+
return needles.map((needle) => {
|
|
90
|
+
const ok = !content.includes(needle);
|
|
91
|
+
return {
|
|
92
|
+
kind: 'notContains',
|
|
93
|
+
ok,
|
|
94
|
+
message: ok ? undefined : `expected NOT to contain: "${needle}"`,
|
|
95
|
+
};
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* @param {string} content
|
|
101
|
+
* @param {string[]} patterns
|
|
102
|
+
* @returns {CheckResult[]}
|
|
103
|
+
*/
|
|
104
|
+
function checkRegex(content, patterns) {
|
|
105
|
+
return patterns.map((pattern) => {
|
|
106
|
+
let ok = false;
|
|
107
|
+
let message;
|
|
108
|
+
try {
|
|
109
|
+
const re = new RegExp(pattern);
|
|
110
|
+
ok = re.test(content);
|
|
111
|
+
message = ok ? undefined : `regex did not match: ${pattern}`;
|
|
112
|
+
} catch (err) {
|
|
113
|
+
ok = false;
|
|
114
|
+
message = `invalid regex: ${pattern} — ${err.message}`;
|
|
115
|
+
}
|
|
116
|
+
return { kind: 'regex', ok, message };
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* @param {string} content
|
|
122
|
+
* @param {object} schema
|
|
123
|
+
* @returns {CheckResult}
|
|
124
|
+
*/
|
|
125
|
+
function checkJsonSchema(content, schema) {
|
|
126
|
+
if (!schema) return { kind: 'jsonSchema', ok: true };
|
|
127
|
+
let parsed;
|
|
128
|
+
try {
|
|
129
|
+
parsed = JSON.parse(content);
|
|
130
|
+
} catch {
|
|
131
|
+
return { kind: 'jsonSchema', ok: false, message: 'response is not valid JSON' };
|
|
132
|
+
}
|
|
133
|
+
// Basic schema validation — check required fields exist
|
|
134
|
+
const errors = [];
|
|
135
|
+
if (schema.type === 'object' && typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
136
|
+
errors.push(`expected object, got ${typeof parsed}`);
|
|
137
|
+
}
|
|
138
|
+
if (schema.properties) {
|
|
139
|
+
for (const [key, prop] of Object.entries(schema.properties)) {
|
|
140
|
+
if (parsed[key] === undefined) {
|
|
141
|
+
if (!schema.required?.includes(key)) continue;
|
|
142
|
+
errors.push(`missing required property: ${key}`);
|
|
143
|
+
} else if (prop.type && typeof parsed[key] !== prop.type) {
|
|
144
|
+
errors.push(`property "${key}" expected type ${prop.type}, got ${typeof parsed[key]}`);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
return {
|
|
149
|
+
kind: 'jsonSchema',
|
|
150
|
+
ok: errors.length === 0,
|
|
151
|
+
message: errors.length > 0 ? errors.join('; ') : undefined,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* @param {{ inputTokens: number, outputTokens: number, totalTokens: number }} usage
|
|
157
|
+
* @param {number} maxTokens
|
|
158
|
+
* @returns {CheckResult}
|
|
159
|
+
*/
|
|
160
|
+
function checkMaxTokens(usage, maxTokens) {
|
|
161
|
+
if (maxTokens == null) return { kind: 'maxTokens', ok: true };
|
|
162
|
+
const ok = usage.totalTokens <= maxTokens;
|
|
163
|
+
return {
|
|
164
|
+
kind: 'maxTokens',
|
|
165
|
+
ok,
|
|
166
|
+
message: ok ? undefined : `token count ${usage.totalTokens} exceeds max ${maxTokens}`,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* @param {number} latencyMs
|
|
172
|
+
* @param {number} maxLatencyMs
|
|
173
|
+
* @returns {CheckResult}
|
|
174
|
+
*/
|
|
175
|
+
function checkMaxLatency(latencyMs, maxLatencyMs) {
|
|
176
|
+
if (maxLatencyMs == null) return { kind: 'maxLatencyMs', ok: true };
|
|
177
|
+
const ok = latencyMs <= maxLatencyMs;
|
|
178
|
+
return {
|
|
179
|
+
kind: 'maxLatencyMs',
|
|
180
|
+
ok,
|
|
181
|
+
message: ok ? undefined : `latency ${latencyMs}ms exceeds max ${maxLatencyMs}ms`,
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// ── Core runner ───────────────────────────────────────────────────────────────
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Run a single fixture against an LLM.
|
|
189
|
+
*
|
|
190
|
+
* @param {Fixture} fixture
|
|
191
|
+
* @param {{ llmCall: (prompt: string, opts: {agent: string}) => Promise<{content: string, usage: object}>, timeoutMs?: number }} opts
|
|
192
|
+
* @returns {Promise<EvalResult>}
|
|
193
|
+
*/
|
|
194
|
+
export async function runFixture(
|
|
195
|
+
fixture,
|
|
196
|
+
{ llmCall, timeoutMs = 60000 } = {},
|
|
197
|
+
) {
|
|
198
|
+
if (!llmCall) throw new Error('llmCall is required');
|
|
199
|
+
const start = Date.now();
|
|
200
|
+
let content = '';
|
|
201
|
+
let usage = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
|
|
202
|
+
let timeoutHit = false;
|
|
203
|
+
let callError = null;
|
|
204
|
+
|
|
205
|
+
try {
|
|
206
|
+
const ctrl = new AbortController();
|
|
207
|
+
const timer = setTimeout(() => ctrl.abort(), timeoutMs);
|
|
208
|
+
const result = await Promise.race([
|
|
209
|
+
llmCall(fixture.prompt, { agent: fixture.agent }),
|
|
210
|
+
new Promise((_, reject) => ctrl.signal.addEventListener('abort', () => reject(new Error('timeout')))),
|
|
211
|
+
]);
|
|
212
|
+
clearTimeout(timer);
|
|
213
|
+
content = result.content || '';
|
|
214
|
+
usage = result.usage || { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
|
|
215
|
+
} catch (err) {
|
|
216
|
+
if (err.message === 'timeout') {
|
|
217
|
+
timeoutHit = true;
|
|
218
|
+
} else {
|
|
219
|
+
callError = err;
|
|
220
|
+
}
|
|
221
|
+
content = '';
|
|
222
|
+
usage = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const latencyMs = Date.now() - start;
|
|
226
|
+
const checks = [];
|
|
227
|
+
const expected = fixture.expected || {};
|
|
228
|
+
|
|
229
|
+
// Timeout check
|
|
230
|
+
if (timeoutHit) {
|
|
231
|
+
checks.push({ kind: 'timeout', ok: false, message: `call exceeded ${timeoutMs}ms` });
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Error check (non-timeout error)
|
|
235
|
+
if (callError) {
|
|
236
|
+
checks.push({ kind: 'error', ok: false, message: callError.message });
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// Content checks
|
|
240
|
+
if (expected.contains?.length) {
|
|
241
|
+
checks.push(...checkContains(content, expected.contains));
|
|
242
|
+
}
|
|
243
|
+
if (expected.notContains?.length) {
|
|
244
|
+
checks.push(...checkNotContains(content, expected.notContains));
|
|
245
|
+
}
|
|
246
|
+
if (expected.regex?.length) {
|
|
247
|
+
checks.push(...checkRegex(content, expected.regex));
|
|
248
|
+
}
|
|
249
|
+
if (expected.jsonSchema) {
|
|
250
|
+
checks.push(checkJsonSchema(content, expected.jsonSchema));
|
|
251
|
+
}
|
|
252
|
+
if (expected.maxTokens != null) {
|
|
253
|
+
checks.push(checkMaxTokens(usage, expected.maxTokens));
|
|
254
|
+
}
|
|
255
|
+
if (expected.maxLatencyMs != null) {
|
|
256
|
+
checks.push(checkMaxLatency(latencyMs, expected.maxLatencyMs));
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const ok = checks.every((c) => c.ok);
|
|
260
|
+
|
|
261
|
+
return {
|
|
262
|
+
fixtureId: fixture.id,
|
|
263
|
+
ok,
|
|
264
|
+
checks,
|
|
265
|
+
latencyMs,
|
|
266
|
+
content,
|
|
267
|
+
usage,
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Load all JSON fixtures from a directory.
|
|
273
|
+
*
|
|
274
|
+
* @param {string} suitePath
|
|
275
|
+
* @returns {Fixture[]}
|
|
276
|
+
*/
|
|
277
|
+
export function loadFixtures(suitePath) {
|
|
278
|
+
/** @type {Fixture[]} */
|
|
279
|
+
const fixtures = [];
|
|
280
|
+
try {
|
|
281
|
+
const entries = readdirSync(suitePath, { withFileTypes: true });
|
|
282
|
+
for (const entry of entries) {
|
|
283
|
+
if (entry.isFile() && extname(entry.name) === '.json') {
|
|
284
|
+
const full = join(suitePath, entry.name);
|
|
285
|
+
try {
|
|
286
|
+
const raw = readFileSync(full, 'utf8');
|
|
287
|
+
const parsed = JSON.parse(raw);
|
|
288
|
+
// Support both single fixture and suite of fixtures
|
|
289
|
+
if (parsed.id && parsed.prompt) {
|
|
290
|
+
fixtures.push(parsed);
|
|
291
|
+
} else if (Array.isArray(parsed.fixtures)) {
|
|
292
|
+
fixtures.push(...parsed.fixtures);
|
|
293
|
+
}
|
|
294
|
+
} catch { /* skip invalid JSON */ }
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
} catch { /* dir not found */ }
|
|
298
|
+
return fixtures;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Run a suite of fixtures.
|
|
303
|
+
*
|
|
304
|
+
* @param {string} suitePath
|
|
305
|
+
* @param {{ llmCall: Function, concurrency?: number, timeoutMs?: number }} opts
|
|
306
|
+
* @returns {Promise<SuiteResult>}
|
|
307
|
+
*/
|
|
308
|
+
export async function runSuite(
|
|
309
|
+
suitePath,
|
|
310
|
+
{ llmCall, concurrency = 5, timeoutMs = 60000 } = {},
|
|
311
|
+
) {
|
|
312
|
+
const fixtures = loadFixtures(suitePath);
|
|
313
|
+
/** @type {EvalResult[]} */
|
|
314
|
+
const results = [];
|
|
315
|
+
let passed = 0;
|
|
316
|
+
let failed = 0;
|
|
317
|
+
|
|
318
|
+
// Run in batches of `concurrency`
|
|
319
|
+
for (let i = 0; i < fixtures.length; i += concurrency) {
|
|
320
|
+
const batch = fixtures.slice(i, i + concurrency);
|
|
321
|
+
const batchResults = await Promise.all(
|
|
322
|
+
batch.map((fixture) =>
|
|
323
|
+
runFixture(fixture, { llmCall, timeoutMs }).catch((err) => ({
|
|
324
|
+
fixtureId: fixture.id,
|
|
325
|
+
ok: false,
|
|
326
|
+
checks: [{ kind: 'error', ok: false, message: err.message }],
|
|
327
|
+
latencyMs: 0,
|
|
328
|
+
content: '',
|
|
329
|
+
usage: { inputTokens: 0, outputTokens: 0, totalTokens: 0 },
|
|
330
|
+
}))
|
|
331
|
+
)
|
|
332
|
+
);
|
|
333
|
+
for (const r of batchResults) {
|
|
334
|
+
results.push(r);
|
|
335
|
+
if (r.ok) passed++;
|
|
336
|
+
else failed++;
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
return {
|
|
341
|
+
suitePath,
|
|
342
|
+
total: fixtures.length,
|
|
343
|
+
passed,
|
|
344
|
+
failed,
|
|
345
|
+
results,
|
|
346
|
+
};
|
|
347
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* src/server/ocr.mjs
|
|
3
|
+
*
|
|
4
|
+
* v5.0.0 — Tesseract.js wrapper for OCR text extraction.
|
|
5
|
+
*
|
|
6
|
+
* Provides a single `extractText(imageBuffer, options?)` function.
|
|
7
|
+
* The worker is created lazily and terminated after each call to avoid
|
|
8
|
+
* memory leaks in long-running server processes.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { createWorker } from 'tesseract.js';
|
|
12
|
+
|
|
13
|
+
let worker = null;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Extract text from an image buffer using Tesseract.js OCR.
|
|
17
|
+
*
|
|
18
|
+
* @param {Buffer} imageBuffer - Raw image data (PNG, JPEG, etc.)
|
|
19
|
+
* @param {{ lang?: string }} [options] - Recognition options.
|
|
20
|
+
* `lang` defaults to 'eng' (English).
|
|
21
|
+
* @returns {Promise<string>} - The extracted text.
|
|
22
|
+
*/
|
|
23
|
+
export async function extractText(imageBuffer, { lang = 'eng' } = {}) {
|
|
24
|
+
const w = worker || (worker = await createWorker(lang));
|
|
25
|
+
|
|
26
|
+
try {
|
|
27
|
+
const { data } = await w.recognize(imageBuffer);
|
|
28
|
+
return (data.text || '').trim();
|
|
29
|
+
} finally {
|
|
30
|
+
// Terminate and reset so a new worker picks up any config changes
|
|
31
|
+
// on the next call. This prevents the worker from accumulating state
|
|
32
|
+
// across requests in long-running servers.
|
|
33
|
+
try { await w.terminate(); } catch { /* ignore */ }
|
|
34
|
+
worker = null;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Check whether Tesseract.js is available and can be initialised.
|
|
40
|
+
* Useful for health-check endpoints.
|
|
41
|
+
*
|
|
42
|
+
* @returns {Promise<{ ok: boolean, message: string }>}
|
|
43
|
+
*/
|
|
44
|
+
export async function checkOcrHealth() {
|
|
45
|
+
try {
|
|
46
|
+
const w = await createWorker('eng');
|
|
47
|
+
await w.terminate();
|
|
48
|
+
return { ok: true, message: 'Tesseract.js worker initialised OK' };
|
|
49
|
+
} catch (err) {
|
|
50
|
+
return {
|
|
51
|
+
ok: false,
|
|
52
|
+
message: `Tesseract.js init failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
}
|