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,395 @@
1
+ import { useEffect, useState, useMemo } from 'react'
2
+ import { useNavigate } from 'react-router-dom'
3
+ import { apiFetch } from '@core/lib/api'
4
+ import { useAuth } from '@core/contexts/AuthContext'
5
+ import { Input } from '@core/components/ui/input'
6
+ import { Badge } from '@core/components/ui/badge'
7
+ import { Button } from '@core/components/ui/button'
8
+ import { Skeleton } from '@core/components/ui/skeleton'
9
+ import { Tabs, TabsList, TabsTrigger } from '@core/components/ui/tabs'
10
+ import { ScrollArea } from '@core/components/ui/scroll-area'
11
+ import { Headphones, Clock, ChevronRight, List, LayoutGrid, Filter, AlertCircle, User, Timer } from 'lucide-react'
12
+
13
+ interface Ticket {
14
+ id: string
15
+ title: string
16
+ status?: string
17
+ priority?: string
18
+ description?: string
19
+ created_at: string
20
+ account_id?: string
21
+ data?: {
22
+ aim_confidence_threshold?: number
23
+ aim_confidence_at_response?: number
24
+ aim_escalation_reason?: string
25
+ aim_human_assignee_id?: string
26
+ status?: string
27
+ }
28
+ }
29
+
30
+ // 3-Factor Priority Score Calculation
31
+ interface PriorityScore {
32
+ total: number
33
+ urgency: number
34
+ risk: number
35
+ staleness: number
36
+ explanation: string
37
+ }
38
+
39
+ function calculatePriorityScore(ticket: Ticket): PriorityScore {
40
+ // Urgency: Plan tier (core=1, custom=2, enterprise=3) - use priority as proxy
41
+ const tierWeights: Record<string, number> = { low: 1, medium: 2, high: 3, urgent: 3 }
42
+ const urgency = tierWeights[ticket.priority || 'low'] || 1
43
+
44
+ // Risk: Escalation reason
45
+ const escalationReason = ticket.data?.aim_escalation_reason
46
+ let risk = 2 // default medium
47
+ if (escalationReason === 'thumbs_down') risk = 3
48
+ else if (escalationReason === 'low_confidence') risk = 2
49
+ else if (escalationReason === 'customer_request') risk = 1
50
+
51
+ // Staleness: Hours since creation (capped at 72h)
52
+ const hoursSince = Math.min(
53
+ Math.floor((Date.now() - new Date(ticket.created_at).getTime()) / 3600000),
54
+ 72
55
+ )
56
+ const staleness = Math.ceil(hoursSince / 24) // 0-3 scale
57
+
58
+ // Weighted composite
59
+ const total = Math.round((urgency * 0.4 + risk * 0.35 + staleness * 0.25) / 3 * 100)
60
+
61
+ const explanationParts: string[] = []
62
+ if (urgency >= 3) explanationParts.push('High urgency')
63
+ if (risk >= 3) explanationParts.push('Negative feedback')
64
+ if (staleness >= 2) explanationParts.push(`${staleness}d stale`)
65
+
66
+ return {
67
+ total,
68
+ urgency,
69
+ risk,
70
+ staleness,
71
+ explanation: explanationParts.join(', ') || 'Standard priority'
72
+ }
73
+ }
74
+
75
+ const STATUS_BADGE: Record<string, string> = {
76
+ open: 'bg-blue-100 text-blue-700',
77
+ ai_responding: 'bg-purple-100 text-purple-700',
78
+ human_assigned: 'bg-amber-100 text-amber-700',
79
+ in_progress: 'bg-amber-100 text-amber-700',
80
+ resolved: 'bg-green-100 text-green-700',
81
+ closed: 'bg-muted text-muted-foreground',
82
+ }
83
+
84
+ function ageLabel(created_at: string) {
85
+ const diff = Date.now() - new Date(created_at).getTime()
86
+ const hours = Math.floor(diff / 3600000)
87
+ if (hours < 1) return 'just now'
88
+ if (hours < 24) return `${hours}h ago`
89
+ return `${Math.floor(hours / 24)}d ago`
90
+ }
91
+
92
+ type FilterType = 'all' | 'mine' | 'unassigned' | 'high_priority'
93
+ type ViewType = 'list' | 'kanban'
94
+
95
+ // Kanban columns based on AI-first workflow
96
+ const KANBAN_COLUMNS = [
97
+ { id: 'open', label: 'New', color: 'border-blue-200' },
98
+ { id: 'to_customer', label: 'To Customer', color: 'border-cyan-200' },
99
+ { id: 'ai_responding', label: 'AI Responding', color: 'border-purple-200' },
100
+ { id: 'human_assigned', label: 'Human Assigned', color: 'border-amber-200' },
101
+ { id: 'in_progress', label: 'In Progress', color: 'border-orange-200' },
102
+ { id: 'resolved', label: 'Resolved', color: 'border-green-200' },
103
+ { id: 'closed', label: 'Closed', color: 'border-gray-200' },
104
+ ]
105
+
106
+ export default function SupportPage() {
107
+ const navigate = useNavigate()
108
+ const { user } = useAuth()
109
+ const [tickets, setTickets] = useState<Ticket[]>([])
110
+ const [loading, setLoading] = useState(true)
111
+ const [search, setSearch] = useState('')
112
+ const [filter, setFilter] = useState<FilterType>('all')
113
+ const [view, setView] = useState<ViewType>('list')
114
+ const [myWatchedIds, setMyWatchedIds] = useState<Set<string>>(new Set())
115
+ const [showAllStatuses, setShowAllStatuses] = useState(false)
116
+ const [showClosed, setShowClosed] = useState(false)
117
+
118
+ useEffect(() => {
119
+ apiFetch('/api/admin-data?action=list&entity=items&type_slug=support_ticket&limit=500')
120
+ .then(r => r.json())
121
+ .then(j => setTickets(Array.isArray(j?.data) ? j.data : Array.isArray(j) ? j : []))
122
+ .catch(() => setTickets([]))
123
+ .finally(() => setLoading(false))
124
+ }, [])
125
+
126
+ // Fetch watched ticket IDs for current user
127
+ useEffect(() => {
128
+ if (!user?.id) return
129
+ apiFetch(`/api/admin-data?action=list&entity=watchers&target_type=item&person_id=${user.id}&limit=500`)
130
+ .then(r => r.json())
131
+ .then(j => {
132
+ const watchers = Array.isArray(j?.data) ? j.data : Array.isArray(j) ? j : []
133
+ setMyWatchedIds(new Set(watchers.map((w: any) => w.target_id)))
134
+ })
135
+ .catch(() => setMyWatchedIds(new Set()))
136
+ }, [user?.id])
137
+
138
+ // Calculate priority scores for all tickets
139
+ const ticketsWithScores = useMemo(() => {
140
+ return tickets.map(t => ({
141
+ ...t,
142
+ score: calculatePriorityScore(t),
143
+ effectiveStatus: t.data?.status || t.status
144
+ }))
145
+ }, [tickets])
146
+
147
+ const filtered = ticketsWithScores.filter(t => {
148
+ const matchesSearch = !search || t.title.toLowerCase().includes(search.toLowerCase())
149
+ const matchesFilter =
150
+ filter === 'all' ||
151
+ (filter === 'high_priority' && t.score.total >= 70) ||
152
+ (filter === 'unassigned' && !t.data?.aim_human_assignee_id) ||
153
+ (filter === 'mine' && myWatchedIds.has(t.id))
154
+ return matchesSearch && matchesFilter
155
+ })
156
+
157
+ // Sort by priority score descending, then by age
158
+ const sorted = [...filtered].sort((a, b) => {
159
+ if (b.score.total !== a.score.total) return b.score.total - a.score.total
160
+ return new Date(a.created_at).getTime() - new Date(b.created_at).getTime()
161
+ })
162
+
163
+ const counts = {
164
+ all: tickets.length,
165
+ new: tickets.filter(t => (t.data?.status || t.status) === 'open').length,
166
+ ai_responding: tickets.filter(t => (t.data?.status || t.status) === 'ai_responding').length,
167
+ human_assigned: tickets.filter(t => (t.data?.status || t.status) === 'human_assigned').length,
168
+ resolved: tickets.filter(t => (t.data?.status || t.status) === 'resolved').length,
169
+ high_priority: ticketsWithScores.filter(t => t.score.total >= 70).length,
170
+ }
171
+
172
+ // Kanban view: group by status with toggle filters
173
+ const kanbanGroups = useMemo(() => {
174
+ const groups: Record<string, typeof sorted> = {}
175
+ KANBAN_COLUMNS.forEach(col => {
176
+ let columnTickets = sorted.filter(t => t.effectiveStatus === col.id)
177
+
178
+ // Apply showClosed toggle (hide closed if toggle is off)
179
+ if (!showClosed && col.id === 'closed') {
180
+ columnTickets = []
181
+ }
182
+
183
+ groups[col.id] = columnTickets
184
+ })
185
+
186
+ // Apply showAllStatuses toggle (hide empty columns if toggle is off)
187
+ if (!showAllStatuses) {
188
+ Object.keys(groups).forEach(key => {
189
+ if (groups[key].length === 0) {
190
+ delete groups[key]
191
+ }
192
+ })
193
+ }
194
+
195
+ return groups
196
+ }, [sorted, showAllStatuses, showClosed])
197
+
198
+ const renderListView = () => (
199
+ <div className="border rounded-lg overflow-hidden">
200
+ {loading ? (
201
+ <div className="p-4 space-y-3">{Array.from({ length: 5 }).map((_, i) => <Skeleton key={i} className="h-14 w-full" />)}</div>
202
+ ) : sorted.length === 0 ? (
203
+ <div className="p-12 text-center text-muted-foreground">
204
+ <Headphones className="h-10 w-10 mx-auto mb-3 opacity-30" />
205
+ <p className="text-sm">{search ? 'No tickets match your search.' : 'No tickets found.'}</p>
206
+ </div>
207
+ ) : (
208
+ <table className="w-full text-sm">
209
+ <thead>
210
+ <tr className="text-xs text-muted-foreground uppercase tracking-wider border-b bg-muted/30">
211
+ <th className="text-left px-5 py-3 font-medium">Score</th>
212
+ <th className="text-left px-5 py-3 font-medium">Subject</th>
213
+ <th className="text-left px-5 py-3 font-medium">Status</th>
214
+ <th className="text-left px-5 py-3 font-medium">Why</th>
215
+ <th className="text-right px-5 py-3 font-medium">Time Waiting</th>
216
+ <th className="px-3" />
217
+ </tr>
218
+ </thead>
219
+ <tbody className="divide-y">
220
+ {sorted.map(ticket => (
221
+ <tr
222
+ key={ticket.id}
223
+ onClick={() => navigate(`/cortex/support/${ticket.id}`)}
224
+ className="hover:bg-accent/50 cursor-pointer transition-colors"
225
+ >
226
+ <td className="px-5 py-3">
227
+ <div className="flex items-center gap-2">
228
+ <Badge
229
+ variant={ticket.score.total >= 70 ? 'destructive' : ticket.score.total >= 50 ? 'default' : 'secondary'}
230
+ className="font-mono"
231
+ >
232
+ {ticket.score.total}
233
+ </Badge>
234
+ </div>
235
+ </td>
236
+ <td className="px-5 py-3">
237
+ <p className="font-medium truncate max-w-xs">{ticket.title}</p>
238
+ {ticket.description && <p className="text-xs text-muted-foreground truncate max-w-xs mt-0.5">{ticket.description}</p>}
239
+ </td>
240
+ <td className="px-5 py-3">
241
+ <span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${STATUS_BADGE[ticket.effectiveStatus] || 'bg-muted text-muted-foreground'}`}>
242
+ {ticket.effectiveStatus?.replace('_', ' ')}
243
+ </span>
244
+ </td>
245
+ <td className="px-5 py-3">
246
+ <span className="text-xs text-muted-foreground">{ticket.score.explanation}</span>
247
+ </td>
248
+ <td className="px-5 py-3 text-right">
249
+ <span className="flex items-center justify-end gap-1 text-xs text-muted-foreground">
250
+ <Clock className="h-3 w-3" /> {ageLabel(ticket.created_at)}
251
+ </span>
252
+ </td>
253
+ <td className="px-3 py-3"><ChevronRight className="h-4 w-4 text-muted-foreground/50" /></td>
254
+ </tr>
255
+ ))}
256
+ </tbody>
257
+ </table>
258
+ )}
259
+ </div>
260
+ )
261
+
262
+ const renderKanbanView = () => (
263
+ <div>
264
+ {/* Kanban Controls */}
265
+ <div className="flex items-center gap-4 mb-4 px-1">
266
+ <div className="flex items-center gap-2">
267
+ <input
268
+ type="checkbox"
269
+ id="show-all-statuses"
270
+ checked={showAllStatuses}
271
+ onChange={(e) => setShowAllStatuses(e.target.checked)}
272
+ className="h-4 w-4 text-primary border-gray-300 rounded focus:ring-primary"
273
+ />
274
+ <label htmlFor="show-all-statuses" className="text-sm font-medium text-gray-700">
275
+ Show all statuses
276
+ </label>
277
+ </div>
278
+ <div className="flex items-center gap-2">
279
+ <input
280
+ type="checkbox"
281
+ id="show-closed"
282
+ checked={showClosed}
283
+ onChange={(e) => setShowClosed(e.target.checked)}
284
+ className="h-4 w-4 text-primary border-gray-300 rounded focus:ring-primary"
285
+ />
286
+ <label htmlFor="show-closed" className="text-sm font-medium text-gray-700">
287
+ Show closed cases
288
+ </label>
289
+ </div>
290
+ </div>
291
+
292
+ {/* Kanban Board */}
293
+ <div className="flex gap-4 overflow-x-auto pb-4">
294
+ {Object.entries(kanbanGroups).map(([columnId, tickets]) => {
295
+ const column = KANBAN_COLUMNS.find(col => col.id === columnId)
296
+ if (!column) return null
297
+ return (
298
+ <div key={columnId} className={`w-80 shrink-0 flex flex-col ${column.color} border-t-4 rounded-lg bg-muted/20`}>
299
+ <div className="px-3 py-2 border-b border-border/50 flex items-center justify-between">
300
+ <span className="text-sm font-medium">{column.label}</span>
301
+ <Badge variant="secondary" className="text-xs">{tickets?.length || 0}</Badge>
302
+ </div>
303
+ <ScrollArea className="flex-1 h-[calc(100vh-280px)]">
304
+ <div className="p-2 space-y-2">
305
+ {tickets?.map(ticket => (
306
+ <div
307
+ key={ticket.id}
308
+ onClick={() => navigate(`/cortex/support/${ticket.id}`)}
309
+ className="bg-background border rounded-lg p-3 cursor-pointer hover:shadow-sm transition-shadow"
310
+ >
311
+ <div className="flex items-start justify-between gap-2">
312
+ <p className="text-sm font-medium line-clamp-2 flex-1">{ticket.title}</p>
313
+ <Badge
314
+ variant={ticket.score.total >= 70 ? 'destructive' : 'secondary'}
315
+ className="text-xs font-mono shrink-0"
316
+ >
317
+ {ticket.score.total}
318
+ </Badge>
319
+ </div>
320
+ <div className="flex items-center gap-3 mt-2 text-xs text-muted-foreground">
321
+ <span className="flex items-center gap-1">
322
+ <Timer className="h-3 w-3" />
323
+ {ageLabel(ticket.created_at)}
324
+ </span>
325
+ {ticket.data?.aim_human_assignee_id && (
326
+ <span className="flex items-center gap-1">
327
+ <User className="h-3 w-3" />
328
+ Assigned
329
+ </span>
330
+ )}
331
+ </div>
332
+ </div>
333
+ ))}
334
+ {(!tickets || tickets.length === 0) && (
335
+ <div className="text-center py-8 text-xs text-muted-foreground">
336
+ No tickets
337
+ </div>
338
+ )}
339
+ </div>
340
+ </ScrollArea>
341
+ </div>
342
+ )
343
+ })}
344
+ </div>
345
+ </div>
346
+ )
347
+
348
+ return (
349
+ <div className="p-6 space-y-5">
350
+ <div className="flex items-center justify-between">
351
+ <div>
352
+ <h1 className="text-2xl font-bold">Support Queue</h1>
353
+ <p className="text-muted-foreground text-sm mt-1">
354
+ {tickets.length} tickets · {counts.new} new · {counts.human_assigned} need human attention
355
+ </p>
356
+ </div>
357
+ <div className="flex items-center gap-2">
358
+ <Tabs value={view} onValueChange={v => setView(v as ViewType)}>
359
+ <TabsList>
360
+ <TabsTrigger value="list" className="gap-1">
361
+ <List className="h-4 w-4" /> List
362
+ </TabsTrigger>
363
+ <TabsTrigger value="kanban" className="gap-1">
364
+ <LayoutGrid className="h-4 w-4" /> Kanban
365
+ </TabsTrigger>
366
+ </TabsList>
367
+ </Tabs>
368
+ </div>
369
+ </div>
370
+
371
+ <div className="flex items-center gap-3">
372
+ <Input value={search} onChange={e => setSearch(e.target.value)} placeholder="Search tickets…" className="w-72" />
373
+ <Tabs value={filter} onValueChange={v => setFilter(v as FilterType)}>
374
+ <TabsList>
375
+ <TabsTrigger value="all">All <Badge variant="secondary" className="ml-1.5">{counts.all}</Badge></TabsTrigger>
376
+ <TabsTrigger value="high_priority">
377
+ <AlertCircle className="h-3 w-3 mr-1" />
378
+ High Priority <Badge variant="secondary" className="ml-1.5">{counts.high_priority}</Badge>
379
+ </TabsTrigger>
380
+ <TabsTrigger value="unassigned">
381
+ <Filter className="h-3 w-3 mr-1" />
382
+ Unassigned
383
+ </TabsTrigger>
384
+ <TabsTrigger value="mine">
385
+ <User className="h-3 w-3 mr-1" />
386
+ My Tickets
387
+ </TabsTrigger>
388
+ </TabsList>
389
+ </Tabs>
390
+ </div>
391
+
392
+ {view === 'list' ? renderListView() : renderKanbanView()}
393
+ </div>
394
+ )
395
+ }