spine-framework 0.3.108 → 0.3.109

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.
@@ -4,16 +4,17 @@
4
4
  * @layer api-handler
5
5
  * @stability stable
6
6
  *
7
- * CRUD and search API for the `embeddings` table. Embeddings store vector
8
- * representations of text chunks for RAG (retrieval-augmented generation)
9
- * workloads. Vector similarity search is performed via the
10
- * `search_similar_embeddings` Postgres RPC function.
7
+ * CRUD, lifecycle, and search API for the `embeddings` table. Embeddings store
8
+ * vector representations of text chunks for RAG workloads.
11
9
  *
12
10
  * **Routed by:** `GET/POST/PATCH /.netlify/functions/embeddings`
13
11
  *
14
12
  * **Actions:**
15
13
  * | method | ?action | handler |
16
14
  * |--------|-----------------|-------------------|
15
+ * | POST | generate | generate |
16
+ * | POST | revoke | revoke |
17
+ * | POST | approve | approve |
17
18
  * | GET | stats | getStats |
18
19
  * | POST | batch-create | batchCreate |
19
20
  * | POST | delete-document | deleteByDocument |
@@ -26,22 +27,208 @@
26
27
  * | PATCH | — | update |
27
28
  *
28
29
  * **Authorization:** All operations use `ctx.db` (RLS-scoped). Account context
29
- * required for writes. No hard-delete endpoint — use `delete-document` or
30
- * `cleanup` for bulk removal.
30
+ * required for writes.
31
31
  *
32
+ * INVARIANT: `generate` is the single entry point for AI-powered embedding
33
+ * creation. Custom code must call `generate` — never write to the embeddings
34
+ * table directly.
32
35
  * INVARIANT: `update` only patches `content` and `metadata`.
33
36
  * INVARIANT: `searchSimilar` requires a pre-computed `query_embedding` vector.
34
- * Use `searchSemantic` for text-based fallback.
35
37
  *
36
- * @seeAlso agent-runner.ts (executeSearchKnowledge calls search_similar_embeddings)
37
- * @seeAlso pipeline-runner.ts (search_knowledge stage type)
38
- * @seeAlso audit.ts (emitLog for embedding.* / embeddings.* events)
38
+ * @seeAlso agent-runner.ts (retrieveKnowledge calls match_embeddings RPC)
39
+ * @seeAlso pipeline-runner.ts (generate_embeddings stage type)
40
+ * @seeAlso audit.ts (emitLog for embedding.* events)
39
41
  */
40
42
 
43
+ import { createHash } from 'crypto'
41
44
  import { createHandler } from './_shared/middleware'
45
+ import { adminDb } from './_shared/db'
42
46
  import { emitLog } from './_shared/audit'
43
47
  import { sanitizeRecordData } from './_shared/permissions'
44
48
 
