spine-framework-cortex 0.2.21 → 0.2.22

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.
@@ -4,7 +4,7 @@ import { chunkArticle, htmlToPlainText } from './custom_kb-chunker'
4
4
 
5
5
  interface KBEmbeddingRequest {
6
6
  item_id: string
7
- vector_types: ('semantic' | 'structure' | 'code')[]
7
+ vector_types: ('semantic' | 'structure' | 'summary' | 'code')[]
8
8
  force_regenerate?: boolean
9
9
  }
10
10
 
@@ -109,6 +109,15 @@ async function generateEmbeddingBatch(inputs: string[]): Promise<string[]> {
109
109
  return sorted.map((d: any) => `[${d.embedding.join(',')}]`)
110
110
  }
111
111
 
112
+ // Resolve platform account ID at runtime (slug: 'spine-system')
113
+ let _platformAccountId: string | null = null
114
+ async function getPlatformAccountId(): Promise<string | null> {
115
+ if (_platformAccountId) return _platformAccountId
116
+ const { data } = await adminDb.from('accounts').select('id').eq('slug', 'spine-system').maybeSingle()
117
+ _platformAccountId = data?.id ?? null
118
+ return _platformAccountId
119
+ }
120
+
112
121
  // Insert an embedding record (used during batch writes)
