Spaces:
Runtime error
Runtime error
| import { randomUUID } from "node:crypto"; | |
| import { getDbInstance, rowToCamel } from "./core"; | |
| export type EvalTargetType = "suite-default" | "model" | "combo"; | |
| export type EvalCaseStrategy = "contains" | "exact" | "regex" | "custom"; | |
| export interface EvalMessage { | |
| role: string; | |
| content: string; | |
| } | |
| export interface EvalCaseRecord { | |
| id: string; | |
| suiteId: string; | |
| name: string; | |
| model?: string; | |
| input: { | |
| messages: EvalMessage[]; | |
| max_tokens?: number; | |
| }; | |
| expected: { | |
| strategy: EvalCaseStrategy; | |
| value?: string; | |
| }; | |
| tags: string[]; | |
| sortOrder: number; | |
| createdAt: string; | |
| updatedAt: string; | |
| } | |
| export interface EvalSuiteRecord { | |
| id: string; | |
| name: string; | |
| description?: string; | |
| source: "custom"; | |
| caseCount: number; | |
| cases: EvalCaseRecord[]; | |
| createdAt: string; | |
| updatedAt: string; | |
| } | |
| export interface EvalTargetDescriptor { | |
| type: EvalTargetType; | |
| id: string | null; | |
| key: string; | |
| label: string; | |
| } | |
| export interface EvalRunSummary { | |
| total: number; | |
| passed: number; | |
| failed: number; | |
| passRate: number; | |
| } | |
| export interface PersistedEvalRun { | |
| id: string; | |
| runGroupId: string | null; | |
| suiteId: string; | |
| suiteName: string; | |
| target: EvalTargetDescriptor; | |
| apiKeyId: string | null; | |
| avgLatencyMs: number; | |
| summary: EvalRunSummary; | |
| results: Array<Record<string, unknown>>; | |
| outputs: Record<string, string>; | |
| createdAt: string; | |
| } | |
| export interface EvalRoutingRunQuery { | |
| targetIds: string[]; | |
| suiteIds?: string[]; | |
| maxAgeHours?: number; | |
| limit?: number; | |
| } | |
| type JsonRecord = Record<string, unknown>; | |
| interface StatementLike<TRow = unknown> { | |
| all: (...params: unknown[]) => TRow[]; | |
| get: (...params: unknown[]) => TRow | undefined; | |
| run: (...params: unknown[]) => { changes: number }; | |
| } | |
| interface DbLike { | |
| prepare: <TRow = unknown>(sql: string) => StatementLike<TRow>; | |
| } | |
| function hasColumn(db: DbLike, table: string, column: string): boolean { | |
| const rows = db.prepare<{ name?: string }>(`PRAGMA table_info(${table})`).all(); | |
| return rows.some((row) => row && typeof row.name === "string" && row.name === column); | |
| } | |
| function ensureEvalSuiteTables(db: DbLike) { | |
| db.prepare( | |
| `CREATE TABLE IF NOT EXISTS eval_suites ( | |
| id TEXT PRIMARY KEY, | |
| name TEXT NOT NULL, | |
| description TEXT, | |
| created_at TEXT NOT NULL, | |
| updated_at TEXT NOT NULL | |
| )` | |
| ).run(); | |
| if (!hasColumn(db, "eval_suites", "description")) { | |
| db.prepare("ALTER TABLE eval_suites ADD COLUMN description TEXT").run(); | |
| } | |
| if (!hasColumn(db, "eval_suites", "created_at")) { | |
| db.prepare("ALTER TABLE eval_suites ADD COLUMN created_at TEXT NOT NULL DEFAULT ''").run(); | |
| } | |
| if (!hasColumn(db, "eval_suites", "updated_at")) { | |
| db.prepare("ALTER TABLE eval_suites ADD COLUMN updated_at TEXT NOT NULL DEFAULT ''").run(); | |
| } | |
| db.prepare( | |
| `CREATE TABLE IF NOT EXISTS eval_cases ( | |
| id TEXT PRIMARY KEY, | |
| suite_id TEXT NOT NULL, | |
| sort_order INTEGER NOT NULL DEFAULT 0, | |
| name TEXT NOT NULL, | |
| model TEXT, | |
| input_json TEXT NOT NULL, | |
| expected_strategy TEXT NOT NULL, | |
| expected_value TEXT, | |
| tags_json TEXT, | |
| created_at TEXT NOT NULL, | |
| updated_at TEXT NOT NULL | |
| )` | |
| ).run(); | |
| if (!hasColumn(db, "eval_cases", "sort_order")) { | |
| db.prepare("ALTER TABLE eval_cases ADD COLUMN sort_order INTEGER NOT NULL DEFAULT 0").run(); | |
| } | |
| if (!hasColumn(db, "eval_cases", "model")) { | |
| db.prepare("ALTER TABLE eval_cases ADD COLUMN model TEXT").run(); | |
| } | |
| if (!hasColumn(db, "eval_cases", "input_json")) { | |
| db.prepare("ALTER TABLE eval_cases ADD COLUMN input_json TEXT NOT NULL DEFAULT '{}'").run(); | |
| } | |
| if (!hasColumn(db, "eval_cases", "expected_strategy")) { | |
| db.prepare( | |
| "ALTER TABLE eval_cases ADD COLUMN expected_strategy TEXT NOT NULL DEFAULT 'contains'" | |
| ).run(); | |
| } | |
| if (!hasColumn(db, "eval_cases", "expected_value")) { | |
| db.prepare("ALTER TABLE eval_cases ADD COLUMN expected_value TEXT").run(); | |
| } | |
| if (!hasColumn(db, "eval_cases", "tags_json")) { | |
| db.prepare("ALTER TABLE eval_cases ADD COLUMN tags_json TEXT").run(); | |
| } | |
| if (!hasColumn(db, "eval_cases", "created_at")) { | |
| db.prepare("ALTER TABLE eval_cases ADD COLUMN created_at TEXT NOT NULL DEFAULT ''").run(); | |
| } | |
| if (!hasColumn(db, "eval_cases", "updated_at")) { | |
| db.prepare("ALTER TABLE eval_cases ADD COLUMN updated_at TEXT NOT NULL DEFAULT ''").run(); | |
| } | |
| db.prepare( | |
| "CREATE INDEX IF NOT EXISTS idx_eval_suites_updated_at ON eval_suites(updated_at DESC)" | |
| ).run(); | |
| db.prepare( | |
| "CREATE INDEX IF NOT EXISTS idx_eval_cases_suite_order ON eval_cases(suite_id, sort_order ASC, created_at ASC)" | |
| ).run(); | |
| } | |
| function parseJsonRecord(value: unknown): JsonRecord { | |
| if (value && typeof value === "object" && !Array.isArray(value)) { | |
| return value as JsonRecord; | |
| } | |
| if (typeof value !== "string" || value.trim().length === 0) { | |
| return {}; | |
| } | |
| try { | |
| const parsed = JSON.parse(value); | |
| return parsed && typeof parsed === "object" && !Array.isArray(parsed) | |
| ? (parsed as JsonRecord) | |
| : {}; | |
| } catch { | |
| return {}; | |
| } | |
| } | |
| function parseJsonArray(value: unknown): Array<Record<string, unknown>> { | |
| if (Array.isArray(value)) { | |
| return value.filter( | |
| (entry): entry is Record<string, unknown> => | |
| !!entry && typeof entry === "object" && !Array.isArray(entry) | |
| ); | |
| } | |
| if (typeof value !== "string" || value.trim().length === 0) { | |
| return []; | |
| } | |
| try { | |
| const parsed = JSON.parse(value); | |
| return Array.isArray(parsed) | |
| ? parsed.filter( | |
| (entry): entry is Record<string, unknown> => | |
| !!entry && typeof entry === "object" && !Array.isArray(entry) | |
| ) | |
| : []; | |
| } catch { | |
| return []; | |
| } | |
| } | |
| function parseStringArray(value: unknown): string[] { | |
| if (Array.isArray(value)) { | |
| return value | |
| .filter((entry): entry is string => typeof entry === "string") | |
| .map((entry) => entry.trim()) | |
| .filter((entry) => entry.length > 0); | |
| } | |
| if (typeof value !== "string" || value.trim().length === 0) { | |
| return []; | |
| } | |
| try { | |
| const parsed = JSON.parse(value); | |
| return Array.isArray(parsed) | |
| ? parsed | |
| .filter((entry): entry is string => typeof entry === "string") | |
| .map((entry) => entry.trim()) | |
| .filter((entry) => entry.length > 0) | |
| : []; | |
| } catch { | |
| return []; | |
| } | |
| } | |
| function parseMessages(value: unknown): EvalMessage[] { | |
| if (!Array.isArray(value)) { | |
| return []; | |
| } | |
| return value | |
| .map((entry) => { | |
| if (!entry || typeof entry !== "object" || Array.isArray(entry)) { | |
| return null; | |
| } | |
| const row = entry as Record<string, unknown>; | |
| const role = typeof row.role === "string" ? row.role.trim() : ""; | |
| const content = typeof row.content === "string" ? row.content : ""; | |
| if (!role || !content.trim()) { | |
| return null; | |
| } | |
| return { | |
| role, | |
| content, | |
| } satisfies EvalMessage; | |
| }) | |
| .filter((entry): entry is EvalMessage => entry !== null); | |
| } | |
| function parseNumber(value: unknown): number { | |
| const parsed = Number(value); | |
| return Number.isFinite(parsed) ? parsed : 0; | |
| } | |
| function sanitizeEvalCaseInput(value: unknown): EvalCaseRecord["input"] { | |
| const record = | |
| value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : {}; | |
| const maxTokens = parseNumber(record.max_tokens); | |
| const input: EvalCaseRecord["input"] = { | |
| messages: parseMessages(record.messages), | |
| }; | |
| if (maxTokens > 0) { | |
| input.max_tokens = Math.floor(maxTokens); | |
| } | |
| return input; | |
| } | |
| function sanitizeEvalExpected(value: unknown): EvalCaseRecord["expected"] { | |
| const record = | |
| value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : {}; | |
| const strategy = typeof record.strategy === "string" ? record.strategy.trim() : ""; | |
| const normalizedStrategy: EvalCaseStrategy = | |
| strategy === "exact" || strategy === "regex" || strategy === "custom" ? strategy : "contains"; | |
| const expectedValue = | |
| typeof record.value === "string" && record.value.trim().length > 0 ? record.value : undefined; | |
| return { | |
| strategy: normalizedStrategy, | |
| ...(expectedValue ? { value: expectedValue } : {}), | |
| }; | |
| } | |
| function createScorecardFromRuns( | |
| runs: Array<{ | |
| suiteId: string; | |
| suiteName: string; | |
| summary: EvalRunSummary; | |
| results: Array<Record<string, unknown>>; | |
| }> | |
| ) { | |
| const totalCases = runs.reduce((sum, run) => sum + run.summary.total, 0); | |
| const totalPassed = runs.reduce((sum, run) => sum + run.summary.passed, 0); | |
| return { | |
| suites: runs.length, | |
| totalCases, | |
| totalPassed, | |
| overallPassRate: totalCases > 0 ? Math.round((totalPassed / totalCases) * 100) : 0, | |
| perSuite: runs.map((run) => ({ | |
| id: run.suiteId, | |
| name: run.suiteName, | |
| passRate: run.summary.passRate, | |
| })), | |
| }; | |
| } | |
| export function serializeEvalTargetKey(type: EvalTargetType, id?: string | null): string { | |
| return `${type}:${typeof id === "string" && id.trim().length > 0 ? id.trim() : "__default__"}`; | |
| } | |
| function toTargetDescriptor(row: JsonRecord): EvalTargetDescriptor { | |
| const type = row.targetType; | |
| const rawId = row.targetId; | |
| const id = typeof rawId === "string" && rawId.trim().length > 0 ? rawId.trim() : null; | |
| const normalizedType: EvalTargetType = | |
| type === "combo" || type === "model" || type === "suite-default" ? type : "suite-default"; | |
| return { | |
| type: normalizedType, | |
| id, | |
| key: serializeEvalTargetKey(normalizedType, id), | |
| label: | |
| typeof row.targetLabel === "string" && row.targetLabel.trim().length > 0 | |
| ? row.targetLabel.trim() | |
| : normalizedType === "combo" | |
| ? `Combo: ${id || "Unknown"}` | |
| : normalizedType === "model" | |
| ? `Model: ${id || "Unknown"}` | |
| : "Suite defaults", | |
| }; | |
| } | |
| function toPersistedEvalRun(row: unknown): PersistedEvalRun | null { | |
| const camel = rowToCamel(row) as JsonRecord | null; | |
| if (!camel) return null; | |
| // rowToCamel auto-parses `*_json` columns and exposes them under the base name | |
| // (summary_json → camel.summary); keep *Json fallbacks for alternate adapters/callers. | |
| const summaryRecord = parseJsonRecord(camel.summary ?? camel.summaryJson); | |
| const outputsRecord = parseJsonRecord(camel.outputs ?? camel.outputsJson); | |
| const outputs = Object.fromEntries( | |
| Object.entries(outputsRecord) | |
| .filter((entry): entry is [string, string] => typeof entry[0] === "string") | |
| .map(([key, value]) => [key, typeof value === "string" ? value : String(value ?? "")]) | |
| ); | |
| return { | |
| id: typeof camel.id === "string" ? camel.id : "", | |
| runGroupId: | |
| typeof camel.runGroupId === "string" && camel.runGroupId.trim().length > 0 | |
| ? camel.runGroupId | |
| : null, | |
| suiteId: typeof camel.suiteId === "string" ? camel.suiteId : "", | |
| suiteName: typeof camel.suiteName === "string" ? camel.suiteName : "", | |
| target: toTargetDescriptor(camel), | |
| apiKeyId: | |
| typeof camel.apiKeyId === "string" && camel.apiKeyId.trim().length > 0 | |
| ? camel.apiKeyId | |
| : null, | |
| avgLatencyMs: parseNumber(camel.avgLatencyMs), | |
| summary: { | |
| total: parseNumber(summaryRecord.total ?? camel.total), | |
| passed: parseNumber(summaryRecord.passed ?? camel.passed), | |
| failed: parseNumber(summaryRecord.failed ?? camel.failed), | |
| passRate: parseNumber(summaryRecord.passRate ?? camel.passRate), | |
| }, | |
| results: parseJsonArray(camel.results ?? camel.resultsJson), | |
| outputs, | |
| createdAt: typeof camel.createdAt === "string" ? camel.createdAt : "", | |
| }; | |
| } | |
| function toEvalCaseRecord(row: unknown): EvalCaseRecord | null { | |
| const camel = rowToCamel(row) as JsonRecord | null; | |
| if (!camel) return null; | |
| const input = sanitizeEvalCaseInput(parseJsonRecord(camel.input ?? camel.inputJson)); | |
| const expected = sanitizeEvalExpected({ | |
| strategy: camel.expectedStrategy, | |
| value: camel.expectedValue, | |
| }); | |
| return { | |
| id: typeof camel.id === "string" ? camel.id : "", | |
| suiteId: typeof camel.suiteId === "string" ? camel.suiteId : "", | |
| name: typeof camel.name === "string" ? camel.name : "", | |
| ...(typeof camel.model === "string" && camel.model.trim().length > 0 | |
| ? { model: camel.model.trim() } | |
| : {}), | |
| input, | |
| expected, | |
| tags: parseStringArray(camel.tags ?? camel.tagsJson), | |
| sortOrder: parseNumber(camel.sortOrder), | |
| createdAt: typeof camel.createdAt === "string" ? camel.createdAt : "", | |
| updatedAt: typeof camel.updatedAt === "string" ? camel.updatedAt : "", | |
| }; | |
| } | |
| function toEvalSuiteRecord(row: unknown, cases: EvalCaseRecord[]): EvalSuiteRecord | null { | |
| const camel = rowToCamel(row) as JsonRecord | null; | |
| if (!camel) return null; | |
| return { | |
| id: typeof camel.id === "string" ? camel.id : "", | |
| name: typeof camel.name === "string" ? camel.name : "", | |
| ...(typeof camel.description === "string" && camel.description.trim().length > 0 | |
| ? { description: camel.description } | |
| : {}), | |
| source: "custom", | |
| caseCount: cases.length, | |
| cases, | |
| createdAt: typeof camel.createdAt === "string" ? camel.createdAt : "", | |
| updatedAt: typeof camel.updatedAt === "string" ? camel.updatedAt : "", | |
| }; | |
| } | |
| export function saveEvalRun(input: { | |
| runGroupId?: string | null; | |
| suiteId: string; | |
| suiteName: string; | |
| target: { type: EvalTargetType; id?: string | null; label: string }; | |
| apiKeyId?: string | null; | |
| avgLatencyMs?: number; | |
| summary: EvalRunSummary; | |
| results: Array<Record<string, unknown>>; | |
| outputs?: Record<string, string>; | |
| createdAt?: string; | |
| }): PersistedEvalRun { | |
| const db = getDbInstance() as unknown as DbLike; | |
| const createdAt = input.createdAt || new Date().toISOString(); | |
| const id = randomUUID(); | |
| const targetId = | |
| typeof input.target.id === "string" && input.target.id.trim().length > 0 | |
| ? input.target.id.trim() | |
| : null; | |
| const avgLatencyMs = Number.isFinite(Number(input.avgLatencyMs)) | |
| ? Math.max(0, Math.round(Number(input.avgLatencyMs))) | |
| : 0; | |
| db.prepare( | |
| `INSERT INTO eval_runs | |
| (id, run_group_id, suite_id, suite_name, target_type, target_id, target_label, api_key_id, | |
| pass_rate, total, passed, failed, avg_latency_ms, summary_json, results_json, outputs_json, created_at) | |
| VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` | |
| ).run( | |
| id, | |
| input.runGroupId || null, | |
| input.suiteId, | |
| input.suiteName, | |
| input.target.type, | |
| targetId, | |
| input.target.label, | |
| input.apiKeyId || null, | |
| input.summary.passRate, | |
| input.summary.total, | |
| input.summary.passed, | |
| input.summary.failed, | |
| avgLatencyMs, | |
| JSON.stringify(input.summary), | |
| JSON.stringify(input.results || []), | |
| JSON.stringify(input.outputs || {}), | |
| createdAt | |
| ); | |
| return { | |
| id, | |
| runGroupId: input.runGroupId || null, | |
| suiteId: input.suiteId, | |
| suiteName: input.suiteName, | |
| target: { | |
| type: input.target.type, | |
| id: targetId, | |
| key: serializeEvalTargetKey(input.target.type, targetId), | |
| label: input.target.label, | |
| }, | |
| apiKeyId: input.apiKeyId || null, | |
| avgLatencyMs, | |
| summary: input.summary, | |
| results: input.results || [], | |
| outputs: input.outputs || {}, | |
| createdAt, | |
| }; | |
| } | |
| export function listEvalRuns( | |
| options: { | |
| suiteId?: string; | |
| runGroupId?: string; | |
| limit?: number; | |
| } = {} | |
| ): PersistedEvalRun[] { | |
| const db = getDbInstance() as unknown as DbLike; | |
| const conditions: string[] = []; | |
| const params: unknown[] = []; | |
| if (options.suiteId) { | |
| conditions.push("suite_id = ?"); | |
| params.push(options.suiteId); | |
| } | |
| if (options.runGroupId) { | |
| conditions.push("run_group_id = ?"); | |
| params.push(options.runGroupId); | |
| } | |
| const limit = Number.isFinite(Number(options.limit)) | |
| ? Math.min(200, Math.max(1, Math.floor(Number(options.limit)))) | |
| : 20; | |
| params.push(limit); | |
| const sql = `SELECT * | |
| FROM eval_runs | |
| ${conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : ""} | |
| ORDER BY created_at DESC | |
| LIMIT ?`; | |
| const rows = db.prepare(sql).all(...params); | |
| return rows | |
| .map((row) => toPersistedEvalRun(row)) | |
| .filter((row): row is PersistedEvalRun => row !== null); | |
| } | |
| export function listModelEvalRunsForRouting(options: EvalRoutingRunQuery): PersistedEvalRun[] { | |
| const targetIds = [...new Set(options.targetIds.map((id) => id.trim()).filter(Boolean))].slice( | |
| 0, | |
| 200 | |
| ); | |
| if (targetIds.length === 0) return []; | |
| const suiteIds = Array.isArray(options.suiteIds) | |
| ? [...new Set(options.suiteIds.map((id) => id.trim()).filter(Boolean))].slice(0, 50) | |
| : []; | |
| const db = getDbInstance() as unknown as DbLike; | |
| const conditions: string[] = ["target_type = 'model'"]; | |
| const params: unknown[] = []; | |
| conditions.push(`target_id IN (${targetIds.map(() => "?").join(", ")})`); | |
| params.push(...targetIds); | |
| if (suiteIds.length > 0) { | |
| conditions.push(`suite_id IN (${suiteIds.map(() => "?").join(", ")})`); | |
| params.push(...suiteIds); | |
| } | |
| const maxAgeHours = Number(options.maxAgeHours); | |
| if (Number.isFinite(maxAgeHours) && maxAgeHours > 0) { | |
| conditions.push("created_at >= ?"); | |
| params.push(new Date(Date.now() - maxAgeHours * 60 * 60 * 1000).toISOString()); | |
| } | |
| const limit = Number.isFinite(Number(options.limit)) | |
| ? Math.min(1000, Math.max(1, Math.floor(Number(options.limit)))) | |
| : Math.min(1000, Math.max(50, targetIds.length * Math.max(3, suiteIds.length || 5) * 2)); | |
| params.push(limit); | |
| const rows = db | |
| .prepare( | |
| `SELECT * | |
| FROM eval_runs | |
| WHERE ${conditions.join(" AND ")} | |
| ORDER BY created_at DESC | |
| LIMIT ?` | |
| ) | |
| .all(...params); | |
| return rows | |
| .map((row) => toPersistedEvalRun(row)) | |
| .filter((row): row is PersistedEvalRun => row !== null); | |
| } | |
| export function getEvalScorecard( | |
| options: { | |
| suiteId?: string; | |
| limit?: number; | |
| } = {} | |
| ) { | |
| const runs = listEvalRuns({ suiteId: options.suiteId, limit: options.limit || 50 }); | |
| if (runs.length === 0) return null; | |
| const latestByScope = new Map<string, PersistedEvalRun>(); | |
| for (const run of runs) { | |
| const scopeKey = `${run.suiteId}:${run.target.key}`; | |
| if (!latestByScope.has(scopeKey)) { | |
| latestByScope.set(scopeKey, run); | |
| } | |
| } | |
| return createScorecardFromRuns( | |
| Array.from(latestByScope.values()).map((run) => ({ | |
| suiteId: `${run.suiteId}:${run.target.key}`, | |
| suiteName: `${run.suiteName} · ${run.target.label}`, | |
| results: run.results, | |
| summary: run.summary, | |
| })) | |
| ); | |
| } | |
| export function listCustomEvalSuites(): EvalSuiteRecord[] { | |
| const db = getDbInstance() as unknown as DbLike; | |
| ensureEvalSuiteTables(db); | |
| const suiteRows = db | |
| .prepare("SELECT * FROM eval_suites ORDER BY updated_at DESC, created_at DESC") | |
| .all(); | |
| const caseRows = db | |
| .prepare( | |
| "SELECT * FROM eval_cases ORDER BY suite_id ASC, sort_order ASC, created_at ASC, id ASC" | |
| ) | |
| .all(); | |
| const casesBySuite = new Map<string, EvalCaseRecord[]>(); | |
| for (const row of caseRows) { | |
| const parsed = toEvalCaseRecord(row); | |
| if (!parsed || !parsed.suiteId) continue; | |
| const current = casesBySuite.get(parsed.suiteId) || []; | |
| current.push(parsed); | |
| casesBySuite.set(parsed.suiteId, current); | |
| } | |
| return suiteRows | |
| .map((row) => { | |
| const camel = rowToCamel(row) as JsonRecord | null; | |
| const suiteId = camel && typeof camel.id === "string" ? camel.id : ""; | |
| return toEvalSuiteRecord(row, casesBySuite.get(suiteId) || []); | |
| }) | |
| .filter((suite): suite is EvalSuiteRecord => suite !== null); | |
| } | |
| export function getCustomEvalSuite(suiteId: string): EvalSuiteRecord | null { | |
| const normalizedSuiteId = suiteId.trim(); | |
| if (!normalizedSuiteId) return null; | |
| return listCustomEvalSuites().find((suite) => suite.id === normalizedSuiteId) || null; | |
| } | |
| export function saveCustomEvalSuite(input: { | |
| id?: string; | |
| name: string; | |
| description?: string; | |
| cases: Array<{ | |
| id?: string; | |
| name: string; | |
| model?: string; | |
| input: { | |
| messages: EvalMessage[]; | |
| max_tokens?: number; | |
| }; | |
| expected: { | |
| strategy: EvalCaseStrategy; | |
| value?: string; | |
| }; | |
| tags?: string[]; | |
| }>; | |
| }): EvalSuiteRecord { | |
| const db = getDbInstance() as unknown as DbLike; | |
| ensureEvalSuiteTables(db); | |
| const now = new Date().toISOString(); | |
| const suiteId = | |
| typeof input.id === "string" && input.id.trim().length > 0 ? input.id.trim() : randomUUID(); | |
| const name = input.name.trim(); | |
| const description = | |
| typeof input.description === "string" && input.description.trim().length > 0 | |
| ? input.description.trim() | |
| : null; | |
| if (!name) { | |
| throw new Error("Suite name is required"); | |
| } | |
| if (!Array.isArray(input.cases) || input.cases.length === 0) { | |
| throw new Error("At least one eval case is required"); | |
| } | |
| db.prepare("BEGIN").run(); | |
| try { | |
| const existing = db | |
| .prepare<{ id: string }>("SELECT id FROM eval_suites WHERE id = ?") | |
| .get(suiteId); | |
| if (existing) { | |
| db.prepare( | |
| `UPDATE eval_suites | |
| SET name = ?, description = ?, updated_at = ? | |
| WHERE id = ?` | |
| ).run(name, description, now, suiteId); | |
| } else { | |
| db.prepare( | |
| `INSERT INTO eval_suites (id, name, description, created_at, updated_at) | |
| VALUES (?, ?, ?, ?, ?)` | |
| ).run(suiteId, name, description, now, now); | |
| } | |
| db.prepare("DELETE FROM eval_cases WHERE suite_id = ?").run(suiteId); | |
| input.cases.forEach((rawCase, index) => { | |
| const caseId = | |
| typeof rawCase.id === "string" && rawCase.id.trim().length > 0 | |
| ? rawCase.id.trim() | |
| : randomUUID(); | |
| const caseName = rawCase.name.trim(); | |
| const model = | |
| typeof rawCase.model === "string" && rawCase.model.trim().length > 0 | |
| ? rawCase.model.trim() | |
| : null; | |
| const sanitizedInput = sanitizeEvalCaseInput(rawCase.input); | |
| const sanitizedExpected = sanitizeEvalExpected(rawCase.expected); | |
| const tags = Array.isArray(rawCase.tags) | |
| ? rawCase.tags.map((tag) => tag.trim()).filter((tag) => tag.length > 0) | |
| : []; | |
| if (!caseName) { | |
| throw new Error(`Case ${index + 1} is missing a name`); | |
| } | |
| if (sanitizedInput.messages.length === 0) { | |
| throw new Error(`Case ${index + 1} must include at least one message`); | |
| } | |
| if ( | |
| (sanitizedExpected.strategy === "contains" || | |
| sanitizedExpected.strategy === "exact" || | |
| sanitizedExpected.strategy === "regex") && | |
| !sanitizedExpected.value | |
| ) { | |
| throw new Error(`Case ${index + 1} must include an expected value`); | |
| } | |
| db.prepare( | |
| `INSERT INTO eval_cases | |
| (id, suite_id, sort_order, name, model, input_json, expected_strategy, expected_value, | |
| tags_json, created_at, updated_at) | |
| VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` | |
| ).run( | |
| caseId, | |
| suiteId, | |
| index, | |
| caseName, | |
| model, | |
| JSON.stringify(sanitizedInput), | |
| sanitizedExpected.strategy, | |
| sanitizedExpected.value || null, | |
| JSON.stringify(tags), | |
| now, | |
| now | |
| ); | |
| }); | |
| db.prepare("COMMIT").run(); | |
| } catch (error) { | |
| db.prepare("ROLLBACK").run(); | |
| throw error; | |
| } | |
| const saved = getCustomEvalSuite(suiteId); | |
| if (!saved) { | |
| throw new Error("Failed to persist eval suite"); | |
| } | |
| return saved; | |
| } | |
| export function deleteCustomEvalSuite(suiteId: string): boolean { | |
| const db = getDbInstance() as unknown as DbLike; | |
| ensureEvalSuiteTables(db); | |
| const normalizedSuiteId = suiteId.trim(); | |
| if (!normalizedSuiteId) return false; | |
| db.prepare("BEGIN").run(); | |
| try { | |
| db.prepare("DELETE FROM eval_cases WHERE suite_id = ?").run(normalizedSuiteId); | |
| const result = db.prepare("DELETE FROM eval_suites WHERE id = ?").run(normalizedSuiteId); | |
| db.prepare("COMMIT").run(); | |
| return result.changes > 0; | |
| } catch (error) { | |
| db.prepare("ROLLBACK").run(); | |
| throw error; | |
| } | |
| } | |