feat: persist summarizer model in app_settings
This commit is contained in:
parent
f50268ef81
commit
1616413e65
4 changed files with 451 additions and 1 deletions
23
app/main.py
23
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)
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
219
app/settings_store.py
Normal file
219
app/settings_store.py
Normal 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 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()
|
||||
172
tests/test_api_settings.py
Normal file
172
tests/test_api_settings.py
Normal 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"}
|
||||
Loading…
Add table
Reference in a new issue