diff --git a/llm-api/wiki_router.py b/llm-api/wiki_router.py index 466ffc1..992f335 100644 --- a/llm-api/wiki_router.py +++ b/llm-api/wiki_router.py @@ -1,24 +1,26 @@ """ -wiki_router.py – v1.4.1 (stabil & nachvollziehbar) +wiki_router.py – v1.4.2 (Swagger angereichert) -Änderungen ggü. v1.4.0: -- /info: Optionalen Request-Parameter entfernt (FastAPI/Pydantic Typfehler behoben) -- Keine API-Signaturänderungen der Routen +Änderungen ggü. v1.4.1: +- Alle Endpunkte mit aussagekräftigem `summary`/`description`/`response_description` versehen +- Parameter-Beschreibungen ergänzt (z. B. `verbose`, `category`, `title`) +- Beispiele über `x-codeSamples` (cURL) und `json_schema_extra` +- **Keine API-Signaturänderungen** Ziele: - /semantic/pages reichert pageid/fullurl für ALLE Titel batchweise an (redirects=1, converttitles=1) -- /info robust: 404 statt 500, mit Titel-Varianten -- Wiederholungen & Throttling gegen MediaWiki -- Optionale Diagnose-Ausgaben und Coverage-Kennzahlen +- /info robust: 404 statt 500, mit Titel-Varianten (Leerzeichen/Unterstrich/Bindestrich) +- Wiederholungen & Throttling gegen MediaWiki (WIKI_RETRIES, WIKI_SLEEP_MS) +- Optional: Diagnose-Ausgaben (verbose) und Coverage-Kennzahlen (Logs) - -Wenn ihr stattdessen den Prefix im Router setzen wollt, einfach in der APIRouter-Zeile unten -prefix="/import/wiki" ergänzen und in main.py OHNE prefix einbinden. +Hinweis Prefix: +- Der Router setzt `prefix="/import/wiki"`. In `llm_api.py` **ohne** weiteren Prefix einbinden, sonst entstehen Doppelpfade. """ from typing import Dict, Any, Optional, List from fastapi import APIRouter, HTTPException, Query -from pydantic import BaseModel +from pydantic import BaseModel, Field +from textwrap import dedent import os, time, logging import requests from dotenv import load_dotenv @@ -35,30 +37,48 @@ WIKI_API_URL = os.getenv("WIKI_API_URL", "https://karatetrainer.net/api.php") WIKI_TIMEOUT = float(os.getenv("WIKI_TIMEOUT", "15")) WIKI_BATCH = int(os.getenv("WIKI_BATCH", "50")) WIKI_RETRIES = int(os.getenv("WIKI_RETRIES", "1")) # zusätzliche Versuche bei Upstream-Fehlern -WIKI_SLEEPMS = int(os.getenv("WIKI_SLEEP_MS", "0")) # Throttle zwischen Requests +WIKI_SLEEPMS = int(os.getenv("WIKI_SLEEP_MS", "0")) # Throttle zwischen Requests (Millisekunden) # Single Session (Cookies für Login) wiki_session = requests.Session() -wiki_session.headers.update({"User-Agent": "local-llm-wiki-proxy/1.4.1"}) +wiki_session.headers.update({"User-Agent": "local-llm-wiki-proxy/1.4.2"}) # -------- Schemas -------- class WikiLoginRequest(BaseModel): - username: str - password: str + username: str = Field(..., description="MediaWiki-Benutzername (kein .env-Wert)") + password: str = Field(..., description="MediaWiki-Passwort (kein .env-Wert)") + + model_config = { + "json_schema_extra": { + "example": {"username": "Bot", "password": "••••••"} + } + } class WikiLoginResponse(BaseModel): - status: str - message: Optional[str] = None + status: str = Field(..., description="'success' bei erfolgreichem Login") + message: Optional[str] = Field(None, description="Optionale Zusatzinfos") class PageInfoResponse(BaseModel): - pageid: int - title: str - fullurl: str + pageid: int = Field(..., description="Eindeutige PageID der MediaWiki-Seite") + title: str = Field(..., description="Aufgelöster Titel (kann von Eingabe abweichen, z. B. Redirect/Normalize)") + fullurl: str = Field(..., description="Kanonsiche URL zur Seite") + + model_config = { + "json_schema_extra": { + "example": {"pageid": 218, "title": "Affenklatschen", "fullurl": "https://…/index.php?title=Affenklatschen"} + } + } class PageContentResponse(BaseModel): - pageid: int - title: str - wikitext: str + pageid: int = Field(..., description="PageID der angefragten Seite") + title: str = Field(..., description="Echo des mitgegebenen Titels (optional)") + wikitext: str = Field(..., description="Roh-Wikitext (inkl. Templates), keine Sanitization") + + model_config = { + "json_schema_extra": { + "example": {"pageid": 218, "title": "Affenklatschen", "wikitext": "{{ÜbungInfoBox|…}}"} + } + } # -------- Utils -------- @@ -68,6 +88,9 @@ def _sleep(): def _request_with_retry(method: str, params: Dict[str, Any], *, data: Dict[str, Any] | None = None) -> requests.Response: + """Wrapper um requests.* mit Retry/Throttle und konsistenten 502-Fehlern bei Upstream-Problemen. + Nutzt .env: WIKI_RETRIES, WIKI_SLEEP_MS, WIKI_TIMEOUT, WIKI_API_URL + """ last_exc: Optional[Exception] = None for attempt in range(WIKI_RETRIES + 1): try: @@ -86,6 +109,7 @@ def _request_with_retry(method: str, params: Dict[str, Any], *, data: Dict[str, def _normalize_variants(title: str) -> List[str]: + """Erzeuge robuste Titel-Varianten: Leerzeichen/Unterstrich, Bindestrich/Dash-Varianten.""" t = (title or "").strip() variants = {t} if " " in t: @@ -98,6 +122,9 @@ def _normalize_variants(title: str) -> List[str]: def _fetch_pageinfo_batch(titles: List[str]) -> Dict[str, Dict[str, Any]]: + """Batch-Resolver für PageInfo (pageid, fullurl). Respektiert Redirects & Titel-Normalisierung. + Achtung: Große Kategorien werden in Chunks à WIKI_BATCH verarbeitet. Throttling via WIKI_SLEEP_MS. + """ if not titles: return {} out: Dict[str, Dict[str, Any]] = {} @@ -137,16 +164,55 @@ def _fetch_pageinfo_batch(titles: List[str]) -> Dict[str, Dict[str, Any]]: return out # -------- Endpoints -------- -@router.get("/health") -def health(verbose: Optional[int] = Query(default=0)) -> Dict[str, Any]: - # einfacher Ping +@router.get( + "/health", + summary="Ping & Site-Info des MediaWiki-Upstreams", + description=dedent( + """ + Führt einen leichten `meta=siteinfo`-Request gegen den konfigurierten MediaWiki-Upstream aus. + + **Besonderheiten** + - Nutzt eine persistente `requests.Session` (Cookies werden für spätere Aufrufe wiederverwendet). + - Respektiert `.env`: `WIKI_API_URL`, `WIKI_TIMEOUT`, `WIKI_RETRIES`, `WIKI_SLEEP_MS`. + - Bei Upstream-Problemen wird **HTTP 502** geworfen (statt 500). + + **Hinweis**: Je nach Wiki-Konfiguration sind detaillierte Infos (Generator/Sitename) nur **nach Login** sichtbar. + """ + ), + response_description="`{"status":"ok"}` oder mit `wiki.sitename/generator` bei `verbose=1`.", + openapi_extra={ + "x-codeSamples": [ + {"lang": "bash", "label": "curl", "source": "curl -s 'http://localhost:8000/import/wiki/health?verbose=1' | jq ."} + ] + } +) +def health(verbose: Optional[int] = Query(default=0, description="1 = Site-Metadaten (sitename/generator) mitsenden")) -> Dict[str, Any]: resp = _request_with_retry("GET", {"action": "query", "meta": "siteinfo", "format": "json"}) if verbose: info = resp.json().get("query", {}).get("general", {}) return {"status": "ok", "wiki": {"sitename": info.get("sitename"), "generator": info.get("generator")}} return {"status": "ok"} -@router.post("/login", response_model=WikiLoginResponse) + +@router.post( + "/login", + response_model=WikiLoginResponse, + summary="MediaWiki-Login (Session-Cookies werden serverseitig gespeichert)", + description=dedent( + """ + Meldet den Proxy am MediaWiki an. Unterstützt `clientlogin` (mit `loginreturnurl`) und + **fällt zurück** auf `action=login`, falls erforderlich. Erfolgreiche Logins hinterlegen die + Session-Cookies in der Server-Session und gelten für nachfolgende Requests. + + **Besonderheiten** + - Erwartet **Benutzername/Passwort im Body** (keine .env-Creds). + - Verwendet vor dem Login ein Logintoken (`meta=tokens`). + - Rückgabe `{"status":"success"}` bei Erfolg, sonst **401**. + - Respektiert Retry/Throttle aus `.env`. + """ + ), + response_description="`{"status":"success"}` bei Erfolg." +) def login(data: WikiLoginRequest): # Token holen tok = _request_with_retry("GET", {"action": "query", "meta": "tokens", "type": "login", "format": "json"}) @@ -182,8 +248,31 @@ def login(data: WikiLoginRequest): return WikiLoginResponse(status="success") raise HTTPException(status_code=401, detail=f"Login fehlgeschlagen: {res}") -@router.get("/semantic/pages") -def semantic_pages(category: str = Query(..., description="Kategorie ohne 'Category:'")) -> Dict[str, Any]: + +@router.get( + "/semantic/pages", + summary="SMW-Ask-Ergebnisse einer Kategorie mit PageID/URL anreichern", + description=dedent( + """ + Ruft Semantic MediaWiki via `action=ask` auf und liefert ein **Dictionary**: `{"Titel": {...}}`. + Anschließend werden **alle** Titel batchweise via `prop=info` um `pageid` und `fullurl` ergänzt + (berücksichtigt Redirects & Titel-Normalisierung). Große Kategorien werden in Chunks der Größe + `WIKI_BATCH` verarbeitet. Throttling gemäß `WIKI_SLEEP_MS`. + + **Rückgabe** + - Key = Seitentitel + - Value = ursprüngliche Ask-Daten **plus** `pageid` & `fullurl` (falls auflösbar) + - Kann leeres Objekt `{}` sein (z. B. wenn Login erforderlich oder Kategorie leer). + """ + ), + response_description="Dictionary pro Titel; Felder `pageid/fullurl` sind evtl. nicht für alle Titel gesetzt.", + openapi_extra={ + "x-codeSamples": [ + {"lang": "bash", "label": "curl", "source": "curl -s 'http://localhost:8000/import/wiki/semantic/pages?category=%C3%9Cbungen' | jq . | head"} + ] + } +) +def semantic_pages(category: str = Query(..., description="Kategorie-Name **ohne** 'Category:' Präfix")) -> Dict[str, Any]: # Rohdaten aus SMW (Ask) ask_query = f"[[Category:{category}]]|limit=50000" r = _request_with_retry("GET", {"action": "ask", "query": ask_query, "format": "json"}) @@ -208,14 +297,59 @@ def semantic_pages(category: str = Query(..., description="Kategorie ohne 'Categ logger.info("/semantic/pages: %d Titel, %d ohne pageid nach Enrichment", len(results), missing) return enriched -@router.get("/parsepage", response_model=PageContentResponse) -def parse_page(pageid: int = Query(...), title: str = Query(None)): + +@router.get( + "/parsepage", + response_model=PageContentResponse, + summary="Wikitext einer Seite per pageid holen", + description=dedent( + """ + Liefert den **Roh-Wikitext** (`prop=wikitext`) zu einer Seite. Der optionale `title`-Parameter dient + nur als Echo/Diagnose. Für strukturierte Extraktion (Infobox/Abschnitte) muss der Aufrufer den + Wikitext selbst parsen. + + **Besonderheiten** + - Erfordert ggf. vorherigen Login (private Wikis). + - Throttling/Retry gemäß `.env`. + - Upstream-Fehler werden als **502** gemeldet. + """ + ), + response_description="Roh-Wikitext (keine HTML-Transformation).", + openapi_extra={ + "x-codeSamples": [ + {"lang": "bash", "label": "curl", "source": "curl -s 'http://localhost:8000/import/wiki/parsepage?pageid=218&title=Affenklatschen' | jq ."} + ] + } +) +def parse_page(pageid: int = Query(..., description="Numerische PageID der Seite"), title: str = Query(None, description="Optional: Seitentitel (nur Echo)")): resp = _request_with_retry("GET", {"action": "parse", "pageid": pageid, "prop": "wikitext", "format": "json"}) wikitext = resp.json().get("parse", {}).get("wikitext", {}).get("*", "") return PageContentResponse(pageid=pageid, title=title or "", wikitext=wikitext) -@router.get("/info", response_model=PageInfoResponse) -def page_info(title: str = Query(..., description="Seitentitel")): + +@router.get( + "/info", + response_model=PageInfoResponse, + summary="PageID/URL zu einem Titel auflösen (inkl. Varianten)", + description=dedent( + """ + Versucht zuerst eine **exakte** Auflösung des angegebenen Titels (mit `redirects=1`, `converttitles=1`). + Falls nicht erfolgreich, werden **Titel-Varianten** getestet (Leerzeichen↔Unterstrich, Bindestrich↔Gedankenstrich). + Bei Erfolg Rückgabe mit `pageid`, aufgelöstem `title` und `fullurl`. Andernfalls **404**. + + **Typische Fälle** + - Unterschiedliche Schreibweisen (z. B. "Yoko Geri" vs. "Yoko_Geri"). + - Redirect-Ketten → es wird der **kanonische** Titel/URL geliefert. + """ + ), + response_description="Erfolg: PageInfo. Fehler: 404 'Page not found: