spine-framework-cortex 0.2.21 → 0.2.22
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 +224 -439
- package/functions/custom_kb-embeddings.ts +129 -22
- package/functions/custom_support-redaction.ts +115 -0
- package/functions/custom_support-solution.ts +104 -0
- package/manifest.json +9 -5
- package/package.json +1 -1
- package/pages/support/RedactionReview.tsx +36 -18
- package/pages/support/TicketDetailPage.tsx +127 -129
- package/seed/ai-agents.json +98 -0
- package/seed/pipelines.json +29 -0
- package/seed/prompt-configs.json +84 -0
- package/LICENSE.md +0 -193
- package/README.md +0 -46
- package/functions/custom_cortex-handler.ts +0 -35
- package/functions/custom_kb-chunker-test.ts +0 -364
- package/functions/custom_kb-ingestion.ts +0 -447
- package/functions/custom_tag_management.ts +0 -314
|
@@ -4,7 +4,7 @@ import { chunkArticle, htmlToPlainText } from './custom_kb-chunker'
|
|
|
4
4
|
|
|
5
5
|
interface KBEmbeddingRequest {
|
|
6
6
|
item_id: string
|
|
7
|
-
vector_types: ('semantic' | 'structure' | 'code')[]
|
|
7
|
+
vector_types: ('semantic' | 'structure' | 'summary' | 'code')[]
|
|
8
8
|
force_regenerate?: boolean
|
|
9
9
|
}
|
|
10
10
|
|
|
@@ -109,6 +109,15 @@ async function generateEmbeddingBatch(inputs: string[]): Promise<string[]> {
|
|
|
109
109
|
return sorted.map((d: any) => `[${d.embedding.join(',')}]`)
|
|
110
110
|
}
|
|
111
111
|
|
|
112
|
+
// Resolve platform account ID at runtime (slug: 'spine-system')
|
|
113
|
+
let _platformAccountId: string | null = null
|
|
114
|
+
async function getPlatformAccountId(): Promise<string | null> {
|
|
115
|
+
if (_platformAccountId) return _platformAccountId
|
|
116
|
+
const { data } = await adminDb.from('accounts').select('id').eq('slug', 'spine-system').maybeSingle()
|
|
117
|
+
_platformAccountId = data?.id ?? null
|
|
118
|
+
return _platformAccountId
|
|
119
|
+
}
|
|
120
|
+
|
|
112
121
|
// Insert an embedding record (used during batch writes)
|
|
113
122
|
async function insertEmbedding(
|
|
114
123
|
itemId: string,
|
|
@@ -116,12 +125,14 @@ async function insertEmbedding(
|
|
|
116
125
|
chunkIndex: number,
|
|
117
126
|
content: string,
|
|
118
127
|
embedding: string,
|
|
119
|
-
metadata: any
|
|
128
|
+
metadata: any,
|
|
129
|
+
accountId?: string
|
|
120
130
|
): Promise<void> {
|
|
131
|
+
const resolvedAccountId = accountId ?? await getPlatformAccountId()
|
|
121
132
|
await adminDb
|
|
122
133
|
.from('embeddings')
|
|
123
134
|
.insert({
|
|
124
|
-
account_id:
|
|
135
|
+
account_id: resolvedAccountId,
|
|
125
136
|
model_id: 'text-embedding-3-small',
|
|
126
137
|
document_id: itemId,
|
|
127
138
|
chunk_index: chunkIndex,
|
|
@@ -135,6 +146,33 @@ async function insertEmbedding(
|
|
|
135
146
|
})
|
|
136
147
|
}
|
|
137
148
|
|
|
149
|
+
// Generate a one-paragraph AI summary for top-level retrieval matching
|
|
150
|
+
async function generateArticleSummary(item: any): Promise<string> {
|
|
151
|
+
const apiKey = process.env.OPENAI_API_KEY || process.env.LLM_API_KEY
|
|
152
|
+
const baseUrl = process.env.OPENAI_BASE_URL || 'https://api.openai.com/v1'
|
|
153
|
+
if (!apiKey) return `${item.title || ''}: ${(item.description || '').slice(0, 300)}`
|
|
154
|
+
|
|
155
|
+
const plainText = (item.description || '')
|
|
156
|
+
.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 3000)
|
|
157
|
+
|
|
158
|
+
const res = await fetch(`${baseUrl}/chat/completions`, {
|
|
159
|
+
method: 'POST',
|
|
160
|
+
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` },
|
|
161
|
+
body: JSON.stringify({
|
|
162
|
+
model: process.env.LLM_DEFAULT_MODEL || 'gpt-4o-mini',
|
|
163
|
+
temperature: 0.2,
|
|
164
|
+
max_tokens: 120,
|
|
165
|
+
messages: [
|
|
166
|
+
{ role: 'system', content: 'Write a single concise paragraph (max 100 words) summarising this knowledge base article for search indexing. Output only the paragraph.' },
|
|
167
|
+
{ role: 'user', content: `Title: ${item.title}\n\n${plainText}` }
|
|
168
|
+
]
|
|
169
|
+
})
|
|
170
|
+
})
|
|
171
|
+
if (!res.ok) return `${item.title}: ${plainText.slice(0, 200)}`
|
|
172
|
+
const result: any = await res.json()
|
|
173
|
+
return result.choices?.[0]?.message?.content?.trim() || `${item.title}: ${plainText.slice(0, 200)}`
|
|
174
|
+
}
|
|
175
|
+
|
|
138
176
|
// Delete all existing embeddings for a document (called before re-embedding)
|
|
139
177
|
async function deleteExistingEmbeddings(itemId: string): Promise<number> {
|
|
140
178
|
const { data } = await adminDb
|
|
@@ -169,7 +207,8 @@ function isCodeChunk(item: any): boolean {
|
|
|
169
207
|
async function handleGenerateEmbeddings(
|
|
170
208
|
itemId: string,
|
|
171
209
|
vectorTypes: string[],
|
|
172
|
-
forceRegenerate: boolean = false
|
|
210
|
+
forceRegenerate: boolean = false,
|
|
211
|
+
overrideAccountId?: string
|
|
173
212
|
): Promise<KBEmbeddingResponse> {
|
|
174
213
|
const response: KBEmbeddingResponse = {
|
|
175
214
|
success: true,
|
|
@@ -179,18 +218,20 @@ async function handleGenerateEmbeddings(
|
|
|
179
218
|
}
|
|
180
219
|
|
|
181
220
|
try {
|
|
182
|
-
// Get the KB item
|
|
221
|
+
// Get the KB item — allow any type when called from case resolution path
|
|
183
222
|
const { data: item, error: itemError } = await adminDb
|
|
184
223
|
.from('items')
|
|
185
224
|
.select('*')
|
|
186
225
|
.eq('id', itemId)
|
|
187
|
-
.eq('type_id', 'ce1e50b6-473e-4581-ba0c-e944f47cb240') // kb_article type
|
|
188
226
|
.single()
|
|
189
227
|
|
|
190
228
|
if (itemError || !item) {
|
|
191
|
-
throw new Error('
|
|
229
|
+
throw new Error('Item not found for embedding')
|
|
192
230
|
}
|
|
193
231
|
|
|
232
|
+
// Resolve account: caller override > item's own account > platform account
|
|
233
|
+
const accountId = overrideAccountId || item.account_id || await getPlatformAccountId()
|
|
234
|
+
|
|
194
235
|
// Check if embeddings already exist
|
|
195
236
|
if (!forceRegenerate) {
|
|
196
237
|
const { data: existing } = await adminDb
|
|
@@ -213,7 +254,7 @@ async function handleGenerateEmbeddings(
|
|
|
213
254
|
response.embeddings_updated = deleted
|
|
214
255
|
}
|
|
215
256
|
|
|
216
|
-
// ── Code chunks: legacy path (
|
|
257
|
+
// ── Code chunks: legacy path (semantic + structure) ──
|
|
217
258
|
if (isCodeChunk(item)) {
|
|
218
259
|
const semanticContent = buildSemanticContent(item)
|
|
219
260
|
const structureContent = buildStructureContent(item)
|
|
@@ -226,33 +267,32 @@ async function handleGenerateEmbeddings(
|
|
|
226
267
|
chunk_id: item.data?.code_metadata?.chunk_id,
|
|
227
268
|
chunk_type: item.data?.code_metadata?.chunk_type,
|
|
228
269
|
file_path: item.data?.code_metadata?.file_path,
|
|
229
|
-
}),
|
|
270
|
+
}, accountId),
|
|
230
271
|
insertEmbedding(itemId, 'structure', 0, structureContent, vectors[1], {
|
|
231
272
|
kb_type: item.data?.kb_type,
|
|
232
273
|
tags: item.data?.tags,
|
|
233
274
|
file_path: item.data?.code_metadata?.file_path,
|
|
234
275
|
depends_on: item.data?.code_metadata?.depends_on,
|
|
235
|
-
}),
|
|
276
|
+
}, accountId),
|
|
236
277
|
])
|
|
237
278
|
|
|
238
279
|
response.embeddings_created = 2
|
|
239
280
|
return response
|
|
240
281
|
}
|
|
241
282
|
|
|
242
|
-
// ── Article path:
|
|
283
|
+
// ── Article path: semantic chunks + structure + summary ──
|
|
243
284
|
const articleContent = item.description || ''
|
|
244
|
-
const chunks = chunkArticle(articleContent, {
|
|
245
|
-
articleTitle: item.title || 'Untitled',
|
|
246
|
-
})
|
|
285
|
+
const chunks = chunkArticle(articleContent, { articleTitle: item.title || 'Untitled' })
|
|
247
286
|
|
|
248
|
-
// Build all texts to embed: N semantic chunks + 1 structure
|
|
249
287
|
const structureContent = buildArticleStructureContent(item)
|
|
250
|
-
const allTexts = [...chunks.map(c => c.content), structureContent]
|
|
251
288
|
|
|
252
|
-
//
|
|
289
|
+
// Generate AI summary for top-level retrieval matching
|
|
290
|
+
const summaryContent = await generateArticleSummary(item)
|
|
291
|
+
|
|
292
|
+
// Single batched embed: N semantic chunks + 1 structure + 1 summary
|
|
293
|
+
const allTexts = [...chunks.map(c => c.content), structureContent, summaryContent]
|
|
253
294
|
const allVectors = await generateEmbeddingBatch(allTexts)
|
|
254
295
|
|
|
255
|
-
// Parallel DB writes
|
|
256
296
|
const writePromises: Promise<void>[] = []
|
|
257
297
|
|
|
258
298
|
// Semantic embeddings — one per chunk
|
|
@@ -263,22 +303,32 @@ async function handleGenerateEmbeddings(
|
|
|
263
303
|
chunk_index: i,
|
|
264
304
|
chunk_total: chunks.length,
|
|
265
305
|
section_path: chunks[i].sectionPath,
|
|
266
|
-
|
|
306
|
+
security_level: item.data?.security_level,
|
|
307
|
+
}, accountId)
|
|
267
308
|
)
|
|
268
309
|
}
|
|
269
310
|
|
|
270
|
-
// Structure embedding —
|
|
311
|
+
// Structure embedding — facets for filtering
|
|
271
312
|
writePromises.push(
|
|
272
313
|
insertEmbedding(itemId, 'structure', 0, structureContent, allVectors[chunks.length], {
|
|
273
314
|
kb_type: item.data?.kb_type,
|
|
274
315
|
tags: item.data?.tags,
|
|
275
316
|
category: item.data?.category,
|
|
276
317
|
audience: item.data?.audience,
|
|
277
|
-
|
|
318
|
+
security_level: item.data?.security_level,
|
|
319
|
+
}, accountId)
|
|
320
|
+
)
|
|
321
|
+
|
|
322
|
+
// Summary embedding — one-paragraph AI summary for top-level matching
|
|
323
|
+
writePromises.push(
|
|
324
|
+
insertEmbedding(itemId, 'summary', 0, summaryContent, allVectors[chunks.length + 1], {
|
|
325
|
+
kb_type: item.data?.kb_type,
|
|
326
|
+
security_level: item.data?.security_level,
|
|
327
|
+
}, accountId)
|
|
278
328
|
)
|
|
279
329
|
|
|
280
330
|
await Promise.all(writePromises)
|
|
281
|
-
response.embeddings_created = chunks.length +
|
|
331
|
+
response.embeddings_created = chunks.length + 2 // +2 for structure + summary
|
|
282
332
|
|
|
283
333
|
} catch (error) {
|
|
284
334
|
response.success = false
|
|
@@ -288,6 +338,63 @@ async function handleGenerateEmbeddings(
|
|
|
288
338
|
return response
|
|
289
339
|
}
|
|
290
340
|
|
|
341
|
+
/**
|
|
342
|
+
* Embeds a resolved ticket's case data as a knowledge source for future triage RAG.
|
|
343
|
+
* Called automatically when a ticket is resolved (via case analysis completion).
|
|
344
|
+
*
|
|
345
|
+
* Stores two vectors:
|
|
346
|
+
* - 'semantic': ca_true_problem + ca_final_solution (the actual answer)
|
|
347
|
+
* - 'summary': ca_reported_issue (how the customer described it — improves matching)
|
|
348
|
+
*
|
|
349
|
+
* These are stored with item_type: 'resolved_case' so they can be included in
|
|
350
|
+
* triage agent knowledge_sources without polluting the main KB article pool.
|
|
351
|
+
*/
|
|
352
|
+
export async function embedResolvedCase(ticketId: string, accountId: string): Promise<void> {
|
|
353
|
+
// Load ticket data fields
|
|
354
|
+
const { data: ticket } = await adminDb
|
|
355
|
+
.from('items')
|
|
356
|
+
.select('id, title, data')
|
|
357
|
+
.eq('id', ticketId)
|
|
358
|
+
.single()
|
|
359
|
+
|
|
360
|
+
if (!ticket) return
|
|
361
|
+
|
|
362
|
+
const trueProb = ticket.data?.ca_true_problem || ''
|
|
363
|
+
const finalSol = ticket.data?.ca_final_solution || ''
|
|
364
|
+
const reportedIssue = ticket.data?.ca_reported_issue || ticket.title || ''
|
|
365
|
+
|
|
366
|
+
if (!trueProb && !finalSol) return // nothing to embed
|
|
367
|
+
|
|
368
|
+
// Delete any pre-existing resolved_case embeddings for this ticket
|
|
369
|
+
await adminDb
|
|
370
|
+
.from('embeddings')
|
|
371
|
+
.delete()
|
|
372
|
+
.eq('document_id', ticketId)
|
|
373
|
+
.eq('metadata->>item_type', 'resolved_case')
|
|
374
|
+
|
|
375
|
+
const semanticContent = `Problem: ${trueProb}\n\nSolution: ${finalSol}`
|
|
376
|
+
const summaryContent = `Customer reported: ${reportedIssue}`
|
|
377
|
+
|
|
378
|
+
try {
|
|
379
|
+
const vectors = await generateEmbeddingBatch([semanticContent, summaryContent])
|
|
380
|
+
await Promise.all([
|
|
381
|
+
insertEmbedding(ticketId, 'semantic', 0, semanticContent, vectors[0], {
|
|
382
|
+
item_type: 'resolved_case',
|
|
383
|
+
security_level: 'internal',
|
|
384
|
+
kb_candidate: ticket.data?.ca_kb_candidate ?? false,
|
|
385
|
+
automation_potential: ticket.data?.ca_automation_potential ?? 'unknown',
|
|
386
|
+
}, accountId),
|
|
387
|
+
insertEmbedding(ticketId, 'summary', 0, summaryContent, vectors[1], {
|
|
388
|
+
item_type: 'resolved_case',
|
|
389
|
+
security_level: 'internal',
|
|
390
|
+
}, accountId),
|
|
391
|
+
])
|
|
392
|
+
} catch (err) {
|
|
393
|
+
// Best-effort: don't fail ticket resolution if embedding fails
|
|
394
|
+
console.error(`embedResolvedCase failed for ticket ${ticketId}:`, err)
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
291
398
|
// Generate embedding vector for a query string via OpenAI
|
|
292
399
|
async function generateQueryEmbedding(text: string): Promise<number[]> {
|
|
293
400
|
const apiKey = process.env.OPENAI_API_KEY || process.env.LLM_API_KEY
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { createHandler } from './_shared/middleware'
|
|
2
|
+
import { adminDb } from './_shared/db'
|
|
3
|
+
import { resolveTypeId, resolveAgentId, resolvePromptConfigId } from './_shared/resolve-ids'
|
|
4
|
+
import { runAgent } from './_shared/index'
|
|
5
|
+
import { create } from './admin-data'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* @module custom_support-redaction
|
|
9
|
+
* @layer custom/cortex
|
|
10
|
+
* @stability custom
|
|
11
|
+
*
|
|
12
|
+
* KB Redaction Analysis — drives the RedactionReview page.
|
|
13
|
+
* Runs content through the KB Redaction Agent and returns structured
|
|
14
|
+
* redaction suggestions + a generalised rewrite ready for human review.
|
|
15
|
+
*
|
|
16
|
+
* Action: POST ?action=analyze
|
|
17
|
+
* body: { ticket_id: string, content: string }
|
|
18
|
+
*
|
|
19
|
+
* Response: { analysis: RedactionAnalysis }
|
|
20
|
+
* RedactionAnalysis matches the shape expected by RedactionReview.tsx:
|
|
21
|
+
* {
|
|
22
|
+
* original_content: string
|
|
23
|
+
* redacted_content: string
|
|
24
|
+
* suggestions: RedactionSuggestion[]
|
|
25
|
+
* confidence_score: number
|
|
26
|
+
* processing_metadata: { model_used, temperature, tokens_consumed }
|
|
27
|
+
* }
|
|
28
|
+
*
|
|
29
|
+
* The agent message is saved in an internal analysis thread on the ticket
|
|
30
|
+
* (thread_purpose: 'redaction_analysis') as an audit trail.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
async function handleAnalyze(ctx: any, body: any) {
|
|
34
|
+
const { ticket_id, content } = body
|
|
35
|
+
if (!ticket_id) throw new Error('ticket_id is required')
|
|
36
|
+
if (!content) throw new Error('content is required')
|
|
37
|
+
|
|
38
|
+
const [threadTypeId, agentId, promptConfigId] = await Promise.all([
|
|
39
|
+
resolveTypeId('thread', 'thread'),
|
|
40
|
+
resolveAgentId('KB Redaction Agent'),
|
|
41
|
+
resolvePromptConfigId('kb_generator'),
|
|
42
|
+
])
|
|
43
|
+
|
|
44
|
+
// Reuse existing redaction thread if one exists (idempotent re-analysis)
|
|
45
|
+
const { data: existing } = await adminDb
|
|
46
|
+
.from('threads')
|
|
47
|
+
.select('id')
|
|
48
|
+
.eq('target_type', 'items')
|
|
49
|
+
.eq('target_id', ticket_id)
|
|
50
|
+
.eq('visibility', 'internal')
|
|
51
|
+
.eq('data->>thread_purpose', 'redaction_analysis')
|
|
52
|
+
.maybeSingle()
|
|
53
|
+
|
|
54
|
+
let threadId = existing?.id
|
|
55
|
+
|
|
56
|
+
if (!threadId) {
|
|
57
|
+
const thread = await create(ctx, {
|
|
58
|
+
entity: 'threads',
|
|
59
|
+
type_id: threadTypeId,
|
|
60
|
+
target_type: 'items',
|
|
61
|
+
target_id: ticket_id,
|
|
62
|
+
visibility: 'internal',
|
|
63
|
+
status: 'active',
|
|
64
|
+
data: {
|
|
65
|
+
thread_purpose: 'redaction_analysis',
|
|
66
|
+
agent_id: agentId,
|
|
67
|
+
prompt_config_id: promptConfigId,
|
|
68
|
+
}
|
|
69
|
+
})
|
|
70
|
+
if (!thread?.id) throw new Error('Failed to create redaction thread')
|
|
71
|
+
threadId = thread.id
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Run the KB Redaction Agent — returns structured JSON in message content
|
|
75
|
+
const agentMsg = await runAgent(threadId, content, ctx)
|
|
76
|
+
|
|
77
|
+
// Parse JSON from agent response
|
|
78
|
+
let analysis: any = null
|
|
79
|
+
try {
|
|
80
|
+
const raw = agentMsg?.content || '{}'
|
|
81
|
+
const cleaned = raw.replace(/^```(?:json)?\s*/i, '').replace(/\s*```$/, '').trim()
|
|
82
|
+
analysis = JSON.parse(cleaned)
|
|
83
|
+
} catch {
|
|
84
|
+
// If the agent didn't return valid JSON, return a minimal analysis
|
|
85
|
+
// so the UI can still proceed with the original content unredacted
|
|
86
|
+
analysis = {
|
|
87
|
+
original_content: content,
|
|
88
|
+
redacted_content: content,
|
|
89
|
+
suggestions: [],
|
|
90
|
+
confidence_score: 0.0,
|
|
91
|
+
processing_metadata: {
|
|
92
|
+
model_used: 'unknown',
|
|
93
|
+
temperature: 0.1,
|
|
94
|
+
tokens_consumed: 0,
|
|
95
|
+
error: 'Agent response was not valid JSON',
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Ensure required fields are present for RedactionReview.tsx
|
|
101
|
+
if (!analysis.original_content) analysis.original_content = content
|
|
102
|
+
if (!analysis.suggestions) analysis.suggestions = []
|
|
103
|
+
if (typeof analysis.confidence_score !== 'number') analysis.confidence_score = 0.5
|
|
104
|
+
if (!analysis.processing_metadata) analysis.processing_metadata = { model_used: 'unknown', temperature: 0.1, tokens_consumed: 0 }
|
|
105
|
+
|
|
106
|
+
return { analysis, analysisThreadId: threadId }
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export const handler = createHandler(async (ctx, body) => {
|
|
110
|
+
const action = (ctx as any).query?.action
|
|
111
|
+
switch (action) {
|
|
112
|
+
case 'analyze': return handleAnalyze(ctx, body)
|
|
113
|
+
default: throw new Error(`Unknown action: ${action}. Use 'analyze'.`)
|
|
114
|
+
}
|
|
115
|
+
})
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { createHandler } from './_shared/middleware'
|
|
2
|
+
import { adminDb } from './_shared/db'
|
|
3
|
+
import { resolveTypeId, resolveAgentId, resolvePromptConfigId } from './_shared/resolve-ids'
|
|
4
|
+
import { runAgent } from './_shared/index'
|
|
5
|
+
import { create } from './admin-data'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* @module custom_support-solution
|
|
9
|
+
* @layer custom/cortex
|
|
10
|
+
* @stability custom
|
|
11
|
+
*
|
|
12
|
+
* Solution AI — internal AI collaborator for support agents working a case.
|
|
13
|
+
* Powered by core's runAgent() with the 'solution_ai' prompt config.
|
|
14
|
+
* Never visible to the customer (all messages are visibility='internal').
|
|
15
|
+
*
|
|
16
|
+
* Two actions:
|
|
17
|
+
* POST ?action=start — ensures an internal AI thread exists for the ticket,
|
|
18
|
+
* sends the first message, returns thread + agent message.
|
|
19
|
+
* POST ?action=message — sends a follow-up message on the existing AI thread.
|
|
20
|
+
*
|
|
21
|
+
* Response: { threadId, agentMessageId, content }
|
|
22
|
+
*
|
|
23
|
+
* The AI thread is distinct from the regular internal thread: it is tagged with
|
|
24
|
+
* data.thread_purpose = 'solution_ai' so the UI can find it deterministically.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
// ─── ACTION: START ─────────────────────────────────────────────────────────────
|
|
28
|
+
|
|
29
|
+
async function handleStart(ctx: any, body: any) {
|
|
30
|
+
const { ticket_id, message } = body
|
|
31
|
+
if (!ticket_id) throw new Error('ticket_id is required')
|
|
32
|
+
if (!message) throw new Error('message is required')
|
|
33
|
+
|
|
34
|
+
const [threadTypeId, solutionAgentId, promptConfigId] = await Promise.all([
|
|
35
|
+
resolveTypeId('thread', 'thread'),
|
|
36
|
+
resolveAgentId('Solution AI Agent'),
|
|
37
|
+
resolvePromptConfigId('solution_ai'),
|
|
38
|
+
])
|
|
39
|
+
|
|
40
|
+
// Find existing solution AI thread for this ticket
|
|
41
|
+
const { data: existing } = await adminDb
|
|
42
|
+
.from('threads')
|
|
43
|
+
.select('id')
|
|
44
|
+
.eq('target_type', 'items')
|
|
45
|
+
.eq('target_id', ticket_id)
|
|
46
|
+
.eq('visibility', 'internal')
|
|
47
|
+
.eq('data->>thread_purpose', 'solution_ai')
|
|
48
|
+
.maybeSingle()
|
|
49
|
+
|
|
50
|
+
let threadId = existing?.id
|
|
51
|
+
|
|
52
|
+
if (!threadId) {
|
|
53
|
+
const thread = await create(ctx, {
|
|
54
|
+
entity: 'threads',
|
|
55
|
+
type_id: threadTypeId,
|
|
56
|
+
target_type: 'items',
|
|
57
|
+
target_id: ticket_id,
|
|
58
|
+
visibility: 'internal',
|
|
59
|
+
status: 'active',
|
|
60
|
+
data: {
|
|
61
|
+
thread_purpose: 'solution_ai',
|
|
62
|
+
agent_id: solutionAgentId,
|
|
63
|
+
prompt_config_id: promptConfigId,
|
|
64
|
+
}
|
|
65
|
+
})
|
|
66
|
+
if (!thread?.id) throw new Error('Failed to create solution AI thread')
|
|
67
|
+
threadId = thread.id
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const agentMsg = await runAgent(threadId, message, ctx)
|
|
71
|
+
|
|
72
|
+
return {
|
|
73
|
+
threadId,
|
|
74
|
+
agentMessageId: agentMsg?.id,
|
|
75
|
+
content: agentMsg?.content || '',
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ─── ACTION: MESSAGE ───────────────────────────────────────────────────────────
|
|
80
|
+
|
|
81
|
+
async function handleMessage(ctx: any, body: any) {
|
|
82
|
+
const { thread_id, message } = body
|
|
83
|
+
if (!thread_id) throw new Error('thread_id is required')
|
|
84
|
+
if (!message) throw new Error('message is required')
|
|
85
|
+
|
|
86
|
+
const agentMsg = await runAgent(thread_id, message, ctx)
|
|
87
|
+
|
|
88
|
+
return {
|
|
89
|
+
threadId: thread_id,
|
|
90
|
+
agentMessageId: agentMsg?.id,
|
|
91
|
+
content: agentMsg?.content || '',
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ─── HANDLER ──────────────────────────────────────────────────────────────────
|
|
96
|
+
|
|
97
|
+
export const handler = createHandler(async (ctx, body) => {
|
|
98
|
+
const action = (ctx as any).query?.action
|
|
99
|
+
switch (action) {
|
|
100
|
+
case 'start': return handleStart(ctx, body)
|
|
101
|
+
case 'message': return handleMessage(ctx, body)
|
|
102
|
+
default: throw new Error(`Unknown action: ${action}. Use 'start' or 'message'.`)
|
|
103
|
+
}
|
|
104
|
+
})
|
package/manifest.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "Cortex",
|
|
3
3
|
"slug": "cortex",
|
|
4
4
|
"description": "Unified workspace for CRM, Support, Community, and Knowledge Base",
|
|
5
|
-
"version": "0.2.
|
|
5
|
+
"version": "0.2.22",
|
|
6
6
|
"app_type": "full",
|
|
7
7
|
"route_prefix": "/cortex",
|
|
8
8
|
"required_roles": ["support", "support_admin"],
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
"/crm/activity",
|
|
20
20
|
"/support",
|
|
21
21
|
"/support/:id",
|
|
22
|
+
"/support/:id/kb-review",
|
|
22
23
|
"/community",
|
|
23
24
|
"/kb",
|
|
24
25
|
"/kb/editor",
|
|
@@ -90,10 +91,13 @@
|
|
|
90
91
|
"entry_point": "./index.tsx",
|
|
91
92
|
"sidebar_component": "./components/CortexSidebar.tsx",
|
|
92
93
|
"seed": [
|
|
93
|
-
{ "file": "seed/roles.json", "table": "roles",
|
|
94
|
-
{ "file": "seed/types.json", "table": "types",
|
|
95
|
-
{ "file": "seed/link-types.json", "table": "link_types",
|
|
96
|
-
{ "file": "seed/
|
|
94
|
+
{ "file": "seed/roles.json", "table": "roles", "conflict": "app_id,slug" },
|
|
95
|
+
{ "file": "seed/types.json", "table": "types", "conflict": "app_id,kind,slug" },
|
|
96
|
+
{ "file": "seed/link-types.json", "table": "link_types", "conflict": "app_id,slug" },
|
|
97
|
+
{ "file": "seed/ai-agents.json", "table": "ai_agents", "conflict": "app_id,name", "inject_app_id": true },
|
|
98
|
+
{ "file": "seed/prompt-configs.json", "table": "prompt_configs","conflict": "app_id,slug", "inject_app_id": true },
|
|
99
|
+
{ "file": "seed/pipelines.json", "table": "pipelines", "conflict": "app_id,name", "inject_app_id": true },
|
|
100
|
+
{ "file": "seed/type_permissions.json", "table": "types", "conflict": "kind,slug", "inject_app_id": false, "permissions_patch": true }
|
|
97
101
|
],
|
|
98
102
|
"registration": {
|
|
99
103
|
"enabled": true,
|
package/package.json
CHANGED
|
@@ -39,13 +39,19 @@ interface Ticket {
|
|
|
39
39
|
title: string
|
|
40
40
|
description?: string
|
|
41
41
|
data?: {
|
|
42
|
+
// Case analysis fields (populated by custom_case_analysis)
|
|
43
|
+
ca_reported_issue?: string
|
|
44
|
+
ca_true_problem?: string
|
|
45
|
+
ca_final_solution?: string
|
|
46
|
+
ca_diagnostic_steps?: string[]
|
|
47
|
+
ca_solution_steps?: string[]
|
|
48
|
+
// Legacy fields
|
|
42
49
|
ai_metadata?: {
|
|
43
50
|
problem_statement?: string
|
|
44
51
|
solution_path?: string
|
|
45
52
|
}
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
}
|
|
53
|
+
// KB tracking
|
|
54
|
+
kb_proposed_kb_id?: string
|
|
49
55
|
}
|
|
50
56
|
}
|
|
51
57
|
|
|
@@ -118,12 +124,11 @@ export default function RedactionReview() {
|
|
|
118
124
|
setArticleTitle(`KB: ${ticketData.title}`)
|
|
119
125
|
}
|
|
120
126
|
|
|
121
|
-
// Trigger redaction analysis via
|
|
122
|
-
const analysisRes = await apiFetch('/
|
|
127
|
+
// Trigger redaction analysis via KB Redaction Agent
|
|
128
|
+
const analysisRes = await apiFetch('/.netlify/functions/custom_support-redaction?action=analyze', {
|
|
123
129
|
method: 'POST',
|
|
124
130
|
headers: { 'Content-Type': 'application/json' },
|
|
125
131
|
body: JSON.stringify({
|
|
126
|
-
action: 'run_redaction_analysis',
|
|
127
132
|
ticket_id: id,
|
|
128
133
|
content: buildContentForAnalysis(ticketData),
|
|
129
134
|
}),
|
|
@@ -155,16 +160,33 @@ export default function RedactionReview() {
|
|
|
155
160
|
})
|
|
156
161
|
}, [id])
|
|
157
162
|
|
|
158
|
-
// Build content from ticket for analysis
|
|
163
|
+
// Build content from ticket for redaction analysis.
|
|
164
|
+
// Prefers case analysis fields (ca_*) populated by custom_case_analysis,
|
|
165
|
+
// falling back to legacy ai_metadata fields.
|
|
159
166
|
function buildContentForAnalysis(ticket: Ticket): string {
|
|
167
|
+
const d = ticket.data || {}
|
|
160
168
|
const parts: string[] = []
|
|
161
169
|
if (ticket.title) parts.push(`Title: ${ticket.title}`)
|
|
162
|
-
if (
|
|
163
|
-
|
|
164
|
-
|
|
170
|
+
if (d.ca_reported_issue) {
|
|
171
|
+
parts.push(`Reported Issue: ${d.ca_reported_issue}`)
|
|
172
|
+
} else if (ticket.description) {
|
|
173
|
+
parts.push(`Description: ${ticket.description}`)
|
|
174
|
+
}
|
|
175
|
+
if (d.ca_true_problem) {
|
|
176
|
+
parts.push(`Root Cause: ${d.ca_true_problem}`)
|
|
177
|
+
} else if (d.ai_metadata?.problem_statement) {
|
|
178
|
+
parts.push(`Problem: ${d.ai_metadata.problem_statement}`)
|
|
179
|
+
}
|
|
180
|
+
if (d.ca_diagnostic_steps?.length) {
|
|
181
|
+
parts.push(`Diagnostic Steps:\n${d.ca_diagnostic_steps.map((s, i) => `${i + 1}. ${s}`).join('\n')}`)
|
|
165
182
|
}
|
|
166
|
-
if (
|
|
167
|
-
parts.push(`Solution
|
|
183
|
+
if (d.ca_solution_steps?.length) {
|
|
184
|
+
parts.push(`Solution Steps:\n${d.ca_solution_steps.map((s, i) => `${i + 1}. ${s}`).join('\n')}`)
|
|
185
|
+
}
|
|
186
|
+
if (d.ca_final_solution) {
|
|
187
|
+
parts.push(`Final Solution: ${d.ca_final_solution}`)
|
|
188
|
+
} else if (d.ai_metadata?.solution_path) {
|
|
189
|
+
parts.push(`Solution: ${d.ai_metadata.solution_path}`)
|
|
168
190
|
}
|
|
169
191
|
return parts.join('\n\n')
|
|
170
192
|
}
|
|
@@ -279,16 +301,12 @@ export default function RedactionReview() {
|
|
|
279
301
|
|
|
280
302
|
// Update ticket with KB reference
|
|
281
303
|
await apiFetch(`/api/admin-data?action=update&entity=items&id=${id}`, {
|
|
282
|
-
method: '
|
|
304
|
+
method: 'PATCH',
|
|
283
305
|
headers: { 'Content-Type': 'application/json' },
|
|
284
306
|
body: JSON.stringify({
|
|
285
307
|
data: {
|
|
286
308
|
...ticket?.data,
|
|
287
|
-
|
|
288
|
-
...ticket?.data?.postmortem,
|
|
289
|
-
kb_generated: true,
|
|
290
|
-
kb_draft_id: kbId,
|
|
291
|
-
},
|
|
309
|
+
kb_proposed_kb_id: kbId,
|
|
292
310
|
},
|
|
293
311
|
}),
|
|
294
312
|
})
|