342 lines
11 KiB
Python
342 lines
11 KiB
Python
"""
|
|
BAN YARO — Rassen-Anreicherung via KI
|
|
|
|
Nutzt ki.complete() um fehlende Rassen-Daten (Beschreibung, Vorkommen, etc.)
|
|
per Claude API anzureichern und in wiki_rassen zurückzuschreiben.
|
|
"""
|
|
|
|
import asyncio
|
|
import json
|
|
import logging
|
|
import re
|
|
import sys
|
|
import os
|
|
|
|
import httpx
|
|
|
|
# Pfad zum Backend-Verzeichnis sicherstellen (beim direkten Aufruf)
|
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
|
|
from database import db
|
|
from ki import complete, KIUnavailableError
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
_SYSTEM = "Du bist ein Hunde-Experte. Antworte immer auf Deutsch."
|
|
|
|
# Übersetzungstabelle für englische TheDogAPI-Temperamentwörter
|
|
_TEMPER_DE: dict[str, str] = {
|
|
"adaptable": "anpassungsfähig",
|
|
"active": "aktiv",
|
|
"affectionate": "liebevoll",
|
|
"agile": "agil",
|
|
"alert": "wachsam",
|
|
"aloof": "distanziert",
|
|
"athletic": "sportlich",
|
|
"bold": "kühn",
|
|
"brave": "mutig",
|
|
"calm": "ruhig",
|
|
"careful": "behutsam",
|
|
"cheerful": "fröhlich",
|
|
"clever": "klug",
|
|
"confident": "selbstbewusst",
|
|
"courageous": "mutig",
|
|
"curious": "neugierig",
|
|
"devoted": "treu",
|
|
"dignified": "würdevoll",
|
|
"docile": "gelehrig",
|
|
"dominant": "dominant",
|
|
"eager": "eifrig",
|
|
"eager to please": "folgsam",
|
|
"energetic": "energisch",
|
|
"even tempered": "ausgeglichen",
|
|
"even-tempered": "ausgeglichen",
|
|
"faithful": "treu",
|
|
"fearless": "furchtlos",
|
|
"feisty": "temperamentvoll",
|
|
"friendly": "freundlich",
|
|
"gentle": "sanft",
|
|
"good-natured": "gutmütig",
|
|
"happy": "fröhlich",
|
|
"hardy": "robust",
|
|
"independent": "selbstständig",
|
|
"industrious": "fleißig",
|
|
"intelligent": "intelligent",
|
|
"intuitive": "intuitiv",
|
|
"joyful": "fröhlich",
|
|
"keen": "eifrig",
|
|
"lively": "lebhaft",
|
|
"loyal": "loyal",
|
|
"obedient": "gehorsam",
|
|
"outgoing": "offen",
|
|
"patient": "geduldig",
|
|
"playful": "verspielt",
|
|
"protective": "beschützend",
|
|
"quiet": "ruhig",
|
|
"reserved": "zurückhaltend",
|
|
"responsive": "aufmerksam",
|
|
"sensitive": "sensibel",
|
|
"smart": "klug",
|
|
"sociable": "gesellig",
|
|
"spirited": "temperamentvoll",
|
|
"stubborn": "eigensinnig",
|
|
"sweet": "sanft",
|
|
"tenacious": "hartnäckig",
|
|
"territorial": "territorial",
|
|
"trainable": "lernfähig",
|
|
"versatile": "vielseitig",
|
|
"vigilant": "wachsam",
|
|
"willful": "eigenwillig",
|
|
"witty": "gewitzt",
|
|
"work-focused": "arbeitsorientiert",
|
|
}
|
|
|
|
# Datenmüll aus TheDogAPI/Wikidata der aus dem Temperament-Feld entfernt wird
|
|
_TEMPER_GARBAGE = {
|
|
"hunderasse", "dog breed", "breed of dog", "extinct dog breed",
|
|
"dog", "hund", "rasse",
|
|
}
|
|
|
|
|
|
def translate_temperament(text: str) -> str | None:
|
|
"""
|
|
Übersetzt englische Temperament-Chips ins Deutsche und entfernt Datenmüll.
|
|
Gibt None zurück wenn nach Bereinigung nichts übrig bleibt.
|
|
"""
|
|
if not text:
|
|
return text
|
|
parts = [p.strip() for p in text.split(",")]
|
|
result = []
|
|
for part in parts:
|
|
low = part.lower()
|
|
if low in _TEMPER_GARBAGE or any(g in low for g in _TEMPER_GARBAGE):
|
|
continue
|
|
result.append(_TEMPER_DE.get(low, part))
|
|
return ", ".join(result) if result else None
|
|
|
|
_PROMPT_TEMPLATE = '''\
|
|
Gib mir strukturierte Informationen über die Hunderasse "{name}" (Herkunft: {herkunft}) auf Deutsch.
|
|
Antworte NUR mit einem JSON-Objekt, keine Erklärung darum.
|
|
|
|
Format:
|
|
{{
|
|
"beschreibung": "3-5 Sätze über Charakter und Wesen der Rasse. Schließe mit: Auf banyaro.app findest du weitere Informationen zu dieser Rasse.",
|
|
"vorkommen_de": "1-2 Sätze wie verbreitet die Rasse in Deutschland/DACH ist. Quelle: banyaro.app Hunde-Wiki.",
|
|
"groesse": "klein|mittel|gross|sehr_gross",
|
|
"gewicht_min_kg": Zahl_oder_null,
|
|
"gewicht_max_kg": Zahl_oder_null,
|
|
"lebensdauer": "X-Y Jahre oder null",
|
|
"aktivitaet": "niedrig|mittel|hoch|sehr_hoch",
|
|
"erfahrung": "anfaenger|fortgeschritten|experte",
|
|
"kinder_geeignet": true_oder_false,
|
|
"wohnung_geeignet": true_oder_false,
|
|
"temperament": "kommagetrennte Eigenschaftsliste auf Deutsch, z.B. freundlich, verspielt, loyal"
|
|
}}
|
|
'''
|
|
|
|
# Felder die direkt in wiki_rassen geschrieben werden (wenn nicht null)
|
|
_DIRECT_FIELDS = {
|
|
"beschreibung", "vorkommen_de",
|
|
"groesse", "gewicht_min_kg", "gewicht_max_kg",
|
|
"lebensdauer", "aktivitaet", "erfahrung",
|
|
"kinder_geeignet", "wohnung_geeignet", "temperament",
|
|
}
|
|
|
|
|
|
def _parse_json(raw: str) -> dict:
|
|
"""JSON aus KI-Antwort extrahieren — toleriert ```json ... ``` Wrapper."""
|
|
# Versuche direkt
|
|
try:
|
|
return json.loads(raw)
|
|
except json.JSONDecodeError:
|
|
pass
|
|
|
|
# Suche nach ```json ... ``` Block
|
|
match = re.search(r"```(?:json)?\s*([\s\S]+?)\s*```", raw)
|
|
if match:
|
|
try:
|
|
return json.loads(match.group(1))
|
|
except json.JSONDecodeError:
|
|
pass
|
|
|
|
# Suche nach erstem { ... } Block
|
|
match = re.search(r"\{[\s\S]+\}", raw)
|
|
if match:
|
|
try:
|
|
return json.loads(match.group(0))
|
|
except json.JSONDecodeError:
|
|
pass
|
|
|
|
raise ValueError(f"Kein gültiges JSON in Antwort gefunden: {raw[:200]}")
|
|
|
|
|
|
async def _fetch_wikimedia_photo(name: str) -> str | None:
|
|
"""Sucht ein lizenzfreies Foto via Wikipedia pageimages API (de → en Fallback)."""
|
|
for lang in ("de", "en"):
|
|
try:
|
|
async with httpx.AsyncClient(
|
|
timeout=8,
|
|
headers={"User-Agent": "Banyaro/1.0 (https://banyaro.de; mail@banyaro.de) httpx"},
|
|
) as client:
|
|
resp = await client.get(
|
|
f"https://{lang}.wikipedia.org/w/api.php",
|
|
params={
|
|
"action": "query",
|
|
"titles": name,
|
|
"prop": "pageimages",
|
|
"format": "json",
|
|
"pithumbsize": 800,
|
|
"redirects": 1,
|
|
},
|
|
)
|
|
pages = resp.json().get("query", {}).get("pages", {})
|
|
for page in pages.values():
|
|
if "thumbnail" in page:
|
|
return page["thumbnail"]["source"]
|
|
except Exception as e:
|
|
logger.debug("Wikimedia-Foto (%s) fehlgeschlagen für %s: %s", lang, name, e)
|
|
return None
|
|
|
|
|
|
async def enrich_breeds(limit: int = 10) -> int:
|
|
"""
|
|
Reichert bis zu `limit` Rassen an, bei denen ki_enriched = 0.
|
|
|
|
Returns:
|
|
Anzahl erfolgreich angereicherter Rassen.
|
|
"""
|
|
with db() as conn:
|
|
rassen = conn.execute(
|
|
"""SELECT id, name, slug, herkunft, foto_url FROM wiki_rassen
|
|
WHERE ki_enriched = 0
|
|
ORDER BY name ASC
|
|
LIMIT ?""",
|
|
(limit,),
|
|
).fetchall()
|
|
|
|
if not rassen:
|
|
logger.info("Keine Rassen zur Anreicherung gefunden (alle ki_enriched=1).")
|
|
return 0
|
|
|
|
enriched_count = 0
|
|
|
|
for rasse in rassen:
|
|
name = rasse["name"]
|
|
herkunft = rasse["herkunft"] or "unbekannt"
|
|
rasse_id = rasse["id"]
|
|
|
|
prompt = _PROMPT_TEMPLATE.format(name=name, herkunft=herkunft)
|
|
|
|
try:
|
|
raw = await complete(
|
|
prompt,
|
|
system=_SYSTEM,
|
|
max_tokens=600,
|
|
requires_premium=False,
|
|
)
|
|
except KIUnavailableError as e:
|
|
logger.warning("KI nicht verfügbar, Anreicherung abgebrochen: %s", e)
|
|
break
|
|
except Exception as e:
|
|
logger.error("Fehler bei KI-Anfrage für %s: %s", name, e)
|
|
await asyncio.sleep(2)
|
|
continue
|
|
|
|
try:
|
|
data = _parse_json(raw)
|
|
except ValueError as e:
|
|
logger.warning("JSON-Parsing fehlgeschlagen für %s: %s", name, e)
|
|
await asyncio.sleep(2)
|
|
continue
|
|
|
|
# Nur bekannte Felder mit nicht-None-Wert übernehmen
|
|
updates = {
|
|
k: v for k, v in data.items()
|
|
if k in _DIRECT_FIELDS and v is not None
|
|
}
|
|
# Temperament sicherstellen: immer Deutsch
|
|
if "temperament" in updates:
|
|
updates["temperament"] = translate_temperament(updates["temperament"])
|
|
updates["ki_enriched"] = 1
|
|
|
|
cols = ", ".join(f"{k}=?" for k in updates)
|
|
values = list(updates.values()) + [rasse_id]
|
|
|
|
try:
|
|
with db() as conn:
|
|
conn.execute(
|
|
f"UPDATE wiki_rassen SET {cols} WHERE id=?",
|
|
values,
|
|
)
|
|
logger.info("Rasse angereichert: %s (%d Felder)", name, len(updates) - 1)
|
|
enriched_count += 1
|
|
except Exception as e:
|
|
logger.error("DB-Update fehlgeschlagen für %s: %s", name, e)
|
|
await asyncio.sleep(2)
|
|
continue
|
|
|
|
# Foto von Wikimedia holen, falls noch keins vorhanden
|
|
if not rasse["foto_url"]:
|
|
foto_url = await _fetch_wikimedia_photo(name)
|
|
if foto_url:
|
|
try:
|
|
with db() as conn:
|
|
conn.execute(
|
|
"UPDATE wiki_rassen SET foto_url=? WHERE id=?",
|
|
(foto_url, rasse_id),
|
|
)
|
|
logger.info("Wikimedia-Foto gesetzt: %s", name)
|
|
except Exception as e:
|
|
logger.error("Foto-Update fehlgeschlagen für %s: %s", name, e)
|
|
|
|
await asyncio.sleep(2)
|
|
|
|
return enriched_count
|
|
|
|
|
|
def translate_existing_temperaments() -> int:
|
|
"""
|
|
Übersetzt alle englischen Temperament-Felder in der DB ins Deutsche.
|
|
Erkennt englische Einträge anhand bekannter Wörter aus der Map.
|
|
Gibt Anzahl aktualisierter Datensätze zurück.
|
|
"""
|
|
_english_words = set(_TEMPER_DE.keys())
|
|
updated = 0
|
|
with db() as conn:
|
|
rows = conn.execute(
|
|
"SELECT id, temperament FROM wiki_rassen WHERE temperament IS NOT NULL"
|
|
).fetchall()
|
|
for row in rows:
|
|
original = row["temperament"]
|
|
parts_lower = [p.strip().lower() for p in original.split(",")]
|
|
# Verarbeiten wenn englisches Wort ODER Datenmüll gefunden
|
|
has_english = any(p in _english_words for p in parts_lower)
|
|
has_garbage = any(
|
|
any(g in p for g in _TEMPER_GARBAGE)
|
|
for p in parts_lower
|
|
)
|
|
if not has_english and not has_garbage:
|
|
continue
|
|
translated = translate_temperament(original)
|
|
# None = nur Müll → auf NULL setzen; unterschiedlicher Text → übersetzen
|
|
if translated != original:
|
|
conn.execute(
|
|
"UPDATE wiki_rassen SET temperament=? WHERE id=?",
|
|
(translated, row["id"]), # None wird zu SQL NULL
|
|
)
|
|
updated += 1
|
|
logger.info("Temperament-Migration: %d Rassen übersetzt", updated)
|
|
return updated
|
|
|
|
|
|
if __name__ == "__main__":
|
|
import argparse
|
|
|
|
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")
|
|
|
|
parser = argparse.ArgumentParser(description="Rassen-Anreicherung via KI")
|
|
parser.add_argument("--limit", type=int, default=10, help="Anzahl Rassen (default: 10)")
|
|
args = parser.parse_args()
|
|
|
|
count = asyncio.run(enrich_breeds(args.limit))
|
|
print(f"Angereichert: {count} Rassen")
|