spine-framework-cortex 0.1.1

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.
Files changed (40) hide show
  1. package/components/CortexSidebar.tsx +130 -0
  2. package/functions/custom_anonymous-sessions.ts +356 -0
  3. package/functions/custom_case_analysis.ts +507 -0
  4. package/functions/custom_community-escalation.ts +234 -0
  5. package/functions/custom_cortex-chunks.ts +52 -0
  6. package/functions/custom_cortex-handler.ts +35 -0
  7. package/functions/custom_funnel-scoring.ts +256 -0
  8. package/functions/custom_funnel-signal.ts +678 -0
  9. package/functions/custom_funnel-timers.ts +449 -0
  10. package/functions/custom_kb-chunker-test.ts +364 -0
  11. package/functions/custom_kb-chunker.ts +576 -0
  12. package/functions/custom_kb-embeddings.ts +481 -0
  13. package/functions/custom_kb-ingestion.ts +448 -0
  14. package/functions/custom_support-triage.ts +649 -0
  15. package/functions/custom_tag_management.ts +314 -0
  16. package/index.tsx +103 -0
  17. package/manifest.json +82 -0
  18. package/package.json +29 -0
  19. package/pages/CortexDashboard.tsx +97 -0
  20. package/pages/community/CommunityPage.tsx +159 -0
  21. package/pages/courses/CoursesPage.tsx +231 -0
  22. package/pages/crm/AccountDetailPage.tsx +393 -0
  23. package/pages/crm/AccountsPage.tsx +164 -0
  24. package/pages/crm/ActivityPage.tsx +82 -0
  25. package/pages/crm/ContactDetailPage.tsx +184 -0
  26. package/pages/crm/ContactsPage.tsx +87 -0
  27. package/pages/crm/DealDetailPage.tsx +191 -0
  28. package/pages/crm/DealsPage.tsx +169 -0
  29. package/pages/crm/HealthPage.tsx +109 -0
  30. package/pages/intelligence/IntelligencePage.tsx +314 -0
  31. package/pages/kb/KBEditorPage.tsx +328 -0
  32. package/pages/kb/KBIngestionPage.tsx +409 -0
  33. package/pages/kb/KBPage.tsx +258 -0
  34. package/pages/support/RedactionReview.tsx +562 -0
  35. package/pages/support/SupportPage.tsx +395 -0
  36. package/pages/support/TicketDetailPage.tsx +919 -0
  37. package/seed/accounts.json +9 -0
  38. package/seed/link-types.json +44 -0
  39. package/seed/triggers.json +80 -0
  40. package/seed/types.json +352 -0
