osint-dashboard/news/summerizer/ollama_client.py
Sirius DevOps 58ff43bdee feat: rebuild news summarizer on local Ollama
Swap Nous Portal for the newsPipeline local_llm generate path
(POST /api/generate, GET /api/tags). No API key. Settings model
selector lists Ollama tags; idle if the host is down.
2026-08-28 19:38:14 -04:00

102 lines
3.1 KiB
Python

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