""" BAN YARO — Rassen-Anreicherung (Wikipedia-grounded) Strategie: 1. Wikipedia-Einleitungstext abrufen (de → en Fallback + opensearch + Aliasse) 2. Claude Haiku extrahiert Fakten NUR aus dem Quelltext 3. Kein Wikipedia-Artikel → ki_enriched=2, ki_source='none' (nicht veröffentlichen) ki_enriched-Werte: 0 = noch nicht verarbeitet 1 = angereichert (mit Wikipedia-Quelle) 2 = kein Wikipedia-Artikel gefunden, übersprungen """ import asyncio import json import logging import re import sys import os import httpx sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) from database import db logger = logging.getLogger(__name__) _HAIKU_MODEL = "claude-haiku-4-5-20251001" _WP_HEADERS = {"User-Agent": "Banyaro/1.0 (https://banyaro.de; mail@banyaro.de) httpx"} # Bekannte Name-Mappings: DB-Name → Liste von Wikipedia-Titeln die zu versuchen sind _NAME_ALIASES: dict[str, list[str]] = { "Staffordshire Bullterrier": ["Staffordshire Bull Terrier"], "Tibet-Spaniel": ["Tibetan Spaniel"], "Tschechischer Terrier": ["Český teriér", "Czech Terrier", "Böhmischer Terrier"], "Weißer Schweizer Schäferhund": ["White Swiss Shepherd Dog", "Berger Blanc Suisse"], "Zwergpinscher": ["Miniature Pinscher"], "wire-haired dachshund": ["Wire-haired Dachshund", "Dachshund"], "Spinone Italiano": ["Spinone"], "Thai Bangkaew Dog": ["Thai Bangkaew"], "Terceira-Dogge": ["Fila da Terceira"], "Ungarische Bracke - Transylvanischer Laufhund": ["Transylvanian Hound"], "anglo-français de moyenne vénerie": ["Anglo-Français de Petite Vénerie", "Anglo-Français de moyenne vénerie"], "artésien-normand": ["Basset Artésien Normand"], "kanadischer Eskinohund": ["Canadian Eskimo Dog"], "Wäller": ["Wäller"], "Serbischer Laufhund": ["Srpski gonič", "Serbian Hound"], "Serbian sheep dog": ["Sharplaninac", "Šarplaninac"], "Slovenský hrubosrstý stavač": ["Slovak Rough-haired Pointer"], "Spino siciliano": ["Cirneco dell'Etna"], "Svensk vit älghund": ["Jamthund", "Jämthund", "Swedish Elkhound"], "Walker Foxhound": ["Treeing Walker Coonhound"], } # Ü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", } _TEMPER_GARBAGE = { "hunderasse", "dog breed", "breed of dog", "extinct dog breed", "dog", "hund", "rasse", } _DIRECT_FIELDS = { "beschreibung", "vorkommen_de", "groesse", "gewicht_min_kg", "gewicht_max_kg", "lebensdauer", "aktivitaet", "erfahrung", "kinder_geeignet", "wohnung_geeignet", "temperament", } _SYSTEM = ( "Du bist ein Datenprozessor für eine Hunderassen-Referenz-Datenbank. " "Extrahiere Informationen AUSSCHLIESSLICH aus dem gegebenen Quelltext. " "Setze null wenn eine Information nicht im Text steht. " "Erfinde keine Werte." ) _PROMPT = '''\ Extrahiere strukturierte Daten für die Hunderasse "{name}" aus diesem Wikipedia-Text. --- WIKIPEDIA ({lang}) --- {wiki_text} --- ENDE --- Antworte NUR mit einem JSON-Objekt. Fehlende Informationen = null. {{ "beschreibung": "3-5 informative Sätze über Charakter, Wesen und Verwendung aus dem Text. Schließe mit: Auf banyaro.app findest du weitere Informationen zu dieser Rasse.", "vorkommen_de": "1-2 Sätze zur Verbreitung in Deutschland/DACH, nur wenn im Text erwähnt, sonst null", "groesse": "klein|mittel|gross|sehr_gross oder null", "gewicht_min_kg": Zahl_oder_null, "gewicht_max_kg": Zahl_oder_null, "lebensdauer": "X-Y Jahre oder null", "aktivitaet": "niedrig|mittel|hoch|sehr_hoch oder null", "erfahrung": "anfaenger|fortgeschritten|experte oder null", "kinder_geeignet": true|false|null, "wohnung_geeignet": true|false|null, "temperament": "kommagetrennte Eigenschaften auf Deutsch aus dem Text, oder null" }} ''' def translate_temperament(text: str) -> str | None: 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 def _parse_json(raw: str) -> dict: try: return json.loads(raw) except json.JSONDecodeError: pass match = re.search(r"```(?:json)?\s*([\s\S]+?)\s*```", raw) if match: try: return json.loads(match.group(1)) except json.JSONDecodeError: pass 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]}") def _normalize_name(name: str) -> list[str]: """Erzeugt normalisierte Suchvarianten für einen Rassennamen.""" variants = [] # Bindestriche durch Leerzeichen ersetzen no_dash = name.replace("-", " ") if no_dash != name: variants.append(no_dash) # Gängige Suffixe abschneiden und Rest behalten suffixes = [ " Sheepdog", " Dog", " Hound", " Terrier", " Spaniel", " Shepherd", " Pointer", " Retriever", " Bulldog", ] for suffix in suffixes: if name.lower().endswith(suffix.lower()) and len(name) > len(suffix) + 3: variants.append(name[: -len(suffix)].strip()) break return variants async def _wp_direct(client: httpx.AsyncClient, lang: str, title: str) -> tuple[str | None, str | None]: """Holt Artikeltext via titles-Parameter (direkte Suche). Nutzt exsentences=5 statt exintro=1, damit Artikel mit sehr kurzer Einleitung (z.B. Zwergpinscher DE: 143 Zeichen) trotzdem genug Text liefern. """ try: resp = await client.get( f"https://{lang}.wikipedia.org/w/api.php", params={ "action": "query", "titles": title, "prop": "extracts", "exsentences": 5, "explaintext": 1, "format": "json", "redirects": 1, }, ) pages = resp.json().get("query", {}).get("pages", {}) for page in pages.values(): if page.get("pageid", -1) == -1: continue text = page.get("extract", "").strip() if len(text) > 80: return text[:3000], lang except Exception as e: logger.debug("WP direkt (%s/%s) fehlgeschlagen: %s", lang, title, e) return None, None async def _wp_opensearch(client: httpx.AsyncClient, lang: str, query: str) -> tuple[str | None, str | None]: """Sucht via opensearch und holt dann den ersten Treffer-Artikel.""" try: resp = await client.get( f"https://{lang}.wikipedia.org/w/api.php", params={ "action": "opensearch", "search": query, "limit": 1, "format": "json", }, ) results = resp.json() # opensearch gibt [query, [titles], [descriptions], [urls]] zurück titles = results[1] if len(results) > 1 else [] if not titles: return None, None found_title = titles[0] logger.debug("Opensearch (%s) '%s' → '%s'", lang, query, found_title) return await _wp_direct(client, lang, found_title) except Exception as e: logger.debug("Opensearch (%s/%s) fehlgeschlagen: %s", lang, query, e) return None, None async def _fetch_wikipedia_text( name: str, name_de: str | None = None ) -> tuple[str | None, str | None]: """Holt den Einleitungstext eines Wikipedia-Artikels. Fallback-Kette: 1. Direkte Suche DE (Originalname) 2. Direkte Suche EN (Originalname) 3. Direkte Suche DE (name_de, falls vorhanden und verschieden) 4. Aliasse aus _NAME_ALIASES (DE + EN je Alias) 5. Normalisierte Namensvarianten direkt DE + EN 6. Opensearch DE (Originalname) 7. Opensearch EN (Originalname) Returns: (text, lang) oder (None, None) wenn kein Artikel gefunden. """ async with httpx.AsyncClient(timeout=10, headers=_WP_HEADERS) as client: # 1+2: Direkte Suche mit Originalnamen for lang in ("de", "en"): text, lang_found = await _wp_direct(client, lang, name) if text: return text, lang_found # 3: Direkte Suche mit name_de (falls vorhanden und nicht identisch) if name_de and name_de.strip() and name_de.strip() != name: for lang in ("de", "en"): text, lang_found = await _wp_direct(client, lang, name_de.strip()) if text: logger.debug("name_de-Treffer (%s): %s → %s", lang, name, name_de) return text, lang_found # 4: Bekannte Aliasse for alias in _NAME_ALIASES.get(name, []): for lang in ("de", "en"): text, lang_found = await _wp_direct(client, lang, alias) if text: logger.debug("Alias-Treffer (%s): %s → %s", lang, name, alias) return text, lang_found # 5: Normalisierte Namensvarianten for variant in _normalize_name(name): for lang in ("de", "en"): text, lang_found = await _wp_direct(client, lang, variant) if text: logger.debug("Normalisierungs-Treffer (%s): %s → %s", lang, name, variant) return text, lang_found # 6+7: Opensearch als letzter Ausweg for lang in ("de", "en"): text, lang_found = await _wp_opensearch(client, lang, name) if text: logger.debug("Opensearch-Treffer (%s): %s", lang, name) return text, lang_found return None, None async def _fetch_wikimedia_photo(name: str) -> str | None: """Sucht ein lizenzfreies Foto via Wikipedia pageimages API (de → en Fallback).""" # Auch Aliasse probieren names_to_try = [name] + _NAME_ALIASES.get(name, []) for lang in ("de", "en"): for title in names_to_try: try: async with httpx.AsyncClient(timeout=8, headers=_WP_HEADERS) as client: resp = await client.get( f"https://{lang}.wikipedia.org/w/api.php", params={ "action": "query", "titles": title, "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/%s) fehlgeschlagen: %s", lang, title, e) return None async def _haiku_complete(prompt: str) -> tuple[str, str]: """ Fakten-Extraktion. Bevorzugt Claude Haiku (günstig + genau); ist kein Cloud-Key gesetzt oder die Cloud nicht erreichbar, fällt es sauber auf das lokale Modell (LM Studio) zurück, statt hart abzubrechen. Returns (text, model) — model fließt in wiki_rassen.ki_model, damit der Evaluator lokal-angereicherte Rassen weiterhin zur QC erkennt. """ key = os.getenv("ANTHROPIC_API_KEY", "") # 1. Bevorzugt: Claude Haiku direkt (günstigstes Cloud-Modell) if key: try: import anthropic def _call(): client = anthropic.Anthropic(api_key=key) return client.messages.create( model=_HAIKU_MODEL, max_tokens=700, system=[{ "type": "text", "text": _SYSTEM, "cache_control": {"type": "ephemeral"}, }], messages=[{"role": "user", "content": prompt}], ) loop = asyncio.get_event_loop() resp = await loop.run_in_executor(None, _call) return resp.content[0].text.strip(), _HAIKU_MODEL except Exception as e: logger.warning("Haiku (Cloud) nicht erreichbar, Fallback lokal: %s", e) # 2. Fallback: lokales Modell über die zentrale KI-Abstraktion import ki if ki.KI_MODE == "off": raise RuntimeError("Kein Cloud-Key und KI_MODE=off — Anreicherung nicht möglich.") text = await ki._local_complete(prompt, _SYSTEM, max_tokens=700, json_mode=False) return text, ki.LOCAL_MODEL async def _enrich_one(rasse, dry_run: bool = False) -> bool: """Reichert eine einzelne Rasse an. Gibt True zurück wenn erfolgreich.""" rasse = dict(rasse) name = rasse["name"] name_de = rasse.get("name_de") rasse_id = rasse["id"] # 1. Wikipedia-Text holen wiki_text, wiki_lang = await _fetch_wikipedia_text(name, name_de) if not wiki_text: if not dry_run: with db() as conn: conn.execute( "UPDATE wiki_rassen SET ki_enriched=2, ki_source='none' WHERE id=?", (rasse_id,), ) logger.info("Kein Wikipedia-Artikel: %s → übersprungen", name) await asyncio.sleep(0.5) return False if dry_run: logger.info("[DRY-RUN] Gefunden: %s (WP-%s, %d Zeichen)", name, wiki_lang.upper(), len(wiki_text)) return True # 2. KI extrahiert Fakten aus dem Quelltext (Haiku, sonst lokaler Fallback) prompt = _PROMPT.format(name=name, lang=wiki_lang.upper(), wiki_text=wiki_text) try: raw, used_model = await _haiku_complete(prompt) except Exception as e: logger.error("KI-Anfrage fehlgeschlagen für %s: %s", name, e) await asyncio.sleep(3) return False try: data = _parse_json(raw) except ValueError as e: logger.warning("JSON-Parsing fehlgeschlagen für %s: %s", name, e) await asyncio.sleep(2) return False # 3. DB-Update updates = { k: v for k, v in data.items() if k in _DIRECT_FIELDS and v is not None } if "temperament" in updates: updates["temperament"] = translate_temperament(updates["temperament"]) updates["ki_enriched"] = 1 updates["ki_model"] = used_model updates["ki_source"] = f"wikipedia_{wiki_lang}" 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, WP-%s)", name, len(updates) - 2, wiki_lang.upper()) except Exception as e: logger.error("DB-Update fehlgeschlagen für %s: %s", name, e) await asyncio.sleep(2) return False # 4. Foto holen wenn noch keins vorhanden if not rasse.get("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(1) return True async def enrich_breeds(limit: int = 10, dry_run: bool = False) -> int: """ Reichert bis zu `limit` Rassen an (ki_enriched = 0). Strategie: Wikipedia-Text holen → Haiku extrahiert Fakten. Kein Wikipedia-Artikel → ki_enriched=2, ki_source='none'. Returns: Anzahl erfolgreich angereicherter Rassen. """ with db() as conn: rassen = conn.execute( """SELECT id, name, slug, herkunft, foto_url, name_de FROM wiki_rassen WHERE ki_enriched = 0 ORDER BY name ASC LIMIT ?""", (limit,), ).fetchall() if not rassen: logger.info("Keine Rassen zur Anreicherung gefunden.") return 0 enriched_count = 0 for rasse in rassen: if await _enrich_one(rasse, dry_run=dry_run): enriched_count += 1 return enriched_count async def retry_missed(limit: int = 70, dry_run: bool = False) -> int: """ Versucht erneut, Rassen anzureichern die beim ersten Durchlauf keinen Wikipedia-Artikel hatten (ki_enriched = 2). Setzt ki_enriched NICHT zurück — verarbeitet direkt. Bei Erfolg wird ki_enriched=1 gesetzt (überschreibt 2). Bei erneutem Misserfolg bleibt ki_enriched=2. Returns: Anzahl erfolgreich angereicherter Rassen. """ with db() as conn: rassen = conn.execute( """SELECT id, name, slug, herkunft, foto_url, name_de FROM wiki_rassen WHERE ki_enriched = 2 ORDER BY name ASC LIMIT ?""", (limit,), ).fetchall() if not rassen: logger.info("Keine übersprungenen Rassen zum Retry gefunden.") return 0 logger.info("Retry für %d Rassen mit ki_enriched=2 ...", len(rassen)) enriched_count = 0 for rasse in rassen: if await _enrich_one(rasse, dry_run=dry_run): enriched_count += 1 return enriched_count def reset_gemma_entries() -> int: """Setzt alle Gemma-angereicherten Einträge zurück auf ki_enriched=0.""" with db() as conn: cur = conn.execute( "UPDATE wiki_rassen SET ki_enriched=0, ki_model=NULL, ki_source=NULL " "WHERE ki_model LIKE 'gemma%'", ) count = cur.rowcount logger.info("Gemma-Reset: %d Rassen zurückgesetzt", count) return count def translate_existing_temperaments() -> int: """Übersetzt alle englischen Temperament-Felder in der DB ins Deutsche.""" _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(",")] 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) if translated != original: conn.execute( "UPDATE wiki_rassen SET temperament=? WHERE id=?", (translated, row["id"]), ) 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 (Wikipedia-grounded)") parser.add_argument("--limit", type=int, default=10, help="Maximale Anzahl zu verarbeitender Rassen") parser.add_argument("--reset-gemma", action="store_true", help="Gemma-Einträge zurücksetzen bevor angereichert wird") parser.add_argument("--retry-missed", action="store_true", help="Rassen mit ki_enriched=2 erneut versuchen") parser.add_argument("--dry-run", action="store_true", help="Nur Wikipedia-Suche testen, keine DB-Änderungen") args = parser.parse_args() if args.reset_gemma: n = reset_gemma_entries() print(f"Reset: {n} Gemma-Einträge zurückgesetzt") if args.retry_missed: count = asyncio.run(retry_missed(args.limit, dry_run=args.dry_run)) prefix = "[DRY-RUN] " if args.dry_run else "" print(f"{prefix}Retry: {count} von {args.limit} Rassen angereichert") else: count = asyncio.run(enrich_breeds(args.limit, dry_run=args.dry_run)) prefix = "[DRY-RUN] " if args.dry_run else "" print(f"{prefix}Angereichert: {count} Rassen")