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.
@@ -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
+ })