49
+ // ─── UNIVERSAL CONTENT BUILDERS ──────────────────────────────────────────────
50
+ // Each builder accepts an item record and returns a plain-text string to embed.
51
+ // Custom apps populate item.data with the named fields below; the builder picks
52
+ // the first non-empty value from a priority chain.
53
+
54
+ function stripHtml(html: string): string {
55
+ return html
56
+ .replace(/<[^>]+>/g, ' ')
57
+ .replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&amp;/g, '&')
58
+ .replace(/&quot;/g, '"').replace(/&#39;/g, "'").replace(/&nbsp;/g, ' ')
59
+ .replace(/\s+/g, ' ').trim()
60
+ }
61
+
62
+ function firstNonEmpty(...values: (string | undefined | null)[]): string {
63
+ for (const v of values) {
64
+ if (v && String(v).trim()) return String(v).trim()
65
+ }
66
+ return ''
67
+ }
68
+
69
+ function buildSemanticContent(item: any): string {
70
+ const title = item.title || ''
71
+ const body = stripHtml(item.description || '')
72
+ return [title, body].filter(Boolean).join('\n')
73
+ }
74
+
75
+ function buildStructureContent(item: any, embeddingFields: string[] = []): string {
76
+ const base: Record<string, any> = {
77
+ title: item.title || '',
78
+ status: item.status || '',
79
+ kb_type: item.data?.kb_type || '',
80
+ category: item.data?.category || '',
81
+ tags: item.data?.tags || [],
82
+ audience: item.data?.audience || [],
83
+ security_level: item.data?.security_level || '',
84
+ product_area: item.data?.product_area || '',
85
+ }
86
+ for (const f of embeddingFields) {
87
+ if (item.data?.[f] !== undefined) base[f] = item.data[f]
88
+ }
89
+ return JSON.stringify(base)
90
+ }
91
+
92
+ function buildIntentContent(item: any): string {
93
+ return firstNonEmpty(
94
+ item.data?.intent,
95
+ item.data?.learning_objectives,
96
+ item.data?.ca_reported_issue,
97
+ item.data?.use_case,
98
+ )
99
+ }
100
+
101
+ function buildCauseContent(item: any): string {
102
+ return firstNonEmpty(
103
+ item.data?.cause,
104
+ item.data?.ca_true_problem,
105
+ item.data?.concept,
106
+ item.data?.root_cause,
107
+ )
108
+ }
109
+
110
+ function buildProcedureContent(item: any): string {
111
+ const steps = item.data?.procedure || item.data?.ca_solution_steps || item.data?.steps
112
+ if (Array.isArray(steps) && steps.length) {
113
+ return steps.map((s: any, i: number) => `${i + 1}. ${s}`).join('\n')
114
+ }
115
+ return firstNonEmpty(item.data?.procedure, item.data?.resolution)
116
+ }
117
+
118
+ function buildOutcomeContent(item: any): string {
119
+ return firstNonEmpty(
120
+ item.data?.outcome,
121
+ item.data?.ca_final_solution,
122
+ item.data?.outcomes,
123
+ item.data?.result,
124
+ )
125
+ }
126
+
127
+ function buildContextContent(item: any): string {
128
+ const steps = item.data?.ca_diagnostic_steps
129
+ if (Array.isArray(steps) && steps.length) {
130
+ return steps.map((s: any, i: number) => `${i + 1}. ${s}`).join('\n')
131
+ }
132
+ return firstNonEmpty(
133
+ item.data?.context,
134
+ item.data?.prerequisites,
135
+ item.data?.when_to_use,
136
+ item.data?.product_area,
137
+ )
138
+ }
139
+
140
+ function buildErrorSignalContent(item: any): string {
141
+ const sigs = item.data?.error_signatures
142
+ if (Array.isArray(sigs) && sigs.length) return sigs.join('\n')
143
+ const tags = item.data?.ca_analysis_tags
144
+ if (Array.isArray(tags) && tags.length) return tags.join('\n')
145
+ // Extract code-like patterns from description
146
+ const desc = item.description || ''
147
+ const codeBlocks = desc.match(/```[\s\S]*?```/g) || []
148
+ return codeBlocks.map((b: string) => b.replace(/```\w*\n?/g, '')).join('\n').trim()
149
+ }
150
+
151
+ function buildSummaryContentSync(item: any): string {
152
+ // Sync fallback — AI-generated summary handled asynchronously in generateSummary()
153
+ const desc = stripHtml(item.description || '')
154
+ return `${item.title || ''}: ${desc.slice(0, 300)}`
155
+ }
156
+
157
+ async function generateAiSummary(item: any): Promise<string> {
158
+ const apiKey = process.env.OPENAI_API_KEY || process.env.LLM_API_KEY
159
+ const baseUrl = process.env.OPENAI_BASE_URL || 'https://api.openai.com/v1'
160
+ if (!apiKey) return buildSummaryContentSync(item)
161
+ const plain = stripHtml(item.description || '').slice(0, 3000)
162
+ try {
163
+ const res = await fetch(`${baseUrl}/chat/completions`, {
164
+ method: 'POST',
165
+ headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` },
166
+ body: JSON.stringify({
167
+ model: process.env.LLM_DEFAULT_MODEL || 'gpt-4o-mini',
168
+ temperature: 0.2,
169
+ max_tokens: 120,
170
+ messages: [
171
+ { role: 'system', content: 'Write one concise paragraph (max 100 words) summarising this knowledge base article for search indexing. Output only the paragraph.' },
172
+ { role: 'user', content: `Title: ${item.title}\n\n${plain}` },
173
+ ],
174
+ }),
175
+ })
176
+ if (!res.ok) return buildSummaryContentSync(item)
177
+ const j: any = await res.json()
178
+ return j.choices?.[0]?.message?.content?.trim() || buildSummaryContentSync(item)
179
+ } catch {
180
+ return buildSummaryContentSync(item)
181
+ }
182
+ }
183
+
184
+ async function callEmbeddingApi(inputs: string[]): Promise<string[]> {
185
+ const apiKey = process.env.OPENAI_API_KEY || process.env.LLM_API_KEY
186
+ const baseUrl = process.env.OPENAI_BASE_URL || 'https://api.openai.com/v1'
187
+ if (!apiKey || !inputs.length) return inputs.map(() => '')
188
+ const res = await fetch(`${baseUrl}/embeddings`, {
189
+ method: 'POST',
190
+ headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` },
191
+ body: JSON.stringify({ model: 'text-embedding-3-small', input: inputs }),
192
+ })
193
+ if (!res.ok) throw new Error(`Embedding API error ${res.status}`)
194
+ const j: any = await res.json()
195
+ const sorted = (j.data || []).sort((a: any, b: any) => a.index - b.index)
196
+ return sorted.map((d: any) => `[${d.embedding.join(',')}]`)
197
+ }
198
+
199
+ function contentHash(text: string): string {
200
+ return createHash('sha256').update(text).digest('hex').slice(0, 16)
201
+ }
202
+
203
+ // Resolve platform account ID (spine-system) — memoised per process
204
+ let _platformAccountId: string | null = null
205
+ async function getPlatformAccountId(): Promise<string | null> {
206
+ if (_platformAccountId) return _platformAccountId
207
+ const { data } = await adminDb.from('accounts').select('id').eq('slug', 'spine-system').maybeSingle()
208
+ _platformAccountId = data?.id ?? null
209
+ return _platformAccountId
210
+ }
211
+
212
+ // ─── VECTOR TYPE → CONTENT BUILDER MAP ───────────────────────────────────────
213
+ // Maps a vector_type string to a function that produces embeddable text from
214
+ // an item record. `summary` is handled separately due to async LLM call.
215
+
216
+ type ContentBuilder = (item: any, meta?: any) => string
217
+
218
+ const CONTENT_BUILDERS: Record<string, ContentBuilder> = {
219
+ semantic: buildSemanticContent,
220
+ structure: (item, meta) => buildStructureContent(item, meta?.embedding_fields ?? []),
221
+ intent: buildIntentContent,
222
+ cause: buildCauseContent,
223
+ procedure: buildProcedureContent,
224
+ outcome: buildOutcomeContent,
225
+ context: buildContextContent,
226
+ error_signal: buildErrorSignalContent,
227
+ // summary handled separately
228
+ }
229
+
230
+ // ─── GENERATE HANDLER ────────────────────────────────────────────────────────
231
+
45
232
  // ─── HANDLERS ─────────────────────────────────────────────────────────────────
46
233
 
47
234
  // ─── CHUNK_START: EMBEDDINGS_LIST ──────────────────────────────────────────────
@@ -485,6 +672,220 @@ export const searchSemantic = createHandler(async (ctx, body) => {
485
672
  })
486
673
  // ─── CHUNK_END: EMBEDDINGS_SEARCH_SEMANTIC ────────────────────────────────────────────────
487
674
 
675
+ // ─── GENERATE ────────────────────────────────────────────────────────────────
676
+ /**
677
+ * Generates embeddings for an item using the universal vector vocabulary.
678
+ * Reads design_schema.vector_types from the item's type record to determine
679
+ * which vectors to produce. Skips chunks whose content_hash is unchanged.
680
+ * Sets approved_at = now() for published items, NULL for drafts.
681
+ *
682
+ * Body: item_id (required), force_regenerate (bool), account_id_override (uuid)
683
+ *
684
+ * This is the ONLY sanctioned way for custom code to write to the embeddings
685
+ * table. Direct writes from custom functions are a hard-rule violation.
686
+ */
687
+ export const generate = createHandler(async (ctx, body) => {
688
+ const { item_id, force_regenerate = false, account_id_override } = body || {}
689
+ if (!item_id) throw new Error('item_id is required')
690
+
691
+ // Load item + type
692
+ const { data: item, error: itemErr } = await adminDb
693
+ .from('items')
694
+ .select('*, type:types(design_schema)')
695
+ .eq('id', item_id)
696
+ .single()
697
+
698
+ if (itemErr || !item) throw new Error(`Item ${item_id} not found`)
699
+
700
+ const typeDesign = item.type?.design_schema || {}
701
+ const kbType = item.data?.kb_type || 'article'
702
+
703
+ // Resolve which vector types to generate
704
+ // design_schema.vector_types can be a plain array (all kb_types share the same list)
705
+ // or a keyed object { postmortem: [...], how_to: [...] } to vary by kb_type sub-type
706
+ let vectorTypes: string[] = []
707
+ const rawVT = typeDesign.vector_types
708
+ if (Array.isArray(rawVT)) {
709
+ vectorTypes = rawVT
710
+ } else if (rawVT && typeof rawVT === 'object') {
711
+ vectorTypes = rawVT[kbType] || rawVT['default'] || ['semantic', 'structure']
712
+ } else {
713
+ vectorTypes = ['semantic', 'structure']
714
+ }
715
+
716
+ const embeddingFields: string[] = typeDesign.embedding_fields?.structure || []
717
+ const resolvedAccountId = account_id_override || item.account_id || await getPlatformAccountId()
718
+ const isPublished = item.status === 'published'
719
+
720
+ // Load existing embeddings for hash comparison
721
+ const { data: existing } = await adminDb
722
+ .from('embeddings')
723
+ .select('id, content_hash, chunk_index, metadata')
724
+ .eq('document_id', item_id)
725
+
726
+ const existingMap = new Map<string, any>()
727
+ for (const row of existing || []) {
728
+ const vt = row.metadata?.vector_type || ''
729
+ const ci = row.chunk_index ?? 0
730
+ existingMap.set(`${vt}:${ci}`, row)
731
+ }
732
+
733
+ // If force_regenerate, delete all current embeddings first
734
+ if (force_regenerate) {
735
+ await adminDb.from('embeddings').delete().eq('document_id', item_id)
736
+ existingMap.clear()
737
+ }
738
+
739
+ const created: string[] = []
740
+ const skipped: string[] = []
741
+ const errors: string[] = []
742
+
743
+ // Build content for non-summary vectors synchronously
744
+ const syncVectors = vectorTypes.filter(vt => vt !== 'summary')
745
+ const contents: Array<{ vt: string; text: string; chunkIndex: number }> = []
746
+
747
+ for (const vt of syncVectors) {
748
+ const builder = CONTENT_BUILDERS[vt]
749
+ if (!builder) { errors.push(`Unknown vector type: ${vt}`); continue }
750
+ const text = builder(item, { embedding_fields: embeddingFields })
751
+ if (!text) { skipped.push(`${vt}:empty`); continue }
752
+ contents.push({ vt, text, chunkIndex: 0 })
753
+ }
754
+
755
+ // Summary vector — async LLM call
756
+ if (vectorTypes.includes('summary')) {
757
+ const summaryText = await generateAiSummary(item)
758
+ if (summaryText) contents.push({ vt: 'summary', text: summaryText, chunkIndex: 0 })
759
+ }
760
+
761
+ // Filter out unchanged chunks (by content hash)
762
+ const toEmbed = contents.filter(({ vt, text, chunkIndex }) => {
763
+ const hash = contentHash(text)
764
+ const key = `${vt}:${chunkIndex}`
765
+ const row = existingMap.get(key)
766
+ if (row && row.content_hash === hash) { skipped.push(key); return false }
767
+ return true
768
+ })
769
+
770
+ if (!toEmbed.length) {
771
+ return { item_id, embeddings_created: 0, embeddings_skipped: skipped.length, errors }
772
+ }
773
+
774
+ // Single batched API call for all vectors that need updating
775
+ let vectors: string[] = []
776
+ try {
777
+ vectors = await callEmbeddingApi(toEmbed.map(c => c.text))
778
+ } catch (e: any) {
779
+ throw new Error(`Embedding API call failed: ${e.message}`)
780
+ }
781
+
782
+ const now = new Date().toISOString()
783
+
784
+ // Upsert each vector
785
+ for (let i = 0; i < toEmbed.length; i++) {
786
+ const { vt, text, chunkIndex } = toEmbed[i]
787
+ const hash = contentHash(text)
788
+ const key = `${vt}:${chunkIndex}`
789
+ const existingRow = existingMap.get(key)
790
+
791
+ const row = {
792
+ account_id: resolvedAccountId,
793
+ model_id: 'text-embedding-3-small',
794
+ document_id: item_id,
795
+ chunk_index: chunkIndex,
796
+ content: text,
797
+ embedding: vectors[i],
798
+ content_hash: hash,
799
+ approved_at: isPublished ? now : null,
800
+ revoked_at: null,
801
+ metadata: {
802
+ vector_type: vt,
803
+ item_type: item.type?.design_schema ? (item.data?.kb_type ? 'kb_article' : 'item') : 'item',
804
+ kb_type: kbType,
805
+ security_level: item.data?.security_level || 'internal',
806
+ source_item_id: item_id,
807
+ },
808
+ }
809
+
810
+ if (existingRow) {
811
+ await adminDb.from('embeddings').update(row).eq('id', existingRow.id)
812
+ } else {
813
+ await adminDb.from('embeddings').insert(row)
814
+ }
815
+ created.push(key)
816
+ }
817
+
818
+ await emitLog(ctx, 'embeddings.generated',
819
+ { type: 'item', id: item_id },
820
+ { after: { vectors_created: created.length, vectors_skipped: skipped.length, is_published: isPublished } }
821
+ )
822
+
823
+ return {
824
+ item_id,
825
+ embeddings_created: created.length,
826
+ embeddings_skipped: skipped.length,
827
+ errors,
828
+ }
829
+ })
830
+
831
+ // ─── REVOKE ───────────────────────────────────────────────────────────────────
832
+ /**
833
+ * Sets revoked_at = now() on all embeddings for a document_id.
834
+ * Embeddings are NOT deleted — the row stays for audit trail.
835
+ * match_embeddings will not return them (revoked_at IS NULL is a hard filter).
836
+ *
837
+ * Body: document_id (required)
838
+ */
839
+ export const revoke = createHandler(async (ctx, body) => {
840
+ const { document_id } = body || {}
841
+ if (!document_id) throw new Error('document_id is required')
842
+
843
+ const { data, error } = await adminDb
844
+ .from('embeddings')
845
+ .update({ revoked_at: new Date().toISOString() })
846
+ .eq('document_id', document_id)
847
+ .is('revoked_at', null)
848
+ .select('id')
849
+
850
+ if (error) throw error
851
+
852
+ await emitLog(ctx, 'embeddings.revoked',
853
+ { type: 'system', id: document_id },
854
+ { after: { revoked_count: data?.length || 0 } }
855
+ )
856
+
857
+ return { document_id, revoked_count: data?.length || 0 }
858
+ })
859
+
860
+ // ─── APPROVE ─────────────────────────────────────────────────────────────────
861
+ /**
862
+ * Clears revoked_at and refreshes approved_at on all embeddings for a document_id.
863
+ * Called when an item re-enters published state (also handled automatically by
864
+ * the items_embedding_sync DB trigger, but available as an explicit API action).
865
+ *
866
+ * Body: document_id (required)
867
+ */
868
+ export const approve = createHandler(async (ctx, body) => {
869
+ const { document_id } = body || {}
870
+ if (!document_id) throw new Error('document_id is required')
871
+
872
+ const now = new Date().toISOString()
873
+ const { data, error } = await adminDb
874
+ .from('embeddings')
875
+ .update({ approved_at: now, revoked_at: null })
876
+ .eq('document_id', document_id)
877
+ .select('id')
878
+
879
+ if (error) throw error
880
+
881
+ await emitLog(ctx, 'embeddings.approved',
882
+ { type: 'system', id: document_id },
883
+ { after: { approved_count: data?.length || 0 } }
884
+ )
885
+
886
+ return { document_id, approved_count: data?.length || 0 }
887
+ })
888
+
488
889
  // ─── MAIN HANDLER ────────────────────────────────────────────────────────────
489
890
 
490
891
  // ─── CHUNK_START: EMBEDDINGS_HANDLER ──────────────────────────────────────────────
@@ -507,6 +908,21 @@ export const handler = createHandler(async (ctx, body) => {
507
908
  const method = ctx.query?.method || 'GET'
508
909
 
509
910
  switch (action) {
911
+ case 'generate':
912
+ if (method === 'POST') {
913
+ return await generate(ctx, body)
914
+ }
915
+ break
916
+ case 'revoke':
917
+ if (method === 'POST') {
918
+ return await revoke(ctx, body)
919
+ }
920
+ break
921
+ case 'approve':
922
+ if (method === 'POST') {
923
+ return await approve(ctx, body)
924
+ }
925
+ break
510
926
  case 'stats':
511
927
  if (method === 'GET') {
512
928
  return await getStats(ctx, body)