- 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)
302 lines
8.7 KiB
Python
302 lines
8.7 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
|
|
|
|
|
|
# ─── 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
|
|
|
|
|
|
# ─── 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]
|
|
|