spine-framework-cortex 0.2.21 → 0.2.23

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.
@@ -1,507 +1,292 @@
1
1
  import { createHandler } from './_shared/middleware'
2
2
  import { adminDb } from './_shared/db'
3
+ import { resolveTypeId, resolveAgentId, resolvePromptConfigId, resolveLinkTypeId } from './_shared/resolve-ids'
4
+ import { runAgent } from './_shared/index'
5
+ import { create } from './admin-data'
6
+ import { embedResolvedCase } from './custom_kb-embeddings'
3
7
 
4
8
  /**
5
- * Custom Case Analysis Handler
9
+ * @module custom_case_analysis
10
+ * @layer custom/cortex
11
+ * @stability custom
12
+ *
13
+ * Post-resolution case analysis. Triggered when a support ticket is resolved.
6
14
  *
7
15
  * 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
16
  *
12
17
  * 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
+ * 1. Load ticket + all thread messages (external + internal + solution AI)
19
+ * 2. Create a temporary internal analysis thread wired to Case Resolution Analysis Agent
20
+ * 3. Call runAgent() with the full case context — agent returns structured JSON
21
+ * 4. Parse JSON from agent message content
22
+ * 5. Upsert tags, create case_analysis item, update ticket with ca_ fields
23
+ * 6. Trigger embedResolvedCase() to embed for future RAG
24
+ *
25
+ * The analysis thread persists as an audit trail of the AI's reasoning.
18
26
  */
19
27
 
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
28
  // ─── TYPES ────────────────────────────────────────────────────────────────────
29
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
- }
30
+ interface AnalysisRequest { ticket_id: string }
41
31
 
42
32
  // ─── HELPERS ──────────────────────────────────────────────────────────────────
43
33
 
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
34
+ async function loadConversationContext(ticketId: string): Promise<{
35
+ externalMessages: any[]
36
+ internalMessages: any[]
37
+ solutionMessages: any[]
38
+ }> {
39
+ const { data: threads } = await adminDb
59
40
  .from('threads')
60
- .select('id')
41
+ .select('id, visibility, data')
61
42
  .eq('target_type', 'items')
62
43
  .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
44
 
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)}`
45
+ const result = { externalMessages: [], internalMessages: [], solutionMessages: [] } as any
46
+
47
+ for (const thread of threads || []) {
48
+ const { data: msgs } = await adminDb
49
+ .from('messages')
50
+ .select('content, direction, visibility, created_at')
51
+ .eq('thread_id', thread.id)
52
+ .eq('visibility', thread.data?.thread_purpose === 'solution_ai' ? 'internal' : 'public')
53
+ .order('created_at', { ascending: true })
54
+ .limit(100)
55
+
56
+ const messages = msgs || []
57
+ if (thread.data?.thread_purpose === 'solution_ai') {
58
+ result.solutionMessages = messages
59
+ } else if (thread.visibility === 'internal') {
60
+ result.internalMessages = messages
61
+ } else {
62
+ result.externalMessages = messages
164
63
  }
165
64
  }
65
+ return result
66
+ }
166
67
 
167
- return {
168
- envelope,
169
- latency_ms: Date.now() - startTime,
170
- token_usage: result.usage || {},
171
- }
68
+ function formatMessages(msgs: any[], label: string): string {
69
+ if (!msgs.length) return ''
70
+ return `\n\n## ${label}\n` + msgs.map(m => {
71
+ const role = m.direction === 'inbound' ? 'Customer' : 'Agent'
72
+ return `${role}: ${m.content}`
73
+ }).join('\n')
172
74
  }
173
75
 
174
- async function getOrCreateTag(tagData: any, accountId: string): Promise<string> {
76
+ async function upsertTag(tagData: any, accountId: string, tagTypeId: string): Promise<string | null> {
175
77
  const { slug, name, purpose, category, applicable_to } = tagData
176
-
177
- // Check if tag already exists
178
- const { data: existingTag } = await adminDb
78
+ if (!slug || !name) return null
79
+
80
+ const { data: existing } = await adminDb
179
81
  .from('items')
180
82
  .select('id')
181
- .eq('type_id', TAG_TYPE_ID)
83
+ .eq('type_id', tagTypeId)
182
84
  .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
85
+ .maybeSingle()
86
+
87
+ if (existing) return existing.id
88
+
89
+ const { data: newTag } = await adminDb
209
90
  .from('items')
210
91
  .insert({
211
- type_id: tagType.id,
92
+ type_id: tagTypeId,
212
93
  account_id: accountId,
213
94
  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'
95
+ description: purpose || '',
96
+ status: 'active',
97
+ data: { slug, name, purpose, applicable_to: applicable_to || ['ticket'], category, usage_count: 1 }
224
98
  })
225
99
  .select('id')
226
100
  .single()
227
-
228
- if (error || !newTag) throw new Error(`Failed to create tag: ${error?.message}`)
229
- return newTag.id
230
- }
231
101
 
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
102
+ return newTag?.id ?? null
279
103
  }
