spine-framework-cortex 0.2.19 → 0.2.21

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,472 @@
1
+ import { createHandler } from './_shared/middleware'
2
+ import { adminDb } from './_shared/db'
3
+ import { chunkArticle, htmlToPlainText } from './custom_kb-chunker'
4
+
5
+ interface KBEmbeddingRequest {
6
+ item_id: string
7
+ vector_types: ('semantic' | 'structure' | 'code')[]
8
+ force_regenerate?: boolean
9
+ }
10
+
11
+ interface KBEmbeddingResponse {
12
+ success: boolean
13
+ embeddings_created: number
14
+ embeddings_updated: number
15
+ errors: string[]
16
+ }
17
+
18
+ // Generate semantic embedding (full content)
19
+ function decodeHtmlContent(html: string): string {
20
+ return html
21
+ .replace(/<[^>]+>/g, ' ')
22
+ .replace(/&lt;/g, '<')
23
+ .replace(/&gt;/g, '>')
24
+ .replace(/&amp;/g, '&')
25
+ .replace(/&quot;/g, '"')
26
+ .replace(/&#39;/g, "'")
27
+ .replace(/\s+/g, ' ')
28
+ .trim()
29
+ }
30
+
31
+ // Build semantic content from structured code_metadata — developer understanding focus
32
+ function buildSemanticContent(item: any): string {
33
+ const cm = item.data?.code_metadata || {}
34
+ const inputs = cm.inputs && Object.keys(cm.inputs).length > 0
35
+ ? 'Parameters: ' + Object.entries(cm.inputs).map(([k, v]) => `${k} (${v})`).join(', ')
36
+ : ''
37
+ const deps = cm.depends_on && cm.depends_on.length > 0
38
+ ? 'Depends on: ' + cm.depends_on.join(', ')
39
+ : ''
40
+ const sideEffects = cm.side_effects && cm.side_effects.length > 0
41
+ ? 'Side effects: ' + cm.side_effects.join(', ')
42
+ : ''
43
+
44
+ return [
45
+ cm.identifier || item.title || '',
46
+ cm.macro || '',
47
+ cm.micro || '',
48
+ inputs,
49
+ cm.outputs ? `Returns: ${cm.outputs}` : '',
50
+ deps,
51
+ sideEffects,
52
+ `Tags: ${(item.data?.tags || []).join(', ')}`,
53
+ cm.file_path ? `File: ${cm.file_path} lines ${cm.line_start}-${cm.line_end}` : ''
54
+ ].filter(Boolean).join('\n')
55
+ }
56
+
57
+ // Build structure content from metadata — for filtering/faceted search
58
+ function buildStructureContent(item: any): string {
59
+ const cm = item.data?.code_metadata || {}
60
+ return JSON.stringify({
61
+ identifier: cm.identifier || '',
62
+ chunk_type: cm.chunk_type || '',
63
+ file_path: cm.file_path || '',
64
+ line_start: cm.line_start,
65
+ line_end: cm.line_end,
66
+ version: cm.version || '',
67
+ tags: item.data?.tags || [],
68
+ depends_on: cm.depends_on || [],
69
+ depended_by: cm.depended_by || [],
70
+ inputs: Object.keys(cm.inputs || {}),
71
+ has_side_effects: (cm.side_effects || []).length > 0
72
+ })
73
+ }
74
+
75
+ // Generate real embedding vector via OpenAI text-embedding-3-small (single input)
76
+ async function generateEmbeddingVector(content: string): Promise<string> {
77
+ const vectors = await generateEmbeddingBatch([content])
78
+ return vectors[0]
79
+ }
80
+
81
+ // Batch-generate embedding vectors — one API call for multiple inputs
82
+ async function generateEmbeddingBatch(inputs: string[]): Promise<string[]> {
83
+ const apiKey = process.env.OPENAI_API_KEY || process.env.LLM_API_KEY
84
+ const baseUrl = process.env.OPENAI_BASE_URL || 'https://api.openai.com/v1'
85
+
86
+ if (!apiKey) {
87
+ throw new Error('No OPENAI_API_KEY configured — cannot generate embeddings')
88
+ }
89
+
90
+ if (inputs.length === 0) return []
91
+
92
+ const res = await fetch(`${baseUrl}/embeddings`, {
93
+ method: 'POST',
94
+ headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` },
95
+ body: JSON.stringify({
96
+ model: 'text-embedding-3-small',
97
+ input: inputs,
98
+ }),
99
+ })
100
+
101
+ if (!res.ok) {
102
+ const err = await res.text()
103
+ throw new Error(`OpenAI embeddings error ${res.status}: ${err.slice(0, 200)}`)
104
+ }
105
+
106
+ const result: any = await res.json()
107
+ // OpenAI returns embeddings sorted by index
108
+ const sorted = (result.data || []).sort((a: any, b: any) => a.index - b.index)
109
+ return sorted.map((d: any) => `[${d.embedding.join(',')}]`)
110
+ }
111
+
112
+ // Insert an embedding record (used during batch writes)
113
+ async function insertEmbedding(
114
+ itemId: string,
115
+ vectorType: string,
116
+ chunkIndex: number,
117
+ content: string,
118
+ embedding: string,
119
+ metadata: any
120
+ ): Promise<void> {
121
+ await adminDb
122
+ .from('embeddings')
123
+ .insert({
124
+ account_id: '12acec9b-8451-40e7-80d5-e80c4e2fc0de', // Master account
125
+ model_id: 'text-embedding-3-small',
126
+ document_id: itemId,
127
+ chunk_index: chunkIndex,
128
+ content: content,
129
+ embedding: embedding,
130
+ metadata: {
131
+ vector_type: vectorType,
132
+ item_type: 'kb_article',
133
+ ...metadata
134
+ }
135
+ })
136
+ }
137
+
138
+ // Delete all existing embeddings for a document (called before re-embedding)
139
+ async function deleteExistingEmbeddings(itemId: string): Promise<number> {
140
+ const { data } = await adminDb
141
+ .from('embeddings')
142
+ .delete()
143
+ .eq('document_id', itemId)
144
+ .eq('metadata->>item_type', 'kb_article')
145
+ .select('id')
146
+
147
+ return data?.length || 0
148
+ }
149
+
150
+ // Build article-level structure content for non-code articles
151
+ function buildArticleStructureContent(item: any): string {
152
+ return JSON.stringify({
153
+ title: item.title || '',
154
+ kb_type: item.data?.kb_type || '',
155
+ category: item.data?.category || '',
156
+ tags: item.data?.tags || [],
157
+ audience: item.data?.audience || [],
158
+ security_level: item.data?.security_level || '',
159
+ priority: item.data?.priority || '',
160
+ })
161
+ }
162
+
163
+ // Determine if an item is a code chunk (ingested) vs a manual article
164
+ function isCodeChunk(item: any): boolean {
165
+ return !!(item.data?.code_metadata?.chunk_id || item.data?.code_metadata?.file_path)
166
+ }
167
+
168
+ // Main handler for generating KB embeddings
169
+ async function handleGenerateEmbeddings(
170
+ itemId: string,
171
+ vectorTypes: string[],
172
+ forceRegenerate: boolean = false
173
+ ): Promise<KBEmbeddingResponse> {
174
+ const response: KBEmbeddingResponse = {
175
+ success: true,
176
+ embeddings_created: 0,
177
+ embeddings_updated: 0,
178
+ errors: []
179
+ }
180
+
181
+ try {
182
+ // Get the KB item
183
+ const { data: item, error: itemError } = await adminDb
184
+ .from('items')
185
+ .select('*')
186
+ .eq('id', itemId)
187
+ .eq('type_id', 'ce1e50b6-473e-4581-ba0c-e944f47cb240') // kb_article type
188
+ .single()
189
+
190
+ if (itemError || !item) {
191
+ throw new Error('KB item not found')
192
+ }
193
+
194
+ // Check if embeddings already exist
195
+ if (!forceRegenerate) {
196
+ const { data: existing } = await adminDb
197
+ .from('embeddings')
198
+ .select('id')
199
+ .eq('document_id', itemId)
200
+ .eq('metadata->>item_type', 'kb_article')
201
+ .limit(1)
202
+
203
+ if (existing && existing.length > 0) {
204
+ response.errors.push('Embeddings already exist (use force_regenerate to override)')
205
+ response.success = false
206
+ return response
207
+ }
208
+ }
209
+
210
+ // Delete existing embeddings before regenerating
211
+ const deleted = await deleteExistingEmbeddings(itemId)
212
+ if (deleted > 0) {
213
+ response.embeddings_updated = deleted
214
+ }
215
+
216
+ // ── Code chunks: legacy path (single semantic + single structure) ──
217
+ if (isCodeChunk(item)) {
218
+ const semanticContent = buildSemanticContent(item)
219
+ const structureContent = buildStructureContent(item)
220
+
221
+ const vectors = await generateEmbeddingBatch([semanticContent, structureContent])
222
+
223
+ await Promise.all([
224
+ insertEmbedding(itemId, 'semantic', 0, semanticContent, vectors[0], {
225
+ kb_type: item.data?.kb_type,
226
+ chunk_id: item.data?.code_metadata?.chunk_id,
227
+ chunk_type: item.data?.code_metadata?.chunk_type,
228
+ file_path: item.data?.code_metadata?.file_path,
229
+ }),
230
+ insertEmbedding(itemId, 'structure', 0, structureContent, vectors[1], {
231
+ kb_type: item.data?.kb_type,
232
+ tags: item.data?.tags,
233
+ file_path: item.data?.code_metadata?.file_path,
234
+ depends_on: item.data?.code_metadata?.depends_on,
235
+ }),
236
+ ])
237
+
238
+ response.embeddings_created = 2
239
+ return response
240
+ }
241
+
242
+ // ── Article path: chunk → batch embed → parallel write ──
243
+ const articleContent = item.description || ''
244
+ const chunks = chunkArticle(articleContent, {
245
+ articleTitle: item.title || 'Untitled',
246
+ })
247
+
248
+ // Build all texts to embed: N semantic chunks + 1 structure
249
+ const structureContent = buildArticleStructureContent(item)
250
+ const allTexts = [...chunks.map(c => c.content), structureContent]
251
+
252
+ // Single batched API call to OpenAI
253
+ const allVectors = await generateEmbeddingBatch(allTexts)
254
+
255
+ // Parallel DB writes
256
+ const writePromises: Promise<void>[] = []
257
+
258
+ // Semantic embeddings — one per chunk
259
+ for (let i = 0; i < chunks.length; i++) {
260
+ writePromises.push(
261
+ insertEmbedding(itemId, 'semantic', i, chunks[i].content, allVectors[i], {
262
+ kb_type: item.data?.kb_type,
263
+ chunk_index: i,
264
+ chunk_total: chunks.length,
265
+ section_path: chunks[i].sectionPath,
266
+ })
267
+ )
268
+ }
269
+
270
+ // Structure embedding — article-level, single vector
271
+ writePromises.push(
272
+ insertEmbedding(itemId, 'structure', 0, structureContent, allVectors[chunks.length], {
273
+ kb_type: item.data?.kb_type,
274
+ tags: item.data?.tags,
275
+ category: item.data?.category,
276
+ audience: item.data?.audience,
277
+ })
278
+ )
279
+
280
+ await Promise.all(writePromises)
281
+ response.embeddings_created = chunks.length + 1
282
+
283
+ } catch (error) {
284
+ response.success = false
285
+ response.errors = [error instanceof Error ? error.message : String(error)]
286
+ }
287
+
288
+ return response
289
+ }
290
+
291
+ // Generate embedding vector for a query string via OpenAI
292
+ async function generateQueryEmbedding(text: string): Promise<number[]> {
293
+ const apiKey = process.env.OPENAI_API_KEY || process.env.LLM_API_KEY
294
+ const baseUrl = process.env.OPENAI_BASE_URL || 'https://api.openai.com/v1'
295
+
296
+ if (!apiKey) {
297
+ throw new Error('No OPENAI_API_KEY configured — vector search unavailable')
298
+ }
299
+
300
+ const res = await fetch(`${baseUrl}/embeddings`, {
301
+ method: 'POST',
302
+ headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` },
303
+ body: JSON.stringify({
304
+ model: 'text-embedding-3-small',
305
+ input: text,
306
+ }),
307
+ })
308
+
309
+ if (!res.ok) {
310
+ const err = await res.text()
311
+ throw new Error(`OpenAI embeddings error ${res.status}: ${err.slice(0, 200)}`)
312
+ }
313
+
314
+ const result: any = await res.json()
315
+ return result.data?.[0]?.embedding || []
316
+ }
317
+
318
+ // Platform KB account
319
+ const KB_PLATFORM_ACCOUNT_ID = '12acec9b-8451-40e7-80d5-e80c4e2fc0de'
320
+
321
+ // Search embeddings via vector similarity
322
+ async function handleSearchEmbeddings(
323
+ query: string,
324
+ accountId: string | null,
325
+ vectorType: string = 'semantic',
326
+ limit: number = 8
327
+ ): Promise<any[]> {
328
+ if (!query || query.trim().length < 2) return []
329
+
330
+ // Generate embedding for the search query
331
+ const queryEmbedding = await generateQueryEmbedding(query.trim())
332
+
333
+ // Build account filter: user's account + platform KB
334
+ const accountIds = accountId
335
+ ? [accountId, KB_PLATFORM_ACCOUNT_ID]
336
+ : [KB_PLATFORM_ACCOUNT_ID]
337
+
338
+ // Call the match_embeddings RPC — fetch extra to account for chunk deduplication
339
+ const { data: matches, error } = await adminDb.rpc('match_embeddings', {
340
+ query_embedding: queryEmbedding,
341
+ match_count: limit * 3,
342
+ filter_account_ids: accountIds,
343
+ filter_vector_type: vectorType,
344
+ similarity_threshold: 0.15,
345
+ })
346
+
347
+ if (error) throw new Error(`Vector search failed: ${error.message}`)
348
+
349
+ // Filter by similarity threshold + restricted cross-account results
350
+ const SIMILARITY_THRESHOLD = 0.15
351
+ const filtered = (matches || []).filter((r: any) => {
352
+ if (r.similarity < SIMILARITY_THRESHOLD) return false
353
+ if (r.account_id === accountId) return true
354
+ return r.metadata?.security_level !== 'restricted'
355
+ })
356
+
357
+ // Deduplicate by document_id — keep only the best-matching chunk per article
358
+ const bestByDoc = new Map<string, any>()
359
+ for (const r of filtered) {
360
+ const existing = bestByDoc.get(r.document_id)
361
+ if (!existing || r.similarity > existing.similarity) {
362
+ bestByDoc.set(r.document_id, r)
363
+ }
364
+ }
365
+ const deduped = [...bestByDoc.values()]
366
+ .sort((a, b) => b.similarity - a.similarity)
367
+ .slice(0, limit)
368
+
369
+ // Enrich with item title from the items table
370
+ const docIds = deduped.map((r: any) => r.document_id)
371
+ if (docIds.length === 0) return []
372
+
373
+ const { data: items } = await adminDb
374
+ .from('items')
375
+ .select('id, title, description, status, data')
376
+ .in('id', docIds)
377
+
378
+ const itemMap = new Map((items || []).map((i: any) => [i.id, i]))
379
+
380
+ return deduped.map((r: any) => {
381
+ const item = itemMap.get(r.document_id)
382
+ return {
383
+ id: r.document_id,
384
+ title: item?.title || '',
385
+ description: item?.description || '',
386
+ status: item?.status || '',
387
+ data: item?.data || {},
388
+ similarity: r.similarity,
389
+ matched_section: r.metadata?.section_path || null,
390
+ }
391
+ })
392
+ }
393
+
394
+ // Delete embeddings for an item
395
+ async function handleDeleteEmbeddings(itemId: string): Promise<{ deleted: number }> {
396
+ const { data, error } = await adminDb
397
+ .from('embeddings')
398
+ .delete()
399
+ .eq('document_id', itemId)
400
+ .eq('metadata->>item_type', 'kb_article')
401
+
402
+ if (error) throw error
403
+
404
+ return { deleted: data?.length || 0 }
405
+ }
406
+
407
+ export const handler = createHandler(async (ctx: any, body: any) => {
408
+ const { action } = ctx.query || {}
409
+ const method = ctx.query?.method || 'POST'
410
+
411
+ switch (action) {
412
+ case 'generate':
413
+ if (method === 'POST') {
414
+ const ids: string[] = body.item_ids || (body.item_id ? [body.item_id] : [])
415
+ let totalCreated = 0, totalUpdated = 0
416
+ const allErrors: string[] = []
417
+ for (const id of ids) {
418
+ const r = await handleGenerateEmbeddings(id, body.vector_types, body.force_regenerate)
419
+ totalCreated += r.embeddings_created
420
+ totalUpdated += r.embeddings_updated
421
+ allErrors.push(...r.errors)
422
+ }
423
+ return {
424
+ success: allErrors.length === 0 || totalCreated > 0 || totalUpdated > 0,
425
+ embeddings_created: totalCreated,
426
+ embeddings_updated: totalUpdated,
427
+ errors: allErrors
428
+ }
429
+ }
430
+ break
431
+
432
+ case 'search':
433
+ if (method === 'GET' || method === 'POST') {
434
+ const q = body.query || ctx.query?.q || ''
435
+ const acctId = body.account_id || ctx.principal?.account_id || null
436
+ return await handleSearchEmbeddings(
437
+ q,
438
+ acctId,
439
+ body.vector_type || 'semantic',
440
+ body.limit || 8
441
+ )
442
+ }
443
+ break
444
+
445
+ case 'delete':
446
+ if (method === 'DELETE' || method === 'POST') {
447
+ return await handleDeleteEmbeddings(body.item_id)
448
+ }
449
+ break
450
+
451
+ default:
452
+ if (method === 'POST') {
453
+ const ids: string[] = body.item_ids || (body.item_id ? [body.item_id] : [])
454
+ let totalCreated = 0, totalUpdated = 0
455
+ const allErrors: string[] = []
456
+ for (const id of ids) {
457
+ const r = await handleGenerateEmbeddings(id, body.vector_types, body.force_regenerate)
458
+ totalCreated += r.embeddings_created
459
+ totalUpdated += r.embeddings_updated
460
+ allErrors.push(...r.errors)
461
+ }
462
+ return {
463
+ success: allErrors.length === 0 || totalCreated > 0 || totalUpdated > 0,
464
+ embeddings_created: totalCreated,
465
+ embeddings_updated: totalUpdated,
466
+ errors: allErrors
467
+ }
468
+ }
469
+ }
470
+
471
+ throw new Error('Invalid action or method')
472
+ })