diff --git a/.env.example b/.env.example
index ac898a9..2ba3877 100644
--- a/.env.example
+++ b/.env.example
@@ -60,7 +60,7 @@ FIRMS_INTERVAL=900
INGEST_FIRES=1
# ── API keys (managed from the dashboard UI) ──────────────────────────────
-# Keys such as NOUS_API_KEY and TELEGRAM_TOKEN are stored in the Postgres
+# Keys such as TELEGRAM_TOKEN are stored in the Postgres
# `api_keys` table and managed from the dashboard's "Keys" tab
# (GET/POST/DELETE /api/keys/{name}) — see app/keystore.py. The FIRMS ingestor
# currently reads FIRMS_MAP_KEY from .env (above); wiring the Keys-UI store as
@@ -68,17 +68,15 @@ INGEST_FIRES=1
# ── News pipeline (scraper + summarizer, profile `ingest`) ────────────────
# Hourly: the scraper crawls 257 RSS sources at minute :00 and the summarizer
-# runs the Nous map-reduce at minute :05, both writing to the shared osint-db
+# runs the local Ollama map-reduce at minute :05, both writing to the shared osint-db
# (tables `articles` + `article_summaries`, created by alembic 003_news).
# Consume via GET /api/news and GET /api/news/summaries.
-# NOUS_API_KEY is also (preferably) set in the Keys UI; env is an override.
-# Unset in both env and api_keys = summarizer logs and idles (never crashes).
-NOUS_API_KEY=
-NOUS_BASE_URL=https://inference-api.nousresearch.com/v1
-# Optional LLM knobs
+# LLM_URL is the Ollama origin (Pi container cannot use 127.0.0.1 for a laptop
+# GPU). Example: LLM_URL=http://100.91.7.84:11434
+# LLM_URL=
# SUMMARY_MODEL is an optional override. Leave unset so Settings
# (app_settings.SUMMARY_MODEL) can reach the summarizer. Code default
-# Hermes-4.3-36B remains after a Postgres miss. Env wins when set.
+# qwen3:30b-a3b remains after a Postgres miss. Env wins when set.
# SUMMARY_MODEL=
NEWS_BATCH_SIZE=50
SUMMARY_WINDOW_HOURS=1
diff --git a/app/keystore.py b/app/keystore.py
index 0eb49a4..a701fb7 100644
--- a/app/keystore.py
+++ b/app/keystore.py
@@ -57,11 +57,6 @@ KEY_REGISTRY: dict[str, dict] = {
"pattern": r"^[0-9a-fA-F]{32}$",
"example": "32-char hex string (e.g. 5f3c…9a02)",
},
- "NOUS_API_KEY": {
- "description": "Nous Portal API key — hourly news summarizer (inference-api.nousresearch.com).",
- "pattern": r"^.{16,}$",
- "example": "key from https://portal.nousresearch.com (API keys page)",
- },
"TELEGRAM_TOKEN": {
"description": "Telegram bot token — push alert notifications to a channel.",
"pattern": r"^\d{8,10}:[0-9A-Za-z_-]{35}$",
@@ -214,7 +209,7 @@ async def get_api_key(name: str) -> str | None:
"""Read a stored key value — used by ingest services, never by the API.
Returns the raw value (or None when unset) so producers can pass it to
- external APIs (FIRMS, Nous, Telegram, …). Reads live from Postgres, so a
+ external APIs (FIRMS, Telegram, …). Reads live from Postgres, so a
key set via the dashboard is picked up on the next poll — no restart.
"""
await ensure_api_keys_table()
diff --git a/app/main.py b/app/main.py
index a2e47ee..df90401 100644
--- a/app/main.py
+++ b/app/main.py
@@ -605,7 +605,7 @@ async def list_documents(
async def list_api_keys():
"""List known API keys with set/missing status — masked, never raw.
- Registered keys (FIRMS_MAP_KEY, NOUS_API_KEY, TELEGRAM_TOKEN)
+ Registered keys (FIRMS_MAP_KEY, TELEGRAM_TOKEN)
are always included. Any extra stored keys are appended.
"""
return await list_keys()
@@ -638,13 +638,13 @@ async def remove_api_key(name: str):
@app.get("/api/settings", response_model=SettingsOut)
async def get_settings():
- """Summarizer model + read-only Nous base URL."""
+ """Summarizer model + read-only Ollama 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."""
+ """Persist SUMMARY_MODEL. ``llm_url`` is ignored even if sent."""
try:
return await set_summary_model(payload.summary_model)
except SettingsError as exc:
@@ -1084,7 +1084,7 @@ async def camera_hls_segment(camera_id: UUID, u: str = Query(..., min_length=8))
# ── News pipeline (scraper + summarizer) ──────────────────────────────────
# Backing data for the frontend news panel. Written by the vendored
-# news-scraper (hourly Scrapy crawl) and news-summarizer (hourly Gemini
+# news-scraper (hourly Scrapy crawl) and news-summarizer (hourly Ollama
# map-reduce) services into the shared osint-db.
@app.get("/api/news", response_model=list[NewsArticleOut])
@@ -1249,7 +1249,7 @@ 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."""
+ """Ollama model catalog for the summarizer selector. Never 502s."""
return await list_models()
diff --git a/app/schemas.py b/app/schemas.py
index b61e296..8fd4c68 100644
--- a/app/schemas.py
+++ b/app/schemas.py
@@ -316,7 +316,7 @@ class NewsModelsOut(BaseModel):
class SettingsIn(BaseModel):
- """Body for PUT /api/settings. ``nous_base_url`` is not writable."""
+ """Body for PUT /api/settings. ``llm_url`` is not writable."""
summary_model: str = Field(..., min_length=1, max_length=128)
@@ -332,10 +332,10 @@ class SettingsIn(BaseModel):
class SettingsOut(BaseModel):
- """Current summarizer settings. ``nous_base_url`` is read-only."""
+ """Current summarizer settings. ``llm_url`` is read-only."""
summary_model: str
- nous_base_url: str
+ llm_url: str
# ─── Aggregations ────────────────────────────────────────────────────────
diff --git a/app/settings_store.py b/app/settings_store.py
index 314db42..44434dc 100644
--- a/app/settings_store.py
+++ b/app/settings_store.py
@@ -17,11 +17,10 @@ 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"
+DEFAULT_LLM_URL = "http://127.0.0.1:11434"
+DEFAULT_SUMMARY_MODEL = "qwen3:30b-a3b"
SETTING_SUMMARY_MODEL = "SUMMARY_MODEL"
ALLOWED_SETTINGS = frozenset({SETTING_SUMMARY_MODEL})
MODELS_CACHE_TTL_S = 600.0
@@ -29,12 +28,7 @@ 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",
+ "qwen3:30b-a3b",
]
app_settings = Table(
@@ -77,10 +71,14 @@ async def ensure_app_settings_table() -> None:
_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 llm_url() -> str:
+ """Read-only Ollama origin (env, never writable from the UI)."""
+ raw = (os.getenv("LLM_URL") or "").strip().rstrip("/")
+ for suffix in ("/api/generate", "/api/chat", "/api", "/v1"):
+ if raw.endswith(suffix):
+ raw = raw[: -len(suffix)].rstrip("/")
+ break
+ return raw or DEFAULT_LLM_URL
def _validate_summary_model(value: str) -> str:
@@ -91,7 +89,7 @@ def _validate_summary_model(value: str) -> str:
async def get_summary_model() -> str:
- """Stored SUMMARY_MODEL, else env, else Hermes-4.3-36B."""
+ """Stored SUMMARY_MODEL, else env, else qwen3:30b-a3b."""
await ensure_app_settings_table()
async with async_session() as session:
row = (
@@ -135,7 +133,7 @@ async def set_summary_model(value: str) -> dict:
async def get_app_settings() -> dict:
return {
"summary_model": await get_summary_model(),
- "nous_base_url": nous_base_url(),
+ "llm_url": llm_url(),
}
@@ -146,18 +144,6 @@ def _fallback_payload() -> dict:
}
-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
@@ -174,7 +160,7 @@ def _parse_models_payload(body: object) -> list[dict[str, str]]:
if isinstance(item, str) and item.strip():
out.append({"id": item.strip()})
elif isinstance(item, dict):
- mid = item.get("id") or item.get("name")
+ mid = item.get("id") or item.get("name") or item.get("model")
if mid:
out.append({"id": str(mid)})
return out
@@ -186,23 +172,15 @@ async def _http_get(url: str, *, headers: dict[str, str], timeout: float) -> htt
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.
- """
+ """Live ``GET {llm_url}/api/tags``. Never 502 — fallback on 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"
+ url = f"{llm_url()}/api/tags"
headers = {
- "Authorization": f"Bearer {key}",
"User-Agent": _models_user_agent(),
"Accept": "application/json",
}
diff --git a/app/static/index.html b/app/static/index.html
index db4fb01..c4f8190 100644
--- a/app/static/index.html
+++ b/app/static/index.html
@@ -840,7 +840,7 @@
Executive Summary WAITING
—
-
No executive summary yet — the Nous summarizer is idle until NOUS_API_KEY is set on the Pi and a model is chosen in Settings.
+
No executive summary yet — the Ollama summarizer is idle until a model is chosen in Settings and LLM_URL is reachable.
@@ -968,7 +968,7 @@
⚿ API Keys
-
Keys used by ingest services (FIRMS, Nous Portal, Telegram, AIS, …). Stored in Postgres, never shown again — only status + last 4 chars.
+
Keys used by ingest services (FIRMS, Telegram, AIS, …). Stored in Postgres, never shown again — only status + last 4 chars.
@@ -997,11 +997,11 @@
Takes effect on the next hourly run (:05). No container restart.
- Nous Portal (inference-api.nousresearch.com)
+ Local Ollama (LLM_URL)
- set in API Keys as NOUS_API_KEY (never shown here)
+ none — Ollama on the GPU host
@@ -1370,7 +1370,7 @@ function renderNewsSummary(summaries) {
const badgeEl = document.getElementById('ns-badge');
if (!bodyEl) return;
if (!summaries || !summaries.length) {
- bodyEl.innerHTML = '
No executive summary yet — the Nous summarizer is idle until NOUS_API_KEY is set on the Pi and a model is chosen in Settings.
';
+ bodyEl.innerHTML = '
No executive summary yet — the Ollama summarizer is idle until a model is chosen in Settings and LLM_URL is reachable.
';
timeEl.textContent = '—';
badgeEl.textContent = 'WAITING';
return;
@@ -1547,7 +1547,6 @@ function sentimentBadge(label) {
/* ═══════════════ API KEYS ═══════════════ */
const KEY_PATTERNS = {
'FIRMS_MAP_KEY': /^[0-9a-fA-F]{32}$/,
- 'NOUS_API_KEY': /^.{16,}$/,
'TELEGRAM_TOKEN': /^\d{8,10}:[0-9A-Za-z_-]{35}$/,
};
async function loadKeys() {
diff --git a/docker-compose.yml b/docker-compose.yml
index aa674f7..400c6b8 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -224,8 +224,7 @@ services:
DB_HOST: db
DB_PORT: ${DB_PORT:-5432}
DB_NAME: ${DB_NAME:-osint_data}
- NOUS_API_KEY: ${NOUS_API_KEY:-}
- NOUS_BASE_URL: ${NOUS_BASE_URL:-https://inference-api.nousresearch.com/v1}
+ LLM_URL: ${LLM_URL:-}
SUMMARY_MODEL: ${SUMMARY_MODEL:-}
OSINT_USER_AGENT: ${OSINT_USER_AGENT:-osint-dashboard-news-summarizer}
BATCH_SIZE: ${NEWS_BATCH_SIZE:-50}
diff --git a/docs/news.md b/docs/news.md
index 43a39c0..41e5ced 100644
--- a/docs/news.md
+++ b/docs/news.md
@@ -1,11 +1,12 @@
-# News pipeline — scraper + Nous Portal summarizer
+# News pipeline — scraper + local Ollama summarizer
The OSINT dashboard ingests ~257 global news RSS sources hourly and produces
an English LLM brief plus flagged ticker/map rows. Both services were vendored
from the upstream `~/Projects/newsPipeline` project and re-integrated here to
replace the old k8s CronJob choreography with in-compose scheduling against
-the EXISTING osint-db — **no second Postgres**. The LLM is **Nous Portal**
-(`inference-api.nousresearch.com`) — not Gemini.
+the EXISTING osint-db — **no second Postgres**. The LLM is **local Ollama**
+(`LLM_URL`, default `http://127.0.0.1:11434`) — the newsPipeline `local_llm`
+path (`POST /api/generate`). No API key.
## Architecture
@@ -16,7 +17,7 @@ the EXISTING osint-db — **no second Postgres**. The LLM is **Nous Portal**
news-scraper (Scrapy, hourly :00) ──► articles table (osint-db)
│ │
│ ▼
-news-summarizer (Nous Portal map-reduce, :05) ──► article_summaries + news_items
+news-summarizer (Ollama map-reduce, :05) ──► article_summaries + news_items
│
▼
GET /api/news · /api/news/summaries · /api/news/ticker · /api/news/map
@@ -46,8 +47,8 @@ feeds (`GET /api/news` exact key set is unchanged on purpose).
(`ON CONFLICT (url) DO NOTHING`).
2. **Summarizer** — `news/summerizer/run_news_summarizer.py` runs
`summarizer.py` at :05 past each hour. It reads articles from the last
- `SUMMARY_WINDOW_HOURS`, map-reduces them through Nous Portal
- (`SUMMARY_MODEL` / Settings, default `Hermes-4.3-36B`), writes the English
+ `SUMMARY_WINDOW_HOURS`, map-reduces them through local Ollama
+ (`SUMMARY_MODEL` / Settings, default `qwen3:30b-a3b`), writes the English
brief to `article_summaries` (column `model` is the LLM id), and flagged
ticker/map rows to `news_items`.
@@ -67,17 +68,16 @@ Container startup order doesn't matter.
## Keys and Settings
-- **`NOUS_API_KEY`** — paste in the dashboard **Keys** UI (`api_keys` /
- `keystore.KEY_REGISTRY`). Env / `.env` is an **override** (env wins, same
- as FIRMS). Never returned by any API; never emitted into `index.html`;
- never proxied from the browser.
-- **Idle without a key** — if env is unset **and** the keystore row is empty,
- the summarizer logs and idles (never crashes). News intel APIs return `[]`.
+- **No API key.** Ollama on the GPU host; the summarizer container calls
+ `LLM_URL` (`POST /api/generate`, `GET /api/tags`).
+- **Idle without Ollama** — if `/api/tags` is down or the chosen model is not
+ pulled, the summarizer logs and idles (never crashes). News intel APIs
+ return `[]`.
- **Model** — non-secret. Settings UI model selector `PUT /api/settings`
`{ "summary_model": "…" }` stores `SUMMARY_MODEL` in `app_settings` (1–128
- chars). `GET /api/settings` echoes `{summary_model, nous_base_url}`.
- `nous_base_url` is read-only. Default `Hermes-4.3-36B`. Live catalog is
- best-effort `GET /api/news/models`.
+ chars). `GET /api/settings` echoes `{summary_model, llm_url}`.
+ `llm_url` is read-only. Default `qwen3:30b-a3b`. Live catalog is
+ best-effort `GET /api/news/models` (`GET {LLM_URL}/api/tags`).
## Endpoints
@@ -192,15 +192,15 @@ Never 502s. `{ "source": "live"|"fallback", "models": [{"id": "…"}] }`.
### GET /api/settings · PUT /api/settings
```json
-{ "summary_model": "Hermes-4.3-36B", "nous_base_url": "https://inference-api.nousresearch.com/v1" }
+{ "summary_model": "qwen3:30b-a3b", "llm_url": "http://127.0.0.1:11434" }
```
-PUT body is `{ "summary_model": "<1–128 char id>" }`. `nous_base_url` is
+PUT body is `{ "summary_model": "<1–128 char id>" }`. `llm_url` is
ignored even if sent.
## Reduce JSON contract
-Reduce phase (`response_format: json_object`, English only) must be a single
+Reduce phase (`format: json` on Ollama generate, English only) must be a single
object. Parser (`intel.parse_reduce_json`) strips `
…` and
markdown json fences, then brace-slices:
@@ -234,9 +234,8 @@ lands in `article_summaries.summary_text`.
| Var | Default | Notes |
|---|---|---|
-| `NOUS_API_KEY` | *(blank)* | **Required for summaries.** Prefer Keys UI; env overrides. Unset in **both** env and `api_keys` = summarizer logs and idles (never crashes); APIs return `[]`. |
-| `NOUS_BASE_URL` | `https://inference-api.nousresearch.com/v1` | Read-only in Settings. |
-| `SUMMARY_MODEL` | `Hermes-4.3-36B` | Compose default. Operator-facing choice is Settings → `app_settings.SUMMARY_MODEL`. |
+| `LLM_URL` | `http://127.0.0.1:11434` | Ollama origin. Pi container must point at the GPU host (e.g. Tailscale). Compose leaves this empty so `.env` wins. |
+| `SUMMARY_MODEL` | `qwen3:30b-a3b` | Leave compose unset so Settings → `app_settings.SUMMARY_MODEL` reaches the worker. Env wins when set. |
| `NEWS_BATCH_SIZE` | `50` | Articles per map-phase batch (compose maps to container `BATCH_SIZE`). |
| `SUMMARY_WINDOW_HOURS` | `1` | How far back the summarizer looks for new articles. |
| `INCLUDE_FUTURES` | `0` | Legacy futures-prices coupling (upstream pipeline). OFF for OSINT; set `1` + install `yfinance` to enable. |
@@ -246,15 +245,14 @@ lands in `article_summaries.summary_text`.
| `NEWS_SUMMARIZE_RUN_ON_START` | `1` | Run one summarize immediately on container start. |
| `NEWS_SUMMARIZE_FORCE` | `0` | `1` ignores the current-UTC-hour idempotency skip (double-pins on recreate). |
| `NEWS_LOG_LEVEL` | `INFO` | Scrapy log level. |
-| `OSINT_USER_AGENT` | `osint-dashboard-news-summarizer` | Sent on every outbound Nous call. |
+| `OSINT_USER_AGENT` | `osint-dashboard-news-summarizer` | Sent on every outbound Ollama call. |
| `TELEGRAM_TOKEN` / `TELEGRAM_CHAT_ID` | *(blank)* | Reserved for the (out-of-scope) Telegram delivery bot. |
DB_* for both services is mapped to the shared osint-db credentials
(`DB_HOST=db`, same `DB_USER/DB_PASSWORD/DB_NAME` as the rest of the stack).
-Nous chat: `POST {NOUS_BASE_URL}/chat/completions` via `news/summerizer/nous_client.py`
-(`httpx`, no `openai` SDK). Auth is a Bearer token from `NOUS_API_KEY`.
-No Hermes-4 reasoning system prompt. Reduce uses `json_mode=True`.
+Ollama generate: `POST {LLM_URL}/api/generate` via `news/summerizer/ollama_client.py`
+(`httpx`, no `ollama` Python SDK). No API key. Reduce uses `format: json`.
## Prompts
@@ -268,7 +266,7 @@ language is gated behind `INCLUDE_FUTURES=1`.
```bash
PYTHONPATH=news/summerizer pytest news/summerizer/tests -v
-# intel + nous_client tests PASS (no network)
+# intel + ollama_client tests PASS (no network)
PYTHONPATH=app pytest tests/test_api_news.py \
tests/test_api_settings.py tests/test_api_live_layers.py -v
@@ -280,16 +278,17 @@ PYTHONPATH=app pytest tests/test_api_news.py \
After deploy / compose rebuild of `news-summarizer` on the Pi:
-1. Keys UI: save `NOUS_API_KEY` → status `****last4`.
-2. Settings: pick a model → Save → `GET /api/settings` echoes it.
-3. `docker compose --profile ingest logs -f news-summarizer` — next run (or
+1. Run Ollama on the GPU host and pull the model (`ollama pull qwen3:30b-a3b`).
+2. Set `LLM_URL` on the Pi to that host (Tailscale IP + `:11434`).
+3. Settings: pick a model → Save → `GET /api/settings` echoes it.
+4. `docker compose --profile ingest logs -f news-summarizer` — next run (or
`NEWS_SUMMARIZE_RUN_ON_START=1` recreate) logs `Processing N articles with
`.
-4. `curl -s localhost:8000/api/news/summaries?limit=1` — English `summary_text`, `model` set.
-5. `curl -s localhost:8000/api/news/ticker` — flagged headlines only.
-6. `curl -s localhost:8000/api/news/map` — only rows with lat/lon.
-7. HUD: NEWS ticker scrolls flagged items; map overlay pins popup with location.
-8. Unset key + empty keystore → summarizer logs idle, APIs return `[]`, no crash.
+5. `curl -s localhost:8000/api/news/summaries?limit=1` — English `summary_text`, `model` set.
+6. `curl -s localhost:8000/api/news/ticker` — flagged headlines only.
+7. `curl -s localhost:8000/api/news/map` — only rows with lat/lon.
+8. HUD: NEWS ticker scrolls flagged items; map overlay pins popup with location.
+9. Ollama down / model missing → summarizer logs idle, APIs return `[]`, no crash.
-**Operator action after merge:** paste a Nous Portal API key in API Keys; pick
-a model in Settings if the default `Hermes-4.3-36B` is not wanted; rebuild
-`osint-news-summarizer` on the Pi (`pi-app-deploy` / compose).
+**Operator action after merge:** start Ollama on the laptop GPU, set `LLM_URL`
+in Pi `.env`, pick a model in Settings if the default `qwen3:30b-a3b` is not
+wanted; rebuild `osint-news-summarizer` on the Pi (`pi-app-deploy` / compose).
diff --git a/news/summerizer/nous_client.py b/news/summerizer/nous_client.py
deleted file mode 100644
index da0b2c4..0000000
--- a/news/summerizer/nous_client.py
+++ /dev/null
@@ -1,36 +0,0 @@
-"""HTTP client for the Nous inference chat completions API."""
-
-from __future__ import annotations
-
-import os
-
-import httpx
-
-_DEFAULT_UA = "osint-dashboard-news-summarizer"
-_DEFAULT_BASE = "https://inference-api.nousresearch.com/v1"
-
-
-def chat(prompt, *, api_key, model, base_url, json_mode=False) -> str:
- resolved = (base_url or os.environ.get("NOUS_BASE_URL", _DEFAULT_BASE)).rstrip("/")
- url = f"{resolved}/chat/completions"
- headers = {
- "Authorization": f"Bearer {api_key}",
- "User-Agent": os.environ.get("OSINT_USER_AGENT") or _DEFAULT_UA,
- }
- payload = {
- "model": model,
- "messages": [{"role": "user", "content": prompt}],
- "temperature": 0.2,
- "max_tokens": 4096,
- }
- if json_mode:
- payload["response_format"] = {"type": "json_object"}
- try:
- with httpx.Client(timeout=60.0) as client:
- resp = client.post(url, headers=headers, json=payload)
- if resp.status_code == 401 or resp.status_code >= 500:
- return ""
- data = resp.json()
- return data["choices"][0]["message"]["content"]
- except Exception:
- return ""
diff --git a/news/summerizer/ollama_client.py b/news/summerizer/ollama_client.py
new file mode 100644
index 0000000..057b437
--- /dev/null
+++ b/news/summerizer/ollama_client.py
@@ -0,0 +1,102 @@
+"""HTTP client for a local Ollama generate API.
+
+Matches the newsPipeline local_llm summarizer: POST /api/generate,
+GET /api/tags. No API key. ``LLM_URL`` may be a host, ``/api``, or
+``/api/generate`` — we normalize to the origin.
+"""
+
+from __future__ import annotations
+
+import os
+
+import httpx
+
+_DEFAULT_UA = "osint-dashboard-news-summarizer"
+_DEFAULT_BASE = "http://127.0.0.1:11434"
+_GENERATE_TIMEOUT = 300.0
+_TAGS_TIMEOUT = 8.0
+
+_GENERATE_OPTIONS = {
+ "num_predict": 4096,
+ "temperature": 0.6,
+ "top_p": 0.9,
+ "top_k": 40,
+ "num_ctx": 32768,
+ "repeat_penalty": 1.1,
+}
+
+
+def normalize_base(url: str | None) -> str:
+ """Strip path suffixes so LLM_URL variants share one origin."""
+ raw = (url or os.environ.get("LLM_URL", "") or _DEFAULT_BASE).strip()
+ raw = raw.rstrip("/")
+ for suffix in ("/api/generate", "/api/chat", "/api", "/v1"):
+ if raw.endswith(suffix):
+ raw = raw[: -len(suffix)].rstrip("/")
+ break
+ return raw or _DEFAULT_BASE
+
+
+def _headers() -> dict[str, str]:
+ return {
+ "User-Agent": os.environ.get("OSINT_USER_AGENT") or _DEFAULT_UA,
+ "Accept": "application/json",
+ }
+
+
+def chat(prompt: str, *, model: str, base_url: str | None = None, json_mode: bool = False) -> str:
+ """POST /api/generate. Returns response text, or \"\" on any failure."""
+ if not (model or "").strip():
+ return ""
+ origin = normalize_base(base_url)
+ url = f"{origin}/api/generate"
+ payload: dict = {
+ "model": model.strip(),
+ "prompt": prompt,
+ "stream": False,
+ "options": dict(_GENERATE_OPTIONS),
+ }
+ if json_mode:
+ payload["format"] = "json"
+ try:
+ with httpx.Client(timeout=_GENERATE_TIMEOUT) as client:
+ resp = client.post(url, headers=_headers(), json=payload)
+ if resp.status_code == 401 or resp.status_code >= 500:
+ return ""
+ data = resp.json()
+ text = data.get("response") if isinstance(data, dict) else None
+ return text if isinstance(text, str) else ""
+ except Exception:
+ return ""
+
+
+def list_tags(base_url: str | None = None) -> list[str]:
+ """GET /api/tags model names. Empty list on failure."""
+ origin = normalize_base(base_url)
+ url = f"{origin}/api/tags"
+ try:
+ with httpx.Client(timeout=_TAGS_TIMEOUT) as client:
+ resp = client.get(url, headers=_headers())
+ if resp.status_code != 200:
+ return []
+ models = (resp.json() or {}).get("models") or []
+ names: list[str] = []
+ for item in models:
+ if isinstance(item, dict):
+ name = item.get("name") or item.get("model") or ""
+ if name:
+ names.append(str(name))
+ return names
+ except Exception:
+ return []
+
+
+def ollama_ready(model: str, *, base_url: str | None = None) -> bool:
+ """True when /api/tags is up and ``model`` is present (prefix match)."""
+ if not (model or "").strip():
+ return False
+ names = list_tags(base_url)
+ if not names:
+ return False
+ want = model.strip()
+ return any(n == want or n.startswith(want) for n in names)
diff --git a/news/summerizer/run_news_summarizer.py b/news/summerizer/run_news_summarizer.py
index 91740c6..483ea9b 100644
--- a/news/summerizer/run_news_summarizer.py
+++ b/news/summerizer/run_news_summarizer.py
@@ -1,16 +1,11 @@
#!/usr/bin/env python3
"""Scheduler loop for the news summarizer — hourly summarize at minute :05.
-Replaces the k8s CronJob (`5 * * * *`) with an in-compose loop. Runs once on
-boot (catches up on any articles scraped since the last summary), then fires
-at each :NEWS_SUMMARIZE_MINUTE wall-clock boundary.
-
-The loop is serial, so a slow LLM pass never overlaps the next run.
-
Env (all optional, 12-factor):
NEWS_SUMMARIZE_MINUTE minute of the hour to fire (default 5)
NEWS_SUMMARIZE_RUN_ON_START "1" to summarize once immediately on boot (default 1)
- NOUS_API_KEY optional in env; Keys UI / api_keys also works
+ LLM_URL Ollama origin; default http://127.0.0.1:11434
+ SUMMARY_MODEL Ollama tag (else app_settings)
"""
from __future__ import annotations
@@ -46,13 +41,10 @@ def run_summarize() -> None:
def main() -> None:
- if not os.getenv("NOUS_API_KEY", "").strip():
- logger.warning(
- "NOUS_API_KEY unset in env — will read api_keys on each run; idle if both empty"
- )
+ llm_url = os.getenv("LLM_URL", "").strip() or "http://127.0.0.1:11434"
logger.info(
- "news summarizer loop starting (minute=%s, run_on_start=%s)",
- MINUTE, RUN_ON_START,
+ "news summarizer loop starting (minute=%s, run_on_start=%s, llm_url=%s)",
+ MINUTE, RUN_ON_START, llm_url,
)
if RUN_ON_START:
run_summarize()
diff --git a/news/summerizer/summarizer.py b/news/summerizer/summarizer.py
index ea82a37..196f3b9 100644
--- a/news/summerizer/summarizer.py
+++ b/news/summerizer/summarizer.py
@@ -1,19 +1,17 @@
#!/usr/bin/env python3
-"""News summarizer — Nous map-reduce of scraped articles into brief/ticker/map.
+"""News summarizer — local Ollama map-reduce into brief/ticker/map.
Reads articles scraped within the last hour from the shared `articles` table,
-maps them with Nous (per-article English fact blocks), reduces to one JSON
-object (summary_en + ticker + map_items), and stores the brief in
+maps them with a local Ollama model (per-article English fact blocks), reduces
+to one JSON object (summary_en + ticker + map_items), and stores the brief in
`article_summaries` plus flagged rows in `news_items`. Tables live in the
EXISTING osint-db (alembic 003_news + 005_news_items, idempotent).
-Everything is env-driven (12-factor). Secrets/config are resolved at the start
-of each summarize_news() — env wins, else api_keys / app_settings:
+LLM is the newsPipeline local_llm path: Ollama POST /api/generate. No API key.
DB_HOST / DB_NAME / DB_USER / DB_PASSWORD / DB_PORT PostgreSQL (osint-db)
- NOUS_API_KEY Nous Portal key (else api_keys.name='NOUS_API_KEY')
- NOUS_BASE_URL default https://inference-api.nousresearch.com/v1
- SUMMARY_MODEL default Hermes-4.3-36B (else app_settings)
+ LLM_URL Ollama origin (default http://127.0.0.1:11434)
+ SUMMARY_MODEL Ollama tag (else app_settings; default qwen3:30b-a3b)
BATCH_SIZE articles per map-phase batch (default 50)
SUMMARY_WINDOW_HOURS look-back window in hours (default 1)
OSINT_USER_AGENT default osint-dashboard-news-summarizer
@@ -21,11 +19,6 @@ of each summarize_news() — env wins, else api_keys / app_settings:
SUMMARY_PROMPT override reduce-phase prompt (uses {final_input})
NEWS_SUMMARIZE_FORCE "1" to ignore the current-UTC-hour idempotency skip
INCLUDE_FUTURES "1" to prepend live futures prices (default 0)
-
-The futures/markets coupling from the original pipeline is gated behind
-INCLUDE_FUTURES and OFF by default — it is irrelevant to the OSINT dashboard
-and pulled yfinance into the image. Re-enable by installing yfinance and
-setting INCLUDE_FUTURES=1.
"""
from __future__ import annotations
@@ -37,7 +30,7 @@ from datetime import datetime
import psycopg2
from intel import parse_reduce_json, select_map, select_ticker
-from nous_client import chat
+from ollama_client import chat, ollama_ready
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
logger = logging.getLogger("news.summarizer")
@@ -51,8 +44,8 @@ DB_CONFIG = {
"port": int(os.getenv("DB_PORT", "5432")),
}
-DEFAULT_NOUS_BASE_URL = "https://inference-api.nousresearch.com/v1"
-DEFAULT_SUMMARY_MODEL = "Hermes-4.3-36B"
+DEFAULT_LLM_URL = "http://127.0.0.1:11434"
+DEFAULT_SUMMARY_MODEL = "qwen3:30b-a3b"
BATCH_SIZE = int(os.getenv("BATCH_SIZE", "50"))
SUMMARY_WINDOW_HOURS = int(os.getenv("SUMMARY_WINDOW_HOURS", "1"))
INCLUDE_FUTURES = os.getenv("INCLUDE_FUTURES", "0").lower() in ("1", "true", "yes")
@@ -134,20 +127,6 @@ def _kv(conn, table, name) -> str:
return (row[0] or "").strip() if row else ""
-def resolve_api_key() -> str:
- env = os.getenv("NOUS_API_KEY", "").strip()
- if env:
- return env
- try:
- conn = psycopg2.connect(**DB_CONFIG)
- try:
- return _kv(conn, "api_keys", "NOUS_API_KEY")
- finally:
- conn.close()
- except Exception: # noqa: BLE001
- return ""
-
-
def resolve_model() -> str:
env = os.getenv("SUMMARY_MODEL", "").strip()
if env:
@@ -164,15 +143,15 @@ def resolve_model() -> str:
def resolve_base_url() -> str:
- return os.getenv("NOUS_BASE_URL", DEFAULT_NOUS_BASE_URL).strip() or DEFAULT_NOUS_BASE_URL
+ return os.getenv("LLM_URL", DEFAULT_LLM_URL).strip() or DEFAULT_LLM_URL
-def call_llm(prompt: str, *, api_key: str, model: str, base_url: str, json_mode: bool = False) -> str:
- """Send a prompt to Nous chat completions and return the text (\"\" on failure)."""
- if not api_key:
- logger.warning("NOUS_API_KEY not set — skipping LLM call")
+def call_llm(prompt: str, *, model: str, base_url: str, json_mode: bool = False) -> str:
+ """Send a prompt to Ollama generate and return the text (\"\" on failure)."""
+ if not model:
+ logger.warning("SUMMARY_MODEL empty — skipping LLM call")
return ""
- return chat(prompt, api_key=api_key, model=model, base_url=base_url, json_mode=json_mode)
+ return chat(prompt, model=model, base_url=base_url, json_mode=json_mode)
# ── Futures (legacy, gated) ────────────────────────────────────────────────
@@ -426,11 +405,16 @@ def summarize_news() -> None:
)
return
- api_key = resolve_api_key()
model = resolve_model()
base_url = resolve_base_url()
- if not api_key:
- logger.warning("NOUS_API_KEY unset in env and api_keys — idle this run")
+ if not model:
+ logger.warning("SUMMARY_MODEL empty — idle this run")
+ return
+ if not ollama_ready(model, base_url=base_url):
+ logger.warning(
+ "Ollama not ready at %s (model %s missing or /api/tags down) — idle this run",
+ base_url, model,
+ )
return
articles = get_recent_news()
@@ -452,7 +436,6 @@ def summarize_news() -> None:
)
summary = call_llm(
build_map_prompt(batch),
- api_key=api_key,
model=model,
base_url=base_url,
json_mode=False,
@@ -468,7 +451,6 @@ def summarize_news() -> None:
logger.info("reduce phase over %d partial summaries", len(partial_summaries))
master_raw = call_llm(
build_master_prompt(final_input),
- api_key=api_key,
model=model,
base_url=base_url,
json_mode=True,
diff --git a/news/summerizer/tests/test_nous_client.py b/news/summerizer/tests/test_nous_client.py
deleted file mode 100644
index 002faa4..0000000
--- a/news/summerizer/tests/test_nous_client.py
+++ /dev/null
@@ -1,97 +0,0 @@
-from unittest.mock import MagicMock
-
-import httpx
-from nous_client import chat
-
-DEFAULT_UA = "osint-dashboard-news-summarizer"
-BASE = "https://inference-api.nousresearch.com/v1"
-
-
-def _ok_response(content="hello"):
- resp = MagicMock()
- resp.status_code = 200
- resp.json.return_value = {"choices": [{"message": {"content": content}}]}
- return resp
-
-
-def _install_fake(monkeypatch, post_impl):
- captured = {}
-
- class FakeClient:
- def __init__(self, timeout=None, **kwargs):
- captured["timeout"] = timeout
-
- def __enter__(self):
- return self
-
- def __exit__(self, *exc):
- return False
-
- def post(self, url, *, headers=None, json=None, **kwargs):
- captured["url"] = url
- captured["headers"] = headers
- captured["json"] = json
- return post_impl(url, headers, json)
-
- monkeypatch.setattr(httpx, "Client", FakeClient)
- return captured
-
-
-def test_posts_chat_completions_with_auth_body_and_returns_content(monkeypatch):
- captured = _install_fake(monkeypatch, lambda *a: _ok_response("the-content"))
- out = chat(
- "summarize this",
- api_key="secret-key",
- model="hermes-3",
- base_url=BASE,
- )
- assert out == "the-content"
- assert captured["url"] == f"{BASE}/chat/completions"
- assert captured["headers"]["Authorization"] == "Bearer secret-key"
- assert captured["headers"]["User-Agent"] == DEFAULT_UA
- assert captured["json"]["model"] == "hermes-3"
- assert captured["json"]["messages"] == [{"role": "user", "content": "summarize this"}]
- assert captured["json"]["temperature"] == 0.2
- assert captured["json"]["max_tokens"] == 4096
- assert "response_format" not in captured["json"]
-
-
-def test_user_agent_equals_osint_user_agent_env(monkeypatch):
- monkeypatch.setenv("OSINT_USER_AGENT", "custom-ua/2.0")
- captured = _install_fake(monkeypatch, lambda *a: _ok_response("ok"))
- chat("p", api_key="k", model="m", base_url=BASE)
- assert captured["headers"]["User-Agent"] == "custom-ua/2.0"
-
-
-def test_json_mode_sets_response_format(monkeypatch):
- captured = _install_fake(monkeypatch, lambda *a: _ok_response("{}"))
- chat("p", api_key="k", model="m", base_url=BASE, json_mode=True)
- assert captured["json"]["response_format"] == {"type": "json_object"}
-
-
-def test_401_returns_empty_string(monkeypatch):
- def post_impl(*a):
- resp = MagicMock()
- resp.status_code = 401
- return resp
-
- _install_fake(monkeypatch, post_impl)
- assert chat("p", api_key="bad", model="m", base_url=BASE) == ""
-
-
-def test_5xx_returns_empty_string(monkeypatch):
- def post_impl(*a):
- resp = MagicMock()
- resp.status_code = 503
- return resp
-
- _install_fake(monkeypatch, post_impl)
- assert chat("p", api_key="k", model="m", base_url=BASE) == ""
-
-
-def test_timeout_returns_empty_string(monkeypatch):
- def post_impl(*a):
- raise httpx.TimeoutException("timed out")
-
- _install_fake(monkeypatch, post_impl)
- assert chat("p", api_key="k", model="m", base_url=BASE) == ""
diff --git a/news/summerizer/tests/test_ollama_client.py b/news/summerizer/tests/test_ollama_client.py
new file mode 100644
index 0000000..8339c86
--- /dev/null
+++ b/news/summerizer/tests/test_ollama_client.py
@@ -0,0 +1,111 @@
+from unittest.mock import MagicMock
+
+import httpx
+from ollama_client import chat, list_tags, normalize_base, ollama_ready
+
+DEFAULT_UA = "osint-dashboard-news-summarizer"
+BASE = "http://127.0.0.1:11434"
+
+
+def _ok_response(content="hello"):
+ resp = MagicMock()
+ resp.status_code = 200
+ resp.json.return_value = {"response": content}
+ return resp
+
+
+def _install_fake(monkeypatch, post_impl=None, get_impl=None):
+ captured = {}
+
+ class FakeClient:
+ def __init__(self, timeout=None, **kwargs):
+ captured["timeout"] = timeout
+
+ def __enter__(self):
+ return self
+
+ def __exit__(self, *exc):
+ return False
+
+ def post(self, url, *, headers=None, json=None, **kwargs):
+ captured["url"] = url
+ captured["headers"] = headers
+ captured["json"] = json
+ if post_impl is None:
+ raise AssertionError("post not expected")
+ return post_impl(url, headers, json)
+
+ def get(self, url, *, headers=None, **kwargs):
+ captured["url"] = url
+ captured["headers"] = headers
+ if get_impl is None:
+ raise AssertionError("get not expected")
+ return get_impl(url, headers)
+
+ monkeypatch.setattr(httpx, "Client", FakeClient)
+ return captured
+
+
+def test_normalize_base_strips_generate_path():
+ assert normalize_base("http://ollama:11434/api/generate") == "http://ollama:11434"
+ assert normalize_base("http://ollama:11434/api/") == "http://ollama:11434"
+ assert normalize_base("http://ollama:11434") == "http://ollama:11434"
+
+
+def test_posts_generate_and_returns_response(monkeypatch):
+ captured = _install_fake(monkeypatch, post_impl=lambda *a: _ok_response("the-content"))
+ out = chat("summarize this", model="qwen3:30b-a3b", base_url=BASE)
+ assert out == "the-content"
+ assert captured["url"] == f"{BASE}/api/generate"
+ assert captured["headers"]["User-Agent"] == DEFAULT_UA
+ assert captured["json"]["model"] == "qwen3:30b-a3b"
+ assert captured["json"]["prompt"] == "summarize this"
+ assert captured["json"]["stream"] is False
+ assert "format" not in captured["json"]
+ assert captured["json"]["options"]["num_ctx"] == 32768
+ assert captured["timeout"] == 300.0
+
+
+def test_json_mode_sets_format_json(monkeypatch):
+ captured = _install_fake(monkeypatch, post_impl=lambda *a: _ok_response("{}"))
+ chat("p", model="m", base_url=BASE, json_mode=True)
+ assert captured["json"]["format"] == "json"
+
+
+def test_empty_model_returns_empty(monkeypatch):
+ assert chat("p", model="", base_url=BASE) == ""
+
+
+def test_5xx_returns_empty_string(monkeypatch):
+ def post_impl(*a):
+ resp = MagicMock()
+ resp.status_code = 503
+ return resp
+
+ _install_fake(monkeypatch, post_impl=post_impl)
+ assert chat("p", model="m", base_url=BASE) == ""
+
+
+def test_timeout_returns_empty_string(monkeypatch):
+ def post_impl(*a):
+ raise httpx.TimeoutException("timed out")
+
+ _install_fake(monkeypatch, post_impl=post_impl)
+ assert chat("p", model="m", base_url=BASE) == ""
+
+
+def test_list_tags_and_ready(monkeypatch):
+ def get_impl(*a):
+ resp = MagicMock()
+ resp.status_code = 200
+ resp.json.return_value = {
+ "models": [{"name": "qwen3:30b-a3b"}, {"name": "llama3.2:latest"}],
+ }
+ return resp
+
+ captured = _install_fake(monkeypatch, get_impl=get_impl)
+ names = list_tags(BASE)
+ assert names == ["qwen3:30b-a3b", "llama3.2:latest"]
+ assert captured["url"] == f"{BASE}/api/tags"
+ assert ollama_ready("qwen3:30b-a3b", base_url=BASE) is True
+ assert ollama_ready("missing", base_url=BASE) is False
diff --git a/tests/test_api_settings.py b/tests/test_api_settings.py
index 6dad2f5..5d2ec40 100644
--- a/tests/test_api_settings.py
+++ b/tests/test_api_settings.py
@@ -64,31 +64,26 @@ 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)
+def test_get_news_models_ollama_down_returns_fallback(monkeypatch):
+ async def boom_http_get(url, *, headers, timeout):
+ raise httpx.ConnectError("ollama 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
from settings_store import FALLBACK_MODELS
assert ids == FALLBACK_MODELS
+ assert "qwen3:30b-a3b" in ids
-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")
+def test_get_news_models_live_from_ollama_tags(monkeypatch):
+ monkeypatch.setenv("LLM_URL", "http://ollama:11434")
monkeypatch.delenv("OSINT_USER_AGENT", raising=False)
captured: dict = {}
@@ -100,7 +95,7 @@ def test_get_news_models_live_from_upstream(monkeypatch):
return None
def json(self):
- return {"data": [{"id": "live-model-a"}, {"id": "Hermes-4.3-36B"}]}
+ return {"models": [{"name": "qwen3:30b-a3b"}, {"name": "llama3.2:latest"}]}
async def fake_http_get(url, *, headers, timeout):
captured["url"] = url
@@ -117,30 +112,10 @@ def test_get_news_models_live_from_upstream(monkeypatch):
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 ids == ["qwen3:30b-a3b", "llama3.2:latest"]
+ assert captured["url"] == "http://ollama:11434/api/tags"
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
+ assert "Authorization" not in captured["headers"]
def test_put_settings_empty_returns_422():
@@ -156,17 +131,17 @@ def test_put_settings_whitespace_only_returns_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)
+ monkeypatch.delenv("LLM_URL", raising=False)
- put_resp = _put("/api/settings", {"summary_model": "google/gemini-2.5-flash"})
+ put_resp = _put("/api/settings", {"summary_model": "qwen3:30b-a3b"})
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"
+ assert put_body["summary_model"] == "qwen3:30b-a3b"
+ assert put_body["llm_url"] == "http://127.0.0.1:11434"
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"}
+ assert get_body["summary_model"] == "qwen3:30b-a3b"
+ assert get_body["llm_url"] == "http://127.0.0.1:11434"
+ assert set(get_body.keys()) == {"summary_model", "llm_url"}