diff --git a/src/fgai/dashboard.py b/src/fgai/dashboard.py index 2cb2991..a893c51 100644 --- a/src/fgai/dashboard.py +++ b/src/fgai/dashboard.py @@ -176,16 +176,24 @@ function inferredProfileType(row) { } function profileDiscoveryDetails(row) { const d = row.discovery || {}; + const detailId = `profile-discovery:${row.stream_id || row.stream_name || ''}:${d.field_count || 0}`; if (!d.field_count) { const common = (row.common_fields || []).slice(0,8).map(item => `${item.field} ${(Number(item.coverage || 0)*100).toFixed(0)}%/${item.unique_values}`).join(', '); - return `
waiting for discovery data

This row was produced before profile discovery metadata was added. Wait for the next monitor cycle after updating, or re-apply/edit the profile.

Observed common fields
${esc(common || '-')}

`; + return `
waiting for discovery data

This row was produced before profile discovery metadata was added. Wait for the next monitor cycle after updating, or re-apply/edit the profile.

Observed common fields
${esc(common || '-')}

`; } const selected = d.selected_fields || {}; const top = (d.top_fields || []).slice(0,8).map(item => `${item.field} ${(Number(item.coverage || 0)*100).toFixed(0)}%/${item.unique_values}`).join(', '); const shared = (d.shared_fields || row.shared_fields || []).slice(0,8).map(item => `${item.field} (${item.streams} streams)`).join(', '); const rejected = (d.rejected_fields || []).slice(0,8).map(item => `${item.field}: ${item.reason}`).join('; '); const reasons = (d.reasons || []).join('; '); - return `
${esc(d.field_count)} fields analyzed

Selected
Entity: ${esc((selected.entity || []).join(', ') || '-')}
Time: ${esc((selected.time || []).join(', ') || '-')}
Categorical: ${esc((selected.categorical || []).join(', ') || '-')}
Numeric: ${esc((selected.numeric || []).join(', ') || '-')}

Shared across streams
${esc(shared || '-')}

Top fields
${esc(top || '-')}

Rejected
${esc(rejected || '-')}

${esc(reasons || '')}

`; + return `
${esc(d.field_count)} fields analyzed

Selected
Entity: ${esc((selected.entity || []).join(', ') || '-')}
Time: ${esc((selected.time || []).join(', ') || '-')}
Categorical: ${esc((selected.categorical || []).join(', ') || '-')}
Numeric: ${esc((selected.numeric || []).join(', ') || '-')}

Shared across streams
${esc(shared || '-')}

Top fields
${esc(top || '-')}

Rejected
${esc(rejected || '-')}

${esc(reasons || '')}

`; +} +function profileAdvisorLabel(row, advisor) { + const rowAdvisor = row.profile_advisor || {}; + if (rowAdvisor.status === 'heuristic' && rowAdvisor.error) return 'heuristic (advisor error)'; + if (rowAdvisor.status) return rowAdvisor.status; + if (advisor.enabled && advisor.status === 'error') return 'heuristic (advisor error)'; + return advisor.enabled ? (advisor.status || 'checking') : 'heuristic'; } function drawTrend(history) { const canvas=document.getElementById('trendChart'), ctx=canvas.getContext('2d'), ratio=window.devicePixelRatio||1, cw=canvas.clientWidth, ch=canvas.clientHeight; @@ -308,7 +316,8 @@ async function refresh() { `MCP aggregate events: ${esc(mcp.aggregate_events || 0)}`, `MCP raw sample events: ${esc(mcp.raw_events_fetched || mcp.events_fetched || 0)}`, `MCP coverage: ${esc(mcp.coverage_status || 'unknown')}${mcp.partial_streams ? ` (${esc(mcp.partial_streams)} partial)` : ''}${mcp.truncated_streams ? ` (${esc(mcp.truncated_streams)} truncated)` : ''}${mcp.sample_limited_streams ? ` (${esc(mcp.sample_limited_streams)} sample capped)` : ''}`, - mcp.coverage_warning ? `${esc(mcp.coverage_warning)}` : '' + mcp.coverage_warning ? `${esc(mcp.coverage_warning)}` : '', + advisor.status === 'error' ? `Profile advisor error: ${esc(advisor.error || 'unknown error')}` : '' ].filter(Boolean).join('
'); document.getElementById('health').innerHTML = [ metric('Enabled streams', enabledStreams.length), metric('Streams missing profile', streamsMissingProfile), metric('MCP aggregate events', mcp.aggregate_events || 0), metric('MCP raw sample', mcp.raw_events_fetched || mcp.events_fetched || 0), metric('Partial streams', mcp.partial_streams || 0), metric('Sample capped', mcp.sample_limited_streams || 0), metric('Truncated streams', mcp.truncated_streams || 0), metric('Correlated entities', correlations.length) @@ -324,7 +333,7 @@ async function refresh() { {label:'Events', key:'events'}, {label:'Type', render:r => esc(inferredProfileType(r))}, {label:'Confidence', key:'confidence'}, - {label:'Advisor', render:r => esc((r.profile_advisor || {}).status || (advisor.enabled ? advisor.status : 'heuristic'))}, + {label:'Advisor', render:r => esc(profileAdvisorLabel(r, advisor))}, {label:'Profile', render:r => r.profile_exists ? 'exists' : 'new'}, {label:'Entity fields', render:r => esc((r.entity_fields || []).join(', ') || '-')}, {label:'Time', key:'timestamp_field'}, diff --git a/src/fgai/llm.py b/src/fgai/llm.py index 1f89679..7448aee 100644 --- a/src/fgai/llm.py +++ b/src/fgai/llm.py @@ -7,6 +7,31 @@ from urllib import request from .models import BlockCandidate, Finding +def _json_object_from_text(text: str) -> dict[str, object]: + text = text.strip() + if text.startswith("```"): + lines = text.splitlines() + if lines and lines[0].startswith("```"): + lines = lines[1:] + if lines and lines[-1].strip() == "```": + lines = lines[:-1] + text = "\n".join(lines).strip() + try: + payload = json.loads(text) + return payload if isinstance(payload, dict) else {} + except json.JSONDecodeError: + pass + start = text.find("{") + end = text.rfind("}") + if start == -1 or end == -1 or end <= start: + return {} + try: + payload = json.loads(text[start:end + 1]) + return payload if isinstance(payload, dict) else {} + except json.JSONDecodeError: + return {} + + def ollama_summary( findings: list[Finding], candidates: list[BlockCandidate], @@ -121,6 +146,6 @@ def ollama_profile_advice(suggestions: list[dict[str, object]], model: str | Non req = request.Request(f"{host}/api/generate", data=body, method="POST", headers={"Content-Type": "application/json"}) with request.urlopen(req, timeout=selected_timeout) as response: data = json.loads(response.read().decode("utf-8")) - payload = json.loads(str(data.get("response", "{}"))) + payload = _json_object_from_text(str(data.get("response", "{}"))) profiles = payload.get("profiles", []) return profiles if isinstance(profiles, list) else [] diff --git a/src/fgai/monitor.py b/src/fgai/monitor.py index a2034e3..22de8b6 100644 --- a/src/fgai/monitor.py +++ b/src/fgai/monitor.py @@ -264,6 +264,8 @@ def build_status( profile_advisor_status = {"enabled": True, "status": "ok", "model": str(runtime_values.get("profile_advisor_model", "") or "qwen3:8b")} except Exception as exc: profile_advisor_status = {"enabled": True, "status": "error", "error": str(exc), "model": str(runtime_values.get("profile_advisor_model", "") or "qwen3:8b")} + for suggestion in profile_suggestions: + suggestion.setdefault("profile_advisor", {"status": "heuristic", "error": str(exc)}) intel_ips = sorted( { ip diff --git a/tests/test_llm.py b/tests/test_llm.py new file mode 100644 index 0000000..0f40c0b --- /dev/null +++ b/tests/test_llm.py @@ -0,0 +1,23 @@ +import unittest + +from fgai.llm import _json_object_from_text + + +class LlmTests(unittest.TestCase): + def test_json_object_from_text_accepts_markdown_wrapped_json(self): + payload = _json_object_from_text( + '```json\n{"profiles":[{"stream_id":"windows","entity_fields":["username"]}]}\n```' + ) + + self.assertEqual(payload["profiles"][0]["stream_id"], "windows") + + def test_json_object_from_text_extracts_object_from_extra_text(self): + payload = _json_object_from_text( + 'Here is the profile:\n{"profiles":[{"stream_id":"firewall"}]}\nDone.' + ) + + self.assertEqual(payload["profiles"][0]["stream_id"], "firewall") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_monitor.py b/tests/test_monitor.py index 28f6452..c45580d 100644 --- a/tests/test_monitor.py +++ b/tests/test_monitor.py @@ -137,6 +137,8 @@ class MonitorTests(unittest.TestCase): advisor = status["capabilities"]["profile_advisor"] self.assertEqual(advisor["status"], "error") self.assertIn("timeout", advisor["error"]) + self.assertEqual(status["profile_suggestions"][0]["profile_advisor"]["status"], "heuristic") + self.assertIn("timeout", status["profile_suggestions"][0]["profile_advisor"]["error"]) def test_cached_status_with_error_keeps_last_good_dashboard_data(self): with tempfile.TemporaryDirectory() as tmp: