Breed-Enricher: verbesserte Wikipedia-Suche + retry_missed für ki_enriched=2

- _NAME_ALIASES-Dict mit 20 Rassen-Mappings (z.B. Zwergpinscher → Miniature Pinscher)
- exsentences=5 statt exintro=1 + Threshold 80 statt 150 Zeichen (fixiert Zwergpinscher, Spinone, Thai Bangkaew)
- Neue Fallback-Kette: direkt DE/EN → name_de → Aliasse → normalisierte Namen → opensearch DE/EN
- Neue Funktion retry_missed(limit) für ki_enriched=2 ohne DB-Reset
- CLI-Flags --retry-missed und --dry-run
This commit is contained in:
rene 2026-04-25 08:36:27 +02:00
parent 5aba366b21
commit 77f6af8817

View file

@ -2,7 +2,7 @@
BAN YARO Rassen-Anreicherung (Wikipedia-grounded)
Strategie:
1. Wikipedia-Einleitungstext abrufen (de en Fallback)
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)
@ -31,6 +31,30 @@ _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",
@ -175,60 +199,164 @@ def _parse_json(raw: str) -> dict:
raise ValueError(f"Kein gültiges JSON in Antwort gefunden: {raw[:200]}")
async def _fetch_wikipedia_text(name: str) -> tuple[str | None, str | None]:
"""Holt den Einleitungstext eines Wikipedia-Artikels (de → en Fallback).
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.
"""
for lang in ("de", "en"):
try:
async with httpx.AsyncClient(timeout=10, headers=_WP_HEADERS) as client:
resp = await client.get(
f"https://{lang}.wikipedia.org/w/api.php",
params={
"action": "query",
"titles": name,
"prop": "extracts",
"exintro": 1,
"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) > 150:
return text[:3000], lang
except Exception as e:
logger.debug("Wikipedia-Text (%s) fehlgeschlagen für %s: %s", lang, name, e)
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"):
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": 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)
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
@ -258,7 +386,89 @@ async def _haiku_complete(prompt: str) -> str:
return resp.content[0].text.strip()
async def enrich_breeds(limit: int = 10) -> int:
async def _enrich_one(rasse: dict, dry_run: bool = False) -> bool:
"""Reichert eine einzelne Rasse an. Gibt True zurück wenn erfolgreich."""
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. Haiku extrahiert Fakten aus dem Quelltext
prompt = _PROMPT.format(name=name, lang=wiki_lang.upper(), wiki_text=wiki_text)
try:
raw = await _haiku_complete(prompt)
except Exception as e:
logger.error("Haiku-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"] = _HAIKU_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).
@ -269,7 +479,7 @@ async def enrich_breeds(limit: int = 10) -> int:
"""
with db() as conn:
rassen = conn.execute(
"""SELECT id, name, slug, herkunft, foto_url FROM wiki_rassen
"""SELECT id, name, slug, herkunft, foto_url, name_de FROM wiki_rassen
WHERE ki_enriched = 0
ORDER BY name ASC
LIMIT ?""",
@ -281,81 +491,42 @@ async def enrich_breeds(limit: int = 10) -> int:
return 0
enriched_count = 0
for rasse in rassen:
name = rasse["name"]
rasse_id = rasse["id"]
# 1. Wikipedia-Text holen
wiki_text, wiki_lang = await _fetch_wikipedia_text(name)
if not wiki_text:
# Kein Artikel → markieren und überspringen
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)
continue
# 2. Haiku extrahiert Fakten aus dem Quelltext
prompt = _PROMPT.format(name=name, lang=wiki_lang.upper(), wiki_text=wiki_text)
try:
raw = await _haiku_complete(prompt)
except Exception as e:
logger.error("Haiku-Anfrage fehlgeschlagen für %s: %s", name, e)
await asyncio.sleep(3)
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
# 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"] = _HAIKU_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())
if await _enrich_one(rasse, dry_run=dry_run):
enriched_count += 1
except Exception as e:
logger.error("DB-Update fehlgeschlagen für %s: %s", name, e)
await asyncio.sleep(2)
continue
# 4. Foto holen wenn 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)
return enriched_count
await asyncio.sleep(1)
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
@ -405,14 +576,25 @@ if __name__ == "__main__":
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)
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")
count = asyncio.run(enrich_breeds(args.limit))
print(f"Angereichert: {count} Rassen")
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")