spine-framework-portal 0.2.19 → 0.2.20

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.
@@ -1,12 +1,12 @@
1
1
  import { createHandler } from './_shared/middleware'
2
- import { adminDb } from './_shared/db'
3
2
  import { resolveTypeId } from './_shared/resolve-ids'
3
+ import { create } from './admin-data'
4
4
 
5
5
  export const handler = createHandler(async (ctx, body) => {
6
- const {
7
- action_type,
8
- action_value,
9
- action_description,
6
+ const {
7
+ action_type,
8
+ action_value,
9
+ action_description,
10
10
  session_id,
11
11
  url,
12
12
  path,
@@ -27,14 +27,7 @@ export const handler = createHandler(async (ctx, body) => {
27
27
  throw err
28
28
  }
29
29
 
30
- // Get the funnel_signal type_id
31
- const { data: typeId } = await adminDb
32
- .from('types')
33
- .select('id')
34
- .eq('kind', 'item')
35
- .eq('slug', 'funnel_signal')
36
- .single()
37
-
30
+ const typeId = await resolveTypeId('item', 'funnel_signal')
38
31
  if (!typeId) {
39
32
  const err: any = new Error('funnel_signal type not found')
40
33
  err.statusCode = 500
@@ -42,36 +35,33 @@ export const handler = createHandler(async (ctx, body) => {
42
35
  }
43
36
 
44
37
  const signalData = {
45
- account_id: ctx.accountId,
46
- person_id: ctx.principal.id,
47
- session_id: session_id || `portal_${ctx.principal.id}_${Date.now()}`,
48
- anonymous_id: anonymous_id || null,
49
- stage: 'identified',
50
- source: 'port', // Portal-specific source for user engagement tracking
38
+ identity_person_id: ctx.principal.id,
39
+ identity_account_id: ctx.accountId,
40
+ identity_session_id: session_id || `portal_${ctx.principal.id}_${Date.now()}`,
41
+ identity_anonymous_id: anonymous_id || null,
42
+ classification_stage: 'identified',
43
+ classification_source: 'port',
51
44
  action_type,
52
45
  action_value,
53
46
  ...(action_description && { action_description }),
54
- ...(url && { url }),
55
- ...(path && { path }),
56
- ...(referrer && { referrer }),
57
- ...(user_agent && { user_agent }),
58
- occurred_at: new Date().toISOString()
47
+ ...(url && { portal_url: url }),
48
+ ...(path && { portal_path: path }),
49
+ ...(referrer && { portal_referrer: referrer }),
50
+ ...(user_agent && { portal_user_agent: user_agent }),
51
+ processing_received_at: new Date().toISOString()
59
52
  }
60
53
 
61
- // Create the signal item directly using admin-data pattern
62
- const { data: signal, error } = await adminDb
63
- .from('items')
64
- .insert({
65
- type_id: typeId.id,
66
- title: `${action_type} - ${action_value}`,
67
- account_id: ctx.accountId,
68
- data: signalData
69
- })
70
- .select('id')
71
- .single()
54
+ // Delegate to admin-data.create so design_schema, validation_schema,
55
+ // scope resolution, and audit fields are all stamped correctly.
56
+ const signal = await create(ctx, {
57
+ entity: 'items',
58
+ type_id: typeId,
59
+ title: `${action_type} - ${action_value}`,
60
+ data: signalData
61
+ })
72
62
 
73
- if (error || !signal) {
74
- const err: any = new Error(`Failed to create signal: ${error?.message || 'Unknown error'}`)
63
+ if (!signal?.id) {
64
+ const err: any = new Error('Failed to create signal')
75
65
  err.statusCode = 500
76
66
  throw err
77
67
  }
@@ -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
156
  resolveTypeId('thread', 'thread'),
512
- resolveTypeId('message', 'message'),
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
  })
package/manifest.json CHANGED
@@ -2,9 +2,10 @@
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.20",
6
6
  "app_type": "full",
7
- "route_prefix": "/portal",
7
+ "route_prefix": "/",
8
+ "prod_domain": "portal.spine-framework.com",
8
9
  "required_roles": ["member", "member_admin"],
9
10
  "routes": [
10
11
  "/",
@@ -41,7 +42,7 @@
41
42
  "registration": {
42
43
  "enabled": true,
43
44
  "default_role": "member",
44
- "redirect_path": "/portal",
45
+ "redirect_path": "/",
45
46
  "account_strategy": "new"
46
47
  }
47
48
  }
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.20",
4
4
  "private": false,
5
5
  "description": "Customer Portal — self-service portal app for Spine Framework",
6
6
  "type": "module",
package/LICENSE.md DELETED
@@ -1,193 +0,0 @@
1
- Spine Framework Internal Use License 1.0.0
2
- Source-available. Free for internal use. Commercial rights reserved.
3
-
4
- This license is based on the structure and intent of the PolyForm Internal Use License 1.0.0, but it has been modified for Spine Framework. This is not the unmodified PolyForm Internal Use License.
5
-
6
- Required Notice
7
- Required Notice: Copyright © 2026 Dahl Ventures Inc. All rights reserved.
8
-
9
- 1. Acceptance
10
- In order to receive any license under these terms, you must agree to these terms as both strict obligations and conditions to all licenses granted to you.
11
-
12
- If you do not agree to these terms, you do not have permission to use, copy, modify, run, install, access, or create works based on Spine Framework.
13
-
14
- 2. Definitions
15
- Software means Spine Framework, including its source code, object code, packages, modules, templates, schemas, migrations, configuration files, documentation, examples, command-line tools, SDKs, application packs, and related materials provided by the licensor.
16
-
17
- Licensor means the legal owner of Spine Framework.
18
-
19
- You means the individual or legal entity receiving the Software.
20
-
21
- Your Company means the legal entity on whose behalf you use the Software, including that entity's employees and authorized contractors.
22
-
23
- Internal Business Use means use of the Software solely inside Your Company for Your Company's own internal business operations, internal workflows, internal applications, internal data, internal users, and internal customers or accounts.
24
-
25
- Third Party means any person or legal entity other than You or Your Company.
26
-
27
- Commercial License means a separate written agreement signed by the Licensor that grants rights beyond this license.
28
-
29
- 3. Copyright License
30
- The Licensor grants You a copyright license to do everything with the Software that would otherwise infringe the Licensor's copyright, but only for Internal Business Use and only as permitted by these terms.
31
-
32
- You may install, run, access, evaluate, test, configure, and use the Software for Internal Business Use.
33
-
34
- You may copy the Software only as reasonably necessary for Internal Business Use, including backup, deployment, development, testing, staging, disaster recovery, and internal security review.
35
-
36
- You may not distribute the Software except as expressly allowed in these terms.
37
-
38
- 4. Changes and New Works
39
- The Licensor grants You a copyright license to modify the Software and create new works based on the Software, but only for Internal Business Use.
40
-
41
- You may create internal applications, extensions, workflows, integrations, automations, schemas, roles, agents, triggers, pipelines, dashboards, reports, and other internal business systems using the Software.
42
-
43
- You may not distribute, sell, sublicense, transfer, lease, rent, host for others, white-label, resell, or commercially exploit any modified version, derivative work, extension, application pack, template, or implementation of the Software except under a Commercial License.
44
-
45
- 5. Internal Contractors
46
- Your Company may allow employees and contractors to access and use the Software solely on behalf of Your Company and solely for Your Company's Internal Business Use.
47
-
48
- Contractors do not receive any independent right to use the Software for themselves, their other clients, their own products, or any Third Party.
49
-
50
- Your Company is responsible for all use of the Software by its employees, contractors, agents, and representatives.
51
-
52
- 6. Your Data and Application-Specific Work
53
- As between You and the Licensor, You own Your Company's data, records, content, prompts, business logic, customer records, internal workflows, and application-specific code that You independently create using the Software.
54
-
55
- These terms do not give the Licensor ownership of Your Company's data or independently created application-specific business content.
56
-
57
- However, ownership of Your Company's data or application-specific work does not give You the right to distribute, resell, white-label, host, or commercially exploit the Software or any portion of the Software except as allowed by these terms or a Commercial License.
58
-
59
- 7. Prohibited Uses
60
- You may not do any of the following without a Commercial License:
61
-
62
- Sell, resell, sublicense, rent, lease, lend, transfer, assign, publish, or distribute the Software.
63
- Offer the Software, or any modified version of the Software, to Third Parties as software, infrastructure, a framework, a platform, a service, or a hosted product.
64
- Offer the Software as SaaS, PaaS, IaaS, framework-as-a-service, backend-as-a-service, workflow-as-a-service, automation-as-a-service, AI-agent platform, managed service, support service, or similar commercial service.
65
- White-label, private-label, rebrand, or remove Spine Framework identity from the Software in order to provide it to Third Parties.
66
- Embed, bundle, package, or include the Software in any product, service, appliance, marketplace offering, application pack, starter kit, template library, SDK, CLI, distribution, or managed implementation made available to Third Parties.
67
- Use the Software to operate, power, support, or provide a product or service for Third Parties where those Third Parties receive access to the Software or to functionality substantially provided by the Software.
68
- Use the Software to build, operate, or improve a competing framework, platform, marketplace, application generator, agentic software infrastructure product, or commercial development infrastructure product.
69
- Provide implementation, consulting, hosting, support, customization, training, migration, or managed services for the Software to Third Parties as a paid or commercial offering, except as expressly authorized by the Licensor.
70
- Remove, alter, obscure, or bypass copyright notices, license notices, attribution, trademarks, branding, license keys, license checks, usage limits, or technical protection measures included in the Software.
71
- Use the Software in violation of law, regulation, third-party rights, or these terms.
72
- 8. Examples of Permitted Use
73
- The following are permitted under this license, provided they are solely for Internal Business Use:
74
-
75
- A company installs Spine Framework to build its own internal CRM.
76
- A company uses Spine Framework to operate an internal customer support portal for its own support team and its own customers.
77
- A company modifies Spine Framework for its internal workflows, roles, permissions, automations, pipelines, and integrations.
78
- A company lets its employees and authorized contractors configure, maintain, and support its internal Spine Framework installation.
79
- A company creates internal dashboards, agents, knowledge bases, audit trails, and operational tools on top of Spine Framework.
80
- 9. Examples Requiring a Commercial License
81
- The following are not permitted under this license and require a Commercial License:
82
-
83
- Selling Spine Framework or a modified version of Spine Framework.
84
- Offering Spine Framework as a hosted SaaS product.
85
- Offering a white-labeled application built on Spine Framework to customers.
86
- Packaging Spine Framework into a commercial starter kit, template, marketplace app, agency offering, or developer platform.
87
- Running Spine Framework for multiple clients as a managed service provider, agency, consultant, systems integrator, or outsourcing firm.
88
- Embedding Spine Framework into a product sold to customers.
89
- Forking Spine Framework to launch a competing framework, backend, app platform, AI-agent platform, or development infrastructure product.
90
- Publishing modified Spine Framework source code to a public repository.
91
- Giving a customer, client, partner, reseller, or affiliate a copy of Spine Framework.
92
- 10. Distribution
93
- You may not distribute the Software, modified versions of the Software, or works based on the Software to any Third Party except as expressly permitted by these terms or a Commercial License.
94
-
95
- You may share copies of the Software with employees and contractors of Your Company only as necessary for Internal Business Use and only if they are bound by obligations consistent with these terms.
96
-
97
- Any copy of the Software must include this license and all Required Notices.
98
-
99
- 11. Trademarks
100
- This license does not grant You any right to use the Licensor's names, trademarks, service marks, logos, product names, domain names, or branding except as necessary to identify the Software for Internal Business Use.
101
-
102
- You may not use Spine Framework branding in a way that suggests sponsorship, endorsement, partnership, certification, resale authorization, or commercial authorization without written permission from the Licensor.
103
-
104
- 12. Patent License
105
- The Licensor grants You a patent license for the Software that covers patent claims the Licensor can license, or later becomes able to license, that You would infringe by using the Software as permitted by these terms.
106
-
107
- 13. Patent Defense
108
- If You make any written claim that the Software infringes or contributes to infringement of any patent, Your patent license for the Software ends immediately.
109
-
110
- If Your Company makes such a claim, Your Company's patent license ends immediately.
111
-
112
- 14. Fair Use and Other Legal Rights
113
- You may have fair use, fair dealing, or other rights under applicable law. These terms do not limit rights that the law does not allow these terms to limit.
114
-
115
- 15. No Other Rights
116
- These terms do not allow You to sublicense, transfer, assign, or delegate any license granted under these terms except as expressly allowed here.
117
-
118
- These terms do not prevent the Licensor from granting licenses to anyone else.
119
-
120
- These terms do not imply any license other than the licenses expressly granted here.
121
-
122
- All rights not expressly granted are reserved by the Licensor.
123
-
124
- 16. Violations and Cure
125
- The first time You are notified in writing that You have violated these terms, or done anything with the Software not covered by these terms, Your licenses can continue if You:
126
-
127
- come into full compliance with these terms;
128
- take practical steps to correct past violations; and
129
- complete both within 32 days of receiving written notice.
130
- If You do not satisfy those requirements, all licenses granted to You under these terms end immediately.
131
-
132
- For any later violation, all licenses granted to You under these terms end immediately without any cure period.
133
-
134
- 17. Termination
135
- When Your licenses end, You must stop all use of the Software and destroy all copies of the Software in Your possession or control, except copies you are legally required to retain for archival, compliance, or legal purposes.
136
-
137
- Sections concerning ownership, prohibited uses, trademarks, patent defense, no other rights, violations, termination, no warranty, limitation of liability, and general terms survive termination.
138
-
139
- 18. No Warranty
140
- As far as the law allows, the Software is provided as is and as available, without any warranty or condition, express or implied.
141
-
142
- The Licensor disclaims all warranties and conditions, including warranties of merchantability, fitness for a particular purpose, title, non-infringement, availability, accuracy, security, and quiet enjoyment.
143
-
144
- 19. Limitation of Liability
145
- As far as the law allows, the Licensor will not be liable to You for any indirect, incidental, special, consequential, exemplary, punitive, or other damages arising out of these terms or the Software.
146
-
147
- As far as the law allows, the Licensor will not be liable for lost profits, lost revenue, lost savings, lost data, business interruption, security incidents, procurement of substitute services, or loss of goodwill.
148
-
149
- The Licensor's total liability arising out of these terms or the Software will not exceed the amount You paid the Licensor for the Software during the 12 months before the event giving rise to liability, or USD $100 if You paid nothing.
150
-
151
- 20. Commercial Licensing
152
- Rights not granted under this license may be available under a separate Commercial License from the Licensor.
153
-
154
- A Commercial License is required for resale, redistribution, SaaS, managed services, white-label use, OEM use, marketplace distribution, partner use, commercial hosting, commercial support offerings, or any use outside Internal Business Use.
155
-
156
- To request commercial rights, contact:
157
-
158
- spine-framework.com
159
- webmaster@spine-framework.com
160
- 21. License Updates
161
- The Licensor may publish updated versions of this license from time to time.
162
-
163
- Each release, package, download, or copy of the Software is governed by the license terms included with that release, package, download, or copy, unless You and the Licensor have signed a separate written agreement.
164
-
165
- Updated license terms apply only to future releases, downloads, packages, updates, upgrades, patches, hosted access, or other versions of the Software made available after the updated terms are published.
166
-
167
- The Licensor may require acceptance of updated license terms as a condition of receiving or using future releases, updates, hosted services, commercial features, support, documentation, marketplace access, license keys, portals, application packs, or other Spine Framework services.
168
-
169
- Nothing in this section gives You rights to continue using future versions of the Software under earlier license terms.
170
-
171
- 22. General Terms
172
- If any part of these terms is held unenforceable, the rest remains in effect as far as the law allows.
173
-
174
- Failure by the Licensor to enforce any term is not a waiver.
175
-
176
- Any waiver must be in writing and signed by the Licensor.
177
-
178
- These terms are the entire license terms for the Software unless You and the Licensor have signed a separate written agreement.
179
-
180
- 23. Plain-English Summary
181
- This summary is not part of the legal license.
182
-
183
- You may use Spine Framework inside your own company for your own internal business operations.
184
-
185
- You may modify Spine Framework for your own internal use.
186
-
187
- You may let your employees and contractors work on your internal Spine Framework installation.
188
-
189
- You may not sell it, redistribute it, white-label it, host it for others, offer it as SaaS, package it into another product, use it as a managed service offering, or use it to build a competing framework without a separate commercial license.
190
-
191
- Future versions, updates, services, support, portals, packs, license keys, and commercial features may require acceptance of updated terms.
192
-
193
- Spine Framework is source-available. It is not open source.
package/README.md DELETED
@@ -1,44 +0,0 @@
1
- # Spine Framework Portal
2
-
3
- Portal is a customer-facing self-service app for [Spine Framework](https://spine-framework.com). It gives customers access to tickets, knowledge base, courses, community, and marketplace — all under your brand.
4
-
5
- ## Features
6
-
7
- - **Tickets** — Submit and track support tickets
8
- - **Knowledge Base** — Browse and search help articles
9
- - **Courses** — Access course catalog and track progress
10
- - **Community** — Community discussions and Q&A
11
- - **Marketplace** — App and integration marketplace
12
-
13
- ## Requirements
14
-
15
- - [Spine Framework](https://www.npmjs.com/package/spine-framework) `>=0.1.0`
16
-
17
- ## Installation
18
-
19
- ```bash
20
- npx spine-framework install-app spine-framework-portal
21
- ```
22
-
23
- This will:
24
- 1. Copy app files to `custom/apps/portal/`
25
- 2. Run any pending migrations
26
- 3. Seed roles, types, link-types, and triggers into the database
27
- 4. Register the app in `spine.config.json`
28
-
29
- ## Configuration
30
-
31
- After installation, edit `custom/apps/portal/manifest.json` to configure:
32
-
33
- - `route_prefix` — where the app is served (default: `/portal`)
34
- - `registration` — self-registration behaviour and account strategy (`new` creates a new account per customer, `existing` assigns to a shared account)
35
-
36
- To apply manifest changes to the database without reinstalling:
37
-
38
- ```bash
39
- npx spine-framework update-db-app portal
40
- ```
41
-
42
- ## License
43
-
44
- See [LICENSE.md](./LICENSE.md)