spine-framework 0.3.107 → 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.
- package/.framework/functions/_shared/agent-runner.ts +146 -88
- package/.framework/functions/_shared/permissions.ts +4 -16
- package/.framework/functions/_shared/pipeline-runner.ts +31 -1
- package/.framework/functions/admin-data.ts +52 -29
- package/.framework/functions/ai-interactions.ts +240 -0
- package/.framework/functions/embeddings.ts +426 -10
- package/.framework/functions/eval-harness.ts +353 -0
- package/.framework/functions/item-progress.ts +12 -5
- package/.framework/functions/observability.ts +146 -1
- package/.framework/migrations/002_rls_and_functions.sql +30 -11
- package/.framework/migrations/026_fix_rls_policies.sql +47 -0
- package/.framework/migrations/027_embedding_lifecycle.sql +176 -0
- package/config/tsconfig.node.tsbuildinfo +1 -1
- package/package.json +3 -1
|
@@ -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
|
|
8
|
-
* representations of text chunks for RAG
|
|
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.
|
|
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 (
|
|
37
|
-
* @seeAlso pipeline-runner.ts (
|
|
38
|
-
* @seeAlso audit.ts (emitLog for embedding.*
|
|
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(/</g, '<').replace(/>/g, '>').replace(/&/g, '&')
|
|
58
|
+
.replace(/"/g, '"').replace(/'/g, "'").replace(/ /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)
|