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"hotspot_index_scoring", "anomaly_detection".tags
string[][osint, geopolitical, cross-layer].max_tokens
integeranalysis.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: 0min=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 distinctlayer_typevalues in.Records{{.LayerStats}}– per-layer statistics (count, field coverage, coordinate/altitude percentages, unique/duplicate external IDs){{.OutputSchema}}– auto-injected JSON Schema string
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"}}
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}}
Structure your prompts with these patterns for reliable LLM responses:
- Role statement – Tell the LLM what role it plays (“You are a senior geopolitical risk analyst…”)
- Data block – Present the records in a structured, scannable format
- Scoring rubric – Define explicit thresholds and calibration anchors so the LLM produces consistent scores across runs
- Output instruction – End with “Respond with JSON matching this schema: {{.OutputSchema}}”
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
requiredto enforce critical fields – the LLM occasionally omits optional fields. - Use
enumfor classification fields (severity,category,trend) to constrain the LLM to valid values and prevent hallucinated categories. - Use
minimum/maximumfor bounded numeric scores to keep values in range. - Include an
entity_external_idfield when you want the engine to link insights back to source entities via theai_insight_refstable.
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: falseai_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
stringai_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"ai_insight" message. This enables real-time dashboard updates without polling.output.retention
duration"168h" (7 days), "336h" (14 days), "720h" (30 days). Use shorter retention for high-frequency analyses to prevent table bloat.output.results_path
stringai_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
stringinfo, 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 IDlayer_type(string, required) – the layer to scope theexternal_idlookup, 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.
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.