Trainer_LLM/llm-api/wiki_router.py
Lars 597b94ff25
All checks were successful
Deploy Trainer_LLM to llm-node / deploy (push) Successful in 1s
llm-api/wiki_router.py aktualisiert
2025-08-13 06:52:28 +02:00

370 lines
15 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
wiki_router.py v1.4.2 (Swagger angereichert)
Ä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 (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.
"""
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
load_dotenv()
logger = logging.getLogger("wiki_router")
logger.setLevel(logging.INFO)
router = APIRouter(prefix="/import/wiki", tags=["wiki"])
# -------- Konfiguration --------
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.2"})
# -------- 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
# -------- Endpoints --------
@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.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"})
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] = {}
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: <title>'.",
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)")):
# 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
res2 = _fetch_pageinfo_batch([v])
if res2.get(v):
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}")