diff --git a/VERSION b/VERSION
index 1cc5f65..867e524 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-1.1.0
\ No newline at end of file
+1.2.0
\ No newline at end of file
diff --git a/backend/frontend/index.html b/backend/frontend/index.html
index 0c0e79a..9f0f11b 100644
--- a/backend/frontend/index.html
+++ b/backend/frontend/index.html
@@ -50,6 +50,9 @@
/>
Ask
+
@@ -491,11 +494,29 @@
this.askAnswer = '';
this.askAnswerHtml = '';
this.askEvents = [];
+ this.askLlmError = '';
+
+ const payload = { question: q };
+ if (this.filters.selectedServices && this.filters.selectedServices.length) {
+ payload.services = this.filters.selectedServices;
+ }
+ if (this.filters.actor) payload.actor = this.filters.actor;
+ if (this.filters.operation) payload.operation = this.filters.operation;
+ if (this.filters.result) payload.result = this.filters.result;
+ if (this.filters.start) payload.start = new Date(this.filters.start).toISOString();
+ if (this.filters.end) payload.end = new Date(this.filters.end).toISOString();
+ if (this.filters.includeTags) {
+ payload.include_tags = this.filters.includeTags.split(/[,;]+/).map(t => t.trim()).filter(Boolean);
+ }
+ if (this.filters.excludeTags) {
+ payload.exclude_tags = this.filters.excludeTags.split(/[,;]+/).map(t => t.trim()).filter(Boolean);
+ }
+
try {
const res = await fetch('/api/ask', {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...this.authHeader() },
- body: JSON.stringify({ question: q }),
+ body: JSON.stringify(payload),
});
if (!res.ok) throw new Error(await res.text());
const body = await res.json();
@@ -532,6 +553,27 @@
.replace(/\n/g, ' ');
},
+ hasActiveFilters() {
+ return this.filters.actor || this.filters.operation || this.filters.result ||
+ this.filters.start || this.filters.end || this.filters.includeTags ||
+ this.filters.excludeTags ||
+ (this.filters.selectedServices && this.filters.selectedServices.length &&
+ this.filters.selectedServices.length < this.options.services.length);
+ },
+
+ activeFilterSummary() {
+ const parts = [];
+ if (this.filters.actor) parts.push('actor');
+ if (this.filters.operation) parts.push('action');
+ if (this.filters.result) parts.push('result');
+ if (this.filters.start || this.filters.end) parts.push('time');
+ if (this.filters.includeTags || this.filters.excludeTags) parts.push('tags');
+ const svcCount = this.filters.selectedServices?.length || 0;
+ const allCount = this.options.services?.length || 0;
+ if (svcCount && svcCount < allCount) parts.push(`${svcCount} service${svcCount === 1 ? '' : 's'}`);
+ return parts.join(', ') || 'none';
+ },
+
async bulkTagMatching() {
const tag = prompt('Enter tag to apply to all matching events:');
if (!tag || !tag.trim()) return;
diff --git a/backend/frontend/style.css b/backend/frontend/style.css
index 1b18cd0..f9c9627 100644
--- a/backend/frontend/style.css
+++ b/backend/frontend/style.css
@@ -428,6 +428,11 @@ input {
margin-bottom: 10px;
}
+.ask-filter-hint {
+ margin-top: 6px;
+ color: var(--muted);
+}
+
.ask-events {
margin-bottom: 14px;
}
diff --git a/backend/models/api.py b/backend/models/api.py
index e7ff83a..f495b20 100644
--- a/backend/models/api.py
+++ b/backend/models/api.py
@@ -74,6 +74,14 @@ class AlertRuleResponse(BaseModel):
class AskRequest(BaseModel):
question: str
+ services: list[str] | None = None
+ actor: str | None = None
+ operation: str | None = None
+ result: str | None = None
+ start: str | None = None
+ end: str | None = None
+ include_tags: list[str] | None = None
+ exclude_tags: list[str] | None = None
class AskEventRef(BaseModel):
diff --git a/backend/routes/ask.py b/backend/routes/ask.py
index 1884df3..9980b11 100644
--- a/backend/routes/ask.py
+++ b/backend/routes/ask.py
@@ -104,7 +104,17 @@ def _extract_entity(question: str) -> str | None:
# ---------------------------------------------------------------------------
-def _build_event_query(entity: str | None, start: str | None, end: str | None) -> dict:
+def _build_event_query(
+ entity: str | None,
+ start: str | None,
+ end: str | None,
+ services: list[str] | None = None,
+ actor: str | None = None,
+ operation: str | None = None,
+ result: str | None = None,
+ include_tags: list[str] | None = None,
+ exclude_tags: list[str] | None = None,
+) -> dict:
filters = []
if start or end:
@@ -128,6 +138,28 @@ def _build_event_query(entity: str | None, start: str | None, end: str | None) -
}
)
+ if services:
+ filters.append({"service": {"$in": services}})
+ if actor:
+ actor_safe = re.escape(actor)
+ filters.append(
+ {
+ "$or": [
+ {"actor_display": {"$regex": actor_safe, "$options": "i"}},
+ {"actor_upn": {"$regex": actor_safe, "$options": "i"}},
+ {"actor.user.userPrincipalName": {"$regex": actor_safe, "$options": "i"}},
+ ]
+ }
+ )
+ if operation:
+ filters.append({"operation": {"$regex": re.escape(operation), "$options": "i"}})
+ if result:
+ filters.append({"result": {"$regex": re.escape(result), "$options": "i"}})
+ if include_tags:
+ filters.append({"tags": {"$all": include_tags}})
+ if exclude_tags:
+ filters.append({"tags": {"$not": {"$all": exclude_tags}}})
+
return {"$and": filters} if filters else {}
@@ -253,7 +285,17 @@ async def ask_question(body: AskRequest, user: dict = Depends(require_auth)):
start = (now - timedelta(days=7)).isoformat().replace("+00:00", "Z")
end = now.isoformat().replace("+00:00", "Z")
- query = _build_event_query(entity, start, end)
+ query = _build_event_query(
+ entity,
+ start,
+ end,
+ services=body.services,
+ actor=body.actor,
+ operation=body.operation,
+ result=body.result,
+ include_tags=body.include_tags,
+ exclude_tags=body.exclude_tags,
+ )
try:
cursor = events_collection.find(query).sort([("timestamp", -1)]).limit(LLM_MAX_EVENTS)
diff --git a/backend/tests/test_ask.py b/backend/tests/test_ask.py
index 1c9544c..697875e 100644
--- a/backend/tests/test_ask.py
+++ b/backend/tests/test_ask.py
@@ -277,3 +277,76 @@ class TestAskEndpoint:
assert data["llm_used"] is False # Falls back
assert len(data["events"]) == 1
assert "Found 1 event" in data["answer"]
+
+ def test_ask_with_explicit_filters(self, client, mock_events_collection):
+ now = datetime.now(UTC)
+ mock_events_collection.insert_one(
+ {
+ "id": "evt-exchange",
+ "timestamp": now.isoformat(),
+ "service": "Exchange",
+ "operation": "Update",
+ "result": "failure",
+ "actor_display": "Alice",
+ "target_displays": ["LAPTOP-001"],
+ "display_summary": "summary",
+ "raw_text": "raw",
+ }
+ )
+ mock_events_collection.insert_one(
+ {
+ "id": "evt-directory",
+ "timestamp": now.isoformat(),
+ "service": "Directory",
+ "operation": "Add user",
+ "result": "success",
+ "actor_display": "Alice",
+ "target_displays": ["LAPTOP-001"],
+ "display_summary": "summary",
+ "raw_text": "raw",
+ }
+ )
+ response = client.post(
+ "/api/ask",
+ json={"question": "What happened to LAPTOP-001?", "services": ["Exchange"], "result": "failure"},
+ )
+ assert response.status_code == 200
+ data = response.json()
+ assert data["query_info"]["event_count"] == 1
+ assert data["events"][0]["id"] == "evt-exchange"
+
+ def test_ask_with_explicit_actor_filter(self, client, mock_events_collection):
+ now = datetime.now(UTC)
+ mock_events_collection.insert_one(
+ {
+ "id": "evt-bob",
+ "timestamp": now.isoformat(),
+ "service": "Directory",
+ "operation": "Add user",
+ "result": "success",
+ "actor_display": "Bob",
+ "actor_upn": "bob@example.com",
+ "target_displays": ["USER-001"],
+ "display_summary": "summary",
+ "raw_text": "raw",
+ }
+ )
+ mock_events_collection.insert_one(
+ {
+ "id": "evt-alice",
+ "timestamp": now.isoformat(),
+ "service": "Directory",
+ "operation": "Remove user",
+ "result": "success",
+ "actor_display": "Alice",
+ "actor_upn": "alice@example.com",
+ "target_displays": ["USER-001"],
+ "display_summary": "summary",
+ "raw_text": "raw",
+ }
+ )
+ response = client.post("/api/ask", json={"question": "What happened to USER-001?", "actor": "bob"})
+ assert response.status_code == 200
+ data = response.json()
+ assert data["query_info"]["event_count"] == 1
+ assert data["events"][0]["id"] == "evt-bob"