From 9327bc48d828c2e2bb5bb4343443ccc9ee4681a1 Mon Sep 17 00:00:00 2001 From: Lars Date: Thu, 14 Aug 2025 09:07:16 +0200 Subject: [PATCH] llm-api/wiki_router.py aktualisiert --- llm-api/wiki_router.py | 206 ++++++++++++++++++++++++++++------------- 1 file changed, 141 insertions(+), 65 deletions(-) diff --git a/llm-api/wiki_router.py b/llm-api/wiki_router.py index 0f21b37..38fdad5 100644 --- a/llm-api/wiki_router.py +++ b/llm-api/wiki_router.py @@ -1,20 +1,16 @@ +# -*- coding: utf-8 -*- """ -wiki_router.py – v1.4.2 (Swagger angereichert) +wiki_router.py – v1.4.3 (Swagger + robustes .env + optionaler ENV-Login) -Ä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** +Ä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) -Ziele: -- /semantic/pages reichert pageid/fullurl für ALLE Titel batchweise an (redirects=1, converttitles=1) -- /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) - -Hinweis Prefix: -- Der Router setzt `prefix="/import/wiki"`. In `llm_api.py` **ohne** weiteren Prefix einbinden, sonst entstehen Doppelpfade. +Prefix-Hinweis: +- Der Router setzt `prefix="/import/wiki"`. In `llm_api.py` **ohne** weiteren Prefix einbinden. """ from typing import Dict, Any, Optional, List @@ -23,17 +19,64 @@ from pydantic import BaseModel, Field from textwrap import dedent import os, time, logging import requests -from dotenv import load_dotenv +from dotenv import load_dotenv, find_dotenv from starlette.responses import PlainTextResponse -load_dotenv() - +# ------------------------------------------------- +# Logging **vor** .env-Bootstrap initialisieren +# ------------------------------------------------- logger = logging.getLogger("wiki_router") logger.setLevel(logging.INFO) -router = APIRouter(prefix="/import/wiki", tags=["wiki"]) +# ------------------------------------------------- +# Robustes .env-Loading (findet Datei auch außerhalb des CWD) +# ------------------------------------------------- -# -------- Konfiguration -------- +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")) @@ -42,18 +85,15 @@ WIKI_SLEEPMS = int(os.getenv("WIKI_SLEEP_MS", "0")) # Throttle zwischen Requ # Single Session (Cookies für Login) wiki_session = requests.Session() -wiki_session.headers.update({"User-Agent": "local-llm-wiki-proxy/1.4.2"}) +wiki_session.headers.update({"User-Agent": "local-llm-wiki-proxy/1.4.3"}) -# -------- Schemas -------- +# ------------------------------------------------- +# 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": "••••••"} - } - } + model_config = {"json_schema_extra": {"example": {"username": "Bot", "password": "••••••"}}} class WikiLoginResponse(BaseModel): status: str = Field(..., description="'success' bei erfolgreichem Login") @@ -63,25 +103,17 @@ 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"} - } - } + 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|…}}"}}} - model_config = { - "json_schema_extra": { - "example": {"pageid": 218, "title": "Affenklatschen", "wikitext": "{{ÜbungInfoBox|…}}"} - } - } - -# -------- Utils -------- +# ------------------------------------------------- +# Utils +# ------------------------------------------------- def _sleep(): if WIKI_SLEEPMS > 0: @@ -164,7 +196,9 @@ def _fetch_pageinfo_batch(titles: List[str]) -> Dict[str, Dict[str, Any]]: _sleep() return out -# -------- Doku-Konstanten (Markdown/.env) -------- +# ------------------------------------------------- +# Doku-Konstanten (Markdown/.env) +# ------------------------------------------------- MANUAL_WIKI_IMPORTER = dedent(""" # wiki_importer.py – Kurzanleitung @@ -215,9 +249,14 @@ ENV_DOC = [ {"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**"}, ] -# -------- Endpoints -------- +# ------------------------------------------------- +# Doku-/Meta-Endpunkte +# ------------------------------------------------- @router.get( "/manual/wiki_importer", summary="Handbuch: wiki_importer.py (Markdown)", @@ -226,18 +265,10 @@ ENV_DOC = [ 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" - } + {"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 @@ -252,7 +283,30 @@ def manual_wiki_importer(): def meta_env() -> List[Dict[str, str]]: return ENV_DOC -# -------- Endpoints -------- + +@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", @@ -268,12 +322,12 @@ def meta_env() -> List[Dict[str, str]]: **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`.", + 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"}) @@ -300,7 +354,7 @@ def health(verbose: Optional[int] = Query(default=0, description="1 = Site-Metad - Respektiert Retry/Throttle aus `.env`. """ ), - response_description="`{\"status\":\"success\"}` bei Erfolg." + response_description='`{"status":"success"}` bei Erfolg.', ) def login(data: WikiLoginRequest): # Token holen @@ -338,6 +392,33 @@ def login(data: WikiLoginRequest): 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", @@ -359,16 +440,14 @@ def login(data: WikiLoginRequest): "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"}) results = r.json().get("query", {}).get("results", {}) or {} titles = list(results.keys()) - # Batch-Anreicherung mit pageid/fullurl für ALLE Titel info_map = _fetch_pageinfo_batch(titles) enriched: Dict[str, Any] = {} @@ -408,7 +487,7 @@ def semantic_pages(category: str = Query(..., description="Kategorie-Name **ohne "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"}) @@ -436,16 +515,14 @@ def parse_page(pageid: int = Query(..., description="Numerische PageID der Seite "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)")): - # 1. Versuch: wie geliefert, mit redirects/converttitles res = _fetch_pageinfo_batch([title]) if res.get(title): d = res[title] return PageInfoResponse(pageid=d["pageid"], title=title, fullurl=d.get("fullurl", "")) - # 2. Varianten probieren for v in _normalize_variants(title): if v == title: continue @@ -454,5 +531,4 @@ def page_info(title: str = Query(..., description="Seitentitel (unscharf; Varian d = res2[v] return PageInfoResponse(pageid=d["pageid"], title=v, fullurl=d.get("fullurl", "")) - # 3. sauber 404 raise HTTPException(status_code=404, detail=f"Page not found: {title}")