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

This commit is contained in:
Lars 2025-08-14 09:07:16 +02:00
parent 508fafd0df
commit 9327bc48d8

View File

@ -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}")