280
104
 
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 ─────────────────────────────────────────────────────
105
+ // ─── ACTION: ANALYZE TICKET ───────────────────────────────────────────────────
408
106
 
409
- async function handleAnalyzeTicket(ctx: any, body: AnalysisRequest): Promise<AnalysisResult> {
107
+ async function handleAnalyzeTicket(ctx: any, body: AnalysisRequest) {
410
108
  const { ticket_id } = body
411
109
  const account_id = ctx.accountId as string
412
-
413
110
  if (!ticket_id) throw new Error('ticket_id is required')
414
111
  if (!account_id) throw new Error('Account context required')
415
112
 
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()
113
+ // Load ticket
114
+ const { data: ticket, error: ticketErr } = await adminDb
115
+ .from('items')
116
+ .select('*')
117
+ .eq('id', ticket_id)
118
+ .single()
119
+ if (ticketErr || !ticket) throw new Error(`Ticket not found: ${ticket_id}`)
120
+ if (ticket.status !== 'resolved') throw new Error('Ticket must be resolved before analysis')
121
+
122
+ // Resolve IDs
123
+ const [threadTypeId, analysisAgentId, promptConfigId, caseAnalysisTypeId, tagTypeId] = await Promise.all([
124
+ resolveTypeId('thread', 'thread'),
125
+ resolveAgentId('Case Resolution Analysis Agent'),
126
+ resolvePromptConfigId('case_analysis_prompt'),
127
+ resolveTypeId('item', 'case_analysis'),
128
+ resolveTypeId('item', 'tag'),
421
129
  ])
422
130
 
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')
131
+ // Load full conversation context (external + internal + solution AI)
132
+ const { externalMessages, internalMessages, solutionMessages } = await loadConversationContext(ticket_id)
133
+
134
+ // Build the analysis prompt as the user message to the agent.
135
+ // The agent's context_template handles placeholders; we pass everything here.
136
+ const conversationText =
137
+ formatMessages(externalMessages, 'Customer Conversation') +
138
+ formatMessages(internalMessages, 'Internal Support Notes') +
139
+ formatMessages(solutionMessages, 'Solution AI Collaboration')
140
+
141
+ const analysisPrompt = [
142
+ `Ticket: ${ticket.title}`,
143
+ `Description: ${ticket.description || ''}`,
144
+ `Created: ${ticket.created_at}`,
145
+ `Resolved: ${ticket.updated_at}`,
146
+ `Status: ${ticket.status}`,
147
+ `Priority: ${ticket.data?.priority || 'medium'}`,
148
+ conversationText,
149
+ '\nProduce the full structured postmortem JSON.'
150
+ ].join('\n')
151
+
152
+ // Create a dedicated analysis thread — persists as audit trail
153
+ const analysisThread = await create(ctx, {
154
+ entity: 'threads',
155
+ type_id: threadTypeId,
156
+ target_type: 'items',
157
+ target_id: ticket_id,
158
+ visibility: 'internal',
159
+ status: 'active',
160
+ data: {
161
+ thread_purpose: 'case_analysis',
162
+ agent_id: analysisAgentId,
163
+ prompt_config_id: promptConfigId,
164
+ }
165
+ })
166
+ if (!analysisThread?.id) throw new Error('Failed to create analysis thread')
445
167
 
446
- const fullPrompt = contextTemplate.replace('{{conversation_history}}', conversationText)
168
+ // Run the agent — saves messages, handles RAG (none needed here), returns message row
169
+ const agentMsg = await runAgent(analysisThread.id, analysisPrompt, ctx)
447
170
 
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
- )
171
+ // Parse structured JSON from agent response
172
+ let envelope: any = {}
173
+ try {
174
+ const raw = agentMsg?.content || '{}'
175
+ // Strip code fences if the model wrapped it
176
+ const cleaned = raw.replace(/^```(?:json)?\s*/i, '').replace(/\s*```$/, '').trim()
177
+ envelope = JSON.parse(cleaned)
178
+ } catch {
179
+ console.error('Case analysis: failed to parse agent JSON response')
180
+ envelope = {
181
+ reported_issue: ticket.description || '',
182
+ true_problem: 'Analysis parse failure — see raw agent message',
183
+ diagnostic_steps: [],
184
+ solution_steps: [],
185
+ final_solution: '',
186
+ confidence_score: 0.1,
187
+ kb_candidate: false,
188
+ suggested_tags: [],
189
+ analysis_summary: 'Structured analysis unavailable — agent response was not valid JSON.'
190
+ }
191
+ }
456
192
 
457
- // Process suggested tags
458
- const suggestedTags = envelope.suggested_tags || []
459
- const createdTagIds: string[] = []
460
-
461
- for (const tagData of suggestedTags) {
193
+ // Upsert tags
194
+ const tagIds: string[] = []
195
+ for (const tagData of envelope.suggested_tags || []) {
462
196
  try {
463
- const tagId = await getOrCreateTag(tagData, account_id)
464
- createdTagIds.push(tagId)
197
+ const tagId = await upsertTag(tagData, account_id, tagTypeId)
198
+ if (tagId) tagIds.push(tagId)
465
199
  } catch (err) {
466
- console.error(`Failed to create tag ${tagData.slug}:`, err)
200
+ console.error('Tag upsert failed:', tagData.slug, err)
467
201
  }
468
202
  }
469
203
 
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
- )
204
+ // Create case_analysis item
205
+ const { data: caseAnalysis } = await adminDb
206
+ .from('items')
207
+ .insert({
208
+ type_id: caseAnalysisTypeId,
209
+ account_id,
210
+ title: `Analysis: ${ticket.title}`,
211
+ status: 'completed',
212
+ data: {
213
+ ticket_id,
214
+ analysis_data: envelope,
215
+ confidence_score: envelope.confidence_score || 0.5,
216
+ analysis_timestamp: new Date().toISOString(),
217
+ ai_agent_id: analysisAgentId,
218
+ analysis_thread_id: analysisThread.id,
219
+ }
220
+ })
221
+ .select('id')
222
+ .single()
478
223
 
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)
224
+ // Link case_analysis ticket
225
+ if (caseAnalysis?.id) {
226
+ const analyzedByLinkTypeId = await resolveLinkTypeId('analyzed_by').catch(() => null)
227
+ if (analyzedByLinkTypeId) {
228
+ const linkTypeItemId = await resolveTypeId('item', 'link').catch(() => null)
229
+ if (linkTypeItemId) {
230
+ await adminDb.from('links').upsert({
231
+ type_id: linkTypeItemId,
232
+ link_type_id: analyzedByLinkTypeId,
233
+ account_id,
234
+ source_type: 'items',
235
+ source_id: caseAnalysis.id,
236
+ target_type: 'items',
237
+ target_id: ticket_id,
238
+ link_type: 'analyzed_by',
239
+ created_at: new Date().toISOString(),
240
+ updated_at: new Date().toISOString(),
241
+ }, { onConflict: 'source_id,target_id,link_type_id' })
242
+ }
243
+ }
485
244
  }
