From 34e00c51a0754b2422ca22e89f05486700520a77 Mon Sep 17 00:00:00 2001 From: Benjamin Simon Date: Wed, 18 Mar 2026 16:28:36 -0400 Subject: [PATCH 1/5] demo cases and ai enrich Adds a case workflow and draft approval path so live anomalies can turn into demo-ready investigations, and teaches the graph/P&ID flow about FEEDS dependencies for better causal context. Made-with: Cursor --- electron-ui/graph-renderer.js | 36 +++ electron-ui/index.html | 87 ++++-- electron-ui/main.js | 153 ++++++++++ electron-ui/preload.js | 11 + electron-ui/renderer.js | 527 ++++++++++++++++++++++++++++++++- electron-ui/styles.css | 473 +++++++++++++++++++++++++++--- scripts/case_api.py | 145 +++++++++ scripts/gpt54_client.py | 2 + scripts/neo4j_ontology.py | 532 ++++++++++++++++++++++++++++++++++ scripts/process_semantics.py | 1 + 10 files changed, 1893 insertions(+), 74 deletions(-) create mode 100644 scripts/case_api.py diff --git a/electron-ui/graph-renderer.js b/electron-ui/graph-renderer.js index 6441750..6b6b04f 100644 --- a/electron-ui/graph-renderer.js +++ b/electron-ui/graph-renderer.js @@ -40,6 +40,9 @@ class GraphRenderer { }; this.cy = null; + this.resizeObserver = null; + this.boundWindowResize = this._handleResize.bind(this); + this.resizeFrame = null; this.currentLayout = this.options.layout; this.pendingChanges = { nodes: { create: [], update: [], delete: [] }, @@ -58,6 +61,7 @@ class GraphRenderer { }; this._initCytoscape(); + this._initResizeHandling(); } /** @@ -275,6 +279,29 @@ class GraphRenderer { this._setupEventHandlers(); } + + _initResizeHandling() { + if (typeof ResizeObserver !== 'undefined') { + this.resizeObserver = new ResizeObserver(() => this._handleResize()); + this.resizeObserver.observe(this.container); + } + + window.addEventListener('resize', this.boundWindowResize); + } + + _handleResize() { + if (!this.cy) return; + + if (this.resizeFrame) { + window.cancelAnimationFrame(this.resizeFrame); + } + + this.resizeFrame = window.requestAnimationFrame(() => { + this.resizeFrame = null; + if (!this.cy) return; + this.cy.resize(); + }); + } /** * Set up event handlers @@ -912,6 +939,15 @@ class GraphRenderer { * Destroy the renderer */ destroy() { + if (this.resizeObserver) { + this.resizeObserver.disconnect(); + this.resizeObserver = null; + } + window.removeEventListener('resize', this.boundWindowResize); + if (this.resizeFrame) { + window.cancelAnimationFrame(this.resizeFrame); + this.resizeFrame = null; + } if (this.cy) { this.cy.destroy(); this.cy = null; diff --git a/electron-ui/index.html b/electron-ui/index.html index d09dac3..b98ec50 100644 --- a/electron-ui/index.html +++ b/electron-ui/index.html @@ -43,6 +43,15 @@ Agents + + + + + + +
+ + +
+
Choose a case to inspect the event context, capture notes, and generate a report.
+
+
+ +
@@ -700,26 +751,6 @@

Ontology Graph

- - -
@@ -1027,6 +1058,22 @@

Add New Node

+
diff --git a/electron-ui/main.js b/electron-ui/main.js index e9aa32d..9a846e2 100644 --- a/electron-ui/main.js +++ b/electron-ui/main.js @@ -205,6 +205,61 @@ function runPythonScript(scriptName, args = [], options = {}) { }); } +function runPythonScriptWithStdin(scriptName, args = [], payload = null) { + return new Promise((resolve, reject) => { + const pythonProcess = spawnPythonProcess(scriptName, args); + let stdout = ''; + let stderr = ''; + + pythonProcess.stdout.on('data', (data) => { + stdout += data.toString(); + }); + + pythonProcess.stderr.on('data', (data) => { + stderr += data.toString(); + }); + + pythonProcess.on('close', (code) => { + if (code === 0) { + resolve(stdout); + } else { + reject(new Error(stderr || `Process exited with code ${code}`)); + } + }); + + pythonProcess.on('error', (err) => { + reject(err); + }); + + if (payload !== null && pythonProcess.stdin) { + pythonProcess.stdin.write(typeof payload === 'string' ? payload : JSON.stringify(payload)); + } + if (pythonProcess.stdin) { + pythonProcess.stdin.end(); + } + }); +} + +function parseJsonFromMixedOutput(output, fallback = {}) { + const text = String(output || '').trim(); + if (!text) return fallback; + try { + return JSON.parse(text); + } catch { + const lines = text.split('\n').map((line) => line.trim()).filter(Boolean); + for (let i = lines.length - 1; i >= 0; i -= 1) { + const line = lines[i]; + if (!line.startsWith('{') && !line.startsWith('[')) continue; + try { + return JSON.parse(line); + } catch { + // Continue scanning upward. + } + } + } + return fallback; +} + function normalizeAgentConfig(config = {}) { const thresholds = (config && typeof config.thresholds === 'object' && config.thresholds) || {}; const scope = (config && typeof config.scope === 'object' && config.scope) || {}; @@ -1801,6 +1856,104 @@ ipcMain.handle('agents:stop-subsystem', async (event, subsystemId) => { return { success: sent, subsystemId }; }); +// ============================================ +// Investigation Cases IPC Handlers +// ============================================ + +ipcMain.handle('cases:list', async (event, filters = {}) => { + const args = ['list']; + if (filters.limit) args.push('--limit', String(filters.limit)); + if (filters.status) args.push('--status', String(filters.status)); + try { + const output = await runPythonScript('case_api.py', args); + return parseJsonFromMixedOutput(output, { success: true, cases: [] }); + } catch (error) { + return { success: false, error: error.message, cases: [] }; + } +}); + +ipcMain.handle('cases:get', async (event, caseId) => { + try { + const output = await runPythonScript('case_api.py', ['get', '--case-id', String(caseId)]); + return parseJsonFromMixedOutput(output, { success: false, error: 'Invalid case response' }); + } catch (error) { + return { success: false, error: error.message }; + } +}); + +ipcMain.handle('cases:create-from-event', async (event, eventPayload = {}) => { + try { + const output = await runPythonScriptWithStdin('case_api.py', ['create-from-event'], eventPayload); + return parseJsonFromMixedOutput(output, { success: false, error: 'Invalid case response' }); + } catch (error) { + return { success: false, error: error.message }; + } +}); + +ipcMain.handle('cases:update', async (event, caseId, patch = {}) => { + try { + const output = await runPythonScriptWithStdin('case_api.py', ['update', '--case-id', String(caseId)], patch); + return parseJsonFromMixedOutput(output, { success: false, error: 'Invalid case response' }); + } catch (error) { + return { success: false, error: error.message }; + } +}); + +ipcMain.handle('cases:generate-draft', async (event, caseId) => { + try { + const output = await runPythonScript('case_api.py', ['generate-draft', '--case-id', String(caseId)]); + return parseJsonFromMixedOutput(output, { success: false, error: 'Invalid draft response' }); + } catch (error) { + return { success: false, error: error.message }; + } +}); + +ipcMain.handle('cases:approve-draft', async (event, caseId) => { + try { + const output = await runPythonScript('case_api.py', ['approve-draft', '--case-id', String(caseId)]); + return parseJsonFromMixedOutput(output, { success: false, error: 'Invalid draft response' }); + } catch (error) { + return { success: false, error: error.message }; + } +}); + +ipcMain.handle('cases:reject-draft', async (event, caseId) => { + try { + const output = await runPythonScript('case_api.py', ['reject-draft', '--case-id', String(caseId)]); + return parseJsonFromMixedOutput(output, { success: false, error: 'Invalid draft response' }); + } catch (error) { + return { success: false, error: error.message }; + } +}); + +ipcMain.handle('cases:generate-report', async (event, caseId) => { + try { + const output = await runPythonScript('case_api.py', ['generate-report', '--case-id', String(caseId)]); + return parseJsonFromMixedOutput(output, { success: false, error: 'Invalid report response' }); + } catch (error) { + return { success: false, error: error.message }; + } +}); + +ipcMain.handle('cases:save-report', async (event, suggestedFilename, markdown) => { + try { + const result = await dialog.showSaveDialog(mainWindow, { + title: 'Save Investigation Report', + defaultPath: suggestedFilename || 'investigation_report.md', + filters: [ + { name: 'Markdown', extensions: ['md'] }, + { name: 'Text', extensions: ['txt'] }, + { name: 'All Files', extensions: ['*'] }, + ], + }); + if (result.canceled || !result.filePath) return { success: false, cancelled: true }; + fs.writeFileSync(result.filePath, String(markdown || ''), 'utf-8'); + return { success: true, filePath: result.filePath }; + } catch (error) { + return { success: false, error: error.message }; + } +}); + // ============================================ // Artifact Ingestion IPC (P&IDs / SOPs / Diagrams via GPT-5.4) // ============================================ diff --git a/electron-ui/preload.js b/electron-ui/preload.js index 3be7255..0d930fd 100644 --- a/electron-ui/preload.js +++ b/electron-ui/preload.js @@ -87,6 +87,17 @@ contextBridge.exposeInMainWorld('api', { agentsCleanup: (retentionDays) => ipcRenderer.invoke('agents:cleanup', retentionDays), agentsStartSubsystem: (subId) => ipcRenderer.invoke('agents:start-subsystem', subId), agentsStopSubsystem: (subId) => ipcRenderer.invoke('agents:stop-subsystem', subId), + + // Investigation cases + casesList: (filters) => ipcRenderer.invoke('cases:list', filters), + casesGet: (caseId) => ipcRenderer.invoke('cases:get', caseId), + casesCreateFromEvent: (eventPayload) => ipcRenderer.invoke('cases:create-from-event', eventPayload), + casesUpdate: (caseId, patch) => ipcRenderer.invoke('cases:update', caseId, patch), + casesGenerateDraft: (caseId) => ipcRenderer.invoke('cases:generate-draft', caseId), + casesApproveDraft: (caseId) => ipcRenderer.invoke('cases:approve-draft', caseId), + casesRejectDraft: (caseId) => ipcRenderer.invoke('cases:reject-draft', caseId), + casesGenerateReport: (caseId) => ipcRenderer.invoke('cases:generate-report', caseId), + casesSaveReport: (suggestedFilename, markdown) => ipcRenderer.invoke('cases:save-report', suggestedFilename, markdown), // Database connections getDbConnections: () => ipcRenderer.invoke('get-db-connections'), diff --git a/electron-ui/renderer.js b/electron-ui/renderer.js index 0523a9e..7dd8912 100644 --- a/electron-ui/renderer.js +++ b/electron-ui/renderer.js @@ -25,6 +25,17 @@ navButtons.forEach(btn => { }); }); +function activateTab(tabId) { + navButtons.forEach(b => b.classList.remove('active')); + document.querySelector(`[data-tab="${tabId}"]`)?.classList.add('active'); + tabContents.forEach(tab => { + tab.classList.remove('active'); + if (tab.id === `tab-${tabId}`) { + tab.classList.add('active'); + } + }); +} + // ============================================ // Loading Overlay // ============================================ @@ -3674,6 +3685,7 @@ const agentsState = { selectedSubsystemId: null, listenersReady: false, subsystemHealth: {}, + subsystemOrder: [], subsystemHistory: {}, agentStates: {}, pendingDeepAnalyze: new Set(), @@ -3765,6 +3777,22 @@ function getFilteredEventsForSubsystem(subId) { }); } +function ensureSubsystemOrder(subId) { + if (!subId) return; + if (!agentsState.subsystemOrder.includes(subId)) { + agentsState.subsystemOrder.push(subId); + } +} + +function getPreferredEventIdForSubsystem(subId) { + const events = getFilteredEventsForSubsystem(subId); + if (!events.length) return null; + if (agentsState.selectedEventId && events.some((event) => event.event_id === agentsState.selectedEventId)) { + return agentsState.selectedEventId; + } + return events[0].event_id; +} + function updateSubsystemHealthFromStatus(payload) { const diagnostics = payload.diagnostics || {}; const phase = diagnostics.phase || ''; @@ -3787,6 +3815,7 @@ function updateSubsystemHealthFromStatus(payload) { agentsState.agentStates[sid] = { state: 'running', cycleCount: 0, avgCycleMs: 0, totalCandidates: 0, totalTriaged: 0, }; + ensureSubsystemOrder(sid); } } } @@ -3798,6 +3827,7 @@ function updateSubsystemHealthFromStatus(payload) { const sid = sig.subsystemId || subId; const healthLevel = computeHealthLevel(sig); agentsState.subsystemHealth[sid] = { ...sig, healthLevel }; + ensureSubsystemOrder(sid); if (!agentsState.subsystemHistory[sid]) agentsState.subsystemHistory[sid] = []; const history = agentsState.subsystemHistory[sid]; history.push({ @@ -3892,12 +3922,11 @@ function renderSubsystemHealthGrid() { return; } - const severityOrder = { critical: 0, warning: 1, elevated: 2, healthy: 3 }; + const orderMap = new Map(agentsState.subsystemOrder.map((subId, index) => [subId, index])); entries.sort((a, b) => { - const sa = severityOrder[a[1].healthLevel] ?? 3; - const sb = severityOrder[b[1].healthLevel] ?? 3; - if (sa !== sb) return sa - sb; - return (b[1].candidate || 0) - (a[1].candidate || 0); + const ia = orderMap.get(a[0]) ?? Number.MAX_SAFE_INTEGER; + const ib = orderMap.get(b[0]) ?? Number.MAX_SAFE_INTEGER; + return ia - ib; }); container.innerHTML = entries @@ -3939,12 +3968,21 @@ function renderSubsystemHealthGrid() { const tagRows = renderTagSignalRows(sig.tagSignals || []); const tagCount = (sig.tagSignals || []).length; const subEvents = getFilteredEventsForSubsystem(subId); + const preferredEventId = getPreferredEventIdForSubsystem(subId); + if (preferredEventId && preferredEventId !== agentsState.selectedEventId) { + agentsState.selectedEventId = preferredEventId; + } const eventRows = renderSubsystemEventRows(subEvents); const eventCount = subEvents.length; expandedBody = `
${bigTrend}
+

Events

+ ${eventCount} live event${eventCount === 1 ? '' : 's'} +
+
${eventRows}
+

Tags

${tagCount} tags
@@ -3952,11 +3990,6 @@ function renderSubsystemHealthGrid() { NameTrendz-scoreAvgCurrent
${tagRows}
-
-

Events

- ${eventCount} events -
-
${eventRows}
`; } else { @@ -3982,7 +4015,7 @@ function renderSubsystemHealthGrid() { ${evaluated}
- Anomalies + Candidates ${candidates}
@@ -4024,11 +4057,11 @@ function renderSubsystemHealthGrid() { }); }); - container.querySelectorAll('.health-event-detail-actions .btn-deep-analyze').forEach((btn) => { + container.querySelectorAll('.health-event-detail-actions .btn-ai-enrich').forEach((btn) => { btn.addEventListener('click', (e) => { e.stopPropagation(); const eventId = btn.getAttribute('data-event-id'); - if (eventId) deepAnalyzeEvent(eventId, btn); + if (eventId) aiEnrichEvent(eventId, btn); }); }); @@ -4040,6 +4073,14 @@ function renderSubsystemHealthGrid() { }); }); + container.querySelectorAll('.health-event-detail-actions .btn-create-case').forEach((btn) => { + btn.addEventListener('click', async (e) => { + e.stopPropagation(); + const eventId = btn.getAttribute('data-event-id'); + if (eventId) await createCaseFromAgentEvent(eventId, btn); + }); + }); + container.querySelectorAll('.health-event-detail-actions .btn-open-graph').forEach((btn) => { btn.addEventListener('click', (e) => { e.stopPropagation(); @@ -4091,7 +4132,7 @@ function renderInlineEventDetail(event) { const ackLabel = st === 'acknowledged' ? 'Clear' : (st === 'cleared' ? 'Cleared' : 'Acknowledge'); const ackDisabled = st === 'cleared' ? ' disabled' : ''; const isPending = agentsState.pendingDeepAnalyze.has(event.event_id); - const analyzeLabel = isPending ? 'Analyzing…' : (event.deep_analyzed ? 'Re-Analyze' : 'Deep Analyze'); + const analyzeLabel = isPending ? 'Enriching…' : 'AI Enrich'; const analyzeDisabled = isPending ? ' disabled' : ''; return ` @@ -4109,7 +4150,8 @@ function renderInlineEventDetail(event) { ${checks.length ? `
Checks
    ${checks.map((x) => `
  • ${escapeHtml(String(x))}
  • `).join('')}
` : ''} ${safety.length ? `
Safety
    ${safety.map((x) => `
  • ${escapeHtml(String(x))}
  • `).join('')}
` : ''}
- + +
@@ -4194,7 +4236,7 @@ function selectSubsystem(subId) { if (clearBtn) clearBtn.style.display = 'none'; } else { agentsState.selectedSubsystemId = subId; - agentsState.selectedEventId = null; + agentsState.selectedEventId = getPreferredEventIdForSubsystem(subId); if (clearBtn) clearBtn.style.display = ''; } renderSubsystemHealthGrid(); @@ -4268,6 +4310,7 @@ async function refreshAgentStatus() { async function startAgentsMonitoring() { const config = getAgentsConfigFromUI(); agentsState.subsystemHealth = {}; + agentsState.subsystemOrder = []; agentsState.subsystemHistory = {}; agentsState.agentStates = {}; agentsState.selectedSubsystemId = null; @@ -4354,6 +4397,454 @@ function upsertRealtimeAgentEvent(payload) { renderSubsystemHealthGrid(); } +// ============================================ +// Cases Tab — Investigation workspace +// ============================================ + +const casesState = { + initialized: false, + cases: [], + selectedCaseId: null, + currentCase: null, + currentReport: null, +}; + +function getCasesElements() { + return { + list: document.getElementById('cases-list'), + detail: document.getElementById('cases-detail'), + statusChip: document.getElementById('cases-status-chip'), + statusText: document.getElementById('cases-status-text'), + countLabel: document.getElementById('cases-count-label'), + filterStatus: document.getElementById('cases-filter-status'), + btnRefresh: document.getElementById('btn-cases-refresh'), + btnGenerate: document.getElementById('btn-cases-generate-report'), + btnSaveReport: document.getElementById('btn-cases-save-report'), + }; +} + +function normalizeCaseStatus(status) { + const value = String(status || 'open').toLowerCase(); + if (value === 'in review') return 'in_review'; + return value; +} + +function formatCaseDate(ts) { + if (!ts) return 'n/a'; + const d = new Date(ts); + if (Number.isNaN(d.getTime())) return String(ts); + return d.toLocaleString(); +} + +function parseCaseListValue(value) { + if (Array.isArray(value)) return value.filter(Boolean).map(String); + if (typeof value === 'string' && value.trim()) { + try { + const parsed = JSON.parse(value); + if (Array.isArray(parsed)) return parsed.filter(Boolean).map(String); + } catch { + return [value]; + } + } + return []; +} + +function parseCaseJsonObject(value) { + if (value && typeof value === 'object') return value; + if (typeof value === 'string' && value.trim()) { + try { + const parsed = JSON.parse(value); + if (parsed && typeof parsed === 'object') return parsed; + } catch { + return {}; + } + } + return {}; +} + +function updateCasesToolbar() { + const el = getCasesElements(); + const selected = casesState.currentCase; + if (el.countLabel) { + const count = casesState.cases.length; + el.countLabel.textContent = `${count} case${count === 1 ? '' : 's'}`; + } + if (el.btnGenerate) el.btnGenerate.disabled = !selected; + if (el.btnSaveReport) el.btnSaveReport.disabled = !casesState.currentReport; + if (el.statusChip) { + el.statusChip.className = 'status-chip'; + if (selected) { + el.statusChip.textContent = String(selected.status || 'open'); + if (String(selected.status || '').toLowerCase() === 'closed') { + el.statusChip.classList.add('running'); + } + } else { + el.statusChip.textContent = 'Cases'; + } + } + if (el.statusText) { + el.statusText.textContent = selected + ? `Selected ${selected.case_id || ''}` + : 'Select a case or create one from the Agents tab.'; + } +} + +function renderCaseList() { + const el = getCasesElements(); + if (!el.list) return; + if (!casesState.cases.length) { + el.list.innerHTML = '
No investigation cases yet. Promote an anomaly from the Agents tab to start one.
'; + updateCasesToolbar(); + return; + } + + el.list.innerHTML = casesState.cases.map((item) => { + const selectedClass = item.case_id === casesState.selectedCaseId ? ' selected' : ''; + const status = normalizeCaseStatus(item.status); + return ` +
+
+ ${escapeHtml(item.title || item.case_id || 'Untitled case')} + ${escapeHtml(status.replace('_', ' '))} +
+
+ ${escapeHtml(item.severity || 'unknown')} severity + ${escapeHtml(item.subsystem_name || item.subsystem_id || 'unscoped')} +
+
+ `; + }).join(''); + + el.list.querySelectorAll('.case-list-item').forEach((node) => { + node.addEventListener('click', async () => { + const caseId = node.getAttribute('data-case-id'); + if (caseId) await loadCaseDetails(caseId); + }); + }); + + updateCasesToolbar(); +} + +function renderCaseDetail() { + const el = getCasesElements(); + if (!el.detail) return; + + if (!casesState.currentCase) { + el.detail.innerHTML = '
Choose a case to inspect the event context, capture notes, and generate a report.
'; + updateCasesToolbar(); + return; + } + + const item = casesState.currentCase; + const event = item.event || {}; + const probableCauses = parseCaseListValue(item.probable_causes_json || event.probable_causes_json); + const recommendedChecks = parseCaseListValue(item.recommended_checks_json || event.recommended_checks_json); + const draftCauses = parseCaseListValue(item.draft_probable_causes_json); + const draftChecks = parseCaseListValue(item.draft_recommended_checks_json); + const draftContext = parseCaseJsonObject(item.draft_context_json); + const tags = Array.isArray(item.tags) ? item.tags : []; + const equipment = Array.isArray(item.equipment) ? item.equipment : []; + const draftStatus = String(item.draft_status || '').toLowerCase(); + const hasDraft = Boolean(item.draft_summary || item.draft_explanation || draftCauses.length || draftChecks.length); + const dependencySummary = [ + ...(draftContext.upstream_views || []).map((name) => `Upstream: ${name}`), + ...(draftContext.downstream_views || []).map((name) => `Downstream: ${name}`), + ]; + const reportHtml = casesState.currentReport + ? `
${escapeHtml(casesState.currentReport.markdown || '')}
` + : '
Generate a report to create a shareable post-incident narrative.
'; + + el.detail.innerHTML = ` +
+
+

${escapeHtml(item.title || 'Untitled case')}

+
Case ID ${escapeHtml(item.case_id || '')} linked to event ${escapeHtml(item.source_event_id || 'n/a')}
+
+ ${escapeHtml(normalizeCaseStatus(item.status).replace('_', ' '))} +
+ +
+
Subsystem${escapeHtml(item.subsystem_name || item.subsystem_id || 'Unknown')}
+
Source Tag${escapeHtml(item.source_tag || event.source_tag || 'n/a')}
+
Severity${escapeHtml(item.severity || 'unknown')}
+
Last Updated${escapeHtml(formatCaseDate(item.updated_at))}
+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ + + +
+ + ${hasDraft ? ` +
+
+

AI Draft

+ ${escapeHtml((draftStatus || 'pending_approval').replace('_', ' '))} +
+ ${item.draft_summary ? `
${escapeHtml(item.draft_summary)}
` : ''} + ${item.draft_explanation ? `
${escapeHtml(item.draft_explanation)}
` : ''} + ${dependencySummary.length ? `
${dependencySummary.map((x) => `${escapeHtml(x)}`).join('')}
` : ''} +
+
+

Draft Probable Causes

+ ${draftCauses.length ? `
    ${draftCauses.map((x) => `
  • ${escapeHtml(x)}
  • `).join('')}
` : '
No draft probable causes.
'} +
+
+

Draft Recommended Checks

+ ${draftChecks.length ? `
    ${draftChecks.map((x) => `
  • ${escapeHtml(x)}
  • `).join('')}
` : '
No draft checks.
'} +
+
+
+ + + +
+
+ ` : ''} + +
+
+

Probable Causes

+ ${probableCauses.length ? `
    ${probableCauses.map((x) => `
  • ${escapeHtml(x)}
  • `).join('')}
` : '
No probable causes recorded yet.
'} +
+
+

Recommended Checks

+ ${recommendedChecks.length ? `
    ${recommendedChecks.map((x) => `
  • ${escapeHtml(x)}
  • `).join('')}
` : '
No recommended checks recorded yet.
'} +
+
+

Linked Tags

+ ${tags.length ? `
    ${tags.map((x) => `
  • ${escapeHtml(x)}
  • `).join('')}
` : '
No linked tags.
'} +
+
+

Linked Equipment

+ ${equipment.length ? `
    ${equipment.map((x) => `
  • ${escapeHtml(x)}
  • `).join('')}
` : '
No linked equipment.
'} +
+
+ +
+

Generated Report

+ ${reportHtml} +
+ `; + + document.getElementById('btn-case-save')?.addEventListener('click', saveSelectedCase); + document.getElementById('btn-case-generate-draft')?.addEventListener('click', generateSelectedCaseDraft); + document.getElementById('btn-case-generate-report')?.addEventListener('click', generateSelectedCaseReport); + document.getElementById('btn-case-approve-draft')?.addEventListener('click', approveSelectedCaseDraft); + document.getElementById('btn-case-reject-draft')?.addEventListener('click', rejectSelectedCaseDraft); + document.getElementById('btn-case-regenerate-draft')?.addEventListener('click', generateSelectedCaseDraft); + + updateCasesToolbar(); +} + +async function loadCaseDetails(caseId) { + const result = await window.api.casesGet(caseId); + if (!result.success || !result.case) return; + casesState.selectedCaseId = caseId; + casesState.currentCase = result.case; + casesState.currentReport = null; + renderCaseList(); + renderCaseDetail(); +} + +async function loadCases(preferredCaseId = null) { + const el = getCasesElements(); + const result = await window.api.casesList({ + limit: 100, + status: el.filterStatus?.value || undefined, + }); + if (!result.success) { + if (el.list) el.list.innerHTML = `
Failed to load cases: ${escapeHtml(result.error || 'Unknown error')}
`; + return; + } + + casesState.cases = Array.isArray(result.cases) ? result.cases : []; + renderCaseList(); + + const nextCaseId = preferredCaseId || casesState.selectedCaseId; + if (nextCaseId && casesState.cases.some((item) => item.case_id === nextCaseId)) { + await loadCaseDetails(nextCaseId); + return; + } + + casesState.selectedCaseId = null; + casesState.currentCase = null; + casesState.currentReport = null; + renderCaseDetail(); +} + +async function createCaseFromAgentEvent(eventId, btnEl) { + const event = agentsState.events.find((item) => item.event_id === eventId); + if (!event) return; + const originalText = btnEl?.textContent; + if (btnEl) { + btnEl.disabled = true; + btnEl.textContent = 'Opening…'; + } + try { + const result = await window.api.casesCreateFromEvent(event); + if (!result.success || !result.case) return; + casesState.selectedCaseId = result.case.case_id; + casesState.currentCase = result.case; + casesState.currentReport = null; + activateTab('cases'); + setTimeout(initCasesTab, 50); + await loadCases(result.case.case_id); + } finally { + if (btnEl) { + btnEl.disabled = false; + btnEl.textContent = originalText || 'Investigate'; + } + } +} + +async function aiEnrichEvent(eventId, btnEl) { + const event = agentsState.events.find((item) => item.event_id === eventId); + if (!event) return; + const originalText = btnEl?.textContent; + agentsState.pendingDeepAnalyze.add(eventId); + renderSubsystemHealthGrid(); + if (btnEl) { + btnEl.disabled = true; + btnEl.textContent = 'Enriching…'; + } + try { + const caseResult = await window.api.casesCreateFromEvent(event); + if (!caseResult.success || !caseResult.case) return; + const draftResult = await window.api.casesGenerateDraft(caseResult.case.case_id); + casesState.selectedCaseId = caseResult.case.case_id; + casesState.currentCase = draftResult.case || caseResult.case; + casesState.currentReport = null; + activateTab('cases'); + setTimeout(initCasesTab, 50); + await loadCases(caseResult.case.case_id); + } finally { + agentsState.pendingDeepAnalyze.delete(eventId); + renderSubsystemHealthGrid(); + if (btnEl) { + btnEl.disabled = false; + btnEl.textContent = originalText || 'AI Enrich'; + } + } +} + +async function generateSelectedCaseDraft() { + if (!casesState.currentCase?.case_id) return; + const result = await window.api.casesGenerateDraft(casesState.currentCase.case_id); + if (!result.success || !result.case) return; + casesState.currentCase = result.case; + casesState.selectedCaseId = result.case.case_id; + casesState.currentReport = null; + await loadCases(result.case.case_id); +} + +async function approveSelectedCaseDraft() { + if (!casesState.currentCase?.case_id) return; + const result = await window.api.casesApproveDraft(casesState.currentCase.case_id); + if (!result.success || !result.case) return; + casesState.currentCase = result.case; + casesState.selectedCaseId = result.case.case_id; + await loadCases(result.case.case_id); +} + +async function rejectSelectedCaseDraft() { + if (!casesState.currentCase?.case_id) return; + const result = await window.api.casesRejectDraft(casesState.currentCase.case_id); + if (!result.success || !result.case) return; + casesState.currentCase = result.case; + casesState.selectedCaseId = result.case.case_id; + await loadCases(result.case.case_id); +} + +async function saveSelectedCase() { + if (!casesState.currentCase?.case_id) return; + const patch = { + status: document.getElementById('case-status-input')?.value || 'open', + owner: document.getElementById('case-owner-input')?.value || '', + disposition: document.getElementById('case-disposition-input')?.value || '', + summary: document.getElementById('case-summary-input')?.value || '', + explanation: document.getElementById('case-explanation-input')?.value || '', + notes: document.getElementById('case-notes-input')?.value || '', + resolution_notes: document.getElementById('case-resolution-input')?.value || '', + }; + const result = await window.api.casesUpdate(casesState.currentCase.case_id, patch); + if (!result.success || !result.case) return; + casesState.currentCase = result.case; + casesState.selectedCaseId = result.case.case_id; + casesState.currentReport = null; + await loadCases(result.case.case_id); +} + +async function generateSelectedCaseReport() { + if (!casesState.currentCase?.case_id) return; + const result = await window.api.casesGenerateReport(casesState.currentCase.case_id); + if (!result.success) return; + casesState.currentReport = { + markdown: result.markdown || '', + filename: result.filename || `investigation_${casesState.currentCase.case_id}.md`, + }; + if (result.case) { + casesState.currentCase = result.case; + } + renderCaseDetail(); +} + +async function saveSelectedCaseReport() { + if (!casesState.currentReport) return; + await window.api.casesSaveReport(casesState.currentReport.filename, casesState.currentReport.markdown); +} + +function initCasesTab() { + const el = getCasesElements(); + if (!el.list || casesState.initialized) { + if (el.list) loadCases(casesState.selectedCaseId); + return; + } + + casesState.initialized = true; + el.btnRefresh?.addEventListener('click', () => loadCases(casesState.selectedCaseId)); + el.filterStatus?.addEventListener('change', () => loadCases(null)); + el.btnGenerate?.addEventListener('click', generateSelectedCaseReport); + el.btnSaveReport?.addEventListener('click', saveSelectedCaseReport); + loadCases(casesState.selectedCaseId); +} + function ensureAgentListeners() { if (agentsState.listenersReady) return; agentsState.listenersReady = true; @@ -4448,6 +4939,9 @@ navButtons.forEach(btn => { if (btn.dataset.tab === 'agents') { setTimeout(initAgentsTab, 100); } + if (btn.dataset.tab === 'cases') { + setTimeout(initCasesTab, 100); + } }); }); @@ -4460,5 +4954,6 @@ setTimeout(() => { loadSettings(); loadDbConnections(); ensureAgentListeners(); + initCasesTab(); }, 500); diff --git a/electron-ui/styles.css b/electron-ui/styles.css index 35e7ffc..a38a40c 100644 --- a/electron-ui/styles.css +++ b/electron-ui/styles.css @@ -56,31 +56,31 @@ --font-sans: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif; --font-mono: 'JetBrains Mono', monospace; - --text-xs: 11px; - --text-sm: 12px; - --text-base: 13px; - --text-md: 14px; - --text-lg: 16px; - --text-xl: 20px; + --text-xs: clamp(11px, 0.14vw + 10.5px, 13px); + --text-sm: clamp(12px, 0.18vw + 11px, 14px); + --text-base: clamp(13px, 0.24vw + 11.5px, 16px); + --text-md: clamp(14px, 0.32vw + 12px, 18px); + --text-lg: clamp(16px, 0.48vw + 13px, 22px); + --text-xl: clamp(20px, 0.82vw + 14px, 32px); --leading-tight: 1.3; --leading-normal: 1.5; --leading-relaxed: 1.65; /* ---- Spacing (8px grid) ---- */ - --space-1: 4px; - --space-2: 8px; - --space-3: 12px; - --space-4: 16px; - --space-5: 20px; - --space-6: 24px; - --space-8: 32px; - --space-10: 40px; + --space-1: clamp(4px, 0.16vw + 3px, 6px); + --space-2: clamp(8px, 0.24vw + 6px, 12px); + --space-3: clamp(12px, 0.32vw + 9px, 16px); + --space-4: clamp(16px, 0.4vw + 12px, 22px); + --space-5: clamp(20px, 0.5vw + 15px, 28px); + --space-6: clamp(24px, 0.7vw + 17px, 36px); + --space-8: clamp(32px, 1vw + 20px, 48px); + --space-10: clamp(40px, 1.2vw + 24px, 60px); /* ---- Radii ---- */ - --radius-sm: 4px; - --radius-md: 6px; - --radius-lg: 8px; + --radius-sm: clamp(4px, 0.1vw + 3px, 6px); + --radius-md: clamp(6px, 0.14vw + 4px, 9px); + --radius-lg: clamp(8px, 0.18vw + 6px, 12px); /* ---- Shadows ---- */ --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.25); @@ -94,8 +94,10 @@ --transition-slow: 0.25s ease; /* ---- Layout ---- */ - --sidebar-width-collapsed: 52px; - --sidebar-width-expanded: 172px; + --sidebar-width-collapsed: clamp(52px, 3vw, 64px); + --sidebar-width-expanded: clamp(172px, 16vw, 240px); + --content-reading-width: clamp(720px, 72vw, 1120px); + --settings-card-width: clamp(560px, 58vw, 880px); } /* ============================================ @@ -165,6 +167,7 @@ body { /* Legacy support */ .main { flex: 1; + min-width: 0; overflow-y: auto; padding: var(--space-4) var(--space-8) var(--space-6); background: var(--color-bg); @@ -178,7 +181,7 @@ body { } .app-logo { - height: 32px; + height: clamp(32px, 1vw + 26px, 48px); width: auto; color: var(--color-text); } @@ -249,7 +252,7 @@ body { .toolbar-separator { width: 1px; - height: 20px; + height: clamp(20px, 1vw + 12px, 28px); background: var(--color-border); margin: 0 var(--space-2); } @@ -350,13 +353,13 @@ body { /* Button: Icon-only */ .btn-icon { padding: var(--space-1); - width: 28px; - height: 28px; + width: clamp(28px, 1vw + 22px, 36px); + height: clamp(28px, 1vw + 22px, 36px); } .btn-icon svg { - width: 14px; - height: 14px; + width: clamp(14px, 0.35vw + 12px, 18px); + height: clamp(14px, 0.35vw + 12px, 18px); } /* Button: Active state (for toggles) */ @@ -750,8 +753,8 @@ select.input, } .nav-icon { - width: 20px; - height: 20px; + width: clamp(20px, 0.6vw + 16px, 28px); + height: clamp(20px, 0.6vw + 16px, 28px); flex-shrink: 0; stroke-width: 1.5; } @@ -796,11 +799,14 @@ select.input, .tab-content { display: none; - max-width: 1200px; + width: 100%; + max-width: none; + min-width: 0; } .tab-content.active { - display: block; + display: flex; + flex-direction: column; } .tab-header { @@ -825,8 +831,9 @@ select.input, .cards { display: grid; - grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); + grid-template-columns: repeat(auto-fit, minmax(clamp(260px, 24vw, 360px), 1fr)); gap: var(--space-4); + width: 100%; margin-bottom: var(--space-6); } @@ -852,8 +859,8 @@ select.input, } .card-icon { - width: 32px; - height: 32px; + width: clamp(32px, 1vw + 26px, 44px); + height: clamp(32px, 1vw + 26px, 44px); margin-bottom: var(--space-3); color: var(--color-text-muted); } @@ -934,6 +941,8 @@ select.input, display: flex; flex-direction: column; height: calc(100vh - 180px); + width: 100%; + min-width: 0; background: var(--color-bg-panel); border: 1px solid var(--color-border); border-radius: var(--radius-lg); @@ -951,12 +960,12 @@ select.input, .chat-messages-inner { width: 100%; - max-width: 680px; + max-width: var(--content-reading-width); } .message { margin-bottom: var(--space-4); - max-width: 680px; + max-width: var(--content-reading-width); width: 100%; } @@ -1050,7 +1059,7 @@ select.input, /* Diagnostics/Trace panel in chat */ .chat-diagnostics { margin-top: var(--space-4); - max-width: 680px; + max-width: var(--content-reading-width); width: 100%; } @@ -1156,9 +1165,11 @@ select.input, .graph-layout { display: grid; - grid-template-columns: 200px 1fr 260px; + grid-template-columns: minmax(0, 1fr) minmax(clamp(260px, 20vw, 360px), 0.42fr); gap: var(--space-4); height: calc(100vh - 180px); + width: 100%; + min-width: 0; } .graph-sidebar { @@ -1166,6 +1177,7 @@ select.input, border: 1px solid var(--color-border); border-radius: var(--radius-lg); overflow-y: auto; + min-width: 0; } .graph-sidebar-left, @@ -1173,6 +1185,15 @@ select.input, padding: var(--space-4); } +.graph-sidebar-right { + display: flex; + flex-direction: column; +} + +.graph-sidebar-right > * { + flex-shrink: 0; +} + .sidebar-section { margin-bottom: var(--space-4); } @@ -1200,6 +1221,11 @@ select.input, overflow: hidden; } +.pending-changes-docked { + margin-top: auto; + margin-bottom: 0; +} + .sidebar-section-collapsible .section-trigger { display: flex; align-items: center; @@ -1308,6 +1334,7 @@ select.input, border: 1px solid var(--color-border); border-radius: var(--radius-lg); overflow: hidden; + min-width: 0; } /* Graph toolbar with segmented controls */ @@ -1349,6 +1376,7 @@ select.input, .graph-container { flex: 1; position: relative; + width: 100%; min-height: 380px; background: var(--color-bg); } @@ -2710,7 +2738,7 @@ select.input, @media (max-width: 1200px) { .graph-layout { - grid-template-columns: 180px 1fr 200px; + grid-template-columns: minmax(0, 1fr) minmax(240px, 320px); } } @@ -2931,7 +2959,7 @@ select.input, .settings-card-wide { grid-column: 1 / -1; - max-width: 560px; + max-width: var(--settings-card-width); } .settings-actions { @@ -3683,3 +3711,372 @@ select.input, color: var(--color-text-secondary); font-size: var(--text-xs); } + +/* ============================================ + Cases / Investigations + ============================================ */ + +.cases-toolbar { + display: flex; + justify-content: space-between; + align-items: center; + gap: var(--space-3); + margin-bottom: var(--space-4); + flex-wrap: wrap; +} + +.cases-toolbar-copy { + display: flex; + align-items: center; + gap: var(--space-2); + min-width: 0; +} + +.cases-status-text { + color: var(--color-text-secondary); + font-size: var(--text-sm); +} + +.cases-toolbar-actions { + display: flex; + align-items: center; + gap: var(--space-2); + flex-wrap: wrap; +} + +.cases-layout { + display: grid; + grid-template-columns: minmax(280px, 0.38fr) minmax(0, 1fr); + gap: var(--space-4); + min-width: 0; + align-items: start; +} + +.cases-sidebar, +.cases-detail { + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + background: var(--color-bg-panel); + min-width: 0; +} + +.cases-sidebar { + overflow: hidden; +} + +.cases-sidebar-header { + display: flex; + justify-content: space-between; + align-items: center; + gap: var(--space-2); + padding: var(--space-3) var(--space-4); + border-bottom: 1px solid var(--color-border-subtle); +} + +.cases-sidebar-header h3 { + margin: 0; + font-size: var(--text-md); +} + +.cases-sidebar-header span { + color: var(--color-text-muted); + font-size: var(--text-sm); +} + +.cases-list { + display: flex; + flex-direction: column; + max-height: calc(100vh - 260px); + overflow-y: auto; +} + +.cases-empty { + padding: var(--space-4); + color: var(--color-text-muted); + font-size: var(--text-sm); +} + +.cases-empty-detail { + min-height: 320px; + display: flex; + align-items: center; + justify-content: center; + text-align: center; +} + +.case-list-item { + display: flex; + flex-direction: column; + gap: var(--space-2); + padding: var(--space-3) var(--space-4); + border-bottom: 1px solid var(--color-border-subtle); + cursor: pointer; + transition: background var(--transition-fast), border-color var(--transition-fast); +} + +.case-list-item:hover { + background: var(--color-bg-elevated); +} + +.case-list-item.selected { + background: rgba(59, 130, 246, 0.08); + box-shadow: inset 2px 0 0 var(--color-accent); +} + +.case-list-item:last-child { + border-bottom: none; +} + +.case-list-topline, +.case-list-meta { + display: flex; + gap: var(--space-2); + align-items: center; + min-width: 0; +} + +.case-list-title { + flex: 1; + min-width: 0; + color: var(--color-text); + font-weight: 600; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.case-list-meta { + justify-content: space-between; + color: var(--color-text-muted); + font-size: var(--text-xs); +} + +.case-pill { + display: inline-flex; + align-items: center; + border-radius: 999px; + padding: 2px 8px; + font-size: var(--text-xs); + text-transform: uppercase; + letter-spacing: 0.3px; + border: 1px solid transparent; +} + +.case-pill.status-open { + color: #bfdbfe; + background: rgba(59, 130, 246, 0.12); + border-color: rgba(59, 130, 246, 0.28); +} + +.case-pill.status-in_review { + color: #fde68a; + background: rgba(245, 158, 11, 0.14); + border-color: rgba(245, 158, 11, 0.3); +} + +.case-pill.status-closed { + color: #bbf7d0; + background: rgba(34, 197, 94, 0.12); + border-color: rgba(34, 197, 94, 0.26); +} + +.cases-detail { + padding: var(--space-4); + display: flex; + flex-direction: column; + gap: var(--space-4); +} + +.case-detail-header { + display: flex; + justify-content: space-between; + gap: var(--space-3); + align-items: flex-start; + flex-wrap: wrap; +} + +.case-detail-title-block { + min-width: 0; +} + +.case-detail-title-block h3 { + margin: 0 0 var(--space-1); + font-size: var(--text-xl); +} + +.case-detail-subtitle { + color: var(--color-text-secondary); + font-size: var(--text-sm); +} + +.case-detail-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: var(--space-3); +} + +.case-detail-stat { + background: var(--color-bg-elevated); + border: 1px solid var(--color-border-subtle); + border-radius: var(--radius-md); + padding: var(--space-3); + min-width: 0; +} + +.case-detail-stat-label { + display: block; + font-size: var(--text-xs); + color: var(--color-text-muted); + text-transform: uppercase; + letter-spacing: 0.3px; + margin-bottom: var(--space-1); +} + +.case-detail-stat-value { + color: var(--color-text); + font-size: var(--text-sm); + word-break: break-word; +} + +.case-form-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: var(--space-3); +} + +.case-form-field { + display: flex; + flex-direction: column; + gap: var(--space-1); +} + +.case-form-field-full { + grid-column: 1 / -1; +} + +.case-form-field label { + color: var(--color-text-secondary); + font-size: var(--text-sm); +} + +.case-form-actions { + display: flex; + gap: var(--space-2); + flex-wrap: wrap; +} + +.case-draft-panel { + background: rgba(59, 130, 246, 0.08); + border: 1px solid rgba(59, 130, 246, 0.25); + border-radius: var(--radius-md); + padding: var(--space-3); + display: flex; + flex-direction: column; + gap: var(--space-3); +} + +.case-draft-header { + display: flex; + justify-content: space-between; + align-items: center; + gap: var(--space-2); +} + +.case-draft-header h4 { + margin: 0; + font-size: var(--text-md); +} + +.case-draft-summary { + color: var(--color-text); + font-weight: 600; +} + +.case-draft-narrative { + color: var(--color-text-secondary); + line-height: 1.5; + white-space: pre-wrap; +} + +.case-draft-deps { + display: flex; + gap: var(--space-2); + flex-wrap: wrap; +} + +.case-dependency-chip { + display: inline-flex; + align-items: center; + border-radius: 999px; + padding: 3px 9px; + font-size: var(--text-xs); + color: #bfdbfe; + background: rgba(37, 99, 235, 0.18); + border: 1px solid rgba(59, 130, 246, 0.35); +} + +.case-linked-lists { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: var(--space-3); +} + +.case-linked-panel { + background: var(--color-bg-elevated); + border: 1px solid var(--color-border-subtle); + border-radius: var(--radius-md); + padding: var(--space-3); +} + +.case-linked-panel h4, +.case-report-panel h4 { + margin: 0 0 var(--space-2); + font-size: var(--text-md); +} + +.case-linked-panel ul { + margin: 0; + padding-left: var(--space-4); + color: var(--color-text-secondary); +} + +.case-report-panel { + background: var(--color-bg-elevated); + border: 1px solid var(--color-border-subtle); + border-radius: var(--radius-md); + padding: var(--space-3); +} + +.case-report-output { + margin: 0; + white-space: pre-wrap; + word-break: break-word; + max-height: 420px; + overflow: auto; + font-size: var(--text-sm); + color: var(--color-text-secondary); +} + +@media (max-width: 1200px) { + .cases-layout { + grid-template-columns: 1fr; + } + + .cases-list { + max-height: 320px; + } + + .case-detail-grid, + .case-form-grid, + .case-linked-lists { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@media (max-width: 760px) { + .case-detail-grid, + .case-form-grid, + .case-linked-lists { + grid-template-columns: 1fr; + } +} diff --git a/scripts/case_api.py b/scripts/case_api.py new file mode 100644 index 0000000..fdea9ee --- /dev/null +++ b/scripts/case_api.py @@ -0,0 +1,145 @@ +#!/usr/bin/env python3 +""" +Case management API for Electron UI. +Provides JSON-based CRUD and report generation for investigation cases. +""" + +import argparse +import json +import sys +from datetime import date, datetime +from typing import Any + +from neo4j_ontology import get_ontology_graph + + +class DateTimeEncoder(json.JSONEncoder): + """JSON encoder that tolerates Neo4j and datetime-like values.""" + + def default(self, obj: Any): + if isinstance(obj, (datetime, date)): + return obj.isoformat() + if hasattr(obj, "isoformat"): + try: + return obj.isoformat() + except Exception: + pass + if hasattr(obj, "to_native"): + try: + return str(obj.to_native()) + except Exception: + pass + return str(obj) + + +def output_json(data: Any) -> None: + print(json.dumps(data, cls=DateTimeEncoder)) + + +def read_stdin_json() -> Any: + payload = sys.stdin.read().strip() + if not payload: + return {} + return json.loads(payload) + + +def main() -> int: + parser = argparse.ArgumentParser(description="Investigation case API") + sub = parser.add_subparsers(dest="command", required=True) + + p_list = sub.add_parser("list", help="List investigation cases") + p_list.add_argument("--limit", type=int, default=100) + p_list.add_argument("--status") + + p_get = sub.add_parser("get", help="Get one investigation case") + p_get.add_argument("--case-id", required=True) + + sub.add_parser("create-from-event", help="Create a case from stdin JSON event payload") + + p_update = sub.add_parser("update", help="Update case fields from stdin JSON") + p_update.add_argument("--case-id", required=True) + + p_draft = sub.add_parser("generate-draft", help="Generate an AI draft for a case") + p_draft.add_argument("--case-id", required=True) + + p_approve = sub.add_parser("approve-draft", help="Approve a draft for a case") + p_approve.add_argument("--case-id", required=True) + + p_reject = sub.add_parser("reject-draft", help="Reject a draft for a case") + p_reject.add_argument("--case-id", required=True) + + p_report = sub.add_parser("generate-report", help="Generate a markdown case report") + p_report.add_argument("--case-id", required=True) + + args = parser.parse_args() + graph = get_ontology_graph() + + try: + if args.command == "list": + cases = graph.list_investigation_cases(limit=args.limit, status=args.status) + output_json({"success": True, "cases": cases}) + return 0 + + if args.command == "get": + case = graph.get_investigation_case(args.case_id) + if not case: + output_json({"success": False, "error": f"Case not found: {args.case_id}"}) + return 1 + output_json({"success": True, "case": case}) + return 0 + + if args.command == "create-from-event": + event_payload = read_stdin_json() + case = graph.create_investigation_case_from_event(event_payload) + output_json({"success": True, "case": case}) + return 0 + + if args.command == "update": + patch = read_stdin_json() + case = graph.update_investigation_case(args.case_id, patch) + if not case: + output_json({"success": False, "error": f"Case not found: {args.case_id}"}) + return 1 + output_json({"success": True, "case": case}) + return 0 + + if args.command == "generate-draft": + case = graph.generate_investigation_case_draft(args.case_id) + if not case: + output_json({"success": False, "error": f"Case not found: {args.case_id}"}) + return 1 + output_json({"success": True, "case": case}) + return 0 + + if args.command == "approve-draft": + case = graph.approve_investigation_case_draft(args.case_id) + if not case: + output_json({"success": False, "error": f"Case not found: {args.case_id}"}) + return 1 + output_json({"success": True, "case": case}) + return 0 + + if args.command == "reject-draft": + case = graph.reject_investigation_case_draft(args.case_id) + if not case: + output_json({"success": False, "error": f"Case not found: {args.case_id}"}) + return 1 + output_json({"success": True, "case": case}) + return 0 + + if args.command == "generate-report": + report = graph.generate_investigation_case_report(args.case_id) + if not report: + output_json({"success": False, "error": f"Case not found: {args.case_id}"}) + return 1 + output_json({"success": True, **report}) + return 0 + + output_json({"success": False, "error": f"Unsupported command: {args.command}"}) + return 1 + finally: + graph.close() + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/gpt54_client.py b/scripts/gpt54_client.py index 2a8da18..0a824b7 100644 --- a/scripts/gpt54_client.py +++ b/scripts/gpt54_client.py @@ -302,6 +302,8 @@ def _build_system_prompt( - For operating parameters, extract numeric limits when available. - Classify media as: utility, product, waste, solvent, or gas. - Classify operations as: transfer, thermal, mixing, separation, cleaning, or reaction. +- For explicit upstream/downstream process dependencies between pieces of equipment, use the relationship type "FEEDS". +- Use canonical relationship names when possible: HANDLES_MEDIUM, PERFORMS_OPERATION, HAS_OPERATING_ENVELOPE, FEEDS, MEASURES, MONITORS_ENVELOPE, IMPLEMENTS_CONTROL_OF, USES_PRINCIPLE, INVOLVES_SPECIES, PROCESSES_SPECIES, HAS_REACTION, MEDIUM_CONTAINS, ENVELOPE_FOR_PRINCIPLE, VISUALIZES. - Return valid JSON matching the schema described below. {entity_hint} diff --git a/scripts/neo4j_ontology.py b/scripts/neo4j_ontology.py index f2a9efc..5ce704e 100644 --- a/scripts/neo4j_ontology.py +++ b/scripts/neo4j_ontology.py @@ -6,6 +6,7 @@ import os import json +import uuid from typing import Dict, List, Optional, Any, Union from dataclasses import dataclass, field from contextlib import contextmanager @@ -176,6 +177,7 @@ def create_indexes(self) -> None: "CREATE CONSTRAINT namedquery_name IF NOT EXISTS FOR (q:NamedQuery) REQUIRE q.name IS UNIQUE", "CREATE CONSTRAINT agentrun_id IF NOT EXISTS FOR (r:AgentRun) REQUIRE r.run_id IS UNIQUE", "CREATE CONSTRAINT anomalyevent_id IF NOT EXISTS FOR (e:AnomalyEvent) REQUIRE e.event_id IS UNIQUE", + "CREATE CONSTRAINT investigationcase_id IF NOT EXISTS FOR (c:InvestigationCase) REQUIRE c.case_id IS UNIQUE", ] # Regular indexes @@ -223,6 +225,10 @@ def create_indexes(self) -> None: "CREATE INDEX anomalyevent_state IF NOT EXISTS FOR (e:AnomalyEvent) ON (e.state)", "CREATE INDEX anomalyevent_severity IF NOT EXISTS FOR (e:AnomalyEvent) ON (e.severity)", "CREATE INDEX anomalyevent_dedup_key IF NOT EXISTS FOR (e:AnomalyEvent) ON (e.dedup_key)", + "CREATE INDEX investigationcase_status IF NOT EXISTS FOR (c:InvestigationCase) ON (c.status)", + "CREATE INDEX investigationcase_severity IF NOT EXISTS FOR (c:InvestigationCase) ON (c.severity)", + "CREATE INDEX investigationcase_created IF NOT EXISTS FOR (c:InvestigationCase) ON (c.created_at)", + "CREATE INDEX investigationcase_source_event IF NOT EXISTS FOR (c:InvestigationCase) ON (c.source_event_id)", # Process-semantic layer indexes "CREATE INDEX processmedium_name IF NOT EXISTS FOR (pm:ProcessMedium) ON (pm.name)", "CREATE INDEX unitoperation_name IF NOT EXISTS FOR (uo:UnitOperation) ON (uo.name)", @@ -335,6 +341,532 @@ def cleanup_anomaly_events(self, retention_days: int = 14) -> int: record = result.single() return int(record["deleted"]) if record else 0 + def list_investigation_cases( + self, + limit: int = 100, + status: Optional[str] = None, + ) -> List[Dict[str, Any]]: + """List investigation cases with lightweight linked context.""" + with self.session() as session: + clauses = [] + params: Dict[str, Any] = {"limit": max(1, min(limit, 500))} + if status: + clauses.append("c.status = $status") + params["status"] = status + where = f"WHERE {' AND '.join(clauses)}" if clauses else "" + result = session.run( + f""" + MATCH (c:InvestigationCase) + {where} + OPTIONAL MATCH (c)-[:BASED_ON]->(e:AnomalyEvent) + OPTIONAL MATCH (c)-[:INVOLVES_TAG]->(t:ScadaTag) + OPTIONAL MATCH (c)-[:INVOLVES_EQUIPMENT]->(eq:Equipment) + RETURN c, e.event_id AS event_id, e.summary AS event_summary, + collect(DISTINCT t.name) AS tags, + collect(DISTINCT eq.name) AS equipment + ORDER BY c.created_at DESC + LIMIT $limit + """, + **params, + ) + cases: List[Dict[str, Any]] = [] + for record in result: + props = dict(record["c"]) + props["source_event_id"] = props.get("source_event_id") or record["event_id"] + props["source_event_summary"] = record["event_summary"] + props["tags"] = [x for x in record["tags"] if x] + props["equipment"] = [x for x in record["equipment"] if x] + cases.append(props) + return cases + + def get_investigation_case(self, case_id: str) -> Optional[Dict[str, Any]]: + """Fetch one investigation case with linked event, tag, and equipment context.""" + with self.session() as session: + record = session.run( + """ + MATCH (c:InvestigationCase {case_id: $case_id}) + OPTIONAL MATCH (c)-[:BASED_ON]->(e:AnomalyEvent) + OPTIONAL MATCH (c)-[:INVOLVES_TAG]->(t:ScadaTag) + OPTIONAL MATCH (c)-[:INVOLVES_EQUIPMENT]->(eq:Equipment) + RETURN c, + e, + collect(DISTINCT t.name) AS tags, + collect(DISTINCT eq.name) AS equipment + LIMIT 1 + """, + case_id=case_id, + ).single() + if not record: + return None + case_data = dict(record["c"]) + case_data["tags"] = [x for x in record["tags"] if x] + case_data["equipment"] = [x for x in record["equipment"] if x] + event = record["e"] + case_data["event"] = dict(event) if event else None + return case_data + + def create_investigation_case_from_event(self, event_data: Dict[str, Any]) -> Dict[str, Any]: + """Create a persistent investigation case from an event payload.""" + event = dict(event_data or {}) + source_event_id = event.get("event_id", "") + if source_event_id: + with self.session() as session: + existing = session.run( + """ + MATCH (c:InvestigationCase {source_event_id: $source_event_id}) + RETURN c.case_id AS case_id + ORDER BY c.created_at DESC + LIMIT 1 + """, + source_event_id=source_event_id, + ).single() + if existing and existing.get("case_id"): + return self.get_investigation_case(existing["case_id"]) + + case_id = str(uuid.uuid4()) + title = ( + event.get("summary") + or event.get("tag_name") + or event.get("source_tag") + or f"Investigation {case_id[:8]}" + ) + tags = event.get("tags") if isinstance(event.get("tags"), list) else [] + equipment = event.get("equipment") if isinstance(event.get("equipment"), list) else [] + if event.get("tag_name") and event["tag_name"] not in tags: + tags = [event["tag_name"], *tags] + + def _json_list(key: str) -> str: + value = event.get(key) + if isinstance(value, list): + return json.dumps(value, default=str) + if isinstance(value, str) and value.strip(): + return value + return "[]" + + with self.session() as session: + session.run( + """ + CREATE (c:InvestigationCase { + case_id: $case_id, + title: $title, + summary: $summary, + status: 'open', + disposition: '', + severity: $severity, + confidence: $confidence, + subsystem_id: $subsystem_id, + subsystem_name: $subsystem_name, + subsystem_type: $subsystem_type, + source_event_id: $source_event_id, + source_tag: $source_tag, + explanation: $explanation, + probable_causes_json: $causes_json, + recommended_checks_json: $checks_json, + notes: '', + resolution_notes: '', + owner: '', + created_at: datetime(), + updated_at: datetime(), + raw_event_json: $raw_event_json + }) + """, + case_id=case_id, + title=title, + summary=event.get("summary", ""), + severity=event.get("severity", "medium"), + confidence=float(event.get("confidence", 0.5) or 0.5), + subsystem_id=event.get("subsystem_id", ""), + subsystem_name=event.get("subsystem_name", ""), + subsystem_type=event.get("subsystem_type", ""), + source_event_id=source_event_id, + source_tag=event.get("source_tag", "") or event.get("tag_name", ""), + explanation=event.get("explanation", ""), + causes_json=_json_list("probable_causes_json"), + checks_json=_json_list("recommended_checks_json"), + raw_event_json=json.dumps(event, default=str), + ) + + if source_event_id: + session.run( + """ + MATCH (c:InvestigationCase {case_id: $case_id}) + MATCH (e:AnomalyEvent {event_id: $event_id}) + MERGE (c)-[:BASED_ON]->(e) + """, + case_id=case_id, + event_id=source_event_id, + ) + + if tags: + session.run( + """ + MATCH (c:InvestigationCase {case_id: $case_id}) + UNWIND $tags AS tag_name + MATCH (t:ScadaTag {name: tag_name}) + MERGE (c)-[:INVOLVES_TAG]->(t) + """, + case_id=case_id, + tags=tags, + ) + + if equipment: + session.run( + """ + MATCH (c:InvestigationCase {case_id: $case_id}) + UNWIND $equipment AS equipment_name + MATCH (eq:Equipment {name: equipment_name}) + MERGE (c)-[:INVOLVES_EQUIPMENT]->(eq) + """, + case_id=case_id, + equipment=equipment, + ) + + return self.get_investigation_case(case_id) + + def update_investigation_case(self, case_id: str, updates: Dict[str, Any]) -> Optional[Dict[str, Any]]: + """Update a subset of investigation case fields.""" + allowed_fields = { + "title", + "summary", + "status", + "disposition", + "notes", + "resolution_notes", + "owner", + "explanation", + "confidence", + } + patch = {k: v for k, v in (updates or {}).items() if k in allowed_fields} + if not patch: + return self.get_investigation_case(case_id) + + assignments = [f"c.{key} = ${key}" for key in patch.keys()] + if patch.get("status") == "closed": + assignments.append("c.closed_at = datetime()") + assignments.append("c.updated_at = datetime()") + + with self.session() as session: + row = session.run( + f""" + MATCH (c:InvestigationCase {{case_id: $case_id}}) + SET {", ".join(assignments)} + RETURN count(c) AS cnt + """, + case_id=case_id, + **patch, + ).single() + if not row or row["cnt"] == 0: + return None + return self.get_investigation_case(case_id) + + @staticmethod + def _parse_json_list(value: Any) -> List[str]: + """Normalize JSON-or-list values into a flat string list.""" + if isinstance(value, list): + return [str(v) for v in value if v] + if isinstance(value, str) and value.strip(): + try: + parsed = json.loads(value) + if isinstance(parsed, list): + return [str(v) for v in parsed if v] + except Exception: + return [value] + return [] + + def _build_investigation_case_ai_context(self, case: Dict[str, Any]) -> Dict[str, Any]: + """Collect graph context for AI draft generation, including FEEDS links.""" + source_tag = case.get("source_tag") or ((case.get("event") or {}).get("source_tag")) + subsystem_name = case.get("subsystem_name") or "" + + with self.session() as session: + record = session.run( + """ + OPTIONAL MATCH (sub:View {name: $subsystem_name}) + OPTIONAL MATCH (up:View)-[:FEEDS]->(sub) + OPTIONAL MATCH (sub)-[:FEEDS]->(down:View) + OPTIONAL MATCH (c:InvestigationCase {case_id: $case_id})-[:INVOLVES_TAG]->(linked_tag:ScadaTag) + OPTIONAL MATCH (c)-[:INVOLVES_EQUIPMENT]->(linked_eq:Equipment) + OPTIONAL MATCH (tag:ScadaTag) + WHERE tag.name = $source_tag OR tag.opc_item_path = $source_tag + OPTIONAL MATCH (vc:ViewComponent)-[:BINDS_TO]->(tag) + OPTIONAL MATCH (tag_view:View)-[:HAS_COMPONENT]->(vc) + OPTIONAL MATCH (tag_up:View)-[:FEEDS]->(tag_view) + OPTIONAL MATCH (tag_view)-[:FEEDS]->(tag_down:View) + RETURN collect(DISTINCT linked_tag.name) AS linked_tags, + collect(DISTINCT linked_eq.name) AS linked_equipment, + collect(DISTINCT up.name) AS upstream_views, + collect(DISTINCT down.name) AS downstream_views, + collect(DISTINCT tag_view.name) AS tag_views, + collect(DISTINCT tag_up.name) AS tag_upstream_views, + collect(DISTINCT tag_down.name) AS tag_downstream_views + LIMIT 1 + """, + case_id=case.get("case_id", ""), + subsystem_name=subsystem_name, + source_tag=source_tag, + ).single() + + event = case.get("event") or {} + upstream_views = [] + downstream_views = [] + tags = list(case.get("tags") or []) + equipment = list(case.get("equipment") or []) + tag_views = [] + + if record: + upstream_views = sorted( + set([x for x in record["upstream_views"] if x] + [x for x in record["tag_upstream_views"] if x]) + ) + downstream_views = sorted( + set([x for x in record["downstream_views"] if x] + [x for x in record["tag_downstream_views"] if x]) + ) + tag_views = sorted(set([x for x in record["tag_views"] if x])) + tags = sorted(set(tags + [x for x in record["linked_tags"] if x])) + equipment = sorted(set(equipment + [x for x in record["linked_equipment"] if x])) + + return { + "case_id": case.get("case_id"), + "subsystem_name": subsystem_name, + "subsystem_type": case.get("subsystem_type", ""), + "source_tag": source_tag, + "linked_tags": tags, + "linked_equipment": equipment, + "views": tag_views or ([subsystem_name] if subsystem_name else []), + "upstream_views": upstream_views, + "downstream_views": downstream_views, + "event_summary": event.get("summary") or case.get("summary", ""), + "event_severity": event.get("severity") or case.get("severity", ""), + "z_score": event.get("z_score"), + "mad_score": event.get("mad_score"), + "live_value": event.get("live_value"), + "probable_causes": self._parse_json_list(case.get("probable_causes_json") or event.get("probable_causes_json")), + "recommended_checks": self._parse_json_list(case.get("recommended_checks_json") or event.get("recommended_checks_json")), + } + + def generate_investigation_case_draft(self, case_id: str) -> Optional[Dict[str, Any]]: + """Generate an AI-assisted draft that requires explicit approval.""" + case = self.get_investigation_case(case_id) + if not case: + return None + + context = self._build_investigation_case_ai_context(case) + event = case.get("event") or {} + subsystem = context.get("subsystem_name") or "the affected subsystem" + upstream = context.get("upstream_views") or [] + downstream = context.get("downstream_views") or [] + dependency_clause = "" + if upstream or downstream: + pieces = [] + if upstream: + pieces.append(f"upstream context: {', '.join(upstream)}") + if downstream: + pieces.append(f"downstream context: {', '.join(downstream)}") + dependency_clause = " The graph shows FEEDS dependencies with " + "; ".join(pieces) + "." + + fallback = { + "summary": case.get("summary") or event.get("summary") or f"Investigate deviation in {subsystem}", + "narrative": ( + f"A deviation was detected on `{context.get('source_tag') or subsystem}` within `{subsystem}`." + f"{dependency_clause} Based on the event timing and graph context, the best current hypothesis is that " + f"this condition may be contributing to downstream process degradation and should be reviewed before " + f"closing the incident." + ), + "probable_causes": context.get("probable_causes") or [ + f"Process deviation originating in {subsystem}.", + "Instrumentation, flow restriction, or equipment underperformance should be verified.", + ], + "recommended_checks": context.get("recommended_checks") or [ + f"Confirm the live value and recent trend for {context.get('source_tag') or subsystem}.", + "Check adjacent upstream/downstream views to validate propagation of the upset.", + ], + "disposition": "awaiting_operator_review", + "confidence": float(case.get("confidence", 0.5) or 0.5), + } + + try: + from claude_client import ClaudeClient + + client = ClaudeClient(enable_tools=False) + llm_result = client.query_json( + system_prompt=( + "You are an industrial incident investigation assistant. " + "You generate a draft investigation narrative that must be approved by an operator. " + "Use FEEDS relationships as evidence of likely upstream/downstream propagation when present. " + "Return ONLY valid JSON with keys: summary, narrative, probable_causes, recommended_checks, disposition, confidence." + ), + user_prompt=json.dumps( + { + "case": { + "title": case.get("title"), + "summary": case.get("summary"), + "severity": case.get("severity"), + "subsystem_name": case.get("subsystem_name"), + "source_event_id": case.get("source_event_id"), + }, + "event": event, + "graph_context": context, + "instruction": ( + "Write a concise but concrete investigation draft. " + "If FEEDS dependencies exist, explain the likely propagation path. " + "Do not overstate certainty." + ), + }, + default=str, + ), + max_tokens=1200, + use_tools=False, + ) + data = llm_result.get("data") if isinstance(llm_result, dict) else None + if isinstance(data, dict): + fallback.update({ + "summary": data.get("summary") or fallback["summary"], + "narrative": data.get("narrative") or fallback["narrative"], + "probable_causes": data.get("probable_causes") or fallback["probable_causes"], + "recommended_checks": data.get("recommended_checks") or fallback["recommended_checks"], + "disposition": data.get("disposition") or fallback["disposition"], + "confidence": float(max(0.0, min(1.0, data.get("confidence", fallback["confidence"]) or fallback["confidence"]))), + }) + except Exception: + pass + + with self.session() as session: + row = session.run( + """ + MATCH (c:InvestigationCase {case_id: $case_id}) + SET c.draft_status = 'pending_approval', + c.draft_generated_at = datetime(), + c.draft_summary = $draft_summary, + c.draft_explanation = $draft_explanation, + c.draft_probable_causes_json = $draft_causes, + c.draft_recommended_checks_json = $draft_checks, + c.draft_disposition = $draft_disposition, + c.draft_confidence = $draft_confidence, + c.draft_context_json = $draft_context_json, + c.updated_at = datetime() + RETURN count(c) AS cnt + """, + case_id=case_id, + draft_summary=fallback["summary"], + draft_explanation=fallback["narrative"], + draft_causes=json.dumps(fallback["probable_causes"], default=str), + draft_checks=json.dumps(fallback["recommended_checks"], default=str), + draft_disposition=fallback["disposition"], + draft_confidence=float(fallback["confidence"]), + draft_context_json=json.dumps(context, default=str), + ).single() + if not row or row["cnt"] == 0: + return None + + return self.get_investigation_case(case_id) + + def approve_investigation_case_draft(self, case_id: str) -> Optional[Dict[str, Any]]: + """Approve an AI draft and copy it into the canonical case fields.""" + with self.session() as session: + row = session.run( + """ + MATCH (c:InvestigationCase {case_id: $case_id}) + SET c.summary = coalesce(c.draft_summary, c.summary), + c.explanation = coalesce(c.draft_explanation, c.explanation), + c.probable_causes_json = coalesce(c.draft_probable_causes_json, c.probable_causes_json), + c.recommended_checks_json = coalesce(c.draft_recommended_checks_json, c.recommended_checks_json), + c.disposition = coalesce(c.draft_disposition, c.disposition), + c.confidence = coalesce(c.draft_confidence, c.confidence), + c.draft_status = 'approved', + c.draft_approved_at = datetime(), + c.updated_at = datetime() + RETURN count(c) AS cnt + """, + case_id=case_id, + ).single() + if not row or row["cnt"] == 0: + return None + return self.get_investigation_case(case_id) + + def reject_investigation_case_draft(self, case_id: str) -> Optional[Dict[str, Any]]: + """Reject the current AI draft while keeping the last draft for auditability.""" + with self.session() as session: + row = session.run( + """ + MATCH (c:InvestigationCase {case_id: $case_id}) + SET c.draft_status = 'rejected', + c.draft_rejected_at = datetime(), + c.updated_at = datetime() + RETURN count(c) AS cnt + """, + case_id=case_id, + ).single() + if not row or row["cnt"] == 0: + return None + return self.get_investigation_case(case_id) + + def generate_investigation_case_report(self, case_id: str) -> Optional[Dict[str, Any]]: + """Generate a deterministic markdown report for a case.""" + case = self.get_investigation_case(case_id) + if not case: + return None + + event = case.get("event") or {} + probable_causes = self._parse_json_list(case.get("probable_causes_json") or event.get("probable_causes_json")) + recommended_checks = self._parse_json_list(case.get("recommended_checks_json") or event.get("recommended_checks_json")) + equipment = case.get("equipment") or [] + tags = case.get("tags") or [] + + report_lines = [ + f"# Investigation Report: {case.get('title', case_id)}", + "", + "## Case Summary", + f"- Case ID: `{case.get('case_id', case_id)}`", + f"- Status: `{case.get('status', 'open')}`", + f"- Disposition: `{case.get('disposition', 'unassigned') or 'unassigned'}`", + f"- Severity: `{case.get('severity', 'unknown')}`", + f"- Created: `{case.get('created_at', '')}`", + f"- Updated: `{case.get('updated_at', '')}`", + "", + "## Operational Context", + f"- Subsystem: `{case.get('subsystem_name') or case.get('subsystem_id') or 'unknown'}`", + f"- Source Tag: `{case.get('source_tag') or event.get('source_tag') or ''}`", + f"- Related Equipment: {', '.join(f'`{name}`' for name in equipment) if equipment else 'None linked'}", + f"- Related Tags: {', '.join(f'`{name}`' for name in tags) if tags else 'None linked'}", + "", + "## Event Snapshot", + f"- Source Event ID: `{case.get('source_event_id') or event.get('event_id') or 'n/a'}`", + f"- Event State: `{event.get('state', 'n/a')}`", + f"- z-score: `{event.get('z_score', 'n/a')}`", + f"- MAD score: `{event.get('mad_score', 'n/a')}`", + f"- Summary: {event.get('summary') or case.get('summary') or 'No summary recorded.'}", + "", + "## Investigation Narrative", + case.get("explanation") or event.get("explanation") or "No generated investigation narrative is available yet.", + "", + "## Probable Causes", + ] + + if probable_causes: + report_lines.extend([f"- {item}" for item in probable_causes]) + else: + report_lines.append("- No probable causes recorded yet.") + + report_lines.extend(["", "## Recommended Checks"]) + if recommended_checks: + report_lines.extend([f"- {item}" for item in recommended_checks]) + else: + report_lines.append("- No recommended checks recorded yet.") + + report_lines.extend([ + "", + "## Operator Notes", + case.get("notes") or "No operator notes recorded.", + "", + "## Resolution Notes", + case.get("resolution_notes") or "No resolution notes recorded.", + ]) + + return { + "case": case, + "markdown": "\n".join(report_lines).strip() + "\n", + "filename": f"investigation_{case.get('case_id', case_id)}.md", + } + def clear_all(self) -> None: """Clear all nodes and relationships. USE WITH CAUTION.""" with self.session() as session: diff --git a/scripts/process_semantics.py b/scripts/process_semantics.py index efc1ca3..3378cb5 100644 --- a/scripts/process_semantics.py +++ b/scripts/process_semantics.py @@ -140,6 +140,7 @@ def merge_evidence(existing_json: Optional[str], new_items: List[EvidenceItem]) "HANDLES_MEDIUM": {"from": "Equipment", "to": "ProcessMedium"}, "PERFORMS_OPERATION": {"from": "Equipment", "to": "UnitOperation"}, "HAS_OPERATING_ENVELOPE":{"from": "Equipment", "to": "OperatingEnvelope"}, + "FEEDS": {"from": "Equipment", "to": "Equipment"}, "MEASURES": {"from": "ScadaTag", "to": "PhysicalPrinciple"}, "MONITORS_ENVELOPE": {"from": "ScadaTag", "to": "OperatingEnvelope"}, "IMPLEMENTS_CONTROL_OF": {"from": "AOI", "to": "UnitOperation"}, From eee1af5a647778e824c4f7c771e103940f4fd788 Mon Sep 17 00:00:00 2001 From: Leor Barak Fishman Date: Wed, 18 Mar 2026 18:17:54 -0700 Subject: [PATCH 2/5] case improvements --- electron-ui/index.html | 8 + electron-ui/main.js | 243 +++++++++- electron-ui/preload.js | 18 +- electron-ui/renderer.js | 913 ++++++++++++++++++++++++++++++++++---- electron-ui/styles.css | 127 ++++++ scripts/case_api.py | 11 + scripts/claude_client.py | 2 + scripts/neo4j_ontology.py | 37 +- scripts/troubleshoot.py | 26 +- 9 files changed, 1270 insertions(+), 115 deletions(-) diff --git a/electron-ui/index.html b/electron-ui/index.html index b98ec50..a0cdf23 100644 --- a/electron-ui/index.html +++ b/electron-ui/index.html @@ -684,6 +684,14 @@

Investigations

Choose a case to inspect the event context, capture notes, and generate a report.
+ +
+
+

Case Python Log

+ +
+

+        
diff --git a/electron-ui/main.js b/electron-ui/main.js index 9a846e2..8fb002a 100644 --- a/electron-ui/main.js +++ b/electron-ui/main.js @@ -131,7 +131,7 @@ app.on('activate', () => { // Helper to run Python scripts with optional streaming function runPythonScript(scriptName, args = [], options = {}) { - const { streaming = false, streamId = null } = options; + const { streaming = false, streamId = null, target = 'default' } = options; return new Promise((resolve, reject) => { const pythonProcess = spawnPythonProcess(scriptName, args); @@ -151,17 +151,20 @@ function runPythonScript(scriptName, args = [], options = {}) { if (line.startsWith('[TOOL]')) { sendToRenderer('tool-call', { streamId, + target, tool: line.replace('[TOOL]', '').trim() }, 'runPythonScript stdout tool'); } else if (line.startsWith('[DEBUG]')) { sendToRenderer('stream-output', { streamId, + target, text: line, type: 'debug' }, 'runPythonScript stdout debug'); } else if (line.trim()) { sendToRenderer('stream-output', { streamId, + target, text: line, type: 'output' }, 'runPythonScript stdout output'); @@ -178,6 +181,7 @@ function runPythonScript(scriptName, args = [], options = {}) { if (streaming) { sendToRenderer('stream-output', { streamId, + target, text, type: 'stderr' }, 'runPythonScript stderr'); @@ -188,6 +192,7 @@ function runPythonScript(scriptName, args = [], options = {}) { if (streaming) { sendToRenderer('stream-complete', { streamId, + target, success: code === 0 }, 'runPythonScript close'); } @@ -205,21 +210,65 @@ function runPythonScript(scriptName, args = [], options = {}) { }); } -function runPythonScriptWithStdin(scriptName, args = [], payload = null) { +function runPythonScriptWithStdin(scriptName, args = [], payload = null, options = {}) { + const { streaming = false, streamId = null, target = 'default' } = options; return new Promise((resolve, reject) => { const pythonProcess = spawnPythonProcess(scriptName, args); let stdout = ''; let stderr = ''; pythonProcess.stdout.on('data', (data) => { - stdout += data.toString(); + const text = data.toString(); + stdout += text; + if (streaming) { + const lines = text.split('\n'); + for (const line of lines) { + if (line.startsWith('[TOOL]')) { + sendToRenderer('tool-call', { + streamId, + target, + tool: line.replace('[TOOL]', '').trim() + }, 'runPythonScriptWithStdin stdout tool'); + } else if (line.startsWith('[DEBUG]')) { + sendToRenderer('stream-output', { + streamId, + target, + text: line, + type: 'debug' + }, 'runPythonScriptWithStdin stdout debug'); + } else if (line.trim()) { + sendToRenderer('stream-output', { + streamId, + target, + text: line, + type: 'output' + }, 'runPythonScriptWithStdin stdout output'); + } + } + } }); pythonProcess.stderr.on('data', (data) => { - stderr += data.toString(); + const text = data.toString(); + stderr += text; + if (streaming) { + sendToRenderer('stream-output', { + streamId, + target, + text, + type: 'stderr' + }, 'runPythonScriptWithStdin stderr'); + } }); pythonProcess.on('close', (code) => { + if (streaming) { + sendToRenderer('stream-complete', { + streamId, + target, + success: code === 0 + }, 'runPythonScriptWithStdin close'); + } if (code === 0) { resolve(stdout); } else { @@ -749,6 +798,115 @@ ipcMain.handle('troubleshoot', async (event, question, history) => { } }); +ipcMain.handle('cases:assistant-query', async (event, question, history, context, options = {}) => { + const streamId = options.streamId || `cases-assistant-${Date.now()}`; + const target = options.target || 'cases-assistant'; + + try { + const payload = JSON.stringify({ + question: question, + history: history || [], + context: context || '', + }); + + return new Promise((resolve) => { + const proc = spawnPythonProcess('troubleshoot.py', ['--history', '-v']); + + proc.stdin.write(payload); + proc.stdin.end(); + + let stdout = ''; + let stderr = ''; + + proc.stdout.on('data', (data) => { + stdout += data.toString(); + }); + + proc.stderr.on('data', (data) => { + const text = data.toString(); + stderr += text; + + if (canSendToRenderer()) { + if (text.includes('[TOOL]') || text.includes('[DEBUG]') || text.includes('[INFO]')) { + const lines = text.split('\n'); + for (const line of lines) { + if (line.startsWith('[TOOL]')) { + sendToRenderer('tool-call', { + streamId, + target, + tool: line.replace('[TOOL]', '').trim() + }, 'cases assistant stderr tool'); + } else if (line.startsWith('[DEBUG]') || line.startsWith('[INFO]')) { + sendToRenderer('stream-output', { + streamId, + target, + text: line, + type: 'debug' + }, 'cases assistant stderr debug'); + } + } + } else if (text.includes('[STREAM]')) { + const streamStart = text.indexOf('[STREAM]'); + const afterStream = text.substring(streamStart + 8); + if (afterStream) { + sendToRenderer('stream-output', { + streamId, + target, + text: afterStream, + type: 'claude-stream' + }, 'cases assistant stderr stream-start'); + } + } else if (text && !text.startsWith('[')) { + sendToRenderer('stream-output', { + streamId, + target, + text: text, + type: 'claude-stream' + }, 'cases assistant stderr stream-cont'); + } + } + }); + + proc.on('close', (code) => { + if (canSendToRenderer()) { + sendToRenderer('stream-complete', { + streamId, + target, + success: code === 0 + }, 'cases assistant close'); + } + + if (code === 0) { + try { + const result = JSON.parse(stdout); + resolve({ + success: true, + response: result.response, + history: result.history, + streamId + }); + } catch (e) { + resolve({ success: true, response: stdout, history: [], streamId }); + } + } else { + const cleanError = stderr + .split('\n') + .filter(line => !line.startsWith('[TOOL]') && !line.startsWith('[DEBUG]')) + .join('\n') + .trim(); + resolve({ success: false, error: cleanError || 'Case investigator query failed', streamId }); + } + }); + + proc.on('error', (err) => { + resolve({ success: false, error: err.message, streamId }); + }); + }); + } catch (error) { + return { success: false, error: error.message, streamId }; + } +}); + // Generate visualization ipcMain.handle('generate-viz', async () => { try { @@ -1860,75 +2018,120 @@ ipcMain.handle('agents:stop-subsystem', async (event, subsystemId) => { // Investigation Cases IPC Handlers // ============================================ -ipcMain.handle('cases:list', async (event, filters = {}) => { +ipcMain.handle('cases:list', async (event, filters = {}, options = {}) => { const args = ['list']; if (filters.limit) args.push('--limit', String(filters.limit)); if (filters.status) args.push('--status', String(filters.status)); try { - const output = await runPythonScript('case_api.py', args); + const output = await runPythonScript('case_api.py', args, { + streaming: Boolean(options.streamId), + streamId: options.streamId || null, + target: options.target || 'default', + }); return parseJsonFromMixedOutput(output, { success: true, cases: [] }); } catch (error) { return { success: false, error: error.message, cases: [] }; } }); -ipcMain.handle('cases:get', async (event, caseId) => { +ipcMain.handle('cases:get', async (event, caseId, options = {}) => { try { - const output = await runPythonScript('case_api.py', ['get', '--case-id', String(caseId)]); + const output = await runPythonScript('case_api.py', ['get', '--case-id', String(caseId)], { + streaming: Boolean(options.streamId), + streamId: options.streamId || null, + target: options.target || 'default', + }); return parseJsonFromMixedOutput(output, { success: false, error: 'Invalid case response' }); } catch (error) { return { success: false, error: error.message }; } }); -ipcMain.handle('cases:create-from-event', async (event, eventPayload = {}) => { +ipcMain.handle('cases:create-from-event', async (event, eventPayload = {}, options = {}) => { try { - const output = await runPythonScriptWithStdin('case_api.py', ['create-from-event'], eventPayload); + const output = await runPythonScriptWithStdin('case_api.py', ['create-from-event'], eventPayload, { + streaming: Boolean(options.streamId), + streamId: options.streamId || null, + target: options.target || 'default', + }); return parseJsonFromMixedOutput(output, { success: false, error: 'Invalid case response' }); } catch (error) { return { success: false, error: error.message }; } }); -ipcMain.handle('cases:update', async (event, caseId, patch = {}) => { +ipcMain.handle('cases:update', async (event, caseId, patch = {}, options = {}) => { try { - const output = await runPythonScriptWithStdin('case_api.py', ['update', '--case-id', String(caseId)], patch); + const output = await runPythonScriptWithStdin('case_api.py', ['update', '--case-id', String(caseId)], patch, { + streaming: Boolean(options.streamId), + streamId: options.streamId || null, + target: options.target || 'default', + }); return parseJsonFromMixedOutput(output, { success: false, error: 'Invalid case response' }); } catch (error) { return { success: false, error: error.message }; } }); -ipcMain.handle('cases:generate-draft', async (event, caseId) => { +ipcMain.handle('cases:delete', async (event, caseId, options = {}) => { try { - const output = await runPythonScript('case_api.py', ['generate-draft', '--case-id', String(caseId)]); + const output = await runPythonScript('case_api.py', ['delete', '--case-id', String(caseId)], { + streaming: Boolean(options.streamId), + streamId: options.streamId || null, + target: options.target || 'default', + }); + return parseJsonFromMixedOutput(output, { success: false, error: 'Invalid delete response' }); + } catch (error) { + return { success: false, error: error.message }; + } +}); + +ipcMain.handle('cases:generate-draft', async (event, caseId, options = {}) => { + try { + const output = await runPythonScript('case_api.py', ['generate-draft', '--case-id', String(caseId)], { + streaming: Boolean(options.streamId), + streamId: options.streamId || null, + target: options.target || 'default', + }); return parseJsonFromMixedOutput(output, { success: false, error: 'Invalid draft response' }); } catch (error) { return { success: false, error: error.message }; } }); -ipcMain.handle('cases:approve-draft', async (event, caseId) => { +ipcMain.handle('cases:approve-draft', async (event, caseId, options = {}) => { try { - const output = await runPythonScript('case_api.py', ['approve-draft', '--case-id', String(caseId)]); + const output = await runPythonScript('case_api.py', ['approve-draft', '--case-id', String(caseId)], { + streaming: Boolean(options.streamId), + streamId: options.streamId || null, + target: options.target || 'default', + }); return parseJsonFromMixedOutput(output, { success: false, error: 'Invalid draft response' }); } catch (error) { return { success: false, error: error.message }; } }); -ipcMain.handle('cases:reject-draft', async (event, caseId) => { +ipcMain.handle('cases:reject-draft', async (event, caseId, options = {}) => { try { - const output = await runPythonScript('case_api.py', ['reject-draft', '--case-id', String(caseId)]); + const output = await runPythonScript('case_api.py', ['reject-draft', '--case-id', String(caseId)], { + streaming: Boolean(options.streamId), + streamId: options.streamId || null, + target: options.target || 'default', + }); return parseJsonFromMixedOutput(output, { success: false, error: 'Invalid draft response' }); } catch (error) { return { success: false, error: error.message }; } }); -ipcMain.handle('cases:generate-report', async (event, caseId) => { +ipcMain.handle('cases:generate-report', async (event, caseId, options = {}) => { try { - const output = await runPythonScript('case_api.py', ['generate-report', '--case-id', String(caseId)]); + const output = await runPythonScript('case_api.py', ['generate-report', '--case-id', String(caseId)], { + streaming: Boolean(options.streamId), + streamId: options.streamId || null, + target: options.target || 'default', + }); return parseJsonFromMixedOutput(output, { success: false, error: 'Invalid report response' }); } catch (error) { return { success: false, error: error.message }; diff --git a/electron-ui/preload.js b/electron-ui/preload.js index 0d930fd..303fb46 100644 --- a/electron-ui/preload.js +++ b/electron-ui/preload.js @@ -89,14 +89,16 @@ contextBridge.exposeInMainWorld('api', { agentsStopSubsystem: (subId) => ipcRenderer.invoke('agents:stop-subsystem', subId), // Investigation cases - casesList: (filters) => ipcRenderer.invoke('cases:list', filters), - casesGet: (caseId) => ipcRenderer.invoke('cases:get', caseId), - casesCreateFromEvent: (eventPayload) => ipcRenderer.invoke('cases:create-from-event', eventPayload), - casesUpdate: (caseId, patch) => ipcRenderer.invoke('cases:update', caseId, patch), - casesGenerateDraft: (caseId) => ipcRenderer.invoke('cases:generate-draft', caseId), - casesApproveDraft: (caseId) => ipcRenderer.invoke('cases:approve-draft', caseId), - casesRejectDraft: (caseId) => ipcRenderer.invoke('cases:reject-draft', caseId), - casesGenerateReport: (caseId) => ipcRenderer.invoke('cases:generate-report', caseId), + casesList: (filters, options) => ipcRenderer.invoke('cases:list', filters, options), + casesGet: (caseId, options) => ipcRenderer.invoke('cases:get', caseId, options), + casesCreateFromEvent: (eventPayload, options) => ipcRenderer.invoke('cases:create-from-event', eventPayload, options), + casesUpdate: (caseId, patch, options) => ipcRenderer.invoke('cases:update', caseId, patch, options), + casesDelete: (caseId, options) => ipcRenderer.invoke('cases:delete', caseId, options), + casesGenerateDraft: (caseId, options) => ipcRenderer.invoke('cases:generate-draft', caseId, options), + casesApproveDraft: (caseId, options) => ipcRenderer.invoke('cases:approve-draft', caseId, options), + casesRejectDraft: (caseId, options) => ipcRenderer.invoke('cases:reject-draft', caseId, options), + casesGenerateReport: (caseId, options) => ipcRenderer.invoke('cases:generate-report', caseId, options), + casesAssistantQuery: (question, history, context, options) => ipcRenderer.invoke('cases:assistant-query', question, history, context, options), casesSaveReport: (suggestedFilename, markdown) => ipcRenderer.invoke('cases:save-report', suggestedFilename, markdown), // Database connections diff --git a/electron-ui/renderer.js b/electron-ui/renderer.js index 7dd8912..64928eb 100644 --- a/electron-ui/renderer.js +++ b/electron-ui/renderer.js @@ -413,6 +413,7 @@ async function sendMessage() { // Set up tool call listener for this request (returns cleanup function) const cleanupToolCall = window.api.onToolCall((data) => { + if (data?.target === 'cases-assistant') return; const chip = document.createElement('span'); chip.className = 'tool-call-chip'; chip.innerHTML = `> ${data.tool}`; @@ -434,6 +435,7 @@ async function sendMessage() { }; const cleanupStream = window.api.onStreamOutput((data) => { + if (data?.target === 'cases-assistant') return; if (data.type === 'claude-stream' && data.text) { if (!streamingStarted) { streamingStarted = true; @@ -1769,6 +1771,7 @@ document.getElementById('btn-ingest-workbench').addEventListener('click', async // Listen for streaming output from Python scripts window.api.onStreamOutput((data) => { + if (['cases', 'cases-assistant'].includes(data?.target)) return; if (data.type === 'debug') { // Show debug lines in output panel with special styling appendOutput(`${data.text}\n`); @@ -1785,11 +1788,13 @@ window.api.onStreamOutput((data) => { // Listen for tool calls window.api.onToolCall((data) => { + if (['cases', 'cases-assistant'].includes(data?.target)) return; appendOutput(`[TOOL] ${data.tool}\n`); }); // Listen for stream completion window.api.onStreamComplete((data) => { + if (['cases', 'cases-assistant'].includes(data?.target)) return; if (data.success) { appendOutput('\n[OK] Operation complete!\n'); } @@ -3689,8 +3694,26 @@ const agentsState = { subsystemHistory: {}, agentStates: {}, pendingDeepAnalyze: new Set(), + eventActionStatus: {}, }; +function setAgentEventActionStatus(eventId, action, message, options = {}) { + if (!eventId) return; + agentsState.eventActionStatus[eventId] = { + action, + message: String(message || ''), + tone: options.tone || 'pending', + buttonLabel: options.buttonLabel || '', + }; + renderSubsystemHealthGrid(); +} + +function clearAgentEventActionStatus(eventId) { + if (!eventId || !agentsState.eventActionStatus[eventId]) return; + delete agentsState.eventActionStatus[eventId]; + renderSubsystemHealthGrid(); +} + function getAgentsElements() { return { btnStart: document.getElementById('btn-agents-start'), @@ -4057,14 +4080,6 @@ function renderSubsystemHealthGrid() { }); }); - container.querySelectorAll('.health-event-detail-actions .btn-ai-enrich').forEach((btn) => { - btn.addEventListener('click', (e) => { - e.stopPropagation(); - const eventId = btn.getAttribute('data-event-id'); - if (eventId) aiEnrichEvent(eventId, btn); - }); - }); - container.querySelectorAll('.health-event-detail-actions .btn-ack-event').forEach((btn) => { btn.addEventListener('click', (e) => { e.stopPropagation(); @@ -4131,9 +4146,15 @@ function renderInlineEventDetail(event) { const st = String(event.state || '').toLowerCase(); const ackLabel = st === 'acknowledged' ? 'Clear' : (st === 'cleared' ? 'Cleared' : 'Acknowledge'); const ackDisabled = st === 'cleared' ? ' disabled' : ''; - const isPending = agentsState.pendingDeepAnalyze.has(event.event_id); - const analyzeLabel = isPending ? 'Enriching…' : 'AI Enrich'; - const analyzeDisabled = isPending ? ' disabled' : ''; + const actionState = agentsState.eventActionStatus[event.event_id] || null; + const actionPending = actionState?.tone === 'pending'; + const createLabel = actionState?.action === 'create-case' + ? (actionState.buttonLabel || 'Creating...') + : 'Create Case'; + const createDisabled = actionPending ? ' disabled' : ''; + const actionStatusHtml = actionState?.message + ? `
${escapeHtml(actionState.message)}
` + : ''; return `
@@ -4150,11 +4171,11 @@ function renderInlineEventDetail(event) { ${checks.length ? `
Checks
    ${checks.map((x) => `
  • ${escapeHtml(String(x))}
  • `).join('')}
` : ''} ${safety.length ? `
Safety
    ${safety.map((x) => `
  • ${escapeHtml(String(x))}
  • `).join('')}
` : ''}
- - +
+ ${actionStatusHtml}
`; } @@ -4407,6 +4428,16 @@ const casesState = { selectedCaseId: null, currentCase: null, currentReport: null, + isLoadingList: false, + isLoadingDetail: false, + statusOverride: null, + actionState: null, + logStreamIds: new Set(), + logListenersReady: false, + assistantSessions: {}, + assistantListenersReady: false, + listRequestSeq: 0, + detailRequestSeq: 0, }; function getCasesElements() { @@ -4420,9 +4451,322 @@ function getCasesElements() { btnRefresh: document.getElementById('btn-cases-refresh'), btnGenerate: document.getElementById('btn-cases-generate-report'), btnSaveReport: document.getElementById('btn-cases-save-report'), + log: document.getElementById('cases-log'), + btnClearLog: document.getElementById('btn-clear-cases-log'), }; } +function appendCasesLog(text, clear = false) { + const log = getCasesElements().log; + if (!log) return; + if (clear) log.textContent = ''; + log.textContent += text; + log.scrollTop = log.scrollHeight; +} + +function createCaseStreamContext(actionLabel, target = 'cases') { + const streamId = `cases-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + casesState.logStreamIds.add(streamId); + appendCasesLog(`\n[${actionLabel}] ${new Date().toLocaleTimeString()}\n`); + return { + streamId, + target, + }; +} + +function completeCaseStream(streamId, success) { + if (!streamId) return; + if (!casesState.logStreamIds.has(streamId)) return; + casesState.logStreamIds.delete(streamId); + appendCasesLog(success ? '[OK] Case action complete.\n' : '[ERROR] Case action failed.\n'); +} + +function appendCasesUiDebugLog(message) { + appendCasesLog(`[UI DEBUG] ${message}\n`); +} + +function ensureCasesLogListeners() { + if (casesState.logListenersReady) return; + casesState.logListenersReady = true; + + window.api.onStreamOutput((data) => { + if (!['cases', 'cases-assistant'].includes(data?.target) || !data.streamId || !casesState.logStreamIds.has(data.streamId)) return; + if (data.type === 'claude-stream') return; + if (data.type === 'stderr') { + const text = String(data.text || ''); + if (!text.includes('GqlStatusObject') && !text.includes('Received notification')) { + appendCasesLog(text.endsWith('\n') ? text : `${text}\n`); + } + return; + } + if (data.text) appendCasesLog(`${data.text}\n`); + }); + + window.api.onToolCall((data) => { + if (!['cases', 'cases-assistant'].includes(data?.target) || !data.streamId || !casesState.logStreamIds.has(data.streamId)) return; + appendCasesLog(`[TOOL] ${data.tool}\n`); + }); + + window.api.onStreamComplete((data) => { + if (!['cases', 'cases-assistant'].includes(data?.target) || !data.streamId) return; + completeCaseStream(data.streamId, Boolean(data.success)); + }); +} + +function setCasesStatusOverride(chip, text, tone = 'pending') { + casesState.statusOverride = { + chip: String(chip || 'Loading'), + text: String(text || ''), + tone, + }; + updateCasesToolbar(); +} + +function clearCasesStatusOverride() { + if (!casesState.statusOverride) return; + casesState.statusOverride = null; + updateCasesToolbar(); +} + +function setCaseActionState(action, message, options = {}) { + casesState.actionState = { + action, + message: String(message || ''), + tone: options.tone || 'pending', + buttonLabel: options.buttonLabel || '', + }; + if (options.statusChip || options.statusText) { + setCasesStatusOverride(options.statusChip || 'Working', options.statusText || message || '', options.tone || 'pending'); + } + updateCasesToolbar(); + renderCaseDetail(); +} + +function clearCaseActionState() { + if (!casesState.actionState) return; + casesState.actionState = null; + updateCasesToolbar(); + renderCaseDetail(); +} + +function getCaseAssistantSession(caseId) { + if (!caseId) return { history: [], turns: [] }; + if (!casesState.assistantSessions[caseId]) { + casesState.assistantSessions[caseId] = { + history: [], + turns: [], + }; + } + return casesState.assistantSessions[caseId]; +} + +function buildCaseAssistantContext(caseData) { + if (!caseData) return ''; + const event = caseData.event || {}; + const tags = Array.isArray(caseData.tags) ? caseData.tags : []; + const equipment = Array.isArray(caseData.equipment) ? caseData.equipment : []; + return [ + `Case ID: ${caseData.case_id || ''}`, + `Title: ${caseData.title || ''}`, + `Status: ${caseData.status || ''}`, + `Severity: ${caseData.severity || ''}`, + `Subsystem: ${caseData.subsystem_name || caseData.subsystem_id || ''}`, + `Source tag: ${caseData.source_tag || event.source_tag || ''}`, + `Event summary: ${event.summary || caseData.summary || ''}`, + `Operator context: ${caseData.operator_context || ''}`, + `Investigator notes: ${caseData.notes || ''}`, + `Resolution notes: ${caseData.resolution_notes || ''}`, + `Linked tags: ${tags.join(', ')}`, + `Linked equipment: ${equipment.join(', ')}`, + ].filter(Boolean).join('\n'); +} + +function renderCaseAssistantTranscript(caseData) { + const session = getCaseAssistantSession(caseData?.case_id); + if (!session.turns.length) { + return '
Ask the investigator assistant to inspect this case using the same tools as the troubleshooting agent.
'; + } + return session.turns.map((turn) => { + const toolCallsHtml = turn.toolCalls?.length + ? `
${turn.toolCalls.map((tool) => `${escapeHtml(tool)}`).join('')}
` + : ''; + const responseText = turn.error || turn.response || (turn.pending ? 'Working...' : ''); + const responseClass = turn.error ? ' error' : ''; + return ` +
+
${escapeHtml(turn.question || '')}
+ ${toolCallsHtml} +
${escapeHtml(responseText || '')}
+
+ `; + }).join(''); +} + +function summarizeAssistantResponse(text, maxLen = 320) { + const normalized = String(text || '').replace(/\s+/g, ' ').trim(); + if (!normalized) return ''; + if (normalized.length <= maxLen) return normalized; + const truncated = normalized.slice(0, maxLen); + const sentenceBreak = Math.max( + truncated.lastIndexOf('. '), + truncated.lastIndexOf('; '), + truncated.lastIndexOf('! '), + truncated.lastIndexOf('? ') + ); + if (sentenceBreak > Math.floor(maxLen * 0.5)) { + return `${truncated.slice(0, sentenceBreak + 1).trim()}`; + } + return `${truncated.trim()}...`; +} + +function buildCaseAssistantNarrativeSummary(caseId) { + const session = getCaseAssistantSession(caseId); + const completedTurns = (session.turns || []).filter((turn) => !turn.pending && !turn.error && (turn.response || '').trim()); + if (!completedTurns.length) return ''; + + const uniqueTools = [...new Set( + completedTurns.flatMap((turn) => Array.isArray(turn.toolCalls) ? turn.toolCalls : []) + )]; + + const lines = ['Investigator assistant summary:']; + completedTurns.forEach((turn, index) => { + lines.push(`${index + 1}. ${turn.question}`); + lines.push(` Findings: ${summarizeAssistantResponse(turn.response)}`); + if (turn.toolCalls?.length) { + lines.push(` Tools used: ${[...new Set(turn.toolCalls)].join(', ')}`); + } + }); + + if (uniqueTools.length) { + lines.push(`Overall tools referenced: ${uniqueTools.join(', ')}`); + } + + return lines.join('\n'); +} + +function appendAssistantSummaryToNarrative() { + const caseId = casesState.currentCase?.case_id; + if (!caseId) return; + const explanationInput = document.getElementById('case-explanation-input'); + if (!explanationInput) return; + const summary = buildCaseAssistantNarrativeSummary(caseId); + if (!summary) return; + const existing = explanationInput.value.trim(); + explanationInput.value = existing ? `${existing}\n\n${summary}` : summary; + explanationInput.focus(); + explanationInput.selectionStart = explanationInput.selectionEnd = explanationInput.value.length; +} + +function getCaseAssistantTranscriptElement() { + return document.querySelector('.case-assistant-transcript'); +} + +function escapeForAttribute(value) { + return CSS.escape(String(value || '')); +} + +function ensureCaseAssistantTurnDom(turn) { + const transcript = getCaseAssistantTranscriptElement(); + if (!transcript || !turn?.streamId) return null; + const selector = `[data-case-assistant-turn="${escapeForAttribute(turn.streamId)}"]`; + let turnEl = transcript.querySelector(selector); + if (turnEl) return turnEl; + + const userText = escapeHtml(turn.question || ''); + turnEl = document.createElement('div'); + turnEl.className = 'case-assistant-turn'; + turnEl.setAttribute('data-case-assistant-turn', turn.streamId); + turnEl.innerHTML = ` +
${userText}
+
${escapeHtml(turn.pending ? 'Working...' : (turn.response || ''))}
+ `; + transcript.appendChild(turnEl); + transcript.scrollTop = transcript.scrollHeight; + return turnEl; +} + +function appendCaseAssistantToolCallDom(streamId, tool) { + const turnEl = document.querySelector(`[data-case-assistant-turn="${escapeForAttribute(streamId)}"]`); + if (!turnEl) return; + let toolsEl = turnEl.querySelector(`[data-case-assistant-tools="${escapeForAttribute(streamId)}"]`); + if (!toolsEl) { + toolsEl = document.createElement('div'); + toolsEl.className = 'case-assistant-tool-calls'; + toolsEl.setAttribute('data-case-assistant-tools', streamId); + const responseEl = turnEl.querySelector(`[data-case-assistant-response="${escapeForAttribute(streamId)}"]`); + if (responseEl) { + turnEl.insertBefore(toolsEl, responseEl); + } else { + turnEl.appendChild(toolsEl); + } + } + const chip = document.createElement('span'); + chip.className = 'tool-call-chip'; + chip.textContent = String(tool || 'tool'); + toolsEl.appendChild(chip); + const transcript = getCaseAssistantTranscriptElement(); + if (transcript) transcript.scrollTop = transcript.scrollHeight; +} + +function appendCaseAssistantResponseChunkDom(streamId, text) { + const responseEl = document.querySelector(`[data-case-assistant-response="${escapeForAttribute(streamId)}"]`); + if (!responseEl) return; + if (responseEl.textContent === 'Working...') responseEl.textContent = ''; + responseEl.textContent += text || ''; + responseEl.classList.remove('error'); + const transcript = getCaseAssistantTranscriptElement(); + if (transcript) transcript.scrollTop = transcript.scrollHeight; +} + +function finalizeCaseAssistantTurnDom(streamId, options = {}) { + const responseEl = document.querySelector(`[data-case-assistant-response="${escapeForAttribute(streamId)}"]`); + if (!responseEl) return; + if (options.error) { + responseEl.textContent = options.error; + responseEl.classList.add('error'); + } else if (!responseEl.textContent.trim()) { + responseEl.textContent = 'Done.'; + } +} + +function ensureCaseAssistantListeners() { + if (casesState.assistantListenersReady) return; + casesState.assistantListenersReady = true; + + const findTurnByStreamId = (streamId) => { + for (const session of Object.values(casesState.assistantSessions)) { + const turn = session.turns.find((item) => item.streamId === streamId); + if (turn) return turn; + } + return null; + }; + + window.api.onToolCall((data) => { + if (data?.target !== 'cases-assistant' || !data.streamId) return; + const turn = findTurnByStreamId(data.streamId); + if (!turn) return; + turn.toolCalls = turn.toolCalls || []; + turn.toolCalls.push(String(data.tool || 'tool')); + appendCaseAssistantToolCallDom(data.streamId, data.tool); + }); + + window.api.onStreamOutput((data) => { + if (data?.target !== 'cases-assistant' || !data.streamId || data.type !== 'claude-stream') return; + const turn = findTurnByStreamId(data.streamId); + if (!turn) return; + turn.response = `${turn.response || ''}${data.text || ''}`; + appendCaseAssistantResponseChunkDom(data.streamId, data.text || ''); + }); + + window.api.onStreamComplete((data) => { + if (data?.target !== 'cases-assistant' || !data.streamId) return; + const turn = findTurnByStreamId(data.streamId); + if (!turn) return; + turn.pending = false; + finalizeCaseAssistantTurnDom(data.streamId); + }); +} + function normalizeCaseStatus(status) { const value = String(status || 'open').toLowerCase(); if (value === 'in review') return 'in_review'; @@ -4465,33 +4809,71 @@ function parseCaseJsonObject(value) { function updateCasesToolbar() { const el = getCasesElements(); const selected = casesState.currentCase; + const actionState = casesState.actionState; + const actionPending = actionState?.tone === 'pending'; if (el.countLabel) { const count = casesState.cases.length; el.countLabel.textContent = `${count} case${count === 1 ? '' : 's'}`; } - if (el.btnGenerate) el.btnGenerate.disabled = !selected; - if (el.btnSaveReport) el.btnSaveReport.disabled = !casesState.currentReport; + if (el.btnRefresh) { + el.btnRefresh.disabled = casesState.isLoadingList || casesState.isLoadingDetail || actionPending; + el.btnRefresh.textContent = casesState.isLoadingList ? 'Refreshing...' : 'Refresh'; + } + if (el.btnGenerate) { + el.btnGenerate.disabled = !selected || casesState.isLoadingList || casesState.isLoadingDetail || actionPending; + el.btnGenerate.textContent = actionState?.action === 'generate-report' + ? (actionState.buttonLabel || 'Generating...') + : 'Generate Report'; + } + if (el.btnSaveReport) { + el.btnSaveReport.disabled = !casesState.currentReport || casesState.isLoadingList || casesState.isLoadingDetail || actionPending; + el.btnSaveReport.textContent = actionState?.action === 'save-report' + ? (actionState.buttonLabel || 'Saving...') + : 'Save Report'; + } + + let chipText = 'Cases'; + let statusText = 'Select a case or create one from the Agents tab.'; + let tone = ''; + + if (casesState.statusOverride) { + chipText = casesState.statusOverride.chip; + statusText = casesState.statusOverride.text; + tone = casesState.statusOverride.tone || ''; + } else if (casesState.isLoadingDetail && casesState.selectedCaseId) { + chipText = 'Loading'; + statusText = `Loading ${casesState.selectedCaseId}...`; + tone = 'pending'; + } else if (casesState.isLoadingList) { + chipText = 'Loading'; + statusText = 'Loading investigations...'; + tone = 'pending'; + } else if (selected) { + chipText = String(selected.status || 'open'); + statusText = `Selected ${selected.case_id || ''}`; + if (String(selected.status || '').toLowerCase() === 'closed') { + tone = 'running'; + } + } + if (el.statusChip) { el.statusChip.className = 'status-chip'; - if (selected) { - el.statusChip.textContent = String(selected.status || 'open'); - if (String(selected.status || '').toLowerCase() === 'closed') { - el.statusChip.classList.add('running'); - } - } else { - el.statusChip.textContent = 'Cases'; - } + if (tone) el.statusChip.classList.add(tone); + el.statusChip.textContent = chipText; } if (el.statusText) { - el.statusText.textContent = selected - ? `Selected ${selected.case_id || ''}` - : 'Select a case or create one from the Agents tab.'; + el.statusText.textContent = statusText; } } function renderCaseList() { const el = getCasesElements(); if (!el.list) return; + if (casesState.isLoadingList && !casesState.cases.length) { + el.list.innerHTML = '
Loading investigations...
'; + updateCasesToolbar(); + return; + } if (!casesState.cases.length) { el.list.innerHTML = '
No investigation cases yet. Promote an anomaly from the Agents tab to start one.
'; updateCasesToolbar(); @@ -4529,6 +4911,12 @@ function renderCaseDetail() { const el = getCasesElements(); if (!el.detail) return; + if (casesState.isLoadingDetail) { + el.detail.innerHTML = '
Loading case details...
'; + updateCasesToolbar(); + return; + } + if (!casesState.currentCase) { el.detail.innerHTML = '
Choose a case to inspect the event context, capture notes, and generate a report.
'; updateCasesToolbar(); @@ -4546,6 +4934,10 @@ function renderCaseDetail() { const equipment = Array.isArray(item.equipment) ? item.equipment : []; const draftStatus = String(item.draft_status || '').toLowerCase(); const hasDraft = Boolean(item.draft_summary || item.draft_explanation || draftCauses.length || draftChecks.length); + const actionState = casesState.actionState; + const actionPending = actionState?.tone === 'pending'; + const assistantSession = getCaseAssistantSession(item.case_id); + const hasAssistantTranscript = assistantSession.turns.some((turn) => !turn.pending && ((turn.response || '').trim() || (turn.error || '').trim())); const dependencySummary = [ ...(draftContext.upstream_views || []).map((name) => `Upstream: ${name}`), ...(draftContext.downstream_views || []).map((name) => `Downstream: ${name}`), @@ -4553,6 +4945,17 @@ function renderCaseDetail() { const reportHtml = casesState.currentReport ? `
${escapeHtml(casesState.currentReport.markdown || '')}
` : '
Generate a report to create a shareable post-incident narrative.
'; + const saveLabel = actionState?.action === 'save-case' ? (actionState.buttonLabel || 'Saving...') : 'Save Case'; + const closeLabel = actionState?.action === 'close-case' ? (actionState.buttonLabel || 'Closing...') : 'Close Case'; + const deleteLabel = actionState?.action === 'delete-case' ? (actionState.buttonLabel || 'Deleting...') : 'Delete Case'; + const draftLabel = actionState?.action === 'generate-draft' ? (actionState.buttonLabel || 'Generating...') : 'AI Enrich Draft'; + const reportLabel = actionState?.action === 'generate-report' ? (actionState.buttonLabel || 'Generating...') : 'Generate Report'; + const approveLabel = actionState?.action === 'approve-draft' ? (actionState.buttonLabel || 'Approving...') : 'Approve Draft'; + const rejectLabel = actionState?.action === 'reject-draft' ? (actionState.buttonLabel || 'Rejecting...') : 'Reject Draft'; + const regenerateLabel = actionState?.action === 'generate-draft' ? (actionState.buttonLabel || 'Generating...') : 'Regenerate Draft'; + const actionStatusHtml = actionState?.message + ? `
${escapeHtml(actionState.message)}
` + : ''; el.detail.innerHTML = `
@@ -4595,6 +4998,10 @@ function renderCaseDetail() {
+
+ + +
@@ -4606,10 +5013,13 @@ function renderCaseDetail() {
- - - + + + + +
+ ${actionStatusHtml} ${hasDraft ? `
@@ -4631,9 +5041,9 @@ function renderCaseDetail() {
- - - + + +
` : ''} @@ -4661,36 +5071,158 @@ function renderCaseDetail() {

Generated Report

${reportHtml} + +
+

Investigator Assistant

+
${renderCaseAssistantTranscript(item)}
+
+ + + + +
+
`; document.getElementById('btn-case-save')?.addEventListener('click', saveSelectedCase); + document.getElementById('btn-case-close')?.addEventListener('click', closeSelectedCase); document.getElementById('btn-case-generate-draft')?.addEventListener('click', generateSelectedCaseDraft); document.getElementById('btn-case-generate-report')?.addEventListener('click', generateSelectedCaseReport); + document.getElementById('btn-case-delete')?.addEventListener('click', deleteSelectedCase); document.getElementById('btn-case-approve-draft')?.addEventListener('click', approveSelectedCaseDraft); document.getElementById('btn-case-reject-draft')?.addEventListener('click', rejectSelectedCaseDraft); document.getElementById('btn-case-regenerate-draft')?.addEventListener('click', generateSelectedCaseDraft); + document.getElementById('btn-case-assistant-send')?.addEventListener('click', sendCaseAssistantQuery); + document.getElementById('btn-case-assistant-summary')?.addEventListener('click', appendAssistantSummaryToNarrative); + document.getElementById('btn-case-assistant-input')?.addEventListener('keydown', (e) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + sendCaseAssistantQuery(); + } + }); + document.getElementById('btn-case-assistant-clear')?.addEventListener('click', clearCaseAssistantSession); + + const caseDetailRoot = el.detail; + caseDetailRoot.querySelectorAll('input, textarea, select').forEach((field) => { + field.addEventListener('pointerdown', (event) => { + const hit = document.elementFromPoint(event.clientX, event.clientY); + appendCasesUiDebugLog( + `pointerdown target=${field.id || field.tagName} hit=${hit?.id || hit?.className || hit?.tagName || 'unknown'} actionPending=${casesState.actionState?.tone === 'pending'}` + ); + }); + field.addEventListener('focusin', () => { + appendCasesUiDebugLog(`focusin target=${field.id || field.tagName}`); + }); + }); + caseDetailRoot.addEventListener('pointerdown', (event) => { + const hit = document.elementFromPoint(event.clientX, event.clientY); + if (!hit) return; + const targetLabel = event.target?.id || event.target?.className || event.target?.tagName || 'unknown'; + const hitLabel = hit.id || hit.className || hit.tagName || 'unknown'; + if (targetLabel !== hitLabel) { + appendCasesUiDebugLog(`detail pointerdown target=${targetLabel} hit=${hitLabel}`); + } + }); updateCasesToolbar(); } +function clearCaseAssistantSession() { + const caseId = casesState.currentCase?.case_id; + if (!caseId) return; + casesState.assistantSessions[caseId] = { + history: [], + turns: [], + }; + renderCaseDetail(); +} + +async function sendCaseAssistantQuery() { + const caseId = casesState.currentCase?.case_id; + if (!caseId) return; + const input = document.getElementById('case-assistant-input'); + const question = input?.value?.trim() || ''; + if (!question) return; + + const session = getCaseAssistantSession(caseId); + const stream = createCaseStreamContext(`INVESTIGATOR QUERY ${caseId}`, 'cases-assistant'); + const turn = { + streamId: stream.streamId, + question, + response: '', + toolCalls: [], + pending: true, + error: '', + }; + session.turns.push(turn); + if (input) input.value = ''; + renderCaseDetail(); + ensureCaseAssistantTurnDom(turn); + + const result = await window.api.casesAssistantQuery( + question, + session.history, + buildCaseAssistantContext(casesState.currentCase), + stream, + ); + + turn.pending = false; + if (!result.success) { + turn.error = result.error || 'Investigator assistant query failed'; + finalizeCaseAssistantTurnDom(stream.streamId, { error: turn.error }); + return; + } + + session.history = Array.isArray(result.history) ? result.history : session.history; + turn.response = result.response || turn.response || ''; + finalizeCaseAssistantTurnDom(stream.streamId); +} + async function loadCaseDetails(caseId) { - const result = await window.api.casesGet(caseId); - if (!result.success || !result.case) return; + const requestSeq = ++casesState.detailRequestSeq; + const stream = createCaseStreamContext(`LOAD CASE ${caseId}`); + casesState.selectedCaseId = caseId; + casesState.currentCase = null; + casesState.currentReport = null; + casesState.isLoadingDetail = true; + renderCaseList(); + renderCaseDetail(); + const result = await window.api.casesGet(caseId, stream); + if (requestSeq !== casesState.detailRequestSeq) return; + casesState.isLoadingDetail = false; + if (!result.success || !result.case) { + completeCaseStream(stream.streamId, false); + setCasesStatusOverride('Error', result.error || `Failed to load ${caseId}`, 'error'); + renderCaseDetail(); + return; + } casesState.selectedCaseId = caseId; casesState.currentCase = result.case; casesState.currentReport = null; + clearCasesStatusOverride(); renderCaseList(); renderCaseDetail(); } async function loadCases(preferredCaseId = null) { const el = getCasesElements(); + const requestSeq = ++casesState.listRequestSeq; + const stream = createCaseStreamContext('LOAD CASE LIST'); + casesState.isLoadingList = true; + renderCaseList(); const result = await window.api.casesList({ limit: 100, status: el.filterStatus?.value || undefined, - }); + }, stream); + if (requestSeq !== casesState.listRequestSeq) return; + casesState.isLoadingList = false; if (!result.success) { - if (el.list) el.list.innerHTML = `
Failed to load cases: ${escapeHtml(result.error || 'Unknown error')}
`; + completeCaseStream(stream.streamId, false); + setCasesStatusOverride('Error', result.error || 'Failed to load investigations', 'error'); + if (el.list && !casesState.cases.length) { + el.list.innerHTML = `
Failed to load cases: ${escapeHtml(result.error || 'Unknown error')}
`; + } + updateCasesToolbar(); return; } @@ -4706,115 +5238,318 @@ async function loadCases(preferredCaseId = null) { casesState.selectedCaseId = null; casesState.currentCase = null; casesState.currentReport = null; + clearCasesStatusOverride(); renderCaseDetail(); } async function createCaseFromAgentEvent(eventId, btnEl) { const event = agentsState.events.find((item) => item.event_id === eventId); if (!event) return; - const originalText = btnEl?.textContent; - if (btnEl) { - btnEl.disabled = true; - btnEl.textContent = 'Opening…'; + const stream = createCaseStreamContext(`CREATE CASE ${eventId}`); + setAgentEventActionStatus(eventId, 'create-case', 'Creating investigation case...', { + buttonLabel: 'Creating...', + }); + casesState.selectedCaseId = null; + casesState.currentCase = null; + casesState.currentReport = null; + casesState.isLoadingDetail = true; + activateTab('cases'); + if (!casesState.initialized) initCasesTab(); + renderCaseDetail(); + setCasesStatusOverride('Opening', 'Creating investigation case...', 'pending'); + const result = await window.api.casesCreateFromEvent(event, stream); + if (!result.success || !result.case) { + completeCaseStream(stream.streamId, false); + casesState.isLoadingDetail = false; + const errorText = result.error || 'Failed to create investigation case'; + setAgentEventActionStatus(eventId, 'create-case', errorText, { + tone: 'error', + buttonLabel: 'Retry', + }); + setCasesStatusOverride('Error', errorText, 'error'); + return; } - try { - const result = await window.api.casesCreateFromEvent(event); - if (!result.success || !result.case) return; - casesState.selectedCaseId = result.case.case_id; - casesState.currentCase = result.case; - casesState.currentReport = null; - activateTab('cases'); - setTimeout(initCasesTab, 50); - await loadCases(result.case.case_id); - } finally { - if (btnEl) { - btnEl.disabled = false; - btnEl.textContent = originalText || 'Investigate'; - } + setAgentEventActionStatus(eventId, 'create-case', 'Opening case workspace...', { + buttonLabel: 'Opening...', + }); + setCasesStatusOverride('Opening', `Opening ${result.case.case_id || 'new case'}...`, 'pending'); + casesState.selectedCaseId = result.case.case_id; + casesState.currentCase = null; + casesState.currentReport = null; + casesState.isLoadingDetail = false; + await loadCases(result.case.case_id); + const draftStream = createCaseStreamContext(`AUTO AI ENRICH ${result.case.case_id}`); + setCaseActionState('generate-draft', 'Generating AI draft for the new case...', { + buttonLabel: 'Generating...', + statusChip: 'Working', + statusText: 'Generating AI draft for the new case...', + }); + const draftResult = await window.api.casesGenerateDraft(result.case.case_id, draftStream); + if (!draftResult.success || !draftResult.case) { + completeCaseStream(draftStream.streamId, false); + setCaseActionState('generate-draft', draftResult.error || 'Failed to generate AI draft', { + tone: 'error', + buttonLabel: 'Retry', + statusChip: 'Error', + statusText: draftResult.error || 'Failed to generate AI draft', + }); + clearAgentEventActionStatus(eventId); + return; } + casesState.currentCase = draftResult.case; + casesState.selectedCaseId = draftResult.case.case_id; + await loadCases(draftResult.case.case_id); + clearCaseActionState(); + clearAgentEventActionStatus(eventId); } async function aiEnrichEvent(eventId, btnEl) { const event = agentsState.events.find((item) => item.event_id === eventId); if (!event) return; - const originalText = btnEl?.textContent; + const createStream = createCaseStreamContext(`AI ENRICH CREATE CASE ${eventId}`); agentsState.pendingDeepAnalyze.add(eventId); + let clearActionStatusOnExit = true; + setAgentEventActionStatus(eventId, 'ai-enrich', 'Creating case for AI draft...', { + buttonLabel: 'Creating...', + }); + setCasesStatusOverride('Opening', 'Creating case for AI draft...', 'pending'); renderSubsystemHealthGrid(); - if (btnEl) { - btnEl.disabled = true; - btnEl.textContent = 'Enriching…'; - } try { - const caseResult = await window.api.casesCreateFromEvent(event); - if (!caseResult.success || !caseResult.case) return; - const draftResult = await window.api.casesGenerateDraft(caseResult.case.case_id); + const caseResult = await window.api.casesCreateFromEvent(event, createStream); + if (!caseResult.success || !caseResult.case) { + completeCaseStream(createStream.streamId, false); + const errorText = caseResult.error || 'Failed to create case for AI draft'; + setAgentEventActionStatus(eventId, 'ai-enrich', errorText, { + tone: 'error', + buttonLabel: 'Retry', + }); + setCasesStatusOverride('Error', errorText, 'error'); + clearActionStatusOnExit = false; + return; + } + setAgentEventActionStatus(eventId, 'ai-enrich', 'Generating AI draft...', { + buttonLabel: 'Generating...', + }); + setCasesStatusOverride('Opening', 'Generating AI draft...', 'pending'); + const draftStream = createCaseStreamContext(`AI ENRICH DRAFT ${caseResult.case.case_id}`); + const draftResult = await window.api.casesGenerateDraft(caseResult.case.case_id, draftStream); + if (!draftResult.success) { + completeCaseStream(draftStream.streamId, false); + setAgentEventActionStatus(eventId, 'ai-enrich', 'AI draft failed. Opening raw case instead...', { + tone: 'error', + buttonLabel: 'Retry', + }); + setCasesStatusOverride('Opening', 'AI draft failed, opening raw case...', 'pending'); + } else { + setAgentEventActionStatus(eventId, 'ai-enrich', 'Opening AI draft in Cases...', { + buttonLabel: 'Opening...', + }); + setCasesStatusOverride('Opening', 'Opening AI draft in Cases...', 'pending'); + } casesState.selectedCaseId = caseResult.case.case_id; casesState.currentCase = draftResult.case || caseResult.case; casesState.currentReport = null; activateTab('cases'); - setTimeout(initCasesTab, 50); + if (!casesState.initialized) initCasesTab(); await loadCases(caseResult.case.case_id); } finally { agentsState.pendingDeepAnalyze.delete(eventId); + if (clearActionStatusOnExit) clearAgentEventActionStatus(eventId); renderSubsystemHealthGrid(); - if (btnEl) { - btnEl.disabled = false; - btnEl.textContent = originalText || 'AI Enrich'; - } } } async function generateSelectedCaseDraft() { if (!casesState.currentCase?.case_id) return; - const result = await window.api.casesGenerateDraft(casesState.currentCase.case_id); - if (!result.success || !result.case) return; + const stream = createCaseStreamContext(`GENERATE DRAFT ${casesState.currentCase.case_id}`); + setCaseActionState('generate-draft', 'Generating AI draft for this case...', { + buttonLabel: 'Generating...', + statusChip: 'Working', + statusText: 'Generating AI draft...', + }); + const result = await window.api.casesGenerateDraft(casesState.currentCase.case_id, stream); + if (!result.success || !result.case) { + completeCaseStream(stream.streamId, false); + setCaseActionState('generate-draft', result.error || 'Failed to generate AI draft', { + tone: 'error', + buttonLabel: 'Retry', + statusChip: 'Error', + statusText: result.error || 'Failed to generate AI draft', + }); + return; + } casesState.currentCase = result.case; casesState.selectedCaseId = result.case.case_id; casesState.currentReport = null; await loadCases(result.case.case_id); + clearCaseActionState(); } async function approveSelectedCaseDraft() { if (!casesState.currentCase?.case_id) return; - const result = await window.api.casesApproveDraft(casesState.currentCase.case_id); - if (!result.success || !result.case) return; + const stream = createCaseStreamContext(`APPROVE DRAFT ${casesState.currentCase.case_id}`); + setCaseActionState('approve-draft', 'Approving AI draft and merging it into the case...', { + buttonLabel: 'Approving...', + statusChip: 'Working', + statusText: 'Approving AI draft...', + }); + const result = await window.api.casesApproveDraft(casesState.currentCase.case_id, stream); + if (!result.success || !result.case) { + completeCaseStream(stream.streamId, false); + setCaseActionState('approve-draft', result.error || 'Failed to approve AI draft', { + tone: 'error', + buttonLabel: 'Retry', + statusChip: 'Error', + statusText: result.error || 'Failed to approve AI draft', + }); + return; + } casesState.currentCase = result.case; casesState.selectedCaseId = result.case.case_id; await loadCases(result.case.case_id); + clearCaseActionState(); } async function rejectSelectedCaseDraft() { if (!casesState.currentCase?.case_id) return; - const result = await window.api.casesRejectDraft(casesState.currentCase.case_id); - if (!result.success || !result.case) return; + const stream = createCaseStreamContext(`REJECT DRAFT ${casesState.currentCase.case_id}`); + setCaseActionState('reject-draft', 'Rejecting AI draft...', { + buttonLabel: 'Rejecting...', + statusChip: 'Working', + statusText: 'Rejecting AI draft...', + }); + const result = await window.api.casesRejectDraft(casesState.currentCase.case_id, stream); + if (!result.success || !result.case) { + completeCaseStream(stream.streamId, false); + setCaseActionState('reject-draft', result.error || 'Failed to reject AI draft', { + tone: 'error', + buttonLabel: 'Retry', + statusChip: 'Error', + statusText: result.error || 'Failed to reject AI draft', + }); + return; + } casesState.currentCase = result.case; casesState.selectedCaseId = result.case.case_id; await loadCases(result.case.case_id); + clearCaseActionState(); } async function saveSelectedCase() { if (!casesState.currentCase?.case_id) return; + const stream = createCaseStreamContext(`SAVE CASE ${casesState.currentCase.case_id}`); + setCaseActionState('save-case', 'Saving case updates...', { + buttonLabel: 'Saving...', + statusChip: 'Working', + statusText: 'Saving case updates...', + }); const patch = { status: document.getElementById('case-status-input')?.value || 'open', owner: document.getElementById('case-owner-input')?.value || '', disposition: document.getElementById('case-disposition-input')?.value || '', summary: document.getElementById('case-summary-input')?.value || '', explanation: document.getElementById('case-explanation-input')?.value || '', + operator_context: document.getElementById('case-operator-context-input')?.value || '', notes: document.getElementById('case-notes-input')?.value || '', resolution_notes: document.getElementById('case-resolution-input')?.value || '', }; - const result = await window.api.casesUpdate(casesState.currentCase.case_id, patch); - if (!result.success || !result.case) return; + const result = await window.api.casesUpdate(casesState.currentCase.case_id, patch, stream); + if (!result.success || !result.case) { + completeCaseStream(stream.streamId, false); + setCaseActionState('save-case', result.error || 'Failed to save case', { + tone: 'error', + buttonLabel: 'Retry', + statusChip: 'Error', + statusText: result.error || 'Failed to save case', + }); + return; + } + casesState.currentCase = result.case; + casesState.selectedCaseId = result.case.case_id; + casesState.currentReport = null; + await loadCases(result.case.case_id); + clearCaseActionState(); +} + +async function closeSelectedCase() { + if (!casesState.currentCase?.case_id) return; + const caseId = casesState.currentCase.case_id; + const stream = createCaseStreamContext(`CLOSE CASE ${caseId}`); + setCaseActionState('close-case', 'Closing case...', { + buttonLabel: 'Closing...', + statusChip: 'Working', + statusText: 'Closing case...', + }); + const result = await window.api.casesUpdate(caseId, { status: 'closed' }, stream); + if (!result.success || !result.case) { + completeCaseStream(stream.streamId, false); + setCaseActionState('close-case', result.error || 'Failed to close case', { + tone: 'error', + buttonLabel: 'Retry', + statusChip: 'Error', + statusText: result.error || 'Failed to close case', + }); + return; + } casesState.currentCase = result.case; casesState.selectedCaseId = result.case.case_id; casesState.currentReport = null; await loadCases(result.case.case_id); + clearCaseActionState(); +} + +async function deleteSelectedCase() { + if (!casesState.currentCase?.case_id) return; + const caseId = casesState.currentCase.case_id; + const confirmed = window.confirm(`Delete case ${caseId}? This cannot be undone.`); + if (!confirmed) return; + + const stream = createCaseStreamContext(`DELETE CASE ${caseId}`); + setCaseActionState('delete-case', 'Deleting case...', { + buttonLabel: 'Deleting...', + statusChip: 'Working', + statusText: 'Deleting case...', + }); + const result = await window.api.casesDelete(caseId, stream); + if (!result.success) { + completeCaseStream(stream.streamId, false); + setCaseActionState('delete-case', result.error || 'Failed to delete case', { + tone: 'error', + buttonLabel: 'Retry', + statusChip: 'Error', + statusText: result.error || 'Failed to delete case', + }); + return; + } + + delete casesState.assistantSessions[caseId]; + casesState.selectedCaseId = null; + casesState.currentCase = null; + casesState.currentReport = null; + clearCasesStatusOverride(); + clearCaseActionState(); + await loadCases(null); } async function generateSelectedCaseReport() { if (!casesState.currentCase?.case_id) return; - const result = await window.api.casesGenerateReport(casesState.currentCase.case_id); - if (!result.success) return; + const stream = createCaseStreamContext(`GENERATE REPORT ${casesState.currentCase.case_id}`); + setCaseActionState('generate-report', 'Generating investigation report...', { + buttonLabel: 'Generating...', + statusChip: 'Working', + statusText: 'Generating investigation report...', + }); + const result = await window.api.casesGenerateReport(casesState.currentCase.case_id, stream); + if (!result.success) { + completeCaseStream(stream.streamId, false); + setCaseActionState('generate-report', result.error || 'Failed to generate report', { + tone: 'error', + buttonLabel: 'Retry', + statusChip: 'Error', + statusText: result.error || 'Failed to generate report', + }); + return; + } casesState.currentReport = { markdown: result.markdown || '', filename: result.filename || `investigation_${casesState.currentCase.case_id}.md`, @@ -4822,22 +5557,43 @@ async function generateSelectedCaseReport() { if (result.case) { casesState.currentCase = result.case; } + clearCasesStatusOverride(); + clearCaseActionState(); renderCaseDetail(); } async function saveSelectedCaseReport() { if (!casesState.currentReport) return; - await window.api.casesSaveReport(casesState.currentReport.filename, casesState.currentReport.markdown); + setCaseActionState('save-report', `Saving ${casesState.currentReport.filename || 'report'}...`, { + buttonLabel: 'Saving...', + statusChip: 'Working', + statusText: 'Saving generated report...', + }); + const result = await window.api.casesSaveReport(casesState.currentReport.filename, casesState.currentReport.markdown); + if (result && result.success === false) { + setCaseActionState('save-report', result.error || 'Failed to save report', { + tone: 'error', + buttonLabel: 'Retry', + statusChip: 'Error', + statusText: result.error || 'Failed to save report', + }); + return; + } + clearCasesStatusOverride(); + clearCaseActionState(); } function initCasesTab() { const el = getCasesElements(); + ensureCasesLogListeners(); + ensureCaseAssistantListeners(); if (!el.list || casesState.initialized) { if (el.list) loadCases(casesState.selectedCaseId); return; } casesState.initialized = true; + el.btnClearLog?.addEventListener('click', () => appendCasesLog('', true)); el.btnRefresh?.addEventListener('click', () => loadCases(casesState.selectedCaseId)); el.filterStatus?.addEventListener('change', () => loadCases(null)); el.btnGenerate?.addEventListener('click', generateSelectedCaseReport); @@ -4940,6 +5696,7 @@ navButtons.forEach(btn => { setTimeout(initAgentsTab, 100); } if (btn.dataset.tab === 'cases') { + setCasesStatusOverride('Loading', casesState.initialized ? 'Refreshing investigations...' : 'Loading investigations...', 'pending'); setTimeout(initCasesTab, 100); } }); diff --git a/electron-ui/styles.css b/electron-ui/styles.css index a38a40c..1957b4a 100644 --- a/electron-ui/styles.css +++ b/electron-ui/styles.css @@ -3056,6 +3056,12 @@ select.input, background: rgba(34, 197, 94, 0.12); } +.status-chip.pending { + color: var(--color-warning); + border-color: rgba(245, 158, 11, 0.35); + background: rgba(245, 158, 11, 0.12); +} + .status-chip.error { color: var(--color-danger); border-color: rgba(239, 68, 68, 0.35); @@ -3705,6 +3711,31 @@ select.input, padding-top: var(--space-1); } +.health-event-action-status { + font-size: 10px; + font-family: var(--font-mono); + border-radius: var(--radius-sm); + padding: 6px 8px; +} + +.health-event-action-status.tone-pending { + color: var(--color-warning); + background: rgba(245, 158, 11, 0.12); + border: 1px solid rgba(245, 158, 11, 0.22); +} + +.health-event-action-status.tone-error { + color: var(--color-danger); + background: rgba(239, 68, 68, 0.12); + border: 1px solid rgba(239, 68, 68, 0.22); +} + +.health-event-action-status.tone-success { + color: var(--color-success); + background: rgba(34, 197, 94, 0.12); + border: 1px solid rgba(34, 197, 94, 0.22); +} + .agents-list { margin: 0; padding-left: var(--space-4); @@ -3752,6 +3783,14 @@ select.input, align-items: start; } +.cases-log-panel { + margin-top: var(--space-4); +} + +.cases-log-panel .output-content { + max-height: 220px; +} + .cases-sidebar, .cases-detail { border: 1px solid var(--color-border); @@ -3796,6 +3835,10 @@ select.input, font-size: var(--text-sm); } +.cases-empty-loading { + color: var(--color-text-secondary); +} + .cases-empty-detail { min-height: 320px; display: flex; @@ -3965,6 +4008,31 @@ select.input, flex-wrap: wrap; } +.case-action-status { + font-size: var(--text-xs); + font-family: var(--font-mono); + border-radius: var(--radius-sm); + padding: var(--space-2) var(--space-3); +} + +.case-action-status.tone-pending { + color: var(--color-warning); + background: rgba(245, 158, 11, 0.12); + border: 1px solid rgba(245, 158, 11, 0.22); +} + +.case-action-status.tone-error { + color: var(--color-danger); + background: rgba(239, 68, 68, 0.12); + border: 1px solid rgba(239, 68, 68, 0.22); +} + +.case-action-status.tone-success { + color: var(--color-success); + background: rgba(34, 197, 94, 0.12); + border: 1px solid rgba(34, 197, 94, 0.22); +} + .case-draft-panel { background: rgba(59, 130, 246, 0.08); border: 1px solid rgba(59, 130, 246, 0.25); @@ -4047,6 +4115,65 @@ select.input, padding: var(--space-3); } +.case-assistant-transcript { + display: flex; + flex-direction: column; + gap: var(--space-3); + max-height: 320px; + overflow-y: auto; + margin-bottom: var(--space-3); +} + +.case-assistant-turn { + display: flex; + flex-direction: column; + gap: var(--space-2); +} + +.case-assistant-user { + align-self: flex-end; + max-width: 85%; + background: var(--color-bg-panel); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + padding: var(--space-2) var(--space-3); + color: var(--color-text); + white-space: pre-wrap; +} + +.case-assistant-response { + background: var(--color-bg-panel); + border: 1px solid var(--color-border-subtle); + border-radius: var(--radius-md); + padding: var(--space-3); + color: var(--color-text-secondary); + white-space: pre-wrap; + line-height: 1.5; +} + +.case-assistant-response.error { + color: var(--color-danger); + border-color: rgba(239, 68, 68, 0.25); + background: rgba(239, 68, 68, 0.08); +} + +.case-assistant-tool-calls { + display: flex; + flex-wrap: wrap; + gap: var(--space-2); +} + +.case-assistant-input-row { + display: flex; + gap: var(--space-2); + align-items: center; + flex-wrap: wrap; +} + +.case-assistant-input-row .input { + flex: 1; +} + .case-report-output { margin: 0; white-space: pre-wrap; diff --git a/scripts/case_api.py b/scripts/case_api.py index fdea9ee..a6de375 100644 --- a/scripts/case_api.py +++ b/scripts/case_api.py @@ -59,6 +59,9 @@ def main() -> int: p_update = sub.add_parser("update", help="Update case fields from stdin JSON") p_update.add_argument("--case-id", required=True) + p_delete = sub.add_parser("delete", help="Delete an investigation case") + p_delete.add_argument("--case-id", required=True) + p_draft = sub.add_parser("generate-draft", help="Generate an AI draft for a case") p_draft.add_argument("--case-id", required=True) @@ -103,6 +106,14 @@ def main() -> int: output_json({"success": True, "case": case}) return 0 + if args.command == "delete": + deleted = graph.delete_investigation_case(args.case_id) + if not deleted: + output_json({"success": False, "error": f"Case not found: {args.case_id}"}) + return 1 + output_json({"success": True, "case_id": args.case_id}) + return 0 + if args.command == "generate-draft": case = graph.generate_investigation_case_draft(args.case_id) if not case: diff --git a/scripts/claude_client.py b/scripts/claude_client.py index 51c0da1..2821b62 100644 --- a/scripts/claude_client.py +++ b/scripts/claude_client.py @@ -1291,6 +1291,7 @@ def query_json( max_tool_rounds: int = 50, # High limit - Claude self-regulates use_tools: bool = True, verbose: bool = False, + require_data_query: bool = False, ) -> Dict[str, Any]: """ Query Claude expecting a JSON response. @@ -1307,6 +1308,7 @@ def query_json( max_tool_rounds=max_tool_rounds, use_tools=use_tools, verbose=verbose, + require_data_query=require_data_query, ) # Extract JSON from response diff --git a/scripts/neo4j_ontology.py b/scripts/neo4j_ontology.py index 5ce704e..866191a 100644 --- a/scripts/neo4j_ontology.py +++ b/scripts/neo4j_ontology.py @@ -462,6 +462,7 @@ def _json_list(key: str) -> str: explanation: $explanation, probable_causes_json: $causes_json, recommended_checks_json: $checks_json, + operator_context: '', notes: '', resolution_notes: '', owner: '', @@ -533,6 +534,7 @@ def update_investigation_case(self, case_id: str, updates: Dict[str, Any]) -> Op "notes", "resolution_notes", "owner", + "operator_context", "explanation", "confidence", } @@ -559,6 +561,20 @@ def update_investigation_case(self, case_id: str, updates: Dict[str, Any]) -> Op return None return self.get_investigation_case(case_id) + def delete_investigation_case(self, case_id: str) -> bool: + """Delete an investigation case and all of its relationships.""" + with self.session() as session: + row = session.run( + """ + MATCH (c:InvestigationCase {case_id: $case_id}) + WITH c, count(c) AS cnt + DETACH DELETE c + RETURN cnt + """, + case_id=case_id, + ).single() + return bool(row and row["cnt"]) + @staticmethod def _parse_json_list(value: Any) -> List[str]: """Normalize JSON-or-list values into a flat string list.""" @@ -639,6 +655,7 @@ def _build_investigation_case_ai_context(self, case: Dict[str, Any]) -> Dict[str "z_score": event.get("z_score"), "mad_score": event.get("mad_score"), "live_value": event.get("live_value"), + "operator_context": case.get("operator_context") or "", "probable_causes": self._parse_json_list(case.get("probable_causes_json") or event.get("probable_causes_json")), "recommended_checks": self._parse_json_list(case.get("recommended_checks_json") or event.get("recommended_checks_json")), } @@ -662,12 +679,16 @@ def generate_investigation_case_draft(self, case_id: str) -> Optional[Dict[str, if downstream: pieces.append(f"downstream context: {', '.join(downstream)}") dependency_clause = " The graph shows FEEDS dependencies with " + "; ".join(pieces) + "." + operator_context = (context.get("operator_context") or "").strip() + operator_clause = "" + if operator_context: + operator_clause = f" Operator-reported context: {operator_context}" fallback = { "summary": case.get("summary") or event.get("summary") or f"Investigate deviation in {subsystem}", "narrative": ( f"A deviation was detected on `{context.get('source_tag') or subsystem}` within `{subsystem}`." - f"{dependency_clause} Based on the event timing and graph context, the best current hypothesis is that " + f"{dependency_clause}{operator_clause} Based on the event timing, operator context, and graph context, the best current hypothesis is that " f"this condition may be contributing to downstream process degradation and should be reviewed before " f"closing the incident." ), @@ -686,11 +707,13 @@ def generate_investigation_case_draft(self, case_id: str) -> Optional[Dict[str, try: from claude_client import ClaudeClient - client = ClaudeClient(enable_tools=False) + client = ClaudeClient(enable_tools=True) llm_result = client.query_json( system_prompt=( "You are an industrial incident investigation assistant. " "You generate a draft investigation narrative that must be approved by an operator. " + "You have access to the same industrial troubleshooting tools as the main query agent. " + "Use those tools to validate graph facts, inspect related nodes, and ground the draft in real data. " "Use FEEDS relationships as evidence of likely upstream/downstream propagation when present. " "Return ONLY valid JSON with keys: summary, narrative, probable_causes, recommended_checks, disposition, confidence." ), @@ -705,16 +728,21 @@ def generate_investigation_case_draft(self, case_id: str) -> Optional[Dict[str, }, "event": event, "graph_context": context, + "operator_context": operator_context, "instruction": ( "Write a concise but concrete investigation draft. " + "Use tool calls to verify and enrich the available graph context before answering. " "If FEEDS dependencies exist, explain the likely propagation path. " + "Use operator context when available and treat it as a high-value signal. " "Do not overstate certainty." ), }, default=str, ), max_tokens=1200, - use_tools=False, + use_tools=True, + verbose=True, + require_data_query=True, ) data = llm_result.get("data") if isinstance(llm_result, dict) else None if isinstance(data, dict): @@ -853,6 +881,9 @@ def generate_investigation_case_report(self, case_id: str) -> Optional[Dict[str, report_lines.append("- No recommended checks recorded yet.") report_lines.extend([ + "", + "## Operator Context", + case.get("operator_context") or "No operator context recorded.", "", "## Operator Notes", case.get("notes") or "No operator notes recorded.", diff --git a/scripts/troubleshoot.py b/scripts/troubleshoot.py index 149796b..d811623 100644 --- a/scripts/troubleshoot.py +++ b/scripts/troubleshoot.py @@ -148,7 +148,7 @@ def run_interactive(): def ask_single( - question: str, history: List[Dict] = None, verbose: bool = False + question: str, history: List[Dict] = None, verbose: bool = False, context: str = "" ) -> Dict: """ Ask a troubleshooting question with optional conversation history. @@ -164,16 +164,27 @@ def ask_single( client = ClaudeClient(enable_tools=True) try: + effective_question = question + if context: + effective_question = ( + "Authoritative case context for this investigation:\n" + f"{context}\n\n" + "Use this context together with tool calls against the ontology and connected systems.\n\n" + f"Investigator question:\n{question}" + ) + # Build messages from history + new question if history: messages = list(history) - messages.append({"role": "user", "content": question}) + messages_for_model = list(history) + messages_for_model.append({"role": "user", "content": effective_question}) else: - messages = [{"role": "user", "content": question}] + messages = [] + messages_for_model = [{"role": "user", "content": effective_question}] result = client.query( system_prompt=SYSTEM_PROMPT, - messages=messages, + messages=messages_for_model, max_tokens=4000, use_tools=True, verbose=verbose, @@ -188,7 +199,9 @@ def ask_single( print(f"\n[DEBUG] Made {len(tool_calls)} tool calls", file=sys.stderr) # Update history with the new exchange - updated_history = messages + [{"role": "assistant", "content": response}] + updated_history = list(history or []) + updated_history.append({"role": "user", "content": question}) + updated_history.append({"role": "assistant", "content": response}) return {"response": response, "history": updated_history} @@ -212,8 +225,9 @@ def ask_with_history_json(history_json: str, verbose: bool = False) -> str: data = json.loads(history_json) question = data.get("question", "") history = data.get("history", []) + context = data.get("context", "") - result = ask_single(question, history=history, verbose=verbose) + result = ask_single(question, history=history, verbose=verbose, context=context) return json.dumps(result, ensure_ascii=False) except json.JSONDecodeError as e: From 66a6eba14a4d3123e65e9a450e05d8ae8cb1204d Mon Sep 17 00:00:00 2001 From: Leor Barak Fishman Date: Thu, 19 Mar 2026 08:28:49 -0700 Subject: [PATCH 3/5] case improvements --- electron-ui/main.js | 18 +++++ electron-ui/preload.js | 1 + electron-ui/renderer.js | 137 ++++++++++++++++---------------------- scripts/neo4j_ontology.py | 12 +++- scripts/troubleshoot.py | 85 ++++++++++++++++++++++- 5 files changed, 172 insertions(+), 81 deletions(-) diff --git a/electron-ui/main.js b/electron-ui/main.js index 8fb002a..8ba0eea 100644 --- a/electron-ui/main.js +++ b/electron-ui/main.js @@ -2138,6 +2138,24 @@ ipcMain.handle('cases:generate-report', async (event, caseId, options = {}) => { } }); +ipcMain.handle('cases:assistant-summarize', async (event, history, turns, context, options = {}) => { + try { + const output = await runPythonScriptWithStdin('troubleshoot.py', ['--history', '-v'], { + mode: 'summarize_case_transcript', + history: history || [], + turns: turns || [], + context: context || '', + }, { + streaming: Boolean(options.streamId), + streamId: options.streamId || null, + target: options.target || 'cases', + }); + return parseJsonFromMixedOutput(output, { success: false, error: 'Invalid case summary response' }); + } catch (error) { + return { success: false, error: error.message }; + } +}); + ipcMain.handle('cases:save-report', async (event, suggestedFilename, markdown) => { try { const result = await dialog.showSaveDialog(mainWindow, { diff --git a/electron-ui/preload.js b/electron-ui/preload.js index 303fb46..612c672 100644 --- a/electron-ui/preload.js +++ b/electron-ui/preload.js @@ -99,6 +99,7 @@ contextBridge.exposeInMainWorld('api', { casesRejectDraft: (caseId, options) => ipcRenderer.invoke('cases:reject-draft', caseId, options), casesGenerateReport: (caseId, options) => ipcRenderer.invoke('cases:generate-report', caseId, options), casesAssistantQuery: (question, history, context, options) => ipcRenderer.invoke('cases:assistant-query', question, history, context, options), + casesAssistantSummarize: (history, turns, context, options) => ipcRenderer.invoke('cases:assistant-summarize', history, turns, context, options), casesSaveReport: (suggestedFilename, markdown) => ipcRenderer.invoke('cases:save-report', suggestedFilename, markdown), // Database connections diff --git a/electron-ui/renderer.js b/electron-ui/renderer.js index 64928eb..fb44054 100644 --- a/electron-ui/renderer.js +++ b/electron-ui/renderer.js @@ -4432,6 +4432,7 @@ const casesState = { isLoadingDetail: false, statusOverride: null, actionState: null, + assistantSummaryPending: false, logStreamIds: new Set(), logListenersReady: false, assistantSessions: {}, @@ -4481,10 +4482,6 @@ function completeCaseStream(streamId, success) { appendCasesLog(success ? '[OK] Case action complete.\n' : '[ERROR] Case action failed.\n'); } -function appendCasesUiDebugLog(message) { - appendCasesLog(`[UI DEBUG] ${message}\n`); -} - function ensureCasesLogListeners() { if (casesState.logListenersReady) return; casesState.logListenersReady = true; @@ -4539,7 +4536,7 @@ function setCaseActionState(action, message, options = {}) { setCasesStatusOverride(options.statusChip || 'Working', options.statusText || message || '', options.tone || 'pending'); } updateCasesToolbar(); - renderCaseDetail(); + if (!options.skipRenderDetail) renderCaseDetail(); } function clearCaseActionState() { @@ -4602,59 +4599,47 @@ function renderCaseAssistantTranscript(caseData) { }).join(''); } -function summarizeAssistantResponse(text, maxLen = 320) { - const normalized = String(text || '').replace(/\s+/g, ' ').trim(); - if (!normalized) return ''; - if (normalized.length <= maxLen) return normalized; - const truncated = normalized.slice(0, maxLen); - const sentenceBreak = Math.max( - truncated.lastIndexOf('. '), - truncated.lastIndexOf('; '), - truncated.lastIndexOf('! '), - truncated.lastIndexOf('? ') - ); - if (sentenceBreak > Math.floor(maxLen * 0.5)) { - return `${truncated.slice(0, sentenceBreak + 1).trim()}`; - } - return `${truncated.trim()}...`; -} - -function buildCaseAssistantNarrativeSummary(caseId) { +async function appendAssistantSummaryToNarrative() { + const caseId = casesState.currentCase?.case_id; + if (!caseId) return; const session = getCaseAssistantSession(caseId); - const completedTurns = (session.turns || []).filter((turn) => !turn.pending && !turn.error && (turn.response || '').trim()); - if (!completedTurns.length) return ''; - - const uniqueTools = [...new Set( - completedTurns.flatMap((turn) => Array.isArray(turn.toolCalls) ? turn.toolCalls : []) - )]; - - const lines = ['Investigator assistant summary:']; - completedTurns.forEach((turn, index) => { - lines.push(`${index + 1}. ${turn.question}`); - lines.push(` Findings: ${summarizeAssistantResponse(turn.response)}`); - if (turn.toolCalls?.length) { - lines.push(` Tools used: ${[...new Set(turn.toolCalls)].join(', ')}`); - } - }); + const completedTurns = (session.turns || []).filter((turn) => !turn.pending && ((turn.response || '').trim() || (turn.error || '').trim())); + if (!completedTurns.length) return; + const stream = createCaseStreamContext(`SUMMARIZE ASSISTANT ${caseId}`); + casesState.assistantSummaryPending = true; + refreshCaseAssistantSummaryButton(); + setCasesStatusOverride('Working', 'Summarizing investigator conversation...', 'pending'); + + const result = await window.api.casesAssistantSummarize( + session.history || [], + session.turns || [], + buildCaseAssistantContext(casesState.currentCase), + stream, + ); + const summary = String(result?.summary || '').trim(); - if (uniqueTools.length) { - lines.push(`Overall tools referenced: ${uniqueTools.join(', ')}`); + if (result?.success === false || !summary) { + completeCaseStream(stream.streamId, false); + casesState.assistantSummaryPending = false; + refreshCaseAssistantSummaryButton(); + setCasesStatusOverride('Error', result.error || 'Failed to summarize investigator conversation', 'error'); + return; } - return lines.join('\n'); -} - -function appendAssistantSummaryToNarrative() { - const caseId = casesState.currentCase?.case_id; - if (!caseId) return; const explanationInput = document.getElementById('case-explanation-input'); - if (!explanationInput) return; - const summary = buildCaseAssistantNarrativeSummary(caseId); - if (!summary) return; + if (!explanationInput) { + casesState.assistantSummaryPending = false; + refreshCaseAssistantSummaryButton(); + setCasesStatusOverride('Error', 'Narrative field is not available', 'error'); + return; + } const existing = explanationInput.value.trim(); explanationInput.value = existing ? `${existing}\n\n${summary}` : summary; explanationInput.focus(); explanationInput.selectionStart = explanationInput.selectionEnd = explanationInput.value.length; + casesState.assistantSummaryPending = false; + refreshCaseAssistantSummaryButton(); + setCasesStatusOverride('Ready', 'Summary appended to investigation narrative.', 'running'); } function getCaseAssistantTranscriptElement() { @@ -4729,6 +4714,19 @@ function finalizeCaseAssistantTurnDom(streamId, options = {}) { } } +function refreshCaseAssistantSummaryButton() { + const caseId = casesState.currentCase?.case_id; + const button = document.getElementById('btn-case-assistant-summary'); + if (!caseId || !button) return; + const session = getCaseAssistantSession(caseId); + const actionPending = casesState.actionState?.tone === 'pending'; + const hasAssistantTranscript = session.turns.some( + (turn) => !turn.pending && ((turn.response || '').trim() || (turn.error || '').trim()) + ); + button.disabled = !hasAssistantTranscript || actionPending || casesState.assistantSummaryPending; + button.textContent = casesState.assistantSummaryPending ? 'Summarizing...' : 'Append Summary to Narrative'; +} + function ensureCaseAssistantListeners() { if (casesState.assistantListenersReady) return; casesState.assistantListenersReady = true; @@ -4764,6 +4762,7 @@ function ensureCaseAssistantListeners() { if (!turn) return; turn.pending = false; finalizeCaseAssistantTurnDom(data.streamId); + refreshCaseAssistantSummaryButton(); }); } @@ -4950,6 +4949,7 @@ function renderCaseDetail() { const deleteLabel = actionState?.action === 'delete-case' ? (actionState.buttonLabel || 'Deleting...') : 'Delete Case'; const draftLabel = actionState?.action === 'generate-draft' ? (actionState.buttonLabel || 'Generating...') : 'AI Enrich Draft'; const reportLabel = actionState?.action === 'generate-report' ? (actionState.buttonLabel || 'Generating...') : 'Generate Report'; + const assistantSummaryLabel = casesState.assistantSummaryPending ? 'Summarizing...' : 'Append Summary to Narrative'; const approveLabel = actionState?.action === 'approve-draft' ? (actionState.buttonLabel || 'Approving...') : 'Approve Draft'; const rejectLabel = actionState?.action === 'reject-draft' ? (actionState.buttonLabel || 'Rejecting...') : 'Reject Draft'; const regenerateLabel = actionState?.action === 'generate-draft' ? (actionState.buttonLabel || 'Generating...') : 'Regenerate Draft'; @@ -5078,7 +5078,7 @@ function renderCaseDetail() {
- +
@@ -5102,28 +5102,6 @@ function renderCaseDetail() { }); document.getElementById('btn-case-assistant-clear')?.addEventListener('click', clearCaseAssistantSession); - const caseDetailRoot = el.detail; - caseDetailRoot.querySelectorAll('input, textarea, select').forEach((field) => { - field.addEventListener('pointerdown', (event) => { - const hit = document.elementFromPoint(event.clientX, event.clientY); - appendCasesUiDebugLog( - `pointerdown target=${field.id || field.tagName} hit=${hit?.id || hit?.className || hit?.tagName || 'unknown'} actionPending=${casesState.actionState?.tone === 'pending'}` - ); - }); - field.addEventListener('focusin', () => { - appendCasesUiDebugLog(`focusin target=${field.id || field.tagName}`); - }); - }); - caseDetailRoot.addEventListener('pointerdown', (event) => { - const hit = document.elementFromPoint(event.clientX, event.clientY); - if (!hit) return; - const targetLabel = event.target?.id || event.target?.className || event.target?.tagName || 'unknown'; - const hitLabel = hit.id || hit.className || hit.tagName || 'unknown'; - if (targetLabel !== hitLabel) { - appendCasesUiDebugLog(`detail pointerdown target=${targetLabel} hit=${hitLabel}`); - } - }); - updateCasesToolbar(); } @@ -5170,12 +5148,14 @@ async function sendCaseAssistantQuery() { if (!result.success) { turn.error = result.error || 'Investigator assistant query failed'; finalizeCaseAssistantTurnDom(stream.streamId, { error: turn.error }); + refreshCaseAssistantSummaryButton(); return; } session.history = Array.isArray(result.history) ? result.history : session.history; turn.response = result.response || turn.response || ''; finalizeCaseAssistantTurnDom(stream.streamId); + refreshCaseAssistantSummaryButton(); } async function loadCaseDetails(caseId) { @@ -5437,12 +5417,6 @@ async function rejectSelectedCaseDraft() { async function saveSelectedCase() { if (!casesState.currentCase?.case_id) return; - const stream = createCaseStreamContext(`SAVE CASE ${casesState.currentCase.case_id}`); - setCaseActionState('save-case', 'Saving case updates...', { - buttonLabel: 'Saving...', - statusChip: 'Working', - statusText: 'Saving case updates...', - }); const patch = { status: document.getElementById('case-status-input')?.value || 'open', owner: document.getElementById('case-owner-input')?.value || '', @@ -5453,6 +5427,13 @@ async function saveSelectedCase() { notes: document.getElementById('case-notes-input')?.value || '', resolution_notes: document.getElementById('case-resolution-input')?.value || '', }; + const stream = createCaseStreamContext(`SAVE CASE ${casesState.currentCase.case_id}`); + setCaseActionState('save-case', 'Saving case updates...', { + buttonLabel: 'Saving...', + statusChip: 'Working', + statusText: 'Saving case updates...', + skipRenderDetail: true, + }); const result = await window.api.casesUpdate(casesState.currentCase.case_id, patch, stream); if (!result.success || !result.case) { completeCaseStream(stream.streamId, false); diff --git a/scripts/neo4j_ontology.py b/scripts/neo4j_ontology.py index 866191a..defc57a 100644 --- a/scripts/neo4j_ontology.py +++ b/scripts/neo4j_ontology.py @@ -706,6 +706,7 @@ def generate_investigation_case_draft(self, case_id: str) -> Optional[Dict[str, try: from claude_client import ClaudeClient + import sys client = ClaudeClient(enable_tools=True) llm_result = client.query_json( @@ -744,6 +745,13 @@ def generate_investigation_case_draft(self, case_id: str) -> Optional[Dict[str, verbose=True, require_data_query=True, ) + tool_calls = llm_result.get("tool_calls") if isinstance(llm_result, dict) else [] + tool_names = [call.get("name", "tool") for call in tool_calls if isinstance(call, dict)] + print( + f"[CASE DRAFT DEBUG] case_id={case_id} tool_calls={len(tool_names)} tools={','.join(tool_names) if tool_names else 'none'} error={llm_result.get('error') if isinstance(llm_result, dict) else 'unknown'}", + file=sys.stderr, + flush=True, + ) data = llm_result.get("data") if isinstance(llm_result, dict) else None if isinstance(data, dict): fallback.update({ @@ -754,8 +762,8 @@ def generate_investigation_case_draft(self, case_id: str) -> Optional[Dict[str, "disposition": data.get("disposition") or fallback["disposition"], "confidence": float(max(0.0, min(1.0, data.get("confidence", fallback["confidence"]) or fallback["confidence"]))), }) - except Exception: - pass + except Exception as exc: + print(f"[CASE DRAFT DEBUG] case_id={case_id} llm_exception={exc}", file=sys.stderr, flush=True) with self.session() as session: row = session.run( diff --git a/scripts/troubleshoot.py b/scripts/troubleshoot.py index d811623..c524779 100644 --- a/scripts/troubleshoot.py +++ b/scripts/troubleshoot.py @@ -83,6 +83,17 @@ Be practical and actionable. Operators need clear guidance, not theory.""" +SUMMARY_SYSTEM_PROMPT = """You summarize an industrial investigation assistant conversation into a concise investigation-narrative update. + +Requirements: +- Write plain text suitable for appending directly into an investigation narrative field. +- Synthesize the conversation into verified findings, likely causes, recommended checks, and remaining uncertainty. +- Be concise but concrete. +- Do not mention that this was generated by AI. +- Do not fabricate facts that are not present in the provided case context or conversation transcript. +- If the transcript contains conflicting or uncertain information, say that clearly. +""" + def run_interactive(): """Run interactive troubleshooting session.""" @@ -209,6 +220,68 @@ def ask_single( client.close() +def summarize_case_history( + history: List[Dict] = None, + turns: List[Dict] = None, + context: str = "", + verbose: bool = False, +) -> Dict: + """Summarize the investigator assistant transcript for the case narrative.""" + client = ClaudeClient(enable_tools=False) + + try: + safe_turns = turns or [] + transcript_lines = [] + for index, turn in enumerate(safe_turns, start=1): + question = str((turn or {}).get("question", "")).strip() + response = str((turn or {}).get("response", "")).strip() + error = str((turn or {}).get("error", "")).strip() + tool_calls = (turn or {}).get("toolCalls") or [] + if question: + transcript_lines.append(f"Turn {index} question: {question}") + if tool_calls: + transcript_lines.append(f"Turn {index} tools: {', '.join(str(tool) for tool in tool_calls if tool)}") + if response: + transcript_lines.append(f"Turn {index} response: {response}") + if error: + transcript_lines.append(f"Turn {index} error: {error}") + + if not transcript_lines and history: + for index, message in enumerate(history, start=1): + role = str((message or {}).get("role", "unknown")) + content = str((message or {}).get("content", "")).strip() + if content: + transcript_lines.append(f"{index}. {role}: {content}") + + if not transcript_lines: + return {"success": False, "summary": "", "error": "No investigator transcript available to summarize"} + + result = client.query( + system_prompt=SUMMARY_SYSTEM_PROMPT, + user_prompt=json.dumps( + { + "case_context": context or "", + "transcript": transcript_lines, + "instruction": ( + "Summarize this investigation assistant exchange into a short narrative update. " + "Prefer 1-3 short paragraphs or compact bullet-style lines in plain text. " + "Focus on findings that should be preserved in the case narrative." + ), + }, + ensure_ascii=False, + ), + max_tokens=1200, + use_tools=False, + verbose=verbose, + require_data_query=False, + ) + + summary = result.get("text", "").strip() + return {"success": True, "summary": summary} + finally: + client.close() + + def ask_with_history_json(history_json: str, verbose: bool = False) -> str: """ Process a request with conversation history from JSON. @@ -223,11 +296,21 @@ def ask_with_history_json(history_json: str, verbose: bool = False) -> str: """ try: data = json.loads(history_json) + mode = data.get("mode", "chat") question = data.get("question", "") history = data.get("history", []) + turns = data.get("turns", []) context = data.get("context", "") - result = ask_single(question, history=history, verbose=verbose, context=context) + if mode == "summarize_case_transcript": + result = summarize_case_history( + history=history, + turns=turns, + context=context, + verbose=verbose, + ) + else: + result = ask_single(question, history=history, verbose=verbose, context=context) return json.dumps(result, ensure_ascii=False) except json.JSONDecodeError as e: From 9a168cddb0cf63461e00867b73dd7a4b9768e60a Mon Sep 17 00:00:00 2001 From: Leor Barak Fishman Date: Fri, 20 Mar 2026 07:51:25 -0700 Subject: [PATCH 4/5] morerendere --- electron-ui/renderer.js | 85 ++++++++++++++++++++++++------------- electron-ui/styles.css | 92 ++++++++++++++++++++++++++++++++++++++--- 2 files changed, 143 insertions(+), 34 deletions(-) diff --git a/electron-ui/renderer.js b/electron-ui/renderer.js index fb44054..e3f04ac 100644 --- a/electron-ui/renderer.js +++ b/electron-ui/renderer.js @@ -4585,7 +4585,12 @@ function renderCaseAssistantTranscript(caseData) { } return session.turns.map((turn) => { const toolCallsHtml = turn.toolCalls?.length - ? `
${turn.toolCalls.map((tool) => `${escapeHtml(tool)}`).join('')}
` + ? ` +
+ +
${turn.toolCalls.map((tool) => `${escapeHtml(tool)}`).join('')}
+
+ ` : ''; const responseText = turn.error || turn.response || (turn.pending ? 'Working...' : ''); const responseClass = turn.error ? ' error' : ''; @@ -4593,7 +4598,10 @@ function renderCaseAssistantTranscript(caseData) {
${escapeHtml(turn.question || '')}
${toolCallsHtml} -
${escapeHtml(responseText || '')}
+
+ +
${escapeHtml(responseText || '')}
+
`; }).join(''); @@ -4663,7 +4671,10 @@ function ensureCaseAssistantTurnDom(turn) { turnEl.setAttribute('data-case-assistant-turn', turn.streamId); turnEl.innerHTML = `
${userText}
-
${escapeHtml(turn.pending ? 'Working...' : (turn.response || ''))}
+
+ +
${escapeHtml(turn.pending ? 'Working...' : (turn.response || ''))}
+
`; transcript.appendChild(turnEl); transcript.scrollTop = transcript.scrollHeight; @@ -4675,14 +4686,19 @@ function appendCaseAssistantToolCallDom(streamId, tool) { if (!turnEl) return; let toolsEl = turnEl.querySelector(`[data-case-assistant-tools="${escapeForAttribute(streamId)}"]`); if (!toolsEl) { + const toolsSection = document.createElement('div'); + toolsSection.className = 'case-assistant-section'; + toolsSection.innerHTML = ``; toolsEl = document.createElement('div'); toolsEl.className = 'case-assistant-tool-calls'; toolsEl.setAttribute('data-case-assistant-tools', streamId); + toolsSection.appendChild(toolsEl); const responseEl = turnEl.querySelector(`[data-case-assistant-response="${escapeForAttribute(streamId)}"]`); + const responseSection = responseEl?.closest('.case-assistant-section'); if (responseEl) { - turnEl.insertBefore(toolsEl, responseEl); + turnEl.insertBefore(toolsSection, responseSection || responseEl); } else { - turnEl.appendChild(toolsEl); + turnEl.appendChild(toolsSection); } } const chip = document.createElement('span'); @@ -4958,22 +4974,24 @@ function renderCaseDetail() { : ''; el.detail.innerHTML = ` -
+
+
+

${escapeHtml(item.title || 'Untitled case')}

Case ID ${escapeHtml(item.case_id || '')} linked to event ${escapeHtml(item.source_event_id || 'n/a')}
${escapeHtml(normalizeCaseStatus(item.status).replace('_', ' '))} -
+
-
+
Subsystem${escapeHtml(item.subsystem_name || item.subsystem_id || 'Unknown')}
Source Tag${escapeHtml(item.source_tag || event.source_tag || 'n/a')}
Severity${escapeHtml(item.severity || 'unknown')}
Last Updated${escapeHtml(formatCaseDate(item.updated_at))}
-
+
-
+
-
+
-
+
-
- ${actionStatusHtml} +
+ ${actionStatusHtml} - ${hasDraft ? ` + ${hasDraft ? `

AI Draft

@@ -5046,9 +5064,9 @@ function renderCaseDetail() {
- ` : ''} + ` : ''} -
+

Probable Causes

${probableCauses.length ? `
    ${probableCauses.map((x) => `
  • ${escapeHtml(x)}
  • `).join('')}
` : '
No probable causes recorded yet.
'} @@ -5065,22 +5083,31 @@ function renderCaseDetail() {

Linked Equipment

${equipment.length ? `
    ${equipment.map((x) => `
  • ${escapeHtml(x)}
  • `).join('')}
` : '
No linked equipment.
'}
-
+
-
+

Generated Report

${reportHtml} -
- -
-

Investigator Assistant

-
${renderCaseAssistantTranscript(item)}
-
- - - - +
+ +
`; diff --git a/electron-ui/styles.css b/electron-ui/styles.css index 1957b4a..56ed5a4 100644 --- a/electron-ui/styles.css +++ b/electron-ui/styles.css @@ -3930,6 +3930,44 @@ select.input, gap: var(--space-4); } +.case-workspace { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(340px, 380px); + gap: var(--space-4); + align-items: start; +} + +.case-main-column { + display: flex; + flex-direction: column; + gap: var(--space-4); + min-width: 0; +} + +.case-assistant-sidebar { + background: var(--color-bg-elevated); + border: 1px solid var(--color-border-subtle); + border-radius: var(--radius-md); + padding: var(--space-3); + display: flex; + flex-direction: column; + gap: var(--space-3); + min-width: 0; + position: sticky; + top: var(--space-2); + min-height: calc(100vh - 260px); +} + +.case-assistant-sidebar-header h4 { + margin: 0 0 var(--space-1); + font-size: var(--text-md); +} + +.case-assistant-sidebar-subtitle { + color: var(--color-text-muted); + font-size: var(--text-sm); +} + .case-detail-header { display: flex; justify-content: space-between; @@ -4119,15 +4157,16 @@ select.input, display: flex; flex-direction: column; gap: var(--space-3); - max-height: 320px; + flex: 1; + min-height: 280px; overflow-y: auto; - margin-bottom: var(--space-3); } .case-assistant-turn { display: flex; flex-direction: column; gap: var(--space-2); + min-width: 0; } .case-assistant-user { @@ -4141,6 +4180,20 @@ select.input, white-space: pre-wrap; } +.case-assistant-section { + display: flex; + flex-direction: column; + gap: var(--space-1); + min-width: 0; +} + +.case-assistant-section-label { + font-size: var(--text-xs); + text-transform: uppercase; + letter-spacing: 0.3px; + color: var(--color-text-muted); +} + .case-assistant-response { background: var(--color-bg-panel); border: 1px solid var(--color-border-subtle); @@ -4149,6 +4202,9 @@ select.input, color: var(--color-text-secondary); white-space: pre-wrap; line-height: 1.5; + max-height: 220px; + overflow-y: auto; + min-width: 0; } .case-assistant-response.error { @@ -4161,17 +4217,32 @@ select.input, display: flex; flex-wrap: wrap; gap: var(--space-2); + max-height: 120px; + overflow-y: auto; + padding: var(--space-2); + background: rgba(15, 23, 42, 0.35); + border: 1px solid var(--color-border-subtle); + border-radius: var(--radius-md); + align-content: flex-start; } .case-assistant-input-row { display: flex; + flex-direction: column; gap: var(--space-2); - align-items: center; - flex-wrap: wrap; + align-items: stretch; } .case-assistant-input-row .input { - flex: 1; + min-height: 110px; + resize: vertical; +} + +.case-assistant-action-row { + display: flex; + gap: var(--space-2); + flex-wrap: wrap; + justify-content: flex-end; } .case-report-output { @@ -4200,6 +4271,17 @@ select.input, } } +@media (max-width: 980px) { + .case-workspace { + grid-template-columns: 1fr; + } + + .case-assistant-sidebar { + position: static; + min-height: 0; + } +} + @media (max-width: 760px) { .case-detail-grid, .case-form-grid, From d885d2997ec8f1f58ba6fdc8ad2dc33079dabf1a Mon Sep 17 00:00:00 2001 From: Leor Barak Fishman Date: Fri, 20 Mar 2026 07:57:18 -0700 Subject: [PATCH 5/5] mcp server add --- scripts/mcp_server.py | 123 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 123 insertions(+) create mode 100644 scripts/mcp_server.py diff --git a/scripts/mcp_server.py b/scripts/mcp_server.py new file mode 100644 index 0000000..70ce67e --- /dev/null +++ b/scripts/mcp_server.py @@ -0,0 +1,123 @@ +#!/usr/bin/env python3 +""" +MCP server that exposes all OntologyTools from claude_client.py. + +Bridges the existing tool layer (get_schema, run_query, get_node, create_mapping, +get_current_time, MES/RCA tools, live Ignition tools, and DB tools) into the +Model Context Protocol so any MCP-aware client (Cursor, Claude Desktop, etc.) +can use them. + +Usage: + python scripts/mcp_server.py + +Configure in Cursor (settings.json) or Claude Desktop (claude_desktop_config.json): + { + "mcpServers": { + "plc-ontology": { + "command": "python", + "args": ["c:/path/to/plcprocessing/scripts/mcp_server.py"] + } + } + } + +Environment variables (from .env or shell): + NEO4J_URI, NEO4J_USER, NEO4J_PASSWORD – Neo4j connection + IGNITION_API_URL, IGNITION_API_TOKEN – optional live Ignition API +""" + +import asyncio +import json +import os +import sys +from typing import Any + +# Ensure the scripts directory is on the path so local imports work. +sys.path.insert(0, os.path.dirname(__file__)) + +from dotenv import load_dotenv + +load_dotenv() + +from mcp.server import Server +from mcp.server.stdio import stdio_server +from mcp import types + +from claude_client import OntologyTools +from neo4j_ontology import get_ontology_graph +from ignition_api_client import IgnitionApiClient + +# --------------------------------------------------------------------------- +# Lazy singleton for OntologyTools (Neo4j connection is expensive to open) +# --------------------------------------------------------------------------- + +_ontology_tools: OntologyTools | None = None + + +def _get_tools() -> OntologyTools: + global _ontology_tools + if _ontology_tools is None: + graph = get_ontology_graph() + + api_client: IgnitionApiClient | None = None + api_url = os.getenv("IGNITION_API_URL") + if api_url: + api_client = IgnitionApiClient( + base_url=api_url, + api_token=os.getenv("IGNITION_API_TOKEN"), + ) + + _ontology_tools = OntologyTools(graph, api_client=api_client) + return _ontology_tools + + +# --------------------------------------------------------------------------- +# MCP Server +# --------------------------------------------------------------------------- + +app = Server("plc-ontology") + + +@app.list_tools() +async def list_tools() -> list[types.Tool]: + """Return all tool definitions from OntologyTools.""" + tools = _get_tools() + mcp_tools = [] + for defn in tools.get_all_tool_definitions(): + mcp_tools.append( + types.Tool( + name=defn["name"], + description=defn["description"], + inputSchema=defn["input_schema"], + ) + ) + return mcp_tools + + +@app.call_tool() +async def call_tool(name: str, arguments: dict[str, Any]) -> list[types.TextContent]: + """Delegate tool execution to OntologyTools.execute().""" + tools = _get_tools() + # execute() is synchronous – run it in a thread to avoid blocking the event loop + loop = asyncio.get_event_loop() + result = await loop.run_in_executor(None, tools.execute, name, arguments or {}) + + # result is already a JSON string; surface it as text content + return [types.TextContent(type="text", text=result)] + + +# --------------------------------------------------------------------------- +# Entry point +# --------------------------------------------------------------------------- + + +async def _serve() -> None: + async with stdio_server() as (read_stream, write_stream): + await app.run( + read_stream, + write_stream, + app.create_initialization_options(), + ) + + +if __name__ == "__main__": + asyncio.run(_serve())