spine-framework 0.3.105 → 0.3.107
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 +178 -46
- package/.framework/migrations/001_schema.sql +1 -0
- package/.framework/migrations/002_rls_and_functions.sql +4 -2
- package/.framework/migrations/003_system_data.sql +14 -2
- package/.framework/migrations/022_route_prefix_domain_unique.sql +21 -0
- package/.framework/migrations/023_match_embeddings_rpc.sql +78 -0
- package/.framework/migrations/024_messages_account_id.sql +9 -0
- package/.framework/migrations/025_retype_threads_messages.sql +240 -0
- package/LICENSE.md +93 -63
- package/config/tsconfig.node.tsbuildinfo +1 -1
- package/docs/README.md +96 -130
- package/package.json +5 -1
- package/scripts/boundary-check.sh +6 -2
|
@@ -45,6 +45,7 @@
|
|
|
45
45
|
import { CoreContext } from './middleware'
|
|
46
46
|
import { adminDb } from './db'
|
|
47
47
|
import { emitAudit } from './audit'
|
|
48
|
+
import { create as createRecord } from '../admin-data'
|
|
48
49
|
|
|
49
50
|
// ─── TYPES ───────────────────────────────────────────────────────────────
|
|
50
51
|
|
|
@@ -159,8 +160,12 @@ export async function runAgent(
|
|
|
159
160
|
|
|
160
161
|
const { agent, promptConfig, thread, threadType } = config
|
|
161
162
|
|
|
163
|
+
// Resolve the message type from the thread type so support/community threads
|
|
164
|
+
// save replies with the correct type (and therefore correct permissions).
|
|
165
|
+
const replyTypeSlug = getReplyTypeSlug(threadType?.slug)
|
|
166
|
+
|
|
162
167
|
// 2. Save user message to thread
|
|
163
|
-
const userMsg = await saveMessage(threadId, userMessage, 'human', null, ctx)
|
|
168
|
+
const userMsg = await saveMessage(threadId, userMessage, 'human', null, ctx, replyTypeSlug)
|
|
164
169
|
|
|
165
170
|
// 3. Execute agent with full context
|
|
166
171
|
const agentResponse = await executeAgentInference(
|
|
@@ -190,7 +195,8 @@ export async function runAgent(
|
|
|
190
195
|
agent_id: agent.id,
|
|
191
196
|
prompt_config_id: promptConfig.id
|
|
192
197
|
},
|
|
193
|
-
ctx
|
|
198
|
+
ctx,
|
|
199
|
+
replyTypeSlug
|
|
194
200
|
)
|
|
195
201
|
|
|
196
202
|
// 6. Emit audit log
|
|
@@ -472,16 +478,28 @@ async function buildContext(
|
|
|
472
478
|
/**
|
|
473
479
|
* Retrieves relevant documents from the `embeddings` table for RAG.
|
|
474
480
|
*
|
|
475
|
-
*
|
|
476
|
-
*
|
|
477
|
-
*
|
|
481
|
+
* Strategy (tiered):
|
|
482
|
+
* 1. Generate a query embedding via the OpenAI embeddings API.
|
|
483
|
+
* 2. Call the `match_embeddings` Postgres RPC (vector cosine similarity).
|
|
484
|
+
* 3. Deduplicate by document_id — keep best-matching chunk per article.
|
|
485
|
+
* 4. Enrich with item titles from the `items` table.
|
|
486
|
+
* 5. Fall back to full-text search if the embedding API is unavailable or
|
|
487
|
+
* the RPC call fails (guarantees graceful degradation in dev/test).
|
|
478
488
|
*
|
|
479
|
-
*
|
|
480
|
-
*
|
|
481
|
-
*
|
|
482
|
-
*
|
|
483
|
-
*
|
|
484
|
-
*
|
|
489
|
+
* Account scoping:
|
|
490
|
+
* - Always includes the requesting account's embeddings.
|
|
491
|
+
* - Always includes the platform KB account (spine-system), excluding
|
|
492
|
+
* rows where metadata.security_level = 'restricted'.
|
|
493
|
+
* - `knowledgeSources` optionally narrows to specific document UUIDs
|
|
494
|
+
* (prompt_config.knowledge_sources); omit or pass [] for full scope.
|
|
495
|
+
*
|
|
496
|
+
* @param query - User message text to embed and search
|
|
497
|
+
* @param knowledgeSources - Optional document UUIDs to restrict search scope
|
|
498
|
+
* @param accountId - Requesting account for scoped access
|
|
499
|
+
* @returns Promise<any[]> — enriched result rows (id, title, content, similarity)
|
|
500
|
+
* @throws never — falls back to text search or [] on any failure
|
|
501
|
+
* @sideEffects HTTP call to OpenAI embeddings API (if API key present)
|
|
502
|
+
* @sideEffects DB read: match_embeddings RPC + items table
|
|
485
503
|
* @calledBy buildContext (if promptConfig.knowledge_sources is non-empty)
|
|
486
504
|
*/
|
|
487
505
|
async function retrieveKnowledge(
|
|
@@ -489,27 +507,113 @@ async function retrieveKnowledge(
|
|
|
489
507
|
knowledgeSources: string[],
|
|
490
508
|
accountId: string
|
|
491
509
|
): Promise<any[]> {
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
// For now, we'll do a text search fallback
|
|
510
|
+
const SIMILARITY_THRESHOLD = 0.15
|
|
511
|
+
const MATCH_COUNT = 8
|
|
495
512
|
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
.
|
|
499
|
-
.
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
513
|
+
// ── Tier 1: vector similarity via match_embeddings RPC ──────────────────
|
|
514
|
+
try {
|
|
515
|
+
const apiKey = process.env.OPENAI_API_KEY || process.env.LLM_API_KEY
|
|
516
|
+
const baseUrl = process.env.OPENAI_BASE_URL || 'https://api.openai.com/v1'
|
|
517
|
+
|
|
518
|
+
if (!apiKey) throw new Error('no_api_key')
|
|
519
|
+
|
|
520
|
+
// Resolve platform account ID at runtime — no hardcoded UUIDs
|
|
521
|
+
const { data: platformAccount } = await adminDb
|
|
522
|
+
.from('accounts')
|
|
523
|
+
.select('id')
|
|
524
|
+
.eq('slug', 'spine-system')
|
|
525
|
+
.maybeSingle()
|
|
526
|
+
|
|
527
|
+
const accountIds: string[] = [accountId]
|
|
528
|
+
if (platformAccount?.id && platformAccount.id !== accountId) {
|
|
529
|
+
accountIds.push(platformAccount.id)
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
// Generate query embedding
|
|
533
|
+
const embedRes = await fetch(`${baseUrl}/embeddings`, {
|
|
534
|
+
method: 'POST',
|
|
535
|
+
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` },
|
|
536
|
+
body: JSON.stringify({ model: 'text-embedding-3-small', input: query }),
|
|
537
|
+
})
|
|
538
|
+
if (!embedRes.ok) throw new Error(`embed_api_${embedRes.status}`)
|
|
539
|
+
const embedJson: any = await embedRes.json()
|
|
540
|
+
const queryEmbedding: number[] = embedJson.data?.[0]?.embedding || []
|
|
541
|
+
if (!queryEmbedding.length) throw new Error('empty_embedding')
|
|
542
|
+
|
|
543
|
+
// Call vector RPC
|
|
544
|
+
const { data: matches, error: rpcError } = await adminDb.rpc('match_embeddings', {
|
|
545
|
+
query_embedding: `[${queryEmbedding.join(',')}]`,
|
|
546
|
+
match_count: MATCH_COUNT * 3,
|
|
547
|
+
filter_account_ids: accountIds,
|
|
548
|
+
filter_vector_type: 'semantic',
|
|
549
|
+
similarity_threshold: SIMILARITY_THRESHOLD,
|
|
504
550
|
})
|
|
505
|
-
|
|
551
|
+
if (rpcError) throw new Error(`rpc_${rpcError.message}`)
|
|
506
552
|
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
553
|
+
// Filter: security_level restriction for cross-account results
|
|
554
|
+
const filtered = (matches || []).filter((r: any) => {
|
|
555
|
+
if (r.similarity < SIMILARITY_THRESHOLD) return false
|
|
556
|
+
if (r.account_id === accountId) return true
|
|
557
|
+
return r.metadata?.security_level !== 'restricted'
|
|
558
|
+
})
|
|
559
|
+
|
|
560
|
+
// Optionally narrow to knowledgeSources doc IDs
|
|
561
|
+
const scoped = knowledgeSources.length > 0
|
|
562
|
+
? filtered.filter((r: any) => knowledgeSources.includes(r.document_id))
|
|
563
|
+
: filtered
|
|
564
|
+
|
|
565
|
+
// Deduplicate — best chunk per document
|
|
566
|
+
const bestByDoc = new Map<string, any>()
|
|
567
|
+
for (const r of scoped) {
|
|
568
|
+
const existing = bestByDoc.get(r.document_id)
|
|
569
|
+
if (!existing || r.similarity > existing.similarity) bestByDoc.set(r.document_id, r)
|
|
570
|
+
}
|
|
571
|
+
const deduped = [...bestByDoc.values()]
|
|
572
|
+
.sort((a, b) => b.similarity - a.similarity)
|
|
573
|
+
.slice(0, MATCH_COUNT)
|
|
574
|
+
|
|
575
|
+
if (deduped.length === 0) return []
|
|
576
|
+
|
|
577
|
+
// Enrich with item titles
|
|
578
|
+
const { data: items } = await adminDb
|
|
579
|
+
.from('items')
|
|
580
|
+
.select('id, title, description')
|
|
581
|
+
.in('id', deduped.map((r: any) => r.document_id))
|
|
582
|
+
|
|
583
|
+
const itemMap = new Map((items || []).map((i: any) => [i.id, i]))
|
|
584
|
+
return deduped.map((r: any) => ({
|
|
585
|
+
document_id: r.document_id,
|
|
586
|
+
content: r.content,
|
|
587
|
+
metadata: r.metadata,
|
|
588
|
+
similarity: r.similarity,
|
|
589
|
+
title: itemMap.get(r.document_id)?.title || '',
|
|
590
|
+
}))
|
|
591
|
+
|
|
592
|
+
} catch (vectorErr: any) {
|
|
593
|
+
// ── Tier 2: full-text fallback ──────────────────────────────────────
|
|
594
|
+
if (vectorErr.message !== 'no_api_key') {
|
|
595
|
+
console.warn('retrieveKnowledge vector search failed, falling back to text search:', vectorErr.message)
|
|
596
|
+
}
|
|
511
597
|
|
|
512
|
-
|
|
598
|
+
const baseQuery = adminDb
|
|
599
|
+
.from('embeddings')
|
|
600
|
+
.select('document_id, content, metadata, account_id')
|
|
601
|
+
.eq('account_id', accountId)
|
|
602
|
+
.eq('metadata->>vector_type', 'semantic')
|
|
603
|
+
.textSearch('content', query, { type: 'websearch', config: 'english' })
|
|
604
|
+
.limit(5)
|
|
605
|
+
|
|
606
|
+
const q = knowledgeSources.length > 0
|
|
607
|
+
? baseQuery.in('document_id', knowledgeSources)
|
|
608
|
+
: baseQuery
|
|
609
|
+
|
|
610
|
+
const { data: docs, error } = await q
|
|
611
|
+
if (error) {
|
|
612
|
+
console.error('Knowledge retrieval fallback failed:', error)
|
|
613
|
+
return []
|
|
614
|
+
}
|
|
615
|
+
return docs || []
|
|
616
|
+
}
|
|
513
617
|
}
|
|
514
618
|
|
|
515
619
|
/**
|
|
@@ -1068,30 +1172,58 @@ async function handleEscalation(
|
|
|
1068
1172
|
* @sideEffects DB write: messages table
|
|
1069
1173
|
* @calledBy runAgent (user message + agent response)
|
|
1070
1174
|
*/
|
|
1175
|
+
// Module-level cache so we only look up message type_ids once per cold-start
|
|
1176
|
+
const _messageTypeIds: Record<string, string> = {}
|
|
1177
|
+
|
|
1178
|
+
async function getMessageTypeId(slug: string): Promise<string> {
|
|
1179
|
+
if (_messageTypeIds[slug]) return _messageTypeIds[slug]
|
|
1180
|
+
const { data } = await adminDb.from('types').select('id').eq('slug', slug).maybeSingle()
|
|
1181
|
+
if (!data?.id) throw new Error(`Type "${slug}" not found in types table`)
|
|
1182
|
+
_messageTypeIds[slug] = data.id
|
|
1183
|
+
return data.id
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
// Map a thread type slug to the corresponding message/reply type slug.
|
|
1187
|
+
function getReplyTypeSlug(threadTypeSlug: string): string {
|
|
1188
|
+
if (threadTypeSlug === 'support_thread') return 'support_reply'
|
|
1189
|
+
if (threadTypeSlug === 'community_thread') return 'community_reply'
|
|
1190
|
+
return 'message'
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1071
1193
|
async function saveMessage(
|
|
1072
1194
|
threadId: string,
|
|
1073
1195
|
content: string,
|
|
1074
1196
|
messageType: 'human' | 'agent' | 'system' | 'tool_call' | 'tool_result',
|
|
1075
1197
|
data: any,
|
|
1076
|
-
ctx: CoreContext
|
|
1198
|
+
ctx: CoreContext,
|
|
1199
|
+
messageTypeSlug: string = 'message'
|
|
1077
1200
|
): Promise<any> {
|
|
1078
|
-
const {
|
|
1079
|
-
|
|
1080
|
-
.
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
.
|
|
1201
|
+
const [typeId, { count }] = await Promise.all([
|
|
1202
|
+
getMessageTypeId(messageTypeSlug),
|
|
1203
|
+
adminDb.from('messages').select('id', { count: 'exact', head: true }).eq('thread_id', threadId),
|
|
1204
|
+
])
|
|
1205
|
+
const sequence = (count ?? 0) + 1
|
|
1206
|
+
const direction = messageType === 'human' ? 'inbound' : 'outbound'
|
|
1207
|
+
// human + agent messages are customer-visible; tool/system messages are internal
|
|
1208
|
+
const visibility = (messageType === 'human' || messageType === 'agent') ? 'external' : 'internal'
|
|
1209
|
+
|
|
1210
|
+
// Use admin-data's create() so design_schema + validation_schema are stamped
|
|
1211
|
+
// from the type record — ensuring the permission engine returns full rows.
|
|
1212
|
+
const message = await createRecord(ctx, {
|
|
1213
|
+
entity: 'messages',
|
|
1214
|
+
type_id: typeId,
|
|
1215
|
+
thread_id: threadId,
|
|
1216
|
+
person_id: messageType === 'human' ? ctx.principal?.id : null,
|
|
1217
|
+
content,
|
|
1218
|
+
direction,
|
|
1219
|
+
sequence,
|
|
1220
|
+
visibility,
|
|
1221
|
+
data: {
|
|
1222
|
+
message_type: messageType,
|
|
1223
|
+
...data
|
|
1224
|
+
},
|
|
1225
|
+
})
|
|
1094
1226
|
|
|
1095
|
-
if (
|
|
1227
|
+
if (!message?.id) throw new Error('Failed to save message')
|
|
1096
1228
|
return message
|
|
1097
1229
|
}
|
|
@@ -105,6 +105,7 @@ CREATE TABLE IF NOT EXISTS public.threads (
|
|
|
105
105
|
CREATE TABLE IF NOT EXISTS public.messages (
|
|
106
106
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
107
107
|
type_id uuid NOT NULL, thread_id uuid NOT NULL,
|
|
108
|
+
account_id uuid REFERENCES public.accounts(id),
|
|
108
109
|
content text NOT NULL, direction text DEFAULT 'outbound',
|
|
109
110
|
sequence integer NOT NULL, visibility text DEFAULT 'internal',
|
|
110
111
|
data jsonb DEFAULT '{}', is_active boolean DEFAULT true,
|
|
@@ -572,12 +572,14 @@ CREATE POLICY threads_access ON public.threads FOR ALL
|
|
|
572
572
|
OR auth.uid() IS NULL
|
|
573
573
|
);
|
|
574
574
|
|
|
575
|
-
-- messages
|
|
575
|
+
-- messages (inherits visibility from parent thread; account-scoped threads use the
|
|
576
|
+
-- account hierarchy, platform-scoped threads are visible to all authenticated users)
|
|
576
577
|
DROP POLICY IF EXISTS messages_access ON public.messages;
|
|
577
578
|
CREATE POLICY messages_access ON public.messages FOR ALL
|
|
578
579
|
USING (EXISTS (SELECT 1 FROM public.threads t
|
|
579
580
|
WHERE t.id = messages.thread_id
|
|
580
|
-
AND t.account_id IN (SELECT public.get_accessible_accounts(auth.uid()))
|
|
581
|
+
AND (t.account_id IN (SELECT public.get_accessible_accounts(auth.uid()))
|
|
582
|
+
OR t.design_schema->>'scope' = 'platform'))
|
|
581
583
|
OR auth.uid() IS NULL);
|
|
582
584
|
|
|
583
585
|
-- links (with platform scope visibility from 012_rls_scope.sql)
|
|
@@ -249,7 +249,13 @@ BEGIN
|
|
|
249
249
|
'Discussion thread for items or accounts',
|
|
250
250
|
jsonb_build_object(
|
|
251
251
|
'scope', 'account',
|
|
252
|
-
'record_permissions', jsonb_build_object(
|
|
252
|
+
'record_permissions', jsonb_build_object(
|
|
253
|
+
'system_admin', ARRAY['create', 'read', 'update', 'delete'],
|
|
254
|
+
'support_admin', ARRAY['create', 'read', 'update'],
|
|
255
|
+
'support', ARRAY['create', 'read', 'update'],
|
|
256
|
+
'member_admin', ARRAY['create', 'read', 'update'],
|
|
257
|
+
'member', ARRAY['create', 'read', 'update']
|
|
258
|
+
),
|
|
253
259
|
'fields', jsonb_build_object(
|
|
254
260
|
'title', jsonb_build_object('data_type', 'text', 'label', 'Title', 'required', false, 'system', true, 'validation', null),
|
|
255
261
|
'target_type', jsonb_build_object('data_type', 'text', 'label', 'Target Type', 'required', true, 'system', true, 'validation', null),
|
|
@@ -295,7 +301,13 @@ BEGIN
|
|
|
295
301
|
'Individual message within a thread',
|
|
296
302
|
jsonb_build_object(
|
|
297
303
|
'scope', 'account',
|
|
298
|
-
'record_permissions', jsonb_build_object(
|
|
304
|
+
'record_permissions', jsonb_build_object(
|
|
305
|
+
'system_admin', ARRAY['create', 'read', 'update', 'delete'],
|
|
306
|
+
'support_admin', ARRAY['create', 'read', 'update'],
|
|
307
|
+
'support', ARRAY['create', 'read', 'update'],
|
|
308
|
+
'member_admin', ARRAY['create', 'read', 'update'],
|
|
309
|
+
'member', ARRAY['create', 'read', 'update']
|
|
310
|
+
),
|
|
299
311
|
'fields', jsonb_build_object(
|
|
300
312
|
'content', jsonb_build_object('data_type', 'textarea', 'label', 'Content', 'required', true, 'system', true, 'validation', null),
|
|
301
313
|
'direction', jsonb_build_object('data_type', 'text', 'label', 'Direction', 'required', true, 'system', true, 'validation', null),
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
-- Migration 022: Allow multiple apps to share a route_prefix when they have
|
|
2
|
+
-- different prod_domains (i.e. they are served from different subdomains).
|
|
3
|
+
--
|
|
4
|
+
-- Before: UNIQUE(route_prefix) WHERE is_active AND route_prefix IS NOT NULL
|
|
5
|
+
-- → only one app could claim "/" across the entire database
|
|
6
|
+
--
|
|
7
|
+
-- After: UNIQUE(route_prefix, COALESCE(prod_domain, '')) WHERE is_active AND route_prefix IS NOT NULL
|
|
8
|
+
-- → two apps can both use "/" as long as they have different prod_domains
|
|
9
|
+
-- → apps with no prod_domain still get a unique slot (COALESCE maps NULL → '')
|
|
10
|
+
--
|
|
11
|
+
-- This is required to support multiple domain-scoped apps (e.g. Cortex on
|
|
12
|
+
-- cortex.spine-framework.com and Portal on portal.spine-framework.com) that
|
|
13
|
+
-- each serve from the root path "/" of their respective domains.
|
|
14
|
+
|
|
15
|
+
-- Drop the old single-column unique index
|
|
16
|
+
DROP INDEX IF EXISTS idx_apps_route_prefix;
|
|
17
|
+
|
|
18
|
+
-- Create the new composite unique index
|
|
19
|
+
CREATE UNIQUE INDEX IF NOT EXISTS idx_apps_route_prefix_domain
|
|
20
|
+
ON public.apps(route_prefix, COALESCE(prod_domain, ''))
|
|
21
|
+
WHERE is_active = true AND route_prefix IS NOT NULL;
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
-- Migration 023: match_embeddings RPC for vector similarity search
|
|
2
|
+
--
|
|
3
|
+
-- Provides the `match_embeddings` Postgres function used by:
|
|
4
|
+
-- - custom_kb-embeddings.ts (handleSearchEmbeddings)
|
|
5
|
+
-- - agent-runner.ts (retrieveKnowledge — after upgrade)
|
|
6
|
+
--
|
|
7
|
+
-- Requires the `vector` extension (enabled in migration 002).
|
|
8
|
+
-- Uses cosine distance (<=>). Returns rows ordered best-first.
|
|
9
|
+
|
|
10
|
+
-- ============================================
|
|
11
|
+
-- HNSW INDEX (idempotent)
|
|
12
|
+
-- ============================================
|
|
13
|
+
-- Create HNSW index on the embedding column for fast approximate nearest-neighbour search.
|
|
14
|
+
-- Skip if it already exists (Postgres 15+ supports IF NOT EXISTS for indexes).
|
|
15
|
+
DO $$
|
|
16
|
+
BEGIN
|
|
17
|
+
IF NOT EXISTS (
|
|
18
|
+
SELECT 1 FROM pg_indexes
|
|
19
|
+
WHERE schemaname = 'public'
|
|
20
|
+
AND tablename = 'embeddings'
|
|
21
|
+
AND indexname = 'embeddings_embedding_hnsw_idx'
|
|
22
|
+
) THEN
|
|
23
|
+
EXECUTE 'CREATE INDEX embeddings_embedding_hnsw_idx
|
|
24
|
+
ON public.embeddings
|
|
25
|
+
USING hnsw (embedding vector_cosine_ops)
|
|
26
|
+
WITH (m = 16, ef_construction = 64)';
|
|
27
|
+
END IF;
|
|
28
|
+
END
|
|
29
|
+
$$;
|
|
30
|
+
|
|
31
|
+
-- ============================================
|
|
32
|
+
-- match_embeddings RPC
|
|
33
|
+
-- ============================================
|
|
34
|
+
CREATE OR REPLACE FUNCTION public.match_embeddings(
|
|
35
|
+
query_embedding vector(1536),
|
|
36
|
+
match_count int DEFAULT 10,
|
|
37
|
+
filter_account_ids uuid[] DEFAULT NULL,
|
|
38
|
+
filter_vector_type text DEFAULT NULL,
|
|
39
|
+
similarity_threshold float DEFAULT 0.15
|
|
40
|
+
)
|
|
41
|
+
RETURNS TABLE (
|
|
42
|
+
id uuid,
|
|
43
|
+
document_id text,
|
|
44
|
+
content text,
|
|
45
|
+
metadata jsonb,
|
|
46
|
+
account_id uuid,
|
|
47
|
+
similarity float
|
|
48
|
+
)
|
|
49
|
+
LANGUAGE plpgsql
|
|
50
|
+
SECURITY DEFINER
|
|
51
|
+
SET search_path = public
|
|
52
|
+
AS $$
|
|
53
|
+
BEGIN
|
|
54
|
+
RETURN QUERY
|
|
55
|
+
SELECT
|
|
56
|
+
e.id,
|
|
57
|
+
e.document_id,
|
|
58
|
+
e.content,
|
|
59
|
+
e.metadata,
|
|
60
|
+
e.account_id,
|
|
61
|
+
1 - (e.embedding <=> query_embedding) AS similarity
|
|
62
|
+
FROM public.embeddings e
|
|
63
|
+
WHERE
|
|
64
|
+
-- account scope filter (NULL = no filter — platform-wide search)
|
|
65
|
+
(filter_account_ids IS NULL OR e.account_id = ANY(filter_account_ids))
|
|
66
|
+
-- vector type filter (NULL = all types)
|
|
67
|
+
AND (filter_vector_type IS NULL OR e.metadata->>'vector_type' = filter_vector_type)
|
|
68
|
+
-- similarity gate (cosine similarity = 1 - cosine distance)
|
|
69
|
+
AND (1 - (e.embedding <=> query_embedding)) >= similarity_threshold
|
|
70
|
+
ORDER BY e.embedding <=> query_embedding -- ascending distance = descending similarity
|
|
71
|
+
LIMIT match_count;
|
|
72
|
+
END;
|
|
73
|
+
$$;
|
|
74
|
+
|
|
75
|
+
-- Grant execute to authenticated and service roles
|
|
76
|
+
GRANT EXECUTE ON FUNCTION public.match_embeddings(vector, int, uuid[], text, float) TO authenticated;
|
|
77
|
+
GRANT EXECUTE ON FUNCTION public.match_embeddings(vector, int, uuid[], text, float) TO service_role;
|
|
78
|
+
GRANT EXECUTE ON FUNCTION public.match_embeddings(vector, int, uuid[], text, float) TO anon;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
-- Migration 024: Add account_id to messages table
|
|
2
|
+
-- messages was the only runtime table missing account_id.
|
|
3
|
+
-- Required by admin-data's create() (which stamps account_id on all runtime records)
|
|
4
|
+
-- and by RLS policies that scope rows to an account.
|
|
5
|
+
|
|
6
|
+
ALTER TABLE public.messages
|
|
7
|
+
ADD COLUMN IF NOT EXISTS account_id uuid REFERENCES public.accounts(id);
|
|
8
|
+
|
|
9
|
+
CREATE INDEX IF NOT EXISTS idx_messages_account_id ON public.messages(account_id);
|