""" 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, used_model = await complete( prompt, system=_SYSTEM, max_tokens=600, requires_premium=False, return_model=True, ) 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 updates["ki_model"] = used_model 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")