Skip to content

AI Features

zach115th edited this page Aug 5, 2026 · 7 revisions

AI Features

Overview

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_artifact table — content-addressed cache keyed on input_hash (MD5 of prompt + payload); adding one IOC re-runs only the affected specialist, not the whole summary
  • Async job queuePOST to any AI endpoint returns 202 + task_id; a dedicated ai_worker container runs jobs off the ai_queue so long LLM calls never block a gunicorn worker

Backend configuration

Admin UI

/manage/settingsAI 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.

Supported backends

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

Synthesizer model routing

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.

Surfaces

Executive case summary

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:

  1. Four domain specialists (notes / timeline / IOCs / assets) run in a ThreadPoolExecutor
  2. 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.

Case-scoped chat assistant

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.

Per-event AI analysis drawer

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.

Running master-timeline analysis panel

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):

  1. What the timeline tells us
  2. What's still uncertain
  3. 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.

MITRE ATT&CK + Unified Kill Chain suggestion

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).

IOC extraction from notes

Endpoint: POST /api/v2/cases/<id>/ai/ioc-extraction
Stateless (not cached)

Returns up to 10 IOC candidates with:

  • type_id pre-resolved against the live IocType table
  • Per-type regex shape sanity (drops mis-classified IOCs)
  • Noise flags (⚠ Public DNS resolver, ⚠ CDN domain, etc.)
  • Dedup against existing case IOCs (renders in case instead 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.

AI-suggested evidence type

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.

AI-suggested case template on alert escalation

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).

AI tag suggester

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.

AI cluster narrative

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.

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.

Why it exists

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.

Storage

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.

Regeneration guard (load-bearing)

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 is data, not a status code, and the function always emits 400 — it cannot express a conflict. The 409 bodies are built via response() directly.

Provenance

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.

Per-surface behaviour

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.

Endpoints

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.

Extending it to another surface

The columns live on case_ai_artifact, so any cached AI surface can adopt this without a migration. Four steps:

  1. Read through display_content instead of content wherever the artifact is rendered.
  2. Add save/revert helpers that write edited_content / edited_by_id / edited_at.
  3. Add the 409 guard to that surface's generate endpoint, keyed on is_edited.
  4. Surface the badge, View AI original and Revert to AI in the panel.

Async job queue

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.

Reasoning model support

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.

max_tokens for reasoning models

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 hit finish_reason=length mid-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_tokens first 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.

Timeout chain

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.1. 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 the timeout= argument in the orchestrator's build_default_client(...) call; the nginx and gunicorn layers already permit far more.

Troubleshooting

Model identifiers are exact strings

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.5 with a dot is not the same string as claude-haiku-4-5 and 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.

A model declines the request

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.

Reading AI errors

From IRIS-NG-v1.1.1, 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"

Nothing happens, then a generic failure

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.

Adding a new AI orchestrator

  1. Create source/app/iris_engine/ai/<name>.py
  2. Call OpenAIClient.build_default_client(feature='<prompt_file_stem>') — this makes the surface participate in per-feature backend routing automatically
  3. Load the prompt from source/app/resources/ai_prompts/<name>.md
  4. Cache output in case_ai_artifact with a descriptive kind discriminator
  5. If the orchestrator has a "reason then compose" shape, add the model pairing to SYNTHESIZER_FAST_MODEL_MAP in case_summary.py
  6. If it should be async, add it to FEATURES in ai_jobs.py
  7. Add the feature key to the per-feature override table in manage_srv_settings.html

Clone this wiki locally