osint-dashboard/app/schemas.py

388 lines
11 KiB
Python
Raw Normal View History

2026-06-04 20:30:04 -04:00
"""OSINT Dashboard — Pydantic schemas."""
from __future__ import annotations
from datetime import datetime
from enum import Enum
from typing import Optional
from uuid import UUID
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"
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
# ─── 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
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
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]
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