spine-framework-cortex 0.2.19 → 0.2.21

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,507 @@
1
+ import { createHandler } from './_shared/middleware'
2
+ import { adminDb } from './_shared/db'
3
+
4
+ /**
5
+ * Custom Case Analysis Handler
6
+ *
7
+ * Action: POST ?action=analyze_ticket
8
+ *
9
+ * Analyzes resolved support tickets to extract insights, identify root causes,
10
+ * and suggest improvements. Creates case_analysis items and manages tags.
11
+ *
12
+ * Process:
13
+ * 1. Fetch ticket data, threads, and messages
14
+ * 2. Run AI analysis with structured prompt
15
+ * 3. Process tag suggestions (check existence, create if needed)
16
+ * 4. Create case_analysis item with results
17
+ * 5. Update ticket with analysis summary and tags
18
+ */
19
+
20
+ // ─── CONSTANTS ────────────────────────────────────────────────────────────────
21
+
22
+ const ANALYSIS_AGENT_ID = 'case_analysis_agent' // Will be looked up by name
23
+ const PROMPT_CONFIG_ID = 'case_analysis_prompt' // Will be looked up by slug
24
+ const SUPPORT_TICKET_TYPE_ID = '82320862-a99c-4a84-b7ed-c2832cf519cd' // From existing triage
25
+ const CASE_ANALYSIS_TYPE_ID = 'case_analysis' // Will be looked up by slug
26
+ const TAG_TYPE_ID = 'tag' // Will be looked up by slug
27
+
28
+ // ─── TYPES ────────────────────────────────────────────────────────────────────
29
+
30
+ interface AnalysisRequest {
31
+ ticket_id: string
32
+ }
33
+
34
+ interface AnalysisResult {
35
+ ticketId: string
36
+ caseAnalysisId: string
37
+ analysisData: any
38
+ createdTags: string[]
39
+ confidence: number
40
+ }
41
+
42
+ // ─── HELPERS ──────────────────────────────────────────────────────────────────
43
+
44
+ async function getTicketData(ticketId: string): Promise<any> {
45
+ const { data: ticket, error } = await adminDb
46
+ .from('items')
47
+ .select('*')
48
+ .eq('id', ticketId)
49
+ .eq('type_id', SUPPORT_TICKET_TYPE_ID)
50
+ .single()
51
+
52
+ if (error || !ticket) throw new Error(`Ticket not found: ${error?.message}`)
53
+ return ticket
54
+ }
55
+
56
+ async function getConversationHistory(ticketId: string): Promise<any[]> {
57
+ // Get threads for this ticket
58
+ const { data: threads, error: threadError } = await adminDb
59
+ .from('threads')
60
+ .select('id')
61
+ .eq('target_type', 'items')
62
+ .eq('target_id', ticketId)
63
+
64
+ if (threadError || !threads || threads.length === 0) return []
65
+
66
+ const threadId = threads[0].id
67
+
68
+ // Get messages for this thread
69
+ const { data: messages, error: msgError } = await adminDb
70
+ .from('messages')
71
+ .select('content, direction, data, created_at, person_id')
72
+ .eq('thread_id', threadId)
73
+ .eq('visibility', 'public')
74
+ .order('created_at', { ascending: true })
75
+ .limit(50)
76
+
77
+ if (msgError) return []
78
+ return messages || []
79
+ }
80
+
81
+ async function getAgentAndPrompt(): Promise<{ agent: any; promptConfig: any }> {
82
+ const [{ data: agent }, { data: promptConfig }] = await Promise.all([
83
+ adminDb.from('ai_agents').select('*').eq('name', 'Case Resolution Analysis Agent').single(),
84
+ adminDb.from('prompt_configs').select('*').eq('slug', 'case_analysis_prompt').single()
85
+ ])
86
+
87
+ if (!agent || !promptConfig) {
88
+ throw new Error('Case analysis agent or prompt configuration not found')
89
+ }
90
+
91
+ return { agent, promptConfig }
92
+ }
93
+
94
+ async function callOpenAI(
95
+ systemPrompt: string,
96
+ messages: Array<{ role: 'user' | 'assistant' | 'system'; content: string }>,
97
+ model: string,
98
+ temperature: number
99
+ ): Promise<{ envelope: any; latency_ms: number; token_usage: any }> {
100
+ const apiKey = process.env.OPENAI_API_KEY || process.env.LLM_API_KEY
101
+ const baseUrl = process.env.OPENAI_BASE_URL || 'https://api.openai.com/v1'
102
+ const startTime = Date.now()
103
+
104
+ if (!apiKey) {
105
+ const mockEnvelope = {
106
+ reported_issue: 'Mock reported issue - no API key configured',
107
+ true_problem: 'Mock true problem - no API key configured',
108
+ diagnostic_steps: ['Mock step 1', 'Mock step 2'],
109
+ solution_steps: ['Mock solution step 1', 'Mock solution step 2'],
110
+ final_solution: 'Mock final solution - no API key configured',
111
+ customer_temperature: 'neutral',
112
+ time_to_resolution: 60,
113
+ escalation_required: false,
114
+ back_and_forth_count: 2,
115
+ sentiment_progression: ['neutral', 'neutral', 'neutral'],
116
+ automation_potential: 'medium',
117
+ kb_candidate: false,
118
+ suggested_tags: [],
119
+ confidence_score: 0.5,
120
+ analysis_summary: 'Mock analysis - no API key configured'
121
+ }
122
+ return { envelope: mockEnvelope, latency_ms: 0, token_usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 } }
123
+ }
124
+
125
+ const res = await fetch(`${baseUrl}/chat/completions`, {
126
+ method: 'POST',
127
+ headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` },
128
+ body: JSON.stringify({
129
+ model,
130
+ temperature,
131
+ response_format: { type: 'json_object' },
132
+ messages: [{ role: 'system', content: systemPrompt }, ...messages],
133
+ }),
134
+ })
135
+
136
+ if (!res.ok) {
137
+ const err = await res.text()
138
+ throw new Error(`OpenAI error ${res.status}: ${err.slice(0, 200)}`)
139
+ }
140
+
141
+ const result: any = await res.json()
142
+ const raw = result.choices?.[0]?.message?.content || '{}'
143
+ let envelope: any
144
+
145
+ try {
146
+ envelope = JSON.parse(raw)
147
+ } catch (parseErr: any) {
148
+ envelope = {
149
+ reported_issue: 'Parse failure',
150
+ true_problem: 'Parse failure',
151
+ diagnostic_steps: ['Parse failure'],
152
+ solution_steps: ['Parse failure'],
153
+ final_solution: `Parse failure: ${parseErr.message}`,
154
+ customer_temperature: 'neutral',
155
+ time_to_resolution: 0,
156
+ escalation_required: true,
157
+ back_and_forth_count: 0,
158
+ sentiment_progression: ['neutral'],
159
+ automation_potential: 'low',
160
+ kb_candidate: false,
161
+ suggested_tags: [],
162
+ confidence_score: 0,
163
+ analysis_summary: `Parse failure: ${parseErr.message}. Raw: ${raw.slice(0, 200)}`
164
+ }
165
+ }
166
+
167
+ return {
168
+ envelope,
169
+ latency_ms: Date.now() - startTime,
170
+ token_usage: result.usage || {},
171
+ }
172
+ }
173
+
174
+ async function getOrCreateTag(tagData: any, accountId: string): Promise<string> {
175
+ const { slug, name, purpose, category, applicable_to } = tagData
176
+
177
+ // Check if tag already exists
178
+ const { data: existingTag } = await adminDb
179
+ .from('items')
180
+ .select('id')
181
+ .eq('type_id', TAG_TYPE_ID)
182
+ .eq('data->>slug', slug)
183
+ .single()
184
+
185
+ if (existingTag) {
186
+ // Increment usage count
187
+ await adminDb
188
+ .from('items')
189
+ .update({
190
+ data: adminDb.sql`jsonb_set(data, '{usage_count}', COALESCE((data->>'usage_count')::int, 0) + 1)`,
191
+ updated_at: new Date().toISOString()
192
+ })
193
+ .eq('id', existingTag.id)
194
+
195
+ return existingTag.id
196
+ }
197
+
198
+ // Get tag type ID
199
+ const { data: tagType } = await adminDb
200
+ .from('types')
201
+ .select('id')
202
+ .eq('slug', 'tag')
203
+ .single()
204
+
205
+ if (!tagType) throw new Error('Tag type not found')
206
+
207
+ // Create new tag
208
+ const { data: newTag, error } = await adminDb
209
+ .from('items')
210
+ .insert({
211
+ type_id: tagType.id,
212
+ account_id: accountId,
213
+ title: name,
214
+ description: purpose,
215
+ data: {
216
+ slug,
217
+ name,
218
+ purpose,
219
+ applicable_to: applicable_to || ['ticket'],
220
+ category,
221
+ usage_count: 1
222
+ },
223
+ status: 'active'
224
+ })
225
+ .select('id')
226
+ .single()
227
+
228
+ if (error || !newTag) throw new Error(`Failed to create tag: ${error?.message}`)
229
+ return newTag.id
230
+ }
231
+
232
+ async function createCaseAnalysis(
233
+ ticketId: string,
234
+ analysisData: any,
235
+ confidence: number,
236
+ agentId: string,
237
+ accountId: string
238
+ ): Promise<string> {
239
+ // Get case_analysis type ID
240
+ const { data: caseAnalysisType } = await adminDb
241
+ .from('types')
242
+ .select('id')
243
+ .eq('slug', 'case_analysis')
244
+ .single()
245
+
246
+ if (!caseAnalysisType) throw new Error('Case analysis type not found')
247
+
248
+ const { data: caseAnalysis, error } = await adminDb
249
+ .from('items')
250
+ .insert({
251
+ type_id: caseAnalysisType.id,
252
+ account_id: accountId,
253
+ title: `Analysis for Ticket ${ticketId.slice(0, 8)}`,
254
+ data: {
255
+ ticket_id: ticketId,
256
+ analysis_data: analysisData,
257
+ confidence_score: confidence,
258
+ analysis_timestamp: new Date().toISOString(),
259
+ ai_agent_id: agentId
260
+ },
261
+ status: 'completed'
262
+ })
263
+ .select('id')
264
+ .single()
265
+
266
+ if (error || !caseAnalysis) throw new Error(`Failed to create case analysis: ${error?.message}`)
267
+
268
+ // Create entity link from case analysis to ticket
269
+ await createEntityLink(
270
+ caseAnalysis.id,
271
+ 'items',
272
+ ticketId,
273
+ 'items',
274
+ 'analyzed_by',
275
+ accountId
276
+ )
277
+
278
+ return caseAnalysis.id
279
+ }
280
+
281
+ async function createEntityLink(
282
+ sourceId: string,
283
+ sourceType: string,
284
+ targetId: string,
285
+ targetType: string,
286
+ linkTypeSlug: string,
287
+ accountId: string
288
+ ): Promise<void> {
289
+ // Get link type ID
290
+ const { data: linkType } = await adminDb
291
+ .from('link_types')
292
+ .select('id')
293
+ .eq('slug', linkTypeSlug)
294
+ .single()
295
+
296
+ if (!linkType) throw new Error(`Link type '${linkTypeSlug}' not found`)
297
+
298
+ // Get link type ID for the links table
299
+ const { data: linkItemType } = await adminDb
300
+ .from('types')
301
+ .select('id')
302
+ .eq('slug', 'link')
303
+ .single()
304
+
305
+ if (!linkItemType) throw new Error('Link type not found')
306
+
307
+ // Check if link already exists
308
+ const { data: existingLink } = await adminDb
309
+ .from('links')
310
+ .select('id')
311
+ .eq('source_id', sourceId)
312
+ .eq('source_type', sourceType)
313
+ .eq('target_id', targetId)
314
+ .eq('target_type', targetType)
315
+ .eq('link_type_id', linkType.id)
316
+ .single()
317
+
318
+ if (existingLink) return // Link already exists
319
+
320
+ // Create the link
321
+ const { error } = await adminDb
322
+ .from('links')
323
+ .insert({
324
+ type_id: linkItemType.id, // Required field
325
+ link_type_id: linkType.id,
326
+ account_id: accountId,
327
+ source_type: sourceType,
328
+ source_id: sourceId,
329
+ target_type: targetType,
330
+ target_id: targetId,
331
+ link_type: linkTypeSlug,
332
+ created_at: new Date().toISOString(),
333
+ updated_at: new Date().toISOString()
334
+ })
335
+
336
+ if (error) throw new Error(`Failed to create entity link: ${error?.message}`)
337
+ }
338
+
339
+ async function createTagLinks(
340
+ ticketId: string,
341
+ tagIds: string[],
342
+ accountId: string
343
+ ): Promise<void> {
344
+ for (const tagId of tagIds) {
345
+ await createEntityLink(
346
+ ticketId,
347
+ 'items',
348
+ tagId,
349
+ 'items',
350
+ 'tagged_with',
351
+ accountId
352
+ )
353
+ }
354
+ }
355
+
356
+ async function updateTicketWithAnalysis(
357
+ ticketId: string,
358
+ analysisData: any,
359
+ tagIds: string[]
360
+ ): Promise<void> {
361
+ // Get current ticket data
362
+ const { data: ticket, error: fetchError } = await adminDb
363
+ .from('items')
364
+ .select('data')
365
+ .eq('id', ticketId)
366
+ .single()
367
+
368
+ if (fetchError || !ticket) {
369
+ throw new Error(`Ticket not found: ${ticketId}`)
370
+ }
371
+
372
+ // Map analysis data to ca_ prefixed field names to match design schema
373
+ const mappedAnalysisData = {
374
+ ca_reported_issue: analysisData.reported_issue,
375
+ ca_true_problem: analysisData.true_problem,
376
+ ca_final_solution: analysisData.final_solution,
377
+ ca_diagnostic_steps: analysisData.diagnostic_steps,
378
+ ca_solution_steps: analysisData.solution_steps,
379
+ ca_analysis_tags: tagIds,
380
+ ca_time_to_resolution: analysisData.time_to_resolution,
381
+ ca_back_and_forth_count: analysisData.back_and_forth_count,
382
+ ca_escalation_required: analysisData.escalation_required,
383
+ ca_automation_potential: analysisData.automation_potential,
384
+ ca_customer_temperature: analysisData.customer_temperature,
385
+ ca_kb_candidate: analysisData.kb_candidate,
386
+ ca_sentiment_progression: analysisData.sentiment_progression,
387
+ analysis_timestamp: new Date().toISOString()
388
+ }
389
+
390
+ // Update ticket with analysis data using simple object merge
391
+ const { error } = await adminDb
392
+ .from('items')
393
+ .update({
394
+ data: {
395
+ ...ticket.data,
396
+ ...mappedAnalysisData
397
+ },
398
+ updated_at: new Date().toISOString()
399
+ })
400
+ .eq('id', ticketId)
401
+
402
+ if (error) {
403
+ throw new Error(`Failed to update ticket with analysis: ${error.message}`)
404
+ }
405
+ }
406
+
407
+ // ─── ACTION: ANALYZE TICKET ─────────────────────────────────────────────────────
408
+
409
+ async function handleAnalyzeTicket(ctx: any, body: AnalysisRequest): Promise<AnalysisResult> {
410
+ const { ticket_id } = body
411
+ const account_id = ctx.accountId as string
412
+
413
+ if (!ticket_id) throw new Error('ticket_id is required')
414
+ if (!account_id) throw new Error('Account context required')
415
+
416
+ // Load ticket and conversation data
417
+ const [ticket, conversationHistory, { agent, promptConfig }] = await Promise.all([
418
+ getTicketData(ticket_id),
419
+ getConversationHistory(ticket_id),
420
+ getAgentAndPrompt()
421
+ ])
422
+
423
+ // Verify ticket is resolved
424
+ if (ticket.status !== 'resolved') {
425
+ throw new Error('Ticket must be resolved before analysis')
426
+ }
427
+
428
+ const modelConfig = agent.model_config || {}
429
+ const model = modelConfig.model || process.env.LLM_DEFAULT_MODEL || 'gpt-4o'
430
+ const temperature = modelConfig.temperature ?? 0.3
431
+
432
+ // Build analysis prompt
433
+ const contextTemplate = promptConfig.context_template
434
+ .replace('{{ticket_title}}', ticket.title || '')
435
+ .replace('{{ticket_description}}', ticket.description || '')
436
+ .replace('{{created_at}}', ticket.created_at || '')
437
+ .replace('{{resolved_at}}', ticket.updated_at || '')
438
+ .replace('{{status}}', ticket.data?.status || ticket.status || '')
439
+ .replace('{{priority}}', ticket.data?.priority || 'medium')
440
+
441
+ const conversationText = conversationHistory.map(msg => {
442
+ const role = msg.direction === 'inbound' ? 'Customer' : 'Agent'
443
+ return `${role}: ${msg.content}`
444
+ }).join('\n')
445
+
446
+ const fullPrompt = contextTemplate.replace('{{conversation_history}}', conversationText)
447
+
448
+ // Run AI analysis
449
+ const promptMessages = [{ role: 'user' as const, content: fullPrompt }]
450
+ const { envelope, latency_ms, token_usage } = await callOpenAI(
451
+ agent.system_prompt,
452
+ promptMessages,
453
+ model,
454
+ temperature
455
+ )
456
+
457
+ // Process suggested tags
458
+ const suggestedTags = envelope.suggested_tags || []
459
+ const createdTagIds: string[] = []
460
+
461
+ for (const tagData of suggestedTags) {
462
+ try {
463
+ const tagId = await getOrCreateTag(tagData, account_id)
464
+ createdTagIds.push(tagId)
465
+ } catch (err) {
466
+ console.error(`Failed to create tag ${tagData.slug}:`, err)
467
+ }
468
+ }
469
+
470
+ // Create case analysis record
471
+ const caseAnalysisId = await createCaseAnalysis(
472
+ ticket_id,
473
+ envelope,
474
+ envelope.confidence_score || 0.5,
475
+ agent.id,
476
+ account_id
477
+ )
478
+
479
+ // Update ticket with analysis data
480
+ await updateTicketWithAnalysis(ticket_id, envelope, createdTagIds)
481
+
482
+ // Create entity links for tags
483
+ if (createdTagIds.length > 0) {
484
+ await createTagLinks(ticket_id, createdTagIds, account_id)
485
+ }
486
+
487
+ return {
488
+ ticketId: ticket_id,
489
+ caseAnalysisId,
490
+ analysisData: envelope,
491
+ createdTags: createdTagIds,
492
+ confidence: envelope.confidence_score || 0.5
493
+ }
494
+ }
495
+
496
+ // ─── HANDLER ──────────────────────────────────────────────────────────────────
497
+
498
+ export const handler = createHandler(async (ctx, body) => {
499
+ const action = ctx.query?.action
500
+
501
+ switch (action) {
502
+ case 'analyze_ticket':
503
+ return await handleAnalyzeTicket(ctx, body)
504
+ default:
505
+ throw new Error(`Unknown action: ${action}. Use 'analyze_ticket'.`)
506
+ }
507
+ })
@@ -0,0 +1,52 @@
1
+ import { createHandler } from './_shared/middleware'
2
+ import { adminDb } from './_shared/db'
3
+
4
+ export const handler = createHandler(async (ctx, body) => {
5
+ const { action } = ctx.query || {}
6
+ const method = ctx.query?.method || 'GET'
7
+
8
+ switch (action) {
9
+ case 'list':
10
+ if (method === 'GET') {
11
+ try {
12
+ // Read chunks from the project root directory
13
+ const fs = require('fs')
14
+ const path = require('path')
15
+ const chunksPath = path.join(process.cwd(), 'chunks.json')
16
+ const fileContent = fs.readFileSync(chunksPath, 'utf8')
17
+ const data = JSON.parse(fileContent)
18
+
19
+ return {
20
+ chunks: data.chunks || [],
21
+ total: data.chunks?.length || 0,
22
+ loaded_at: new Date().toISOString()
23
+ }
24
+ } catch (error) {
25
+ throw new Error(`Failed to load chunks: ${error instanceof Error ? error.message : 'Unknown error'}`)
26
+ }
27
+ }
28
+ break
29
+
30
+ default:
31
+ if (method === 'GET') {
32
+ try {
33
+ // Read chunks from the project root directory
34
+ const fs = require('fs')
35
+ const path = require('path')
36
+ const chunksPath = path.join(process.cwd(), 'chunks.json')
37
+ const fileContent = fs.readFileSync(chunksPath, 'utf8')
38
+ const data = JSON.parse(fileContent)
39
+
40
+ return {
41
+ chunks: data.chunks || [],
42
+ total: data.chunks?.length || 0,
43
+ loaded_at: new Date().toISOString()
44
+ }
45
+ } catch (error) {
46
+ throw new Error(`Failed to load chunks: ${error instanceof Error ? error.message : 'Unknown error'}`)
47
+ }
48
+ }
49
+ }
50
+
51
+ throw new Error('Invalid action or method')
52
+ })
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Cortex Webhook Handler
3
+ *
4
+ * Convention: custom_*.ts files are assembled into /functions/
5
+ * and loaded by integration-routes via: import('./custom_cortex-handler')
6
+ *
7
+ * Receives: (sanitizedData, context, event)
8
+ * Returns: plain text or object
9
+ */
10
+ export default async function cortexHandler(
11
+ data: Record<string, any>,
12
+ ctx: {
13
+ integrationId: string
14
+ accountId: string
15
+ slug: string
16
+ principal: { id: string; type: string; accountId: string }
17
+ requestId: string
18
+ headers: Record<string, string>
19
+ },
20
+ event: {
21
+ httpMethod: string
22
+ headers: Record<string, string>
23
+ body: any
24
+ path: string
25
+ queryStringParameters: Record<string, string>
26
+ }
27
+ ): Promise<string> {
28
+ console.log(`[${ctx.requestId}] Cortex handler received:`, {
29
+ testText: data['test-text'],
30
+ integrationId: ctx.integrationId,
31
+ accountId: ctx.accountId
32
+ })
33
+
34
+ return data['test-text']
35
+ }