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.
- package/.framework/functions/_shared/agent-runner.ts +139 -70
- package/.framework/functions/_shared/pipeline-runner.ts +31 -1
- package/.framework/functions/ai-interactions.ts +240 -0
- package/.framework/functions/embeddings.ts +426 -10
- package/.framework/functions/eval-harness.ts +353 -0
- package/.framework/functions/observability.ts +146 -1
- package/.framework/migrations/027_embedding_lifecycle.sql +176 -0
- package/package.json +2 -1
|
@@ -184,16 +184,26 @@ export async function runAgent(
|
|
|
184
184
|
)
|
|
185
185
|
}
|
|
186
186
|
|
|
187
|
-
// 5. Save agent response
|
|
187
|
+
// 5. Save agent response — include retrieved_sources + retrieval_confidence
|
|
188
|
+
// so the feedback capture pipeline and the Portal UI can access them.
|
|
188
189
|
const agentMsg = await saveMessage(
|
|
189
190
|
threadId,
|
|
190
191
|
agentResponse.content,
|
|
191
192
|
'agent',
|
|
192
193
|
{
|
|
193
194
|
confidence: agentResponse.confidence,
|
|
195
|
+
retrieval_confidence: agentResponse.retrievalConfidence,
|
|
196
|
+
retrieved_sources: (agentResponse.retrievedSources || []).map((s: any) => ({
|
|
197
|
+
document_id: s.document_id,
|
|
198
|
+
title: s.title,
|
|
199
|
+
similarity: s.similarity,
|
|
200
|
+
item_type: s.item_type,
|
|
201
|
+
vector_type: s.vector_type,
|
|
202
|
+
})),
|
|
194
203
|
tool_calls: agentResponse.tool_calls,
|
|
195
204
|
agent_id: agent.id,
|
|
196
|
-
prompt_config_id: promptConfig.id
|
|
205
|
+
prompt_config_id: promptConfig.id,
|
|
206
|
+
sender_type: 'agent',
|
|
197
207
|
},
|
|
198
208
|
ctx,
|
|
199
209
|
replyTypeSlug
|
|
@@ -208,6 +218,8 @@ export async function runAgent(
|
|
|
208
218
|
thread_id: threadId,
|
|
209
219
|
agent_id: agent.id,
|
|
210
220
|
confidence: agentResponse.confidence,
|
|
221
|
+
retrieval_confidence: agentResponse.retrievalConfidence,
|
|
222
|
+
sources_count: agentResponse.retrievedSources?.length || 0,
|
|
211
223
|
has_tool_calls: !!agentResponse.tool_calls?.length,
|
|
212
224
|
duration_ms: Date.now() - startTime
|
|
213
225
|
})
|
|
@@ -347,36 +359,49 @@ async function executeAgentInference(
|
|
|
347
359
|
userMessage: string,
|
|
348
360
|
ctx: CoreContext,
|
|
349
361
|
maxToolIterations: number = 5
|
|
350
|
-
): Promise<InferenceResult> {
|
|
351
|
-
const { agent, promptConfig
|
|
362
|
+
): Promise<InferenceResult & { retrievedSources: any[]; retrievalConfidence: number }> {
|
|
363
|
+
const { agent, promptConfig } = config
|
|
352
364
|
|
|
353
|
-
// Build initial context
|
|
354
|
-
let context = await buildContext(config, userMessage, [], ctx)
|
|
365
|
+
// Build initial context — captures retrieved sources for feedback capture
|
|
366
|
+
let { context, retrievedSources, meanSimilarity } = await buildContext(config, userMessage, [], ctx)
|
|
355
367
|
|
|
356
368
|
for (let iteration = 0; iteration < maxToolIterations; iteration++) {
|
|
357
|
-
// Call inference
|
|
358
369
|
const inferenceResult = await callInference(context, agent, promptConfig, ctx)
|
|
359
370
|
|
|
360
|
-
// If no tool calls, return immediately
|
|
361
371
|
if (!inferenceResult.tool_calls || inferenceResult.tool_calls.length === 0) {
|
|
362
|
-
|
|
372
|
+
// Composite confidence: 60% LLM logprobs + 40% retrieval similarity
|
|
373
|
+
// If mean retrieval similarity < 0.40, cap composite at 0.60
|
|
374
|
+
const llmConf = inferenceResult.confidence ?? 0.85
|
|
375
|
+
let compositeConf: number
|
|
376
|
+
if (meanSimilarity < 0.40 && meanSimilarity > 0) {
|
|
377
|
+
compositeConf = Math.min(llmConf * 0.6 + meanSimilarity * 0.4, 0.60)
|
|
378
|
+
} else {
|
|
379
|
+
compositeConf = llmConf * 0.6 + Math.min(meanSimilarity, 1) * 0.4
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
return {
|
|
383
|
+
...inferenceResult,
|
|
384
|
+
confidence: Math.min(Math.max(compositeConf, 0), 1),
|
|
385
|
+
retrievedSources,
|
|
386
|
+
retrievalConfidence: meanSimilarity,
|
|
387
|
+
}
|
|
363
388
|
}
|
|
364
389
|
|
|
365
|
-
// Execute tools
|
|
366
390
|
const toolResults = await dispatchTools(inferenceResult.tool_calls, ctx)
|
|
391
|
+
const rebuilt = await buildContext(config, userMessage, toolResults, ctx)
|
|
392
|
+
context = rebuilt.context
|
|
393
|
+
if (rebuilt.retrievedSources.length) {
|
|
394
|
+
retrievedSources = rebuilt.retrievedSources
|
|
395
|
+
meanSimilarity = rebuilt.meanSimilarity
|
|
396
|
+
}
|
|
367
397
|
|
|
368
|
-
// Add tool results to context and re-inference
|
|
369
|
-
context = await buildContext(config, userMessage, toolResults, ctx)
|
|
370
|
-
|
|
371
|
-
// Check if any tool failed - if so, return with error info
|
|
372
398
|
const hasErrors = toolResults.some(r => r.error)
|
|
373
399
|
if (hasErrors && iteration === maxToolIterations - 1) {
|
|
374
400
|
inferenceResult.content += '\n\n[Note: Some tools failed to execute.]'
|
|
375
|
-
return inferenceResult
|
|
401
|
+
return { ...inferenceResult, retrievedSources, retrievalConfidence: meanSimilarity }
|
|
376
402
|
}
|
|
377
403
|
}
|
|
378
404
|
|
|
379
|
-
// Max iterations reached
|
|
380
405
|
throw new Error('Max tool iterations reached')
|
|
381
406
|
}
|
|
382
407
|
|
|
@@ -407,7 +432,7 @@ async function buildContext(
|
|
|
407
432
|
userMessage: string,
|
|
408
433
|
toolResults: ToolResult[],
|
|
409
434
|
ctx: CoreContext
|
|
410
|
-
): Promise<string> {
|
|
435
|
+
): Promise<{ context: string; retrievedSources: any[]; meanSimilarity: number }> {
|
|
411
436
|
const { agent, promptConfig, thread } = config
|
|
412
437
|
|
|
413
438
|
// 1. System prompt (from agent)
|
|
@@ -419,19 +444,30 @@ async function buildContext(
|
|
|
419
444
|
}
|
|
420
445
|
|
|
421
446
|
// 3. Retrieved knowledge (RAG)
|
|
422
|
-
|
|
423
|
-
|
|
447
|
+
// Always attempt retrieval — even with empty knowledge_sources (full scope search)
|
|
448
|
+
let retrievedSources: any[] = []
|
|
449
|
+
let meanSimilarity = 0
|
|
450
|
+
const retrievalPolicy = promptConfig.metadata?.retrieval_policy || {}
|
|
451
|
+
|
|
452
|
+
try {
|
|
453
|
+
const { docs, meanSimilarity: ms } = await retrieveKnowledge(
|
|
424
454
|
userMessage,
|
|
425
|
-
promptConfig.knowledge_sources,
|
|
426
|
-
ctx.accountId
|
|
455
|
+
promptConfig.knowledge_sources || [],
|
|
456
|
+
ctx.accountId!,
|
|
457
|
+
retrievalPolicy
|
|
427
458
|
)
|
|
428
|
-
|
|
459
|
+
retrievedSources = docs
|
|
460
|
+
meanSimilarity = ms
|
|
461
|
+
|
|
462
|
+
if (docs.length > 0) {
|
|
429
463
|
context += '## Relevant Information\n'
|
|
430
|
-
|
|
431
|
-
|
|
464
|
+
docs.forEach((doc, i) => {
|
|
465
|
+
const sourceLabel = doc.title ? `[${i + 1}] ${doc.title}` : `[${i + 1}]`
|
|
466
|
+
context += `${sourceLabel}\n${doc.content}\n\n`
|
|
432
467
|
})
|
|
433
|
-
context += '\n'
|
|
434
468
|
}
|
|
469
|
+
} catch (err) {
|
|
470
|
+
console.warn('retrieveKnowledge failed — proceeding without context:', err)
|
|
435
471
|
}
|
|
436
472
|
|
|
437
473
|
// 4. Conversation history
|
|
@@ -470,7 +506,7 @@ async function buildContext(
|
|
|
470
506
|
// 7. Current user message
|
|
471
507
|
context += `## Current Message\nUser: ${userMessage}\n\nAssistant: `
|
|
472
508
|
|
|
473
|
-
return context
|
|
509
|
+
return { context, retrievedSources, meanSimilarity }
|
|
474
510
|
}
|
|
475
511
|
|
|
476
512
|
// ─── KNOWLEDGE & HISTORY ───────────────────────────────────────────────────────────
|
|
@@ -505,29 +541,35 @@ async function buildContext(
|
|
|
505
541
|
async function retrieveKnowledge(
|
|
506
542
|
query: string,
|
|
507
543
|
knowledgeSources: string[],
|
|
508
|
-
accountId: string
|
|
509
|
-
|
|
544
|
+
accountId: string,
|
|
545
|
+
retrievalPolicy?: {
|
|
546
|
+
item_types?: string[]
|
|
547
|
+
vector_types?: string[]
|
|
548
|
+
max_security_level?: 'external' | 'internal' | 'restricted'
|
|
549
|
+
require_approved?: boolean
|
|
550
|
+
}
|
|
551
|
+
): Promise<{ docs: any[]; meanSimilarity: number }> {
|
|
510
552
|
const SIMILARITY_THRESHOLD = 0.15
|
|
511
553
|
const MATCH_COUNT = 8
|
|
554
|
+
const policy = retrievalPolicy || {}
|
|
555
|
+
const vectorTypesToSearch = policy.vector_types?.length ? policy.vector_types : ['semantic']
|
|
556
|
+
const maxSecurityLevel = policy.max_security_level || 'internal'
|
|
557
|
+
|
|
558
|
+
// Security level ordering: external < internal < restricted
|
|
559
|
+
const SECURITY_ORDER: Record<string, number> = { external: 0, internal: 1, restricted: 2 }
|
|
560
|
+
const maxSecurityRank = SECURITY_ORDER[maxSecurityLevel] ?? 1
|
|
512
561
|
|
|
513
562
|
// ── Tier 1: vector similarity via match_embeddings RPC ──────────────────
|
|
514
563
|
try {
|
|
515
564
|
const apiKey = process.env.OPENAI_API_KEY || process.env.LLM_API_KEY
|
|
516
565
|
const baseUrl = process.env.OPENAI_BASE_URL || 'https://api.openai.com/v1'
|
|
517
|
-
|
|
518
566
|
if (!apiKey) throw new Error('no_api_key')
|
|
519
567
|
|
|
520
|
-
// Resolve platform account
|
|
568
|
+
// Resolve platform account — always include spine-system KB
|
|
521
569
|
const { data: platformAccount } = await adminDb
|
|
522
|
-
.from('accounts')
|
|
523
|
-
.select('id')
|
|
524
|
-
.eq('slug', 'spine-system')
|
|
525
|
-
.maybeSingle()
|
|
526
|
-
|
|
570
|
+
.from('accounts').select('id').eq('slug', 'spine-system').maybeSingle()
|
|
527
571
|
const accountIds: string[] = [accountId]
|
|
528
|
-
if (platformAccount?.id && platformAccount.id !== accountId)
|
|
529
|
-
accountIds.push(platformAccount.id)
|
|
530
|
-
}
|
|
572
|
+
if (platformAccount?.id && platformAccount.id !== accountId) accountIds.push(platformAccount.id)
|
|
531
573
|
|
|
532
574
|
// Generate query embedding
|
|
533
575
|
const embedRes = await fetch(`${baseUrl}/embeddings`, {
|
|
@@ -540,29 +582,37 @@ async function retrieveKnowledge(
|
|
|
540
582
|
const queryEmbedding: number[] = embedJson.data?.[0]?.embedding || []
|
|
541
583
|
if (!queryEmbedding.length) throw new Error('empty_embedding')
|
|
542
584
|
|
|
543
|
-
//
|
|
544
|
-
const
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
585
|
+
// Multi-vector search: run all declared vector types and merge
|
|
586
|
+
const allMatches: any[] = []
|
|
587
|
+
for (const vt of vectorTypesToSearch) {
|
|
588
|
+
const { data: matches, error: rpcError } = await adminDb.rpc('match_embeddings', {
|
|
589
|
+
query_embedding: `[${queryEmbedding.join(',')}]`,
|
|
590
|
+
match_count: MATCH_COUNT * 3,
|
|
591
|
+
filter_account_ids: accountIds,
|
|
592
|
+
filter_vector_type: vt,
|
|
593
|
+
similarity_threshold: SIMILARITY_THRESHOLD,
|
|
594
|
+
filter_item_types: policy.item_types?.length ? policy.item_types : null,
|
|
595
|
+
})
|
|
596
|
+
if (rpcError) throw new Error(`rpc_${rpcError.message}`)
|
|
597
|
+
allMatches.push(...(matches || []))
|
|
598
|
+
}
|
|
552
599
|
|
|
553
|
-
//
|
|
554
|
-
const filtered =
|
|
600
|
+
// Hard security filter
|
|
601
|
+
const filtered = allMatches.filter((r: any) => {
|
|
555
602
|
if (r.similarity < SIMILARITY_THRESHOLD) return false
|
|
556
|
-
|
|
557
|
-
|
|
603
|
+
const secLevel = r.metadata?.security_level || 'internal'
|
|
604
|
+
const rank = SECURITY_ORDER[secLevel] ?? 1
|
|
605
|
+
if (rank > maxSecurityRank) return false
|
|
606
|
+
if (r.account_id !== accountId && secLevel === 'restricted') return false
|
|
607
|
+
return true
|
|
558
608
|
})
|
|
559
609
|
|
|
560
|
-
//
|
|
610
|
+
// Narrow to knowledgeSources if specified
|
|
561
611
|
const scoped = knowledgeSources.length > 0
|
|
562
612
|
? filtered.filter((r: any) => knowledgeSources.includes(r.document_id))
|
|
563
613
|
: filtered
|
|
564
614
|
|
|
565
|
-
// Deduplicate — best chunk per document
|
|
615
|
+
// Deduplicate — best chunk per document across all vector types
|
|
566
616
|
const bestByDoc = new Map<string, any>()
|
|
567
617
|
for (const r of scoped) {
|
|
568
618
|
const existing = bestByDoc.get(r.document_id)
|
|
@@ -572,47 +622,66 @@ async function retrieveKnowledge(
|
|
|
572
622
|
.sort((a, b) => b.similarity - a.similarity)
|
|
573
623
|
.slice(0, MATCH_COUNT)
|
|
574
624
|
|
|
575
|
-
if (deduped.length
|
|
625
|
+
if (!deduped.length) return { docs: [], meanSimilarity: 0 }
|
|
626
|
+
|
|
627
|
+
// If top result < 0.50 and summary not already included, try summary fallback
|
|
628
|
+
if (deduped[0]?.similarity < 0.50 && !vectorTypesToSearch.includes('summary')) {
|
|
629
|
+
const { data: summaryMatches } = await adminDb.rpc('match_embeddings', {
|
|
630
|
+
query_embedding: `[${queryEmbedding.join(',')}]`,
|
|
631
|
+
match_count: MATCH_COUNT,
|
|
632
|
+
filter_account_ids: accountIds,
|
|
633
|
+
filter_vector_type: 'summary',
|
|
634
|
+
similarity_threshold: SIMILARITY_THRESHOLD,
|
|
635
|
+
filter_item_types: policy.item_types?.length ? policy.item_types : null,
|
|
636
|
+
})
|
|
637
|
+
for (const r of summaryMatches || []) {
|
|
638
|
+
if (!bestByDoc.has(r.document_id) && r.similarity >= SIMILARITY_THRESHOLD) {
|
|
639
|
+
bestByDoc.set(r.document_id, r)
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
const finalDocs = [...bestByDoc.values()]
|
|
644
|
+
.sort((a, b) => b.similarity - a.similarity)
|
|
645
|
+
.slice(0, MATCH_COUNT)
|
|
576
646
|
|
|
577
647
|
// Enrich with item titles
|
|
578
648
|
const { data: items } = await adminDb
|
|
579
|
-
.from('items')
|
|
580
|
-
.select('id, title, description')
|
|
581
|
-
.in('id', deduped.map((r: any) => r.document_id))
|
|
582
|
-
|
|
649
|
+
.from('items').select('id, title').in('id', finalDocs.map((r: any) => r.document_id))
|
|
583
650
|
const itemMap = new Map((items || []).map((i: any) => [i.id, i]))
|
|
584
|
-
|
|
651
|
+
|
|
652
|
+
const docs = finalDocs.map((r: any) => ({
|
|
585
653
|
document_id: r.document_id,
|
|
586
654
|
content: r.content,
|
|
587
655
|
metadata: r.metadata,
|
|
588
656
|
similarity: r.similarity,
|
|
589
657
|
title: itemMap.get(r.document_id)?.title || '',
|
|
658
|
+
item_type: r.metadata?.item_type || '',
|
|
659
|
+
vector_type: r.metadata?.vector_type || '',
|
|
590
660
|
}))
|
|
591
661
|
|
|
662
|
+
const meanSimilarity = docs.length
|
|
663
|
+
? docs.reduce((sum: number, d: any) => sum + d.similarity, 0) / docs.length
|
|
664
|
+
: 0
|
|
665
|
+
|
|
666
|
+
return { docs, meanSimilarity }
|
|
667
|
+
|
|
592
668
|
} catch (vectorErr: any) {
|
|
593
669
|
// ── Tier 2: full-text fallback ──────────────────────────────────────
|
|
594
670
|
if (vectorErr.message !== 'no_api_key') {
|
|
595
671
|
console.warn('retrieveKnowledge vector search failed, falling back to text search:', vectorErr.message)
|
|
596
672
|
}
|
|
597
|
-
|
|
598
673
|
const baseQuery = adminDb
|
|
599
674
|
.from('embeddings')
|
|
600
675
|
.select('document_id, content, metadata, account_id')
|
|
601
676
|
.eq('account_id', accountId)
|
|
602
677
|
.eq('metadata->>vector_type', 'semantic')
|
|
678
|
+
.not('revoked_at', 'is', null)
|
|
603
679
|
.textSearch('content', query, { type: 'websearch', config: 'english' })
|
|
604
680
|
.limit(5)
|
|
605
|
-
|
|
606
|
-
const q = knowledgeSources.length > 0
|
|
607
|
-
? baseQuery.in('document_id', knowledgeSources)
|
|
608
|
-
: baseQuery
|
|
609
|
-
|
|
681
|
+
const q = knowledgeSources.length > 0 ? baseQuery.in('document_id', knowledgeSources) : baseQuery
|
|
610
682
|
const { data: docs, error } = await q
|
|
611
|
-
if (error) {
|
|
612
|
-
|
|
613
|
-
return []
|
|
614
|
-
}
|
|
615
|
-
return docs || []
|
|
683
|
+
if (error) { console.error('Knowledge retrieval fallback failed:', error); return { docs: [], meanSimilarity: 0 } }
|
|
684
|
+
return { docs: docs || [], meanSimilarity: 0 }
|
|
616
685
|
}
|
|
617
686
|
}
|
|
618
687
|
|
|
@@ -626,7 +626,37 @@ const stageHandlers: Record<string, Function> = {
|
|
|
626
626
|
metadata: { mock: true },
|
|
627
627
|
model: model || 'gpt-4o'
|
|
628
628
|
}
|
|
629
|
-
}
|
|
629
|
+
},
|
|
630
|
+
|
|
631
|
+
// ── generate_embeddings ─────────────────────────────────────────────────────
|
|
632
|
+
// Generates embeddings for an item using the core embeddings.generate action.
|
|
633
|
+
// Supports {{trigger.entity_id}} template interpolation in config.item_id.
|
|
634
|
+
//
|
|
635
|
+
// Config: item_id (required — supports {{trigger.entity_id}}),
|
|
636
|
+
// force_regenerate (bool, default false)
|
|
637
|
+
//
|
|
638
|
+
// Called by the auto_embed_on_publish and auto_revoke_on_unpublish pipelines.
|
|
639
|
+
'generate_embeddings': async (ctx: CoreContext, config: any) => {
|
|
640
|
+
const triggerData = config._triggerData || {}
|
|
641
|
+
let itemId = config.item_id
|
|
642
|
+
if (!itemId) throw new Error('item_id is required for generate_embeddings')
|
|
643
|
+
|
|
644
|
+
// Template interpolation: {{trigger.entity_id}} or {{entity_id}}
|
|
645
|
+
if (typeof itemId === 'string' && itemId.includes('{{')) {
|
|
646
|
+
itemId = itemId
|
|
647
|
+
.replace(/\{\{trigger\.entity_id\}\}/g, triggerData?.entity_id || '')
|
|
648
|
+
.replace(/\{\{entity_id\}\}/g, triggerData?.entity_id || '')
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
if (!itemId) throw new Error('generate_embeddings: could not resolve item_id from trigger data')
|
|
652
|
+
|
|
653
|
+
const { generate } = await import('../embeddings')
|
|
654
|
+
const genCtx = { ...ctx, query: { ...(ctx.query || {}), action: 'generate' } }
|
|
655
|
+
return await generate(genCtx, {
|
|
656
|
+
item_id: itemId,
|
|
657
|
+
force_regenerate: config.force_regenerate ?? false,
|
|
658
|
+
})
|
|
659
|
+
},
|
|
630
660
|
}
|
|
631
661
|
|
|
632
662
|
// ─── MODULE DISPATCHERS ────────────────────────────────────────────────────────────
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module ai-interactions
|
|
3
|
+
* @audience both
|
|
4
|
+
* @layer api-handler
|
|
5
|
+
* @stability stable
|
|
6
|
+
*
|
|
7
|
+
* Feedback capture and training data API for the AI-first support loop.
|
|
8
|
+
* Every AI↔human exchange produces a structured interaction record so the
|
|
9
|
+
* platform can measure loop health, drive KB creation, and accumulate a
|
|
10
|
+
* proprietary training dataset.
|
|
11
|
+
*
|
|
12
|
+
* **Routed by:** `GET/POST/PATCH /.netlify/functions/ai-interactions`
|
|
13
|
+
*
|
|
14
|
+
* **Actions:**
|
|
15
|
+
* | method | ?action | handler |
|
|
16
|
+
* |--------|--------------------------|--------------------------|
|
|
17
|
+
* | POST | submit_feedback | submitFeedback |
|
|
18
|
+
* | POST | create_interaction | createInteraction |
|
|
19
|
+
* | PATCH | update_interaction | updateInteraction |
|
|
20
|
+
* | GET | list | list |
|
|
21
|
+
* | GET | metrics | metrics |
|
|
22
|
+
*
|
|
23
|
+
* INVARIANT: All writes go through admin-data — no direct DB writes here.
|
|
24
|
+
* INVARIANT: `submit_feedback` not_helpful triggers ticket escalation
|
|
25
|
+
* by updating ticket status to human_assigned.
|
|
26
|
+
*
|
|
27
|
+
* @seeAlso agent-runner.ts (populates messages.data.retrieved_sources)
|
|
28
|
+
* @seeAlso custom/portal/hooks/useTickets.ts (calls submit_feedback)
|
|
29
|
+
* @seeAlso custom/cortex/functions/custom_case_analysis.ts (calls create_interaction)
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import { createHandler } from './_shared/middleware'
|
|
33
|
+
import { adminDb } from './_shared/db'
|
|
34
|
+
import { resolveTypeId } from './_shared/resolve-ids'
|
|
35
|
+
import { create, update } from './admin-data'
|
|
36
|
+
import { emitLog } from './_shared/audit'
|
|
37
|
+
|
|
38
|
+
// ─── SUBMIT FEEDBACK ─────────────────────────────────────────────────────────
|
|
39
|
+
/**
|
|
40
|
+
* Records customer helpful / not_helpful feedback on an AI message.
|
|
41
|
+
*
|
|
42
|
+
* - Stamps messages.data.feedback
|
|
43
|
+
* - If not_helpful: updates ticket to human_assigned + stamps escalation reason
|
|
44
|
+
* - Updates the matching ai_feedback_record if one exists for this message
|
|
45
|
+
*
|
|
46
|
+
* Body: message_id (required), ticket_id (required), feedback ('helpful' | 'not_helpful')
|
|
47
|
+
*/
|
|
48
|
+
export const submitFeedback = createHandler(async (ctx, body) => {
|
|
49
|
+
const { message_id, ticket_id, feedback } = body || {}
|
|
50
|
+
if (!message_id) throw new Error('message_id is required')
|
|
51
|
+
if (!ticket_id) throw new Error('ticket_id is required')
|
|
52
|
+
if (!['helpful', 'not_helpful'].includes(feedback)) {
|
|
53
|
+
throw new Error('feedback must be "helpful" or "not_helpful"')
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Stamp feedback on the message — load existing data to merge safely
|
|
57
|
+
const { data: msg } = await adminDb
|
|
58
|
+
.from('messages').select('data').eq('id', message_id).single()
|
|
59
|
+
if (msg) {
|
|
60
|
+
await adminDb
|
|
61
|
+
.from('messages')
|
|
62
|
+
.update({ data: { ...(msg.data || {}), feedback } })
|
|
63
|
+
.eq('id', message_id)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// If not_helpful: escalate the ticket to human support
|
|
67
|
+
if (feedback === 'not_helpful' && ticket_id) {
|
|
68
|
+
const { data: ticket } = await adminDb
|
|
69
|
+
.from('items').select('status, data').eq('id', ticket_id).single()
|
|
70
|
+
if (ticket && ticket.status !== 'human_assigned' && ticket.status !== 'resolved') {
|
|
71
|
+
await adminDb
|
|
72
|
+
.from('items')
|
|
73
|
+
.update({
|
|
74
|
+
status: 'human_assigned',
|
|
75
|
+
data: { ...(ticket.data || {}), aim_escalation_reason: 'thumbs_down' },
|
|
76
|
+
updated_at: new Date().toISOString(),
|
|
77
|
+
})
|
|
78
|
+
.eq('id', ticket_id)
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Best-effort: update matching ai_feedback_record customer_feedback field
|
|
83
|
+
const { data: interactionRows } = await adminDb
|
|
84
|
+
.from('items')
|
|
85
|
+
.select('id, data')
|
|
86
|
+
.contains('data', { ai_message_id: message_id })
|
|
87
|
+
.limit(1)
|
|
88
|
+
if (interactionRows?.length) {
|
|
89
|
+
const interaction = interactionRows[0]
|
|
90
|
+
await adminDb
|
|
91
|
+
.from('items')
|
|
92
|
+
.update({ data: { ...(interaction.data || {}), customer_feedback: feedback } })
|
|
93
|
+
.eq('id', interaction.id)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
await emitLog(ctx, 'ai_interaction.feedback_submitted',
|
|
97
|
+
{ type: 'message', id: message_id },
|
|
98
|
+
{ after: { feedback, ticket_id, escalated: feedback === 'not_helpful' } }
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
return { message_id, ticket_id, feedback, escalated: feedback === 'not_helpful' }
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
// ─── CREATE INTERACTION ───────────────────────────────────────────────────────
|
|
105
|
+
/**
|
|
106
|
+
* Creates an ai_feedback_record item capturing a full AI↔human exchange.
|
|
107
|
+
* Called by the capture_ai_interaction pipeline after each agent message.
|
|
108
|
+
*
|
|
109
|
+
* Body: thread_id, ai_message_id, retrieved_sources, ai_confidence,
|
|
110
|
+
* retrieval_confidence, kb_candidate (optional)
|
|
111
|
+
*/
|
|
112
|
+
export const createInteraction = createHandler(async (ctx, body) => {
|
|
113
|
+
const {
|
|
114
|
+
thread_id, ai_message_id, retrieved_sources = [],
|
|
115
|
+
ai_confidence, retrieval_confidence, kb_candidate
|
|
116
|
+
} = body || {}
|
|
117
|
+
|
|
118
|
+
if (!thread_id && !ai_message_id) {
|
|
119
|
+
throw new Error('thread_id or ai_message_id is required')
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Resolve the ai_feedback_record type — it lives in the cortex/portal app seeds
|
|
123
|
+
let typeId: string | null = null
|
|
124
|
+
try {
|
|
125
|
+
typeId = await resolveTypeId('item', 'ai_feedback_record')
|
|
126
|
+
} catch {
|
|
127
|
+
// Type not yet seeded — skip creation gracefully
|
|
128
|
+
return { skipped: true, reason: 'ai_feedback_record type not found' }
|
|
129
|
+
}
|
|
130
|
+
if (!typeId) return { skipped: true, reason: 'ai_feedback_record type not found' }
|
|
131
|
+
|
|
132
|
+
const record = await create(ctx, {
|
|
133
|
+
entity: 'items',
|
|
134
|
+
type_id: typeId,
|
|
135
|
+
title: `AI Interaction — ${new Date().toISOString().slice(0, 10)}`,
|
|
136
|
+
status: 'active',
|
|
137
|
+
data: {
|
|
138
|
+
thread_id: thread_id || null,
|
|
139
|
+
ai_message_id: ai_message_id || null,
|
|
140
|
+
human_message_id: null,
|
|
141
|
+
retrieved_sources: retrieved_sources,
|
|
142
|
+
ai_confidence: ai_confidence ?? null,
|
|
143
|
+
retrieval_confidence: retrieval_confidence ?? null,
|
|
144
|
+
human_edits_made: false,
|
|
145
|
+
customer_feedback: null,
|
|
146
|
+
kb_candidate: kb_candidate ?? null,
|
|
147
|
+
kb_article_id: null,
|
|
148
|
+
reuse_count: 0,
|
|
149
|
+
},
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
return record
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
// ─── UPDATE INTERACTION ───────────────────────────────────────────────────────
|
|
156
|
+
/**
|
|
157
|
+
* Patches an existing ai_feedback_record.
|
|
158
|
+
* Used to:
|
|
159
|
+
* - Link the KB article once a feedback record's question is promoted
|
|
160
|
+
* - Increment reuse_count when a similar question is AI-answered
|
|
161
|
+
* - Record human_message_id and human_edits_made after resolution
|
|
162
|
+
*
|
|
163
|
+
* Body: id (required), plus any of: kb_article_id, reuse_count,
|
|
164
|
+
* human_message_id, human_edits_made, customer_feedback
|
|
165
|
+
*/
|
|
166
|
+
export const updateInteraction = createHandler(async (ctx, body) => {
|
|
167
|
+
const { id, ...fields } = body || {}
|
|
168
|
+
if (!id) throw new Error('id is required')
|
|
169
|
+
|
|
170
|
+
const allowed = ['kb_article_id', 'human_message_id', 'human_edits_made',
|
|
171
|
+
'customer_feedback', 'kb_candidate', 'reuse_count']
|
|
172
|
+
const patch: Record<string, any> = {}
|
|
173
|
+
for (const key of allowed) {
|
|
174
|
+
if (fields[key] !== undefined) patch[key] = fields[key]
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (!Object.keys(patch).length) throw new Error('No updatable fields provided')
|
|
178
|
+
|
|
179
|
+
// Load existing data then merge
|
|
180
|
+
const { data: existing } = await adminDb
|
|
181
|
+
.from('items').select('data').eq('id', id).single()
|
|
182
|
+
if (!existing) throw new Error('Interaction record not found')
|
|
183
|
+
|
|
184
|
+
return update(
|
|
185
|
+
{ ...ctx, query: { ...(ctx.query || {}), entity: 'items', id } } as any,
|
|
186
|
+
{ data: { ...(existing.data || {}), ...patch } }
|
|
187
|
+
)
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
// ─── LIST ─────────────────────────────────────────────────────────────────────
|
|
191
|
+
/**
|
|
192
|
+
* Lists ai_feedback_record items for the current account.
|
|
193
|
+
* Supports filtering by: feedback, kb_candidate, date_from, date_to
|
|
194
|
+
*/
|
|
195
|
+
export const list = createHandler(async (ctx, _body) => {
|
|
196
|
+
const { feedback, kb_candidate, limit = '50', offset = '0' } = ctx.query || {}
|
|
197
|
+
|
|
198
|
+
let q = ctx.db
|
|
199
|
+
.from('items')
|
|
200
|
+
.select('*')
|
|
201
|
+
.eq('account_id', ctx.accountId)
|
|
202
|
+
.order('created_at', { ascending: false })
|
|
203
|
+
.range(Number(offset), Number(offset) + Number(limit) - 1)
|
|
204
|
+
|
|
205
|
+
if (feedback) q = q.eq('data->>customer_feedback', feedback)
|
|
206
|
+
if (kb_candidate !== undefined) q = q.eq('data->>kb_candidate', kb_candidate)
|
|
207
|
+
|
|
208
|
+
// Filter to ai_feedback_record type only
|
|
209
|
+
const { data: typeRow } = await adminDb
|
|
210
|
+
.from('types').select('id').eq('slug', 'ai_feedback_record').eq('kind', 'item').maybeSingle()
|
|
211
|
+
if (typeRow?.id) q = q.eq('type_id', typeRow.id)
|
|
212
|
+
|
|
213
|
+
const { data, error } = await q
|
|
214
|
+
if (error) throw error
|
|
215
|
+
return data || []
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
// ─── HANDLER ─────────────────────────────────────────────────────────────────
|
|
219
|
+
|
|
220
|
+
export const handler = createHandler(async (ctx, body) => {
|
|
221
|
+
const { action } = ctx.query || {}
|
|
222
|
+
const method = ctx.query?.method || 'GET'
|
|
223
|
+
|
|
224
|
+
switch (action) {
|
|
225
|
+
case 'submit_feedback':
|
|
226
|
+
if (method === 'POST') return await submitFeedback(ctx, body)
|
|
227
|
+
break
|
|
228
|
+
case 'create_interaction':
|
|
229
|
+
if (method === 'POST') return await createInteraction(ctx, body)
|
|
230
|
+
break
|
|
231
|
+
case 'update_interaction':
|
|
232
|
+
if (method === 'PATCH' || method === 'POST') return await updateInteraction(ctx, body)
|
|
233
|
+
break
|
|
234
|
+
case 'list':
|
|
235
|
+
default:
|
|
236
|
+
if (method === 'GET') return await list(ctx, body)
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
throw new Error('Invalid action or method')
|
|
240
|
+
})
|