spine-framework-cortex 0.2.21 → 0.2.23
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/crm/AccountDetailPage.tsx +5 -2
- package/pages/crm/AccountsPage.tsx +3 -1
- package/pages/crm/DealDetailPage.tsx +5 -3
- package/pages/crm/DealsPage.tsx +4 -2
- package/pages/kb/KBEditorPage.tsx +4 -2
- package/pages/kb/KBPage.tsx +6 -4
- package/pages/support/RedactionReview.tsx +41 -21
- package/pages/support/SupportPage.tsx +4 -2
- package/pages/support/TicketDetailPage.tsx +131 -131
- 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
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { useEffect, useState } from 'react'
|
|
2
2
|
import { useParams, useNavigate } from 'react-router-dom'
|
|
3
|
+
import { useAppPath } from '@core/hooks/useAppPath'
|
|
3
4
|
import { apiFetch } from '@core/lib/api'
|
|
4
5
|
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@core/components/ui/tabs'
|
|
5
6
|
import { Badge } from '@core/components/ui/badge'
|
|
@@ -81,6 +82,7 @@ function PeopleTab({ accountId }: { accountId: string }) {
|
|
|
81
82
|
|
|
82
83
|
function ItemsTab({ accountId, typeSlug, emptyText }: { accountId: string; typeSlug: string; emptyText: string }) {
|
|
83
84
|
const navigate = useNavigate()
|
|
85
|
+
const appPath = useAppPath()
|
|
84
86
|
const [items, setItems] = useState<Item[]>([])
|
|
85
87
|
const [loading, setLoading] = useState(true)
|
|
86
88
|
useEffect(() => {
|
|
@@ -94,7 +96,7 @@ function ItemsTab({ accountId, typeSlug, emptyText }: { accountId: string; typeS
|
|
|
94
96
|
{items.map(item => (
|
|
95
97
|
<div key={item.id}
|
|
96
98
|
className="flex items-center gap-3 px-4 py-3 hover:bg-accent/50 cursor-pointer transition-colors"
|
|
97
|
-
onClick={() => typeSlug === 'support_ticket' ? navigate(`/
|
|
99
|
+
onClick={() => typeSlug === 'support_ticket' ? navigate(appPath(`/support/${item.id}`)) : typeSlug === 'deal' ? navigate(appPath(`/crm/deals/${item.id}`)) : undefined}
|
|
98
100
|
>
|
|
99
101
|
<div className="flex-1 min-w-0">
|
|
100
102
|
<p className="text-sm font-medium truncate">{item.title}</p>
|
|
@@ -330,6 +332,7 @@ function FunnelTab({ account }: { account: Account }) {
|
|
|
330
332
|
export default function AccountDetailPage() {
|
|
331
333
|
const { id } = useParams<{ id: string }>()
|
|
332
334
|
const navigate = useNavigate()
|
|
335
|
+
const appPath = useAppPath()
|
|
333
336
|
const [account, setAccount] = useState<Account | null>(null)
|
|
334
337
|
const [loading, setLoading] = useState(true)
|
|
335
338
|
|
|
@@ -345,7 +348,7 @@ export default function AccountDetailPage() {
|
|
|
345
348
|
return (
|
|
346
349
|
<div className="flex flex-col h-full">
|
|
347
350
|
<div className="px-6 py-4 border-b border-border shrink-0">
|
|
348
|
-
<Button variant="ghost" size="sm" className="mb-2 -ml-2 gap-1 text-muted-foreground" onClick={() => navigate('/
|
|
351
|
+
<Button variant="ghost" size="sm" className="mb-2 -ml-2 gap-1 text-muted-foreground" onClick={() => navigate(appPath('/crm/accounts'))}>
|
|
349
352
|
<ArrowLeft className="h-3.5 w-3.5" /> Accounts
|
|
350
353
|
</Button>
|
|
351
354
|
<div className="flex items-center gap-3">
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { useEffect, useState } from 'react'
|
|
2
2
|
import { useNavigate } from 'react-router-dom'
|
|
3
|
+
import { useAppPath } from '@core/hooks/useAppPath'
|
|
3
4
|
import { apiFetch } from '@core/lib/api'
|
|
4
5
|
import { Input } from '@core/components/ui/input'
|
|
5
6
|
import { Badge } from '@core/components/ui/badge'
|
|
@@ -26,6 +27,7 @@ type Filter = 'all'
|
|
|
26
27
|
|
|
27
28
|
export default function AccountsPage() {
|
|
28
29
|
const navigate = useNavigate()
|
|
30
|
+
const appPath = useAppPath()
|
|
29
31
|
const [accounts, setAccounts] = useState<Account[]>([])
|
|
30
32
|
const [loading, setLoading] = useState(true)
|
|
31
33
|
const [search, setSearch] = useState('')
|
|
@@ -91,7 +93,7 @@ export default function AccountsPage() {
|
|
|
91
93
|
{filtered.map(account => (
|
|
92
94
|
<tr
|
|
93
95
|
key={account.id}
|
|
94
|
-
onClick={() => navigate(`/
|
|
96
|
+
onClick={() => navigate(appPath(`/crm/accounts/${account.id}`))}
|
|
95
97
|
className="hover:bg-accent/50 cursor-pointer transition-colors"
|
|
96
98
|
>
|
|
97
99
|
<td className="px-5 py-3 font-medium">
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { useEffect, useState } from 'react'
|
|
2
2
|
import { useNavigate, useParams } from 'react-router-dom'
|
|
3
|
+
import { useAppPath } from '@core/hooks/useAppPath'
|
|
3
4
|
import { apiFetch } from '@core/lib/api'
|
|
4
5
|
|
|
5
6
|
const STAGES = ['prospecting', 'qualification', 'proposal', 'negotiation', 'closed_won', 'closed_lost']
|
|
@@ -19,6 +20,7 @@ const EMPTY: DealForm = { title: '', stage: 'prospecting', value: '', close_date
|
|
|
19
20
|
export default function DealDetailPage() {
|
|
20
21
|
const { id } = useParams<{ id: string }>()
|
|
21
22
|
const navigate = useNavigate()
|
|
23
|
+
const appPath = useAppPath()
|
|
22
24
|
const isNew = !id || id === 'new'
|
|
23
25
|
|
|
24
26
|
const [form, setForm] = useState<DealForm>(EMPTY)
|
|
@@ -82,7 +84,7 @@ export default function DealDetailPage() {
|
|
|
82
84
|
const handleDelete = async () => {
|
|
83
85
|
if (!confirm('Delete this deal?')) return
|
|
84
86
|
await apiFetch(`/api/admin-data?action=delete&entity=items&id=${id}`, { method: 'POST' })
|
|
85
|
-
navigate('/
|
|
87
|
+
navigate(appPath('/crm/deals'))
|
|
86
88
|
}
|
|
87
89
|
|
|
88
90
|
const set = (field: keyof DealForm) => (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) =>
|
|
@@ -93,7 +95,7 @@ export default function DealDetailPage() {
|
|
|
93
95
|
return (
|
|
94
96
|
<div className="p-6 max-w-2xl">
|
|
95
97
|
<div className="flex items-center gap-3 mb-6">
|
|
96
|
-
<button onClick={() => navigate('/
|
|
98
|
+
<button onClick={() => navigate(appPath('/crm/deals'))} className="text-slate-400 hover:text-slate-700 text-sm">
|
|
97
99
|
← Deals
|
|
98
100
|
</button>
|
|
99
101
|
<h1 className="text-xl font-bold text-slate-900">{isNew ? 'New Deal' : 'Edit Deal'}</h1>
|
|
@@ -174,7 +176,7 @@ export default function DealDetailPage() {
|
|
|
174
176
|
</button>
|
|
175
177
|
) : <div />}
|
|
176
178
|
<div className="flex gap-3">
|
|
177
|
-
<button onClick={() => navigate('/
|
|
179
|
+
<button onClick={() => navigate(appPath('/crm/deals'))} className="px-4 py-2 text-sm text-slate-600 border border-slate-200 rounded-lg hover:bg-slate-50">
|
|
178
180
|
Cancel
|
|
179
181
|
</button>
|
|
180
182
|
<button
|
package/pages/crm/DealsPage.tsx
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { useEffect, useState } from 'react'
|
|
2
2
|
import { useNavigate } from 'react-router-dom'
|
|
3
|
+
import { useAppPath } from '@core/hooks/useAppPath'
|
|
3
4
|
import { apiFetch } from '@core/lib/api'
|
|
4
5
|
|
|
5
6
|
interface Deal {
|
|
@@ -40,6 +41,7 @@ function DealCard({ deal, onClick }: { deal: Deal; onClick: () => void }) {
|
|
|
40
41
|
|
|
41
42
|
export default function DealsPage() {
|
|
42
43
|
const navigate = useNavigate()
|
|
44
|
+
const appPath = useAppPath()
|
|
43
45
|
const [deals, setDeals] = useState<Deal[]>([])
|
|
44
46
|
const [loading, setLoading] = useState(true)
|
|
45
47
|
const [view, setView] = useState<'kanban' | 'list'>('kanban')
|
|
@@ -77,7 +79,7 @@ export default function DealsPage() {
|
|
|
77
79
|
</button>
|
|
78
80
|
</div>
|
|
79
81
|
<button
|
|
80
|
-
onClick={() => navigate('/
|
|
82
|
+
onClick={() => navigate(appPath('/crm/deals/new'))}
|
|
81
83
|
className="bg-blue-600 text-white text-sm font-medium px-4 py-2 rounded-lg hover:bg-blue-700 transition-colors"
|
|
82
84
|
>
|
|
83
85
|
+ New Deal
|
|
@@ -133,7 +135,7 @@ export default function DealsPage() {
|
|
|
133
135
|
<tr>
|
|
134
136
|
<td colSpan={5} className="px-5 py-12 text-center text-slate-400">
|
|
135
137
|
No deals yet.{' '}
|
|
136
|
-
<button onClick={() => navigate('/
|
|
138
|
+
<button onClick={() => navigate(appPath('/crm/deals/new'))} className="text-blue-600 hover:underline">
|
|
137
139
|
Create one →
|
|
138
140
|
</button>
|
|
139
141
|
</td>
|