spine-framework 0.3.107 → 0.3.109
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/.framework/functions/_shared/agent-runner.ts +146 -88
- package/.framework/functions/_shared/permissions.ts +4 -16
- package/.framework/functions/_shared/pipeline-runner.ts +31 -1
- package/.framework/functions/admin-data.ts +52 -29
- package/.framework/functions/ai-interactions.ts +240 -0
- package/.framework/functions/embeddings.ts +426 -10
- package/.framework/functions/eval-harness.ts +353 -0
- package/.framework/functions/item-progress.ts +12 -5
- package/.framework/functions/observability.ts +146 -1
- package/.framework/migrations/002_rls_and_functions.sql +30 -11
- package/.framework/migrations/026_fix_rls_policies.sql +47 -0
- package/.framework/migrations/027_embedding_lifecycle.sql +176 -0
- package/config/tsconfig.node.tsbuildinfo +1 -1
- package/package.json +3 -1
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module ai-interactions
|
|
3
|
+
* @audience both
|
|
4
|
+
* @layer api-handler
|
|
5
|
+
* @stability stable
|
|
6
|
+
*
|
|
7
|
+
* Feedback capture and training data API for the AI-first support loop.
|
|
8
|
+
* Every AI↔human exchange produces a structured interaction record so the
|
|
9
|
+
* platform can measure loop health, drive KB creation, and accumulate a
|
|
10
|
+
* proprietary training dataset.
|
|
11
|
+
*
|
|
12
|
+
* **Routed by:** `GET/POST/PATCH /.netlify/functions/ai-interactions`
|
|
13
|
+
*
|
|
14
|
+
* **Actions:**
|
|
15
|
+
* | method | ?action | handler |
|
|
16
|
+
* |--------|--------------------------|--------------------------|
|
|
17
|
+
* | POST | submit_feedback | submitFeedback |
|
|
18
|
+
* | POST | create_interaction | createInteraction |
|
|
19
|
+
* | PATCH | update_interaction | updateInteraction |
|
|
20
|
+
* | GET | list | list |
|
|
21
|
+
* | GET | metrics | metrics |
|
|
22
|
+
*
|
|
23
|
+
* INVARIANT: All writes go through admin-data — no direct DB writes here.
|
|
24
|
+
* INVARIANT: `submit_feedback` not_helpful triggers ticket escalation
|
|
25
|
+
* by updating ticket status to human_assigned.
|
|
26
|
+
*
|
|
27
|
+
* @seeAlso agent-runner.ts (populates messages.data.retrieved_sources)
|
|
28
|
+
* @seeAlso custom/portal/hooks/useTickets.ts (calls submit_feedback)
|
|
29
|
+
* @seeAlso custom/cortex/functions/custom_case_analysis.ts (calls create_interaction)
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import { createHandler } from './_shared/middleware'
|
|
33
|
+
import { adminDb } from './_shared/db'
|
|
34
|
+
import { resolveTypeId } from './_shared/resolve-ids'
|
|
35
|
+
import { create, update } from './admin-data'
|
|
36
|
+
import { emitLog } from './_shared/audit'
|
|
37
|
+
|
|
38
|
+
// ─── SUBMIT FEEDBACK ─────────────────────────────────────────────────────────
|
|
39
|
+
/**
|
|
40
|
+
* Records customer helpful / not_helpful feedback on an AI message.
|
|
41
|
+
*
|
|
42
|
+
* - Stamps messages.data.feedback
|
|
43
|
+
* - If not_helpful: updates ticket to human_assigned + stamps escalation reason
|
|
44
|
+
* - Updates the matching ai_feedback_record if one exists for this message
|
|
45
|
+
*
|
|
46
|
+
* Body: message_id (required), ticket_id (required), feedback ('helpful' | 'not_helpful')
|
|
47
|
+
*/
|
|
48
|
+
export const submitFeedback = createHandler(async (ctx, body) => {
|
|
49
|
+
const { message_id, ticket_id, feedback } = body || {}
|
|
50
|
+
if (!message_id) throw new Error('message_id is required')
|
|
51
|
+
if (!ticket_id) throw new Error('ticket_id is required')
|
|
52
|
+
if (!['helpful', 'not_helpful'].includes(feedback)) {
|
|
53
|
+
throw new Error('feedback must be "helpful" or "not_helpful"')
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Stamp feedback on the message — load existing data to merge safely
|
|
57
|
+
const { data: msg } = await adminDb
|
|
58
|
+
.from('messages').select('data').eq('id', message_id).single()
|
|
59
|
+
if (msg) {
|
|
60
|
+
await adminDb
|
|
61
|
+
.from('messages')
|
|
62
|
+
.update({ data: { ...(msg.data || {}), feedback } })
|
|
63
|
+
.eq('id', message_id)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// If not_helpful: escalate the ticket to human support
|
|
67
|
+
if (feedback === 'not_helpful' && ticket_id) {
|
|
68
|
+
const { data: ticket } = await adminDb
|
|
69
|
+
.from('items').select('status, data').eq('id', ticket_id).single()
|
|
70
|
+
if (ticket && ticket.status !== 'human_assigned' && ticket.status !== 'resolved') {
|
|
71
|
+
await adminDb
|
|
72
|
+
.from('items')
|
|
73
|
+
.update({
|
|
74
|
+
status: 'human_assigned',
|
|
75
|
+
data: { ...(ticket.data || {}), aim_escalation_reason: 'thumbs_down' },
|
|
76
|
+
updated_at: new Date().toISOString(),
|
|
77
|
+
})
|
|
78
|
+
.eq('id', ticket_id)
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Best-effort: update matching ai_feedback_record customer_feedback field
|
|
83
|
+
const { data: interactionRows } = await adminDb
|
|
84
|
+
.from('items')
|
|
85
|
+
.select('id, data')
|
|
86
|
+
.contains('data', { ai_message_id: message_id })
|
|
87
|
+
.limit(1)
|
|
88
|
+
if (interactionRows?.length) {
|
|
89
|
+
const interaction = interactionRows[0]
|
|
90
|
+
await adminDb
|
|
91
|
+
.from('items')
|
|
92
|
+
.update({ data: { ...(interaction.data || {}), customer_feedback: feedback } })
|
|
93
|
+
.eq('id', interaction.id)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
await emitLog(ctx, 'ai_interaction.feedback_submitted',
|
|
97
|
+
{ type: 'message', id: message_id },
|
|
98
|
+
{ after: { feedback, ticket_id, escalated: feedback === 'not_helpful' } }
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
return { message_id, ticket_id, feedback, escalated: feedback === 'not_helpful' }
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
// ─── CREATE INTERACTION ───────────────────────────────────────────────────────
|
|
105
|
+
/**
|
|
106
|
+
* Creates an ai_feedback_record item capturing a full AI↔human exchange.
|
|
107
|
+
* Called by the capture_ai_interaction pipeline after each agent message.
|
|
108
|
+
*
|
|
109
|
+
* Body: thread_id, ai_message_id, retrieved_sources, ai_confidence,
|
|
110
|
+
* retrieval_confidence, kb_candidate (optional)
|
|
111
|
+
*/
|
|
112
|
+
export const createInteraction = createHandler(async (ctx, body) => {
|
|
113
|
+
const {
|
|
114
|
+
thread_id, ai_message_id, retrieved_sources = [],
|
|
115
|
+
ai_confidence, retrieval_confidence, kb_candidate
|
|
116
|
+
} = body || {}
|
|
117
|
+
|
|
118
|
+
if (!thread_id && !ai_message_id) {
|
|
119
|
+
throw new Error('thread_id or ai_message_id is required')
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Resolve the ai_feedback_record type — it lives in the cortex/portal app seeds
|
|
123
|
+
let typeId: string | null = null
|
|
124
|
+
try {
|
|
125
|
+
typeId = await resolveTypeId('item', 'ai_feedback_record')
|
|
126
|
+
} catch {
|
|
127
|
+
// Type not yet seeded — skip creation gracefully
|
|
128
|
+
return { skipped: true, reason: 'ai_feedback_record type not found' }
|
|
129
|
+
}
|
|
130
|
+
if (!typeId) return { skipped: true, reason: 'ai_feedback_record type not found' }
|
|
131
|
+
|
|
132
|
+
const record = await create(ctx, {
|
|
133
|
+
entity: 'items',
|
|
134
|
+
type_id: typeId,
|
|
135
|
+
title: `AI Interaction — ${new Date().toISOString().slice(0, 10)}`,
|
|
136
|
+
status: 'active',
|
|
137
|
+
data: {
|
|
138
|
+
thread_id: thread_id || null,
|
|
139
|
+
ai_message_id: ai_message_id || null,
|
|
140
|
+
human_message_id: null,
|
|
141
|
+
retrieved_sources: retrieved_sources,
|
|
142
|
+
ai_confidence: ai_confidence ?? null,
|
|
143
|
+
retrieval_confidence: retrieval_confidence ?? null,
|
|
144
|
+
human_edits_made: false,
|
|
145
|
+
customer_feedback: null,
|
|
146
|
+
kb_candidate: kb_candidate ?? null,
|
|
147
|
+
kb_article_id: null,
|
|
148
|
+
reuse_count: 0,
|
|
149
|
+
},
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
return record
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
// ─── UPDATE INTERACTION ───────────────────────────────────────────────────────
|
|
156
|
+
/**
|
|
157
|
+
* Patches an existing ai_feedback_record.
|
|
158
|
+
* Used to:
|
|
159
|
+
* - Link the KB article once a feedback record's question is promoted
|
|
160
|
+
* - Increment reuse_count when a similar question is AI-answered
|
|
161
|
+
* - Record human_message_id and human_edits_made after resolution
|
|
162
|
+
*
|
|
163
|
+
* Body: id (required), plus any of: kb_article_id, reuse_count,
|
|
164
|
+
* human_message_id, human_edits_made, customer_feedback
|
|
165
|
+
*/
|
|
166
|
+
export const updateInteraction = createHandler(async (ctx, body) => {
|
|
167
|
+
const { id, ...fields } = body || {}
|
|
168
|
+
if (!id) throw new Error('id is required')
|
|
169
|
+
|
|
170
|
+
const allowed = ['kb_article_id', 'human_message_id', 'human_edits_made',
|
|
171
|
+
'customer_feedback', 'kb_candidate', 'reuse_count']
|
|
172
|
+
const patch: Record<string, any> = {}
|
|
173
|
+
for (const key of allowed) {
|
|
174
|
+
if (fields[key] !== undefined) patch[key] = fields[key]
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (!Object.keys(patch).length) throw new Error('No updatable fields provided')
|
|
178
|
+
|
|
179
|
+
// Load existing data then merge
|
|
180
|
+
const { data: existing } = await adminDb
|
|
181
|
+
.from('items').select('data').eq('id', id).single()
|
|
182
|
+
if (!existing) throw new Error('Interaction record not found')
|
|
183
|
+
|
|
184
|
+
return update(
|
|
185
|
+
{ ...ctx, query: { ...(ctx.query || {}), entity: 'items', id } } as any,
|
|
186
|
+
{ data: { ...(existing.data || {}), ...patch } }
|
|
187
|
+
)
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
// ─── LIST ─────────────────────────────────────────────────────────────────────
|
|
191
|
+
/**
|
|
192
|
+
* Lists ai_feedback_record items for the current account.
|
|
193
|
+
* Supports filtering by: feedback, kb_candidate, date_from, date_to
|
|
194
|
+
*/
|
|
195
|
+
export const list = createHandler(async (ctx, _body) => {
|
|
196
|
+
const { feedback, kb_candidate, limit = '50', offset = '0' } = ctx.query || {}
|
|
197
|
+
|
|
198
|
+
let q = ctx.db
|
|
199
|
+
.from('items')
|
|
200
|
+
.select('*')
|
|
201
|
+
.eq('account_id', ctx.accountId)
|
|
202
|
+
.order('created_at', { ascending: false })
|
|
203
|
+
.range(Number(offset), Number(offset) + Number(limit) - 1)
|
|
204
|
+
|
|
205
|
+
if (feedback) q = q.eq('data->>customer_feedback', feedback)
|
|
206
|
+
if (kb_candidate !== undefined) q = q.eq('data->>kb_candidate', kb_candidate)
|
|
207
|
+
|
|
208
|
+
// Filter to ai_feedback_record type only
|
|
209
|
+
const { data: typeRow } = await adminDb
|
|
210
|
+
.from('types').select('id').eq('slug', 'ai_feedback_record').eq('kind', 'item').maybeSingle()
|
|
211
|
+
if (typeRow?.id) q = q.eq('type_id', typeRow.id)
|
|
212
|
+
|
|
213
|
+
const { data, error } = await q
|
|
214
|
+
if (error) throw error
|
|
215
|
+
return data || []
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
// ─── HANDLER ─────────────────────────────────────────────────────────────────
|
|
219
|
+
|
|
220
|
+
export const handler = createHandler(async (ctx, body) => {
|
|
221
|
+
const { action } = ctx.query || {}
|
|
222
|
+
const method = ctx.query?.method || 'GET'
|
|
223
|
+
|
|
224
|
+
switch (action) {
|
|
225
|
+
case 'submit_feedback':
|
|
226
|
+
if (method === 'POST') return await submitFeedback(ctx, body)
|
|
227
|
+
break
|
|
228
|
+
case 'create_interaction':
|
|
229
|
+
if (method === 'POST') return await createInteraction(ctx, body)
|
|
230
|
+
break
|
|
231
|
+
case 'update_interaction':
|
|
232
|
+
if (method === 'PATCH' || method === 'POST') return await updateInteraction(ctx, body)
|
|
233
|
+
break
|
|
234
|
+
case 'list':
|
|
235
|
+
default:
|
|
236
|
+
if (method === 'GET') return await list(ctx, body)
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
throw new Error('Invalid action or method')
|
|
240
|
+
})
|