import { createHandler } from './_shared/middleware' import { adminDb } from './_shared/db' import { generate } from './embeddings' import { create } from './admin-data' // Resolve platform account ID at runtime (slug: 'spine-system') let _platformAccountId: string | null = null async function getPlatformAccountId(): Promise { if (_platformAccountId) return _platformAccountId const { data } = await adminDb.from('accounts').select('id').eq('slug', 'spine-system').maybeSingle() _platformAccountId = data?.id ?? null return _platformAccountId } // Delegate embedding generation to core embeddings.generate // vectorTypes is accepted for API compatibility but ignored — core decides vector types from type design_schema async function handleGenerateEmbeddings( ctx: any, itemId: string, vectorTypes: string[], forceRegenerate: boolean = false, overrideAccountId?: string ): Promise<{ success: boolean; embeddings_created: number; embeddings_updated: number; errors: string[] }> { try { await generate(ctx, { item_id: itemId, force_regenerate: forceRegenerate, account_id_override: overrideAccountId }) return { success: true, embeddings_created: 1, embeddings_updated: 0, errors: [] } } catch (error) { return { success: false, embeddings_created: 0, embeddings_updated: 0, errors: [error instanceof Error ? error.message : String(error)] } } } /** * Creates a draft KB article from a resolved ticket's case analysis fields. * Called automatically when a ticket is resolved (via case analysis completion). * * Does NOT embed the draft — embeddings are only created when the article is * published (the DB trigger handles this automatically). * * Returns the created KB article item, or the existing draft if one already exists. */ export async function embedResolvedCase(ctx: any, ticketId: string, accountId: string): Promise { // Load ticket data fields const { data: ticket } = await adminDb .from('items') .select('id, title, data') .eq('id', ticketId) .single() if (!ticket) return null // Check if a draft kb_article for this ticket already exists const { data: existing } = await adminDb .from('items') .select('id') .eq('data->>source_ticket_id', ticketId) .eq('status', 'draft') .maybeSingle() if (existing) return existing // Create a draft kb_article — embeddings generated automatically on publish return await create(ctx, { entity: 'items', type_slug: 'kb_article', status: 'draft', title: 'KB Draft: ' + (ticket.title || 'Resolved Case'), data: { kb_type: 'postmortem', intent: ticket.data?.ca_reported_issue, cause: ticket.data?.ca_true_problem, procedure: ticket.data?.ca_solution_steps, outcome: ticket.data?.ca_final_solution, context: ticket.data?.ca_diagnostic_steps, error_signatures: ticket.data?.ca_analysis_tags, source_ticket_id: ticketId, automation_potential: ticket.data?.ca_automation_potential, } }) } // Generate embedding vector for a query string via OpenAI async function generateQueryEmbedding(text: string): Promise { const apiKey = process.env.OPENAI_API_KEY || process.env.LLM_API_KEY const baseUrl = process.env.OPENAI_BASE_URL || 'https://api.openai.com/v1' if (!apiKey) { throw new Error('No OPENAI_API_KEY configured — vector search unavailable') } const res = await fetch(`${baseUrl}/embeddings`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` }, body: JSON.stringify({ model: 'text-embedding-3-small', input: text, }), }) if (!res.ok) { const err = await res.text() throw new Error(`OpenAI embeddings error ${res.status}: ${err.slice(0, 200)}`) } const result: any = await res.json() return result.data?.[0]?.embedding || [] } // Platform KB account const KB_PLATFORM_ACCOUNT_ID = '12acec9b-8451-40e7-80d5-e80c4e2fc0de' // Search embeddings via vector similarity async function handleSearchEmbeddings( query: string, accountId: string | null, vectorType: string = 'semantic', limit: number = 8 ): Promise { if (!query || query.trim().length < 2) return [] // Generate embedding for the search query const queryEmbedding = await generateQueryEmbedding(query.trim()) // Build account filter: user's account + platform KB const accountIds = accountId ? [accountId, KB_PLATFORM_ACCOUNT_ID] : [KB_PLATFORM_ACCOUNT_ID] // Call the match_embeddings RPC — fetch extra to account for chunk deduplication const { data: matches, error } = await adminDb.rpc('match_embeddings', { query_embedding: queryEmbedding, match_count: limit * 3, filter_account_ids: accountIds, filter_vector_type: vectorType, similarity_threshold: 0.15, }) if (error) throw new Error(`Vector search failed: ${error.message}`) // Filter by similarity threshold + restricted cross-account results const SIMILARITY_THRESHOLD = 0.15 const filtered = (matches || []).filter((r: any) => { if (r.similarity < SIMILARITY_THRESHOLD) return false if (r.account_id === accountId) return true return r.metadata?.security_level !== 'restricted' }) // Deduplicate by document_id — keep only the best-matching chunk per article const bestByDoc = new Map() for (const r of filtered) { const existing = bestByDoc.get(r.document_id) if (!existing || r.similarity > existing.similarity) { bestByDoc.set(r.document_id, r) } } const deduped = [...bestByDoc.values()] .sort((a, b) => b.similarity - a.similarity) .slice(0, limit) // Enrich with item title from the items table const docIds = deduped.map((r: any) => r.document_id) if (docIds.length === 0) return [] const { data: items } = await adminDb .from('items') .select('id, title, description, status, data') .in('id', docIds) const itemMap = new Map((items || []).map((i: any) => [i.id, i])) return deduped.map((r: any) => { const item = itemMap.get(r.document_id) return { id: r.document_id, title: item?.title || '', description: item?.description || '', status: item?.status || '', data: item?.data || {}, similarity: r.similarity, matched_section: r.metadata?.section_path || null, } }) } // Delete embeddings for an item async function handleDeleteEmbeddings(itemId: string): Promise<{ deleted: number }> { const { data, error } = await adminDb .from('embeddings') .delete() .eq('document_id', itemId) .eq('metadata->>item_type', 'kb_article') if (error) throw error return { deleted: data?.length || 0 } } export const handler = createHandler(async (ctx: any, body: any) => { const { action } = ctx.query || {} const method = ctx.query?.method || 'POST' switch (action) { case 'generate': if (method === 'POST') { const ids: string[] = body.item_ids || (body.item_id ? [body.item_id] : []) let totalCreated = 0, totalUpdated = 0 const allErrors: string[] = [] for (const id of ids) { const r = await handleGenerateEmbeddings(ctx, id, body.vector_types, body.force_regenerate) totalCreated += r.embeddings_created totalUpdated += r.embeddings_updated allErrors.push(...r.errors) } return { success: allErrors.length === 0 || totalCreated > 0 || totalUpdated > 0, embeddings_created: totalCreated, embeddings_updated: totalUpdated, errors: allErrors } } break case 'search': if (method === 'GET' || method === 'POST') { const q = body.query || ctx.query?.q || '' const acctId = body.account_id || ctx.principal?.account_id || null return await handleSearchEmbeddings( q, acctId, body.vector_type || 'semantic', body.limit || 8 ) } break case 'delete': if (method === 'DELETE' || method === 'POST') { return await handleDeleteEmbeddings(body.item_id) } break default: if (method === 'POST') { const ids: string[] = body.item_ids || (body.item_id ? [body.item_id] : []) let totalCreated = 0, totalUpdated = 0 const allErrors: string[] = [] for (const id of ids) { const r = await handleGenerateEmbeddings(ctx, id, body.vector_types, body.force_regenerate) totalCreated += r.embeddings_created totalUpdated += r.embeddings_updated allErrors.push(...r.errors) } return { success: allErrors.length === 0 || totalCreated > 0 || totalUpdated > 0, embeddings_created: totalCreated, embeddings_updated: totalUpdated, errors: allErrors } } } throw new Error('Invalid action or method') })