feat: persist summarizer model in app_settings

This commit is contained in:
Sirius DevOps 2026-08-27 23:07:01 -04:00
parent f50268ef81
commit 1616413e65
4 changed files with 451 additions and 1 deletions

View file

@ -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)

View file

@ -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 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):

219
app/settings_store.py Normal file
View file

@ -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 1128 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()

172
tests/test_api_settings.py Normal file
View file

@ -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"}