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: