Examples
Complete analysis definitions — from simple anomaly detection to cross-layer spatial analysis
This page provides two complete analysis definitions that you can copy into your analysis.d/ directory. The first is a straightforward layer-based analysis; the second demonstrates cross-layer SQL on the read-only SQLite engine using haversine_km() proximity joins (there is no PostGIS).
analysis.d/disabled/ (ocean_buoy_anomaly.yaml and geopolitical_hotspot_index.yaml) so they don’t run out of the box. Files in disabled/ are not loaded by the engine. To run one, copy it into analysis.d/ and set enabled: true.Simple: Ocean Buoy Anomaly Detection
This analysis monitors ocean buoy sensor readings for anomalies that could indicate tsunami precursors, storm surge, or marine heatwaves. It queries a single layer and asks the LLM to flag unusual patterns.
layers array instead of raw SQL. Layer-based queries are the right choice when you analyze data from one layer without needing geographic joins against other layers.Schedule and data
schema_version: 1
name: ocean_buoy_anomaly
display_name: "Ocean Buoy Anomaly Detection"
enabled: true
schedule:
interval: "300s"
data:
layers: [ocean_buoys]
lookback: "24h"
min_records: 5
max_records: 50
min_records: 5 ensures the analysis only runs when enough data points exist for meaningful anomaly detection. With fewer than 5 buoys, the LLM cannot identify outlier patterns, so the engine skips the tick.AI operation
ai:
enabled: true
operations:
- name: buoy_anomaly_scan
tags: [ocean, buoy, tsunami, anomaly]
prompt: |
You are an oceanographic analyst monitoring buoy sensor data for
anomalies that could indicate natural hazards.
Analyze {{.RecordCount}} ocean buoy readings from the last {{.Lookback}}:
{{range .Records}}
BUOY: {{.EntityName}} ({{.Lat}}, {{.Lon}})
Station ID: {{index .Metadata "station_id"}}
Timestamp: {{.Timestamp}}
{{end}}
For each buoy, assess whether its position or metadata indicates
anomalous conditions. Look for:
- Sudden position changes (possible mooring failure or tsunami drag)
- Clusters of buoys in unusual proximity (convergence events)
- Any metadata values outside normal operational ranges
Classify each finding as: normal, watch, warning, or critical.
Only include buoys with non-normal classifications in the results.
Set "attention" DIRECTLY from each finding's classification (do not
auto-elevate): watch -> low, warning -> medium, critical -> critical.
Respond with JSON matching this schema:
{{.OutputSchema}}
output_schema:
type: object
required: [summary, results]
properties:
summary:
type: string
results:
type: array
items:
type: object
required: [buoy_name, classification, attention, assessment]
properties:
buoy_name: { type: string }
lat: { type: number }
lon: { type: number }
classification:
type: string
enum: [watch, warning, critical]
attention:
type: string
enum: [info, low, medium, high, critical]
assessment: { type: string }
entity_external_id: { type: string }
max_tokens: 3000
temperature: 0.1
output:
store_insights: true
min_attention: "low" # noise gate: drop results below this attention
insight_type: "ocean_buoy_anomaly"
results_path: "results"
websocket_push: true
retention: "168h"
min_attention: "low" tells the engine to store and push only results whose attention is low or higher (the ranking is info < low < medium < high < critical). Results below the floor – and any unclassified result, treated as info – are dropped before storage and before the WebSocket notify. Set the attention field from the model’s own per-result severity; the gate then suppresses the analysis’s own routine/“all clear” conclusions instead of paging the operator with them. When no per-operation floor is set, the engine-wide ai.min_attention default applies.results_path: "results" tells the engine to iterate the results array and store one ai_insight per anomalous buoy. If no anomalies are found (empty array), a single summary insight is stored instead. This makes it possible to query “show all buoy warnings” from the API.0.1 keeps the LLM deterministic for classification tasks. If you find the assessments too uniform, bump it to 0.2-0.3 for slightly more nuanced narrative. Do not go above 0.5 for scoring tasks – you will get inconsistent classifications across runs.Complete definition
Save this as analysis.d/ocean_buoy_anomaly.yaml (the repo ships a placeholder version under analysis.d/disabled/; set enabled: true to run it):
# analysis.d/ocean_buoy_anomaly.yaml
# Detect anomalous ocean buoy readings that could indicate
# tsunami precursors, storm surge, or marine heatwaves.
schema_version: 1
name: ocean_buoy_anomaly
display_name: "Ocean Buoy Anomaly Detection"
enabled: true
schedule:
interval: "300s"
data:
layers: [ocean_buoys]
lookback: "24h"
min_records: 5
max_records: 50
ai:
enabled: true
operations:
- name: buoy_anomaly_scan
tags: [ocean, buoy, tsunami, anomaly]
prompt: |
You are an oceanographic analyst monitoring buoy sensor data for
anomalies that could indicate natural hazards.
Analyze {{.RecordCount}} ocean buoy readings from the last {{.Lookback}}:
{{range .Records}}
BUOY: {{.EntityName}} ({{.Lat}}, {{.Lon}})
Station ID: {{index .Metadata "station_id"}}
Timestamp: {{.Timestamp}}
{{end}}
For each buoy, assess whether its position or metadata indicates
anomalous conditions. Look for:
- Sudden position changes (possible mooring failure or tsunami drag)
- Clusters of buoys in unusual proximity (convergence events)
- Any metadata values outside normal operational ranges
Classify each finding as: normal, watch, warning, or critical.
Only include buoys with non-normal classifications in the results.
Set "attention" DIRECTLY from each finding's classification (do not
auto-elevate): watch -> low, warning -> medium, critical -> critical.
Respond with JSON matching this schema:
{{.OutputSchema}}
output_schema:
type: object
required: [summary, results]
properties:
summary:
type: string
results:
type: array
items:
type: object
required: [buoy_name, classification, attention, assessment]
properties:
buoy_name: { type: string }
lat: { type: number }
lon: { type: number }
classification:
type: string
enum: [watch, warning, critical]
attention:
type: string
enum: [info, low, medium, high, critical]
assessment: { type: string }
entity_external_id: { type: string }
max_tokens: 3000
temperature: 0.1
output:
store_insights: true
min_attention: "low" # noise gate: drop results below this attention
insight_type: "ocean_buoy_anomaly"
results_path: "results"
websocket_push: true
retention: "168h"
Advanced: Geopolitical Hotspot Index
This analysis fuses four data layers – conflict events, military aircraft, disaster alerts, and internet outages – into a composite threat score per region. It demonstrates SQL-based queries on the SQLite engine using haversine_km() proximity joins (no PostGIS), weighted scoring rubrics, and array output. It ships disabled at analysis.d/disabled/geopolitical_hotspot_index.yaml.
Schedule and data
schema_version: 1
name: geopolitical_hotspot_index
display_name: "Geopolitical Hotspot Index"
enabled: true
schedule:
interval: "300s"
data:
lookback: "24h"
min_records: 1
max_records: 15
layers: [conflict_events]
dedup:
window: "12h"
key_fields: ["entity_external_id"]
dedup block ensures that if the same set of conflict regions is returned within a 12-hour window, the analysis is skipped. This is important for expensive cross-layer analyses – without dedup, you would call the LLM every 5 minutes with identical data.SQL query
sql: |
WITH latest_conf AS (
-- Latest observation position per conflict entity (replaces LATERAL).
SELECT o.entity_id, o.lat, o.lon,
ROW_NUMBER() OVER (PARTITION BY o.entity_id ORDER BY o.ts DESC) AS rn
FROM observations o
JOIN entities e ON e.id = o.entity_id
WHERE e.layer_type = 'conflict_events'
),
latest_mil AS (
SELECT o.entity_id, o.lat, o.lon, o.ts,
ROW_NUMBER() OVER (PARTITION BY o.entity_id ORDER BY o.ts DESC) AS rn
FROM observations o
JOIN entities e ON e.id = o.entity_id
WHERE e.layer_type = 'flights_military'
),
latest_dis AS (
SELECT o.entity_id, o.lat, o.lon,
ROW_NUMBER() OVER (PARTITION BY o.entity_id ORDER BY o.ts DESC) AS rn
FROM observations o
JOIN entities e ON e.id = o.entity_id
WHERE e.layer_type = 'disaster_alerts'
),
latest_out AS (
SELECT o.entity_id, o.lat, o.lon,
ROW_NUMBER() OVER (PARTITION BY o.entity_id ORDER BY o.ts DESC) AS rn
FROM observations o
JOIN entities e ON e.id = o.entity_id
WHERE e.layer_type = 'internet_outages'
),
region_signals AS (
-- CTE 1: ANCHOR — Conflict regions with confirmed fatalities.
SELECT
conf.id, conf.name AS region_name, conf.external_id,
o_conf.lat, o_conf.lon,
CAST(COALESCE(json_extract(conf.metadata,'$.fatalities'), '0') AS INTEGER) AS fatalities,
CAST(COALESCE(json_extract(conf.metadata,'$.events'), '0') AS INTEGER) AS event_count
FROM entities conf
JOIN latest_conf o_conf ON o_conf.entity_id = conf.id AND o_conf.rn = 1
WHERE conf.layer_type = 'conflict_events'
AND CAST(COALESCE(json_extract(conf.metadata,'$.fatalities'), '0') AS INTEGER) > 0
),
military_presence AS (
-- CTE 2: SIGNAL — Military aircraft within 300km, observed in last 24h.
SELECT
rs.region_name,
COUNT(DISTINCT mil.id) AS military_aircraft_count,
group_concat(DISTINCT json_extract(mil.metadata,'$.type')) AS aircraft_types
FROM region_signals rs
JOIN entities mil ON mil.layer_type = 'flights_military'
JOIN latest_mil o_mil ON o_mil.entity_id = mil.id AND o_mil.rn = 1
WHERE julianday(o_mil.ts) > julianday('now','-24 hours')
AND o_mil.lat BETWEEN rs.lat-(300/111.32) AND rs.lat+(300/111.32)
AND o_mil.lon BETWEEN rs.lon-(300/(111.32*max(COS(RADIANS(rs.lat)),0.01))) AND rs.lon+(300/(111.32*max(COS(RADIANS(rs.lat)),0.01)))
AND haversine_km(rs.lat, rs.lon, o_mil.lat, o_mil.lon) <= 300
GROUP BY rs.region_name
),
nearby_disasters AS (
-- CTE 3: SIGNAL — Disaster alerts within 500km.
SELECT
rs.region_name,
COUNT(DISTINCT d.id) AS disaster_count,
group_concat(DISTINCT json_extract(d.metadata,'$.eventtype')) AS disaster_types
FROM region_signals rs
JOIN entities d ON d.layer_type = 'disaster_alerts'
JOIN latest_dis o_d ON o_d.entity_id = d.id AND o_d.rn = 1
WHERE o_d.lat BETWEEN rs.lat-(500/111.32) AND rs.lat+(500/111.32)
AND o_d.lon BETWEEN rs.lon-(500/(111.32*max(COS(RADIANS(rs.lat)),0.01))) AND rs.lon+(500/(111.32*max(COS(RADIANS(rs.lat)),0.01)))
AND haversine_km(rs.lat, rs.lon, o_d.lat, o_d.lon) <= 500
GROUP BY rs.region_name
),
nearby_outages AS (
-- CTE 4: SIGNAL — Internet outages within 500km.
SELECT
rs.region_name,
COUNT(DISTINCT io.id) AS outage_count,
group_concat(DISTINCT json_extract(io.metadata,'$.outage_type')) AS outage_types
FROM region_signals rs
JOIN entities io ON io.layer_type = 'internet_outages'
JOIN latest_out o_io ON o_io.entity_id = io.id AND o_io.rn = 1
WHERE o_io.lat BETWEEN rs.lat-(500/111.32) AND rs.lat+(500/111.32)
AND o_io.lon BETWEEN rs.lon-(500/(111.32*max(COS(RADIANS(rs.lat)),0.01))) AND rs.lon+(500/(111.32*max(COS(RADIANS(rs.lat)),0.01)))
AND haversine_km(rs.lat, rs.lon, o_io.lat, o_io.lon) <= 500
GROUP BY rs.region_name
)
-- FINAL SELECT: Merge all signals per region via LEFT JOINs.
SELECT
rs.id AS entity_id,
rs.region_name AS name,
rs.external_id AS external_id,
'conflict_events' AS layer_type,
rs.lat, rs.lon, 0.0 AS altitude_m,
strftime('%Y-%m-%dT%H:%M:%SZ','now') AS ts,
CAST(rs.fatalities AS TEXT) AS fatalities,
CAST(rs.event_count AS TEXT) AS event_count,
CAST(COALESCE(mp.military_aircraft_count, 0) AS TEXT) AS military_aircraft,
COALESCE(mp.aircraft_types, 'none') AS aircraft_types,
CAST(COALESCE(nd.disaster_count, 0) AS TEXT) AS disaster_count,
COALESCE(nd.disaster_types, 'none') AS disaster_types,
CAST(COALESCE(no2.outage_count, 0) AS TEXT) AS internet_outages,
COALESCE(no2.outage_types, 'none') AS outage_types
FROM region_signals rs
LEFT JOIN military_presence mp ON mp.region_name = rs.region_name
LEFT JOIN nearby_disasters nd ON nd.region_name = rs.region_name
LEFT JOIN nearby_outages no2 ON no2.region_name = rs.region_name
WHERE rs.fatalities > 0
AND (COALESCE(mp.military_aircraft_count, 0) > 0
OR COALESCE(nd.disaster_count, 0) > 0
OR COALESCE(no2.outage_count, 0) > 0
OR rs.fatalities >= 10)
ORDER BY rs.fatalities DESC
LIMIT 15
The query opens with four latest_* window CTEs – one per layer – that rank each entity’s observations with ROW_NUMBER() OVER (PARTITION BY entity_id ORDER BY ts DESC) and are joined on rn = 1 to get the latest position (the SQLite replacement for CROSS JOIN LATERAL). Then:
region_signals (anchor) – Conflict regions with confirmed fatalities (json_extract(metadata,'$.fatalities') cast to INTEGER, > 0).
military_presence, nearby_disasters, nearby_outages (signals) – For each anchor region, count distinct entities from the other layer within radius. Each uses a lat/lon bounding-box prefilter (the lon bound divides by max(COS(RADIANS(rs.lat)), 0.01)) followed by a precise haversine_km(...) <= radius gate: 300km for military, 500km for disasters and outages. Military presence is additionally time-filtered to the last 24h via julianday(o_mil.ts) > julianday('now','-24 hours'). Distinct type codes are aggregated with group_concat(DISTINCT ...).
Final SELECT – Merges all signals via LEFT JOIN so regions without a given signal still appear, casting every extra column to TEXT and synthesizing ts with strftime('%Y-%m-%dT%H:%M:%SZ','now'). The WHERE clause requires at least one corroborating signal OR high fatalities (>= 10) to reduce noise.
latest_* window CTE plus an aggregating CTE, joined back with a LEFT JOIN in the final SELECT. To add a fifth signal, follow the same pattern. For heavy anchor or hazard sets feeding a spatial join, mark the CTE AS MATERIALIZED so SQLite computes it once instead of re-running it per candidate.AI operation
ai:
enabled: true
operations:
- name: hotspot_index_scoring
tags: [osint, geopolitical, composite, cross-layer]
prompt: |
You are a senior geopolitical risk analyst producing composite threat
assessments. Your assessments will be read by decision-makers who need
accurate, calibrated risk scores.
GEOPOLITICAL HOTSPOT INDEX: Composite threat scoring for
{{.RecordCount}} regions using cross-layer signal fusion
(conflict + military + disasters + comms) over {{.Lookback}}.
Multi-signal regional data:
{{range .Records}}
REGION: {{.EntityName}} ({{.Lat}}, {{.Lon}}, ID: {{.ExternalID}})
Conflict: {{index .Metadata "fatalities"}} fatalities / {{index .Metadata "event_count"}} events
Military: {{index .Metadata "military_aircraft"}} aircraft ({{index .Metadata "aircraft_types"}})
Disasters: {{index .Metadata "disaster_count"}} ({{index .Metadata "disaster_types"}})
Comms disruption: {{index .Metadata "internet_outages"}} outages ({{index .Metadata "outage_types"}})
{{end}}
For each region, compute a composite Hotspot Index (0-100) using
these weighted signals:
- Conflict intensity (fatalities + events): 40% weight
- Military presence (aircraft count + types — ISR/AWACS higher than transport): 25% weight
- Disaster overlay (compounding humanitarian crisis): 20% weight
- Communications disruption (internet outages indicate escalation/censorship): 15% weight
CALIBRATION:
- critical (81-100): Active large-scale war, 1000+ fatalities, confirmed military ops
- crisis (61-80): Sustained armed conflict, 100-1000 fatalities, military escalation signals
- volatile (41-60): Ongoing low-intensity conflict, military posturing
- elevated (21-40): Chronic instability, intermittent violence
- stable (0-20): Genuinely low risk, minimal recent violence
CRITICAL: Do NOT classify a region as "stable" purely because its
fatality count is low relative to active war zones. Use your world
knowledge of the region's chronic instability.
The strategic_assessment MUST be 2-4 sentences explaining what drives
the score, referencing specific signals from the data.
Set "attention" DIRECTLY from each region's classification (do not
auto-elevate): stable -> info, elevated -> low, volatile -> medium,
crisis -> high, critical -> critical.
Respond with JSON matching this schema:
{{.OutputSchema}}
Output schema and configuration
output_schema:
type: object
required: [hotspots, global_summary]
properties:
global_summary:
type: string
hotspots:
type: array
items:
type: object
required: [region, hotspot_index, classification, attention, strategic_assessment]
properties:
region: { type: string }
lat: { type: number }
lon: { type: number }
hotspot_index: { type: integer, minimum: 0, maximum: 100 }
classification:
type: string
enum: [stable, elevated, volatile, crisis, critical]
attention:
type: string
enum: [info, low, medium, high, critical]
conflict_score: { type: integer, minimum: 0, maximum: 100 }
military_score: { type: integer, minimum: 0, maximum: 100 }
disaster_score: { type: integer, minimum: 0, maximum: 100 }
comms_score: { type: integer, minimum: 0, maximum: 100 }
strategic_assessment: { type: string }
entity_external_id: { type: string }
max_tokens: 8000
temperature: 0.2
output:
store_insights: true
min_attention: "medium" # noise gate: drop results below this attention
insight_type: "hotspot_index"
results_path: "hotspots"
ref_fields:
- field: "entity_external_id"
layer_type: "conflict_events"
websocket_push: true
retention: "336h"
results_path: "hotspots" extracts each item from the hotspots array and stores it as a separate ai_insight row. If the LLM includes entity_external_id in each item, the engine matches it against the input records and links the insight to the source entity – enabling “show all insights for Ukraine” queries.ref_fields resolves an additional cross-layer entity reference per result. Each entry names a result field and the layer_type to look it up in; the engine resolves (layer_type, field-value) to an entity and adds an ai_insight_refs row. Here entity_external_id is resolved against conflict_events. Use this when a single insight should link to entities the matcher would not otherwise associate (e.g., the threatened cable or weather alert in the infrastructure and aviation analyses).min_attention: "medium" stores and pushes only hotspots whose attention is medium or higher (ranking: info < low < medium < high < critical). Lower or unclassified results are dropped before storage and WebSocket notify – so stable/elevated regions mapped to info/low are suppressed, and only volatile/crisis/critical regions page through. Set the attention field from the model’s own classification; when no per-operation floor is set, the engine-wide ai.min_attention default applies.retention: "336h" (14 days) means insights older than two weeks are automatically deleted by the hourly cleanup goroutine. Increase retention for audit trails; decrease it for high-frequency analyses.Complete definition
Save this as analysis.d/geopolitical_hotspot_index.yaml (it ships under analysis.d/disabled/; copy it up and set enabled: true to run it):
# analysis.d/geopolitical_hotspot_index.yaml
# Cross-layer composite threat scoring:
# conflict + military + disaster + comms signals.
schema_version: 1
name: geopolitical_hotspot_index
display_name: "Geopolitical Hotspot Index"
enabled: true
schedule:
interval: "300s"
data:
lookback: "24h"
min_records: 1
max_records: 15
layers: [conflict_events]
dedup:
window: "12h"
key_fields: ["entity_external_id"]
sql: |
WITH latest_conf AS (
SELECT o.entity_id, o.lat, o.lon,
ROW_NUMBER() OVER (PARTITION BY o.entity_id ORDER BY o.ts DESC) AS rn
FROM observations o
JOIN entities e ON e.id = o.entity_id
WHERE e.layer_type = 'conflict_events'
),
latest_mil AS (
SELECT o.entity_id, o.lat, o.lon, o.ts,
ROW_NUMBER() OVER (PARTITION BY o.entity_id ORDER BY o.ts DESC) AS rn
FROM observations o
JOIN entities e ON e.id = o.entity_id
WHERE e.layer_type = 'flights_military'
),
latest_dis AS (
SELECT o.entity_id, o.lat, o.lon,
ROW_NUMBER() OVER (PARTITION BY o.entity_id ORDER BY o.ts DESC) AS rn
FROM observations o
JOIN entities e ON e.id = o.entity_id
WHERE e.layer_type = 'disaster_alerts'
),
latest_out AS (
SELECT o.entity_id, o.lat, o.lon,
ROW_NUMBER() OVER (PARTITION BY o.entity_id ORDER BY o.ts DESC) AS rn
FROM observations o
JOIN entities e ON e.id = o.entity_id
WHERE e.layer_type = 'internet_outages'
),
region_signals AS (
-- CTE 1: ANCHOR — Conflict regions with confirmed fatalities.
SELECT
conf.id, conf.name AS region_name, conf.external_id,
o_conf.lat, o_conf.lon,
CAST(COALESCE(json_extract(conf.metadata,'$.fatalities'), '0') AS INTEGER) AS fatalities,
CAST(COALESCE(json_extract(conf.metadata,'$.events'), '0') AS INTEGER) AS event_count
FROM entities conf
JOIN latest_conf o_conf ON o_conf.entity_id = conf.id AND o_conf.rn = 1
WHERE conf.layer_type = 'conflict_events'
AND CAST(COALESCE(json_extract(conf.metadata,'$.fatalities'), '0') AS INTEGER) > 0
),
military_presence AS (
-- CTE 2: SIGNAL — Military aircraft within 300km, observed in last 24h.
SELECT
rs.region_name,
COUNT(DISTINCT mil.id) AS military_aircraft_count,
group_concat(DISTINCT json_extract(mil.metadata,'$.type')) AS aircraft_types
FROM region_signals rs
JOIN entities mil ON mil.layer_type = 'flights_military'
JOIN latest_mil o_mil ON o_mil.entity_id = mil.id AND o_mil.rn = 1
WHERE julianday(o_mil.ts) > julianday('now','-24 hours')
AND o_mil.lat BETWEEN rs.lat-(300/111.32) AND rs.lat+(300/111.32)
AND o_mil.lon BETWEEN rs.lon-(300/(111.32*max(COS(RADIANS(rs.lat)),0.01))) AND rs.lon+(300/(111.32*max(COS(RADIANS(rs.lat)),0.01)))
AND haversine_km(rs.lat, rs.lon, o_mil.lat, o_mil.lon) <= 300
GROUP BY rs.region_name
),
nearby_disasters AS (
-- CTE 3: SIGNAL — Disaster alerts within 500km.
SELECT
rs.region_name,
COUNT(DISTINCT d.id) AS disaster_count,
group_concat(DISTINCT json_extract(d.metadata,'$.eventtype')) AS disaster_types
FROM region_signals rs
JOIN entities d ON d.layer_type = 'disaster_alerts'
JOIN latest_dis o_d ON o_d.entity_id = d.id AND o_d.rn = 1
WHERE o_d.lat BETWEEN rs.lat-(500/111.32) AND rs.lat+(500/111.32)
AND o_d.lon BETWEEN rs.lon-(500/(111.32*max(COS(RADIANS(rs.lat)),0.01))) AND rs.lon+(500/(111.32*max(COS(RADIANS(rs.lat)),0.01)))
AND haversine_km(rs.lat, rs.lon, o_d.lat, o_d.lon) <= 500
GROUP BY rs.region_name
),
nearby_outages AS (
-- CTE 4: SIGNAL — Internet outages within 500km.
SELECT
rs.region_name,
COUNT(DISTINCT io.id) AS outage_count,
group_concat(DISTINCT json_extract(io.metadata,'$.outage_type')) AS outage_types
FROM region_signals rs
JOIN entities io ON io.layer_type = 'internet_outages'
JOIN latest_out o_io ON o_io.entity_id = io.id AND o_io.rn = 1
WHERE o_io.lat BETWEEN rs.lat-(500/111.32) AND rs.lat+(500/111.32)
AND o_io.lon BETWEEN rs.lon-(500/(111.32*max(COS(RADIANS(rs.lat)),0.01))) AND rs.lon+(500/(111.32*max(COS(RADIANS(rs.lat)),0.01)))
AND haversine_km(rs.lat, rs.lon, o_io.lat, o_io.lon) <= 500
GROUP BY rs.region_name
)
-- FINAL SELECT: Merge all signals per region via LEFT JOINs.
SELECT
rs.id AS entity_id,
rs.region_name AS name,
rs.external_id AS external_id,
'conflict_events' AS layer_type,
rs.lat, rs.lon, 0.0 AS altitude_m,
strftime('%Y-%m-%dT%H:%M:%SZ','now') AS ts,
CAST(rs.fatalities AS TEXT) AS fatalities,
CAST(rs.event_count AS TEXT) AS event_count,
CAST(COALESCE(mp.military_aircraft_count, 0) AS TEXT) AS military_aircraft,
COALESCE(mp.aircraft_types, 'none') AS aircraft_types,
CAST(COALESCE(nd.disaster_count, 0) AS TEXT) AS disaster_count,
COALESCE(nd.disaster_types, 'none') AS disaster_types,
CAST(COALESCE(no2.outage_count, 0) AS TEXT) AS internet_outages,
COALESCE(no2.outage_types, 'none') AS outage_types
FROM region_signals rs
LEFT JOIN military_presence mp ON mp.region_name = rs.region_name
LEFT JOIN nearby_disasters nd ON nd.region_name = rs.region_name
LEFT JOIN nearby_outages no2 ON no2.region_name = rs.region_name
WHERE rs.fatalities > 0
AND (COALESCE(mp.military_aircraft_count, 0) > 0
OR COALESCE(nd.disaster_count, 0) > 0
OR COALESCE(no2.outage_count, 0) > 0
OR rs.fatalities >= 10)
ORDER BY rs.fatalities DESC
LIMIT 15
ai:
enabled: true
operations:
- name: hotspot_index_scoring
tags: [osint, geopolitical, composite, cross-layer]
prompt: |
You are a senior geopolitical risk analyst producing composite threat
assessments. Your assessments will be read by decision-makers who need
accurate, calibrated risk scores.
GEOPOLITICAL HOTSPOT INDEX: Composite threat scoring for
{{.RecordCount}} regions using cross-layer signal fusion
(conflict + military + disasters + comms) over {{.Lookback}}.
Multi-signal regional data:
{{range .Records}}
REGION: {{.EntityName}} ({{.Lat}}, {{.Lon}}, ID: {{.ExternalID}})
Conflict: {{index .Metadata "fatalities"}} fatalities / {{index .Metadata "event_count"}} events
Military: {{index .Metadata "military_aircraft"}} aircraft ({{index .Metadata "aircraft_types"}})
Disasters: {{index .Metadata "disaster_count"}} ({{index .Metadata "disaster_types"}})
Comms disruption: {{index .Metadata "internet_outages"}} outages ({{index .Metadata "outage_types"}})
{{end}}
For each region, compute a composite Hotspot Index (0-100) using
these weighted signals:
- Conflict intensity (fatalities + events): 40% weight
- Military presence (aircraft count + types — ISR/AWACS higher than transport): 25% weight
- Disaster overlay (compounding humanitarian crisis): 20% weight
- Communications disruption (internet outages indicate escalation/censorship): 15% weight
CALIBRATION:
- critical (81-100): Active large-scale war, 1000+ fatalities, confirmed military ops
- crisis (61-80): Sustained armed conflict, 100-1000 fatalities, military escalation signals
- volatile (41-60): Ongoing low-intensity conflict, military posturing
- elevated (21-40): Chronic instability, intermittent violence
- stable (0-20): Genuinely low risk, minimal recent violence
CRITICAL: Do NOT classify a region as "stable" purely because its
fatality count is low relative to active war zones. Use your world
knowledge of the region's chronic instability.
The strategic_assessment MUST be 2-4 sentences explaining what drives
the score, referencing specific signals from the data.
Set "attention" DIRECTLY from each region's classification (do not
auto-elevate): stable -> info, elevated -> low, volatile -> medium,
crisis -> high, critical -> critical.
Respond with JSON matching this schema:
{{.OutputSchema}}
output_schema:
type: object
required: [hotspots, global_summary]
properties:
global_summary:
type: string
hotspots:
type: array
items:
type: object
required: [region, hotspot_index, classification, attention, strategic_assessment]
properties:
region: { type: string }
lat: { type: number }
lon: { type: number }
hotspot_index: { type: integer, minimum: 0, maximum: 100 }
classification:
type: string
enum: [stable, elevated, volatile, crisis, critical]
attention:
type: string
enum: [info, low, medium, high, critical]
conflict_score: { type: integer, minimum: 0, maximum: 100 }
military_score: { type: integer, minimum: 0, maximum: 100 }
disaster_score: { type: integer, minimum: 0, maximum: 100 }
comms_score: { type: integer, minimum: 0, maximum: 100 }
strategic_assessment: { type: string }
entity_external_id: { type: string }
max_tokens: 8000
temperature: 0.2
output:
store_insights: true
min_attention: "medium" # noise gate: drop results below this attention
insight_type: "hotspot_index"
results_path: "hotspots"
ref_fields:
- field: "entity_external_id"
layer_type: "conflict_events"
websocket_push: true
retention: "336h"