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
|
@@ -184,16 +184,26 @@ export async function runAgent(
|
|
|
184
184
|
)
|
|
185
185
|
}
|
|
186
186
|
|
|
187
|
-
// 5. Save agent response
|
|
187
|
+
// 5. Save agent response — include retrieved_sources + retrieval_confidence
|
|
188
|
+
// so the feedback capture pipeline and the Portal UI can access them.
|
|
188
189
|
const agentMsg = await saveMessage(
|
|
189
190
|
threadId,
|
|
190
191
|
agentResponse.content,
|
|
191
192
|
'agent',
|
|
192
193
|
{
|
|
193
194
|
confidence: agentResponse.confidence,
|
|
195
|
+
retrieval_confidence: agentResponse.retrievalConfidence,
|
|
196
|
+
retrieved_sources: (agentResponse.retrievedSources || []).map((s: any) => ({
|
|
197
|
+
document_id: s.document_id,
|
|
198
|
+
title: s.title,
|
|
199
|
+
similarity: s.similarity,
|
|
200
|
+
item_type: s.item_type,
|
|
201
|
+
vector_type: s.vector_type,
|
|
202
|
+
})),
|
|
194
203
|
tool_calls: agentResponse.tool_calls,
|
|
195
204
|
agent_id: agent.id,
|
|
196
|
-
prompt_config_id: promptConfig.id
|
|
205
|
+
prompt_config_id: promptConfig.id,
|
|
206
|
+
sender_type: 'agent',
|
|
197
207
|
},
|
|
198
208
|
ctx,
|
|
199
209
|
replyTypeSlug
|
|
@@ -208,6 +218,8 @@ export async function runAgent(
|
|
|
208
218
|
thread_id: threadId,
|
|
209
219
|
agent_id: agent.id,
|
|
210
220
|
confidence: agentResponse.confidence,
|
|
221
|
+
retrieval_confidence: agentResponse.retrievalConfidence,
|
|
222
|
+
sources_count: agentResponse.retrievedSources?.length || 0,
|
|
211
223
|
has_tool_calls: !!agentResponse.tool_calls?.length,
|
|
212
224
|
duration_ms: Date.now() - startTime
|
|
213
225
|
})
|
|
@@ -347,36 +359,49 @@ async function executeAgentInference(
|
|
|
347
359
|
userMessage: string,
|
|
348
360
|
ctx: CoreContext,
|
|
349
361
|
maxToolIterations: number = 5
|
|
350
|
-
): Promise<InferenceResult> {
|
|
351
|
-
const { agent, promptConfig
|
|
362
|
+
): Promise<InferenceResult & { retrievedSources: any[]; retrievalConfidence: number }> {
|
|
363
|
+
const { agent, promptConfig } = config
|
|
352
364
|
|
|
353
|
-
// Build initial context
|
|
354
|
-
let context = await buildContext(config, userMessage, [], ctx)
|
|
365
|
+
// Build initial context — captures retrieved sources for feedback capture
|
|
366
|
+
let { context, retrievedSources, meanSimilarity } = await buildContext(config, userMessage, [], ctx)
|
|
355
367
|
|
|
356
368
|
for (let iteration = 0; iteration < maxToolIterations; iteration++) {
|
|
357
|
-
// Call inference
|
|
358
369
|
const inferenceResult = await callInference(context, agent, promptConfig, ctx)
|
|
359
370
|
|
|
360
|
-
// If no tool calls, return immediately
|
|
361
371
|
if (!inferenceResult.tool_calls || inferenceResult.tool_calls.length === 0) {
|
|
362
|
-
|
|
372
|
+
// Composite confidence: 60% LLM logprobs + 40% retrieval similarity
|
|
373
|
+
// If mean retrieval similarity < 0.40, cap composite at 0.60
|
|
374
|
+
const llmConf = inferenceResult.confidence ?? 0.85
|
|
375
|
+
let compositeConf: number
|
|
376
|
+
if (meanSimilarity < 0.40 && meanSimilarity > 0) {
|
|
377
|
+
compositeConf = Math.min(llmConf * 0.6 + meanSimilarity * 0.4, 0.60)
|
|
378
|
+
} else {
|
|
379
|
+
compositeConf = llmConf * 0.6 + Math.min(meanSimilarity, 1) * 0.4
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
return {
|
|
383
|
+
...inferenceResult,
|
|
384
|
+
confidence: Math.min(Math.max(compositeConf, 0), 1),
|
|
385
|
+
retrievedSources,
|
|
386
|
+
retrievalConfidence: meanSimilarity,
|
|
387
|
+
}
|
|
363
388
|
}
|
|
364
389
|
|
|
365
|
-
// Execute tools
|
|
366
390
|
const toolResults = await dispatchTools(inferenceResult.tool_calls, ctx)
|
|
391
|
+
const rebuilt = await buildContext(config, userMessage, toolResults, ctx)
|
|
392
|
+
context = rebuilt.context
|
|
393
|
+
if (rebuilt.retrievedSources.length) {
|
|
394
|
+
retrievedSources = rebuilt.retrievedSources
|
|
395
|
+
meanSimilarity = rebuilt.meanSimilarity
|
|
396
|
+
}
|
|
367
397
|
|
|
368
|
-
// Add tool results to context and re-inference
|
|
369
|
-
context = await buildContext(config, userMessage, toolResults, ctx)
|
|
370
|
-
|
|
371
|
-
// Check if any tool failed - if so, return with error info
|
|
372
398
|
const hasErrors = toolResults.some(r => r.error)
|
|
373
399
|
if (hasErrors && iteration === maxToolIterations - 1) {
|
|
374
400
|
inferenceResult.content += '\n\n[Note: Some tools failed to execute.]'
|
|
375
|
-
return inferenceResult
|
|
401
|
+
return { ...inferenceResult, retrievedSources, retrievalConfidence: meanSimilarity }
|
|
376
402
|
}
|
|
377
403
|
}
|
|
378
404
|
|
|
379
|
-
// Max iterations reached
|
|
380
405
|
throw new Error('Max tool iterations reached')
|
|
381
406
|
}
|
|
382
407
|
|
|
@@ -407,7 +432,7 @@ async function buildContext(
|
|
|
407
432
|
userMessage: string,
|
|
408
433
|
toolResults: ToolResult[],
|
|
409
434
|
ctx: CoreContext
|
|
410
|
-
): Promise<string> {
|
|
435
|
+
): Promise<{ context: string; retrievedSources: any[]; meanSimilarity: number }> {
|
|
411
436
|
const { agent, promptConfig, thread } = config
|
|
412
437
|
|
|
413
438
|
// 1. System prompt (from agent)
|
|
@@ -419,19 +444,30 @@ async function buildContext(
|
|
|
419
444
|
}
|
|
420
445
|
|
|
421
446
|
// 3. Retrieved knowledge (RAG)
|
|
422
|
-
|
|
423
|
-
|
|
447
|
+
// Always attempt retrieval — even with empty knowledge_sources (full scope search)
|
|
448
|
+
let retrievedSources: any[] = []
|
|
449
|
+
let meanSimilarity = 0
|
|
450
|
+
const retrievalPolicy = promptConfig.metadata?.retrieval_policy || {}
|
|
451
|
+
|
|
452
|
+
try {
|
|
453
|
+
const { docs, meanSimilarity: ms } = await retrieveKnowledge(
|
|
424
454
|
userMessage,
|
|
425
|
-
promptConfig.knowledge_sources,
|
|
426
|
-
ctx.accountId
|
|
455
|
+
promptConfig.knowledge_sources || [],
|
|
456
|
+
ctx.accountId!,
|
|
457
|
+
retrievalPolicy
|
|
427
458
|
)
|
|
428
|
-
|
|
459
|
+
retrievedSources = docs
|
|
460
|
+
meanSimilarity = ms
|
|
461
|
+
|
|
462
|
+
if (docs.length > 0) {
|
|
429
463
|
context += '## Relevant Information\n'
|
|
430
|
-
|
|
431
|
-
|
|
464
|
+
docs.forEach((doc, i) => {
|
|
465
|
+
const sourceLabel = doc.title ? `[${i + 1}] ${doc.title}` : `[${i + 1}]`
|
|
466
|
+
context += `${sourceLabel}\n${doc.content}\n\n`
|
|
432
467
|
})
|
|
433
|
-
context += '\n'
|
|
434
468
|
}
|
|
469
|
+
} catch (err) {
|
|
470
|
+
console.warn('retrieveKnowledge failed — proceeding without context:', err)
|
|
435
471
|
}
|
|
436
472
|
|
|
437
473
|
// 4. Conversation history
|
|
@@ -470,7 +506,7 @@ async function buildContext(
|
|
|
470
506
|
// 7. Current user message
|
|
471
507
|
context += `## Current Message\nUser: ${userMessage}\n\nAssistant: `
|
|
472
508
|
|
|
473
|
-
return context
|
|
509
|
+
return { context, retrievedSources, meanSimilarity }
|
|
474
510
|
}
|
|
475
511
|
|
|
476
512
|
// ─── KNOWLEDGE & HISTORY ───────────────────────────────────────────────────────────
|
|
@@ -505,29 +541,35 @@ async function buildContext(
|
|
|
505
541
|
async function retrieveKnowledge(
|
|
506
542
|
query: string,
|
|
507
543
|
knowledgeSources: string[],
|
|
508
|
-
accountId: string
|
|
509
|
-
|
|
544
|
+
accountId: string,
|
|
545
|
+
retrievalPolicy?: {
|
|
546
|
+
item_types?: string[]
|
|
547
|
+
vector_types?: string[]
|
|
548
|
+
max_security_level?: 'external' | 'internal' | 'restricted'
|
|
549
|
+
require_approved?: boolean
|
|
550
|
+
}
|
|
551
|
+
): Promise<{ docs: any[]; meanSimilarity: number }> {
|
|
510
552
|
const SIMILARITY_THRESHOLD = 0.15
|
|
511
553
|
const MATCH_COUNT = 8
|
|
554
|
+
const policy = retrievalPolicy || {}
|
|
555
|
+
const vectorTypesToSearch = policy.vector_types?.length ? policy.vector_types : ['semantic']
|
|
556
|
+
const maxSecurityLevel = policy.max_security_level || 'internal'
|
|
557
|
+
|
|
558
|
+
// Security level ordering: external < internal < restricted
|
|
559
|
+
const SECURITY_ORDER: Record<string, number> = { external: 0, internal: 1, restricted: 2 }
|
|
560
|
+
const maxSecurityRank = SECURITY_ORDER[maxSecurityLevel] ?? 1
|
|
512
561
|
|
|
513
562
|
// ── Tier 1: vector similarity via match_embeddings RPC ──────────────────
|
|
514
563
|
try {
|
|
515
564
|
const apiKey = process.env.OPENAI_API_KEY || process.env.LLM_API_KEY
|
|
516
565
|
const baseUrl = process.env.OPENAI_BASE_URL || 'https://api.openai.com/v1'
|
|
517
|
-
|
|
518
566
|
if (!apiKey) throw new Error('no_api_key')
|
|
519
567
|
|
|
520
|
-
// Resolve platform account
|
|
568
|
+
// Resolve platform account — always include spine-system KB
|
|
521
569
|
const { data: platformAccount } = await adminDb
|
|
522
|
-
.from('accounts')
|
|
523
|
-
.select('id')
|
|
524
|
-
.eq('slug', 'spine-system')
|
|
525
|
-
.maybeSingle()
|
|
526
|
-
|
|
570
|
+
.from('accounts').select('id').eq('slug', 'spine-system').maybeSingle()
|
|
527
571
|
const accountIds: string[] = [accountId]
|
|
528
|
-
if (platformAccount?.id && platformAccount.id !== accountId)
|
|
529
|
-
accountIds.push(platformAccount.id)
|
|
530
|
-
}
|
|
572
|
+
if (platformAccount?.id && platformAccount.id !== accountId) accountIds.push(platformAccount.id)
|
|
531
573
|
|
|
532
574
|
// Generate query embedding
|
|
533
575
|
const embedRes = await fetch(`${baseUrl}/embeddings`, {
|
|
@@ -540,29 +582,37 @@ async function retrieveKnowledge(
|
|
|
540
582
|
const queryEmbedding: number[] = embedJson.data?.[0]?.embedding || []
|
|
541
583
|
if (!queryEmbedding.length) throw new Error('empty_embedding')
|
|
542
584
|
|
|
543
|
-
//
|
|
544
|
-
const
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
585
|
+
// Multi-vector search: run all declared vector types and merge
|
|
586
|
+
const allMatches: any[] = []
|
|
587
|
+
for (const vt of vectorTypesToSearch) {
|
|
588
|
+
const { data: matches, error: rpcError } = await adminDb.rpc('match_embeddings', {
|
|
589
|
+
query_embedding: `[${queryEmbedding.join(',')}]`,
|
|
590
|
+
match_count: MATCH_COUNT * 3,
|
|
591
|
+
filter_account_ids: accountIds,
|
|
592
|
+
filter_vector_type: vt,
|
|
593
|
+
similarity_threshold: SIMILARITY_THRESHOLD,
|
|
594
|
+
filter_item_types: policy.item_types?.length ? policy.item_types : null,
|
|
595
|
+
})
|
|
596
|
+
if (rpcError) throw new Error(`rpc_${rpcError.message}`)
|
|
597
|
+
allMatches.push(...(matches || []))
|
|
598
|
+
}
|
|
552
599
|
|
|
553
|
-
//
|
|
554
|
-
const filtered =
|
|
600
|
+
// Hard security filter
|
|
601
|
+
const filtered = allMatches.filter((r: any) => {
|
|
555
602
|
if (r.similarity < SIMILARITY_THRESHOLD) return false
|
|
556
|
-
|
|
557
|
-
|
|
603
|
+
const secLevel = r.metadata?.security_level || 'internal'
|
|
604
|
+
const rank = SECURITY_ORDER[secLevel] ?? 1
|
|
605
|
+
if (rank > maxSecurityRank) return false
|
|
606
|
+
if (r.account_id !== accountId && secLevel === 'restricted') return false
|
|
607
|
+
return true
|
|
558
608
|
})
|
|
559
609
|
|
|
560
|
-
//
|
|
610
|
+
// Narrow to knowledgeSources if specified
|
|
561
611
|
const scoped = knowledgeSources.length > 0
|
|
562
612
|
? filtered.filter((r: any) => knowledgeSources.includes(r.document_id))
|
|
563
613
|
: filtered
|
|
564
614
|
|
|
565
|
-
// Deduplicate — best chunk per document
|
|
615
|
+
// Deduplicate — best chunk per document across all vector types
|
|
566
616
|
const bestByDoc = new Map<string, any>()
|
|
567
617
|
for (const r of scoped) {
|
|
568
618
|
const existing = bestByDoc.get(r.document_id)
|
|
@@ -572,47 +622,66 @@ async function retrieveKnowledge(
|
|
|
572
622
|
.sort((a, b) => b.similarity - a.similarity)
|
|
573
623
|
.slice(0, MATCH_COUNT)
|
|
574
624
|
|
|
575
|
-
if (deduped.length
|
|
625
|
+
if (!deduped.length) return { docs: [], meanSimilarity: 0 }
|
|
626
|
+
|
|
627
|
+
// If top result < 0.50 and summary not already included, try summary fallback
|
|
628
|
+
if (deduped[0]?.similarity < 0.50 && !vectorTypesToSearch.includes('summary')) {
|
|
629
|
+
const { data: summaryMatches } = await adminDb.rpc('match_embeddings', {
|
|
630
|
+
query_embedding: `[${queryEmbedding.join(',')}]`,
|
|
631
|
+
match_count: MATCH_COUNT,
|
|
632
|
+
filter_account_ids: accountIds,
|
|
633
|
+
filter_vector_type: 'summary',
|
|
634
|
+
similarity_threshold: SIMILARITY_THRESHOLD,
|
|
635
|
+
filter_item_types: policy.item_types?.length ? policy.item_types : null,
|
|
636
|
+
})
|
|
637
|
+
for (const r of summaryMatches || []) {
|
|
638
|
+
if (!bestByDoc.has(r.document_id) && r.similarity >= SIMILARITY_THRESHOLD) {
|
|
639
|
+
bestByDoc.set(r.document_id, r)
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
const finalDocs = [...bestByDoc.values()]
|
|
644
|
+
.sort((a, b) => b.similarity - a.similarity)
|
|
645
|
+
.slice(0, MATCH_COUNT)
|
|
576
646
|
|
|
577
647
|
// Enrich with item titles
|
|
578
648
|
const { data: items } = await adminDb
|
|
579
|
-
.from('items')
|
|
580
|
-
.select('id, title, description')
|
|
581
|
-
.in('id', deduped.map((r: any) => r.document_id))
|
|
582
|
-
|
|
649
|
+
.from('items').select('id, title').in('id', finalDocs.map((r: any) => r.document_id))
|
|
583
650
|
const itemMap = new Map((items || []).map((i: any) => [i.id, i]))
|
|
584
|
-
|
|
651
|
+
|
|
652
|
+
const docs = finalDocs.map((r: any) => ({
|
|
585
653
|
document_id: r.document_id,
|
|
586
654
|
content: r.content,
|
|
587
655
|
metadata: r.metadata,
|
|
588
656
|
similarity: r.similarity,
|
|
589
657
|
title: itemMap.get(r.document_id)?.title || '',
|
|
658
|
+
item_type: r.metadata?.item_type || '',
|
|
659
|
+
vector_type: r.metadata?.vector_type || '',
|
|
590
660
|
}))
|
|
591
661
|
|
|
662
|
+
const meanSimilarity = docs.length
|
|
663
|
+
? docs.reduce((sum: number, d: any) => sum + d.similarity, 0) / docs.length
|
|
664
|
+
: 0
|
|
665
|
+
|
|
666
|
+
return { docs, meanSimilarity }
|
|
667
|
+
|
|
592
668
|
} catch (vectorErr: any) {
|
|
593
669
|
// ── Tier 2: full-text fallback ──────────────────────────────────────
|
|
594
670
|
if (vectorErr.message !== 'no_api_key') {
|
|
595
671
|
console.warn('retrieveKnowledge vector search failed, falling back to text search:', vectorErr.message)
|
|
596
672
|
}
|
|
597
|
-
|
|
598
673
|
const baseQuery = adminDb
|
|
599
674
|
.from('embeddings')
|
|
600
675
|
.select('document_id, content, metadata, account_id')
|
|
601
676
|
.eq('account_id', accountId)
|
|
602
677
|
.eq('metadata->>vector_type', 'semantic')
|
|
678
|
+
.not('revoked_at', 'is', null)
|
|
603
679
|
.textSearch('content', query, { type: 'websearch', config: 'english' })
|
|
604
680
|
.limit(5)
|
|
605
|
-
|
|
606
|
-
const q = knowledgeSources.length > 0
|
|
607
|
-
? baseQuery.in('document_id', knowledgeSources)
|
|
608
|
-
: baseQuery
|
|
609
|
-
|
|
681
|
+
const q = knowledgeSources.length > 0 ? baseQuery.in('document_id', knowledgeSources) : baseQuery
|
|
610
682
|
const { data: docs, error } = await q
|
|
611
|
-
if (error) {
|
|
612
|
-
|
|
613
|
-
return []
|
|
614
|
-
}
|
|
615
|
-
return docs || []
|
|
683
|
+
if (error) { console.error('Knowledge retrieval fallback failed:', error); return { docs: [], meanSimilarity: 0 } }
|
|
684
|
+
return { docs: docs || [], meanSimilarity: 0 }
|
|
616
685
|
}
|
|
617
686
|
}
|
|
618
687
|
|
|
@@ -1172,17 +1241,6 @@ async function handleEscalation(
|
|
|
1172
1241
|
* @sideEffects DB write: messages table
|
|
1173
1242
|
* @calledBy runAgent (user message + agent response)
|
|
1174
1243
|
*/
|
|
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
1244
|
// Map a thread type slug to the corresponding message/reply type slug.
|
|
1187
1245
|
function getReplyTypeSlug(threadTypeSlug: string): string {
|
|
1188
1246
|
if (threadTypeSlug === 'support_thread') return 'support_reply'
|
|
@@ -1198,20 +1256,20 @@ async function saveMessage(
|
|
|
1198
1256
|
ctx: CoreContext,
|
|
1199
1257
|
messageTypeSlug: string = 'message'
|
|
1200
1258
|
): Promise<any> {
|
|
1201
|
-
const
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1259
|
+
const { count } = await adminDb
|
|
1260
|
+
.from('messages')
|
|
1261
|
+
.select('id', { count: 'exact', head: true })
|
|
1262
|
+
.eq('thread_id', threadId)
|
|
1205
1263
|
const sequence = (count ?? 0) + 1
|
|
1206
1264
|
const direction = messageType === 'human' ? 'inbound' : 'outbound'
|
|
1207
1265
|
// human + agent messages are customer-visible; tool/system messages are internal
|
|
1208
1266
|
const visibility = (messageType === 'human' || messageType === 'agent') ? 'external' : 'internal'
|
|
1209
1267
|
|
|
1210
|
-
// Use admin-data's create() so
|
|
1211
|
-
//
|
|
1268
|
+
// Use admin-data's create() so type_slug is resolved server-side and
|
|
1269
|
+
// design_schema + validation_schema are stamped from the type record.
|
|
1212
1270
|
const message = await createRecord(ctx, {
|
|
1213
1271
|
entity: 'messages',
|
|
1214
|
-
|
|
1272
|
+
type_slug: messageTypeSlug,
|
|
1215
1273
|
thread_id: threadId,
|
|
1216
1274
|
person_id: messageType === 'human' ? ctx.principal?.id : null,
|
|
1217
1275
|
content,
|
|
@@ -252,22 +252,10 @@ class _PermissionEngineInternal {
|
|
|
252
252
|
// 1. Load type design schema if not provided (pre-stamped on record is preferred)
|
|
253
253
|
let schema = designSchema
|
|
254
254
|
if (!schema || !schema.record_permissions) {
|
|
255
|
-
//
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
.eq('slug', typeSlug)
|
|
260
|
-
.eq('is_active', true)
|
|
261
|
-
.single()
|
|
262
|
-
|
|
263
|
-
// No schema = no permissions. RLS controls row access;
|
|
264
|
-
// design_schema controls what the principal can do with the record.
|
|
265
|
-
// A missing or empty schema is an explicit deny — not a free pass.
|
|
266
|
-
if (!typeRecord?.design_schema?.record_permissions) {
|
|
267
|
-
return defaultResult
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
schema = typeRecord.design_schema
|
|
255
|
+
// Runtime records are stamped with design_schema at creation. We do not
|
|
256
|
+
// fall back to the type definition because the runtime record is the
|
|
257
|
+
// source of truth for its own schema and permissions.
|
|
258
|
+
return defaultResult
|
|
271
259
|
}
|
|
272
260
|
|
|
273
261
|
// 2. Get user's role via people.role_id FK
|
|
@@ -626,7 +626,37 @@ const stageHandlers: Record<string, Function> = {
|
|
|
626
626
|
metadata: { mock: true },
|
|
627
627
|
model: model || 'gpt-4o'
|
|
628
628
|
}
|
|
629
|
-
}
|
|
629
|
+
},
|
|
630
|
+
|
|
631
|
+
// ── generate_embeddings ─────────────────────────────────────────────────────
|
|
632
|
+
// Generates embeddings for an item using the core embeddings.generate action.
|
|
633
|
+
// Supports {{trigger.entity_id}} template interpolation in config.item_id.
|
|
634
|
+
//
|
|
635
|
+
// Config: item_id (required — supports {{trigger.entity_id}}),
|
|
636
|
+
// force_regenerate (bool, default false)
|
|
637
|
+
//
|
|
638
|
+
// Called by the auto_embed_on_publish and auto_revoke_on_unpublish pipelines.
|
|
639
|
+
'generate_embeddings': async (ctx: CoreContext, config: any) => {
|
|
640
|
+
const triggerData = config._triggerData || {}
|
|
641
|
+
let itemId = config.item_id
|
|
642
|
+
if (!itemId) throw new Error('item_id is required for generate_embeddings')
|
|
643
|
+
|
|
644
|
+
// Template interpolation: {{trigger.entity_id}} or {{entity_id}}
|
|
645
|
+
if (typeof itemId === 'string' && itemId.includes('{{')) {
|
|
646
|
+
itemId = itemId
|
|
647
|
+
.replace(/\{\{trigger\.entity_id\}\}/g, triggerData?.entity_id || '')
|
|
648
|
+
.replace(/\{\{entity_id\}\}/g, triggerData?.entity_id || '')
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
if (!itemId) throw new Error('generate_embeddings: could not resolve item_id from trigger data')
|
|
652
|
+
|
|
653
|
+
const { generate } = await import('../embeddings')
|
|
654
|
+
const genCtx = { ...ctx, query: { ...(ctx.query || {}), action: 'generate' } }
|
|
655
|
+
return await generate(genCtx, {
|
|
656
|
+
item_id: itemId,
|
|
657
|
+
force_regenerate: config.force_regenerate ?? false,
|
|
658
|
+
})
|
|
659
|
+
},
|
|
630
660
|
}
|
|
631
661
|
|
|
632
662
|
// ─── MODULE DISPATCHERS ────────────────────────────────────────────────────────────
|
|
@@ -44,6 +44,7 @@
|
|
|
44
44
|
import { createHandler } from './_shared/middleware'
|
|
45
45
|
import { sanitizeRecordData } from './_shared/permissions'
|
|
46
46
|
import { adminDb } from './_shared/db'
|
|
47
|
+
import { resolveTypeId } from './_shared/resolve-ids'
|
|
47
48
|
import { fireCreateTriggers, fireUpdateTriggers, fireDeleteTriggers } from './_shared/trigger-engine'
|
|
48
49
|
|
|
49
50
|
// ─── CONSTANTS ────────────────────────────────────────────────────────────────
|
|
@@ -59,6 +60,17 @@ const PERMISSIONS_ALL = {
|
|
|
59
60
|
*/
|
|
60
61
|
const VALID_ENTITIES = ['accounts', 'people', 'items', 'threads', 'messages', 'links', 'attachments', 'watchers', 'item_progress']
|
|
61
62
|
|
|
63
|
+
// Map admin-data entity names to the kind field used in the types table
|
|
64
|
+
const ENTITY_TO_KIND: Record<string, string> = {
|
|
65
|
+
items: 'item',
|
|
66
|
+
threads: 'thread',
|
|
67
|
+
messages: 'message',
|
|
68
|
+
links: 'link',
|
|
69
|
+
attachments: 'attachment',
|
|
70
|
+
watchers: 'watcher',
|
|
71
|
+
item_progress: 'item_progress'
|
|
72
|
+
}
|
|
73
|
+
|
|
62
74
|
// ─── HANDLERS ─────────────────────────────────────────────────────────────────
|
|
63
75
|
|
|
64
76
|
// ─── CHUNK_START: ADMIN_DATA_LIST ──────────────────────────────────────────────
|
|
@@ -89,17 +101,14 @@ export const list = createHandler(async (ctx, _body) => {
|
|
|
89
101
|
let query = ctx.db.from(entity).select('*')
|
|
90
102
|
|
|
91
103
|
// Apply type_slug filter if provided (for schema-driven entities)
|
|
92
|
-
if (type_slug
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
if (typeRecord) {
|
|
102
|
-
query = query.eq('type_id', typeRecord.id)
|
|
104
|
+
if (type_slug) {
|
|
105
|
+
const kind = ENTITY_TO_KIND[entity]
|
|
106
|
+
if (kind) {
|
|
107
|
+
// Resolve the type ID from the slug via the shared resolver
|
|
108
|
+
const typeId = await resolveTypeId(kind, type_slug)
|
|
109
|
+
if (typeId) {
|
|
110
|
+
query = query.eq('type_id', typeId)
|
|
111
|
+
}
|
|
103
112
|
}
|
|
104
113
|
}
|
|
105
114
|
|
|
@@ -250,30 +259,41 @@ export const get = createHandler(async (ctx, _body) => {
|
|
|
250
259
|
*/
|
|
251
260
|
export const create = createHandler(async (ctx, body) => {
|
|
252
261
|
const entity = body?.entity || ctx.query?.entity
|
|
253
|
-
const { entity: _e, design_schema: _ds, validation_schema: _vs, account_id: _ai, ...recordData } = body || {}
|
|
262
|
+
const { entity: _e, type_slug: _ts, design_schema: _ds, validation_schema: _vs, account_id: _ai, ...recordData } = body || {}
|
|
254
263
|
|
|
255
264
|
if (!entity || !VALID_ENTITIES.includes(entity)) {
|
|
256
265
|
const e: any = new Error('Valid entity parameter is required'); e.statusCode = 400; throw e
|
|
257
266
|
}
|
|
258
267
|
|
|
268
|
+
// Resolve type_id from type_slug if provided, otherwise require type_id directly
|
|
269
|
+
let typeId = recordData.type_id
|
|
270
|
+
if (!typeId && body?.type_slug) {
|
|
271
|
+
const kind = ENTITY_TO_KIND[entity]
|
|
272
|
+
if (!kind) {
|
|
273
|
+
const e: any = new Error(`type_slug is not supported for entity: ${entity}`); e.statusCode = 400; throw e
|
|
274
|
+
}
|
|
275
|
+
typeId = await resolveTypeId(kind, body.type_slug)
|
|
276
|
+
}
|
|
277
|
+
|
|
259
278
|
// type_id is required on all runtime record creation
|
|
260
|
-
if (!
|
|
261
|
-
const e: any = new Error('type_id is required — every runtime record must reference a type'); e.statusCode = 400; throw e
|
|
279
|
+
if (!typeId) {
|
|
280
|
+
const e: any = new Error('type_id or type_slug is required — every runtime record must reference a type'); e.statusCode = 400; throw e
|
|
262
281
|
}
|
|
282
|
+
recordData.type_id = typeId
|
|
263
283
|
|
|
264
284
|
// Look up the type to stamp design_schema and validation_schema
|
|
265
285
|
const { data: typeRecord, error: typeErr } = await adminDb
|
|
266
286
|
.from('types')
|
|
267
287
|
.select('id, design_schema, validation_schema, is_active')
|
|
268
|
-
.eq('id',
|
|
288
|
+
.eq('id', typeId)
|
|
269
289
|
.single()
|
|
270
290
|
|
|
271
291
|
if (typeErr || !typeRecord) {
|
|
272
|
-
throw new Error(`type_id not found: ${
|
|
292
|
+
throw new Error(`type_id not found: ${typeId}`)
|
|
273
293
|
}
|
|
274
294
|
|
|
275
295
|
if (!typeRecord.is_active) {
|
|
276
|
-
throw new Error(`type_id references an inactive type: ${
|
|
296
|
+
throw new Error(`type_id references an inactive type: ${typeId}`)
|
|
277
297
|
}
|
|
278
298
|
|
|
279
299
|
// Ensure the type has at least permissions=ALL (defensive — migration 062 guarantees this)
|
|
@@ -282,8 +302,9 @@ export const create = createHandler(async (ctx, body) => {
|
|
|
282
302
|
designSchema = { ...PERMISSIONS_ALL, ...designSchema }
|
|
283
303
|
}
|
|
284
304
|
|
|
285
|
-
// Resolve account_id and propagate scope via parent inheritance
|
|
286
|
-
//
|
|
305
|
+
// Resolve account_id and propagate scope via parent inheritance.
|
|
306
|
+
// Runtime records are the source of truth: the parent record's stamped scope
|
|
307
|
+
// and account_id are inherited, not re-looked up from the type table.
|
|
287
308
|
let recordAccountId = ctx.accountId
|
|
288
309
|
const parentRef = recordData.thread_id || recordData.target_id || recordData.parent_id
|
|
289
310
|
if (parentRef) {
|
|
@@ -301,16 +322,18 @@ export const create = createHandler(async (ctx, body) => {
|
|
|
301
322
|
if (parentScope) {
|
|
302
323
|
designSchema = { ...designSchema, scope: parentScope }
|
|
303
324
|
}
|
|
304
|
-
}
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// Platform-scoped records belong to the system account so they are visible
|
|
328
|
+
// across all accounts. Child records already inherited the parent's scope above.
|
|
329
|
+
const resolvedScope: string = designSchema.scope ?? 'account'
|
|
330
|
+
if (resolvedScope === 'platform') {
|
|
331
|
+
const { data: sysAccount } = await adminDb
|
|
332
|
+
.from('accounts')
|
|
333
|
+
.select('id')
|
|
334
|
+
.eq('slug', 'spine-system')
|
|
335
|
+
.single()
|
|
336
|
+
if (sysAccount?.id) recordAccountId = sysAccount.id
|
|
314
337
|
}
|
|
315
338
|
|
|
316
339
|
// Add audit fields + stamped schema
|