-
-
Notifications
You must be signed in to change notification settings - Fork 0
AI Features
All AI surfaces share a common infrastructure:
-
OpenAIClient(source/app/iris_engine/ai/openai_client.py) — thin wrapper around the OpenAI-compatible HTTP API; backends can be LM Studio or any OpenAI-compatible endpoint -
case_ai_artifacttable — content-addressed cache keyed oninput_hash(MD5 of prompt + payload); adding one IOC re-runs only the affected specialist, not the whole summary -
Async job queue —
POSTto any AI endpoint returns202 + task_id; a dedicatedai_workercontainer runs jobs off theai_queueso long LLM calls never block a gunicorn worker
/manage/settings → AI tab
Two-slot design — a Primary backend and an Alt backend. The active-slot radio switches all AI calls with no restart.
Per-feature overrides: a collapsible table at the bottom of the AI tab lets you pin individual surfaces to a specific slot regardless of the global radio.
| Backend | How to configure |
|---|---|
| LM Studio (local) | Primary slot URL: http://host.docker.internal:1234/v1
|
| OpenAI / Azure OpenAI | Any OpenAI-compatible URL + API key |
| Anthropic API | Any OpenAI-compatible gateway in front of the Claude API |
For AI features with a "reason then compose" shape, the compose stage routes to a smaller/faster sibling model:
| Configured model | Synthesizer routes to |
|---|---|
| Claude Opus / Sonnet | claude-haiku-4-5 |
| LM Studio / other | unchanged (same model) |
This is configured via SYNTHESIZER_FAST_MODEL_MAP in case_summary.py.
When adding a new "reason then compose" feature, add the pairing there.
Endpoint: POST /api/v2/cases/<id>/ai/summary
Prompt files: case_summary.md (synthesizer) + case_summary_{notes,timeline,iocs,assets}.md
Cache kind: case_summary:<domain> (specialists) + case_summary (final)
Multi-pass map-reduce:
- Four domain specialists (notes / timeline / IOCs / assets) run in a
ThreadPoolExecutor - A synthesizer composes a 7-section executive output from the specialists' output
Cold path: ~12 s on LM Studio gpt-oss-20b, ~26 ms on full cache hit.
Pool worker threads do NOT inherit the calling thread's Flask app context — each worker
pushes its own with app.app_context():. ORM objects created in worker context detach
on exit; return artifact IDs and re-fetch from the main thread.
?sync=true keeps the old inline path for scripted use.
The generated summary can be corrected by hand — see Manual override.
Endpoint: POST /api/v2/cases/<id>/ai/ask
Prompt files: case_chat_<variant>.md (falls back to case_chat.md)
Body: {question, history, variant?, csrf_token}
Available on six case-detail tabs: Notes, Timeline, Assets, IOC, Tasks, Evidence.
Each tab passes chat_variant to the include which loads the matching specialized prompt.
Multi-turn; client owns the history array.
?sync=true bypasses the job queue for scripted use.
Endpoint: POST /api/v2/cases/<id>/ai/timeline/events/<eid>/analysis
Prompt file: event_analysis.md
Cache kind: event_analysis:<event_id>
3 paragraphs: what the event detects / what likely happened / triage hint (80-160 words). Right slide-in opened by clicking a timeline card body.
Endpoint: POST /api/v2/cases/<id>/ai/timeline-analysis
Prompt file: case_timeline_analysis.md (prompt id TimelineNarrativeSystemPrompt-v4)
Cache kind: timeline_analysis
Three sections (prose only, 250-450 words):
- What the timeline tells us
- What's still uncertain
- Where to dig next
Flag-aware: is_flagged: false events contribute with HIGH confidence (reviewed fact);
is_flagged: true events contribute with MEDIUM confidence (provisional).
The panel header shows model · prompt_id · age from the cached artifact.
Endpoint: POST /api/v2/cases/<id>/ai/attack-suggestion
Stateless (not cached)
Returns up to 4 validated ATT&CK technique IDs plus a single UKC v1.3 phase.
Per-type regex shape validation, confidence ≥ 0.5 enforced server-side.
A Set Event Category button on the event modal auto-selects the matching dropdown option.
UKC phases are also wired into the Event Category dropdown (7 phases added via post_init).
Endpoint: POST /api/v2/cases/<id>/ai/ioc-extraction
Stateless (not cached)
Returns up to 10 IOC candidates with:
-
type_idpre-resolved against the liveIocTypetable - Per-type regex shape sanity (drops mis-classified IOCs)
- Noise flags (
⚠ Public DNS resolver,⚠ CDN domain, etc.) - Dedup against existing case IOCs (renders
in caseinstead of+ add)
Accept all is async/await-serialized to avoid celery prefork concurrency crashes.
Available in both the modal note editor and the inline (full-page) note editor.
+ add auto-fires POST /api/v2/cases/<id>/iocs/<id>/source-notes to create the IOC ↔ Note provenance link.
Endpoint: POST /api/v2/cases/<id>/ai/evidence-type-suggestion
Stateless (not cached)
Auto-fires alongside the hash/size step when the analyst clicks Process in the
Register Evidence modal. Reads filename + size + first 4 KB as hex (file never leaves
the browser via FileReader). Returns one validated EvidenceTypes catalog entry with
confidence + reason. Auto-applies to the dropdown; analyst override clears the chip.
Evidence type catalog is snapshotted at request time — admin-added types are picked up automatically.
Endpoint: POST /api/v2/alerts/<alert_id>/ai/case-template-suggestion
Stateless (not cached)
Auto-fires when the Escalate modal opens. Pulls alert title + description + source +
severity + classification + tags + first 20 IOCs + first 20 assets from the Alert ORM.
Returns one validated CaseTemplate with confidence + reason. Validated 13/13 MATCH
against the full template catalog at ≥ 0.92 confidence.
Regression harness: scripts/sim_alert_template_pick.py --all (exit 2 on any non-MATCH).
Endpoint: POST /api/v2/cases/<id>/ai/tag-suggestion (object-type variants)
Stateless (not cached)
Validates output against the bundled MISP catalog (169 taxonomies + 122 galaxies = 66,109 records). ✨ Suggest tags pill appears on IOC, asset, task, and event modals.
Endpoint: POST /api/v2/correlation/cluster-narrative
Prompt file: cluster_narrative.md (prompt id ClusterNarrativeSystemPrompt-v2)
Cache: case_ai_artifact anchored to min(cluster.case_ids), kind cluster_narrative:<cluster_id>
Generates a short narrative for a cross-case IOC cluster. Cached server-side;
force: true bypasses cache. Also cached client-side in CORR._narrativeCache[cluster_id]
for toggle-within-session without API calls.
Entity-name prohibition (v2 prompt, load-bearing for STIX safety): the prompt explicitly forbids echoing specific organization names, client names, or case identifiers in the output. Victims are described by sector role only. This makes cached narratives safe to embed in STIX bundles shared with third parties.
When bumping the prompt, update both the # ClusterNarrativeSystemPrompt-<N> header in
cluster_narrative.md AND the PROMPT_ID constant in cluster_narrative.py. Existing
cache entries miss automatically (input_hash includes full prompt text).
The narrative title and prose can be corrected by hand, and the correction also flows into the STIX export — see Manual override.
Two AI surfaces can be corrected by hand instead of re-rolling the model: the executive case summary and the AI cluster narrative. Both share the same mechanics and the same storage.
Regenerating is a poor fix for a summary that is 95% right. Manual override lets an analyst correct the wrong detail directly, while keeping the model's original output for comparison and revert.
case_ai_artifact carries three nullable columns (Alembic d3b8f5a1c674):
| Column | Meaning |
|---|---|
content |
The original model output, never overwritten by an edit |
edited_content |
The analyst's corrected text; takes display precedence while non-NULL |
edited_by_id |
FK to user (ON DELETE SET NULL) |
edited_at |
When the edit was saved |
CaseAiArtifact.display_content returns the edit when present and the model output
otherwise; CaseAiArtifact.is_edited is the flag. Read paths use display_content so a
correction propagates everywhere without per-consumer changes.
API shape: the serialized content field carries the corrected text when an edit
exists, with the untouched model output exposed as ai_content. Existing consumers
therefore pick up analyst corrections automatically rather than continuing to serve
superseded machine output.
Each generation INSERTS a new artifact row, and reads take the most recent one. An unguarded regeneration would therefore silently orphan an analyst's corrections rather than conflict with them.
Both surfaces refuse to regenerate over an edited artifact, returning HTTP 409 with
reason: manual_edit_present, unless the caller passes discard_edit=true:
POST /api/v2/cases/<cid>/ai/summary -> 409 (query param ?discard_edit=true)
POST /api/v2/correlation/cluster-narrative -> 409 (body field discard_edit: true)
The guard lives in the endpoint, not the browser, so the async job queue, scripts and API clients are all covered. The UI renders the 409 as a confirm dialog; declining restores the panel rather than leaving it blank.
response_api_error()'s second positional argument isdata, not a status code, and the function always emits 400 — it cannot express a conflict. The 409 bodies are built viaresponse()directly.
Editing flips the surface's badge from AI Generated to Edited by analyst and records who and when. Both surfaces offer View AI original (client-side toggle, no extra request — the model text ships alongside the edit) and Revert to AI, which clears the three columns and restores the original.
| Executive case summary | AI cluster narrative | |
|---|---|---|
| Editable | Whole summary (Markdown) | Campaign title + prose |
| Not editable | — |
confidence (grades the correlation data, not the wording) |
| Stale hint | Yes — warns when case data changed after the edit | No |
| Downstream effect | Any consumer reading the artifact | Also the STIX 2.1 export |
The summary's stale hint reuses the same cross-object recency signal the synthesizer
already computes (case_last_activity_at). It is advisory only and never overwrites
anything.
The cluster narrative feeds the STIX export. A corrected title becomes
campaign.name and corrected prose becomes campaign.description, so partners receive
the analyst's version. The v2 prompt's entity-name prohibition constrains model output;
text you write by hand is your own responsibility.
| Method | Path | Description |
|---|---|---|
PUT |
/api/v2/cases/<cid>/ai/summary/edit |
Save a correction (body: content) |
DELETE |
/api/v2/cases/<cid>/ai/summary/edit |
Revert to the AI original |
PUT |
/api/v2/correlation/cluster-narrative/edit |
Save (body: cluster_id, case_ids, suggested_name, narrative) |
DELETE |
/api/v2/correlation/cluster-narrative/edit |
Revert to the AI original |
Both correlation routes resolve the artifact anchor as min(case_ids) — the same
convention generate_cluster_narrative uses.
The columns live on case_ai_artifact, so any cached AI surface can adopt this without a
migration. Four steps:
- Read through
display_contentinstead ofcontentwherever the artifact is rendered. - Add save/revert helpers that write
edited_content/edited_by_id/edited_at. - Add the 409 guard to that surface's generate endpoint, keyed on
is_edited. - Surface the badge, View AI original and Revert to AI in the panel.
POST to /api/v2/cases/<id>/ai/summary or .../ai/ask returns:
{"task_id": "abc123", "state": "queued"}Poll:
GET /api/v2/ai/jobs/<task_id>
→ {state: "running"|"done"|"error"|"cancelled", result: {...}}
DELETE /api/v2/ai/jobs/<task_id> # cancel a queued job
GET /api/v2/ai/jobs?case_id=<id>&state=<state> # list jobs
Adding a new async surface: add one entry to the FEATURES registry in
source/app/iris_engine/ai/ai_jobs.py ({runner, kind: 'artifact'|'dict', priority}).
No dispatcher edits needed.
OpenAIClient.extract_content() strips internal chain-of-thought before returning text
to orchestrators. Callers receive clean JSON/text and need no per-feature handling.
| Format | Models | Stripping |
|---|---|---|
| Gemma-4 channel format | Gemma-4, Gemma-4-e4b | Extracts everything after the last <|channel>output marker; if the output channel is empty, falls back to _last_json_object(thought) which walks the thought channel backwards for the last valid JSON block |
<think> tags |
DeepSeek R1, Qwen-thinking | Strips <think>…</think> with re.DOTALL; also handles truncated (unclosed) tags |
content = None is normalised to "" before stripping.
Reasoning models spend 500–1 000 tokens on the thinking step before emitting output.
Budget default_max_tokens = thinking_budget + output_budget:
- The ATT&CK suggester uses
default_max_tokens = 2 000(was 800 — reasoning models hitfinish_reason=lengthmid-JSON fence at 800) - Short JSON responses still need at least 200–400 tokens above expected output size
- When adding a new orchestrator, test with a small
max_tokensfirst to surface truncation
Diagnostic: "AI backend returned non-JSON content (parse error: Expecting value: line 1 column 1 (char 0))" → model completed its thinking channel but emitted nothing in the output channel; either max_tokens is too low or _last_json_object found no valid JSON in the thought.
Three layers all need matching headroom when adding any synchronous AI path:
| Layer | Setting | Current value |
|---|---|---|
OpenAIClient.timeout |
urllib socket timeout | per surface — see below |
nginx proxy_read_timeout
|
AI endpoint location block | 10 min |
Gunicorn --timeout
|
worker ceiling | 600 s (baked into entrypoint, needs rebuild) |
Per-surface client timeouts:
| Surface | Timeout |
|---|---|
| Executive case summary, case chat | 600 s |
| Tag / ATT&CK / evidence-type / case-template suggesters, IOC extractor | 180 s |
| Cluster narrative | 120 s |
These are ceilings, not waits. A backend that answers in two seconds is unaffected by a 180 s limit.
Raised from 60–90 s in
IRIS-NG-v1.1.0. The original values assumed a local model answering in seconds. A backend that reaches a hosted API — particularly one that spawns a subprocess per request — takes an order of magnitude longer: a measured 28–34 s for a single tag suggestion, against a 60 s ceiling. That is under the limit only until any variance or concurrency pushes it over, and the failure looks like a generic error rather than a timeout. If you run a slower backend than these values allow, raise thetimeout=argument in the orchestrator'sbuild_default_client(...)call; the nginx and gunicorn layers already permit far more.
A backend rejects a name it does not recognise, and the failure surfaces as a generic AI error rather than "unknown model". Two things to check:
-
Separators. Model identifiers use hyphens throughout.
claude-haiku-4.5with a dot is not the same string asclaude-haiku-4-5and will not resolve. -
What the backend actually offers. Any OpenAI-compatible backend exposes
GET /v1/models. Ask it rather than guessing:curl -s http://<backend-host>:<port>/v1/models | python -m json.tool
Use an identifier from that list verbatim in the AI tab.
Some hosted models apply safety classification and will refuse security content — IOCs, compromise details, attacker infrastructure — returning prose rather than the JSON the orchestrator expects. This is a decision by the model provider, not a fault in IRIS-NG.
Options: switch the slot to a backend that does not classify this content (a local model is the usual answer), pin only the affected surface with a per-feature override, or take up whatever verification programme the provider offers for security work.
From IRIS-NG-v1.1.0, the message shown includes what the backend actually said:
AI backend did not return JSON. Backend said: <the backend's own message>
Earlier releases showed only the JSON parse failure — typically
Expecting value: line 1 column 1 (char 0) — which was identical whether the model had
declined the request, the model name was wrong, or the response was empty. If you are on
an older release, the backend's message is in the application log:
docker logs --tail 100 iriswebapp_app | grep -i "returned non-JSON"Usually a timeout. Compare the elapsed time against the table above: a failure at almost exactly 60, 120, 180 or 600 seconds is the client timeout for that surface, not the model failing. The application log records the request start, so the gap to the error tells you which layer gave up.
- Create
source/app/iris_engine/ai/<name>.py - Call
OpenAIClient.build_default_client(feature='<prompt_file_stem>')— this makes the surface participate in per-feature backend routing automatically - Load the prompt from
source/app/resources/ai_prompts/<name>.md - Cache output in
case_ai_artifactwith a descriptivekinddiscriminator - If the orchestrator has a "reason then compose" shape, add the model pairing to
SYNTHESIZER_FAST_MODEL_MAPincase_summary.py - If it should be async, add it to
FEATURESinai_jobs.py - Add the
featurekey to the per-feature override table inmanage_srv_settings.html