spine-framework 0.3.108 → 0.3.109

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.
@@ -0,0 +1,353 @@
1
+ /**
2
+ * @module eval-harness
3
+ * @audience core-contributor
4
+ * @layer api-handler
5
+ * @stability stable
6
+ *
7
+ * Evaluation infrastructure for the AI support loop. Provides CRUD for
8
+ * `eval_test_case` items and a `run_eval` action that exercises the real
9
+ * retrieval + inference pipeline and scores the results.
10
+ *
11
+ * Build evals before you scale automation. A support AI without evals is a
12
+ * demo. With evals it is a product.
13
+ *
14
+ * **Routed by:** `GET/POST /.netlify/functions/eval-harness`
15
+ *
16
+ * **Actions:**
17
+ * | method | ?action | handler |
18
+ * |--------|------------------|-------------------|
19
+ * | POST | create_test_case | createTestCase |
20
+ * | GET | list_test_cases | listTestCases |
21
+ * | POST | run_eval | runEval |
22
+ * | GET | get_results | getResults |
23
+ * | GET | get_coverage | getCoverage |
24
+ *
25
+ * INVARIANT: `run_eval` fires the actual retrieval pipeline — not a mock.
26
+ * Only run against a non-production Supabase project when making schema changes.
27
+ *
28
+ * INVARIANT: Eval results are stored as `pipeline_executions` rows so they
29
+ * are auditable and time-series comparable.
30
+ *
31
+ * @seeAlso agent-runner.ts (retrieveKnowledge — what is being tested)
32
+ * @seeAlso embeddings.ts (match_embeddings — the retrieval function under test)
33
+ */
34
+
35
+ import { createHandler } from './_shared/middleware'
36
+ import { adminDb } from './_shared/db'
37
+ import { resolveTypeId } from './_shared/resolve-ids'
38
+ import { create } from './admin-data'
39
+ import { emitLog } from './_shared/audit'
40
+
41
+ // ─── TEST CATEGORIES ─────────────────────────────────────────────────────────
42
+
43
+ const TEST_TYPES = ['easy', 'medium', 'hard', 'security', 'hallucination', 'stale', 'account_gating'] as const
44
+ type TestType = typeof TEST_TYPES[number]
45
+
46
+ const CONFIDENCE_TIERS = {
47
+ high: { min: 0.85, max: 1.00 },
48
+ medium: { min: 0.60, max: 0.849 },
49
+ low: { min: 0.00, max: 0.599 },
50
+ }
51
+
52
+ // ─── CREATE TEST CASE ─────────────────────────────────────────────────────────
53
+ /**
54
+ * Creates an eval_test_case item.
55
+ *
56
+ * Body: question (required), expected_tier ('high'|'medium'|'low'),
57
+ * expected_escalate (bool), expected_source_ids (string[]),
58
+ * excluded_source_ids (string[]), test_type
59
+ */
60
+ export const createTestCase = createHandler(async (ctx, body) => {
61
+ const {
62
+ question, expected_tier = 'medium', expected_escalate = false,
63
+ expected_source_ids = [], excluded_source_ids = [],
64
+ test_type = 'medium',
65
+ } = body || {}
66
+
67
+ if (!question) throw new Error('question is required')
68
+
69
+ let typeId: string | null = null
70
+ try { typeId = await resolveTypeId('item', 'eval_test_case') } catch { /* not yet seeded */ }
71
+ if (!typeId) throw new Error('eval_test_case type not found — run app install first')
72
+
73
+ return await create(ctx, {
74
+ entity: 'items',
75
+ type_id: typeId,
76
+ title: question.slice(0, 120),
77
+ status: 'active',
78
+ data: {
79
+ question,
80
+ expected_tier,
81
+ expected_escalate,
82
+ expected_source_ids,
83
+ excluded_source_ids,
84
+ test_type,
85
+ },
86
+ })
87
+ })
88
+
89
+ // ─── LIST TEST CASES ──────────────────────────────────────────────────────────
90
+ /**
91
+ * Lists eval_test_case items. Optionally filter by test_type.
92
+ * Query params: test_type, limit (default 200), offset
93
+ */
94
+ export const listTestCases = createHandler(async (ctx, _body) => {
95
+ const { test_type, limit = '200', offset = '0' } = ctx.query || {}
96
+
97
+ const { data: typeRow } = await adminDb
98
+ .from('types').select('id').eq('slug', 'eval_test_case').eq('kind', 'item').maybeSingle()
99
+
100
+ let q = ctx.db
101
+ .from('items')
102
+ .select('*')
103
+ .eq('status', 'active')
104
+ .order('created_at', { ascending: true })
105
+ .range(Number(offset), Number(offset) + Number(limit) - 1)
106
+
107
+ if (typeRow?.id) q = q.eq('type_id', typeRow.id)
108
+ if (test_type) q = q.eq('data->>test_type', test_type)
109
+
110
+ const { data, error } = await q
111
+ if (error) throw error
112
+ return data || []
113
+ })
114
+
115
+ // ─── RUN EVAL ─────────────────────────────────────────────────────────────────
116
+ /**
117
+ * Runs the retrieval pipeline against all active eval_test_case items and
118
+ * scores the results. Stores the run summary as a pipeline_executions row.
119
+ *
120
+ * Scoring per test case:
121
+ * - retrieval_recall: expected_source_ids found in top-N results?
122
+ * - boundary_safe: excluded_source_ids NOT found in results?
123
+ * - tier_correct: mean similarity maps to expected_tier?
124
+ *
125
+ * Body: test_type (optional, filter), similarity_threshold (default 0.15),
126
+ * match_count (default 8)
127
+ *
128
+ * Returns a summary + per-case results.
129
+ *
130
+ * NOTE: Does not call the LLM — retrieval only. Full inference eval is a
131
+ * future extension.
132
+ */
133
+ export const runEval = createHandler(async (ctx, body) => {
134
+ const { test_type, similarity_threshold = 0.15, match_count = 8 } = body || {}
135
+ if (!ctx.accountId) throw new Error('Account context required')
136
+
137
+ const apiKey = process.env.OPENAI_API_KEY || process.env.LLM_API_KEY
138
+ if (!apiKey) throw new Error('No OPENAI_API_KEY — cannot run eval (needs embedding API)')
139
+
140
+ const baseUrl = process.env.OPENAI_BASE_URL || 'https://api.openai.com/v1'
141
+
142
+ // Load test cases
143
+ const { data: typeRow } = await adminDb
144
+ .from('types').select('id').eq('slug', 'eval_test_case').eq('kind', 'item').maybeSingle()
145
+
146
+ let q = adminDb.from('items').select('*').eq('status', 'active').limit(500)
147
+ if (typeRow?.id) q = q.eq('type_id', typeRow.id)
148
+ if (test_type) q = q.eq('data->>test_type', test_type)
149
+ const { data: testCases, error: tcErr } = await q
150
+ if (tcErr) throw tcErr
151
+ if (!testCases?.length) return { run_id: null, message: 'No test cases found', total: 0 }
152
+
153
+ // Resolve platform account for cross-account retrieval
154
+ const { data: platformAccount } = await adminDb
155
+ .from('accounts').select('id').eq('slug', 'spine-system').maybeSingle()
156
+ const accountIds = [ctx.accountId]
157
+ if (platformAccount?.id && platformAccount.id !== ctx.accountId) accountIds.push(platformAccount.id)
158
+
159
+ // Run retrieval for each test case
160
+ const results: any[] = []
161
+ let passed = 0; let failed = 0; let skipped = 0
162
+
163
+ for (const tc of testCases) {
164
+ const question = tc.data?.question || tc.title
165
+ const expectedSrcs = tc.data?.expected_source_ids || []
166
+ const excludedSrcs = tc.data?.excluded_source_ids || []
167
+ const expectedTier = tc.data?.expected_tier || 'medium'
168
+
169
+ try {
170
+ // Generate embedding for the question
171
+ const embedRes = await fetch(`${baseUrl}/embeddings`, {
172
+ method: 'POST',
173
+ headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` },
174
+ body: JSON.stringify({ model: 'text-embedding-3-small', input: question }),
175
+ })
176
+ if (!embedRes.ok) { skipped++; results.push({ id: tc.id, status: 'skipped', reason: 'embed_api_error' }); continue }
177
+ const embedJson: any = await embedRes.json()
178
+ const queryEmbedding: number[] = embedJson.data?.[0]?.embedding || []
179
+
180
+ // Run retrieval
181
+ const { data: matches } = await adminDb.rpc('match_embeddings', {
182
+ query_embedding: `[${queryEmbedding.join(',')}]`,
183
+ match_count: match_count * 2,
184
+ filter_account_ids: accountIds,
185
+ filter_vector_type: 'semantic',
186
+ similarity_threshold,
187
+ })
188
+
189
+ const retrievedIds = new Set((matches || []).map((m: any) => m.document_id))
190
+ const meanSim = matches?.length
191
+ ? (matches as any[]).reduce((s: number, m: any) => s + m.similarity, 0) / matches.length
192
+ : 0
193
+
194
+ // Score
195
+ const recallHits = expectedSrcs.filter((id: string) => retrievedIds.has(id)).length
196
+ const retrievalRecall = expectedSrcs.length > 0 ? recallHits / expectedSrcs.length : null
197
+
198
+ const boundaryViolations = excludedSrcs.filter((id: string) => retrievedIds.has(id))
199
+ const boundarySafe = boundaryViolations.length === 0
200
+
201
+ // Tier based on mean similarity (proxy — full LLM confidence would be better)
202
+ let inferredTier: string
203
+ if (meanSim >= CONFIDENCE_TIERS.high.min) inferredTier = 'high'
204
+ else if (meanSim >= CONFIDENCE_TIERS.medium.min) inferredTier = 'medium'
205
+ else inferredTier = 'low'
206
+ const tierCorrect = inferredTier === expectedTier
207
+
208
+ const casePassed = boundarySafe &&
209
+ (retrievalRecall === null || retrievalRecall >= 0.5) &&
210
+ tierCorrect
211
+
212
+ if (casePassed) passed++; else failed++
213
+
214
+ results.push({
215
+ id: tc.id,
216
+ question: question.slice(0, 80),
217
+ test_type: tc.data?.test_type,
218
+ status: casePassed ? 'pass' : 'fail',
219
+ retrieval_recall: retrievalRecall,
220
+ boundary_safe: boundarySafe,
221
+ boundary_violations: boundaryViolations,
222
+ tier_correct: tierCorrect,
223
+ inferred_tier: inferredTier,
224
+ expected_tier: expectedTier,
225
+ mean_similarity: Math.round(meanSim * 1000) / 1000,
226
+ retrieved_count: retrievedIds.size,
227
+ })
228
+ } catch (err: any) {
229
+ skipped++
230
+ results.push({ id: tc.id, status: 'skipped', reason: err.message })
231
+ }
232
+ }
233
+
234
+ const summary = {
235
+ total: testCases.length,
236
+ passed,
237
+ failed,
238
+ skipped,
239
+ pass_rate: testCases.length > 0 ? Math.round((passed / testCases.length) * 100) : 0,
240
+ run_at: new Date().toISOString(),
241
+ account_id: ctx.accountId,
242
+ }
243
+
244
+ // Store as pipeline_executions row for audit trail
245
+ const { data: execRow } = await adminDb
246
+ .from('pipeline_executions')
247
+ .insert({
248
+ pipeline_id: '00000000-0000-0000-0000-000000000000', // sentinel UUID for eval runs
249
+ account_id: ctx.accountId,
250
+ status: failed === 0 ? 'completed' : 'failed',
251
+ trigger_data: { eval_run: true, test_type: test_type || 'all' },
252
+ result: { summary, results },
253
+ started_at: summary.run_at,
254
+ completed_at: new Date().toISOString(),
255
+ duration_ms: 0,
256
+ })
257
+ .select('id')
258
+ .single()
259
+
260
+ await emitLog(ctx, 'eval.run_completed',
261
+ { type: 'system', id: execRow?.id || 'eval' },
262
+ { after: summary }
263
+ )
264
+
265
+ return { run_id: execRow?.id || null, summary, results }
266
+ })
267
+
268
+ // ─── GET RESULTS ─────────────────────────────────────────────────────────────
269
+ /**
270
+ * Returns the most recent eval run summary for this account.
271
+ * Query params: limit (default 5) — number of recent runs to return
272
+ */
273
+ export const getResults = createHandler(async (ctx, _body) => {
274
+ if (!ctx.accountId) throw new Error('Account context required')
275
+
276
+ const limit = Number(ctx.query?.limit || 5)
277
+
278
+ const { data, error } = await adminDb
279
+ .from('pipeline_executions')
280
+ .select('id, status, result, started_at, completed_at, trigger_data')
281
+ .eq('account_id', ctx.accountId)
282
+ .eq('trigger_data->>eval_run', 'true')
283
+ .order('started_at', { ascending: false })
284
+ .limit(limit)
285
+
286
+ if (error) throw error
287
+ return data || []
288
+ })
289
+
290
+ // ─── GET COVERAGE ────────────────────────────────────────────────────────────
291
+ /**
292
+ * Returns coverage summary: which test categories have cases, which are empty,
293
+ * and the most recent pass rate per category if eval runs exist.
294
+ */
295
+ export const getCoverage = createHandler(async (ctx, _body) => {
296
+ const { data: typeRow } = await adminDb
297
+ .from('types').select('id').eq('slug', 'eval_test_case').eq('kind', 'item').maybeSingle()
298
+
299
+ const { data: testCases } = await adminDb
300
+ .from('items')
301
+ .select('data')
302
+ .eq('status', 'active')
303
+ .eq('type_id', typeRow?.id || '00000000-0000-0000-0000-000000000000')
304
+ .limit(1000)
305
+
306
+ const countByType: Record<string, number> = {}
307
+ for (const tt of TEST_TYPES) countByType[tt] = 0
308
+ for (const tc of testCases || []) {
309
+ const t = tc.data?.test_type || 'medium'
310
+ countByType[t] = (countByType[t] || 0) + 1
311
+ }
312
+
313
+ const coverage = TEST_TYPES.map(tt => ({
314
+ test_type: tt,
315
+ case_count: countByType[tt] || 0,
316
+ has_cases: (countByType[tt] || 0) > 0,
317
+ }))
318
+
319
+ return {
320
+ total_cases: testCases?.length || 0,
321
+ coverage,
322
+ recommendation: coverage.filter(c => !c.has_cases).map(c => c.test_type),
323
+ }
324
+ })
325
+
326
+ // ─── HANDLER ─────────────────────────────────────────────────────────────────
327
+
328
+ export const handler = createHandler(async (ctx, body) => {
329
+ const { action } = ctx.query || {}
330
+ const method = ctx.query?.method || 'GET'
331
+
332
+ switch (action) {
333
+ case 'create_test_case':
334
+ if (method === 'POST') return await createTestCase(ctx, body)
335
+ break
336
+ case 'list_test_cases':
337
+ if (method === 'GET') return await listTestCases(ctx, body)
338
+ break
339
+ case 'run_eval':
340
+ if (method === 'POST') return await runEval(ctx, body)
341
+ break
342
+ case 'get_results':
343
+ if (method === 'GET') return await getResults(ctx, body)
344
+ break
345
+ case 'get_coverage':
346
+ if (method === 'GET') return await getCoverage(ctx, body)
347
+ break
348
+ default:
349
+ if (method === 'GET') return await listTestCases(ctx, body)
350
+ }
351
+
352
+ throw new Error('Invalid action or method')
353
+ })
@@ -18,6 +18,7 @@
18
18
  * | latency_percentiles | getLatencyPercentiles |
19
19
  * | pipeline_stats | getPipelineStats |
20
20
  * | top_actors | getTopActors |
21
+ * | ai_support_metrics | getAiSupportMetrics |
21
22
  * | cleanup | cleanupOldLogs (POST) |
22
23
  *
23
24
  * **Authorization:** All read endpoints require account context (RLS-scoped
@@ -32,6 +33,7 @@
32
33
  */
33
34
 
34
35
  import { createHandler } from './_shared/middleware'
36
+ import { adminDb } from './_shared/db'
35
37
 
36
38
  // ─── HANDLERS ─────────────────────────────────────────────────────────────────
37
39
 
@@ -245,6 +247,147 @@ export const cleanupOldLogs = createHandler(async (ctx, body) => {
245
247
  return { deleted_count: data?.[0]?.deleted_count || 0 }
246
248
  })
247
249
 
250
+ // ─── AI SUPPORT METRICS ──────────────────────────────────────────────────────
251
+
252
+ /**
253
+ * Returns the AI support loop health metrics.
254
+ * All values are derived from existing tables — no new table required.
255
+ *
256
+ * Metrics:
257
+ * - ai_attempted: AI agent messages sent
258
+ * - ai_resolved: Tickets resolved without escalation
259
+ * - human_escalations: Tickets assigned to humans
260
+ * - thumbs_down_rate: Ratio of not_helpful / total AI messages (0–1)
261
+ * - medium_confidence: AI messages with confidence 0.60–0.85
262
+ * - kb_articles_created: Postmortem KB articles
263
+ * - kb_reuse_count: Total reuse_count across ai_feedback_record items
264
+ * - knowledge_gaps: Interactions with kb_candidate=false + not_helpful feedback
265
+ *
266
+ * Query params: from, to (ISO dates, optional — defaults to last 30 days)
267
+ *
268
+ * @sideEffects DB reads: items, messages tables
269
+ * @calledBy handler (?action=ai_support_metrics)
270
+ */
271
+ export const getAiSupportMetrics = createHandler(async (ctx, _body) => {
272
+ if (!ctx.accountId) throw new Error('Account context required')
273
+
274
+ const now = new Date()
275
+ const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000).toISOString()
276
+ const from = ctx.query?.from || thirtyDaysAgo
277
+ const to = ctx.query?.to || now.toISOString()
278
+
279
+ // Run all queries in parallel for performance
280
+ const [
281
+ aiMessagesResult,
282
+ resolvedResult,
283
+ escalatedResult,
284
+ notHelpfulResult,
285
+ mediumConfResult,
286
+ kbCreatedResult,
287
+ feedbackRecordsResult,
288
+ ] = await Promise.allSettled([
289
+ // AI agent messages
290
+ adminDb
291
+ .from('messages')
292
+ .select('id, data', { count: 'exact', head: false })
293
+ .eq('account_id', ctx.accountId)
294
+ .eq('data->>sender_type', 'agent')
295
+ .gte('created_at', from)
296
+ .lte('created_at', to),
297
+
298
+ // Tickets resolved without AI escalation
299
+ adminDb
300
+ .from('items')
301
+ .select('id', { count: 'exact', head: true })
302
+ .eq('account_id', ctx.accountId)
303
+ .eq('status', 'resolved')
304
+ .eq('data->>aim_escalation_reason', 'none')
305
+ .gte('created_at', from)
306
+ .lte('created_at', to),
307
+
308
+ // Tickets escalated to humans
309
+ adminDb
310
+ .from('items')
311
+ .select('id', { count: 'exact', head: true })
312
+ .eq('account_id', ctx.accountId)
313
+ .eq('status', 'human_assigned')
314
+ .gte('created_at', from)
315
+ .lte('created_at', to),
316
+
317
+ // Not-helpful AI messages
318
+ adminDb
319
+ .from('messages')
320
+ .select('id', { count: 'exact', head: true })
321
+ .eq('account_id', ctx.accountId)
322
+ .eq('data->>sender_type', 'agent')
323
+ .eq('data->>feedback', 'not_helpful')
324
+ .gte('created_at', from)
325
+ .lte('created_at', to),
326
+
327
+ // Medium-confidence AI messages
328
+ adminDb
329
+ .from('messages')
330
+ .select('id', { count: 'exact', head: true })
331
+ .eq('account_id', ctx.accountId)
332
+ .eq('data->>sender_type', 'agent')
333
+ .gte('data->>confidence', '0.60')
334
+ .lte('data->>confidence', '0.85')
335
+ .gte('created_at', from)
336
+ .lte('created_at', to),
337
+
338
+ // Postmortem KB articles created
339
+ adminDb
340
+ .from('items')
341
+ .select('id', { count: 'exact', head: true })
342
+ .eq('account_id', ctx.accountId)
343
+ .eq('data->>kb_type', 'postmortem')
344
+ .gte('created_at', from)
345
+ .lte('created_at', to),
346
+
347
+ // AI feedback records for reuse + gaps
348
+ adminDb
349
+ .from('items')
350
+ .select('data')
351
+ .eq('account_id', ctx.accountId)
352
+ .gte('created_at', from)
353
+ .lte('created_at', to)
354
+ .limit(500),
355
+ ])
356
+
357
+ // Extract values safely
358
+ const aiMessages = aiMessagesResult.status === 'fulfilled' ? (aiMessagesResult.value.data || []) : []
359
+ const aiAttempted = aiMessages.length
360
+
361
+ const aiResolved = resolvedResult.status === 'fulfilled' ? (resolvedResult.value.count || 0) : 0
362
+ const escalated = escalatedResult.status === 'fulfilled' ? (escalatedResult.value.count || 0) : 0
363
+ const notHelpful = notHelpfulResult.status === 'fulfilled' ? (notHelpfulResult.value.count || 0) : 0
364
+ const mediumConf = mediumConfResult.status === 'fulfilled' ? (mediumConfResult.value.count || 0) : 0
365
+ const kbCreated = kbCreatedResult.status === 'fulfilled' ? (kbCreatedResult.value.count || 0) : 0
366
+
367
+ const feedbackRows = feedbackRecordsResult.status === 'fulfilled'
368
+ ? (feedbackRecordsResult.value.data || [])
369
+ : []
370
+
371
+ const kbReuseCount = feedbackRows.reduce((sum: number, r: any) => sum + (Number(r.data?.reuse_count) || 0), 0)
372
+ const knowledgeGaps = feedbackRows.filter((r: any) =>
373
+ r.data?.kb_candidate === false && r.data?.customer_feedback === 'not_helpful'
374
+ ).length
375
+
376
+ const thumbsDownRate = aiAttempted > 0 ? notHelpful / aiAttempted : 0
377
+
378
+ return {
379
+ period: { from, to },
380
+ ai_attempted: aiAttempted,
381
+ ai_resolved: aiResolved,
382
+ human_escalations: escalated,
383
+ thumbs_down_rate: Math.round(thumbsDownRate * 1000) / 1000,
384
+ medium_confidence: mediumConf,
385
+ kb_articles_created: kbCreated,
386
+ kb_reuse_count: kbReuseCount,
387
+ knowledge_gaps: knowledgeGaps,
388
+ }
389
+ })
390
+
248
391
  // ─── MAIN HANDLER ────────────────────────────────────────────────────────────
249
392
 
250
393
  /**
@@ -267,9 +410,11 @@ export const handler = createHandler(async (ctx, body) => {
267
410
  return await getPipelineStats(ctx, body)
268
411
  case 'top_actors':
269
412
  return await getTopActors(ctx, body)
413
+ case 'ai_support_metrics':
414
+ return await getAiSupportMetrics(ctx, body)
270
415
  case 'cleanup':
271
416
  return await cleanupOldLogs(ctx, body)
272
417
  default:
273
- throw new Error(`Unknown action: ${action}. Valid actions: event_volume, error_rate, latency_percentiles, pipeline_stats, top_actors, cleanup`)
418
+ throw new Error(`Unknown action: ${action}. Valid actions: event_volume, error_rate, latency_percentiles, pipeline_stats, top_actors, ai_support_metrics, cleanup`)
274
419
  }
275
420
  })