spine-framework-portal 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.
@@ -165,7 +165,7 @@ export const checkUnanswered = createHandler(async (ctx, _body) => {
165
165
  // Resolve type IDs dynamically
166
166
  const [supportTicketTypeId, threadTypeId] = await Promise.all([
167
167
  resolveTypeId('item', 'support_ticket'),
168
- resolveTypeId('thread', 'thread')
168
+ resolveTypeId('thread', 'support_thread')
169
169
  ])
170
170
 
171
171
  try {
@@ -1,489 +1,137 @@
1
1
  import { createHandler, CoreContext } from './_shared/middleware'
2
2
  import { adminDb } from './_shared/db'
3
3
  import { resolveTypeId, resolveAgentId, resolvePromptConfigId } from './_shared/resolve-ids'
4
+ import { runAgent } from './_shared/index'
4
5
  import { create, update } from './admin-data'
5
6
 
6
7
  /**
7
- * Custom Support Triage Handler
8
+ * @module custom_support-triage
9
+ * @layer custom/portal
10
+ * @stability custom
8
11
  *
9
- * Two actions:
10
- * POST ?action=new_ticket — first message: creates ticket + thread + messages, runs AI
11
- * POST ?action=reply — follow-up message on existing ticket: posts message, runs AI
12
+ * Support ticket entry point. Two actions:
13
+ * POST ?action=new_ticket — creates ticket + external thread, calls runAgent()
14
+ * POST ?action=reply — posts the human message, calls runAgent() on the existing thread
12
15
  *
13
- * All runtime record creation goes through admin-data.create so design_schema,
14
- * validation_schema, scope, audit fields, and triggers are handled consistently.
16
+ * All AI inference (RAG, tool calls, confidence scoring, escalation pipeline) is
17
+ * delegated to core's runAgent() agent behaviour is controlled entirely via
18
+ * ai_agents + prompt_configs records (no AI logic in this file).
15
19
  *
16
- * AI response schema (enforced via response_format: json_object):
17
- * {
18
- * public_response: string,
19
- * confidence: number (0-1),
20
- * confidence_reasoning: string,
21
- * escalate: boolean,
22
- * escalation_reason: 'low_confidence' | 'none',
23
- * sources_used: Array<{ type, id, title, relevance }>,
24
- * suggested_title: string (turn 1 only)
25
- * }
20
+ * Response shape:
21
+ * { ticketId, threadId, agentMessageId, content, confidence, escalated }
22
+ *
23
+ * Escalation is handled inside agent-runner.ts via the escalation_pipeline
24
+ * configured in the prompt_config row — no escalation code here.
26
25
  */
27
26
 
28
- // ─── CONSTANTS ────────────────────────────────────────────────────────────────
29
-
30
- const CONFIDENCE_THRESHOLD = 0.75
27
+ // ─── HELPERS ──────────────────────────────────────────────────────────────────
31
28
 
32
- // Helper: call admin-data.update as a nested import (entity+id go in ctx.query)
33
29
  function adminDataUpdate(ctx: CoreContext, entity: string, id: string, fields: Record<string, any>) {
34
30
  return update({ ...ctx, query: { ...((ctx as any).query || {}), entity, id } } as any, fields)
35
31
  }
36
32
 
37
- // ─── TYPES ────────────────────────────────────────────────────────────────────
38
-
39
- interface TriageEnvelope {
40
- public_response: string
41
- confidence: number
42
- confidence_reasoning: string
43
- escalate: boolean
44
- escalation_reason: 'low_confidence' | 'none'
45
- sources_used: Array<{ type: string; id: string; title: string; relevance: number }>
46
- suggested_title?: string
47
- }
48
-
49
- interface TriageResult {
50
- ticketId: string
51
- threadId: string
52
- publicMessageId: string
53
- internalMessageId: string
54
- public_response: string
55
- confidence: number
56
- escalated: boolean
57
- escalation_reason: string
58
- suggested_title?: string
59
- }
60
-
61
- interface TypeIds {
62
- supportTicket: string
63
- thread: string
64
- message: string
65
- triageAgentId: string
66
- promptConfigId: string
67
- }
68
-
69
- // ─── HELPERS ──────────────────────────────────────────────────────────────────
70
-
71
- async function getConversationHistory(threadId: string): Promise<any[]> {
72
- const { data: msgs, error } = await adminDb
73
- .from('messages')
74
- .select('content, direction, data, created_at')
75
- .eq('thread_id', threadId)
76
- .eq('visibility', 'public')
77
- .order('created_at', { ascending: true })
78
- .limit(20)
79
- if (error) return []
80
- return msgs || []
81
- }
82
-
83
- async function getPriorTickets(accountId: string, personId: string, supportTicketTypeId: string): Promise<any[]> {
84
- const { data: tickets, error } = await adminDb
85
- .from('items')
86
- .select('id, title, description, created_at')
87
- .eq('type_id', supportTicketTypeId)
88
- .eq('account_id', accountId)
89
- .eq('created_by', personId)
90
- .order('created_at', { ascending: false })
91
- .limit(5)
92
- if (error) return []
93
- return tickets || []
94
- }
95
-
96
- const KB_PLATFORM_ACCOUNT_ID = '12acec9b-8451-40e7-80d5-e80c4e2fc0de' // spine-system
97
-
98
- async function searchKB(query: string, accountId: string): Promise<any[]> {
99
- const accountIds = [accountId]
100
- if (accountId !== KB_PLATFORM_ACCOUNT_ID) accountIds.push(KB_PLATFORM_ACCOUNT_ID)
101
-
102
- const { data: results, error } = await adminDb
103
- .from('embeddings')
104
- .select('document_id, content, metadata, account_id')
105
- .in('account_id', accountIds)
106
- .eq('metadata->>item_type', 'kb_article')
107
- .eq('metadata->>vector_type', 'semantic')
108
- .textSearch('content', query, { type: 'websearch', config: 'english' })
109
- .limit(8)
110
-
111
- if (error) return []
112
-
113
- return (results || [])
114
- .filter((r: any) => r.account_id === accountId || r.metadata?.security_level !== 'restricted')
115
- .slice(0, 5)
116
- }
117
-
118
- async function callOpenAI(
119
- systemPrompt: string,
120
- messages: Array<{ role: 'user' | 'assistant' | 'system'; content: string }>,
121
- model: string,
122
- temperature: number
123
- ): Promise<{ envelope: TriageEnvelope; latency_ms: number; token_usage: any }> {
124
- const apiKey = process.env.OPENAI_API_KEY || process.env.LLM_API_KEY
125
- const baseUrl = process.env.OPENAI_BASE_URL || 'https://api.openai.com/v1'
126
- const startTime = Date.now()
127
-
128
- if (!apiKey) {
129
- return {
130
- envelope: {
131
- public_response: '[Mock] No OPENAI_API_KEY set. This is a mock AI response for local development.',
132
- confidence: 0.85,
133
- confidence_reasoning: 'Mock response — no API key configured.',
134
- escalate: false,
135
- escalation_reason: 'none',
136
- sources_used: [],
137
- suggested_title: 'Mock Support Ticket'
138
- },
139
- latency_ms: 0,
140
- token_usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 }
141
- }
142
- }
143
-
144
- const res = await fetch(`${baseUrl}/chat/completions`, {
145
- method: 'POST',
146
- headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` },
147
- body: JSON.stringify({
148
- model,
149
- temperature,
150
- response_format: { type: 'json_object' },
151
- messages: [{ role: 'system', content: systemPrompt }, ...messages]
152
- })
153
- })
154
-
155
- if (!res.ok) {
156
- const err = await res.text()
157
- throw new Error(`OpenAI error ${res.status}: ${err.slice(0, 200)}`)
158
- }
159
-
160
- const result: any = await res.json()
161
- const raw = result.choices?.[0]?.message?.content || '{}'
162
- let envelope: TriageEnvelope
163
-
164
- try {
165
- envelope = JSON.parse(raw)
166
- if (typeof envelope.public_response !== 'string') throw new Error('missing public_response')
167
- if (typeof envelope.confidence !== 'number') throw new Error('missing confidence')
168
- } catch (parseErr: any) {
169
- envelope = {
170
- public_response: "We're looking into this and will have a human response shortly.",
171
- confidence: 0,
172
- confidence_reasoning: `Parse failure: ${parseErr.message}. Raw: ${raw.slice(0, 200)}`,
173
- escalate: true,
174
- escalation_reason: 'low_confidence',
175
- sources_used: []
176
- }
177
- }
178
-
179
- return { envelope, latency_ms: Date.now() - startTime, token_usage: result.usage || {} }
180
- }
181
-
182
- function buildSystemPrompt(
183
- agentSystemPrompt: string,
184
- contextTemplate: string,
185
- kbSources: any[],
186
- priorTickets: any[],
187
- history: any[]
188
- ): string {
189
- let prompt = agentSystemPrompt + '\n\n'
190
- prompt += contextTemplate + '\n\n'
191
-
192
- if (kbSources.length > 0) {
193
- prompt += '## Knowledge Base Articles\n'
194
- kbSources.forEach((doc, i) => { prompt += `[KB${i + 1}] ${doc.content?.slice(0, 500)}\n` })
195
- prompt += '\n'
196
- }
197
-
198
- if (priorTickets.length > 0) {
199
- prompt += "## Customer's Prior Tickets\n"
200
- priorTickets.forEach((t) => { prompt += `- ${t.title}: ${t.description?.slice(0, 200) || 'No description'}\n` })
201
- prompt += '\n'
202
- }
203
-
204
- if (history.length > 0) {
205
- prompt += '## Conversation So Far\n'
206
- history.forEach((m) => {
207
- prompt += `${m.direction === 'inbound' ? 'Customer' : 'Assistant'}: ${m.content}\n`
208
- })
209
- prompt += '\n'
210
- }
211
-
212
- prompt += `\nYou MUST respond with a single valid JSON object matching EXACTLY this schema. No markdown, no code fences, no extra text:\n`
213
- prompt += `{
214
- "public_response": "<your response to the customer>",
215
- "confidence": <0.0-1.0>,
216
- "confidence_reasoning": "<one sentence explaining your confidence score>",
217
- "escalate": <true|false>,
218
- "escalation_reason": "<low_confidence|none>",
219
- "sources_used": [{"type": "kb_article|prior_ticket", "id": "<id>", "title": "<title>", "relevance": <0.0-1.0>}],
220
- "suggested_title": "<3-8 word case title — ONLY include on the first turn>"
221
- }`
222
-
223
- return prompt
224
- }
225
-
226
33
  // ─── ACTION: NEW TICKET ───────────────────────────────────────────────────────
227
34
 
228
- async function handleNewTicket(ctx: any, body: any, typeIds: TypeIds): Promise<TriageResult> {
35
+ async function handleNewTicket(
36
+ ctx: any,
37
+ body: any,
38
+ ids: { supportTicket: string; thread: string; triageAgentId: string; promptConfigId: string }
39
+ ) {
229
40
  const { message } = body
230
41
  const account_id = ctx.accountId as string
231
42
  const person_id = ctx.principal?.id as string
232
43
  if (!message) throw new Error('message is required')
233
44
  if (!account_id || !person_id) throw new Error('User context (account + person) required')
234
45
 
235
- const { triageAgentId, promptConfigId } = typeIds
236
-
237
- // Load agent + prompt config (config tables — direct reads are fine)
238
- const [{ data: agent }, { data: promptConfig }] = await Promise.all([
239
- adminDb.from('ai_agents').select('*').eq('id', triageAgentId).single(),
240
- adminDb.from('prompt_configs').select('*').eq('id', promptConfigId).single()
241
- ])
242
- if (!agent || !promptConfig) throw new Error('Triage agent configuration not found')
243
-
244
- const model = agent.model_config?.model || process.env.LLM_DEFAULT_MODEL || 'gpt-4o'
245
- const temperature = agent.model_config?.temperature ?? 0.7
246
-
247
- // Run AI before ticket exists (no history yet)
248
- const [kbSources, priorTickets] = await Promise.all([
249
- searchKB(message, account_id),
250
- getPriorTickets(account_id, person_id, typeIds.supportTicket)
251
- ])
252
- const systemPrompt = buildSystemPrompt(
253
- agent.system_prompt,
254
- promptConfig.context_template.replace('{{user_message}}', message),
255
- kbSources,
256
- priorTickets,
257
- []
258
- )
259
- const { envelope, latency_ms, token_usage } = await callOpenAI(
260
- systemPrompt,
261
- [{ role: 'user', content: message }],
262
- model,
263
- temperature
264
- )
46
+ const { supportTicket, thread, triageAgentId, promptConfigId } = ids
265
47
 
266
- const escalate = envelope.escalate || envelope.confidence < (promptConfig.confidence_threshold || CONFIDENCE_THRESHOLD)
267
- const suggestedTitle = envelope.suggested_title || message.slice(0, 80)
268
-
269
- // Create ticket via admin-data (stamps schema, fires triggers)
48
+ // 1. Create a placeholder ticket title will be updated after agent responds
270
49
  const ticket = await create(ctx, {
271
50
  entity: 'items',
272
- type_id: typeIds.supportTicket,
273
- title: suggestedTitle,
51
+ type_id: supportTicket,
52
+ title: message.slice(0, 100),
274
53
  description: message,
275
- status: escalate ? 'human_assigned' : 'ai_responding',
54
+ status: 'ai_responding',
276
55
  data: {
277
- status: escalate ? 'human_assigned' : 'ai_responding',
278
56
  aim_triage_agent_id: triageAgentId,
279
- aim_confidence_threshold: promptConfig.confidence_threshold || CONFIDENCE_THRESHOLD,
280
- aim_confidence_at_response: envelope.confidence,
281
- aim_escalation_reason: escalate ? envelope.escalation_reason : 'none'
282
57
  }
283
58
  })
284
59
  if (!ticket?.id) throw new Error('Failed to create ticket')
285
- const ticketId = ticket.id
286
60
 
287
- // Create thread via admin-data
288
- const thread = await create(ctx, {
61
+ // 2. Create the external thread wired to the triage agent + prompt config.
62
+ // agent-runner resolves agent & config from thread.data.agent_id / prompt_config_id.
63
+ const threadRecord = await create(ctx, {
289
64
  entity: 'threads',
290
- type_id: typeIds.thread,
65
+ type_id: thread,
291
66
  target_type: 'items',
292
- target_id: ticketId,
67
+ target_id: ticket.id,
293
68
  visibility: 'external',
294
69
  status: 'active',
295
- data: { agent_id: triageAgentId, prompt_config_id: promptConfigId }
70
+ data: {
71
+ agent_id: triageAgentId,
72
+ prompt_config_id: promptConfigId,
73
+ }
296
74
  })
297
- if (!thread?.id) throw new Error('Failed to create thread')
298
- const threadId = thread.id
299
-
300
- const publicContent = escalate
301
- ? "We're looking into this and will have a human response shortly."
302
- : envelope.public_response
75
+ if (!threadRecord?.id) throw new Error('Failed to create thread')
303
76
 
304
- // Post all three messages via admin-data
305
- const customerMsg = await create(ctx, {
306
- entity: 'messages',
307
- type_id: typeIds.message,
308
- thread_id: threadId,
309
- person_id,
310
- content: message,
311
- direction: 'inbound',
312
- visibility: 'public',
313
- sequence: 1,
314
- data: { message_type: 'human' }
315
- })
316
- if (!customerMsg?.id) throw new Error('Failed to save customer message')
77
+ // 3. Run the agent saves user message, runs RAG+inference, handles escalation,
78
+ // saves agent message, emits audit. Returns the persisted agent message row.
79
+ const agentMsg = await runAgent(threadRecord.id, message, ctx)
317
80
 
318
- const publicMsg = await create(ctx, {
319
- entity: 'messages',
320
- type_id: typeIds.message,
321
- thread_id: threadId,
322
- content: publicContent,
323
- direction: 'outbound',
324
- visibility: 'public',
325
- sequence: 2,
326
- data: { message_type: 'agent', agent_id: triageAgentId, confidence: envelope.confidence, escalated: escalate }
327
- })
328
- if (!publicMsg?.id) throw new Error('Failed to save public AI message')
81
+ const confidence = agentMsg?.data?.confidence ?? 0
82
+ const escalated = agentMsg?.data?.escalated ?? false
329
83
 
330
- const internalMsg = await create(ctx, {
331
- entity: 'messages',
332
- type_id: typeIds.message,
333
- thread_id: threadId,
334
- content: `[AI Internal] Turn 1 — Confidence: ${envelope.confidence.toFixed(2)} — ${envelope.confidence_reasoning}`,
335
- direction: 'outbound',
336
- visibility: 'internal',
337
- sequence: 3,
84
+ // 4. Backfill suggested title if agent produced one, and stamp confidence/status
85
+ const suggestedTitle = agentMsg?.data?.suggested_title || message.slice(0, 80)
86
+ await adminDataUpdate(ctx, 'items', ticket.id, {
87
+ title: suggestedTitle,
88
+ status: escalated ? 'human_assigned' : 'ai_responding',
338
89
  data: {
339
- message_type: 'agent_internal',
340
- ai_internal: {
341
- turn: 1,
342
- prompt_sent: [{ role: 'system', content: systemPrompt }, { role: 'user', content: message }],
343
- kb_sources_retrieved: kbSources.map((s) => ({ id: s.document_id, content: s.content?.slice(0, 100), relevance: null })),
344
- prior_tickets_retrieved: priorTickets.map((t) => ({ id: t.id, title: t.title })),
345
- ai_raw_response: envelope,
346
- confidence: envelope.confidence,
347
- confidence_reasoning: envelope.confidence_reasoning,
348
- escalation_decision: escalate ? envelope.escalation_reason : 'none',
349
- model, temperature, latency_ms, token_usage, suggested_title: suggestedTitle
350
- }
90
+ aim_triage_agent_id: triageAgentId,
91
+ aim_confidence_at_response: confidence,
92
+ aim_escalation_reason: escalated ? 'low_confidence' : 'none',
351
93
  }
352
94
  })
353
- if (!internalMsg?.id) throw new Error('Failed to save internal AI message')
354
95
 
355
96
  return {
356
- ticketId,
357
- threadId,
358
- publicMessageId: publicMsg.id,
359
- internalMessageId: internalMsg.id,
360
- public_response: publicContent,
361
- confidence: envelope.confidence,
362
- escalated: escalate,
363
- escalation_reason: escalate ? envelope.escalation_reason : 'none',
364
- suggested_title: suggestedTitle
97
+ ticketId: ticket.id,
98
+ threadId: threadRecord.id,
99
+ agentMessageId: agentMsg?.id,
100
+ content: agentMsg?.content || '',
101
+ confidence,
102
+ escalated,
365
103
  }
366
104
  }
367
105
 
368
- // ─── ACTION: REPLY (multi-turn) ───────────────────────────────────────────────
106
+ // ─── ACTION: REPLY ────────────────────────────────────────────────────────────
369
107
 
370
- async function handleReply(ctx: any, body: any, typeIds: TypeIds): Promise<TriageResult> {
371
- const { message, thread_id, ticket_id } = body
372
- const account_id = ctx.accountId as string
108
+ async function handleReply(
109
+ ctx: any,
110
+ body: any,
111
+ _ids: { supportTicket: string; thread: string; triageAgentId: string; promptConfigId: string }
112
+ ) {
113
+ const { message, ticket_id, thread_id } = body
373
114
  const person_id = ctx.principal?.id as string
374
- if (!message || !thread_id || !ticket_id) throw new Error('message, thread_id, and ticket_id are required')
375
- if (!account_id || !person_id) throw new Error('User context (account + person) required')
376
-
377
- const { triageAgentId, promptConfigId } = typeIds
378
-
379
- // Load agent + prompt config (config tables — direct reads are fine)
380
- const [{ data: agent }, { data: promptConfig }] = await Promise.all([
381
- adminDb.from('ai_agents').select('*').eq('id', triageAgentId).single(),
382
- adminDb.from('prompt_configs').select('*').eq('id', promptConfigId).single()
383
- ])
384
- if (!agent || !promptConfig) throw new Error('Triage agent configuration not found')
385
-
386
- const model = agent.model_config?.model || process.env.LLM_DEFAULT_MODEL || 'gpt-4o'
387
- const temperature = agent.model_config?.temperature ?? 0.7
388
-
389
- const history = await getConversationHistory(thread_id)
390
- const turnNumber = history.filter((m) => m.direction === 'inbound').length + 1
391
-
392
- const { count: msgCount } = await adminDb
393
- .from('messages')
394
- .select('id', { count: 'exact', head: true })
395
- .eq('thread_id', thread_id)
396
- const nextSeq = (msgCount || 0) + 1
397
-
398
- // Post customer message first (shows immediately)
399
- const customerMsg = await create(ctx, {
400
- entity: 'messages',
401
- type_id: typeIds.message,
402
- thread_id,
403
- person_id,
404
- content: message,
405
- direction: 'inbound',
406
- visibility: 'public',
407
- sequence: nextSeq,
408
- data: { message_type: 'human' }
409
- })
410
- if (!customerMsg?.id) throw new Error('Failed to save customer message')
411
-
412
- // Run AI with full history
413
- const [kbSources, priorTickets] = await Promise.all([
414
- searchKB(message, account_id),
415
- getPriorTickets(account_id, person_id, typeIds.supportTicket)
416
- ])
417
- const systemPrompt = buildSystemPrompt(
418
- agent.system_prompt,
419
- promptConfig.context_template.replace('{{user_message}}', message),
420
- kbSources,
421
- priorTickets,
422
- history
423
- )
424
- const promptMessages: Array<{ role: 'user' | 'assistant'; content: string }> = [
425
- ...history.map((m) => ({ role: (m.direction === 'inbound' ? 'user' : 'assistant') as 'user' | 'assistant', content: m.content })),
426
- { role: 'user', content: message }
427
- ]
428
- const { envelope, latency_ms, token_usage } = await callOpenAI(systemPrompt, promptMessages, model, temperature)
429
-
430
- const escalate = envelope.escalate || envelope.confidence < (promptConfig.confidence_threshold || CONFIDENCE_THRESHOLD)
431
- const publicContent = escalate
432
- ? "We're looking into this and will have a human response shortly."
433
- : envelope.public_response
115
+ if (!message) throw new Error('message is required')
116
+ if (!ticket_id) throw new Error('ticket_id is required')
117
+ if (!thread_id) throw new Error('thread_id is required')
434
118
 
435
- const publicMsg = await create(ctx, {
436
- entity: 'messages',
437
- type_id: typeIds.message,
438
- thread_id,
439
- content: publicContent,
440
- direction: 'outbound',
441
- visibility: 'public',
442
- sequence: nextSeq + 1,
443
- data: { message_type: 'agent', agent_id: triageAgentId, confidence: envelope.confidence, escalated: escalate }
444
- })
445
- if (!publicMsg?.id) throw new Error('Failed to save public AI message')
119
+ // agent-runner saves the user message then runs inference on the existing thread.
120
+ // Thread.data.agent_id / prompt_config_id are already set from ticket creation.
121
+ const agentMsg = await runAgent(thread_id, message, ctx)
446
122
 
447
- const internalMsg = await create(ctx, {
448
- entity: 'messages',
449
- type_id: typeIds.message,
450
- thread_id,
451
- content: `[AI Internal] Turn ${turnNumber} — Confidence: ${envelope.confidence.toFixed(2)} — ${envelope.confidence_reasoning}`,
452
- direction: 'outbound',
453
- visibility: 'internal',
454
- sequence: nextSeq + 2,
455
- data: {
456
- message_type: 'agent_internal',
457
- ai_internal: {
458
- turn: turnNumber,
459
- prompt_sent: [{ role: 'system', content: systemPrompt }, ...promptMessages],
460
- kb_sources_retrieved: kbSources.map((s) => ({ id: s.document_id, content: s.content?.slice(0, 100), relevance: null })),
461
- prior_tickets_retrieved: priorTickets.map((t) => ({ id: t.id, title: t.title })),
462
- ai_raw_response: envelope,
463
- confidence: envelope.confidence,
464
- confidence_reasoning: envelope.confidence_reasoning,
465
- escalation_decision: escalate ? envelope.escalation_reason : 'none',
466
- model, temperature, latency_ms, token_usage
467
- }
468
- }
469
- })
470
- if (!internalMsg?.id) throw new Error('Failed to save internal AI message')
123
+ const confidence = agentMsg?.data?.confidence ?? 0
124
+ const escalated = agentMsg?.data?.escalated ?? false
471
125
 
472
- // Update ticket status if escalated
473
- if (escalate) {
126
+ if (escalated) {
474
127
  const { data: currentTicket } = await adminDb
475
- .from('items')
476
- .select('data')
477
- .eq('id', ticket_id)
478
- .single()
479
-
128
+ .from('items').select('data').eq('id', ticket_id).single()
480
129
  await adminDataUpdate(ctx, 'items', ticket_id, {
481
130
  status: 'human_assigned',
482
131
  data: {
483
132
  ...(currentTicket?.data || {}),
484
- status: 'human_assigned',
485
- aim_confidence_at_response: envelope.confidence,
486
- aim_escalation_reason: envelope.escalation_reason
133
+ aim_confidence_at_response: confidence,
134
+ aim_escalation_reason: 'low_confidence',
487
135
  }
488
136
  })
489
137
  }
@@ -491,12 +139,10 @@ async function handleReply(ctx: any, body: any, typeIds: TypeIds): Promise<Triag
491
139
  return {
492
140
  ticketId: ticket_id,
493
141
  threadId: thread_id,
494
- publicMessageId: publicMsg.id,
495
- internalMessageId: internalMsg.id,
496
- public_response: publicContent,
497
- confidence: envelope.confidence,
498
- escalated: escalate,
499
- escalation_reason: escalate ? envelope.escalation_reason : 'none'
142
+ agentMessageId: agentMsg?.id,
143
+ content: agentMsg?.content || '',
144
+ confidence,
145
+ escalated,
500
146
  }
501
147
  }
502
148
 
@@ -505,19 +151,17 @@ async function handleReply(ctx: any, body: any, typeIds: TypeIds): Promise<Triag
505
151
  export const handler = createHandler(async (ctx, body) => {
506
152
  const action = (ctx as any).query?.action
507
153
 
508
- // Resolve all IDs once, shared across both actions
509
- const [supportTicket, thread, message, triageAgentId, promptConfigId] = await Promise.all([
154
+ const [supportTicket, thread, triageAgentId, promptConfigId] = await Promise.all([
510
155
  resolveTypeId('item', 'support_ticket'),
511
- resolveTypeId('thread', 'thread'),
512
- resolveTypeId('message', 'message'),
156
+ resolveTypeId('thread', 'support_thread'),
513
157
  resolveAgentId('Support Triage Agent'),
514
158
  resolvePromptConfigId('support_triage'),
515
159
  ])
516
- const typeIds: TypeIds = { supportTicket, thread, message, triageAgentId, promptConfigId }
160
+ const ids = { supportTicket, thread, triageAgentId, promptConfigId }
517
161
 
518
162
  switch (action) {
519
- case 'new_ticket': return handleNewTicket(ctx, body, typeIds)
520
- case 'reply': return handleReply(ctx, body, typeIds)
163
+ case 'new_ticket': return handleNewTicket(ctx, body, ids)
164
+ case 'reply': return handleReply(ctx, body, ids)
521
165
  default: throw new Error(`Unknown action: ${action}. Use 'new_ticket' or 'reply'.`)
522
166
  }
523
167
  })
@@ -35,19 +35,39 @@ async function fetchJSON(path: string, options?: RequestInit) {
35
35
  return json.data
36
36
  }
37
37
 
38
- export function usePortalThread(targetType: string, targetId: string | null) {
38
+ type ThreadDomain = 'support' | 'community' | 'generic'
39
+
40
+ function getThreadTypeSlug(domain: ThreadDomain) {
41
+ if (domain === 'support') return 'support_thread'
42
+ if (domain === 'community') return 'community_thread'
43
+ return 'thread'
44
+ }
45
+
46
+ function getMessageTypeSlug(domain: ThreadDomain) {
47
+ if (domain === 'support') return 'support_reply'
48
+ if (domain === 'community') return 'community_reply'
49
+ return 'message'
50
+ }
51
+
52
+ export function usePortalThread(targetType: string, targetId: string | null, domain: ThreadDomain = 'generic') {
39
53
  const [thread, setThread] = useState<PortalThread | null>(null)
40
54
  const [messages, setMessages] = useState<PortalMessage[]>([])
41
55
  const [loading, setLoading] = useState(false)
42
56
  const [error, setError] = useState<string | null>(null)
43
57
 
58
+ const threadTypeSlug = getThreadTypeSlug(domain)
59
+ const messageTypeSlug = getMessageTypeSlug(domain)
60
+
44
61
  const load = useCallback(async () => {
45
62
  if (!targetId) return
46
63
  setLoading(true)
47
64
  setError(null)
48
65
  try {
66
+ const threadTypeId = await getTypeIdAsync(threadTypeSlug)
67
+ if (!threadTypeId) throw new Error(`${threadTypeSlug} type not found`)
68
+
49
69
  const threads = await fetchJSON(
50
- `/.netlify/functions/admin-data?entity=threads&target_type=${targetType}&target_id=${targetId}`
70
+ `/.netlify/functions/admin-data?entity=threads&target_type=${targetType}&target_id=${targetId}&type_id=${threadTypeId}`
51
71
  )
52
72
  const found: PortalThread | null = Array.isArray(threads) ? (threads[0] ?? null) : null
53
73
  setThread(found)
@@ -64,7 +84,7 @@ export function usePortalThread(targetType: string, targetId: string | null) {
64
84
  } finally {
65
85
  setLoading(false)
66
86
  }
67
- }, [targetType, targetId])
87
+ }, [targetType, targetId, threadTypeSlug])
68
88
 
69
89
  useEffect(() => { load() }, [load])
70
90
 
@@ -72,8 +92,8 @@ export function usePortalThread(targetType: string, targetId: string | null) {
72
92
  let activeThread = thread
73
93
 
74
94
  if (!activeThread?.id) {
75
- const threadTypeId = await getTypeIdAsync('thread')
76
- if (!threadTypeId) throw new Error('thread type not found')
95
+ const threadTypeId = await getTypeIdAsync(threadTypeSlug)
96
+ if (!threadTypeId) throw new Error(`${threadTypeSlug} type not found`)
77
97
 
78
98
  activeThread = await fetchJSON('/.netlify/functions/admin-data', {
79
99
  method: 'POST',
@@ -88,8 +108,8 @@ export function usePortalThread(targetType: string, targetId: string | null) {
88
108
  setThread(activeThread)
89
109
  }
90
110
 
91
- const messageTypeId = await getTypeIdAsync('message')
92
- if (!messageTypeId) throw new Error('message type not found')
111
+ const messageTypeId = await getTypeIdAsync(messageTypeSlug)
112
+ if (!messageTypeId) throw new Error(`${messageTypeSlug} type not found`)
93
113
 
94
114
  // Calculate next sequence based on current messages to avoid stale state
95
115
  const nextSequence = messages.reduce((max, m) => Math.max(max, m.sequence || 0), 0) + 1
@@ -107,7 +127,7 @@ export function usePortalThread(targetType: string, targetId: string | null) {
107
127
  })
108
128
  setMessages((prev) => [...prev, msg])
109
129
  return msg
110
- }, [thread, targetType, targetId, messages])
130
+ }, [thread, targetType, targetId, messages, threadTypeSlug, messageTypeSlug])
111
131
 
112
132
  return { thread, messages, loading, error, reply, refetch: load }
113
133
  }
package/manifest.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "Portal",
3
3
  "slug": "portal",
4
4
  "description": "Self-service portal for customers to access tickets, knowledge base, courses, and community",
5
- "version": "0.2.10",
5
+ "version": "0.2.21",
6
6
  "app_type": "full",
7
7
  "route_prefix": "/portal",
8
8
  "required_roles": ["member", "member_admin"],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spine-framework-portal",
3
- "version": "0.2.19",
3
+ "version": "0.2.21",
4
4
  "private": false,
5
5
  "description": "Customer Portal — self-service portal app for Spine Framework",
6
6
  "type": "module",
@@ -22,7 +22,7 @@ const CHANNEL_LABELS: Record<string, string> = {
22
22
  }
23
23
 
24
24
  function ThreadPane({ post }: { post: CommunityPost }) {
25
- const { messages, loading, reply } = usePortalThread('items', post.id)
25
+ const { messages, loading, reply } = usePortalThread('items', post.id, 'community')
26
26
  const [replyText, setReplyText] = useState('')
27
27
  const [sending, setSending] = useState(false)
28
28
  const { sendSignal } = usePortalSignal()
@@ -85,7 +85,7 @@ function AIResponseCard({ message, ticketId, messageId, onFeedback }: {
85
85
  }
86
86
 
87
87
  function TicketThread({ ticketId, ticketStatus, onStatusChange }: TicketThreadProps) {
88
- const { messages, loading, thread, refetch } = usePortalThread('items', ticketId)
88
+ const { messages, loading, thread, refetch } = usePortalThread('items', ticketId, 'support')
89
89
  const [replyText, setReplyText] = useState('')
90
90
  const [aiState, setAiState] = useState<'idle' | 'analyzing' | 'responded' | 'escalated'>('idle')
91
91
  const { sendReply, loading: replying } = useTriageReply()
package/seed/types.json CHANGED
@@ -1300,10 +1300,162 @@
1300
1300
  }
1301
1301
  },
1302
1302
  {
1303
- "kind": "item",
1303
+ "kind": "thread",
1304
+ "slug": "support_thread",
1305
+ "name": "Support Thread",
1306
+ "description": "Customer-visible conversation thread attached to a support ticket",
1307
+ "icon": "message-square",
1308
+ "color": "#EF4444",
1309
+ "ownership": "tenant",
1310
+ "is_active": true,
1311
+ "validation_schema": {},
1312
+ "design_schema": {
1313
+ "scope": "account",
1314
+ "views": {
1315
+ "default_list": {
1316
+ "type": "list",
1317
+ "label": "Support Threads",
1318
+ "fields": {
1319
+ "title": { "sortable": true, "display_type": "text" },
1320
+ "target_type": { "sortable": true, "display_type": "text" },
1321
+ "visibility": { "sortable": true, "display_type": "badge" },
1322
+ "status": { "sortable": true, "display_type": "badge" }
1323
+ },
1324
+ "display": "table"
1325
+ },
1326
+ "default_detail": {
1327
+ "type": "detail",
1328
+ "label": "Support Thread",
1329
+ "sections": [
1330
+ { "title": "Overview", "fields": ["title", "target_type", "target_id", "visibility", "status"] }
1331
+ ]
1332
+ }
1333
+ },
1334
+ "fields": {
1335
+ "title": { "data_type": "text", "label": "Title", "required": false, "system": true },
1336
+ "target_type": { "data_type": "text", "label": "Target Type", "required": true, "system": true },
1337
+ "target_id": { "data_type": "uuid", "label": "Target ID", "required": true, "system": true },
1338
+ "visibility": { "data_type": "text", "label": "Visibility", "required": true, "system": true },
1339
+ "status": { "data_type": "text", "label": "Status", "required": true, "system": true },
1340
+ "is_active": { "data_type": "boolean", "label": "Active", "required": true, "system": true },
1341
+ "created_at": { "data_type": "datetime", "label": "Created", "required": false, "system": true, "readonly": true },
1342
+ "updated_at": { "data_type": "datetime", "label": "Updated", "required": false, "system": true, "readonly": true }
1343
+ },
1344
+ "record_permissions": {
1345
+ "system_admin": ["create", "read", "update", "delete"],
1346
+ "support_admin": ["create", "read", "update"],
1347
+ "support": ["create", "read", "update"],
1348
+ "member_admin": ["create", "read", "update"],
1349
+ "member": ["create", "read", "update"]
1350
+ }
1351
+ }
1352
+ },
1353
+ {
1354
+ "kind": "message",
1355
+ "slug": "support_reply",
1356
+ "name": "Support Reply",
1357
+ "description": "Customer or agent message within a support thread",
1358
+ "icon": "message-circle",
1359
+ "color": "#EF4444",
1360
+ "ownership": "tenant",
1361
+ "is_active": true,
1362
+ "validation_schema": {},
1363
+ "design_schema": {
1364
+ "scope": "account",
1365
+ "views": {
1366
+ "default_list": {
1367
+ "type": "list",
1368
+ "label": "Support Replies",
1369
+ "fields": {
1370
+ "content": { "sortable": false, "display_type": "text" },
1371
+ "direction": { "sortable": true, "display_type": "badge" },
1372
+ "visibility": { "sortable": true, "display_type": "badge" },
1373
+ "created_at": { "sortable": true, "display_type": "datetime" }
1374
+ },
1375
+ "display": "table"
1376
+ },
1377
+ "default_detail": {
1378
+ "type": "detail",
1379
+ "label": "Support Reply",
1380
+ "sections": [
1381
+ { "title": "Content", "fields": ["content", "direction", "visibility"] }
1382
+ ]
1383
+ }
1384
+ },
1385
+ "fields": {
1386
+ "content": { "data_type": "textarea", "label": "Content", "required": true, "system": true },
1387
+ "direction": { "data_type": "text", "label": "Direction", "required": true, "system": true },
1388
+ "sequence": { "data_type": "number", "label": "Sequence", "required": true, "system": true },
1389
+ "visibility": { "data_type": "text", "label": "Visibility", "required": true, "system": true },
1390
+ "is_active": { "data_type": "boolean", "label": "Active", "required": true, "system": true },
1391
+ "created_at": { "data_type": "datetime", "label": "Created", "required": false, "system": true, "readonly": true },
1392
+ "updated_at": { "data_type": "datetime", "label": "Updated", "required": false, "system": true, "readonly": true }
1393
+ },
1394
+ "record_permissions": {
1395
+ "system_admin": ["create", "read", "update", "delete"],
1396
+ "support_admin": ["create", "read", "update"],
1397
+ "support": ["create", "read", "update"],
1398
+ "member_admin": ["create", "read", "update"],
1399
+ "member": ["create", "read", "update"]
1400
+ }
1401
+ }
1402
+ },
1403
+ {
1404
+ "kind": "thread",
1405
+ "slug": "community_thread",
1406
+ "name": "Community Thread",
1407
+ "description": "Public discussion thread attached to a community post",
1408
+ "icon": "users",
1409
+ "color": "#6366F1",
1410
+ "ownership": "tenant",
1411
+ "is_active": true,
1412
+ "validation_schema": {},
1413
+ "design_schema": {
1414
+ "scope": "platform",
1415
+ "views": {
1416
+ "default_list": {
1417
+ "type": "list",
1418
+ "label": "Community Threads",
1419
+ "fields": {
1420
+ "title": { "sortable": true, "display_type": "text" },
1421
+ "target_type": { "sortable": true, "display_type": "text" },
1422
+ "visibility": { "sortable": true, "display_type": "badge" },
1423
+ "status": { "sortable": true, "display_type": "badge" }
1424
+ },
1425
+ "display": "table"
1426
+ },
1427
+ "default_detail": {
1428
+ "type": "detail",
1429
+ "label": "Community Thread",
1430
+ "sections": [
1431
+ { "title": "Overview", "fields": ["title", "target_type", "target_id", "visibility", "status"] }
1432
+ ]
1433
+ }
1434
+ },
1435
+ "fields": {
1436
+ "title": { "data_type": "text", "label": "Title", "required": false, "system": true },
1437
+ "target_type": { "data_type": "text", "label": "Target Type", "required": true, "system": true },
1438
+ "target_id": { "data_type": "uuid", "label": "Target ID", "required": true, "system": true },
1439
+ "visibility": { "data_type": "text", "label": "Visibility", "required": true, "system": true },
1440
+ "status": { "data_type": "text", "label": "Status", "required": true, "system": true },
1441
+ "is_active": { "data_type": "boolean", "label": "Active", "required": true, "system": true },
1442
+ "created_at": { "data_type": "datetime", "label": "Created", "required": false, "system": true, "readonly": true },
1443
+ "updated_at": { "data_type": "datetime", "label": "Updated", "required": false, "system": true, "readonly": true }
1444
+ },
1445
+ "record_permissions": {
1446
+ "system_admin": ["create", "read", "update", "delete"],
1447
+ "support_admin": ["create", "read", "update"],
1448
+ "support": ["create", "read", "update"],
1449
+ "member_admin": ["create", "read", "update"],
1450
+ "member": ["create", "read", "update"]
1451
+ }
1452
+ }
1453
+ },
1454
+ {
1455
+ "kind": "message",
1304
1456
  "slug": "community_reply",
1305
1457
  "name": "Community Reply",
1306
- "description": "Reply to a community post or discussion",
1458
+ "description": "Public reply within a community thread",
1307
1459
  "icon": "message-circle",
1308
1460
  "color": "#22C55E",
1309
1461
  "ownership": "tenant",
@@ -1316,18 +1468,10 @@
1316
1468
  "type": "list",
1317
1469
  "label": "Community Replies",
1318
1470
  "fields": {
1319
- "title": {
1320
- "sortable": true,
1321
- "display_type": "text"
1322
- },
1323
- "parent_post_id": {
1324
- "sortable": true,
1325
- "display_type": "text"
1326
- },
1327
- "created_at": {
1328
- "sortable": true,
1329
- "display_type": "datetime"
1330
- }
1471
+ "content": { "sortable": false, "display_type": "text" },
1472
+ "direction": { "sortable": true, "display_type": "badge" },
1473
+ "visibility": { "sortable": true, "display_type": "badge" },
1474
+ "created_at": { "sortable": true, "display_type": "datetime" }
1331
1475
  },
1332
1476
  "display": "table"
1333
1477
  },
@@ -1335,73 +1479,26 @@
1335
1479
  "type": "detail",
1336
1480
  "label": "Community Reply",
1337
1481
  "sections": [
1338
- {
1339
- "title": "Content",
1340
- "fields": [
1341
- "title",
1342
- "content"
1343
- ]
1344
- },
1345
- {
1346
- "title": "Context",
1347
- "fields": [
1348
- "parent_post_id"
1349
- ]
1350
- }
1482
+ { "title": "Content", "fields": ["content", "direction", "visibility"] }
1351
1483
  ]
1352
1484
  }
1353
1485
  },
1354
1486
  "fields": {
1355
- "title": {
1356
- "label": "Title",
1357
- "required": true,
1358
- "data_type": "text"
1359
- },
1360
- "content": {
1361
- "label": "Content",
1362
- "required": true,
1363
- "data_type": "textarea"
1364
- },
1365
- "parent_post_id": {
1366
- "label": "Parent Post ID",
1367
- "required": true,
1368
- "data_type": "text"
1369
- },
1370
- "is_solution": {
1371
- "label": "Is Solution",
1372
- "data_type": "boolean",
1373
- "default": false
1374
- }
1487
+ "content": { "data_type": "textarea", "label": "Content", "required": true, "system": true },
1488
+ "direction": { "data_type": "text", "label": "Direction", "required": true, "system": true },
1489
+ "sequence": { "data_type": "number", "label": "Sequence", "required": true, "system": true },
1490
+ "visibility": { "data_type": "text", "label": "Visibility", "required": true, "system": true },
1491
+ "is_active": { "data_type": "boolean", "label": "Active", "required": true, "system": true },
1492
+ "created_at": { "data_type": "datetime", "label": "Created", "required": false, "system": true, "readonly": true },
1493
+ "updated_at": { "data_type": "datetime", "label": "Updated", "required": false, "system": true, "readonly": true }
1375
1494
  },
1376
1495
  "record_permissions": {
1377
- "system_admin": [
1378
- "create",
1379
- "read",
1380
- "update",
1381
- "delete"
1382
- ],
1383
- "support_admin": [
1384
- "create",
1385
- "read",
1386
- "update"
1387
- ],
1388
- "support": [
1389
- "create",
1390
- "read",
1391
- "update"
1392
- ],
1393
- "member_admin": [
1394
- "create",
1395
- "read",
1396
- "update"
1397
- ],
1398
- "member": [
1399
- "create",
1400
- "read",
1401
- "update"
1402
- ]
1403
- },
1404
- "functionality": null
1496
+ "system_admin": ["create", "read", "update", "delete"],
1497
+ "support_admin": ["create", "read", "update"],
1498
+ "support": ["create", "read", "update"],
1499
+ "member_admin": ["create", "read", "update"],
1500
+ "member": ["create", "read", "update"]
1501
+ }
1405
1502
  }
1406
1503
  }
1407
1504
  ]