banyaro/backend/scraper/breed_enricher.py
rene f7370028da KI-Vision-Model, Breed-Scraper, Karte/Routen + Release v1292
Parallele Arbeit (auf Staging mitgetestet): KI-Vision-Model (VISION_MODEL in
ki.py/routes, im KI-Status sichtbar), Breed-Scraper-Anpassungen
(breed_enricher/breed_evaluator, evaluate_enrichment mit user_id),
Karten-/Routen-Änderungen (map.js, routes.js), kleinere UI-Anpassungen
(admin.js, components.css), docker-compose, MARKETING, nav-loop-Test.

Version-Bump auf 1292 (VERSION, sw.js, app.js, index.html, landing.html).
2026-06-14 20:23:21 +02:00

618 lines
22 KiB
Python

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