@@ -0,0 +1,234 @@
1
+ import { createHandler } from './_shared/middleware'
2
+ import { adminDb } from './_shared/db'
3
+
4
+ /**
5
+ * Custom Community Escalation Handler
6
+ *
7
+ * Triggered by cron schedule to convert unanswered community posts to support tickets.
8
+ * Posts unanswered for 24+ hours are escalated to the AI-first support queue.
9
+ *
10
+ * Uses adminDb (service role) for system-level operations across all accounts.
11
+ */
12
+
13
+ interface CommunityPost {
14
+ id: string
15
+ title: string
16
+ description?: string
17
+ account_id: string
18
+ person_id: string
19
+ created_at: string
20
+ data?: {
21
+ category?: string
22
+ tags?: string[]
23
+ status?: string
24
+ escalation?: {
25
+ escalated_to_ticket_id?: string
26
+ }
27
+ }
28
+ }
29
+
30
+ async function escalatePostToTicket(post: CommunityPost): Promise<string | null> {
31
+ try {
32
+ // Check if a ticket already exists for this post
33
+ const { data: existingTicket } = await adminDb
34
+ .from('items')
35
+ .select('id')
36
+ .eq('type_slug', 'support_ticket')
37
+ .eq('data->>source_post_id', post.id)
38
+ .limit(1)
39
+ .maybeSingle()
40
+
41
+ if (existingTicket) {
42
+ console.log(`Post ${post.id} already escalated to ticket ${existingTicket.id}`)
43
+ return null
44
+ }
45
+
46
+ // Create the support ticket
47
+ const ticketTitle = `Escalated: ${post.title}`
48
+ const ticketDescription = post.description || 'No description provided'
49
+
50
+ const { data: newTicket, error: insertError } = await adminDb
51
+ .from('items')
52
+ .insert({
53
+ type_slug: 'support_ticket',
54
+ title: ticketTitle,
55
+ description: ticketDescription,
56
+ account_id: post.account_id,
57
+ person_id: post.person_id,
58
+ status: 'open',
59
+ data: {
60
+ source_post_id: post.id,
61
+ source: 'community_escalation',
62
+ escalated_at: new Date().toISOString(),
63
+ original_category: post.data?.category || 'general',
64
+ original_tags: post.data?.tags || [],
65
+ community_status: 'unanswered_24h',
66
+ ai_metadata: {
67
+ confidence_threshold: 0.75,
68
+ escalation_reason: 'community_unanswered',
69
+ problem_statement: post.title,
70
+ source_content: post.description?.slice(0, 1000)
71
+ }
72
+ }
73
+ })
74
+ .select('id')
75
+ .single()
76
+
77
+ if (insertError || !newTicket) {
78
+ throw new Error(`Failed to create ticket: ${insertError?.message}`)
79
+ }
80
+
81
+ const ticketId = newTicket.id
82
+
83
+ // Create external thread for the ticket
84
+ await adminDb.from('threads').insert({
85
+ target_type: 'items',
86
+ target_id: ticketId,
87
+ visibility: 'external',
88
+ status: 'active'
89
+ })
90
+
91
+ // Update the community post to mark it as escalated
92
+ const updatedData = {
93
+ ...post.data,
94
+ status: 'escalated',
95
+ escalation: {
96
+ escalated_to_ticket_id: ticketId,
97
+ escalated_at: new Date().toISOString(),
98
+ reason: 'unanswered_24h'
99
+ }
100
+ }
101
+
102
+ await adminDb
103
+ .from('items')
104
+ .update({ data: updatedData, updated_at: new Date().toISOString() })
105
+ .eq('id', post.id)
106
+
107
+ // Trigger AI triage agent on the new ticket
108
+ await triggerTriageAgent(ticketId, post.account_id, post.title, post.description)
109
+
110
+ console.log(`Successfully escalated post ${post.id} to ticket ${ticketId}`)
111
+ return ticketId
112
+
113
+ } catch (err) {
114
+ console.error(`Failed to escalate post ${post.id}:`, err)
115
+ throw err
116
+ }
117
+ }
118
+
119
+ async function triggerTriageAgent(
120
+ ticketId: string,
121
+ accountId: string,
122
+ title: string,
123
+ content?: string
124
+ ): Promise<void> {
125
+ try {
126
+ // Find the support triage pipeline
127
+ const { data: pipeline } = await adminDb
128
+ .from('pipelines')
129
+ .select('id')
130
+ .ilike('name', '%support%triage%')
131
+ .limit(1)
132
+ .maybeSingle()
133
+
134
+ if (!pipeline) {
135
+ console.log('No support triage pipeline found, skipping auto-trigger')
136
+ return
137
+ }
138
+
139
+ // Create pipeline execution
140
+ await adminDb.from('pipeline_executions').insert({
141
+ pipeline_id: pipeline.id,
142
+ target_type: 'items',
143
+ target_id: ticketId,
144
+ status: 'pending',
145
+ input_context: {
146
+ ticket_id: ticketId,
147
+ account_id: accountId,
148
+ title: title,
149
+ description: content || '',
150
+ source: 'community_escalation'
151
+ }
152
+ })
153
+ } catch (err) {
154
+ console.error('Failed to trigger triage agent:', err)
155
+ // Non-fatal: ticket was created, triage can be run manually
156
+ }
157
+ }
158
+
159
+ export const handler = createHandler(async (_ctx, _body) => {
160
+ console.log('Starting community escalation check...')
161
+
162
+ try {
163
+ const cutoffTime = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString()
164
+
165
+ // Find posts unanswered for 24+ hours
166
+ const { data: unansweredPosts, error: postsError } = await adminDb
167
+ .from('items')
168
+ .select('id, title, description, account_id, person_id, created_at, data')
169
+ .eq('type_slug', 'community_post')
170
+ .not('data->>status', 'eq', 'escalated')
171
+ .lt('created_at', cutoffTime)
172
+ .order('created_at', { ascending: true })
173
+ .limit(50)
174
+
175
+ if (postsError) {
176
+ throw new Error(`Failed to fetch posts: ${postsError.message}`)
177
+ }
178
+
179
+ if (!unansweredPosts || unansweredPosts.length === 0) {
180
+ return { status: 'ok', processed: 0, escalated: 0, failed: 0, skipped: 0 }
181
+ }
182
+
183
+ // Filter out posts that have replies
184
+ const postsToEscalate: CommunityPost[] = []
185
+ for (const post of unansweredPosts) {
186
+ const { data: replies } = await adminDb
187
+ .from('items')
188
+ .select('id')
189
+ .eq('type_slug', 'community_reply')
190
+ .eq('data->>post_id', post.id)
191
+ .gt('created_at', post.created_at)
192
+ .limit(1)
193
+
194
+ if (!replies || replies.length === 0) {
195
+ postsToEscalate.push(post as CommunityPost)
196
+ }
197
+ }
198
+
199
+ console.log(`Found ${postsToEscalate.length} unanswered posts to escalate`)
200
+
201
+ const results = { escalated: [] as string[], failed: [] as string[], skipped: [] as string[] }
202
+
203
+ for (const post of postsToEscalate) {
204
+ try {
205
+ const ticketId = await escalatePostToTicket(post)
206
+ if (ticketId) {
207
+ results.escalated.push(post.id)
208
+ } else {
209
+ results.skipped.push(post.id)
210
+ }
211
+ } catch (err) {
212
+ console.error(`Failed to escalate post ${post.id}:`, err)
213
+ results.failed.push(post.id)
214
+ }
215
+ }
216
+
217
+ console.log('Escalation complete:', results)
218
+
219
+ return {
220
+ status: 'ok',
221
+ processed: postsToEscalate.length,
222
+ escalated: results.escalated.length,
223
+ failed: results.failed.length,
224
+ skipped: results.skipped.length,
225
+ details: results
226
+ }
227
+
228
+ } catch (err) {
229
+ console.error('Community escalation failed:', err)
230
+ const error: any = new Error('Failed to process community escalation')
231
+ error.statusCode = 500
232
+ throw error
233
+ }
234
+ })
@@ -0,0 +1,52 @@
1
+ import { createHandler } from './_shared/middleware'
2
+ import { adminDb } from './_shared/db'
3
+
4
+ export const handler = createHandler(async (ctx, body) => {
5
+ const { action } = ctx.query || {}
6
+ const method = ctx.query?.method || 'GET'
7
+
8
+ switch (action) {
9
+ case 'list':
10
+ if (method === 'GET') {
11
+ try {
12
+ // Read chunks from the project root directory
13
+ const fs = require('fs')
14
+ const path = require('path')
15
+ const chunksPath = path.join(process.cwd(), 'chunks.json')
16
+ const fileContent = fs.readFileSync(chunksPath, 'utf8')
17
+ const data = JSON.parse(fileContent)
18
+
19
+ return {
20
+ chunks: data.chunks || [],
21
+ total: data.chunks?.length || 0,
22
+ loaded_at: new Date().toISOString()
23
+ }
24
+ } catch (error) {
25
+ throw new Error(`Failed to load chunks: ${error instanceof Error ? error.message : 'Unknown error'}`)
26
+ }
27
+ }
28
+ break
29
+
30
+ default:
31
+ if (method === 'GET') {
32
+ try {
33
+ // Read chunks from the project root directory
34
+ const fs = require('fs')
35
+ const path = require('path')
36
+ const chunksPath = path.join(process.cwd(), 'chunks.json')
37
+ const fileContent = fs.readFileSync(chunksPath, 'utf8')
38
+ const data = JSON.parse(fileContent)
39
+
40
+ return {
41
+ chunks: data.chunks || [],
42
+ total: data.chunks?.length || 0,
43
+ loaded_at: new Date().toISOString()
44
+ }
45
+ } catch (error) {
46
+ throw new Error(`Failed to load chunks: ${error instanceof Error ? error.message : 'Unknown error'}`)
47
+ }
48
+ }
49
+ }
50
+
51
+ throw new Error('Invalid action or method')
52
+ })
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Cortex Webhook Handler
3
+ *
4
+ * Convention: custom_*.ts files are assembled into /functions/
5
+ * and loaded by integration-routes via: import('./custom_cortex-handler')
6
+ *
7
+ * Receives: (sanitizedData, context, event)
8
+ * Returns: plain text or object
9
+ */
10
+ export default async function cortexHandler(
11
+ data: Record<string, any>,
12
+ ctx: {
13
+ integrationId: string
14
+ accountId: string
15
+ slug: string
16
+ principal: { id: string; type: string; accountId: string }
17
+ requestId: string
18
+ headers: Record<string, string>
19
+ },
20
+ event: {
21
+ httpMethod: string
22
+ headers: Record<string, string>
23
+ body: any
24
+ path: string
25
+ queryStringParameters: Record<string, string>
26
+ }
27
+ ): Promise<string> {
28
+ console.log(`[${ctx.requestId}] Cortex handler received:`, {
29
+ testText: data['test-text'],
30
+ integrationId: ctx.integrationId,
31
+ accountId: ctx.accountId
32
+ })
33
+
34
+ return data['test-text']
35
+ }
@@ -0,0 +1,256 @@
1
+ // Funnel Scoring Engine
2
+ // Pure calculation utilities - NO database access
3
+ // All functions are deterministic and testable
4
+
5
+ // ============================================
6
+ // TYPES
7
+ // ============================================
8
+
9
+ export interface EngagementResult {
10
+ type: 1 | 2 | 5
11
+ context: 'first_visit' | 'deep_session' | 'return_visit'
12
+ session_depth: number
13
+ prior_session_count?: number
14
+ }
15
+
16
+ export interface RecencyResult {
17
+ divisor: 1 | 2 | 5 | null
18
+ age_days: number
19
+ window: 'fresh' | 'cooling' | 'stale' | 'expired'
20
+ }
21
+
22
+ export interface RawScoreResult {
23
+ calculated: number
24
+ max_possible: number
25
+ rating: 1 | 2 | 3 | 4 | 5
26
+ }
27
+
28
+ export interface StageConfig {
29
+ max_lookback_days: number
30
+ fresh_days: number
31
+ cooling_days: number
32
+ stale_days: number
33
+ deep_engagement_action_count: number
34
+ }
35
+
36
+ // Stage configurations per plan
37
+ const STAGE_CONFIGS: Record<string, StageConfig> = {
38
+ anonymous: {
39
+ max_lookback_days: 90,
40
+ fresh_days: 7,
41
+ cooling_days: 30,
42
+ stale_days: 90,
43
+ deep_engagement_action_count: 4
44
+ },
45
+ identified: {
46
+ max_lookback_days: 120,
47
+ fresh_days: 14,
48
+ cooling_days: 45,
49
+ stale_days: 90,
50
+ deep_engagement_action_count: 3
51
+ },
52
+ installed: {
53
+ max_lookback_days: 90,
54
+ fresh_days: 7,
55
+ cooling_days: 21,
56
+ stale_days: 45,
57
+ deep_engagement_action_count: 3
58
+ }
59
+ }
60
+
61
+ // ============================================
62
+ // ENGAGEMENT CALCULATION
63
+ // ============================================
64
+
65
+ export function calculateEngagement(
66
+ priorSignals: Array<{ session_id: string; occurred_at: string }>,
67
+ currentSessionId: string,
68
+ currentOccurredAt: string,
69
+ stage: string
70
+ ): EngagementResult {
71
+ const config = STAGE_CONFIGS[stage] || STAGE_CONFIGS.anonymous
72
+
73
+ // First visit - no prior signals
74
+ if (priorSignals.length === 0) {
75
+ return { type: 1, context: 'first_visit', session_depth: 1 }
76
+ }
77
+
78
+ // Check for return visit
79
+ const lastSignal = priorSignals[priorSignals.length - 1]
80
+ const hoursSinceLast = differenceInHours(
81
+ new Date(currentOccurredAt),
82
+ new Date(lastSignal.occurred_at)
83
+ )
84
+ const isNewSession = currentSessionId !== lastSignal.session_id
85
+
86
+ if (isNewSession || hoursSinceLast >= 4) {
87
+ const uniqueSessions = new Set(priorSignals.map(s => s.session_id)).size
88
+ return {
89
+ type: 5,
90
+ context: 'return_visit',
91
+ session_depth: 1,
92
+ prior_session_count: uniqueSessions
93
+ }
94
+ }
95
+
96
+ // Same session - check depth
97
+ const sameSessionSignals = priorSignals.filter(s =>
98
+ s.session_id === currentSessionId &&
99
+ isSameDay(new Date(s.occurred_at), new Date(currentOccurredAt))
100
+ )
101
+
102
+ const sessionDepth = sameSessionSignals.length + 1
103
+
104
+ if (sessionDepth >= config.deep_engagement_action_count) {
105
+ return { type: 2, context: 'deep_session', session_depth: sessionDepth }
106
+ }
107
+
108
+ return { type: 1, context: 'first_visit', session_depth: sessionDepth }
109
+ }
110
+
111
+ // ============================================
112
+ // RECENCY CALCULATION
113
+ // ============================================
114
+
115
+ export function calculateRecency(
116
+ occurredAt: Date,
117
+ now: Date = new Date(),
118
+ stage: string = 'anonymous'
119
+ ): RecencyResult {
120
+ const config = STAGE_CONFIGS[stage] || STAGE_CONFIGS.anonymous
121
+ const ageDays = differenceInDays(now, occurredAt)
122
+
123
+ if (ageDays > config.max_lookback_days) {
124
+ return { divisor: null, age_days: ageDays, window: 'expired' }
125
+ }
126
+
127
+ if (ageDays <= config.fresh_days) {
128
+ return { divisor: 1, age_days: ageDays, window: 'fresh' }
129
+ }
130
+
131
+ if (ageDays <= config.cooling_days) {
132
+ return { divisor: 2, age_days: ageDays, window: 'cooling' }
133
+ }
134
+
135
+ return { divisor: 5, age_days: ageDays, window: 'stale' }
136
+ }
137
+
138
+ // ============================================
139
+ // RAW SCORE CALCULATION
140
+ // ============================================
141
+
142
+ export function calculateRawScore(
143
+ actionValue: 1 | 2 | 5,
144
+ engagementType: 1 | 2 | 5,
145
+ recencyDivisor: 1 | 2 | 5
146
+ ): RawScoreResult {
147
+ const calculated = (actionValue * engagementType) / recencyDivisor
148
+
149
+ let rating: 1 | 2 | 3 | 4 | 5
150
+ if (calculated <= 1) rating = 1
151
+ else if (calculated <= 4) rating = 2
152
+ else if (calculated <= 8) rating = 3
153
+ else if (calculated <= 15) rating = 4
154
+ else rating = 5
155
+
156
+ return {
157
+ calculated,
158
+ max_possible: 25, // 5 * 5 / 1
159
+ rating
160
+ }
161
+ }
162
+
163
+ // ============================================
164
+ // BEST-SIGNAL-WINS CALCULATION
165
+ // ============================================
166
+
167
+ export function findBestSignal<T extends { scoring_components?: { raw_score?: { calculated?: number; rating?: number } } }>(
168
+ signals: T[]
169
+ ): { signal: T | null; rating: number; raw_score: number } {
170
+ if (signals.length === 0) {
171
+ return { signal: null, rating: 0, raw_score: 0 }
172
+ }
173
+
174
+ let bestSignal = signals[0]
175
+ let bestScore = signals[0]?.scoring_components?.raw_score?.calculated || 0
176
+
177
+ for (const signal of signals) {
178
+ const score = signal?.scoring_components?.raw_score?.calculated || 0
179
+ if (score > bestScore) {
180
+ bestScore = score
181
+ bestSignal = signal
182
+ }
183
+ }
184
+
185
+ return {
186
+ signal: bestSignal,
187
+ rating: bestSignal?.scoring_components?.raw_score?.rating || 0,
188
+ raw_score: bestScore
189
+ }
190
+ }
191
+
192
+ // ============================================
193
+ // UTILITY FUNCTIONS
194
+ // ============================================
195
+
196
+ function differenceInHours(date1: Date, date2: Date): number {
197
+ const msPerHour = 1000 * 60 * 60
198
+ return Math.abs(date1.getTime() - date2.getTime()) / msPerHour
199
+ }
200
+
201
+ function differenceInDays(date1: Date, date2: Date): number {
202
+ return Math.floor(differenceInHours(date1, date2) / 24)
203
+ }
204
+
205
+ function isSameDay(date1: Date, date2: Date): boolean {
206
+ return date1.toDateString() === date2.toDateString()
207
+ }
208
+
209
+ // ============================================
210
+ // REFERRER CATEGORIZATION
211
+ // ============================================
212
+
213
+ export function categorizeReferrer(referrerDomain: string): string {
214
+ const social = ['linkedin.com', 'twitter.com', 'x.com', 'facebook.com', 'instagram.com']
215
+ const search = ['google.com', 'bing.com', 'duckduckgo.com']
216
+
217
+ const domain = referrerDomain.toLowerCase()
218
+
219
+ if (social.some(s => domain.includes(s))) return 'social'
220
+ if (search.some(s => domain.includes(s))) return 'search'
221
+ if (!domain || domain === 'direct') return 'direct'
222
+
223
+ return 'referral'
224
+ }
225
+
226
+ // ============================================
227
+ // OPPORTUNITY TYPE INFERENCE
228
+ // ============================================
229
+
230
+ export function inferOpportunityType(
231
+ signals: Array<{ action?: { action_type: string } }>,
232
+ stage: string,
233
+ rating: number
234
+ ): { type: string; confidence: 'low' | 'medium' | 'high' } {
235
+ const actionTypes = signals.map(s => s.action?.action_type || '').join(' ')
236
+
237
+ // High-value signals indicate specific opportunities
238
+ if (actionTypes.includes('pricing') && rating >= 4) {
239
+ return { type: 'advanced_portal', confidence: 'high' }
240
+ }
241
+ if (actionTypes.includes('health_ping') && actionTypes.includes('production')) {
242
+ return { type: 'managed_services', confidence: 'high' }
243
+ }
244
+ if (actionTypes.includes('support_ticket') && rating >= 3) {
245
+ return { type: 'support_plan', confidence: 'medium' }
246
+ }
247
+ if (stage === 'installed' && rating >= 4) {
248
+ return { type: 'expansion', confidence: 'medium' }
249
+ }
250
+
251
+ // Default based on stage
252
+ if (stage === 'anonymous') return { type: 'implementation', confidence: 'low' }
253
+ if (stage === 'identified') return { type: 'advanced_portal', confidence: 'low' }
254
+
255
+ return { type: 'advocate', confidence: 'low' }
256
+ }