osint-dashboard/app/schemas.py
Sirius DevOps a82b62a011 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

409 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""OSINT Dashboard — Pydantic schemas."""
from __future__ import annotations
from datetime import datetime
from enum import Enum
from typing import Literal, Optional
from uuid import UUID
from pydantic import BaseModel, Field, field_validator
# ─── Enums ───────────────────────────────────────────────────────────────
class SourceType(str, Enum):
rss = "rss"
gdel_t2 = "gdel-t2"
social = "social"
earthquake = "earthquake"
disaster = "disaster"
weather = "weather"
fire = "fire"
satellite = "satellite"
camera = "camera"
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
# ─── 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
# ─── 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
model: Optional[str] = None
kind: Optional[str] = None
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
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 1128 chars, not whitespace-only")
if len(stripped) > 128:
raise ValueError("summary_model must be 1128 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
# ─── 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]
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
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
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