113
122
  async function insertEmbedding(
114
123
  itemId: string,
@@ -116,12 +125,14 @@ async function insertEmbedding(
116
125
  chunkIndex: number,
117
126
  content: string,
118
127
  embedding: string,
119
- metadata: any
128
+ metadata: any,
129
+ accountId?: string
120
130
  ): Promise<void> {
131
+ const resolvedAccountId = accountId ?? await getPlatformAccountId()
121
132
  await adminDb
122
133
  .from('embeddings')
123
134
  .insert({
124
- account_id: '12acec9b-8451-40e7-80d5-e80c4e2fc0de', // Master account
135
+ account_id: resolvedAccountId,
125
136
  model_id: 'text-embedding-3-small',
126
137
  document_id: itemId,
127
138
  chunk_index: chunkIndex,
@@ -135,6 +146,33 @@ async function insertEmbedding(
135
146
  })
136
147
  }
137
148
 
149
+ // Generate a one-paragraph AI summary for top-level retrieval matching
150
+ async function generateArticleSummary(item: any): Promise<string> {
151
+ const apiKey = process.env.OPENAI_API_KEY || process.env.LLM_API_KEY
152
+ const baseUrl = process.env.OPENAI_BASE_URL || 'https://api.openai.com/v1'
153
+ if (!apiKey) return `${item.title || ''}: ${(item.description || '').slice(0, 300)}`
154
+
155
+ const plainText = (item.description || '')
156
+ .replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 3000)
157
+
158
+ const res = await fetch(`${baseUrl}/chat/completions`, {
159
+ method: 'POST',
160
+ headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` },
161
+ body: JSON.stringify({
162
+ model: process.env.LLM_DEFAULT_MODEL || 'gpt-4o-mini',
163
+ temperature: 0.2,
164
+ max_tokens: 120,
165
+ messages: [
166
+ { role: 'system', content: 'Write a single concise paragraph (max 100 words) summarising this knowledge base article for search indexing. Output only the paragraph.' },
167
+ { role: 'user', content: `Title: ${item.title}\n\n${plainText}` }
168
+ ]
169
+ })
170
+ })
171
+ if (!res.ok) return `${item.title}: ${plainText.slice(0, 200)}`
172
+ const result: any = await res.json()
173
+ return result.choices?.[0]?.message?.content?.trim() || `${item.title}: ${plainText.slice(0, 200)}`
174
+ }
175
+
138
176
  // Delete all existing embeddings for a document (called before re-embedding)
139
177
  async function deleteExistingEmbeddings(itemId: string): Promise<number> {
140
178
  const { data } = await adminDb
@@ -169,7 +207,8 @@ function isCodeChunk(item: any): boolean {
169
207
  async function handleGenerateEmbeddings(
170
208
  itemId: string,
171
209
  vectorTypes: string[],
172
- forceRegenerate: boolean = false
210
+ forceRegenerate: boolean = false,
211
+ overrideAccountId?: string
173
212
  ): Promise<KBEmbeddingResponse> {
174
213
  const response: KBEmbeddingResponse = {
175
214
  success: true,
@@ -179,18 +218,20 @@ async function handleGenerateEmbeddings(
179
218
  }
180
219
 
181
220
  try {
182
- // Get the KB item
221
+ // Get the KB item — allow any type when called from case resolution path
183
222
  const { data: item, error: itemError } = await adminDb
184
223
  .from('items')
185
224
  .select('*')
186
225
  .eq('id', itemId)
187
- .eq('type_id', 'ce1e50b6-473e-4581-ba0c-e944f47cb240') // kb_article type
188
226
  .single()
189
227
 
190
228
  if (itemError || !item) {
191
- throw new Error('KB item not found')
229
+ throw new Error('Item not found for embedding')
192
230
  }
193
231
 
232
+ // Resolve account: caller override > item's own account > platform account
233
+ const accountId = overrideAccountId || item.account_id || await getPlatformAccountId()
234
+
194
235
  // Check if embeddings already exist
195
236
  if (!forceRegenerate) {
196
237
  const { data: existing } = await adminDb
@@ -213,7 +254,7 @@ async function handleGenerateEmbeddings(
213
254
  response.embeddings_updated = deleted
214
255
  }
215
256
 
216
- // ── Code chunks: legacy path (single semantic + single structure) ──
257
+ // ── Code chunks: legacy path (semantic + structure) ──
217
258
  if (isCodeChunk(item)) {
218
259
  const semanticContent = buildSemanticContent(item)
219
260
  const structureContent = buildStructureContent(item)
@@ -226,33 +267,32 @@ async function handleGenerateEmbeddings(
226
267
  chunk_id: item.data?.code_metadata?.chunk_id,
227
268
  chunk_type: item.data?.code_metadata?.chunk_type,
228
269
  file_path: item.data?.code_metadata?.file_path,
229
- }),
270
+ }, accountId),
230
271
  insertEmbedding(itemId, 'structure', 0, structureContent, vectors[1], {
231
272
  kb_type: item.data?.kb_type,
232
273
  tags: item.data?.tags,
233
274
  file_path: item.data?.code_metadata?.file_path,
234
275
  depends_on: item.data?.code_metadata?.depends_on,
235
- }),
276
+ }, accountId),
236
277
  ])
237
278
 
238
279
  response.embeddings_created = 2
239
280
  return response
240
281
  }
241
282
 
242
- // ── Article path: chunk batch embed parallel write ──
283
+ // ── Article path: semantic chunks + structure + summary ──
243
284
  const articleContent = item.description || ''
244
- const chunks = chunkArticle(articleContent, {
245
- articleTitle: item.title || 'Untitled',
246
- })
285
+ const chunks = chunkArticle(articleContent, { articleTitle: item.title || 'Untitled' })
247
286
 
248
- // Build all texts to embed: N semantic chunks + 1 structure
249
287
  const structureContent = buildArticleStructureContent(item)
250
- const allTexts = [...chunks.map(c => c.content), structureContent]
251
288
 
252
- // Single batched API call to OpenAI
289
+ // Generate AI summary for top-level retrieval matching
290
+ const summaryContent = await generateArticleSummary(item)
291
+
292
+ // Single batched embed: N semantic chunks + 1 structure + 1 summary
293
+ const allTexts = [...chunks.map(c => c.content), structureContent, summaryContent]
253
294
  const allVectors = await generateEmbeddingBatch(allTexts)
254
295
 
255
- // Parallel DB writes
256
296
  const writePromises: Promise<void>[] = []
257
297
 
258
298
  // Semantic embeddings — one per chunk
@@ -263,22 +303,32 @@ async function handleGenerateEmbeddings(
263
303
  chunk_index: i,
264
304
  chunk_total: chunks.length,
265
305
  section_path: chunks[i].sectionPath,
266
- })
306
+ security_level: item.data?.security_level,
307
+ }, accountId)
267
308
  )
268
309
  }
269
310
 
270
- // Structure embedding — article-level, single vector
311
+ // Structure embedding — facets for filtering
271
312
  writePromises.push(
272
313
  insertEmbedding(itemId, 'structure', 0, structureContent, allVectors[chunks.length], {
273
314
  kb_type: item.data?.kb_type,
274
315
  tags: item.data?.tags,
275
316
  category: item.data?.category,
276
317
  audience: item.data?.audience,
277
- })
318
+ security_level: item.data?.security_level,
319
+ }, accountId)
320
+ )
321
+
322
+ // Summary embedding — one-paragraph AI summary for top-level matching
323
+ writePromises.push(
324
+ insertEmbedding(itemId, 'summary', 0, summaryContent, allVectors[chunks.length + 1], {
325
+ kb_type: item.data?.kb_type,
326
+ security_level: item.data?.security_level,
327
+ }, accountId)
278
328
  )
279
329
 
280
330
  await Promise.all(writePromises)
281
- response.embeddings_created = chunks.length + 1
331
+ response.embeddings_created = chunks.length + 2 // +2 for structure + summary
282
332
 
283
333
  } catch (error) {
284
334
  response.success = false
@@ -288,6 +338,63 @@ async function handleGenerateEmbeddings(
288
338
  return response
289
339
  }
290
340
 
341
+ /**
342
+ * Embeds a resolved ticket's case data as a knowledge source for future triage RAG.
343
+ * Called automatically when a ticket is resolved (via case analysis completion).
344
+ *
345
+ * Stores two vectors:
346
+ * - 'semantic': ca_true_problem + ca_final_solution (the actual answer)
347
+ * - 'summary': ca_reported_issue (how the customer described it — improves matching)
348
+ *
349
+ * These are stored with item_type: 'resolved_case' so they can be included in
350
+ * triage agent knowledge_sources without polluting the main KB article pool.
351
+ */
352
+ export async function embedResolvedCase(ticketId: string, accountId: string): Promise<void> {
353
+ // Load ticket data fields
354
+ const { data: ticket } = await adminDb
355
+ .from('items')
356
+ .select('id, title, data')
357
+ .eq('id', ticketId)
358
+ .single()
359
+
360
+ if (!ticket) return
361
+
362
+ const trueProb = ticket.data?.ca_true_problem || ''
363
+ const finalSol = ticket.data?.ca_final_solution || ''
364
+ const reportedIssue = ticket.data?.ca_reported_issue || ticket.title || ''
365
+
366
+ if (!trueProb && !finalSol) return // nothing to embed
367
+
368
+ // Delete any pre-existing resolved_case embeddings for this ticket
369
+ await adminDb
370
+ .from('embeddings')
371
+ .delete()
372
+ .eq('document_id', ticketId)
373
+ .eq('metadata->>item_type', 'resolved_case')
374
+
375
+ const semanticContent = `Problem: ${trueProb}\n\nSolution: ${finalSol}`
376
+ const summaryContent = `Customer reported: ${reportedIssue}`
377
+
378
+ try {
379
+ const vectors = await generateEmbeddingBatch([semanticContent, summaryContent])
380
+ await Promise.all([
381
+ insertEmbedding(ticketId, 'semantic', 0, semanticContent, vectors[0], {
382
+ item_type: 'resolved_case',
383
+ security_level: 'internal',
384
+ kb_candidate: ticket.data?.ca_kb_candidate ?? false,
385
+ automation_potential: ticket.data?.ca_automation_potential ?? 'unknown',
386
+ }, accountId),
387
+ insertEmbedding(ticketId, 'summary', 0, summaryContent, vectors[1], {
388
+ item_type: 'resolved_case',
389
+ security_level: 'internal',
390
+ }, accountId),
391
+ ])
392
+ } catch (err) {
393
+ // Best-effort: don't fail ticket resolution if embedding fails
394
+ console.error(`embedResolvedCase failed for ticket ${ticketId}:`, err)
395
+ }
396
+ }
397
+
291
398
  // Generate embedding vector for a query string via OpenAI
292
399
  async function generateQueryEmbedding(text: string): Promise<number[]> {
293
400
  const apiKey = process.env.OPENAI_API_KEY || process.env.LLM_API_KEY
@@ -0,0 +1,115 @@
1
+ import { createHandler } from './_shared/middleware'
2
+ import { adminDb } from './_shared/db'
3
+ import { resolveTypeId, resolveAgentId, resolvePromptConfigId } from './_shared/resolve-ids'
4
+ import { runAgent } from './_shared/index'
5
+ import { create } from './admin-data'
6
+
7
+ /**
8
+ * @module custom_support-redaction
9
+ * @layer custom/cortex
10
+ * @stability custom
11
+ *
12
+ * KB Redaction Analysis — drives the RedactionReview page.
13
+ * Runs content through the KB Redaction Agent and returns structured
14
+ * redaction suggestions + a generalised rewrite ready for human review.
15
+ *
16
+ * Action: POST ?action=analyze
17
+ * body: { ticket_id: string, content: string }
18
+ *
19
+ * Response: { analysis: RedactionAnalysis }
20
+ * RedactionAnalysis matches the shape expected by RedactionReview.tsx:
21
+ * {
22
+ * original_content: string
23
+ * redacted_content: string
24
+ * suggestions: RedactionSuggestion[]
25
+ * confidence_score: number
26
+ * processing_metadata: { model_used, temperature, tokens_consumed }
27
+ * }
28
+ *
29
+ * The agent message is saved in an internal analysis thread on the ticket
30
+ * (thread_purpose: 'redaction_analysis') as an audit trail.
31
+ */
32
+
33
+ async function handleAnalyze(ctx: any, body: any) {
34
+ const { ticket_id, content } = body
35
+ if (!ticket_id) throw new Error('ticket_id is required')
36
+ if (!content) throw new Error('content is required')
37
+
38
+ const [threadTypeId, agentId, promptConfigId] = await Promise.all([
39
+ resolveTypeId('thread', 'thread'),
40
+ resolveAgentId('KB Redaction Agent'),
41
+ resolvePromptConfigId('kb_generator'),
42
+ ])
43
+
44
+ // Reuse existing redaction thread if one exists (idempotent re-analysis)
45
+ const { data: existing } = await adminDb
46
+ .from('threads')
47
+ .select('id')
48
+ .eq('target_type', 'items')
49
+ .eq('target_id', ticket_id)
50
+ .eq('visibility', 'internal')
51
+ .eq('data->>thread_purpose', 'redaction_analysis')
52
+ .maybeSingle()
53
+
54
+ let threadId = existing?.id
55
+
56
+ if (!threadId) {
57
+ const thread = await create(ctx, {
58
+ entity: 'threads',
59
+ type_id: threadTypeId,
60
+ target_type: 'items',
61
+ target_id: ticket_id,
62
+ visibility: 'internal',
63
+ status: 'active',
64
+ data: {
65
+ thread_purpose: 'redaction_analysis',
66
+ agent_id: agentId,
67
+ prompt_config_id: promptConfigId,
68
+ }
69
+ })
70
+ if (!thread?.id) throw new Error('Failed to create redaction thread')
71
+ threadId = thread.id
72
+ }
73
+
74
+ // Run the KB Redaction Agent — returns structured JSON in message content
75
+ const agentMsg = await runAgent(threadId, content, ctx)
76
+
77
+ // Parse JSON from agent response
78
+ let analysis: any = null
79
+ try {
80
+ const raw = agentMsg?.content || '{}'
81
+ const cleaned = raw.replace(/^```(?:json)?\s*/i, '').replace(/\s*```$/, '').trim()
82
+ analysis = JSON.parse(cleaned)
83
+ } catch {
84
+ // If the agent didn't return valid JSON, return a minimal analysis
85
+ // so the UI can still proceed with the original content unredacted
86
+ analysis = {
87
+ original_content: content,
88
+ redacted_content: content,
89
+ suggestions: [],
90
+ confidence_score: 0.0,
91
+ processing_metadata: {
92
+ model_used: 'unknown',
93
+ temperature: 0.1,
94
+ tokens_consumed: 0,
95
+ error: 'Agent response was not valid JSON',
96
+ }
97
+ }
98
+ }
99
+
100
+ // Ensure required fields are present for RedactionReview.tsx
101
+ if (!analysis.original_content) analysis.original_content = content
102
+ if (!analysis.suggestions) analysis.suggestions = []
103
+ if (typeof analysis.confidence_score !== 'number') analysis.confidence_score = 0.5
104
+ if (!analysis.processing_metadata) analysis.processing_metadata = { model_used: 'unknown', temperature: 0.1, tokens_consumed: 0 }
105
+
106
+ return { analysis, analysisThreadId: threadId }
107
+ }
108
+
109
+ export const handler = createHandler(async (ctx, body) => {
110
+ const action = (ctx as any).query?.action
111
+ switch (action) {
112
+ case 'analyze': return handleAnalyze(ctx, body)
113
+ default: throw new Error(`Unknown action: ${action}. Use 'analyze'.`)
114
+ }
115
+ })
@@ -0,0 +1,104 @@
1
+ import { createHandler } from './_shared/middleware'
2
+ import { adminDb } from './_shared/db'
3
+ import { resolveTypeId, resolveAgentId, resolvePromptConfigId } from './_shared/resolve-ids'
4
+ import { runAgent } from './_shared/index'
5
+ import { create } from './admin-data'
6
+
7
+ /**
8
+ * @module custom_support-solution
9
+ * @layer custom/cortex
10
+ * @stability custom
11
+ *
12
+ * Solution AI — internal AI collaborator for support agents working a case.
13
+ * Powered by core's runAgent() with the 'solution_ai' prompt config.
14
+ * Never visible to the customer (all messages are visibility='internal').
15
+ *
16
+ * Two actions:
17
+ * POST ?action=start — ensures an internal AI thread exists for the ticket,
18
+ * sends the first message, returns thread + agent message.
19
+ * POST ?action=message — sends a follow-up message on the existing AI thread.
20
+ *
21
+ * Response: { threadId, agentMessageId, content }
22
+ *
23
+ * The AI thread is distinct from the regular internal thread: it is tagged with
24
+ * data.thread_purpose = 'solution_ai' so the UI can find it deterministically.
25
+ */
26
+
27
+ // ─── ACTION: START ─────────────────────────────────────────────────────────────
28
+
29
+ async function handleStart(ctx: any, body: any) {
30
+ const { ticket_id, message } = body
31
+ if (!ticket_id) throw new Error('ticket_id is required')
32
+ if (!message) throw new Error('message is required')
33
+
34
+ const [threadTypeId, solutionAgentId, promptConfigId] = await Promise.all([
35
+ resolveTypeId('thread', 'thread'),
36
+ resolveAgentId('Solution AI Agent'),
37
+ resolvePromptConfigId('solution_ai'),
38
+ ])
39
+
40
+ // Find existing solution AI thread for this ticket
41
+ const { data: existing } = await adminDb
42
+ .from('threads')
43
+ .select('id')
44
+ .eq('target_type', 'items')
45
+ .eq('target_id', ticket_id)
46
+ .eq('visibility', 'internal')
47
+ .eq('data->>thread_purpose', 'solution_ai')
48
+ .maybeSingle()
49
+
50
+ let threadId = existing?.id
51
+
52
+ if (!threadId) {
53
+ const thread = await create(ctx, {
54
+ entity: 'threads',
55
+ type_id: threadTypeId,
56
+ target_type: 'items',
57
+ target_id: ticket_id,
58
+ visibility: 'internal',
59
+ status: 'active',
60
+ data: {
61
+ thread_purpose: 'solution_ai',
62
+ agent_id: solutionAgentId,
63
+ prompt_config_id: promptConfigId,
64
+ }
65
+ })
66
+ if (!thread?.id) throw new Error('Failed to create solution AI thread')
67
+ threadId = thread.id
68
+ }
69
+
70
+ const agentMsg = await runAgent(threadId, message, ctx)
71
+
72
+ return {
73
+ threadId,
74
+ agentMessageId: agentMsg?.id,
75
+ content: agentMsg?.content || '',
76
+ }
77
+ }
78
+
79
+ // ─── ACTION: MESSAGE ───────────────────────────────────────────────────────────
80
+
81
+ async function handleMessage(ctx: any, body: any) {
82
+ const { thread_id, message } = body
83
+ if (!thread_id) throw new Error('thread_id is required')
84
+ if (!message) throw new Error('message is required')
85
+
86
+ const agentMsg = await runAgent(thread_id, message, ctx)
87
+
88
+ return {
89
+ threadId: thread_id,
90
+ agentMessageId: agentMsg?.id,
91
+ content: agentMsg?.content || '',
92
+ }
93
+ }
94
+
95
+ // ─── HANDLER ──────────────────────────────────────────────────────────────────
96
+
97
+ export const handler = createHandler(async (ctx, body) => {
98
+ const action = (ctx as any).query?.action
99
+ switch (action) {
100
+ case 'start': return handleStart(ctx, body)
101
+ case 'message': return handleMessage(ctx, body)
102
+ default: throw new Error(`Unknown action: ${action}. Use 'start' or 'message'.`)
103
+ }
104
+ })
package/manifest.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "Cortex",
3
3
  "slug": "cortex",
4
4
  "description": "Unified workspace for CRM, Support, Community, and Knowledge Base",
5
- "version": "0.2.12",
5
+ "version": "0.2.22",
6
6
  "app_type": "full",
7
7
  "route_prefix": "/cortex",
8
8
  "required_roles": ["support", "support_admin"],
@@ -19,6 +19,7 @@
19
19
  "/crm/activity",
20
20
  "/support",
21
21
  "/support/:id",
22
+ "/support/:id/kb-review",
22
23
  "/community",
23
24
  "/kb",
24
25
  "/kb/editor",
@@ -90,10 +91,13 @@
90
91
  "entry_point": "./index.tsx",
91
92
  "sidebar_component": "./components/CortexSidebar.tsx",
92
93
  "seed": [
93
- { "file": "seed/roles.json", "table": "roles", "conflict": "app_id,slug" },
94
- { "file": "seed/types.json", "table": "types", "conflict": "app_id,kind,slug" },
95
- { "file": "seed/link-types.json", "table": "link_types", "conflict": "app_id,slug" },
96
- { "file": "seed/type_permissions.json", "table": "types", "conflict": "kind,slug", "inject_app_id": false, "permissions_patch": true }
94
+ { "file": "seed/roles.json", "table": "roles", "conflict": "app_id,slug" },
95
+ { "file": "seed/types.json", "table": "types", "conflict": "app_id,kind,slug" },
96
+ { "file": "seed/link-types.json", "table": "link_types", "conflict": "app_id,slug" },
97
+ { "file": "seed/ai-agents.json", "table": "ai_agents", "conflict": "app_id,name", "inject_app_id": true },
98
+ { "file": "seed/prompt-configs.json", "table": "prompt_configs","conflict": "app_id,slug", "inject_app_id": true },
99
+ { "file": "seed/pipelines.json", "table": "pipelines", "conflict": "app_id,name", "inject_app_id": true },
100
+ { "file": "seed/type_permissions.json", "table": "types", "conflict": "kind,slug", "inject_app_id": false, "permissions_patch": true }
97
101
  ],
98
102
  "registration": {
99
103
  "enabled": true,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spine-framework-cortex",
3
- "version": "0.2.21",
3
+ "version": "0.2.22",
4
4
  "private": false,
5
5
  "description": "Cortex — AI-powered support, CRM, and knowledge base app for Spine Framework",
6
6
  "keywords": [
@@ -39,13 +39,19 @@ interface Ticket {
39
39
  title: string
40
40
  description?: string
41
41
  data?: {
42
+ // Case analysis fields (populated by custom_case_analysis)
43
+ ca_reported_issue?: string
44
+ ca_true_problem?: string
45
+ ca_final_solution?: string
46
+ ca_diagnostic_steps?: string[]
47
+ ca_solution_steps?: string[]
48
+ // Legacy fields
42
49
  ai_metadata?: {
43
50
  problem_statement?: string
44
51
  solution_path?: string
45
52
  }
46
- postmortem?: {
47
- kb_draft_id?: string
48
- }
53
+ // KB tracking
54
+ kb_proposed_kb_id?: string
49
55
  }
50
56
  }
51
57
 
@@ -118,12 +124,11 @@ export default function RedactionReview() {
118
124
  setArticleTitle(`KB: ${ticketData.title}`)
119
125
  }
120
126
 
121
- // Trigger redaction analysis via AI agent
122
- const analysisRes = await apiFetch('/api/ai-agents', {
127
+ // Trigger redaction analysis via KB Redaction Agent
128
+ const analysisRes = await apiFetch('/.netlify/functions/custom_support-redaction?action=analyze', {
123
129
  method: 'POST',
124
130
  headers: { 'Content-Type': 'application/json' },
125
131
  body: JSON.stringify({
126
- action: 'run_redaction_analysis',
127
132
  ticket_id: id,
128
133
  content: buildContentForAnalysis(ticketData),
129
134
  }),
@@ -155,16 +160,33 @@ export default function RedactionReview() {
155
160
  })
156
161
  }, [id])
157
162
 
158
- // Build content from ticket for analysis
163
+ // Build content from ticket for redaction analysis.
164
+ // Prefers case analysis fields (ca_*) populated by custom_case_analysis,
165
+ // falling back to legacy ai_metadata fields.
159
166
  function buildContentForAnalysis(ticket: Ticket): string {
167
+ const d = ticket.data || {}
160
168
  const parts: string[] = []
161
169
  if (ticket.title) parts.push(`Title: ${ticket.title}`)
162
- if (ticket.description) parts.push(`Description: ${ticket.description}`)
163
- if (ticket.data?.ai_metadata?.problem_statement) {
164
- parts.push(`Problem: ${ticket.data.ai_metadata.problem_statement}`)
170
+ if (d.ca_reported_issue) {
171
+ parts.push(`Reported Issue: ${d.ca_reported_issue}`)
172
+ } else if (ticket.description) {
173
+ parts.push(`Description: ${ticket.description}`)
174
+ }
175
+ if (d.ca_true_problem) {
176
+ parts.push(`Root Cause: ${d.ca_true_problem}`)
177
+ } else if (d.ai_metadata?.problem_statement) {
178
+ parts.push(`Problem: ${d.ai_metadata.problem_statement}`)
179
+ }
180
+ if (d.ca_diagnostic_steps?.length) {
181
+ parts.push(`Diagnostic Steps:\n${d.ca_diagnostic_steps.map((s, i) => `${i + 1}. ${s}`).join('\n')}`)
165
182
  }
166
- if (ticket.data?.ai_metadata?.solution_path) {
167
- parts.push(`Solution: ${ticket.data.ai_metadata.solution_path}`)
183
+ if (d.ca_solution_steps?.length) {
184
+ parts.push(`Solution Steps:\n${d.ca_solution_steps.map((s, i) => `${i + 1}. ${s}`).join('\n')}`)
185
+ }
186
+ if (d.ca_final_solution) {
187
+ parts.push(`Final Solution: ${d.ca_final_solution}`)
188
+ } else if (d.ai_metadata?.solution_path) {
189
+ parts.push(`Solution: ${d.ai_metadata.solution_path}`)
168
190
  }
169
191
  return parts.join('\n\n')
170
192
  }
@@ -279,16 +301,12 @@ export default function RedactionReview() {
279
301
 
280
302
  // Update ticket with KB reference
281
303
  await apiFetch(`/api/admin-data?action=update&entity=items&id=${id}`, {
282
- method: 'POST',
304
+ method: 'PATCH',
283
305
  headers: { 'Content-Type': 'application/json' },
284
306
  body: JSON.stringify({
285
307
  data: {
286
308
  ...ticket?.data,
287
- postmortem: {
288
- ...ticket?.data?.postmortem,
289
- kb_generated: true,
290
- kb_draft_id: kbId,
291
- },
309
+ kb_proposed_kb_id: kbId,
292
310
  },
293
311
  }),
294
312
  })