486
245
 
246
+ // Update ticket with ca_ fields
247
+ const { data: currentTicket } = await adminDb.from('items').select('data').eq('id', ticket_id).single()
248
+ await adminDb.from('items').update({
249
+ data: {
250
+ ...(currentTicket?.data || {}),
251
+ ca_reported_issue: envelope.reported_issue,
252
+ ca_true_problem: envelope.true_problem,
253
+ ca_final_solution: envelope.final_solution,
254
+ ca_diagnostic_steps: envelope.diagnostic_steps,
255
+ ca_solution_steps: envelope.solution_steps,
256
+ ca_analysis_tags: tagIds,
257
+ ca_time_to_resolution: envelope.time_to_resolution,
258
+ ca_back_and_forth_count: envelope.back_and_forth_count,
259
+ ca_escalation_required: envelope.escalation_required,
260
+ ca_automation_potential: envelope.automation_potential,
261
+ ca_customer_temperature: envelope.customer_temperature,
262
+ ca_kb_candidate: envelope.kb_candidate,
263
+ ca_sentiment_progression: envelope.sentiment_progression,
264
+ analysis_timestamp: new Date().toISOString(),
265
+ },
266
+ updated_at: new Date().toISOString(),
267
+ }).eq('id', ticket_id)
268
+
269
+ // Embed the resolved case for future triage RAG (best-effort, non-blocking)
270
+ embedResolvedCase(ticket_id, account_id).catch(err =>
271
+ console.error('embedResolvedCase failed (non-fatal):', err)
272
+ )
273
+
487
274
  return {
488
275
  ticketId: ticket_id,
489
- caseAnalysisId,
490
- analysisData: envelope,
491
- createdTags: createdTagIds,
492
- confidence: envelope.confidence_score || 0.5
276
+ caseAnalysisId: caseAnalysis?.id,
277
+ analysisThreadId: analysisThread.id,
278
+ confidence: envelope.confidence_score || 0.5,
279
+ kbCandidate: envelope.kb_candidate,
280
+ createdTags: tagIds.length,
493
281
  }
494
282
  }
495
283
 
496
284
  // ─── HANDLER ──────────────────────────────────────────────────────────────────
497
285
 
498
286
  export const handler = createHandler(async (ctx, body) => {
499
- const action = ctx.query?.action
500
-
287
+ const action = (ctx as any).query?.action
501
288
  switch (action) {
502
- case 'analyze_ticket':
503
- return await handleAnalyzeTicket(ctx, body)
504
- default:
505
- throw new Error(`Unknown action: ${action}. Use 'analyze_ticket'.`)
289
+ case 'analyze_ticket': return handleAnalyzeTicket(ctx, body as AnalysisRequest)
290
+ default: throw new Error(`Unknown action: ${action}. Use 'analyze_ticket'.`)
506
291
  }
507
292
  })