llm-api/wiki_router.py aktualisiert
All checks were successful
Deploy Trainer_LLM to llm-node / deploy (push) Successful in 1s

This commit is contained in:
Lars 2025-08-13 06:50:11 +02:00
parent 41e5db3921
commit 375ed57778

View File

@ -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: Änderungen ggü. v1.4.1:
- /info: Optionalen Request-Parameter entfernt (FastAPI/Pydantic Typfehler behoben) - Alle Endpunkte mit aussagekräftigem `summary`/`description`/`response_description` versehen
- Keine API-Signaturänderungen der Routen - Parameter-Beschreibungen ergänzt (z. B. `verbose`, `category`, `title`)
- Beispiele über `x-codeSamples` (cURL) und `json_schema_extra`
- **Keine API-Signaturänderungen**
Ziele: Ziele:
- /semantic/pages reichert pageid/fullurl für ALLE Titel batchweise an (redirects=1, converttitles=1) - /semantic/pages reichert pageid/fullurl für ALLE Titel batchweise an (redirects=1, converttitles=1)
- /info robust: 404 statt 500, mit Titel-Varianten - /info robust: 404 statt 500, mit Titel-Varianten (Leerzeichen/Unterstrich/Bindestrich)
- Wiederholungen & Throttling gegen MediaWiki - Wiederholungen & Throttling gegen MediaWiki (WIKI_RETRIES, WIKI_SLEEP_MS)
- Optionale Diagnose-Ausgaben und Coverage-Kennzahlen - Optional: Diagnose-Ausgaben (verbose) und Coverage-Kennzahlen (Logs)
Hinweis Prefix:
Wenn ihr stattdessen den Prefix im Router setzen wollt, einfach in der APIRouter-Zeile unten - Der Router setzt `prefix="/import/wiki"`. In `llm_api.py` **ohne** weiteren Prefix einbinden, sonst entstehen Doppelpfade.
prefix="/import/wiki" ergänzen und in main.py OHNE prefix einbinden.
""" """
from typing import Dict, Any, Optional, List from typing import Dict, Any, Optional, List
from fastapi import APIRouter, HTTPException, Query from fastapi import APIRouter, HTTPException, Query
from pydantic import BaseModel from pydantic import BaseModel, Field
from textwrap import dedent
import os, time, logging import os, time, logging
import requests import requests
from dotenv import load_dotenv 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_TIMEOUT = float(os.getenv("WIKI_TIMEOUT", "15"))
WIKI_BATCH = int(os.getenv("WIKI_BATCH", "50")) WIKI_BATCH = int(os.getenv("WIKI_BATCH", "50"))
WIKI_RETRIES = int(os.getenv("WIKI_RETRIES", "1")) # zusätzliche Versuche bei Upstream-Fehlern 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) # Single Session (Cookies für Login)
wiki_session = requests.Session() 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 -------- # -------- Schemas --------
class WikiLoginRequest(BaseModel): class WikiLoginRequest(BaseModel):
username: str username: str = Field(..., description="MediaWiki-Benutzername (kein .env-Wert)")
password: str password: str = Field(..., description="MediaWiki-Passwort (kein .env-Wert)")
model_config = {
"json_schema_extra": {
"example": {"username": "Bot", "password": "••••••"}
}
}
class WikiLoginResponse(BaseModel): class WikiLoginResponse(BaseModel):
status: str status: str = Field(..., description="'success' bei erfolgreichem Login")
message: Optional[str] = None message: Optional[str] = Field(None, description="Optionale Zusatzinfos")
class PageInfoResponse(BaseModel): class PageInfoResponse(BaseModel):
pageid: int pageid: int = Field(..., description="Eindeutige PageID der MediaWiki-Seite")
title: str title: str = Field(..., description="Aufgelöster Titel (kann von Eingabe abweichen, z. B. Redirect/Normalize)")
fullurl: str 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): class PageContentResponse(BaseModel):
pageid: int pageid: int = Field(..., description="PageID der angefragten Seite")
title: str title: str = Field(..., description="Echo des mitgegebenen Titels (optional)")
wikitext: str wikitext: str = Field(..., description="Roh-Wikitext (inkl. Templates), keine Sanitization")
model_config = {
"json_schema_extra": {
"example": {"pageid": 218, "title": "Affenklatschen", "wikitext": "{{ÜbungInfoBox|…}}"}
}
}
# -------- Utils -------- # -------- 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: 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 last_exc: Optional[Exception] = None
for attempt in range(WIKI_RETRIES + 1): for attempt in range(WIKI_RETRIES + 1):
try: 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]: def _normalize_variants(title: str) -> List[str]:
"""Erzeuge robuste Titel-Varianten: Leerzeichen/Unterstrich, Bindestrich/Dash-Varianten."""
t = (title or "").strip() t = (title or "").strip()
variants = {t} variants = {t}
if " " in 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]]: 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: if not titles:
return {} return {}
out: Dict[str, Dict[str, Any]] = {} out: Dict[str, Dict[str, Any]] = {}
@ -137,16 +164,55 @@ def _fetch_pageinfo_batch(titles: List[str]) -> Dict[str, Dict[str, Any]]:
return out return out
# -------- Endpoints -------- # -------- Endpoints --------
@router.get("/health") @router.get(
def health(verbose: Optional[int] = Query(default=0)) -> Dict[str, Any]: "/health",
# einfacher Ping 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"}) resp = _request_with_retry("GET", {"action": "query", "meta": "siteinfo", "format": "json"})
if verbose: if verbose:
info = resp.json().get("query", {}).get("general", {}) info = resp.json().get("query", {}).get("general", {})
return {"status": "ok", "wiki": {"sitename": info.get("sitename"), "generator": info.get("generator")}} return {"status": "ok", "wiki": {"sitename": info.get("sitename"), "generator": info.get("generator")}}
return {"status": "ok"} 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): def login(data: WikiLoginRequest):
# Token holen # Token holen
tok = _request_with_retry("GET", {"action": "query", "meta": "tokens", "type": "login", "format": "json"}) 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") return WikiLoginResponse(status="success")
raise HTTPException(status_code=401, detail=f"Login fehlgeschlagen: {res}") 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) # Rohdaten aus SMW (Ask)
ask_query = f"[[Category:{category}]]|limit=50000" ask_query = f"[[Category:{category}]]|limit=50000"
r = _request_with_retry("GET", {"action": "ask", "query": ask_query, "format": "json"}) 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) logger.info("/semantic/pages: %d Titel, %d ohne pageid nach Enrichment", len(results), missing)
return enriched 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"}) resp = _request_with_retry("GET", {"action": "parse", "pageid": pageid, "prop": "wikitext", "format": "json"})
wikitext = resp.json().get("parse", {}).get("wikitext", {}).get("*", "") wikitext = resp.json().get("parse", {}).get("wikitext", {}).get("*", "")
return PageContentResponse(pageid=pageid, title=title or "", wikitext=wikitext) 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 (LeerzeichenUnterstrich, BindestrichGedankenstrich).
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 # 1. Versuch: wie geliefert, mit redirects/converttitles
res = _fetch_pageinfo_batch([title]) res = _fetch_pageinfo_batch([title])
if res.get(title): if res.get(title):