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,649 +0,0 @@
|
|
|
1
|
-
import { createHandler } from './_shared/middleware'
|
|
2
|
-
import { adminDb } from './_shared/db'
|
|
3
|
-
import { resolveTypeIds, resolveAccountId, resolveAgentId, resolvePromptConfigId } from './_shared/resolve-ids'
|
|
4
|
-
|
|
5
|
-
async function resolveIds() {
|
|
6
|
-
const [types, kbPlatformAccountId, triageAgentId, promptConfigId] = await Promise.all([
|
|
7
|
-
resolveTypeIds([
|
|
8
|
-
{ kind: 'item', slug: 'support_ticket' },
|
|
9
|
-
{ kind: 'thread', slug: 'thread' },
|
|
10
|
-
{ kind: 'message', slug: 'message' },
|
|
11
|
-
]),
|
|
12
|
-
resolveAccountId('spine-system'),
|
|
13
|
-
resolveAgentId('Support Triage Agent'),
|
|
14
|
-
resolvePromptConfigId('support_triage_config'),
|
|
15
|
-
])
|
|
16
|
-
return {
|
|
17
|
-
SUPPORT_TICKET_TYPE_ID: types['item/support_ticket'],
|
|
18
|
-
THREAD_TYPE_ID: types['thread/thread'],
|
|
19
|
-
MESSAGE_TYPE_ID: types['message/message'],
|
|
20
|
-
KB_PLATFORM_ACCOUNT_ID: kbPlatformAccountId,
|
|
21
|
-
TRIAGE_AGENT_ID: triageAgentId,
|
|
22
|
-
PROMPT_CONFIG_ID: promptConfigId,
|
|
23
|
-
}
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
/**
|
|
27
|
-
* Custom Support Triage Handler
|
|
28
|
-
*
|
|
29
|
-
* Two actions:
|
|
30
|
-
* POST ?action=new_ticket — first message: creates ticket + thread + messages, runs AI
|
|
31
|
-
* POST ?action=reply — follow-up message on existing ticket: posts message, runs AI
|
|
32
|
-
*
|
|
33
|
-
* No direct DB access from custom code — all writes go through admin-data API or
|
|
34
|
-
* adminDb (service-role) where the API is not available in server context.
|
|
35
|
-
*
|
|
36
|
-
* AI response schema (enforced via response_format: json_object):
|
|
37
|
-
* {
|
|
38
|
-
* public_response: string,
|
|
39
|
-
* confidence: number (0-1),
|
|
40
|
-
* confidence_reasoning: string,
|
|
41
|
-
* escalate: boolean,
|
|
42
|
-
* escalation_reason: 'low_confidence' | 'none',
|
|
43
|
-
* sources_used: Array<{ type, id, title, relevance }>,
|
|
44
|
-
* suggested_title: string (turn 1 only)
|
|
45
|
-
* }
|
|
46
|
-
*/
|
|
47
|
-
|
|
48
|
-
// ─── CONSTANTS ────────────────────────────────────────────────────────────────
|
|
49
|
-
|
|
50
|
-
const CONFIDENCE_THRESHOLD = 0.75
|
|
51
|
-
|
|
52
|
-
// ─── TYPES ────────────────────────────────────────────────────────────────────
|
|
53
|
-
|
|
54
|
-
interface TriageEnvelope {
|
|
55
|
-
public_response: string
|
|
56
|
-
confidence: number
|
|
57
|
-
confidence_reasoning: string
|
|
58
|
-
escalate: boolean
|
|
59
|
-
escalation_reason: 'low_confidence' | 'none'
|
|
60
|
-
sources_used: Array<{ type: string; id: string; title: string; relevance: number }>
|
|
61
|
-
suggested_title?: string
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
interface TriageResult {
|
|
65
|
-
ticketId: string
|
|
66
|
-
threadId: string
|
|
67
|
-
publicMessageId: string
|
|
68
|
-
internalMessageId: string
|
|
69
|
-
public_response: string
|
|
70
|
-
confidence: number
|
|
71
|
-
escalated: boolean
|
|
72
|
-
escalation_reason: string
|
|
73
|
-
suggested_title?: string
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
// ─── HELPERS ──────────────────────────────────────────────────────────────────
|
|
77
|
-
|
|
78
|
-
async function getConversationHistory(threadId: string): Promise<any[]> {
|
|
79
|
-
const { data: msgs, error } = await adminDb
|
|
80
|
-
.from('messages')
|
|
81
|
-
.select('content, direction, data, created_at')
|
|
82
|
-
.eq('thread_id', threadId)
|
|
83
|
-
.eq('visibility', 'public')
|
|
84
|
-
.order('created_at', { ascending: true })
|
|
85
|
-
.limit(20)
|
|
86
|
-
if (error) return []
|
|
87
|
-
return msgs || []
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
type ResolvedIds = Awaited<ReturnType<typeof resolveIds>>
|
|
91
|
-
|
|
92
|
-
async function searchKB(query: string, accountId: string, ids: ResolvedIds): Promise<any[]> {
|
|
93
|
-
// Search embeddings scoped to: (1) client's own KB articles, and (2) platform-wide KB articles
|
|
94
|
-
// Exclude restricted items that belong to a different account
|
|
95
|
-
const accountIds = [accountId]
|
|
96
|
-
if (accountId !== ids.KB_PLATFORM_ACCOUNT_ID) {
|
|
97
|
-
accountIds.push(ids.KB_PLATFORM_ACCOUNT_ID)
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
const { data: results, error } = await adminDb
|
|
101
|
-
.from('embeddings')
|
|
102
|
-
.select('document_id, content, metadata, account_id')
|
|
103
|
-
.in('account_id', accountIds)
|
|
104
|
-
.eq('metadata->>item_type', 'kb_article')
|
|
105
|
-
.eq('metadata->>vector_type', 'semantic')
|
|
106
|
-
.textSearch('content', query, { type: 'websearch', config: 'english' })
|
|
107
|
-
.limit(8)
|
|
108
|
-
|
|
109
|
-
if (error) return []
|
|
110
|
-
|
|
111
|
-
// For cross-account (platform) results, filter out restricted items
|
|
112
|
-
const filtered = (results || []).filter((r: any) => {
|
|
113
|
-
if (r.account_id === accountId) return true
|
|
114
|
-
return r.metadata?.security_level !== 'restricted'
|
|
115
|
-
})
|
|
116
|
-
|
|
117
|
-
return filtered.slice(0, 5)
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
async function getPriorTickets(accountId: string, personId: string, ids: ResolvedIds): Promise<any[]> {
|
|
121
|
-
const { data: tickets, error } = await adminDb
|
|
122
|
-
.from('items')
|
|
123
|
-
.select('id, title, description, created_at')
|
|
124
|
-
.eq('type_id', ids.SUPPORT_TICKET_TYPE_ID)
|
|
125
|
-
.eq('account_id', accountId)
|
|
126
|
-
.eq('created_by', personId)
|
|
127
|
-
.order('created_at', { ascending: false })
|
|
128
|
-
.limit(5)
|
|
129
|
-
if (error) return []
|
|
130
|
-
return tickets || []
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
async function callOpenAI(
|
|
134
|
-
systemPrompt: string,
|
|
135
|
-
messages: Array<{ role: 'user' | 'assistant' | 'system'; content: string }>,
|
|
136
|
-
model: string,
|
|
137
|
-
temperature: number
|
|
138
|
-
): Promise<{ envelope: TriageEnvelope; latency_ms: number; token_usage: any }> {
|
|
139
|
-
const apiKey = process.env.OPENAI_API_KEY || process.env.LLM_API_KEY
|
|
140
|
-
const baseUrl = process.env.OPENAI_BASE_URL || 'https://api.openai.com/v1'
|
|
141
|
-
const startTime = Date.now()
|
|
142
|
-
|
|
143
|
-
if (!apiKey) {
|
|
144
|
-
const mockEnvelope: TriageEnvelope = {
|
|
145
|
-
public_response: '[Mock] No OPENAI_API_KEY set. This is a mock AI response for local development.',
|
|
146
|
-
confidence: 0.85,
|
|
147
|
-
confidence_reasoning: 'Mock response — no API key configured.',
|
|
148
|
-
escalate: false,
|
|
149
|
-
escalation_reason: 'none',
|
|
150
|
-
sources_used: [],
|
|
151
|
-
suggested_title: 'Mock Support Ticket'
|
|
152
|
-
}
|
|
153
|
-
return { envelope: mockEnvelope, latency_ms: 0, token_usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 } }
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
const res = await fetch(`${baseUrl}/chat/completions`, {
|
|
157
|
-
method: 'POST',
|
|
158
|
-
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` },
|
|
159
|
-
body: JSON.stringify({
|
|
160
|
-
model,
|
|
161
|
-
temperature,
|
|
162
|
-
response_format: { type: 'json_object' },
|
|
163
|
-
messages: [{ role: 'system', content: systemPrompt }, ...messages],
|
|
164
|
-
}),
|
|
165
|
-
})
|
|
166
|
-
|
|
167
|
-
if (!res.ok) {
|
|
168
|
-
const err = await res.text()
|
|
169
|
-
throw new Error(`OpenAI error ${res.status}: ${err.slice(0, 200)}`)
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
const result: any = await res.json()
|
|
173
|
-
const raw = result.choices?.[0]?.message?.content || '{}'
|
|
174
|
-
let envelope: TriageEnvelope
|
|
175
|
-
|
|
176
|
-
try {
|
|
177
|
-
envelope = JSON.parse(raw)
|
|
178
|
-
if (typeof envelope.public_response !== 'string') throw new Error('missing public_response')
|
|
179
|
-
if (typeof envelope.confidence !== 'number') throw new Error('missing confidence')
|
|
180
|
-
} catch (parseErr: any) {
|
|
181
|
-
envelope = {
|
|
182
|
-
public_response: "We're looking into this and will have a human response shortly.",
|
|
183
|
-
confidence: 0,
|
|
184
|
-
confidence_reasoning: `Parse failure: ${parseErr.message}. Raw: ${raw.slice(0, 200)}`,
|
|
185
|
-
escalate: true,
|
|
186
|
-
escalation_reason: 'low_confidence',
|
|
187
|
-
sources_used: [],
|
|
188
|
-
}
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
return {
|
|
192
|
-
envelope,
|
|
193
|
-
latency_ms: Date.now() - startTime,
|
|
194
|
-
token_usage: result.usage || {},
|
|
195
|
-
}
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
function buildSystemPrompt(
|
|
199
|
-
agentSystemPrompt: string,
|
|
200
|
-
contextTemplate: string,
|
|
201
|
-
kbSources: any[],
|
|
202
|
-
priorTickets: any[],
|
|
203
|
-
history: any[]
|
|
204
|
-
): string {
|
|
205
|
-
let prompt = agentSystemPrompt + '\n\n'
|
|
206
|
-
prompt += contextTemplate + '\n\n'
|
|
207
|
-
|
|
208
|
-
if (kbSources.length > 0) {
|
|
209
|
-
prompt += '## Knowledge Base Articles\n'
|
|
210
|
-
kbSources.forEach((doc, i) => {
|
|
211
|
-
prompt += `[KB${i + 1}] ${doc.content?.slice(0, 500)}\n`
|
|
212
|
-
})
|
|
213
|
-
prompt += '\n'
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
if (priorTickets.length > 0) {
|
|
217
|
-
prompt += "## Customer's Prior Tickets\n"
|
|
218
|
-
priorTickets.forEach((t) => {
|
|
219
|
-
prompt += `- ${t.title}: ${t.description?.slice(0, 200) || 'No description'}\n`
|
|
220
|
-
})
|
|
221
|
-
prompt += '\n'
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
if (history.length > 0) {
|
|
225
|
-
prompt += '## Conversation So Far\n'
|
|
226
|
-
history.forEach((m) => {
|
|
227
|
-
const role = m.direction === 'inbound' ? 'Customer' : 'Assistant'
|
|
228
|
-
prompt += `${role}: ${m.content}\n`
|
|
229
|
-
})
|
|
230
|
-
prompt += '\n'
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
prompt += `\nYou MUST respond with a single valid JSON object matching EXACTLY this schema. No markdown, no code fences, no extra text:\n`
|
|
234
|
-
prompt += `{
|
|
235
|
-
"public_response": "<your response to the customer>",
|
|
236
|
-
"confidence": <0.0-1.0>,
|
|
237
|
-
"confidence_reasoning": "<one sentence explaining your confidence score>",
|
|
238
|
-
"escalate": <true|false>,
|
|
239
|
-
"escalation_reason": "<low_confidence|none>",
|
|
240
|
-
"sources_used": [{"type": "kb_article|prior_ticket", "id": "<id>", "title": "<title>", "relevance": <0.0-1.0>}],
|
|
241
|
-
"suggested_title": "<3-8 word case title — ONLY include on the first turn>"
|
|
242
|
-
}`
|
|
243
|
-
|
|
244
|
-
return prompt
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
// ─── SCHEMA LOADER ──────────────────────────────────────────────────────────
|
|
248
|
-
|
|
249
|
-
async function loadTypeSchemas(ids: ResolvedIds) {
|
|
250
|
-
const { data: types } = await adminDb
|
|
251
|
-
.from('types')
|
|
252
|
-
.select('id, design_schema, validation_schema')
|
|
253
|
-
.in('id', [ids.SUPPORT_TICKET_TYPE_ID, ids.THREAD_TYPE_ID, ids.MESSAGE_TYPE_ID])
|
|
254
|
-
const byId = Object.fromEntries((types || []).map((t: any) => [t.id, t]))
|
|
255
|
-
return {
|
|
256
|
-
ticketSchema: byId[ids.SUPPORT_TICKET_TYPE_ID]?.design_schema || {},
|
|
257
|
-
ticketValidation: byId[ids.SUPPORT_TICKET_TYPE_ID]?.validation_schema || {},
|
|
258
|
-
threadSchema: byId[ids.THREAD_TYPE_ID]?.design_schema || {},
|
|
259
|
-
threadValidation: byId[ids.THREAD_TYPE_ID]?.validation_schema || {},
|
|
260
|
-
messageSchema: byId[ids.MESSAGE_TYPE_ID]?.design_schema || {},
|
|
261
|
-
messageValidation: byId[ids.MESSAGE_TYPE_ID]?.validation_schema || {},
|
|
262
|
-
}
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
// ─── ACTION: NEW TICKET ───────────────────────────────────────────────────────
|
|
266
|
-
|
|
267
|
-
async function handleNewTicket(ctx: any, body: any): Promise<TriageResult> {
|
|
268
|
-
const { message } = body
|
|
269
|
-
const account_id = ctx.accountId as string
|
|
270
|
-
const person_id = ctx.principal?.id as string
|
|
271
|
-
if (!message) throw new Error('message is required')
|
|
272
|
-
if (!account_id || !person_id) throw new Error('User context (account + person) required')
|
|
273
|
-
|
|
274
|
-
const ids = await resolveIds()
|
|
275
|
-
|
|
276
|
-
// Load agent + prompt config + type schemas
|
|
277
|
-
const [{ data: agent }, { data: promptConfig }, schemas] = await Promise.all([
|
|
278
|
-
adminDb.from('ai_agents').select('*').eq('id', ids.TRIAGE_AGENT_ID).single(),
|
|
279
|
-
adminDb.from('prompt_configs').select('*').eq('id', ids.PROMPT_CONFIG_ID).single(),
|
|
280
|
-
loadTypeSchemas(ids),
|
|
281
|
-
])
|
|
282
|
-
if (!agent || !promptConfig) throw new Error('Triage agent configuration not found')
|
|
283
|
-
|
|
284
|
-
const modelConfig = agent.model_config || {}
|
|
285
|
-
const model = modelConfig.model || process.env.LLM_DEFAULT_MODEL || 'gpt-4o'
|
|
286
|
-
const temperature = modelConfig.temperature ?? 0.7
|
|
287
|
-
|
|
288
|
-
// CALL A: Run AI (before ticket exists — no history yet)
|
|
289
|
-
const kbSources = await searchKB(message, account_id, ids)
|
|
290
|
-
const priorTickets = await getPriorTickets(account_id, person_id, ids)
|
|
291
|
-
const systemPrompt = buildSystemPrompt(
|
|
292
|
-
agent.system_prompt,
|
|
293
|
-
promptConfig.context_template.replace('{{user_message}}', message),
|
|
294
|
-
kbSources,
|
|
295
|
-
priorTickets,
|
|
296
|
-
[]
|
|
297
|
-
)
|
|
298
|
-
|
|
299
|
-
const promptMessages = [{ role: 'user' as const, content: message }]
|
|
300
|
-
const { envelope, latency_ms, token_usage } = await callOpenAI(systemPrompt, promptMessages, model, temperature)
|
|
301
|
-
|
|
302
|
-
const escalate = envelope.escalate || envelope.confidence < (promptConfig.confidence_threshold || CONFIDENCE_THRESHOLD)
|
|
303
|
-
const suggestedTitle = envelope.suggested_title || message.slice(0, 80)
|
|
304
|
-
|
|
305
|
-
// CALL A continued: Create ticket via adminDb
|
|
306
|
-
const { data: ticket, error: ticketError } = await adminDb
|
|
307
|
-
.from('items')
|
|
308
|
-
.insert({
|
|
309
|
-
type_id: ids.SUPPORT_TICKET_TYPE_ID,
|
|
310
|
-
account_id,
|
|
311
|
-
created_by: person_id,
|
|
312
|
-
title: suggestedTitle,
|
|
313
|
-
description: message,
|
|
314
|
-
status: escalate ? 'human_assigned' : 'ai_responding',
|
|
315
|
-
design_schema: schemas.ticketSchema,
|
|
316
|
-
validation_schema: schemas.ticketValidation,
|
|
317
|
-
data: {
|
|
318
|
-
status: escalate ? 'human_assigned' : 'ai_responding',
|
|
319
|
-
aim_triage_agent_id: ids.TRIAGE_AGENT_ID,
|
|
320
|
-
aim_confidence_threshold: promptConfig.confidence_threshold || CONFIDENCE_THRESHOLD,
|
|
321
|
-
aim_confidence_at_response: envelope.confidence,
|
|
322
|
-
aim_escalation_reason: escalate ? envelope.escalation_reason : 'none',
|
|
323
|
-
},
|
|
324
|
-
})
|
|
325
|
-
.select('id')
|
|
326
|
-
.single()
|
|
327
|
-
|
|
328
|
-
if (ticketError || !ticket) throw new Error(`Failed to create ticket: ${ticketError?.message}`)
|
|
329
|
-
const ticketId = ticket.id
|
|
330
|
-
|
|
331
|
-
// Create external thread with agent routing in data
|
|
332
|
-
const { data: thread, error: threadError } = await adminDb
|
|
333
|
-
.from('threads')
|
|
334
|
-
.insert({
|
|
335
|
-
type_id: ids.THREAD_TYPE_ID,
|
|
336
|
-
account_id,
|
|
337
|
-
target_type: 'items',
|
|
338
|
-
target_id: ticketId,
|
|
339
|
-
visibility: 'external',
|
|
340
|
-
status: 'active',
|
|
341
|
-
design_schema: schemas.threadSchema,
|
|
342
|
-
validation_schema: schemas.threadValidation,
|
|
343
|
-
data: {
|
|
344
|
-
agent_id: ids.TRIAGE_AGENT_ID,
|
|
345
|
-
prompt_config_id: ids.PROMPT_CONFIG_ID,
|
|
346
|
-
},
|
|
347
|
-
})
|
|
348
|
-
.select('id')
|
|
349
|
-
.single()
|
|
350
|
-
|
|
351
|
-
if (threadError || !thread) throw new Error(`Failed to create thread: ${threadError?.message}`)
|
|
352
|
-
const threadId = thread.id
|
|
353
|
-
|
|
354
|
-
// Post customer's inbound message (public)
|
|
355
|
-
const nextSeq = 1
|
|
356
|
-
const { data: customerMsg, error: custMsgErr } = await adminDb
|
|
357
|
-
.from('messages')
|
|
358
|
-
.insert({
|
|
359
|
-
type_id: ids.MESSAGE_TYPE_ID,
|
|
360
|
-
thread_id: threadId,
|
|
361
|
-
account_id,
|
|
362
|
-
person_id,
|
|
363
|
-
content: message,
|
|
364
|
-
direction: 'inbound',
|
|
365
|
-
visibility: 'public',
|
|
366
|
-
sequence: nextSeq,
|
|
367
|
-
design_schema: schemas.messageSchema,
|
|
368
|
-
validation_schema: schemas.messageValidation,
|
|
369
|
-
data: { message_type: 'human' },
|
|
370
|
-
})
|
|
371
|
-
.select('id')
|
|
372
|
-
.single()
|
|
373
|
-
|
|
374
|
-
if (custMsgErr || !customerMsg) throw new Error(`Failed to save customer message: ${custMsgErr?.message}`)
|
|
375
|
-
|
|
376
|
-
// Post AI public response (outbound, public)
|
|
377
|
-
const publicContent = escalate
|
|
378
|
-
? "We're looking into this and will have a human response shortly."
|
|
379
|
-
: envelope.public_response
|
|
380
|
-
|
|
381
|
-
const { data: publicMsg, error: pubMsgErr } = await adminDb
|
|
382
|
-
.from('messages')
|
|
383
|
-
.insert({
|
|
384
|
-
type_id: ids.MESSAGE_TYPE_ID,
|
|
385
|
-
thread_id: threadId,
|
|
386
|
-
account_id,
|
|
387
|
-
content: publicContent,
|
|
388
|
-
direction: 'outbound',
|
|
389
|
-
visibility: 'public',
|
|
390
|
-
sequence: nextSeq + 1,
|
|
391
|
-
design_schema: schemas.messageSchema,
|
|
392
|
-
validation_schema: schemas.messageValidation,
|
|
393
|
-
data: {
|
|
394
|
-
message_type: 'agent',
|
|
395
|
-
agent_id: ids.TRIAGE_AGENT_ID,
|
|
396
|
-
confidence: envelope.confidence,
|
|
397
|
-
escalated: escalate,
|
|
398
|
-
},
|
|
399
|
-
})
|
|
400
|
-
.select('id')
|
|
401
|
-
.single()
|
|
402
|
-
|
|
403
|
-
if (pubMsgErr || !publicMsg) throw new Error(`Failed to save public AI message: ${pubMsgErr?.message}`)
|
|
404
|
-
|
|
405
|
-
// Post internal AI note (outbound, internal) — full audit data
|
|
406
|
-
const internalPayload = {
|
|
407
|
-
turn: 1,
|
|
408
|
-
prompt_sent: [{ role: 'system', content: systemPrompt }, ...promptMessages],
|
|
409
|
-
kb_sources_retrieved: kbSources.map((s) => ({ id: s.document_id, content: s.content?.slice(0, 100), relevance: null })),
|
|
410
|
-
prior_tickets_retrieved: priorTickets.map((t) => ({ id: t.id, title: t.title })),
|
|
411
|
-
ai_raw_response: envelope,
|
|
412
|
-
confidence: envelope.confidence,
|
|
413
|
-
confidence_reasoning: envelope.confidence_reasoning,
|
|
414
|
-
escalation_decision: escalate ? envelope.escalation_reason : 'none',
|
|
415
|
-
model,
|
|
416
|
-
temperature,
|
|
417
|
-
latency_ms,
|
|
418
|
-
token_usage,
|
|
419
|
-
suggested_title: suggestedTitle,
|
|
420
|
-
}
|
|
421
|
-
|
|
422
|
-
const { data: internalMsg, error: intMsgErr } = await adminDb
|
|
423
|
-
.from('messages')
|
|
424
|
-
.insert({
|
|
425
|
-
type_id: ids.MESSAGE_TYPE_ID,
|
|
426
|
-
thread_id: threadId,
|
|
427
|
-
account_id,
|
|
428
|
-
content: `[AI Internal] Turn 1 — Confidence: ${envelope.confidence.toFixed(2)} — ${envelope.confidence_reasoning}`,
|
|
429
|
-
direction: 'outbound',
|
|
430
|
-
visibility: 'internal',
|
|
431
|
-
sequence: nextSeq + 2,
|
|
432
|
-
design_schema: schemas.messageSchema,
|
|
433
|
-
validation_schema: schemas.messageValidation,
|
|
434
|
-
data: {
|
|
435
|
-
message_type: 'agent_internal',
|
|
436
|
-
ai_internal: internalPayload,
|
|
437
|
-
},
|
|
438
|
-
})
|
|
439
|
-
.select('id')
|
|
440
|
-
.single()
|
|
441
|
-
|
|
442
|
-
if (intMsgErr || !internalMsg) throw new Error(`Failed to save internal AI message: ${intMsgErr?.message}`)
|
|
443
|
-
|
|
444
|
-
return {
|
|
445
|
-
ticketId,
|
|
446
|
-
threadId,
|
|
447
|
-
publicMessageId: publicMsg.id,
|
|
448
|
-
internalMessageId: internalMsg.id,
|
|
449
|
-
public_response: publicContent,
|
|
450
|
-
confidence: envelope.confidence,
|
|
451
|
-
escalated: escalate,
|
|
452
|
-
escalation_reason: escalate ? envelope.escalation_reason : 'none',
|
|
453
|
-
suggested_title: suggestedTitle,
|
|
454
|
-
}
|
|
455
|
-
}
|
|
456
|
-
|
|
457
|
-
// ─── ACTION: REPLY (multi-turn) ───────────────────────────────────────────────
|
|
458
|
-
|
|
459
|
-
async function handleReply(ctx: any, body: any): Promise<TriageResult> {
|
|
460
|
-
const { message, thread_id, ticket_id } = body
|
|
461
|
-
const account_id = ctx.accountId as string
|
|
462
|
-
const person_id = ctx.principal?.id as string
|
|
463
|
-
if (!message || !thread_id || !ticket_id) throw new Error('message, thread_id, and ticket_id are required')
|
|
464
|
-
if (!account_id || !person_id) throw new Error('User context (account + person) required')
|
|
465
|
-
|
|
466
|
-
const ids = await resolveIds()
|
|
467
|
-
|
|
468
|
-
// Load agent + prompt config + type schemas
|
|
469
|
-
const [{ data: agent }, { data: promptConfig }, schemas] = await Promise.all([
|
|
470
|
-
adminDb.from('ai_agents').select('*').eq('id', ids.TRIAGE_AGENT_ID).single(),
|
|
471
|
-
adminDb.from('prompt_configs').select('*').eq('id', ids.PROMPT_CONFIG_ID).single(),
|
|
472
|
-
loadTypeSchemas(ids),
|
|
473
|
-
])
|
|
474
|
-
if (!agent || !promptConfig) throw new Error('Triage agent configuration not found')
|
|
475
|
-
|
|
476
|
-
const modelConfig = agent.model_config || {}
|
|
477
|
-
const model = modelConfig.model || process.env.LLM_DEFAULT_MODEL || 'gpt-4o'
|
|
478
|
-
const temperature = modelConfig.temperature ?? 0.7
|
|
479
|
-
|
|
480
|
-
// Load existing conversation history (public messages only)
|
|
481
|
-
const history = await getConversationHistory(thread_id)
|
|
482
|
-
const turnNumber = history.filter((m) => m.direction === 'inbound').length + 1
|
|
483
|
-
|
|
484
|
-
// Get current message count for sequencing
|
|
485
|
-
const { count: msgCount } = await adminDb
|
|
486
|
-
.from('messages')
|
|
487
|
-
.select('id', { count: 'exact', head: true })
|
|
488
|
-
.eq('thread_id', thread_id)
|
|
489
|
-
const nextSeq = (msgCount || 0) + 1
|
|
490
|
-
|
|
491
|
-
// Post customer message first (so it shows immediately)
|
|
492
|
-
const { data: customerMsg, error: custMsgErr } = await adminDb
|
|
493
|
-
.from('messages')
|
|
494
|
-
.insert({
|
|
495
|
-
type_id: ids.MESSAGE_TYPE_ID,
|
|
496
|
-
thread_id,
|
|
497
|
-
account_id,
|
|
498
|
-
person_id,
|
|
499
|
-
content: message,
|
|
500
|
-
direction: 'inbound',
|
|
501
|
-
visibility: 'public',
|
|
502
|
-
sequence: nextSeq,
|
|
503
|
-
design_schema: schemas.messageSchema,
|
|
504
|
-
validation_schema: schemas.messageValidation,
|
|
505
|
-
data: { message_type: 'human' },
|
|
506
|
-
})
|
|
507
|
-
.select('id')
|
|
508
|
-
.single()
|
|
509
|
-
|
|
510
|
-
if (custMsgErr || !customerMsg) throw new Error(`Failed to save customer message: ${custMsgErr?.message}`)
|
|
511
|
-
|
|
512
|
-
// Run AI with full history context
|
|
513
|
-
const kbSources = await searchKB(message, account_id, ids)
|
|
514
|
-
const priorTickets = await getPriorTickets(account_id, person_id, ids)
|
|
515
|
-
const systemPrompt = buildSystemPrompt(
|
|
516
|
-
agent.system_prompt,
|
|
517
|
-
promptConfig.context_template.replace('{{user_message}}', message),
|
|
518
|
-
kbSources,
|
|
519
|
-
priorTickets,
|
|
520
|
-
history
|
|
521
|
-
)
|
|
522
|
-
|
|
523
|
-
const promptMessages: Array<{ role: 'user' | 'assistant'; content: string }> = [
|
|
524
|
-
...history.map((m) => ({
|
|
525
|
-
role: (m.direction === 'inbound' ? 'user' : 'assistant') as 'user' | 'assistant',
|
|
526
|
-
content: m.content,
|
|
527
|
-
})),
|
|
528
|
-
{ role: 'user', content: message },
|
|
529
|
-
]
|
|
530
|
-
|
|
531
|
-
const { envelope, latency_ms, token_usage } = await callOpenAI(systemPrompt, promptMessages, model, temperature)
|
|
532
|
-
|
|
533
|
-
const escalate = envelope.escalate || envelope.confidence < (promptConfig.confidence_threshold || CONFIDENCE_THRESHOLD)
|
|
534
|
-
const publicContent = escalate
|
|
535
|
-
? "We're looking into this and will have a human response shortly."
|
|
536
|
-
: envelope.public_response
|
|
537
|
-
|
|
538
|
-
// Post AI public response
|
|
539
|
-
const { data: publicMsg, error: pubMsgErr } = await adminDb
|
|
540
|
-
.from('messages')
|
|
541
|
-
.insert({
|
|
542
|
-
type_id: ids.MESSAGE_TYPE_ID,
|
|
543
|
-
thread_id,
|
|
544
|
-
account_id,
|
|
545
|
-
content: publicContent,
|
|
546
|
-
direction: 'outbound',
|
|
547
|
-
visibility: 'public',
|
|
548
|
-
sequence: nextSeq + 1,
|
|
549
|
-
design_schema: schemas.messageSchema,
|
|
550
|
-
validation_schema: schemas.messageValidation,
|
|
551
|
-
data: {
|
|
552
|
-
message_type: 'agent',
|
|
553
|
-
agent_id: ids.TRIAGE_AGENT_ID,
|
|
554
|
-
confidence: envelope.confidence,
|
|
555
|
-
escalated: escalate,
|
|
556
|
-
},
|
|
557
|
-
})
|
|
558
|
-
.select('id')
|
|
559
|
-
.single()
|
|
560
|
-
|
|
561
|
-
if (pubMsgErr || !publicMsg) throw new Error(`Failed to save public AI message: ${pubMsgErr?.message}`)
|
|
562
|
-
|
|
563
|
-
// Post internal note
|
|
564
|
-
const internalPayload = {
|
|
565
|
-
turn: turnNumber,
|
|
566
|
-
prompt_sent: [{ role: 'system', content: systemPrompt }, ...promptMessages],
|
|
567
|
-
kb_sources_retrieved: kbSources.map((s) => ({ id: s.document_id, content: s.content?.slice(0, 100), relevance: null })),
|
|
568
|
-
prior_tickets_retrieved: priorTickets.map((t) => ({ id: t.id, title: t.title })),
|
|
569
|
-
ai_raw_response: envelope,
|
|
570
|
-
confidence: envelope.confidence,
|
|
571
|
-
confidence_reasoning: envelope.confidence_reasoning,
|
|
572
|
-
escalation_decision: escalate ? envelope.escalation_reason : 'none',
|
|
573
|
-
model,
|
|
574
|
-
temperature,
|
|
575
|
-
latency_ms,
|
|
576
|
-
token_usage,
|
|
577
|
-
}
|
|
578
|
-
|
|
579
|
-
const { data: internalMsg, error: intMsgErr } = await adminDb
|
|
580
|
-
.from('messages')
|
|
581
|
-
.insert({
|
|
582
|
-
type_id: ids.MESSAGE_TYPE_ID,
|
|
583
|
-
thread_id,
|
|
584
|
-
account_id,
|
|
585
|
-
content: `[AI Internal] Turn ${turnNumber} — Confidence: ${envelope.confidence.toFixed(2)} — ${envelope.confidence_reasoning}`,
|
|
586
|
-
direction: 'outbound',
|
|
587
|
-
visibility: 'internal',
|
|
588
|
-
sequence: nextSeq + 2,
|
|
589
|
-
design_schema: schemas.messageSchema,
|
|
590
|
-
validation_schema: schemas.messageValidation,
|
|
591
|
-
data: {
|
|
592
|
-
message_type: 'agent_internal',
|
|
593
|
-
ai_internal: internalPayload,
|
|
594
|
-
},
|
|
595
|
-
})
|
|
596
|
-
.select('id')
|
|
597
|
-
.single()
|
|
598
|
-
|
|
599
|
-
if (intMsgErr || !internalMsg) throw new Error(`Failed to save internal AI message: ${intMsgErr?.message}`)
|
|
600
|
-
|
|
601
|
-
// Update ticket status if escalated
|
|
602
|
-
if (escalate) {
|
|
603
|
-
const { data: currentTicket } = await adminDb
|
|
604
|
-
.from('items')
|
|
605
|
-
.select('data')
|
|
606
|
-
.eq('id', ticket_id)
|
|
607
|
-
.single()
|
|
608
|
-
|
|
609
|
-
await adminDb
|
|
610
|
-
.from('items')
|
|
611
|
-
.update({
|
|
612
|
-
status: 'human_assigned',
|
|
613
|
-
data: {
|
|
614
|
-
...(currentTicket?.data || {}),
|
|
615
|
-
status: 'human_assigned',
|
|
616
|
-
aim_confidence_at_response: envelope.confidence,
|
|
617
|
-
aim_escalation_reason: envelope.escalation_reason,
|
|
618
|
-
},
|
|
619
|
-
updated_at: new Date().toISOString(),
|
|
620
|
-
})
|
|
621
|
-
.eq('id', ticket_id)
|
|
622
|
-
}
|
|
623
|
-
|
|
624
|
-
return {
|
|
625
|
-
ticketId: ticket_id,
|
|
626
|
-
threadId: thread_id,
|
|
627
|
-
publicMessageId: publicMsg.id,
|
|
628
|
-
internalMessageId: internalMsg.id,
|
|
629
|
-
public_response: publicContent,
|
|
630
|
-
confidence: envelope.confidence,
|
|
631
|
-
escalated: escalate,
|
|
632
|
-
escalation_reason: escalate ? envelope.escalation_reason : 'none',
|
|
633
|
-
}
|
|
634
|
-
}
|
|
635
|
-
|
|
636
|
-
// ─── HANDLER ──────────────────────────────────────────────────────────────────
|
|
637
|
-
|
|
638
|
-
export const handler = createHandler(async (ctx, body) => {
|
|
639
|
-
const action = ctx.query?.action
|
|
640
|
-
|
|
641
|
-
switch (action) {
|
|
642
|
-
case 'new_ticket':
|
|
643
|
-
return await handleNewTicket(ctx, body)
|
|
644
|
-
case 'reply':
|
|
645
|
-
return await handleReply(ctx, body)
|
|
646
|
-
default:
|
|
647
|
-
throw new Error(`Unknown action: ${action}. Use 'new_ticket' or 'reply'.`)
|
|
648
|
-
}
|
|
649
|
-
})
|