387 lines
11 KiB
Python
387 lines
11 KiB
Python
"""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
|
||
|
||
|
||
# ─── 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
|
||
|
||
|
||
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 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
|
||
|
||
|
||
# ─── 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
|
||
|