From 1616413e6592945afb7fafa8f3ef4f348cfb63ca Mon Sep 17 00:00:00 2001 From: Sirius DevOps Date: Thu, 27 Aug 2026 23:07:01 -0400 Subject: [PATCH] feat: persist summarizer model in app_settings --- app/main.py | 23 ++++ app/schemas.py | 38 ++++++- app/settings_store.py | 219 +++++++++++++++++++++++++++++++++++++ tests/test_api_settings.py | 172 +++++++++++++++++++++++++++++ 4 files changed, 451 insertions(+), 1 deletion(-) create mode 100644 app/settings_store.py create mode 100644 tests/test_api_settings.py diff --git a/app/main.py b/app/main.py index 6afd550..f08c0a7 100644 --- a/app/main.py +++ b/app/main.py @@ -40,6 +40,7 @@ from schemas import ( NewsSummaryOut, NewsTickerItemOut, FeedSourceCreate, FeedSourceOut, KeyOut, KeyValueIn, + NewsModelsOut, SettingsIn, SettingsOut, SearchResult, SentimentSummary, SourceType, SearchQuery, TimelinePoint, VesselBboxUpdate, GeofenceCreate, GeofenceUpdate, @@ -48,6 +49,7 @@ from ingestor import ingest_event, fetch_and_process from sources import ingest_rss_feed, ingest_gdelt, ingest_earthquakes, ingest_social_signals from fire_sources import ingest_fires from keystore import KeyFormatError, delete_key, list_keys, set_key +from settings_store import SettingsError, get_app_settings, list_models, set_summary_model from live_layers import ( fetch_aircraft, fetch_fire_incidents, fetch_fire_perimeters, fetch_radar_meta, fetch_storms, fetch_trains, fetch_vessels, @@ -634,6 +636,21 @@ async def remove_api_key(name: str): return {"ok": True} +@app.get("/api/settings", response_model=SettingsOut) +async def get_settings(): + """Summarizer model + read-only Nous base URL.""" + return await get_app_settings() + + +@app.put("/api/settings", response_model=SettingsOut) +async def put_settings(payload: SettingsIn): + """Persist SUMMARY_MODEL. ``nous_base_url`` is ignored even if sent.""" + try: + return await set_summary_model(payload.summary_model) + except SettingsError as exc: + raise HTTPException(status_code=422, detail=str(exc)) + + # ── Ingestion Triggers ─────────────────────────────────────────────────── @app.post("/api/ingest/rss") @@ -1230,6 +1247,12 @@ async def list_news_map( ] +@app.get("/api/news/models", response_model=NewsModelsOut) +async def list_news_models(): + """Nous model catalog for the summarizer selector. Never 502s.""" + return await list_models() + + # ── Frontend ────────────────────────────────────────────────────────────── @app.get("/", response_class=HTMLResponse) diff --git a/app/schemas.py b/app/schemas.py index 35bda45..b61e296 100644 --- a/app/schemas.py +++ b/app/schemas.py @@ -7,7 +7,7 @@ from enum import Enum from typing import Optional from uuid import UUID -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, field_validator # ─── Enums ─────────────────────────────────────────────────────────────── @@ -302,6 +302,42 @@ class NewsMapItemOut(BaseModel): 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): diff --git a/app/settings_store.py b/app/settings_store.py new file mode 100644 index 0000000..314db42 --- /dev/null +++ b/app/settings_store.py @@ -0,0 +1,219 @@ +"""OSINT Dashboard — non-secret app settings (keyv-style Postgres table). + +Model choice lives here so the summarizer container can read it from Postgres. +Only whitelisted names are stored — this is not a generic dump. + +Storage: the table is created lazily with ``CREATE TABLE IF NOT EXISTS`` on +first use in each process (same bootstrap pattern as ``api_keys``). +""" + +from __future__ import annotations + +import asyncio +import os +import time +from datetime import datetime, timezone + +import httpx +from sqlalchemy import Column, DateTime, String, Table, Text, func, select, text + +import keystore +from database import async_session, engine, metadata + +DEFAULT_NOUS_BASE_URL = "https://inference-api.nousresearch.com/v1" +DEFAULT_SUMMARY_MODEL = "Hermes-4.3-36B" +SETTING_SUMMARY_MODEL = "SUMMARY_MODEL" +ALLOWED_SETTINGS = frozenset({SETTING_SUMMARY_MODEL}) +MODELS_CACHE_TTL_S = 600.0 +MODELS_TIMEOUT_S = 8.0 +DEFAULT_MODELS_USER_AGENT = "osint-dashboard-news-summarizer" + +FALLBACK_MODELS = [ + "Hermes-4.3-36B", + "Hermes-4-70B", + "google/gemini-2.5-flash", + "anthropic/claude-haiku-4.5", + "openai/gpt-4.1-mini", + "x-ai/grok-4", +] + +app_settings = Table( + "app_settings", + metadata, + Column("name", String(128), primary_key=True), + Column("value", Text, nullable=False), + Column("updated_at", DateTime(timezone=True), server_default=func.now(), nullable=False), +) + +_CREATE_TABLE_SQL = text( + """ + CREATE TABLE IF NOT EXISTS app_settings ( + name VARCHAR(128) PRIMARY KEY, + value TEXT NOT NULL, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() + ) + """ +) + +_ensure_lock = asyncio.Lock() +_ensured = False +_models_cache: tuple[float, dict] | None = None + + +class SettingsError(ValueError): + """Raised when a setting name or value fails validation.""" + + +async def ensure_app_settings_table() -> None: + """Create the app_settings table if it doesn't exist (idempotent, per process).""" + global _ensured + if _ensured: + return + async with _ensure_lock: + if _ensured: + return + async with engine.begin() as conn: + await conn.execute(_CREATE_TABLE_SQL) + _ensured = True + + +def nous_base_url() -> str: + """Read-only Nous inference base URL (env, never writable from the UI).""" + raw = (os.getenv("NOUS_BASE_URL") or "").strip().rstrip("/") + return raw or DEFAULT_NOUS_BASE_URL + + +def _validate_summary_model(value: str) -> str: + stripped = (value or "").strip() + if not stripped or len(stripped) > 128: + raise SettingsError("summary_model must be 1–128 chars, not whitespace-only") + return stripped + + +async def get_summary_model() -> str: + """Stored SUMMARY_MODEL, else env, else Hermes-4.3-36B.""" + await ensure_app_settings_table() + async with async_session() as session: + row = ( + await session.execute( + select(app_settings).where(app_settings.c.name == SETTING_SUMMARY_MODEL) + ) + ).mappings().one_or_none() + if row and row["value"]: + return row["value"] + return os.getenv("SUMMARY_MODEL", DEFAULT_SUMMARY_MODEL) + + +async def set_summary_model(value: str) -> dict: + """Upsert SUMMARY_MODEL and return the public settings payload.""" + value = _validate_summary_model(value) + now = datetime.now(timezone.utc) + + await ensure_app_settings_table() + async with async_session() as session: + existing = ( + await session.execute( + select(app_settings).where(app_settings.c.name == SETTING_SUMMARY_MODEL) + ) + ).mappings().one_or_none() + if existing: + await session.execute( + app_settings.update() + .where(app_settings.c.name == SETTING_SUMMARY_MODEL) + .values(value=value, updated_at=now) + ) + else: + await session.execute( + app_settings.insert().values( + name=SETTING_SUMMARY_MODEL, value=value, updated_at=now + ) + ) + await session.commit() + return await get_app_settings() + + +async def get_app_settings() -> dict: + return { + "summary_model": await get_summary_model(), + "nous_base_url": nous_base_url(), + } + + +def _fallback_payload() -> dict: + return { + "source": "fallback", + "models": [{"id": mid} for mid in FALLBACK_MODELS], + } + + +async def _nous_api_key() -> str | None: + """Keystore first, then env. Any lookup failure is treated as missing.""" + try: + stored = await keystore.get_api_key("NOUS_API_KEY") + except Exception: + stored = None + if stored and str(stored).strip(): + return str(stored).strip() + env = (os.getenv("NOUS_API_KEY") or "").strip() + return env or None + + +def _models_user_agent() -> str: + return os.getenv("OSINT_USER_AGENT") or DEFAULT_MODELS_USER_AGENT + + +def _parse_models_payload(body: object) -> list[dict[str, str]]: + if isinstance(body, dict): + raw = body.get("data", body.get("models", [])) + elif isinstance(body, list): + raw = body + else: + raw = [] + out: list[dict[str, str]] = [] + for item in raw or []: + if isinstance(item, str) and item.strip(): + out.append({"id": item.strip()}) + elif isinstance(item, dict): + mid = item.get("id") or item.get("name") + if mid: + out.append({"id": str(mid)}) + return out + + +async def _http_get(url: str, *, headers: dict[str, str], timeout: float) -> httpx.Response: + async with httpx.AsyncClient(timeout=timeout, headers=headers) as client: + return await client.get(url) + + +async def list_models() -> dict: + """Live ``GET {base}/models`` when a key is present; otherwise curated fallback. + + Never raises to the caller for missing key or upstream failure. + """ + global _models_cache + key = await _nous_api_key() + if not key: + return _fallback_payload() + + now = time.monotonic() + hit = _models_cache + if hit and now - hit[0] < MODELS_CACHE_TTL_S: + return hit[1] + + url = f"{nous_base_url()}/models" + headers = { + "Authorization": f"Bearer {key}", + "User-Agent": _models_user_agent(), + "Accept": "application/json", + } + try: + resp = await _http_get(url, headers=headers, timeout=MODELS_TIMEOUT_S) + resp.raise_for_status() + models = _parse_models_payload(resp.json()) + if not models: + return _fallback_payload() + payload = {"source": "live", "models": models} + _models_cache = (now, payload) + return payload + except Exception: + return _fallback_payload() diff --git a/tests/test_api_settings.py b/tests/test_api_settings.py new file mode 100644 index 0000000..6dad2f5 --- /dev/null +++ b/tests/test_api_settings.py @@ -0,0 +1,172 @@ +"""API tests for GET/PUT /api/settings and GET /api/news/models.""" + +from __future__ import annotations + +import asyncio +import os + +import asyncpg +import httpx +import pytest + +from conftest import requires_db + +from main import app + +BASE = "http://test" + + +def _conn_kwargs() -> dict: + return { + "host": os.environ["DB_HOST"], + "port": int(os.environ["DB_PORT"]), + "user": os.environ["DB_USER"], + "password": os.environ["DB_PASSWORD"], + "database": os.environ["DB_NAME"], + } + + +def _truncate_settings() -> None: + async def run(): + conn = await asyncpg.connect(**_conn_kwargs()) + try: + await conn.execute("DROP TABLE IF EXISTS app_settings") + finally: + await conn.close() + + asyncio.run(run()) + + +@pytest.fixture() +def clean_settings(): + import settings_store + settings_store._ensured = False + _truncate_settings() + yield + settings_store._ensured = False + _truncate_settings() + + +def _request(method: str, path: str, json: dict | None = None) -> httpx.Response: + async def _run() -> httpx.Response: + transport = httpx.ASGITransport(app=app) + async with httpx.AsyncClient(transport=transport, base_url=BASE) as client: + return await client.request(method, path, json=json) + + return asyncio.run(_run()) + + +def _get(path: str) -> httpx.Response: + return _request("GET", path) + + +def _put(path: str, json: dict) -> httpx.Response: + return _request("PUT", path, json=json) + + +def test_get_news_models_without_key_returns_fallback(monkeypatch): + monkeypatch.delenv("NOUS_API_KEY", raising=False) + monkeypatch.setattr("keystore.get_api_key", _missing_key) + + resp = _get("/api/news/models") + assert resp.status_code == 200 + body = resp.json() + assert body["source"] == "fallback" + ids = [m["id"] for m in body["models"]] + assert "Hermes-4.3-36B" in ids + from settings_store import FALLBACK_MODELS + assert ids == FALLBACK_MODELS + + +async def _missing_key(name: str): + return None + + +async def _present_key(name: str): + return "test-nous-api-key-1234" + + +def test_get_news_models_live_from_upstream(monkeypatch): + monkeypatch.setattr("keystore.get_api_key", _present_key) + monkeypatch.setenv("NOUS_BASE_URL", "https://inference-api.nousresearch.com/v1") + monkeypatch.delenv("OSINT_USER_AGENT", raising=False) + + captured: dict = {} + + class FakeResponse: + status_code = 200 + + def raise_for_status(self): + return None + + def json(self): + return {"data": [{"id": "live-model-a"}, {"id": "Hermes-4.3-36B"}]} + + async def fake_http_get(url, *, headers, timeout): + captured["url"] = url + captured["headers"] = headers + captured["timeout"] = timeout + return FakeResponse() + + import settings_store + monkeypatch.setattr(settings_store, "_http_get", fake_http_get) + settings_store._models_cache = None + + resp = _get("/api/news/models") + assert resp.status_code == 200 + body = resp.json() + assert body["source"] == "live" + ids = [m["id"] for m in body["models"]] + assert ids == ["live-model-a", "Hermes-4.3-36B"] + assert captured["url"] == "https://inference-api.nousresearch.com/v1/models" + assert captured["headers"]["User-Agent"] == "osint-dashboard-news-summarizer" + assert captured["headers"]["Authorization"] == "Bearer test-nous-api-key-1234" + timeout = captured["timeout"] + assert timeout == 8 or getattr(timeout, "read", timeout) == 8 or float(timeout) == 8.0 + + +def test_get_news_models_upstream_failure_returns_fallback(monkeypatch): + monkeypatch.setattr("keystore.get_api_key", _present_key) + + async def boom_http_get(url, *, headers, timeout): + raise httpx.ConnectError("upstream down") + + import settings_store + monkeypatch.setattr(settings_store, "_http_get", boom_http_get) + settings_store._models_cache = None + + resp = _get("/api/news/models") + assert resp.status_code == 200 + body = resp.json() + assert body["source"] == "fallback" + ids = [m["id"] for m in body["models"]] + assert "Hermes-4.3-36B" in ids + + +def test_put_settings_empty_returns_422(): + resp = _put("/api/settings", {"summary_model": ""}) + assert resp.status_code == 422 + + +def test_put_settings_whitespace_only_returns_422(): + resp = _put("/api/settings", {"summary_model": " "}) + assert resp.status_code == 422 + + +@requires_db +def test_put_settings_round_trip(clean_settings, monkeypatch): + monkeypatch.delenv("SUMMARY_MODEL", raising=False) + monkeypatch.delenv("NOUS_BASE_URL", raising=False) + + put_resp = _put("/api/settings", {"summary_model": "google/gemini-2.5-flash"}) + assert put_resp.status_code == 200 + put_body = put_resp.json() + assert put_body["summary_model"] == "google/gemini-2.5-flash" + assert put_body["nous_base_url"] == "https://inference-api.nousresearch.com/v1" + + get_resp = _get("/api/settings") + assert get_resp.status_code == 200 + get_body = get_resp.json() + assert get_body["summary_model"] == "google/gemini-2.5-flash" + assert get_body["nous_base_url"] == "https://inference-api.nousresearch.com/v1" + assert set(get_body.keys()) == {"summary_model", "nous_base_url"}