AI Operations

Configure LLM-powered analysis operations with structured output

The ai section defines one or more LLM operations that run on the fetched data. Each operation receives the records from the data phase, renders a prompt template, calls the configured LLM provider, validates the response against a JSON Schema, and stores or pushes the results.

ai:
  enabled: true
  operations:
    - name: anomaly_detection
      tags: [seismic, anomaly]
      prompt: |
        Analyze these {{.RecordCount}} records...
      output_schema:
        type: object
        required: [summary]
        properties:
          summary: { type: string }
      max_tokens: 2000
      temperature: 0
      output:
        store_insights: true
        insight_type: "anomaly"
        websocket_push: true

Operation structure

Each operation in the operations array defines a complete LLM interaction.

name string required
Unique name for this operation within the analysis definition. Used in logs and metrics. Example: "hotspot_index_scoring", "anomaly_detection".
tags string[]
Metadata labels for filtering and searching analysis definitions. Used by the UI and API to categorize insights. Not consumed by the engine. Example: [osint, geopolitical, cross-layer].
max_tokens integer
LLM response token budget (validator range: 1-128000). Set this high enough to avoid truncation but low enough to control cost. Most analysis definitions in analysis.d/ that emit per-item assessment arrays use 8000-16000 (commonly 12000); single-summary analyses can run much lower. The hard cap is far higher than typical needs, so size it to your schema rather than to the ceiling.
temperature float default: 0
LLM creativity dial. Range: 0.0 (deterministic) to 2.0 (validator min=0,max=2). Use 0 or 0.1 for scoring and classification tasks where reproducibility matters. Use 0.3-0.5 for open-ended narrative generation.

Prompt template syntax

Prompts use Go text/template syntax. The engine renders the template with the fetched data before sending it to the LLM.

Basic template

prompt: |
  Analyze {{.RecordCount}} records from the last {{.Lookback}}:

  {{range .Records}}
  - {{.EntityName}} at {{.Lat}}, {{.Lon}} ({{.Timestamp}})
    Magnitude: {{index .Metadata "magnitude"}}
  {{end}}

  Respond with JSON matching this schema:
  {{.OutputSchema}}

Available variables

These are the same template variables described in Data Queries:

  • {{.RecordCount}} – total records
  • {{.Lookback}} – time window as a duration string
  • {{.Records}} – iterate with {{range .Records}}...{{end}}
  • {{.LayerCount}} – number of distinct layer_type values in .Records
  • {{.LayerStats}} – per-layer statistics (count, field coverage, coordinate/altitude percentages, unique/duplicate external IDs)
  • {{.OutputSchema}} – auto-injected JSON Schema string
Attention suffix is appended automatically
When an operation defines an output_schema, the engine appends a base-schema instruction block to your rendered prompt before sending it to the LLM. This block tells the model to emit the required attention field (info/low/medium/high/critical) on every item, and injects the matching attention property into your schema. You do not write this yourself – author your prompt and schema as if attention were already there.

Inside {{range .Records}}:

  • {{.EntityName}}, {{.ExternalID}}, {{.LayerType}}
  • {{.Lat}}, {{.Lon}}, {{.Altitude}}, {{.Timestamp}}
  • {{index .Metadata "key"}} – access metadata fields by key

Metadata access

Use {{index .Metadata "key"}} to access metadata fields. The key corresponds to the entity metadata field name (for layer-based queries) or the SQL column name (for SQL-based queries).

prompt: |
  Region: {{.EntityName}}
  Fatalities: {{index .Metadata "fatalities"}}
  Military aircraft: {{index .Metadata "military_aircraft"}}
  Disaster count: {{index .Metadata "disaster_count"}}
Missing metadata keys
If a metadata key does not exist on a record, the template will render an empty string. Ensure your SQL uses COALESCE to fill in defaults for optional columns, or structure your prompt to handle blank values gracefully.

Conditional blocks

Use Go template conditionals for optional sections:

prompt: |
  {{if gt .RecordCount 10}}
  Large dataset detected. Focus on the top 5 highest-severity records.
  {{end}}

  {{range .Records}}
  {{.EntityName}}:
    {{if index .Metadata "severity"}}Severity: {{index .Metadata "severity"}}{{end}}
  {{end}}
Prompt engineering for consistent output

Structure your prompts with these patterns for reliable LLM responses:

  1. Role statement – Tell the LLM what role it plays (“You are a senior geopolitical risk analyst…”)
  2. Data block – Present the records in a structured, scannable format
  3. Scoring rubric – Define explicit thresholds and calibration anchors so the LLM produces consistent scores across runs
  4. Output instruction – End with “Respond with JSON matching this schema: {{.OutputSchema}}”
Keep prompts scannable

Present records in a line-oriented format, not as paragraphs. The LLM processes structured data more reliably:

REGION: Ukraine (48.37, 37.62)
  Conflict: 125 fatalities / 902 events
  Military: 14 aircraft (K35R, R135)

is better than:

