# -*- coding: utf-8 -*- """ wiki_router.py – v1.4.3 (Swagger + robustes .env + optionaler ENV-Login) Änderungen ggü. v1.4.2: - **/login/env** hinzugefügt: Login mit WIKI_BOT_USER/WIKI_BOT_PASSWORD aus ENV (Secrets werden nie ausgegeben) - .env-Bootstrap robuster und **vor** dem ersten Aufruf geloggt - /.meta/env/runtime um Credentials-Flags ergänzt (ohne Klartext) - response_description-Strings mit JSON-Beispielen sauber gequotet - Keine Breaking-Changes (Signaturen & Pfade unverändert) Prefix-Hinweis: - Der Router setzt `prefix="/import/wiki"`. In `llm_api.py` **ohne** weiteren Prefix einbinden. """ from typing import Dict, Any, Optional, List from fastapi import APIRouter, HTTPException, Query from pydantic import BaseModel, Field from textwrap import dedent import os, time, logging import requests from dotenv import load_dotenv, find_dotenv from starlette.responses import PlainTextResponse # ------------------------------------------------- # Logging **vor** .env-Bootstrap initialisieren # ------------------------------------------------- logger = logging.getLogger("wiki_router") logger.setLevel(logging.INFO) # ------------------------------------------------- # Robustes .env-Loading (findet Datei auch außerhalb des CWD) # ------------------------------------------------- def _bootstrap_env() -> Optional[str]: """Versucht mehrere typische Pfade für .env zu laden und loggt die Fundstelle. Reihenfolge: 1) env `LLMAPI_ENV_FILE` 2) find_dotenv() relativ zum CWD 3) CWD/.env 4) Verzeichnis dieser Datei /.env 5) $HOME/.env 6) $HOME/.llm-api.env 7) /etc/llm-api.env """ candidates: List[str] = [] if os.getenv("LLMAPI_ENV_FILE"): candidates.append(os.getenv("LLMAPI_ENV_FILE") or "") fd = find_dotenv(".env", usecwd=True) if fd: candidates.append(fd) candidates += [ os.path.join(os.getcwd(), ".env"), os.path.join(os.path.dirname(__file__), ".env"), os.path.expanduser("~/.env"), os.path.expanduser("~/.llm-api.env"), "/etc/llm-api.env", ] for path in candidates: try: if path and os.path.exists(path): loaded = load_dotenv(path, override=False) if loaded: logger.info("wiki_router: .env geladen aus %s", path) return path except Exception as e: logger.warning("wiki_router: .env laden fehlgeschlagen (%s): %s", path, e) logger.info("wiki_router: keine .env gefunden – verwende Prozess-Umgebung") return None _BOOTSTRAP_ENV = _bootstrap_env() # ------------------------------------------------- # Router & Konfiguration # ------------------------------------------------- router = APIRouter(prefix="/import/wiki", tags=["wiki"]) # Hinweis: Werte werden NACH dem .env-Bootstrap aus os.environ gelesen. # Änderungen an .env erfordern i. d. R. einen Neustart des Dienstes. 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 (Millisekunden) # Single Session (Cookies für Login) wiki_session = requests.Session() wiki_session.headers.update({"User-Agent": "local-llm-wiki-proxy/1.4.3"}) # ------------------------------------------------- # Schemas # ------------------------------------------------- class WikiLoginRequest(BaseModel): 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 = Field(..., description="'success' bei erfolgreichem Login") message: Optional[str] = Field(None, description="Optionale Zusatzinfos") class PageInfoResponse(BaseModel): 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 = 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 # ------------------------------------------------- def _sleep(): if WIKI_SLEEPMS > 0: time.sleep(WIKI_SLEEPMS / 1000.0) 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: if method == "GET": resp = wiki_session.get(WIKI_API_URL, params=params, timeout=WIKI_TIMEOUT) else: resp = wiki_session.post(WIKI_API_URL, data=data or params, timeout=WIKI_TIMEOUT) resp.raise_for_status() return resp except Exception as e: last_exc = e logger.warning("Upstream error on %s (try %d/%d): %s", method, attempt + 1, WIKI_RETRIES + 1, e) _sleep() # alle Versuche erschöpft raise HTTPException(status_code=502, detail=f"Upstream error: {last_exc}") 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: variants.add(t.replace(" ", "_")) # Bindestrich / Gedankenstrich Varianten for a, b in [("-", "–"), ("-", "—"), ("–", "-"), ("—", "-")]: if a in t: variants.add(t.replace(a, b)) return list(variants) 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]] = {} for i in range(0, len(titles), max(1, WIKI_BATCH)): chunk = titles[i:i + max(1, WIKI_BATCH)] params = { "action": "query", "format": "json", "prop": "info", "inprop": "url", "redirects": 1, "converttitles": 1, "titles": "|".join(chunk), } resp = _request_with_retry("GET", params) data = resp.json() or {} q = data.get("query", {}) redirects = {d.get("from"): d.get("to") for d in (q.get("redirects") or [])} pages = q.get("pages", {}) or {} for pid_str, page in pages.items(): if page.get("missing") is not None or str(pid_str) == "-1": continue try: pid = int(pid_str) except ValueError: pid = int(page.get("pageid", -1)) title_out = page.get("title") fullurl = page.get("fullurl") or page.get("canonicalurl") or "" if not title_out: continue out[title_out] = {"pageid": pid, "fullurl": fullurl} # auch Originaltitel der Redirects auflösen for frm, to in redirects.items(): if to == title_out and frm not in out: out[frm] = {"pageid": pid, "fullurl": fullurl} _sleep() return out # ------------------------------------------------- # Doku-Konstanten (Markdown/.env) # ------------------------------------------------- MANUAL_WIKI_IMPORTER = dedent(""" # wiki_importer.py – Kurzanleitung ## Voraussetzungen - API erreichbar: `GET /import/wiki/health` (Status `ok`) - .env: - `API_BASE_URL=http://localhost:8000` - `WIKI_BOT_USER`, `WIKI_BOT_PASSWORD` - optional: `EXERCISE_COLLECTION=exercises` ## Smoke-Test (3 Läufe) ```bash python3 wiki_importer.py --title "Affenklatschen" --category "Übungen" --smoke-test ``` ## Vollimport ```bash python3 wiki_importer.py --all # optional: python3 wiki_importer.py --all --category "Übungen" python3 wiki_importer.py --all --dry-run ``` ## Idempotenz-Logik - external_id = `mw:{pageid}` - Fingerprint (sha256) über: `title, summary, execution, notes, duration_minutes, capabilities, keywords` - Entscheid: - not found → create - fingerprint gleich → skip - fingerprint ungleich → update (+ `imported_at`) ## Mapping (Wiki → Exercise) - Schlüsselworte → `keywords` (`,`-getrennt, getrimmt, dedupliziert) - Hilfsmittel → `equipment` - Disziplin → `discipline` - Durchführung/Notizen/Vorbereitung/Methodik → `execution`, `notes`, `preparation`, `method` - Capabilities → `capabilities` (Level 1..5) + Facetten (`capability_ge1..5`, `capability_eq1..5`, `capability_keys`) - Metadaten → `external_id`, `source="mediawiki"`, `imported_at` ## Troubleshooting - 404 bei `/import/wiki/info?...`: prüfe Prefix (kein Doppelprefix), Titelvarianten - 401 Login: echte User-Creds verwenden - 502 Upstream: `WIKI_API_URL`/TLS prüfen; Timeouts/Retry/Throttle (`WIKI_TIMEOUT`, `WIKI_RETRIES`, `WIKI_SLEEP_MS`) """) ENV_DOC = [ {"name": "WIKI_API_URL", "desc": "Basis-URL zur MediaWiki-API (z. B. http://…/w/api.php)"}, {"name": "WIKI_TIMEOUT", "desc": "Timeout in Sekunden (Default 15)"}, {"name": "WIKI_RETRIES", "desc": "Anzahl zusätzlicher Versuche (Default 1)"}, {"name": "WIKI_SLEEP_MS", "desc": "Throttle zwischen Requests in Millisekunden (Default 0)"}, {"name": "WIKI_BATCH", "desc": "Batchgröße für Titel-Enrichment (Default 50)"}, {"name": "WIKI_BOT_USER", "desc": "(optional) Benutzername für /login/env – **Wert wird nie im Klartext zurückgegeben**"}, {"name": "WIKI_BOT_PASSWORD", "desc": "(optional) Passwort für /login/env – **Wert wird nie im Klartext zurückgegeben**"}, ] # ------------------------------------------------- # Doku-/Meta-Endpunkte # ------------------------------------------------- @router.get( "/manual/wiki_importer", summary="Handbuch: wiki_importer.py (Markdown)", description="Kompaktes Handbuch mit .env-Hinweisen, Aufrufen, Idempotenz und Troubleshooting.", response_class=PlainTextResponse, response_description="Markdown-Text.", openapi_extra={ "x-codeSamples": [ {"lang": "bash", "label": "Vollimport (Standard)", "source": "python3 wiki_importer.py --all"}, {"lang": "bash", "label": "Dry-Run + Kategorie", "source": "python3 wiki_importer.py --all --category \"Übungen\" --dry-run"}, ] }, ) def manual_wiki_importer(): return MANUAL_WIKI_IMPORTER @router.get( "/meta/env", summary=".env Referenz (Wiki-bezogen)", description="Listet die relevanten Umgebungsvariablen für die Wiki-Integration auf (ohne Werte).", response_description="Array aus {name, desc}.", ) def meta_env() -> List[Dict[str, str]]: return ENV_DOC @router.get( "/meta/env/runtime", summary=".env Runtime (wirksame Werte)", description="Zeigt die aktuell wirksamen Konfigurationswerte für den Wiki-Router (ohne Secrets) und die geladene .env-Quelle.", response_description="Objekt mit 'loaded_from' und 'env' (Key→Value).", ) def meta_env_runtime() -> Dict[str, Any]: keys = ["WIKI_API_URL", "WIKI_TIMEOUT", "WIKI_RETRIES", "WIKI_SLEEP_MS", "WIKI_BATCH"] has_user = bool(os.getenv("WIKI_BOT_USER")) has_pwd = bool(os.getenv("WIKI_BOT_PASSWORD")) return { "loaded_from": _BOOTSTRAP_ENV, "env": {k: os.getenv(k) for k in keys}, "credentials": { "WIKI_BOT_USER_set": has_user, "WIKI_BOT_PASSWORD_set": has_pwd, "ready_for_login_env": has_user and has_pwd, }, } # ------------------------------------------------- # API-Endpunkte # ------------------------------------------------- @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, 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"}) token = tok.json().get("query", {}).get("tokens", {}).get("logintoken") if not token: raise HTTPException(status_code=502, detail="Kein Login-Token erhalten") # clientlogin (mit returnurl) + Fallback action=login try: cl = _request_with_retry("POST", {}, data={ "action": "clientlogin", "format": "json", "username": data.username, "password": data.password, "logintoken": token, "loginreturnurl": "https://example.org/", }) st = cl.json().get("clientlogin", {}).get("status") if st == "PASS": return WikiLoginResponse(status="success") except HTTPException: pass lg = _request_with_retry("POST", {}, data={ "action": "login", "format": "json", "lgname": data.username, "lgpassword": data.password, "lgtoken": token, }) res = lg.json().get("login", {}).get("result") if res == "Success": return WikiLoginResponse(status="success") raise HTTPException(status_code=401, detail=f"Login fehlgeschlagen: {res}") @router.post( "/login/env", response_model=WikiLoginResponse, summary="MediaWiki-Login mit .env-Credentials", description=dedent( """ Führt den Login mit **WIKI_BOT_USER/WIKI_BOT_PASSWORD** aus der Prozess-Umgebung durch. Praktisch für geplante Jobs/CLI ohne Übergabe im Body. Secrets werden **nie** im Klartext zurückgegeben. **Voraussetzung**: Beide Variablen sind gesetzt (siehe `/import/wiki/meta/env/runtime`). """ ), response_description='`{"status":"success"}` bei Erfolg.', openapi_extra={ "x-codeSamples": [ {"lang": "bash", "label": "curl", "source": "curl -s -X POST http://localhost:8000/import/wiki/login/env | jq ."} ] }, ) def login_env(): user = os.getenv("WIKI_BOT_USER") pwd = os.getenv("WIKI_BOT_PASSWORD") if not user or not pwd: raise HTTPException(status_code=400, detail="WIKI_BOT_USER/WIKI_BOT_PASSWORD nicht gesetzt") return login(WikiLoginRequest(username=user, password=pwd)) @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]: ask_query = f"[[Category:{category}]]|limit=50000" r = _request_with_retry("GET", {"action": "ask", "query": ask_query, "format": "json"}) results = r.json().get("query", {}).get("results", {}) or {} titles = list(results.keys()) info_map = _fetch_pageinfo_batch(titles) enriched: Dict[str, Any] = {} missing = 0 for title, entry in results.items(): base = entry if isinstance(entry, dict) else {} extra = info_map.get(title, {}) if not extra: missing += 1 enriched[title] = { **base, "pageid": extra.get("pageid", base.get("pageid")), "fullurl": extra.get("fullurl", base.get("fullurl")), } logger.info("/semantic/pages: %d Titel, %d ohne pageid nach Enrichment", len(results), missing) return enriched @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, 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: '.", openapi_extra={ "x-codeSamples": [ {"lang": "bash", "label": "curl", "source": "curl -s 'http://localhost:8000/import/wiki/info?title=Affenklatschen' | jq ."} ] }, ) def page_info(title: str = Query(..., description="Seitentitel (unscharf; Varianten werden versucht)")): res = _fetch_pageinfo_batch([title]) if res.get(title): d = res[title] return PageInfoResponse(pageid=d["pageid"], title=title, fullurl=d.get("fullurl", "")) for v in _normalize_variants(title): if v == title: continue res2 = _fetch_pageinfo_batch([v]) if res2.get(v): d = res2[v] return PageInfoResponse(pageid=d["pageid"], title=v, fullurl=d.get("fullurl", "")) raise HTTPException(status_code=404, detail=f"Page not found: {title}")