spine-framework-cortex 0.1.19 → 0.2.0
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/{functions/custom_cortex-handler.ts → api/cortex-handler.ts} +9 -9
- package/components/CliInstancesCard.tsx +144 -0
- package/components/CortexSidebar.tsx +27 -53
- package/hooks/useTypeRegistry.ts +74 -0
- package/index.tsx +13 -24
- package/manifest.json +1 -13
- package/package.json +11 -20
- package/pages/courses/CoursesPage.tsx +14 -4
- package/pages/crm/AccountDetailPage.tsx +149 -194
- package/pages/crm/ContactsPage.tsx +7 -7
- package/pages/intelligence/IntelligencePage.tsx +24 -31
- package/pages/kb/KBEditorPage.tsx +9 -2
- package/pages/operations/AuditFunnelPage.tsx +378 -0
- package/pages/operations/InstallFunnelPage.tsx +410 -0
- package/pages/operations/OperationsDashboard.tsx +275 -0
- package/pages/support/RedactionReview.tsx +11 -2
- package/seed/link-types.json +8 -42
- package/seed/package.json +27 -0
- package/seed/roles.json +1 -1
- package/seed/types.json +2711 -596
- package/CHANGELOG.md +0 -46
- package/LICENSE.md +0 -223
- package/README.md +0 -69
- package/functions/custom_anonymous-sessions.ts +0 -356
- package/functions/custom_case_analysis.ts +0 -507
- package/functions/custom_community-escalation.ts +0 -234
- package/functions/custom_cortex-chunks.ts +0 -52
- package/functions/custom_funnel-scoring.ts +0 -256
- package/functions/custom_funnel-signal.ts +0 -446
- package/functions/custom_funnel-timers.ts +0 -449
- package/functions/custom_kb-chunker-test.ts +0 -364
- package/functions/custom_kb-chunker.ts +0 -576
- package/functions/custom_kb-embeddings.ts +0 -481
- package/functions/custom_kb-ingestion.ts +0 -448
- package/functions/custom_support-triage.ts +0 -649
- package/functions/custom_tag_management.ts +0 -314
- package/functions/webhook-handlers.ts +0 -29
- package/lib/resolveTypeId.ts +0 -16
- package/pages/crm/ContactDetailPage.tsx +0 -184
- package/pages/ops/AuditFunnelPage.tsx +0 -191
- package/pages/ops/CommandCenterPage.tsx +0 -377
- package/pages/ops/InstallFunnelPage.tsx +0 -226
- package/seed/accounts.json +0 -9
- package/seed/integrations.json +0 -24
- package/seed/pipelines.json +0 -59
- package/seed/triggers.json +0 -125
|
@@ -1,449 +0,0 @@
|
|
|
1
|
-
// Funnel Timer Functions
|
|
2
|
-
// Uses ONLY Spine APIs (ctx.db) - NO direct database access
|
|
3
|
-
|
|
4
|
-
import { createHandler } from './_shared/middleware'
|
|
5
|
-
import { calculateRecency, calculateRawScore, inferOpportunityType } from './custom_funnel-scoring'
|
|
6
|
-
import { resolveTypeIds } from './_shared/resolve-ids'
|
|
7
|
-
|
|
8
|
-
async function resolveIds() {
|
|
9
|
-
const types = await resolveTypeIds([
|
|
10
|
-
{ kind: 'item', slug: 'anonymous_session' },
|
|
11
|
-
{ kind: 'item', slug: 'funnel_signal' },
|
|
12
|
-
{ kind: 'item', slug: 'funnel_aggregation' },
|
|
13
|
-
{ kind: 'item', slug: 'opportunity_queue' },
|
|
14
|
-
])
|
|
15
|
-
return {
|
|
16
|
-
TYPE_IDS: {
|
|
17
|
-
anonymous_session: types['item/anonymous_session'],
|
|
18
|
-
funnel_signal: types['item/funnel_signal'],
|
|
19
|
-
funnel_aggregation: types['item/funnel_aggregation'],
|
|
20
|
-
opportunity_queue: types['item/opportunity_queue'],
|
|
21
|
-
},
|
|
22
|
-
}
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
// ============================================
|
|
26
|
-
// TIMER 1: Score Decay (Daily at 11:59:59 PM)
|
|
27
|
-
// Updates account ratings based on recency decay
|
|
28
|
-
// ============================================
|
|
29
|
-
|
|
30
|
-
export const scoreDecay = createHandler(async (ctx, _body) => {
|
|
31
|
-
console.log('[FunnelTimer] Starting score decay recalculation')
|
|
32
|
-
const { TYPE_IDS } = await resolveIds()
|
|
33
|
-
|
|
34
|
-
const startTime = Date.now()
|
|
35
|
-
const yesterday = new Date()
|
|
36
|
-
yesterday.setDate(yesterday.getDate() - 1)
|
|
37
|
-
|
|
38
|
-
// Find accounts needing recalculation using ctx.db
|
|
39
|
-
const { data: accounts, error } = await ctx.db
|
|
40
|
-
.from('accounts')
|
|
41
|
-
.select('id, data')
|
|
42
|
-
.not('data->funnel->>current_stage', 'is', null)
|
|
43
|
-
.eq('is_active', true)
|
|
44
|
-
|
|
45
|
-
if (error) {
|
|
46
|
-
return { status: 'error', error: error.message, task: 'score_decay' }
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
let updated = 0
|
|
50
|
-
let skipped = 0
|
|
51
|
-
let errors = 0
|
|
52
|
-
|
|
53
|
-
for (const account of accounts || []) {
|
|
54
|
-
try {
|
|
55
|
-
const funnel = account.data?.funnel
|
|
56
|
-
if (!funnel) {
|
|
57
|
-
skipped++
|
|
58
|
-
continue
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
const ratings = funnel.ratings || {}
|
|
62
|
-
let hasChanges = false
|
|
63
|
-
const updatedRatings = { ...ratings }
|
|
64
|
-
|
|
65
|
-
// Check each stage for decay
|
|
66
|
-
for (const stage of ['anonymous', 'identified', 'installed'] as const) {
|
|
67
|
-
const stageRating = ratings[stage]
|
|
68
|
-
if (!stageRating) continue
|
|
69
|
-
|
|
70
|
-
// Get the best signal for this stage
|
|
71
|
-
const { data: signals } = await ctx.db
|
|
72
|
-
.from('items')
|
|
73
|
-
.select('data')
|
|
74
|
-
.eq('type_id', TYPE_IDS.funnel_signal)
|
|
75
|
-
.eq('account_id', account.id)
|
|
76
|
-
.eq('data->classification->>stage', stage)
|
|
77
|
-
.eq('is_active', true)
|
|
78
|
-
.order('data->processing->>scored_at', { ascending: false })
|
|
79
|
-
.limit(50)
|
|
80
|
-
|
|
81
|
-
if (!signals || signals.length === 0) continue
|
|
82
|
-
|
|
83
|
-
// Find best signal
|
|
84
|
-
let bestSignal = signals[0]
|
|
85
|
-
let bestScore = bestSignal.data?.scoring_components?.raw_score?.calculated || 0
|
|
86
|
-
|
|
87
|
-
for (const signal of signals) {
|
|
88
|
-
const score = signal.data?.scoring_components?.raw_score?.calculated || 0
|
|
89
|
-
if (score > bestScore) {
|
|
90
|
-
bestScore = score
|
|
91
|
-
bestSignal = signal
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
// Recalculate recency
|
|
96
|
-
const signalDate = new Date(bestSignal.data?.processing?.scored_at || bestSignal.created_at)
|
|
97
|
-
const recency = calculateRecency(signalDate, new Date(), stage)
|
|
98
|
-
|
|
99
|
-
if (recency.window === 'expired') {
|
|
100
|
-
// Score expired - rating drops to 0
|
|
101
|
-
if (stageRating.rating > 0) {
|
|
102
|
-
updatedRatings[stage] = {
|
|
103
|
-
...stageRating,
|
|
104
|
-
rating: 0,
|
|
105
|
-
raw_score: 0,
|
|
106
|
-
calculated_at: new Date().toISOString()
|
|
107
|
-
}
|
|
108
|
-
hasChanges = true
|
|
109
|
-
}
|
|
110
|
-
continue
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
// Recalculate score with current recency
|
|
114
|
-
const actionValue = bestSignal.data?.action?.action_value || 1
|
|
115
|
-
const engagementType = bestSignal.data?.scoring_components?.engagement?.type || 1
|
|
116
|
-
|
|
117
|
-
const newScore = calculateRawScore(
|
|
118
|
-
actionValue,
|
|
119
|
-
engagementType,
|
|
120
|
-
recency.divisor || 5
|
|
121
|
-
)
|
|
122
|
-
|
|
123
|
-
// Update if rating changed
|
|
124
|
-
if (newScore.rating !== stageRating.rating) {
|
|
125
|
-
updatedRatings[stage] = {
|
|
126
|
-
...stageRating,
|
|
127
|
-
rating: newScore.rating,
|
|
128
|
-
raw_score: newScore.calculated,
|
|
129
|
-
calculated_at: new Date().toISOString()
|
|
130
|
-
}
|
|
131
|
-
hasChanges = true
|
|
132
|
-
}
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
// Update account if changes were made
|
|
136
|
-
if (hasChanges) {
|
|
137
|
-
const maxRating = Math.max(
|
|
138
|
-
updatedRatings.anonymous?.rating || 0,
|
|
139
|
-
updatedRatings.identified?.rating || 0,
|
|
140
|
-
updatedRatings.installed?.rating || 0
|
|
141
|
-
)
|
|
142
|
-
|
|
143
|
-
const updatedFunnel = {
|
|
144
|
-
...funnel,
|
|
145
|
-
ratings: updatedRatings,
|
|
146
|
-
temperature: ratingToTemperature(maxRating)
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
await ctx.db
|
|
150
|
-
.from('accounts')
|
|
151
|
-
.update({
|
|
152
|
-
data: { ...account.data, funnel: updatedFunnel }
|
|
153
|
-
})
|
|
154
|
-
.eq('id', account.id)
|
|
155
|
-
|
|
156
|
-
updated++
|
|
157
|
-
} else {
|
|
158
|
-
skipped++
|
|
159
|
-
}
|
|
160
|
-
} catch (err) {
|
|
161
|
-
errors++
|
|
162
|
-
console.error(`[FunnelTimer] Failed to recalculate account ${account.id}:`, err)
|
|
163
|
-
}
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
const duration = Date.now() - startTime
|
|
167
|
-
console.log(`[FunnelTimer] Score decay complete: ${updated} updated, ${skipped} skipped, ${errors} errors, ${duration}ms`)
|
|
168
|
-
|
|
169
|
-
return {
|
|
170
|
-
status: 'success',
|
|
171
|
-
task: 'score_decay',
|
|
172
|
-
updated_count: updated,
|
|
173
|
-
skipped_count: skipped,
|
|
174
|
-
error_count: errors,
|
|
175
|
-
duration_ms: duration
|
|
176
|
-
}
|
|
177
|
-
})
|
|
178
|
-
|
|
179
|
-
// ============================================
|
|
180
|
-
// TIMER 2: Session Cleanup (Daily at 2:00 AM)
|
|
181
|
-
// Soft-deletes expired anonymous sessions
|
|
182
|
-
// ============================================
|
|
183
|
-
|
|
184
|
-
export const sessionCleanup = createHandler(async (ctx, _body) => {
|
|
185
|
-
console.log('[FunnelTimer] Starting anonymous session cleanup')
|
|
186
|
-
const { TYPE_IDS } = await resolveIds()
|
|
187
|
-
|
|
188
|
-
const startTime = Date.now()
|
|
189
|
-
const now = new Date().toISOString()
|
|
190
|
-
|
|
191
|
-
// Find expired sessions using ctx.db
|
|
192
|
-
const { data: sessions, error } = await ctx.db
|
|
193
|
-
.from('items')
|
|
194
|
-
.select('id, data')
|
|
195
|
-
.eq('type_id', TYPE_IDS.anonymous_session)
|
|
196
|
-
.eq('is_active', true)
|
|
197
|
-
.lt('data->retention->>purge_after', now)
|
|
198
|
-
.is('data->lifecycle->>stitched_at', null)
|
|
199
|
-
|
|
200
|
-
if (error) {
|
|
201
|
-
return { status: 'error', error: error.message, task: 'session_cleanup' }
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
let purged = 0
|
|
205
|
-
let errors = 0
|
|
206
|
-
|
|
207
|
-
for (const session of sessions || []) {
|
|
208
|
-
try {
|
|
209
|
-
// Soft delete - mark as inactive
|
|
210
|
-
await ctx.db
|
|
211
|
-
.from('items')
|
|
212
|
-
.update({ is_active: false, updated_at: now })
|
|
213
|
-
.eq('id', session.id)
|
|
214
|
-
|
|
215
|
-
purged++
|
|
216
|
-
console.log(`[FunnelTimer] Purged session: ${session.id}`)
|
|
217
|
-
} catch (err) {
|
|
218
|
-
errors++
|
|
219
|
-
console.error(`[FunnelTimer] Failed to purge session ${session.id}:`, err)
|
|
220
|
-
}
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
const duration = Date.now() - startTime
|
|
224
|
-
console.log(`[FunnelTimer] Cleanup complete: ${purged} purged, ${errors} errors, ${duration}ms`)
|
|
225
|
-
|
|
226
|
-
return {
|
|
227
|
-
status: 'success',
|
|
228
|
-
task: 'session_cleanup',
|
|
229
|
-
purged_count: purged,
|
|
230
|
-
error_count: errors,
|
|
231
|
-
duration_ms: duration
|
|
232
|
-
}
|
|
233
|
-
})
|
|
234
|
-
|
|
235
|
-
// ============================================
|
|
236
|
-
// TIMER 3: Aggregation (Hourly)
|
|
237
|
-
// Creates/updates funnel_aggregation items for dashboard cache
|
|
238
|
-
// ============================================
|
|
239
|
-
|
|
240
|
-
export const aggregation = createHandler(async (ctx, _body) => {
|
|
241
|
-
console.log('[FunnelTimer] Starting funnel aggregation')
|
|
242
|
-
const { TYPE_IDS } = await resolveIds()
|
|
243
|
-
|
|
244
|
-
const startTime = Date.now()
|
|
245
|
-
const now = new Date()
|
|
246
|
-
const periodEnd = now.toISOString()
|
|
247
|
-
const periodStart = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000).toISOString()
|
|
248
|
-
|
|
249
|
-
// 1. System-wide aggregation
|
|
250
|
-
try {
|
|
251
|
-
// Count accounts by stage
|
|
252
|
-
const { data: accounts } = await ctx.db
|
|
253
|
-
.from('accounts')
|
|
254
|
-
.select('data->funnel->>current_stage as stage, data->funnel->>temperature as temperature')
|
|
255
|
-
.eq('is_active', true)
|
|
256
|
-
|
|
257
|
-
const stageDistribution: Record<string, number> = { anonymous: 0, identified: 0, installed: 0, null: 0 }
|
|
258
|
-
const tempDistribution: Record<string, number> = { cold: 0, warm: 0, hot: 0, null: 0 }
|
|
259
|
-
|
|
260
|
-
for (const account of accounts || []) {
|
|
261
|
-
const stage = account.stage || 'null'
|
|
262
|
-
const temp = account.temperature || 'null'
|
|
263
|
-
stageDistribution[stage] = (stageDistribution[stage] || 0) + 1
|
|
264
|
-
tempDistribution[temp] = (tempDistribution[temp] || 0) + 1
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
// Count signals by source
|
|
268
|
-
const { data: signals } = await ctx.db
|
|
269
|
-
.from('items')
|
|
270
|
-
.select('data->classification->>source as source')
|
|
271
|
-
.eq('type_id', TYPE_IDS.funnel_signal)
|
|
272
|
-
.eq('is_active', true)
|
|
273
|
-
.gte('created_at', periodStart)
|
|
274
|
-
|
|
275
|
-
const signalVolume: Record<string, number> = {}
|
|
276
|
-
for (const signal of signals || []) {
|
|
277
|
-
const source = signal.source || 'unknown'
|
|
278
|
-
signalVolume[source] = (signalVolume[source] || 0) + 1
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
// Count queue entries
|
|
282
|
-
const { count: pendingQueue } = await ctx.db
|
|
283
|
-
.from('items')
|
|
284
|
-
.select('*', { count: 'exact', head: true })
|
|
285
|
-
.eq('type_id', TYPE_IDS.opportunity_queue)
|
|
286
|
-
.eq('data->review->>status', 'pending')
|
|
287
|
-
.eq('is_active', true)
|
|
288
|
-
|
|
289
|
-
// Check for existing system aggregation
|
|
290
|
-
const { data: existingSystemAgg } = await ctx.db
|
|
291
|
-
.from('items')
|
|
292
|
-
.select('id')
|
|
293
|
-
.eq('type_id', TYPE_IDS.funnel_aggregation)
|
|
294
|
-
.eq('data->identity->>aggregation_scope', 'system')
|
|
295
|
-
.eq('is_active', true)
|
|
296
|
-
.order('created_at', { ascending: false })
|
|
297
|
-
.limit(1)
|
|
298
|
-
|
|
299
|
-
const aggData = {
|
|
300
|
-
identity: {
|
|
301
|
-
account_id: null,
|
|
302
|
-
aggregation_scope: 'system'
|
|
303
|
-
},
|
|
304
|
-
metadata: {
|
|
305
|
-
computed_at: now.toISOString(),
|
|
306
|
-
period_start: periodStart,
|
|
307
|
-
period_end: periodEnd,
|
|
308
|
-
ttl_hours: 1
|
|
309
|
-
},
|
|
310
|
-
metrics: {
|
|
311
|
-
stage_distribution: stageDistribution,
|
|
312
|
-
temperature_distribution: tempDistribution,
|
|
313
|
-
signal_volume: signalVolume,
|
|
314
|
-
queue_summary: {
|
|
315
|
-
pending: pendingQueue || 0
|
|
316
|
-
},
|
|
317
|
-
total_accounts: accounts?.length || 0
|
|
318
|
-
}
|
|
319
|
-
}
|
|
320
|
-
|
|
321
|
-
if (existingSystemAgg?.[0]) {
|
|
322
|
-
// Update existing
|
|
323
|
-
await ctx.db
|
|
324
|
-
.from('items')
|
|
325
|
-
.update({
|
|
326
|
-
data: aggData,
|
|
327
|
-
updated_at: now.toISOString()
|
|
328
|
-
})
|
|
329
|
-
.eq('id', existingSystemAgg[0].id)
|
|
330
|
-
} else {
|
|
331
|
-
// Create new
|
|
332
|
-
await ctx.db
|
|
333
|
-
.from('items')
|
|
334
|
-
.insert({
|
|
335
|
-
type_id: TYPE_IDS.funnel_aggregation,
|
|
336
|
-
title: 'System Funnel Aggregation',
|
|
337
|
-
data: aggData
|
|
338
|
-
})
|
|
339
|
-
}
|
|
340
|
-
} catch (err) {
|
|
341
|
-
console.error('[FunnelTimer] Failed to create system aggregation:', err)
|
|
342
|
-
}
|
|
343
|
-
|
|
344
|
-
// 2. Per-account aggregation (top 100 accounts by rating)
|
|
345
|
-
try {
|
|
346
|
-
const { data: accounts } = await ctx.db
|
|
347
|
-
.from('accounts')
|
|
348
|
-
.select('id, data->funnel as funnel')
|
|
349
|
-
.not('data->funnel', 'is', null)
|
|
350
|
-
.eq('is_active', true)
|
|
351
|
-
.order('data->funnel->ratings->identified->>rating', { ascending: false })
|
|
352
|
-
.limit(100)
|
|
353
|
-
|
|
354
|
-
for (const account of accounts || []) {
|
|
355
|
-
try {
|
|
356
|
-
// Get recent signals for this account
|
|
357
|
-
const { data: accountSignals } = await ctx.db
|
|
358
|
-
.from('items')
|
|
359
|
-
.select('data->classification->>stage as stage, data->action->>action_type as action_type')
|
|
360
|
-
.eq('type_id', TYPE_IDS.funnel_signal)
|
|
361
|
-
.eq('account_id', account.id)
|
|
362
|
-
.eq('is_active', true)
|
|
363
|
-
.gte('created_at', periodStart)
|
|
364
|
-
|
|
365
|
-
const signalCounts: Record<string, number> = {}
|
|
366
|
-
for (const signal of accountSignals || []) {
|
|
367
|
-
const stage = signal.stage || 'unknown'
|
|
368
|
-
signalCounts[stage] = (signalCounts[stage] || 0) + 1
|
|
369
|
-
}
|
|
370
|
-
|
|
371
|
-
// Check for existing aggregation
|
|
372
|
-
const { data: existingAgg } = await ctx.db
|
|
373
|
-
.from('items')
|
|
374
|
-
.select('id')
|
|
375
|
-
.eq('type_id', TYPE_IDS.funnel_aggregation)
|
|
376
|
-
.eq('data->identity->>aggregation_scope', 'account')
|
|
377
|
-
.eq('data->identity->>account_id', account.id)
|
|
378
|
-
.eq('is_active', true)
|
|
379
|
-
.order('created_at', { ascending: false })
|
|
380
|
-
.limit(1)
|
|
381
|
-
|
|
382
|
-
const aggData = {
|
|
383
|
-
identity: {
|
|
384
|
-
account_id: account.id,
|
|
385
|
-
aggregation_scope: 'account'
|
|
386
|
-
},
|
|
387
|
-
metadata: {
|
|
388
|
-
computed_at: now.toISOString(),
|
|
389
|
-
period_start: periodStart,
|
|
390
|
-
period_end: periodEnd,
|
|
391
|
-
ttl_hours: 1
|
|
392
|
-
},
|
|
393
|
-
metrics: {
|
|
394
|
-
signal_counts: signalCounts,
|
|
395
|
-
current_stage: account.funnel?.current_stage,
|
|
396
|
-
temperature: account.funnel?.temperature,
|
|
397
|
-
best_rating: Math.max(
|
|
398
|
-
account.funnel?.ratings?.anonymous?.rating || 0,
|
|
399
|
-
account.funnel?.ratings?.identified?.rating || 0,
|
|
400
|
-
account.funnel?.ratings?.installed?.rating || 0
|
|
401
|
-
)
|
|
402
|
-
}
|
|
403
|
-
}
|
|
404
|
-
|
|
405
|
-
if (existingAgg?.[0]) {
|
|
406
|
-
await ctx.db
|
|
407
|
-
.from('items')
|
|
408
|
-
.update({
|
|
409
|
-
data: aggData,
|
|
410
|
-
updated_at: now.toISOString()
|
|
411
|
-
})
|
|
412
|
-
.eq('id', existingAgg[0].id)
|
|
413
|
-
} else {
|
|
414
|
-
await ctx.db
|
|
415
|
-
.from('items')
|
|
416
|
-
.insert({
|
|
417
|
-
type_id: TYPE_IDS.funnel_aggregation,
|
|
418
|
-
title: `Funnel Aggregation: ${account.id.slice(0, 8)}`,
|
|
419
|
-
account_id: account.id,
|
|
420
|
-
data: aggData
|
|
421
|
-
})
|
|
422
|
-
}
|
|
423
|
-
} catch (err) {
|
|
424
|
-
console.error(`[FunnelTimer] Failed to aggregate account ${account.id}:`, err)
|
|
425
|
-
}
|
|
426
|
-
}
|
|
427
|
-
} catch (err) {
|
|
428
|
-
console.error('[FunnelTimer] Failed to create account aggregations:', err)
|
|
429
|
-
}
|
|
430
|
-
|
|
431
|
-
const duration = Date.now() - startTime
|
|
432
|
-
console.log(`[FunnelTimer] Aggregation complete: ${duration}ms`)
|
|
433
|
-
|
|
434
|
-
return {
|
|
435
|
-
status: 'success',
|
|
436
|
-
task: 'aggregation',
|
|
437
|
-
duration_ms: duration
|
|
438
|
-
}
|
|
439
|
-
})
|
|
440
|
-
|
|
441
|
-
// ============================================
|
|
442
|
-
// UTILITY FUNCTIONS
|
|
443
|
-
// ============================================
|
|
444
|
-
|
|
445
|
-
function ratingToTemperature(rating: number): 'cold' | 'warm' | 'hot' {
|
|
446
|
-
if (rating <= 2) return 'cold'
|
|
447
|
-
if (rating <= 3) return 'warm'
|
|
448
|
-
return 'hot'
|
|
449
|
-
}
|