spine-framework-cortex 0.2.20 → 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.
@@ -0,0 +1,292 @@
1
+ import { createHandler } from './_shared/middleware'
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'
7
+
8
+ /**
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.
14
+ *
15
+ * Action: POST ?action=analyze_ticket
16
+ *
17
+ * Process:
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.
26
+ */
27
+
28
+ // ─── TYPES ────────────────────────────────────────────────────────────────────
29
+
30
+ interface AnalysisRequest { ticket_id: string }
31
+
32
+ // ─── HELPERS ──────────────────────────────────────────────────────────────────
33
+
34
+ async function loadConversationContext(ticketId: string): Promise<{
35
+ externalMessages: any[]
36
+ internalMessages: any[]
37
+ solutionMessages: any[]
38
+ }> {
39
+ const { data: threads } = await adminDb
40
+ .from('threads')
41
+ .select('id, visibility, data')
42
+ .eq('target_type', 'items')
43
+ .eq('target_id', ticketId)
44
+
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
63
+ }
64
+ }
65
+ return result
66
+ }
67
+
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')
74
+ }
75
+
76
+ async function upsertTag(tagData: any, accountId: string, tagTypeId: string): Promise<string | null> {
77
+ const { slug, name, purpose, category, applicable_to } = tagData
78
+ if (!slug || !name) return null
79
+
80
+ const { data: existing } = await adminDb
81
+ .from('items')
82
+ .select('id')
83
+ .eq('type_id', tagTypeId)
84
+ .eq('data->>slug', slug)
85
+ .maybeSingle()
86
+
87
+ if (existing) return existing.id
88
+
89
+ const { data: newTag } = await adminDb
90
+ .from('items')
91
+ .insert({
92
+ type_id: tagTypeId,
93
+ account_id: accountId,
94
+ title: name,
95
+ description: purpose || '',
96
+ status: 'active',
97
+ data: { slug, name, purpose, applicable_to: applicable_to || ['ticket'], category, usage_count: 1 }
98
+ })
99
+ .select('id')
100
+ .single()
101
+
102
+ return newTag?.id ?? null
103
+ }
104
+
105
+ // ─── ACTION: ANALYZE TICKET ───────────────────────────────────────────────────
106
+
107
+ async function handleAnalyzeTicket(ctx: any, body: AnalysisRequest) {
108
+ const { ticket_id } = body
109
+ const account_id = ctx.accountId as string
110
+ if (!ticket_id) throw new Error('ticket_id is required')
111
+ if (!account_id) throw new Error('Account context required')
112
+
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'),
129
+ ])
130
+
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')
167
+
168
+ // Run the agent — saves messages, handles RAG (none needed here), returns message row
169
+ const agentMsg = await runAgent(analysisThread.id, analysisPrompt, ctx)
170
+
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
+ }
192
+
193
+ // Upsert tags
194
+ const tagIds: string[] = []
195
+ for (const tagData of envelope.suggested_tags || []) {
196
+ try {
197
+ const tagId = await upsertTag(tagData, account_id, tagTypeId)
198
+ if (tagId) tagIds.push(tagId)
199
+ } catch (err) {
200
+ console.error('Tag upsert failed:', tagData.slug, err)
201
+ }
202
+ }
203
+
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()
223
+
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
+ }
244
+ }
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
+
274
+ return {
275
+ ticketId: ticket_id,
276
+ caseAnalysisId: caseAnalysis?.id,
277
+ analysisThreadId: analysisThread.id,
278
+ confidence: envelope.confidence_score || 0.5,
279
+ kbCandidate: envelope.kb_candidate,
280
+ createdTags: tagIds.length,
281
+ }
282
+ }
283
+
284
+ // ─── HANDLER ──────────────────────────────────────────────────────────────────
285
+
286
+ export const handler = createHandler(async (ctx, body) => {
287
+ const action = (ctx as any).query?.action
288
+ switch (action) {
289
+ case 'analyze_ticket': return handleAnalyzeTicket(ctx, body as AnalysisRequest)
290
+ default: throw new Error(`Unknown action: ${action}. Use 'analyze_ticket'.`)
291
+ }
292
+ })
@@ -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
+ })