From e069869a943207cad520513ed6c4157a290c9213 Mon Sep 17 00:00:00 2001 From: Tomas Kracmar Date: Wed, 22 Apr 2026 07:12:10 +0200 Subject: [PATCH] feat: exclude Teams from defaults + GUID resolution in explain - Add Teams to noisy services excluded by default (frontend + backend ask) - Exchange, SharePoint, and Teams now unchecked by default in filters - Enhance explain endpoint with GUID resolution: * Extract UUIDs from raw event JSON recursively * Resolve directory objects via Graph API (user, group, SP, device) * Include resolved names in LLM prompt so explanations reference human-readable names instead of raw GUIDs - Add asyncio import for to_thread wrapper around sync Graph calls --- backend/frontend/index.html | 4 +-- backend/routes/ask.py | 67 +++++++++++++++++++++++++++++++++++-- 2 files changed, 67 insertions(+), 4 deletions(-) diff --git a/backend/frontend/index.html b/backend/frontend/index.html index 1d7b195..157a264 100644 --- a/backend/frontend/index.html +++ b/backend/frontend/index.html @@ -522,7 +522,7 @@ const saved = localStorage.getItem('aoc_filters'); if (!saved && this.options.services.length) { // Default: exclude noisy high-volume services - const noisy = ['Exchange', 'SharePoint']; + const noisy = ['Exchange', 'SharePoint', 'Teams']; this.filters.selectedServices = this.options.services.filter((s) => !noisy.includes(s)); } else if (saved) { try { @@ -617,7 +617,7 @@ }, clearFilters() { - const noisy = ['Exchange', 'SharePoint']; + const noisy = ['Exchange', 'SharePoint', 'Teams']; this.filters = { actor: '', selectedServices: this.options.services.filter((s) => !noisy.includes(s)), search: '', operation: '', result: '', start: '', end: '', limit: 100, includeTags: '', excludeTags: '' }; this.saveFilters(); this.resetPagination(); diff --git a/backend/routes/ask.py b/backend/routes/ask.py index d855e39..23bab4a 100644 --- a/backend/routes/ask.py +++ b/backend/routes/ask.py @@ -1,3 +1,4 @@ +import asyncio import json import re from datetime import UTC, datetime, timedelta @@ -49,7 +50,7 @@ _SERVICE_INTENTS = { # Services that are extremely noisy for typical admin questions. # We exclude them by default on broad questions unless the user explicitly mentions them. -_NOISY_SERVICES = {"Exchange", "SharePoint"} +_NOISY_SERVICES = {"Exchange", "SharePoint", "Teams"} # Services that are generally admin-relevant and kept by default. _DEFAULT_ADMIN_SERVICES = { @@ -471,11 +472,73 @@ Do not invent facts that are not in the data. """ +_GUID_RE = re.compile(r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$") + + +def _extract_guids(obj: dict | list | str) -> set[str]: + """Recursively extract UUID-like strings from a JSON structure.""" + guids = set() + if isinstance(obj, dict): + for k, v in obj.items(): + if k.lower() in ("id", "groupid", "userid", "targetid") and isinstance(v, str) and _GUID_RE.match(v): + guids.add(v) + guids.update(_extract_guids(v)) + elif isinstance(obj, list): + for item in obj: + guids.update(_extract_guids(item)) + elif isinstance(obj, str) and _GUID_RE.match(obj): + guids.add(obj) + return guids + + +async def _resolve_guids_for_event(event: dict) -> dict[str, str]: + """Try to resolve GUIDs in an event to human-readable names via Graph API.""" + raw = event.get("raw") or {} + guids = _extract_guids(raw) + # Also include any GUIDs in targetResources that might not have displayName + for tr in raw.get("targetResources") or []: + tid = tr.get("id") + if tid and _GUID_RE.match(tid): + guids.add(tid) + for tr in raw.get("modifiedProperties") or []: + for key in ("oldValue", "newValue"): + val = tr.get(key) + if val and _GUID_RE.match(val): + guids.add(val) + + if not guids: + return {} + + try: + from graph.auth import get_access_token + from graph.resolve import resolve_directory_object + + token = await asyncio.to_thread(get_access_token) + cache: dict[str, dict] = {} + resolved = {} + for gid in guids: + result = await asyncio.to_thread(resolve_directory_object, gid, token, cache) + if result: + resolved[gid] = result["name"] + return resolved + except Exception as exc: + logger.warning("GUID resolution failed", error=str(exc)) + return {} + + async def _explain_event(event: dict, related: list[dict]) -> str: if not LLM_API_KEY: raise RuntimeError("LLM_API_KEY not configured") + # Resolve GUIDs to names before sending to LLM + resolved = await _resolve_guids_for_event(event) + event_text = json.dumps(event, indent=2, default=str) + resolution_text = "" + if resolved: + resolution_text = "\nResolved GUIDs:\n" + for gid, name in resolved.items(): + resolution_text += f" {gid} → {name}\n" related_text = "" if related: @@ -492,7 +555,7 @@ async def _explain_event(event: dict, related: list[dict]) -> str: {"role": "system", "content": _EXPLAIN_SYSTEM_PROMPT}, { "role": "user", - "content": f"Audit event:\n{event_text}{related_text}\n\nPlease explain this event.", + "content": f"Audit event:\n{event_text}{resolution_text}{related_text}\n\nPlease explain this event.", }, ]