@@ -4024,19 +4080,19 @@ function renderSubsystemHealthGrid() {
});
});
- container.querySelectorAll('.health-event-detail-actions .btn-deep-analyze').forEach((btn) => {
+ container.querySelectorAll('.health-event-detail-actions .btn-ack-event').forEach((btn) => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
const eventId = btn.getAttribute('data-event-id');
- if (eventId) deepAnalyzeEvent(eventId, btn);
+ if (eventId) acknowledgeEvent(eventId);
});
});
- container.querySelectorAll('.health-event-detail-actions .btn-ack-event').forEach((btn) => {
- btn.addEventListener('click', (e) => {
+ 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) acknowledgeEvent(eventId);
+ if (eventId) await createCaseFromAgentEvent(eventId, btn);
});
});
@@ -4090,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 ? 'Analyzing…' : (event.deep_analyzed ? 'Re-Analyze' : 'Deep Analyze');
- 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 `
@@ -4109,10 +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}
`;
}
@@ -4194,7 +4257,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 +4331,7 @@ async function refreshAgentStatus() {
async function startAgentsMonitoring() {
const config = getAgentsConfigFromUI();
agentsState.subsystemHealth = {};
+ agentsState.subsystemOrder = [];
agentsState.subsystemHistory = {};
agentsState.agentStates = {};
agentsState.selectedSubsystemId = null;
@@ -4354,6 +4418,1197 @@ function upsertRealtimeAgentEvent(payload) {
renderSubsystemHealthGrid();
}
+// ============================================
+// Cases Tab — Investigation workspace
+// ============================================
+
+const casesState = {
+ initialized: false,
+ cases: [],
+ selectedCaseId: null,
+ currentCase: null,
+ currentReport: null,
+ isLoadingList: false,
+ isLoadingDetail: false,
+ statusOverride: null,
+ actionState: null,
+ assistantSummaryPending: false,
+ logStreamIds: new Set(),
+ logListenersReady: false,
+ assistantSessions: {},
+ assistantListenersReady: false,
+ listRequestSeq: 0,
+ detailRequestSeq: 0,
+};
+
+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'),
+ 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 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();
+ if (!options.skipRenderDetail) 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
+ ? `
+
+
Tool Calls
+
${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}
+
+
Response
+
${escapeHtml(responseText || '')}
+
+
+ `;
+ }).join('');
+}
+
+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.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 (result?.success === false || !summary) {
+ completeCaseStream(stream.streamId, false);
+ casesState.assistantSummaryPending = false;
+ refreshCaseAssistantSummaryButton();
+ setCasesStatusOverride('Error', result.error || 'Failed to summarize investigator conversation', 'error');
+ return;
+ }
+
+ const explanationInput = document.getElementById('case-explanation-input');
+ 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() {
+ 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}
+
+
Response
+
${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) {
+ const toolsSection = document.createElement('div');
+ toolsSection.className = 'case-assistant-section';
+ toolsSection.innerHTML = `
Tool Calls
`;
+ 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(toolsSection, responseSection || responseEl);
+ } else {
+ turnEl.appendChild(toolsSection);
+ }
+ }
+ 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 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;
+
+ 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);
+ refreshCaseAssistantSummaryButton();
+ });
+}
+
+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;
+ 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.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 (tone) el.statusChip.classList.add(tone);
+ el.statusChip.textContent = chipText;
+ }
+ if (el.statusText) {
+ 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();
+ 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.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();
+ 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 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}`),
+ ];
+ 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 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';
+ const actionStatusHtml = actionState?.message
+ ? `
${escapeHtml(actionState.message)}
`
+ : '';
+
+ el.detail.innerHTML = `
+
+
+
+
+
+
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}
+
+ ${hasDraft ? `
+
+
+ ${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-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);
+
+ 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 });
+ 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) {
+ 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) {
+ 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;
+ }
+
+ 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;
+ clearCasesStatusOverride();
+ renderCaseDetail();
+}
+
+async function createCaseFromAgentEvent(eventId, btnEl) {
+ const event = agentsState.events.find((item) => item.event_id === eventId);
+ if (!event) return;
+ 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;
+ }
+ 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 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();
+ try {
+ 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');
+ if (!casesState.initialized) initCasesTab();
+ await loadCases(caseResult.case.case_id);
+ } finally {
+ agentsState.pendingDeepAnalyze.delete(eventId);
+ if (clearActionStatusOnExit) clearAgentEventActionStatus(eventId);
+ renderSubsystemHealthGrid();
+ }
+}
+
+async function generateSelectedCaseDraft() {
+ if (!casesState.currentCase?.case_id) 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 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 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 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 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);
+ 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 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`,
+ };
+ if (result.case) {
+ casesState.currentCase = result.case;
+ }
+ clearCasesStatusOverride();
+ clearCaseActionState();
+ renderCaseDetail();
+}
+
+async function saveSelectedCaseReport() {
+ if (!casesState.currentReport) return;
+ 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);
+ el.btnSaveReport?.addEventListener('click', saveSelectedCaseReport);
+ loadCases(casesState.selectedCaseId);
+}
+
function ensureAgentListeners() {
if (agentsState.listenersReady) return;
agentsState.listenersReady = true;
@@ -4448,6 +5703,10 @@ navButtons.forEach(btn => {
if (btn.dataset.tab === 'agents') {
setTimeout(initAgentsTab, 100);
}
+ if (btn.dataset.tab === 'cases') {
+ setCasesStatusOverride('Loading', casesState.initialized ? 'Refreshing investigations...' : 'Loading investigations...', 'pending');
+ setTimeout(initCasesTab, 100);
+ }
});
});
@@ -4460,5 +5719,6 @@ setTimeout(() => {
loadSettings();
loadDbConnections();
ensureAgentListeners();
+ initCasesTab();
}, 500);
diff --git a/electron-ui/styles.css b/electron-ui/styles.css
index 35e7ffc..56ed5a4 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 {
@@ -3028,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);
@@ -3677,9 +3711,581 @@ 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);
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-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);
+ 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-loading {
+ color: var(--color-text-secondary);
+}
+
+.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-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;
+ 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-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);
+ 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-assistant-transcript {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-3);
+ flex: 1;
+ min-height: 280px;
+ overflow-y: auto;
+}
+
+.case-assistant-turn {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-2);
+ min-width: 0;
+}
+
+.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-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);
+ border-radius: var(--radius-md);
+ padding: var(--space-3);
+ 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 {
+ 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);
+ 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: stretch;
+}
+
+.case-assistant-input-row .input {
+ 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 {
+ 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: 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,
+ .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..a6de375
--- /dev/null
+++ b/scripts/case_api.py
@@ -0,0 +1,156 @@
+#!/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_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)
+
+ 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 == "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:
+ 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/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/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/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())
diff --git a/scripts/neo4j_ontology.py b/scripts/neo4j_ontology.py
index f2a9efc..defc57a 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,571 @@ 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,
+ operator_context: '',
+ 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",
+ "operator_context",
+ "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)
+
+ 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."""
+ 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"),
+ "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")),
+ }
+
+ 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) + "."
+ 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}{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."
+ ),
+ "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
+ import sys
+
+ 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."
+ ),
+ 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,
+ "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=True,
+ 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({
+ "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 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(
+ """
+ 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 Context",
+ case.get("operator_context") or "No operator context recorded.",
+ "",
+ "## 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"},
diff --git a/scripts/troubleshoot.py b/scripts/troubleshoot.py
index 149796b..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."""
@@ -148,7 +159,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 +175,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 +210,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}
@@ -196,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.
@@ -210,10 +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", [])
-
- result = ask_single(question, history=history, verbose=verbose)
+ turns = data.get("turns", [])
+ context = data.get("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: