2026-06-04 20:30:04 -04:00
|
|
|
|
"""OSINT Dashboard — Pydantic schemas."""
|
|
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
|
|
from datetime import datetime
|
|
|
|
|
|
from enum import Enum
|
feat(conflicts): GET /api/conflicts catalog + news/GDELT event counts
Curated static catalogue of 13 conflict theatres (Ukraine, Gaza, Sudan,
Myanmar, DRC, Yemen, Syria, Lebanon, Sahel, Somalia, Red Sea, Taiwan
Strait, Korean DMZ) with war|high|elevated severity and short factual
descriptions. GET /api/conflicts returns { zones: [{id,label,severity,
lat,lon,description,eventCount,lastUpdated}], timestamp } where
eventCount rolls up pre-existing geocoded news/GDELT//api/news/map rows
inside each zone bbox (0 when the tables are empty). Adds the conflicts
entry to overlay_catalog(). No upstream scraping, no jittered coords.
2026-08-31 21:30:23 -04:00
|
|
|
|
from typing import Literal, Optional
|
2026-06-04 20:30:04 -04:00
|
|
|
|
from uuid import UUID
|
|
|
|
|
|
|
2026-08-27 23:07:01 -04:00
|
|
|
|
from pydantic import BaseModel, Field, field_validator
|
2026-06-04 20:30:04 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ─── Enums ───────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
class SourceType(str, Enum):
|
|
|
|
|
|
rss = "rss"
|
|
|
|
|
|
gdel_t2 = "gdel-t2"
|
|
|
|
|
|
social = "social"
|
|
|
|
|
|
earthquake = "earthquake"
|
|
|
|
|
|
disaster = "disaster"
|
|
|
|
|
|
weather = "weather"
|
|
|
|
|
|
fire = "fire"
|
|
|
|
|
|
satellite = "satellite"
|
frontend: Ghost-in-the-Shell overhaul — map-first landing, HUD chrome, event blips, tickers
- Map is now the landing view: full-viewport NASA GIBS globe, existing
fires/cameras/HLS map machinery preserved verbatim
- New 'Event Blips' layer: geolocated ingest events, color-coded by source,
bbox/since/has_coords filters added to GET /api/events
- Weather / Flights(ADS-B) / Vessels(AIS) layer slots reserved (feed pending)
- Market ticker strip with configurable symbols — auto-promotes to LIVE when
GET /api/market returns {symbols:[...]} (contract documented in Settings)
- Breaking-news ticker: LLM exec-summary flash + headline marquee, 15-min cycle
- Dropdown nav (Map/News/Events/Alerts/Entities/Ingest/API Keys/Settings),
Settings view (localStorage: symbols, map layer defaults), System panel
- Section-9 theme: near-black navy, cyan/magenta accents, Orbitron/Rajdhani/
Share Tech Mono, chamfered HUD panels, scanlines, boot splash, UTC clock
- Fix: events.camera enum value missing from models.py/schemas.py caused 500s
on every events query once camera events flowed in (migration 004 added it
to the DB enum only)
2026-08-27 17:22:14 -04:00
|
|
|
|
camera = "camera"
|
2026-06-04 20:30:04 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class EntityKind(str, Enum):
|
|
|
|
|
|
person = "person"
|
|
|
|
|
|
organization = "organization"
|
|
|
|
|
|
location = "location"
|
|
|
|
|
|
topic = "topic"
|
|
|
|
|
|
asset = "asset"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class Sentiment(str, Enum):
|
|
|
|
|
|
positive = "positive"
|
|
|
|
|
|
neutral = "neutral"
|
|
|
|
|
|
negative = "negative"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class AlertType(str, Enum):
|
|
|
|
|
|
entity_mention = "entity_mention"
|
|
|
|
|
|
sentiment_shift = "sentiment_shift"
|
|
|
|
|
|
geo_proximity = "geo_proximity"
|
|
|
|
|
|
keyword_match = "keyword_match"
|
|
|
|
|
|
threshold = "threshold"
|
|
|
|
|
|
anomaly = "anomaly"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class AlertSeverity(str, Enum):
|
|
|
|
|
|
low = "low"
|
|
|
|
|
|
medium = "medium"
|
|
|
|
|
|
high = "high"
|
|
|
|
|
|
critical = "critical"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ─── Feed Sources ───────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
class FeedSourceCreate(BaseModel):
|
|
|
|
|
|
name: str
|
|
|
|
|
|
source_type: SourceType
|
|
|
|
|
|
url: Optional[str] = None
|
|
|
|
|
|
config: Optional[dict] = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class FeedSourceOut(BaseModel):
|
|
|
|
|
|
id: UUID
|
|
|
|
|
|
name: str
|
|
|
|
|
|
source_type: SourceType
|
|
|
|
|
|
url: Optional[str]
|
|
|
|
|
|
config: Optional[dict]
|
|
|
|
|
|
enabled: bool
|
|
|
|
|
|
created_at: datetime
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ─── Events ─────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
class EventCreate(BaseModel):
|
|
|
|
|
|
source_type: SourceType
|
|
|
|
|
|
source_id: Optional[UUID] = None
|
|
|
|
|
|
title: Optional[str] = None
|
|
|
|
|
|
body: Optional[str] = None
|
|
|
|
|
|
url: Optional[str] = None
|
|
|
|
|
|
sentiment_score: Optional[float] = None
|
|
|
|
|
|
sentiment_label: Optional[Sentiment] = None
|
|
|
|
|
|
location_lat: Optional[float] = None
|
|
|
|
|
|
location_lon: Optional[float] = None
|
|
|
|
|
|
location_name: Optional[str] = None
|
|
|
|
|
|
entities: Optional[list[dict]] = None
|
|
|
|
|
|
tags: Optional[list[str]] = None
|
|
|
|
|
|
raw: Optional[dict] = None
|
|
|
|
|
|
source_timestamp: Optional[datetime] = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class EventOut(BaseModel):
|
|
|
|
|
|
id: UUID
|
|
|
|
|
|
source_type: SourceType
|
|
|
|
|
|
source_id: Optional[UUID]
|
|
|
|
|
|
title: Optional[str]
|
|
|
|
|
|
body: Optional[str]
|
|
|
|
|
|
url: Optional[str]
|
|
|
|
|
|
sentiment_score: Optional[float]
|
|
|
|
|
|
sentiment_label: Optional[Sentiment]
|
|
|
|
|
|
location_lat: Optional[float]
|
|
|
|
|
|
location_lon: Optional[float]
|
|
|
|
|
|
location_name: Optional[str]
|
|
|
|
|
|
entities: Optional[list[dict]]
|
|
|
|
|
|
tags: Optional[list[str]]
|
|
|
|
|
|
ingested_at: datetime
|
|
|
|
|
|
source_timestamp: datetime
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ─── Search ──────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
class SearchQuery(BaseModel):
|
|
|
|
|
|
q: str = Field(..., min_length=1, max_length=500)
|
|
|
|
|
|
source_type: Optional[SourceType] = None
|
|
|
|
|
|
entity_id: Optional[UUID] = None
|
|
|
|
|
|
sentiment: Optional[Sentiment] = None
|
|
|
|
|
|
min_date: Optional[datetime] = None
|
|
|
|
|
|
max_date: Optional[datetime] = None
|
|
|
|
|
|
min_lat: Optional[float] = None
|
|
|
|
|
|
max_lat: Optional[float] = None
|
|
|
|
|
|
min_lon: Optional[float] = None
|
|
|
|
|
|
max_lon: Optional[float] = None
|
|
|
|
|
|
limit: int = Field(50, ge=1, le=500)
|
|
|
|
|
|
offset: int = Field(0, ge=0)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class SearchResult(BaseModel):
|
|
|
|
|
|
events: list[EventOut]
|
|
|
|
|
|
total: int
|
|
|
|
|
|
has_more: bool
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ─── Entities ────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
class EntityCreate(BaseModel):
|
|
|
|
|
|
name: str
|
|
|
|
|
|
entity_type: EntityKind
|
|
|
|
|
|
aliases: Optional[list[str]] = None
|
|
|
|
|
|
description: Optional[str] = None
|
|
|
|
|
|
metadata: Optional[dict] = None
|
|
|
|
|
|
location_lat: Optional[float] = None
|
|
|
|
|
|
location_lon: Optional[float] = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class EntityOut(BaseModel):
|
|
|
|
|
|
id: UUID
|
|
|
|
|
|
name: str
|
|
|
|
|
|
entity_type: EntityKind
|
|
|
|
|
|
aliases: Optional[list[str]]
|
|
|
|
|
|
description: Optional[str]
|
|
|
|
|
|
metadata: Optional[dict]
|
|
|
|
|
|
location_lat: Optional[float]
|
|
|
|
|
|
location_lon: Optional[float]
|
|
|
|
|
|
event_count: int
|
|
|
|
|
|
first_seen: datetime
|
|
|
|
|
|
last_seen: datetime
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ─── Alerts ──────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
class AlertCreate(BaseModel):
|
|
|
|
|
|
alert_type: AlertType
|
|
|
|
|
|
entity_id: Optional[UUID] = None
|
|
|
|
|
|
event_id: Optional[UUID] = None
|
|
|
|
|
|
severity: AlertSeverity
|
|
|
|
|
|
title: str
|
|
|
|
|
|
message: Optional[str] = None
|
|
|
|
|
|
context: Optional[dict] = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class AlertUpdate(BaseModel):
|
|
|
|
|
|
acknowledged: Optional[bool] = None
|
|
|
|
|
|
acknowledged_by: Optional[str] = None
|
|
|
|
|
|
resolved_at: Optional[datetime] = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class AlertOut(BaseModel):
|
|
|
|
|
|
id: UUID
|
|
|
|
|
|
alert_type: AlertType
|
|
|
|
|
|
entity_id: Optional[UUID]
|
|
|
|
|
|
event_id: Optional[UUID]
|
|
|
|
|
|
severity: AlertSeverity
|
|
|
|
|
|
title: str
|
|
|
|
|
|
message: Optional[str]
|
|
|
|
|
|
context: Optional[dict]
|
|
|
|
|
|
acknowledged: bool
|
|
|
|
|
|
acknowledged_by: Optional[str]
|
|
|
|
|
|
created_at: datetime
|
|
|
|
|
|
resolved_at: Optional[datetime]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ─── Documents ───────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
class DocumentCreate(BaseModel):
|
|
|
|
|
|
bucket: str
|
|
|
|
|
|
object_key: str
|
|
|
|
|
|
content_type: Optional[str] = None
|
|
|
|
|
|
size_bytes: Optional[int] = None
|
|
|
|
|
|
description: Optional[str] = None
|
|
|
|
|
|
tags: Optional[list[str]] = None
|
|
|
|
|
|
event_id: Optional[UUID] = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class DocumentOut(BaseModel):
|
|
|
|
|
|
id: UUID
|
|
|
|
|
|
bucket: str
|
|
|
|
|
|
object_key: str
|
|
|
|
|
|
content_type: Optional[str]
|
|
|
|
|
|
size_bytes: Optional[int]
|
|
|
|
|
|
description: Optional[str]
|
|
|
|
|
|
tags: Optional[list[str]]
|
|
|
|
|
|
event_id: Optional[UUID]
|
|
|
|
|
|
uploaded_at: datetime
|
|
|
|
|
|
|
|
|
|
|
|
|
Add NASA FIRMS active-fire ingest + /api/fires; API keys management page
Coherent merge of two coordinated features on the shared working tree:
FIRMS fire heatmap (backend, t_6e404c14):
- app/fire_sources.py: fetch FIRMS VIIRS area CSV (free MAP_KEY) -> NATS events.fire
- fires hypertable (TimescaleDB, 1-day chunks) with natural-key PK
(latitude, longitude, acq_time, satellite); idempotent ON CONFLICT DO NOTHING
- alembic/versions/002_fires.py; GET /api/fires?bbox=&since= (JSON only)
- POST /api/ingest/fires; ~15 min poll loop (FIRMS_INTERVAL=900) in ingester
- env-driven config (FIRMS_MAP_KEY/DATASET/BBOX/INTERVAL); docs/firms.md covers
the zero-cost GIBS VIIRS_SNPP_Thermal_Anomalies_375m_All tile alternative
- 18 tests (parser, mapping, idempotency, API contract) verified vs real
TimescaleDB+PostGIS (localhost/osint-dashboard-pg image)
API keys page (frontend, t_4433cff2):
- app/keystore.py: api_keys table (self-creating), FIRMS/GEMINI/TELEGRAM
registry with format validation, ****last4 masking, get_api_key()
- GET/POST/DELETE /api/keys (never returns full values); Keys tab in index.html
DB_NULL_POOL env switch in app/database.py enables a NullPool for tests /
short-lived processes that open a fresh event loop per unit.
2026-08-24 15:37:42 -04:00
|
|
|
|
# ─── API Keys ────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
class KeyValueIn(BaseModel):
|
|
|
|
|
|
"""Body for POST /api/keys/{name} — the value to store.
|
|
|
|
|
|
|
|
|
|
|
|
The raw value is accepted only on write; it is never read back out.
|
|
|
|
|
|
"""
|
|
|
|
|
|
value: str = Field(..., min_length=1, max_length=4096)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class KeyOut(BaseModel):
|
|
|
|
|
|
"""One key's status as exposed by GET /api/keys.
|
|
|
|
|
|
|
|
|
|
|
|
``masked`` is the "****last4" hint; the full value is NEVER included.
|
|
|
|
|
|
"""
|
|
|
|
|
|
name: str
|
|
|
|
|
|
set: bool
|
|
|
|
|
|
masked: Optional[str] = None
|
|
|
|
|
|
description: Optional[str] = None
|
|
|
|
|
|
example: Optional[str] = None
|
|
|
|
|
|
validated: bool = True
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ─── Active fires / hotspots (NASA FIRMS) ────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
class FireOut(BaseModel):
|
|
|
|
|
|
latitude: float
|
|
|
|
|
|
longitude: float
|
|
|
|
|
|
brightness: float # bright_ti4 brightness temperature, Kelvin
|
|
|
|
|
|
confidence: str # VIIRS: n (nominal) / l (low) / h (high)
|
|
|
|
|
|
acq_time: datetime
|
|
|
|
|
|
satellite: str
|
|
|
|
|
|
instrument: Optional[str] = None
|
|
|
|
|
|
bright_ti5: Optional[float] = None # 12µm brightness temperature, Kelvin
|
|
|
|
|
|
frp: Optional[float] = None # fire radiative power, MW
|
|
|
|
|
|
daynight: Optional[str] = None # D / N
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-08-24 17:28:46 -04:00
|
|
|
|
# ─── News pipeline (scraper + summarizer) ────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
class NewsArticleOut(BaseModel):
|
|
|
|
|
|
"""One scraped article as exposed by GET /api/news."""
|
|
|
|
|
|
|
|
|
|
|
|
id: int
|
|
|
|
|
|
title: Optional[str] = None
|
|
|
|
|
|
url: Optional[str] = None
|
|
|
|
|
|
content: Optional[str] = None
|
|
|
|
|
|
domain: Optional[str] = None
|
|
|
|
|
|
timestamp: Optional[datetime] = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class NewsSummaryOut(BaseModel):
|
|
|
|
|
|
"""One master LLM summary as exposed by GET /api/news/summaries."""
|
|
|
|
|
|
|
|
|
|
|
|
id: int
|
|
|
|
|
|
summary_text: str
|
|
|
|
|
|
batch_timestamp: datetime
|
2026-08-27 22:49:59 -04:00
|
|
|
|
model: Optional[str] = None
|
2026-08-28 22:53:31 -04:00
|
|
|
|
kind: Optional[str] = None
|
2026-08-27 22:49:59 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class NewsTickerItemOut(BaseModel):
|
|
|
|
|
|
"""One flagged ticker row as exposed by GET /api/news/ticker."""
|
|
|
|
|
|
|
|
|
|
|
|
id: int
|
|
|
|
|
|
headline: str
|
|
|
|
|
|
importance: str
|
|
|
|
|
|
location_name: Optional[str] = None
|
|
|
|
|
|
url: Optional[str] = None
|
|
|
|
|
|
created_at: datetime
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class NewsMapItemOut(BaseModel):
|
|
|
|
|
|
"""One flagged map pin as exposed by GET /api/news/map."""
|
|
|
|
|
|
|
|
|
|
|
|
id: int
|
|
|
|
|
|
headline: str
|
|
|
|
|
|
importance: str
|
|
|
|
|
|
location_name: Optional[str] = None
|
|
|
|
|
|
lat: float
|
|
|
|
|
|
lon: float
|
|
|
|
|
|
location_confidence: Optional[str] = None
|
|
|
|
|
|
category: Optional[str] = None
|
|
|
|
|
|
url: Optional[str] = None
|
|
|
|
|
|
created_at: datetime
|
2026-08-24 17:28:46 -04:00
|
|
|
|
|
|
|
|
|
|
|
2026-08-27 23:07:01 -04:00
|
|
|
|
class NewsModelId(BaseModel):
|
|
|
|
|
|
"""One model id as exposed by GET /api/news/models."""
|
|
|
|
|
|
|
|
|
|
|
|
id: str
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class NewsModelsOut(BaseModel):
|
|
|
|
|
|
"""Catalog for the summarizer model selector."""
|
|
|
|
|
|
|
|
|
|
|
|
source: str
|
|
|
|
|
|
models: list[NewsModelId]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class SettingsIn(BaseModel):
|
|
|
|
|
|
"""Body for PUT /api/settings. ``nous_base_url`` is not writable."""
|
|
|
|
|
|
|
|
|
|
|
|
summary_model: str = Field(..., min_length=1, max_length=128)
|
|
|
|
|
|
|
|
|
|
|
|
@field_validator("summary_model")
|
|
|
|
|
|
@classmethod
|
|
|
|
|
|
def summary_model_not_blank(cls, v: str) -> str:
|
|
|
|
|
|
stripped = v.strip()
|
|
|
|
|
|
if not stripped:
|
|
|
|
|
|
raise ValueError("summary_model must be 1–128 chars, not whitespace-only")
|
|
|
|
|
|
if len(stripped) > 128:
|
|
|
|
|
|
raise ValueError("summary_model must be 1–128 chars, not whitespace-only")
|
|
|
|
|
|
return stripped
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class SettingsOut(BaseModel):
|
|
|
|
|
|
"""Current summarizer settings. ``nous_base_url`` is read-only."""
|
|
|
|
|
|
|
|
|
|
|
|
summary_model: str
|
|
|
|
|
|
nous_base_url: str
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-06-04 20:30:04 -04:00
|
|
|
|
# ─── Aggregations ────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
class SentimentSummary(BaseModel):
|
|
|
|
|
|
period: str
|
|
|
|
|
|
positive_count: int
|
|
|
|
|
|
neutral_count: int
|
|
|
|
|
|
negative_count: int
|
|
|
|
|
|
avg_score: float
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TimelinePoint(BaseModel):
|
|
|
|
|
|
timestamp: datetime
|
|
|
|
|
|
event_count: int
|
|
|
|
|
|
avg_sentiment: float
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class DashboardSummary(BaseModel):
|
|
|
|
|
|
total_events: int
|
|
|
|
|
|
events_last_24h: int
|
|
|
|
|
|
active_sources: int
|
|
|
|
|
|
open_alerts: int
|
|
|
|
|
|
tracked_entities: int
|
|
|
|
|
|
sentiment: SentimentSummary
|
|
|
|
|
|
top_entities: list[EntityOut]
|
|
|
|
|
|
|
2026-08-27 21:46:37 -04:00
|
|
|
|
|
|
|
|
|
|
class VesselBboxUpdate(BaseModel):
|
|
|
|
|
|
"""Retune the server-side AISStream subscription to a client viewport box.
|
|
|
|
|
|
|
|
|
|
|
|
``bbox`` is "minlon,minlat,maxlon,maxlat" (Leaflet order). ``None``/empty
|
|
|
|
|
|
resets to the env AISSTREAM_BBOX default.
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
bbox: str | None = None
|
|
|
|
|
|
|
2026-08-28 09:33:19 -04:00
|
|
|
|
|
|
|
|
|
|
class GeofenceCreate(BaseModel):
|
|
|
|
|
|
name: str
|
|
|
|
|
|
geojson: dict
|
|
|
|
|
|
active: bool = True
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class GeofenceUpdate(BaseModel):
|
|
|
|
|
|
name: Optional[str] = None
|
|
|
|
|
|
geojson: Optional[dict] = None
|
|
|
|
|
|
active: Optional[bool] = None
|
|
|
|
|
|
|
feat(conflicts): GET /api/conflicts catalog + news/GDELT event counts
Curated static catalogue of 13 conflict theatres (Ukraine, Gaza, Sudan,
Myanmar, DRC, Yemen, Syria, Lebanon, Sahel, Somalia, Red Sea, Taiwan
Strait, Korean DMZ) with war|high|elevated severity and short factual
descriptions. GET /api/conflicts returns { zones: [{id,label,severity,
lat,lon,description,eventCount,lastUpdated}], timestamp } where
eventCount rolls up pre-existing geocoded news/GDELT//api/news/map rows
inside each zone bbox (0 when the tables are empty). Adds the conflicts
entry to overlay_catalog(). No upstream scraping, no jittered coords.
2026-08-31 21:30:23 -04:00
|
|
|
|
|
|
|
|
|
|
class ConflictZoneOut(BaseModel):
|
|
|
|
|
|
"""One curated conflict theatre as exposed by GET /api/conflicts."""
|
|
|
|
|
|
|
|
|
|
|
|
id: str
|
|
|
|
|
|
label: str
|
|
|
|
|
|
severity: Literal["war", "high", "elevated"]
|
|
|
|
|
|
lat: float
|
|
|
|
|
|
lon: float
|
|
|
|
|
|
description: str
|
|
|
|
|
|
eventCount: int
|
|
|
|
|
|
lastUpdated: Optional[datetime] = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class ConflictsOut(BaseModel):
|
|
|
|
|
|
"""Response envelope for GET /api/conflicts."""
|
|
|
|
|
|
|
|
|
|
|
|
zones: list[ConflictZoneOut]
|
|
|
|
|
|
timestamp: datetime
|
|
|
|
|
|
|
|
|
|
|
|
|