Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cf0283b20b | |||
| 28542f7b80 |
56
RELEASE_NOTES_v1.1.0.md
Normal file
56
RELEASE_NOTES_v1.1.0.md
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
# AOC v1.1.0 Release Notes
|
||||||
|
|
||||||
|
**Release date:** 2026-04-20
|
||||||
|
|
||||||
|
## What's new
|
||||||
|
|
||||||
|
### Natural language query (`/api/ask`)
|
||||||
|
Ask questions in plain English and get AI-generated answers backed by your audit logs.
|
||||||
|
|
||||||
|
- **Regex-based parsing** extracts time ranges (`last 3 days`, `yesterday`, `today`) and entities (`device ABC123`, `user bob@example.com`) without calling an LLM — fast and deterministic.
|
||||||
|
- **AI narrative summarisation** via any OpenAI-compatible API (OpenAI, Azure OpenAI, MS Foundry, Ollama). The LLM reads the matching events and writes a concise story for non-expert admins.
|
||||||
|
- **Graceful fallback** when no LLM is configured — returns a structured bullet list instead of a narrative.
|
||||||
|
- **Cited evidence** — every answer includes the raw events that back it up, so admins can verify claims.
|
||||||
|
|
||||||
|
### Azure OpenAI / MS Foundry support
|
||||||
|
- Automatic `api-key` header detection for Azure endpoints.
|
||||||
|
- `LLM_API_VERSION` config for Azure `api-version` query parameters.
|
||||||
|
- `max_completion_tokens` support for newer model deployments.
|
||||||
|
|
||||||
|
### Production hardening
|
||||||
|
- **Dockerfile:** runs as non-root user, uses Gunicorn + Uvicorn workers.
|
||||||
|
- **docker-compose.prod.yml:** MongoDB is internal-only (no host port exposure), health checks on all services, nginx reverse proxy with security headers.
|
||||||
|
- **nginx config:** gzip, security headers (`X-Frame-Options`, `X-Content-Type-Options`), ready for TLS.
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
- New **"Ask a question"** panel at the top of the page.
|
||||||
|
- Markdown rendering for LLM answers (bold, italic, code).
|
||||||
|
- Orange warning banner when LLM is not configured or fails.
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
- 29 new tests covering ask parsing, query building, and endpoint behaviour.
|
||||||
|
- 62 tests total, all passing.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Add to your `.env`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Required for AI narrative summarisation
|
||||||
|
LLM_API_KEY=your-key
|
||||||
|
LLM_BASE_URL=https://api.openai.com/v1
|
||||||
|
LLM_MODEL=gpt-4o-mini
|
||||||
|
LLM_MAX_EVENTS=50
|
||||||
|
LLM_TIMEOUT_SECONDS=30
|
||||||
|
LLM_API_VERSION= # set for Azure OpenAI, e.g. 2024-12-01-preview
|
||||||
|
```
|
||||||
|
|
||||||
|
## Upgrade notes
|
||||||
|
|
||||||
|
No breaking changes. Existing `/api/events`, filters, pagination, tags, and comments work unchanged.
|
||||||
|
|
||||||
|
## Docker image
|
||||||
|
|
||||||
|
```
|
||||||
|
git.cqre.net/cqrenet/aoc-backend:v1.1.0
|
||||||
|
```
|
||||||
@@ -50,6 +50,9 @@
|
|||||||
/>
|
/>
|
||||||
<button type="submit" :disabled="askLoading" x-text="askLoading ? 'Thinking…' : 'Ask'">Ask</button>
|
<button type="submit" :disabled="askLoading" x-text="askLoading ? 'Thinking…' : 'Ask'">Ask</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div x-show="hasActiveFilters()" class="ask-filter-hint">
|
||||||
|
<small>Respecting active filters: <span x-text="activeFilterSummary()"></span></small>
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
<template x-if="askAnswer">
|
<template x-if="askAnswer">
|
||||||
<div class="ask-result">
|
<div class="ask-result">
|
||||||
@@ -491,11 +494,29 @@
|
|||||||
this.askAnswer = '';
|
this.askAnswer = '';
|
||||||
this.askAnswerHtml = '';
|
this.askAnswerHtml = '';
|
||||||
this.askEvents = [];
|
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 {
|
try {
|
||||||
const res = await fetch('/api/ask', {
|
const res = await fetch('/api/ask', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json', ...this.authHeader() },
|
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());
|
if (!res.ok) throw new Error(await res.text());
|
||||||
const body = await res.json();
|
const body = await res.json();
|
||||||
@@ -532,6 +553,27 @@
|
|||||||
.replace(/\n/g, '<br>');
|
.replace(/\n/g, '<br>');
|
||||||
},
|
},
|
||||||
|
|
||||||
|
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() {
|
async bulkTagMatching() {
|
||||||
const tag = prompt('Enter tag to apply to all matching events:');
|
const tag = prompt('Enter tag to apply to all matching events:');
|
||||||
if (!tag || !tag.trim()) return;
|
if (!tag || !tag.trim()) return;
|
||||||
|
|||||||
@@ -428,6 +428,11 @@ input {
|
|||||||
margin-bottom: 10px;
|
margin-bottom: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ask-filter-hint {
|
||||||
|
margin-top: 6px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
.ask-events {
|
.ask-events {
|
||||||
margin-bottom: 14px;
|
margin-bottom: 14px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -74,6 +74,14 @@ class AlertRuleResponse(BaseModel):
|
|||||||
|
|
||||||
class AskRequest(BaseModel):
|
class AskRequest(BaseModel):
|
||||||
question: str
|
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):
|
class AskEventRef(BaseModel):
|
||||||
|
|||||||
@@ -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 = []
|
filters = []
|
||||||
|
|
||||||
if start or end:
|
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 {}
|
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")
|
start = (now - timedelta(days=7)).isoformat().replace("+00:00", "Z")
|
||||||
end = now.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:
|
try:
|
||||||
cursor = events_collection.find(query).sort([("timestamp", -1)]).limit(LLM_MAX_EVENTS)
|
cursor = events_collection.find(query).sort([("timestamp", -1)]).limit(LLM_MAX_EVENTS)
|
||||||
|
|||||||
@@ -277,3 +277,76 @@ class TestAskEndpoint:
|
|||||||
assert data["llm_used"] is False # Falls back
|
assert data["llm_used"] is False # Falls back
|
||||||
assert len(data["events"]) == 1
|
assert len(data["events"]) == 1
|
||||||
assert "Found 1 event" in data["answer"]
|
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"
|
||||||
|
|||||||
Reference in New Issue
Block a user