spine-framework-cortex 0.2.19 → 0.2.21
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_case_analysis.ts +507 -0
- package/functions/custom_cortex-chunks.ts +52 -0
- package/functions/custom_cortex-handler.ts +35 -0
- package/functions/custom_kb-chunker-test.ts +364 -0
- package/functions/custom_kb-chunker.ts +576 -0
- package/functions/custom_kb-embeddings.ts +472 -0
- package/functions/custom_kb-ingestion.ts +447 -0
- package/functions/custom_tag_management.ts +314 -0
- package/manifest.json +1 -0
- package/package.json +1 -1
- package/pages/courses/CoursesPage.tsx +1 -1
- package/pages/kb/KBEditorPage.tsx +1 -1
- package/pages/support/RedactionReview.tsx +1 -1
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
import { createHandler } from './_shared/middleware'
|
|
2
|
+
import { adminDb } from './_shared/db'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Custom Tag Management Handler
|
|
6
|
+
*
|
|
7
|
+
* Actions:
|
|
8
|
+
* - POST ?action=create_or_get_tag - Get existing tag or create new one
|
|
9
|
+
* - POST ?action=list_tags - List tags with filtering
|
|
10
|
+
* - POST ?action=update_tag_usage - Increment tag usage count
|
|
11
|
+
* - POST ?action=merge_tags - Merge duplicate tags
|
|
12
|
+
*
|
|
13
|
+
* Provides centralized tag management for case analysis and other systems.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
// ─── CONSTANTS ────────────────────────────────────────────────────────────────
|
|
17
|
+
|
|
18
|
+
const TAG_TYPE_ID = 'tag' // Will be looked up by slug
|
|
19
|
+
|
|
20
|
+
// ─── TYPES ────────────────────────────────────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
interface TagRequest {
|
|
23
|
+
slug: string
|
|
24
|
+
name: string
|
|
25
|
+
purpose?: string
|
|
26
|
+
category: 'bug_classification' | 'knowledge_value' | 'process_type' | 'sentiment'
|
|
27
|
+
applicable_to?: string[]
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface TagResponse {
|
|
31
|
+
id: string
|
|
32
|
+
slug: string
|
|
33
|
+
name: string
|
|
34
|
+
purpose?: string
|
|
35
|
+
category: string
|
|
36
|
+
applicable_to: string[]
|
|
37
|
+
usage_count: number
|
|
38
|
+
created_at: string
|
|
39
|
+
updated_at: string
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface ListTagsRequest {
|
|
43
|
+
category?: string
|
|
44
|
+
applicable_to?: string
|
|
45
|
+
limit?: number
|
|
46
|
+
offset?: number
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// ─── HELPERS ──────────────────────────────────────────────────────────────────
|
|
50
|
+
|
|
51
|
+
async function getTagTypeId(): Promise<string> {
|
|
52
|
+
const { data: tagType } = await adminDb
|
|
53
|
+
.from('types')
|
|
54
|
+
.select('id')
|
|
55
|
+
.eq('slug', 'tag')
|
|
56
|
+
.single()
|
|
57
|
+
|
|
58
|
+
if (!tagType) throw new Error('Tag type not found')
|
|
59
|
+
return tagType.id
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function findExistingTag(slug: string): Promise<TagResponse | null> {
|
|
63
|
+
const { data: tag } = await adminDb
|
|
64
|
+
.from('items')
|
|
65
|
+
.select('*')
|
|
66
|
+
.eq('type_id', await getTagTypeId())
|
|
67
|
+
.eq('data->>slug', slug)
|
|
68
|
+
.single()
|
|
69
|
+
|
|
70
|
+
if (!tag) return null
|
|
71
|
+
|
|
72
|
+
return {
|
|
73
|
+
id: tag.id,
|
|
74
|
+
slug: tag.data?.slug || '',
|
|
75
|
+
name: tag.title,
|
|
76
|
+
purpose: tag.description,
|
|
77
|
+
category: tag.data?.category || '',
|
|
78
|
+
applicable_to: tag.data?.applicable_to || ['ticket'],
|
|
79
|
+
usage_count: tag.data?.usage_count || 0,
|
|
80
|
+
created_at: tag.created_at,
|
|
81
|
+
updated_at: tag.updated_at
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function createTag(
|
|
86
|
+
tagData: TagRequest,
|
|
87
|
+
accountId: string
|
|
88
|
+
): Promise<TagResponse> {
|
|
89
|
+
const tagTypeId = await getTagTypeId()
|
|
90
|
+
|
|
91
|
+
const { data: tag, error } = await adminDb
|
|
92
|
+
.from('items')
|
|
93
|
+
.insert({
|
|
94
|
+
type_id: tagTypeId,
|
|
95
|
+
account_id: accountId,
|
|
96
|
+
title: tagData.name,
|
|
97
|
+
description: tagData.purpose,
|
|
98
|
+
data: {
|
|
99
|
+
slug: tagData.slug,
|
|
100
|
+
name: tagData.name,
|
|
101
|
+
purpose: tagData.purpose,
|
|
102
|
+
applicable_to: tagData.applicable_to || ['ticket'],
|
|
103
|
+
category: tagData.category,
|
|
104
|
+
usage_count: 1
|
|
105
|
+
},
|
|
106
|
+
status: 'active'
|
|
107
|
+
})
|
|
108
|
+
.select('*')
|
|
109
|
+
.single()
|
|
110
|
+
|
|
111
|
+
if (error || !tag) throw new Error(`Failed to create tag: ${error?.message}`)
|
|
112
|
+
|
|
113
|
+
return {
|
|
114
|
+
id: tag.id,
|
|
115
|
+
slug: tag.data?.slug || '',
|
|
116
|
+
name: tag.title,
|
|
117
|
+
purpose: tag.description,
|
|
118
|
+
category: tag.data?.category || '',
|
|
119
|
+
applicable_to: tag.data?.applicable_to || ['ticket'],
|
|
120
|
+
usage_count: tag.data?.usage_count || 0,
|
|
121
|
+
created_at: tag.created_at,
|
|
122
|
+
updated_at: tag.updated_at
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async function incrementTagUsage(tagId: string): Promise<void> {
|
|
127
|
+
await adminDb
|
|
128
|
+
.from('items')
|
|
129
|
+
.update({
|
|
130
|
+
data: adminDb.sql`jsonb_set(data, '{usage_count}', COALESCE((data->>'usage_count')::int, 0) + 1)`,
|
|
131
|
+
updated_at: new Date().toISOString()
|
|
132
|
+
})
|
|
133
|
+
.eq('id', tagId)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// ─── ACTIONS ───────────────────────────────────────────────────────────────────
|
|
137
|
+
|
|
138
|
+
async function handleCreateOrGetTag(
|
|
139
|
+
ctx: any,
|
|
140
|
+
body: TagRequest
|
|
141
|
+
): Promise<TagResponse> {
|
|
142
|
+
const account_id = ctx.accountId as string
|
|
143
|
+
|
|
144
|
+
if (!account_id) throw new Error('Account context required')
|
|
145
|
+
if (!body.slug || !body.name || !body.category) {
|
|
146
|
+
throw new Error('slug, name, and category are required')
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Validate slug format
|
|
150
|
+
if (!/^[a-z0-9_-]+$/.test(body.slug)) {
|
|
151
|
+
throw new Error('Slug must contain only lowercase letters, numbers, hyphens, and underscores')
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Try to find existing tag
|
|
155
|
+
const existingTag = await findExistingTag(body.slug)
|
|
156
|
+
|
|
157
|
+
if (existingTag) {
|
|
158
|
+
// Increment usage count
|
|
159
|
+
await incrementTagUsage(existingTag.id)
|
|
160
|
+
|
|
161
|
+
// Return updated tag with incremented count
|
|
162
|
+
const updatedTag = await findExistingTag(body.slug)
|
|
163
|
+
if (!updatedTag) throw new Error('Failed to retrieve updated tag')
|
|
164
|
+
|
|
165
|
+
return updatedTag
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Create new tag
|
|
169
|
+
return await createTag(body, account_id)
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
async function handleListTags(
|
|
173
|
+
ctx: any,
|
|
174
|
+
body: ListTagsRequest
|
|
175
|
+
): Promise<{ tags: TagResponse[]; total: number }> {
|
|
176
|
+
const { category, applicable_to, limit = 50, offset = 0 } = body
|
|
177
|
+
const tagTypeId = await getTagTypeId()
|
|
178
|
+
|
|
179
|
+
let query = adminDb
|
|
180
|
+
.from('items')
|
|
181
|
+
.select('*', { count: 'exact' })
|
|
182
|
+
.eq('type_id', tagTypeId)
|
|
183
|
+
.eq('status', 'active')
|
|
184
|
+
.order('data->>usage_count', { ascending: false })
|
|
185
|
+
.range(offset, offset + limit - 1)
|
|
186
|
+
|
|
187
|
+
// Add filters
|
|
188
|
+
if (category) {
|
|
189
|
+
query = query.eq('data->>category', category)
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (applicable_to) {
|
|
193
|
+
query = query.contains('data->>applicable_to', [applicable_to])
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const { data: tags, error, count } = await query
|
|
197
|
+
|
|
198
|
+
if (error) throw new Error(`Failed to list tags: ${error?.message}`)
|
|
199
|
+
|
|
200
|
+
const formattedTags: TagResponse[] = (tags || []).map(tag => ({
|
|
201
|
+
id: tag.id,
|
|
202
|
+
slug: tag.data?.slug || '',
|
|
203
|
+
name: tag.title,
|
|
204
|
+
purpose: tag.description,
|
|
205
|
+
category: tag.data?.category || '',
|
|
206
|
+
applicable_to: tag.data?.applicable_to || ['ticket'],
|
|
207
|
+
usage_count: tag.data?.usage_count || 0,
|
|
208
|
+
created_at: tag.created_at,
|
|
209
|
+
updated_at: tag.updated_at
|
|
210
|
+
}))
|
|
211
|
+
|
|
212
|
+
return {
|
|
213
|
+
tags: formattedTags,
|
|
214
|
+
total: count || 0
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
async function handleUpdateTagUsage(
|
|
219
|
+
ctx: any,
|
|
220
|
+
body: { tag_id: string }
|
|
221
|
+
): Promise<void> {
|
|
222
|
+
if (!body.tag_id) throw new Error('tag_id is required')
|
|
223
|
+
|
|
224
|
+
await incrementTagUsage(body.tag_id)
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
async function handleMergeTags(
|
|
228
|
+
ctx: any,
|
|
229
|
+
body: { source_tag_id: string; target_tag_id: string }
|
|
230
|
+
): Promise<{ merged_count: number }> {
|
|
231
|
+
const { source_tag_id, target_tag_id } = body
|
|
232
|
+
|
|
233
|
+
if (!source_tag_id || !target_tag_id) {
|
|
234
|
+
throw new Error('source_tag_id and target_tag_id are required')
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
if (source_tag_id === target_tag_id) {
|
|
238
|
+
throw new Error('Source and target tags cannot be the same')
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Get source tag info
|
|
242
|
+
const { data: sourceTag } = await adminDb
|
|
243
|
+
.from('items')
|
|
244
|
+
.select('data')
|
|
245
|
+
.eq('id', source_tag_id)
|
|
246
|
+
.single()
|
|
247
|
+
|
|
248
|
+
if (!sourceTag) throw new Error('Source tag not found')
|
|
249
|
+
|
|
250
|
+
// Update all references to source tag to point to target tag
|
|
251
|
+
const sourceTagSlug = sourceTag.data?.slug
|
|
252
|
+
|
|
253
|
+
// Update ticket analysis_tags arrays
|
|
254
|
+
const { data: ticketsToUpdate } = await adminDb
|
|
255
|
+
.from('items')
|
|
256
|
+
.select('id, data')
|
|
257
|
+
.eq('status', 'active')
|
|
258
|
+
.contains('data->>case_analysis->>analysis_tags', [source_tag_id])
|
|
259
|
+
|
|
260
|
+
let mergedCount = 0
|
|
261
|
+
|
|
262
|
+
for (const ticket of ticketsToUpdate || []) {
|
|
263
|
+
const caseAnalysis = ticket.data?.case_analysis || {}
|
|
264
|
+
const analysisTags = caseAnalysis.analysis_tags || []
|
|
265
|
+
|
|
266
|
+
// Replace source tag with target tag
|
|
267
|
+
const updatedTags = analysisTags.map((tagId: string) =>
|
|
268
|
+
tagId === source_tag_id ? target_tag_id : tagId
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
await adminDb
|
|
272
|
+
.from('items')
|
|
273
|
+
.update({
|
|
274
|
+
data: adminDb.sql`jsonb_set(data, '{case_analysis,analysis_tags}', ${updatedTags}::jsonb)`,
|
|
275
|
+
updated_at: new Date().toISOString()
|
|
276
|
+
})
|
|
277
|
+
.eq('id', ticket.id)
|
|
278
|
+
|
|
279
|
+
mergedCount++
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// Increment target tag usage by source tag's usage count
|
|
283
|
+
const sourceUsage = sourceTag.data?.usage_count || 0
|
|
284
|
+
for (let i = 0; i < sourceUsage; i++) {
|
|
285
|
+
await incrementTagUsage(target_tag_id)
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// Delete source tag
|
|
289
|
+
await adminDb
|
|
290
|
+
.from('items')
|
|
291
|
+
.update({ status: 'deleted', updated_at: new Date().toISOString() })
|
|
292
|
+
.eq('id', source_tag_id)
|
|
293
|
+
|
|
294
|
+
return { merged_count }
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// ─── HANDLER ──────────────────────────────────────────────────────────────────
|
|
298
|
+
|
|
299
|
+
export const handler = createHandler(async (ctx, body) => {
|
|
300
|
+
const action = ctx.query?.action
|
|
301
|
+
|
|
302
|
+
switch (action) {
|
|
303
|
+
case 'create_or_get_tag':
|
|
304
|
+
return await handleCreateOrGetTag(ctx, body)
|
|
305
|
+
case 'list_tags':
|
|
306
|
+
return await handleListTags(ctx, body)
|
|
307
|
+
case 'update_tag_usage':
|
|
308
|
+
return await handleUpdateTagUsage(ctx, body)
|
|
309
|
+
case 'merge_tags':
|
|
310
|
+
return await handleMergeTags(ctx, body)
|
|
311
|
+
default:
|
|
312
|
+
throw new Error(`Unknown action: ${action}. Use create_or_get_tag, list_tags, update_tag_usage, or merge_tags.`)
|
|
313
|
+
}
|
|
314
|
+
})
|
package/manifest.json
CHANGED
|
@@ -86,6 +86,7 @@
|
|
|
86
86
|
],
|
|
87
87
|
"features": ["crm", "support", "community", "kb", "courses", "intelligence"],
|
|
88
88
|
"dependencies": ["items", "accounts", "pipelines", "integrations"],
|
|
89
|
+
"prod_domain": "cortex.spine-framework.com",
|
|
89
90
|
"entry_point": "./index.tsx",
|
|
90
91
|
"sidebar_component": "./components/CortexSidebar.tsx",
|
|
91
92
|
"seed": [
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { useState, useEffect, useRef } from 'react'
|
|
2
2
|
import { useNavigate } from 'react-router-dom'
|
|
3
3
|
import { apiFetch } from '@core/lib/api'
|
|
4
|
-
import { getTypeIdAsync } from '
|
|
4
|
+
import { getTypeIdAsync } from '../../hooks/useTypeRegistry'
|
|
5
5
|
import { Button } from '@core/components/ui/button'
|
|
6
6
|
import { Input } from '@core/components/ui/input'
|
|
7
7
|
import { Badge } from '@core/components/ui/badge'
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { useState, useEffect } from 'react'
|
|
2
2
|
import { useParams, useNavigate } from 'react-router-dom'
|
|
3
3
|
import { apiFetch } from '@core/lib/api'
|
|
4
|
-
import { getTypeIdAsync } from '
|
|
4
|
+
import { getTypeIdAsync } from '../../hooks/useTypeRegistry'
|
|
5
5
|
import { RichTextEditor } from '@core/components/ui/RichTextEditor'
|
|
6
6
|
import { Button } from '@core/components/ui/button'
|
|
7
7
|
import { Input } from '@core/components/ui/input'
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { useState, useEffect, useCallback } from 'react'
|
|
2
2
|
import { useParams, useNavigate } from 'react-router-dom'
|
|
3
3
|
import { apiFetch } from '@core/lib/api'
|
|
4
|
-
import { getTypeIdAsync } from '
|
|
4
|
+
import { getTypeIdAsync } from '../../hooks/useTypeRegistry'
|
|
5
5
|
import { Button } from '@core/components/ui/button'
|
|
6
6
|
import { Badge } from '@core/components/ui/badge'
|
|
7
7
|
import { ScrollArea } from '@core/components/ui/scroll-area'
|