spine-framework-cortex 0.2.21 → 0.2.22
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/functions/custom_case_analysis.ts +224 -439
- package/functions/custom_kb-embeddings.ts +129 -22
- package/functions/custom_support-redaction.ts +115 -0
- package/functions/custom_support-solution.ts +104 -0
- package/manifest.json +9 -5
- package/package.json +1 -1
- package/pages/support/RedactionReview.tsx +36 -18
- package/pages/support/TicketDetailPage.tsx +127 -129
- package/seed/ai-agents.json +98 -0
- package/seed/pipelines.json +29 -0
- package/seed/prompt-configs.json +84 -0
- package/LICENSE.md +0 -193
- package/README.md +0 -46
- package/functions/custom_cortex-handler.ts +0 -35
- package/functions/custom_kb-chunker-test.ts +0 -364
- package/functions/custom_kb-ingestion.ts +0 -447
- package/functions/custom_tag_management.ts +0 -314
|
@@ -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
|
-
*
|
|
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
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
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
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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
|
|
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
|
-
|
|
178
|
-
const { data:
|
|
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',
|
|
83
|
+
.eq('type_id', tagTypeId)
|
|
182
84
|
.eq('data->>slug', slug)
|
|
183
|
-
.
|
|
184
|
-
|
|
185
|
-
if (
|
|
186
|
-
|
|
187
|
-
|
|
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:
|
|
92
|
+
type_id: tagTypeId,
|
|
212
93
|
account_id: accountId,
|
|
213
94
|
title: name,
|
|
214
|
-
description: purpose,
|
|
215
|
-
|
|
216
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
|
417
|
-
const
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
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
|
-
//
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
const
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
const
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
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
|
-
//
|
|
458
|
-
const
|
|
459
|
-
const
|
|
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
|
|
464
|
-
|
|
197
|
+
const tagId = await upsertTag(tagData, account_id, tagTypeId)
|
|
198
|
+
if (tagId) tagIds.push(tagId)
|
|
465
199
|
} catch (err) {
|
|
466
|
-
console.error(
|
|
200
|
+
console.error('Tag upsert failed:', tagData.slug, err)
|
|
467
201
|
}
|
|
468
202
|
}
|
|
469
203
|
|
|
470
|
-
// Create
|
|
471
|
-
const
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
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
|
-
//
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
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
|
-
|
|
491
|
-
|
|
492
|
-
|
|
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
|
-
|
|
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
|
})
|