Ukraine is located at 48.37, 37.62 and has experienced 125 fatalities across 902 events with 14 military aircraft including K35R and R135.

Output schema

The output_schema defines a JSON Schema that the LLM response is validated against. If validation fails, the insight is rejected and the error is logged.

output_schema:
  type: object
  required: [severity, summary]
  properties:
    severity:
      type: string
      enum: [low, moderate, high, critical]
    summary:
      type: string
    score:
      type: integer
      minimum: 0
      maximum: 100
    affected_regions:
      type: array
      items:
        type: object
        required: [name, score]
        properties:
          name: { type: string }
          score: { type: integer, minimum: 0, maximum: 100 }
          entity_external_id: { type: string }

Schema design guidelines

  • Use required to enforce critical fields – the LLM occasionally omits optional fields.
  • Use enum for classification fields (severity, category, trend) to constrain the LLM to valid values and prevent hallucinated categories.
  • Use minimum/maximum for bounded numeric scores to keep values in range.
  • Include an entity_external_id field when you want the engine to link insights back to source entities via the ai_insight_refs table.
The OutputSchema variable
End your prompt with Respond with JSON matching this schema: {{.OutputSchema}}. The engine injects the JSON Schema string automatically, so the LLM knows exactly what structure to produce. This works better than describing the format in natural language.

Output configuration

The output section controls how validated LLM responses are stored and delivered.

output:
  store_insights: true
  insight_type: "hotspot_index"
  websocket_push: true
  retention: "168h"
  results_path: "results"
output.store_insights boolean default: false
When true, the engine persists results to the ai_insights table. Set to false for dry-run or debug definitions where you want to test prompts without writing to the database.
output.insight_type string
Type label stored in ai_insights.insight_type. Used by the frontend and API to query and filter insights by category. Keep these stable across deployments – changing them breaks existing frontend queries. Example: "hotspot_index", "risk_assessment", "anomaly".
output.websocket_push boolean default: false
When true and a notifier is configured, each stored insight is pushed to connected WebSocket clients as an "ai_insight" message. This enables real-time dashboard updates without polling.
output.retention duration
How long insights are kept before automatic cleanup. The engine runs an hourly cleanup goroutine that deletes expired insights. Example: "168h" (7 days), "336h" (14 days), "720h" (30 days). Use shorter retention for high-frequency analyses to prevent table bloat.
output.results_path string
JSON path to an array field in the LLM response. When set, the engine iterates the array and stores one ai_insight per array item. If the array is empty, a single summary insight is stored instead (the full LLM response). If omitted, the entire LLM response is stored as a single insight.
output.min_attention string
Minimum attention level a result must carry to be stored and pushed. One of info, low, medium, high, critical. Results below this floor – including results with absent or unclassified attention, which are treated as info – are dropped before storage and notification. When results_path points at an empty array, the all-clear summary insight is also suppressed if a floor is configured (an empty result is info by definition). When unset, the engine-wide ai.min_attention default applies (community default: low); a per-operation value here overrides that default.
output.ref_fields object[]

Cross-layer entity references. Each entry links a field in the LLM result item – whose value is an external_id – to an entity in another layer, creating an additional ai_insight_refs row. Both keys are required per entry:

  • field (string, required) – the result-item property holding the external ID
  • layer_type (string, required) – the layer to scope the external_id lookup, preventing collisions when different layers share the same ID

This is how an insight is linked to entities it references but did not iterate over – e.g. linking an infrastructure-threat insight to both the hazard entity and the affected infrastructure entity.

output:
  results_path: "assessments"
  ref_fields:
    - field: "conflict_external_id"
      layer_type: "conflict_events"

results_path behavior

When your LLM returns a response like:

{
  "global_summary": "Three regions show elevated threat levels...",
  "hotspots": [
    { "region": "Ukraine", "score": 92, "classification": "critical" },
    { "region": "Sudan", "score": 78, "classification": "crisis" }
  ]
}

Setting results_path: "hotspots" tells the engine to extract the hotspots array and store each item as a separate ai_insight row. This gives you per-region insights that can be queried independently.

Single vs. array insights
Omit results_path when your analysis produces a single overall assessment (e.g., “is this entity anomalous?”). Set results_path when your analysis produces a list of scored items (e.g., “score each of these 15 regions”). The per-item storage makes it possible to query “show me all regions with score > 80” from the API.

Entity linking

When a results_path item includes an entity_external_id field (optionally scoped by entity_layer_type), the engine matches it against the fetched records and creates an ai_insight_refs row for each matched entity. This links the insight to its source entity, enabling “show all insights for this entity” queries in the frontend.

This works on both data paths. Layer-based fetches set each record’s EntityID from the queried entity; SQL-based fetches (data.sql) populate it from the result’s entity_id/external_id columns (rowToAnalysisRecord). Matching is by external_id value, scoped to entity_layer_type when the item provides it, so SQL-path analyses link entities exactly like layer-path analyses. For broader links to entities an item references but did not iterate over, use output.ref_fields.

Edit this page on GitHub