llm-api/wiki_router.py aktualisiert
All checks were successful
Deploy Trainer_LLM to llm-node / deploy (push) Successful in 1s
All checks were successful
Deploy Trainer_LLM to llm-node / deploy (push) Successful in 1s
This commit is contained in:
parent
41e5db3921
commit
375ed57778
|
|
@ -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 (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
|
# 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):
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user