diff --git a/app.py b/app.py index c245b3e..185c5ba 100644 --- a/app.py +++ b/app.py @@ -20,7 +20,7 @@ from fastapi import FastAPI, HTTPException from fastapi.responses import FileResponse, JSONResponse from fastapi.staticfiles import StaticFiles -from routers import jobs, maintenance, models, system, cookbook, integration +from routers import jobs, maintenance, models, system, cookbook, integration, news app = FastAPI(title="Mission Control") @@ -42,6 +42,7 @@ app.include_router(maintenance.router) app.include_router(system.router) app.include_router(cookbook.router) app.include_router(integration.router) +app.include_router(news.router) _STATIC = Path(__file__).parent / "static" diff --git a/routers/news.py b/routers/news.py new file mode 100644 index 0000000..6e64341 --- /dev/null +++ b/routers/news.py @@ -0,0 +1,98 @@ +""" +News-Router: aggregiert kuratierte RSS/Atom-Feeds rund um lokale LLMs. +Serverseitig geholt (httpx) + mit stdlib geparst (kein neuer Dep), In-memory-Cache (30 min). +Degradiert sauber: einzelne Feeds dürfen fehlschlagen, der Rest kommt trotzdem. +""" + +import time +import xml.etree.ElementTree as ET +from datetime import datetime +from email.utils import parsedate_to_datetime + +import httpx +from fastapi import APIRouter, Depends + +from auth import auth + +router = APIRouter(prefix="/api", dependencies=[Depends(auth)]) + +FEEDS = [ + ("HuggingFace", "https://huggingface.co/blog/feed.xml"), + ("llama.cpp", "https://github.com/ggml-org/llama.cpp/releases.atom"), + ("Ollama", "https://github.com/ollama/ollama/releases.atom"), + ("r/LocalLLaMA", "https://www.reddit.com/r/LocalLLaMA/.rss"), + ("Simon Willison", "https://simonwillison.net/atom/everything/"), +] +_UA = {"User-Agent": "Mozilla/5.0 (MissionControl NewsBot)"} +_TTL = 1800 # 30 min +_CACHE = {"ts": 0.0, "items": []} +_RELEVANT = ("rocm", "gfx1151", "strix", "amd", "vulkan", "llama.cpp", "gguf", "quant") + + +def _tag(t): + return t.split("}")[-1] + + +def _epoch(s): + if not s: + return 0.0 + try: + return parsedate_to_datetime(s).timestamp() + except Exception: # noqa: BLE001 + pass + try: + return datetime.fromisoformat(s.replace("Z", "+00:00")).timestamp() + except Exception: # noqa: BLE001 + return 0.0 + + +def _parse(source, xml_text): + out = [] + try: + root = ET.fromstring(xml_text) + except Exception: # noqa: BLE001 + return out + for el in root.iter(): + if _tag(el.tag) not in ("item", "entry"): + continue + title = link = date = "" + for ch in el: + t = _tag(ch.tag) + if t == "title": + title = (ch.text or "").strip() + elif t == "link": + link = ch.get("href") or (ch.text or "").strip() or link + elif t in ("pubDate", "published", "updated") and not date: + date = (ch.text or "").strip() + if title: + low = title.lower() + out.append({ + "source": source, "title": title, "link": link, + "ts": _epoch(date), + "relevant": any(k in low for k in _RELEVANT), + }) + return out[:8] + + +def get_news(): + now = time.time() + if now - _CACHE["ts"] < _TTL and _CACHE["items"]: + return _CACHE["items"] + items = [] + with httpx.Client(timeout=8.0, headers=_UA, follow_redirects=True) as c: + for source, url in FEEDS: + try: + r = c.get(url) + r.raise_for_status() + items += _parse(source, r.text) + except Exception: # noqa: BLE001 + continue + items.sort(key=lambda x: x["ts"], reverse=True) + items = items[:24] + _CACHE.update(ts=now, items=items) + return items + + +@router.get("/news") +def news(): + return {"items": get_news()} diff --git a/static/index.html b/static/index.html index ace5eeb..d5a99ae 100644 --- a/static/index.html +++ b/static/index.html @@ -20,6 +20,7 @@ Server Cookbook Verbinden + News Guides