From 5c3f43a596cb9d5c6a1f405a0deae1ac7f103ddb5e38b73cad39b376462d0155 Mon Sep 17 00:00:00 2001 From: larssand Date: Mon, 6 Jul 2026 11:21:41 +0200 Subject: [PATCH] advisor runs to update new and existing profiles if new field occur --- src/fgai/monitor.py | 51 +++++++++++++++++++++++++++++++++++++++++-- tests/test_monitor.py | 42 +++++++++++++++++++++++++++++++++++ 2 files changed, 91 insertions(+), 2 deletions(-) diff --git a/src/fgai/monitor.py b/src/fgai/monitor.py index 24b9ddb..83f424f 100644 --- a/src/fgai/monitor.py +++ b/src/fgai/monitor.py @@ -79,6 +79,50 @@ def _public_runtime_config(config: dict[str, object]) -> dict[str, object]: return public +def _profile_field_set(profile: object | None) -> set[str]: + if profile is None: + return set() + fields = { + str(getattr(profile, "entity_field", "") or ""), + str(getattr(profile, "timestamp_field", "") or ""), + } + fields.update(str(field) for field in getattr(profile, "entity_fields", ()) if field) + fields.update(str(field) for field in getattr(profile, "categorical_fields", ()) if field) + fields.update(str(field) for field in getattr(profile, "numeric_fields", ()) if field) + for relation in getattr(profile, "relationship_fields", ()): + fields.add(str(getattr(relation, "left", "") or "")) + fields.add(str(getattr(relation, "right", "") or "")) + return {field for field in fields if field} + + +def _suggestion_field_set(suggestion: dict[str, object]) -> set[str]: + profile = suggestion.get("profile", {}) if isinstance(suggestion.get("profile"), dict) else {} + fields = { + str(profile.get("entity_field", "") or ""), + str(profile.get("timestamp_field", "") or ""), + } + for key in ("entity_fields", "categorical_fields", "numeric_fields"): + value = profile.get(key, []) + if isinstance(value, list): + fields.update(str(field) for field in value if field) + relationships = profile.get("relationship_fields", []) + if isinstance(relationships, list): + for relation in relationships: + if isinstance(relation, dict): + fields.add(str(relation.get("left", "") or "")) + fields.add(str(relation.get("right", "") or "")) + return {field for field in fields if field} + + +def _needs_profile_advisor(suggestion: dict[str, object], stream_profiles: dict[str, object]) -> bool: + if not suggestion.get("profile_exists"): + return True + stream_id = str(suggestion.get("stream_id", "")) + suggested_fields = _suggestion_field_set(suggestion) + existing_fields = _profile_field_set(stream_profiles.get(stream_id)) + return bool(suggested_fields - existing_fields) + + def _stream_name(stream_id: str, stream_titles: dict[str, str], profile: object | None = None) -> str: return stream_titles.get(stream_id) or getattr(profile, "name", "") or stream_id @@ -375,10 +419,13 @@ def build_status( discovery_cache_events = discovery_store.synthetic_events() profile_suggestions = suggest_stream_profiles([*discovery_cache_events, *events], existing_profiles=stream_profiles) profile_advisor_status = {"enabled": bool(runtime_values.get("profile_advisor_enabled")), "status": "disabled"} - if runtime_values.get("profile_advisor_enabled") and profile_suggestions: + advisor_candidates = [item for item in profile_suggestions if _needs_profile_advisor(item, stream_profiles)] + if runtime_values.get("profile_advisor_enabled") and profile_suggestions and not advisor_candidates: + profile_advisor_status = {"enabled": True, "status": "skipped_no_profile_changes", "reason": "No missing profiles or newly discovered profile fields need advisor review."} + elif runtime_values.get("profile_advisor_enabled") and profile_suggestions: try: advice = ollama_profile_advice( - profile_suggestions, + advisor_candidates[:10], model=str(runtime_values.get("profile_advisor_model", "") or "qwen3:8b"), timeout=int(runtime_values.get("profile_advisor_timeout", 120) or 120), ) diff --git a/tests/test_monitor.py b/tests/test_monitor.py index 77dd8d5..63259e8 100644 --- a/tests/test_monitor.py +++ b/tests/test_monitor.py @@ -201,6 +201,48 @@ class MonitorTests(unittest.TestCase): self.assertEqual(status["profile_suggestions"][0]["profile_advisor"]["status"], "heuristic") self.assertIn("timeout", status["profile_suggestions"][0]["profile_advisor"]["error"]) + def test_profile_advisor_skips_when_all_streams_have_profiles(self): + with tempfile.TemporaryDirectory() as tmp: + log_path = Path(tmp) / "events.log" + log_path.write_text("fgai_stream_id=windows fgai_stream=Windows username=alice\n", encoding="utf-8") + config_path = Path(tmp) / "config.json" + config_path.write_text( + json.dumps( + { + "profile_advisor_enabled": True, + "graylog_stream_profiles": [{"stream_id": "windows", "name": "Windows profile", "entity_field": "username", "entity_fields": ["username"], "timestamp_field": "timestamp"}], + } + ), + encoding="utf-8", + ) + + with patch("fgai.monitor.ollama_profile_advice") as advisor: + status = build_status(str(log_path), config_path=str(config_path), incident_path=str(Path(tmp) / "incidents.json")) + + advisor.assert_not_called() + self.assertEqual(status["capabilities"]["profile_advisor"]["status"], "skipped_no_profile_changes") + + def test_profile_advisor_runs_when_existing_profile_has_new_fields(self): + with tempfile.TemporaryDirectory() as tmp: + log_path = Path(tmp) / "events.log" + log_path.write_text("fgai_stream_id=windows fgai_stream=Windows username=alice eventid=4625 action=failure\n", encoding="utf-8") + config_path = Path(tmp) / "config.json" + config_path.write_text( + json.dumps( + { + "profile_advisor_enabled": True, + "graylog_stream_profiles": [{"stream_id": "windows", "name": "Windows profile", "entity_field": "username", "entity_fields": ["username"], "timestamp_field": "timestamp"}], + } + ), + encoding="utf-8", + ) + + with patch("fgai.monitor.ollama_profile_advice", return_value=[]) as advisor: + status = build_status(str(log_path), config_path=str(config_path), incident_path=str(Path(tmp) / "incidents.json")) + + advisor.assert_called_once() + self.assertEqual(status["capabilities"]["profile_advisor"]["status"], "empty") + def test_profile_suggestions_use_cached_discovered_fields(self): with tempfile.TemporaryDirectory() as tmp: history_path = str(Path(tmp) / "history.sqlite3")