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.
@@ -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
- * Uses Supabase full-text search (`textSearch`) on `content`. Falls back
476
- * to returning an empty array on error (never throws).
477
- * Future: replace with vector similarity search when embedding service is wired.
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
- * @param query - User message text to search against
480
- * @param knowledgeSources - Array of document UUIDs to filter the search
481
- * @param accountId - Account scope for the embeddings lookup
482
- * @returns Promise<any[]> — array of embedding rows (content, metadata)
483
- * @throws never — returns [] on failure
484
- * @sideEffects DB read: embeddings table
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
- // For now, we'll need the query embedding - this requires calling an embedding service
493
- // In production, this would call the same embedding model used to create embeddings
494
- // For now, we'll do a text search fallback
510
+ const SIMILARITY_THRESHOLD = 0.15
511
+ const MATCH_COUNT = 8
495
512
 
496
- const { data: docs, error } = await adminDb
497
- .from('embeddings')
498
- .select('*')
499
- .eq('account_id', accountId)
500
- .in('document_id', knowledgeSources)
501
- .textSearch('content', query, {
502
- type: 'websearch',
503
- config: 'english'
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
- .limit(5)
551
+ if (rpcError) throw new Error(`rpc_${rpcError.message}`)
506
552
 
507
- if (error) {
508
- console.error('Knowledge retrieval failed:', error)
509
- return []
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
- return docs || []
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 { data: message, error } = await adminDb
1079
- .from('messages')
1080
- .insert({
1081
- thread_id: threadId,
1082
- account_id: ctx.accountId,
1083
- person_id: messageType === 'human' ? ctx.principal?.id : null,
1084
- content,
1085
- content_format: 'text',
1086
- data: {
1087
- message_type: messageType,
1088
- ...data
1089
- },
1090
- created_at: new Date().toISOString()
1091
- })
1092
- .select()
1093
- .single()
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 (error) throw new Error(`Failed to save message: ${error.message}`)
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('system_admin', ARRAY['create', 'read', 'update', 'delete']),
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('system_admin', ARRAY['create', 'read', 'update', 'delete']),
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);