import { createHandler } from './_shared/middleware' import { adminDb } from './_shared/db' import { resolveTypeId, resolveAgentId, resolvePromptConfigId, resolveLinkTypeId } from './_shared/resolve-ids' import { runAgent } from './_shared/index' import { create } from './admin-data' import { embedResolvedCase } from './custom_kb-embeddings' import { createInteraction } from './ai-interactions' /** * @module custom_case_analysis * @layer custom/cortex * @stability custom * * Post-resolution case analysis. Triggered when a support ticket is resolved. * * Action: POST ?action=analyze_ticket * * Process: * 1. Load ticket + all thread messages (external + internal + solution AI) * 2. Create a temporary internal analysis thread wired to Case Resolution Analysis Agent * 3. Call runAgent() with the full case context — agent returns structured JSON * 4. Parse JSON from agent message content * 5. Upsert tags, create case_analysis item, update ticket with ca_ fields * 6. Trigger embedResolvedCase() to embed for future RAG * * The analysis thread persists as an audit trail of the AI's reasoning. */ // ─── TYPES ──────────────────────────────────────────────────────────────────── interface AnalysisRequest { ticket_id: string } // ─── HELPERS ────────────────────────────────────────────────────────────────── async function loadConversationContext(ticketId: string): Promise<{ externalMessages: any[] internalMessages: any[] solutionMessages: any[] }> { const { data: threads } = await adminDb .from('threads') .select('id, visibility, data') .eq('target_type', 'items') .eq('target_id', ticketId) const result = { externalMessages: [], internalMessages: [], solutionMessages: [] } as any for (const thread of threads || []) { const { data: msgs } = await adminDb .from('messages') .select('content, direction, visibility, created_at') .eq('thread_id', thread.id) .eq('visibility', thread.data?.thread_purpose === 'solution_ai' ? 'internal' : 'public') .order('created_at', { ascending: true }) .limit(100) const messages = msgs || [] if (thread.data?.thread_purpose === 'solution_ai') { result.solutionMessages = messages } else if (thread.visibility === 'internal') { result.internalMessages = messages } else { result.externalMessages = messages } } return result } function formatMessages(msgs: any[], label: string): string { if (!msgs.length) return '' return `\n\n## ${label}\n` + msgs.map(m => { const role = m.direction === 'inbound' ? 'Customer' : 'Agent' return `${role}: ${m.content}` }).join('\n') } async function upsertTag(tagData: any, accountId: string, tagTypeId: string): Promise { const { slug, name, purpose, category, applicable_to } = tagData if (!slug || !name) return null const { data: existing } = await adminDb .from('items') .select('id') .eq('type_id', tagTypeId) .eq('data->>slug', slug) .maybeSingle() if (existing) return existing.id const { data: newTag } = await adminDb .from('items') .insert({ type_id: tagTypeId, account_id: accountId, title: name, description: purpose || '', status: 'active', data: { slug, name, purpose, applicable_to: applicable_to || ['ticket'], category, usage_count: 1 } }) .select('id') .single() return newTag?.id ?? null } // ─── ACTION: ANALYZE TICKET ─────────────────────────────────────────────────── async function handleAnalyzeTicket(ctx: any, body: AnalysisRequest) { const { ticket_id } = body const account_id = ctx.accountId as string if (!ticket_id) throw new Error('ticket_id is required') if (!account_id) throw new Error('Account context required') // Load ticket const { data: ticket, error: ticketErr } = await adminDb .from('items') .select('*') .eq('id', ticket_id) .single() if (ticketErr || !ticket) throw new Error(`Ticket not found: ${ticket_id}`) if (ticket.status !== 'resolved') throw new Error('Ticket must be resolved before analysis') // Resolve IDs const [threadTypeId, analysisAgentId, promptConfigId, caseAnalysisTypeId, tagTypeId] = await Promise.all([ resolveTypeId('thread', 'support_thread'), resolveAgentId('Case Resolution Analysis Agent'), resolvePromptConfigId('case_analysis_prompt'), resolveTypeId('item', 'case_analysis'), resolveTypeId('item', 'tag'), ]) // Load full conversation context (external + internal + solution AI) const { externalMessages, internalMessages, solutionMessages } = await loadConversationContext(ticket_id) // Build the analysis prompt as the user message to the agent. // The agent's context_template handles placeholders; we pass everything here. const conversationText = formatMessages(externalMessages, 'Customer Conversation') + formatMessages(internalMessages, 'Internal Support Notes') + formatMessages(solutionMessages, 'Solution AI Collaboration') const analysisPrompt = [ `Ticket: ${ticket.title}`, `Description: ${ticket.description || ''}`, `Created: ${ticket.created_at}`, `Resolved: ${ticket.updated_at}`, `Status: ${ticket.status}`, `Priority: ${ticket.data?.priority || 'medium'}`, conversationText, '\nProduce the full structured postmortem JSON.' ].join('\n') // Create a dedicated analysis thread — persists as audit trail const analysisThread = await create(ctx, { entity: 'threads', type_id: threadTypeId, target_type: 'items', target_id: ticket_id, visibility: 'internal', status: 'active', data: { thread_purpose: 'case_analysis', agent_id: analysisAgentId, prompt_config_id: promptConfigId, } }) if (!analysisThread?.id) throw new Error('Failed to create analysis thread') // Run the agent — saves messages, handles RAG (none needed here), returns message row const agentMsg = await runAgent(analysisThread.id, analysisPrompt, ctx) // Parse structured JSON from agent response let envelope: any = {} try { const raw = agentMsg?.content || '{}' // Strip code fences if the model wrapped it const cleaned = raw.replace(/^```(?:json)?\s*/i, '').replace(/\s*```$/, '').trim() envelope = JSON.parse(cleaned) } catch { console.error('Case analysis: failed to parse agent JSON response') envelope = { reported_issue: ticket.description || '', true_problem: 'Analysis parse failure — see raw agent message', diagnostic_steps: [], solution_steps: [], final_solution: '', confidence_score: 0.1, kb_candidate: false, suggested_tags: [], analysis_summary: 'Structured analysis unavailable — agent response was not valid JSON.' } } // Upsert tags const tagIds: string[] = [] for (const tagData of envelope.suggested_tags || []) { try { const tagId = await upsertTag(tagData, account_id, tagTypeId) if (tagId) tagIds.push(tagId) } catch (err) { console.error('Tag upsert failed:', tagData.slug, err) } } // Create case_analysis item const { data: caseAnalysis } = await adminDb .from('items') .insert({ type_id: caseAnalysisTypeId, account_id, title: `Analysis: ${ticket.title}`, status: 'completed', data: { ticket_id, analysis_data: envelope, confidence_score: envelope.confidence_score || 0.5, analysis_timestamp: new Date().toISOString(), ai_agent_id: analysisAgentId, analysis_thread_id: analysisThread.id, } }) .select('id') .single() // Link case_analysis → ticket if (caseAnalysis?.id) { const analyzedByLinkTypeId = await resolveLinkTypeId('analyzed_by').catch(() => null) if (analyzedByLinkTypeId) { const linkTypeItemId = await resolveTypeId('item', 'link').catch(() => null) if (linkTypeItemId) { await adminDb.from('links').upsert({ type_id: linkTypeItemId, link_type_id: analyzedByLinkTypeId, account_id, source_type: 'items', source_id: caseAnalysis.id, target_type: 'items', target_id: ticket_id, link_type: 'analyzed_by', created_at: new Date().toISOString(), updated_at: new Date().toISOString(), }, { onConflict: 'source_id,target_id,link_type_id' }) } } } // Update ticket with ca_ fields const { data: currentTicket } = await adminDb.from('items').select('data').eq('id', ticket_id).single() await adminDb.from('items').update({ data: { ...(currentTicket?.data || {}), ca_reported_issue: envelope.reported_issue, ca_true_problem: envelope.true_problem, ca_final_solution: envelope.final_solution, ca_diagnostic_steps: envelope.diagnostic_steps, ca_solution_steps: envelope.solution_steps, ca_analysis_tags: tagIds, ca_time_to_resolution: envelope.time_to_resolution, ca_back_and_forth_count: envelope.back_and_forth_count, ca_escalation_required: envelope.escalation_required, ca_automation_potential: envelope.automation_potential, ca_customer_temperature: envelope.customer_temperature, ca_kb_candidate: envelope.kb_candidate, ca_sentiment_progression: envelope.sentiment_progression, aim_resolved_by_ai: (ticket.data?.aim_escalation_reason === 'none' || !ticket.data?.aim_escalation_reason), analysis_timestamp: new Date().toISOString(), }, updated_at: new Date().toISOString(), }).eq('id', ticket_id) // Create a draft KB article from the resolved case (best-effort, non-blocking) // Embeddings are generated automatically when the draft is published embedResolvedCase(ctx, ticket_id, account_id).catch(err => console.error('embedResolvedCase failed (non-fatal):', err) ) // Record an AI interaction for this analysis (best-effort, non-blocking) createInteraction(ctx, { thread_id: analysisThread.id, kb_candidate: envelope.kb_candidate }).catch(err => console.error('createInteraction failed (non-fatal):', err) ) return { ticketId: ticket_id, caseAnalysisId: caseAnalysis?.id, analysisThreadId: analysisThread.id, confidence: envelope.confidence_score || 0.5, kbCandidate: envelope.kb_candidate, createdTags: tagIds.length, } } // ─── HANDLER ────────────────────────────────────────────────────────────────── export const handler = createHandler(async (ctx, body) => { const action = (ctx as any).query?.action switch (action) { case 'analyze_ticket': return handleAnalyzeTicket(ctx, body as AnalysisRequest) default: throw new Error(`Unknown action: ${action}. Use 'analyze_ticket'.`) } })