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) BAN YARO Rassen-Anreicherung (Wikipedia-grounded)
Strategie: 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 2. Claude Haiku extrahiert Fakten NUR aus dem Quelltext
3. Kein Wikipedia-Artikel ki_enriched=2, ki_source='none' (nicht veröffentlichen) 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"} _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 # Übersetzungstabelle für englische TheDogAPI-Temperamentwörter
_TEMPER_DE: dict[str, str] = { _TEMPER_DE: dict[str, str] = {
"adaptable": "anpassungsfähig", "adaptable": "anpassungsfähig",
@ -175,21 +199,39 @@ def _parse_json(raw: str) -> dict:
raise ValueError(f"Kein gültiges JSON in Antwort gefunden: {raw[:200]}") raise ValueError(f"Kein gültiges JSON in Antwort gefunden: {raw[:200]}")
async def _fetch_wikipedia_text(name: str) -> tuple[str | None, str | None]: def _normalize_name(name: str) -> list[str]:
"""Holt den Einleitungstext eines Wikipedia-Artikels (de → en Fallback). """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
Returns: (text, lang) oder (None, None) wenn kein Artikel gefunden.
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.
""" """
for lang in ("de", "en"):
try: try:
async with httpx.AsyncClient(timeout=10, headers=_WP_HEADERS) as client:
resp = await client.get( resp = await client.get(
f"https://{lang}.wikipedia.org/w/api.php", f"https://{lang}.wikipedia.org/w/api.php",
params={ params={
"action": "query", "action": "query",
"titles": name, "titles": title,
"prop": "extracts", "prop": "extracts",
"exintro": 1, "exsentences": 5,
"explaintext": 1, "explaintext": 1,
"format": "json", "format": "json",
"redirects": 1, "redirects": 1,
@ -200,23 +242,109 @@ async def _fetch_wikipedia_text(name: str) -> tuple[str | None, str | None]:
if page.get("pageid", -1) == -1: if page.get("pageid", -1) == -1:
continue continue
text = page.get("extract", "").strip() text = page.get("extract", "").strip()
if len(text) > 150: if len(text) > 80:
return text[:3000], lang return text[:3000], lang
except Exception as e: except Exception as e:
logger.debug("Wikipedia-Text (%s) fehlgeschlagen für %s: %s", lang, name, 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 return None, None
async def _fetch_wikimedia_photo(name: str) -> str | None: async def _fetch_wikimedia_photo(name: str) -> str | None:
"""Sucht ein lizenzfreies Foto via Wikipedia pageimages API (de → en Fallback).""" """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 lang in ("de", "en"):
for title in names_to_try:
try: try:
async with httpx.AsyncClient(timeout=8, headers=_WP_HEADERS) as client: async with httpx.AsyncClient(timeout=8, headers=_WP_HEADERS) as client:
resp = await client.get( resp = await client.get(
f"https://{lang}.wikipedia.org/w/api.php", f"https://{lang}.wikipedia.org/w/api.php",
params={ params={
"action": "query", "action": "query",
"titles": name, "titles": title,
"prop": "pageimages", "prop": "pageimages",
"format": "json", "format": "json",
"pithumbsize": 800, "pithumbsize": 800,
@ -228,7 +356,7 @@ async def _fetch_wikimedia_photo(name: str) -> str | None:
if "thumbnail" in page: if "thumbnail" in page:
return page["thumbnail"]["source"] return page["thumbnail"]["source"]
except Exception as e: except Exception as e:
logger.debug("Wikimedia-Foto (%s) fehlgeschlagen für %s: %s", lang, name, e) logger.debug("Wikimedia-Foto (%s/%s) fehlgeschlagen: %s", lang, title, e)
return None return None
@ -258,39 +386,17 @@ async def _haiku_complete(prompt: str) -> str:
return resp.content[0].text.strip() 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."""
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 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:
name = rasse["name"] name = rasse["name"]
name_de = rasse.get("name_de")
rasse_id = rasse["id"] rasse_id = rasse["id"]
# 1. Wikipedia-Text holen # 1. Wikipedia-Text holen
wiki_text, wiki_lang = await _fetch_wikipedia_text(name) wiki_text, wiki_lang = await _fetch_wikipedia_text(name, name_de)
if not wiki_text: if not wiki_text:
# Kein Artikel → markieren und überspringen if not dry_run:
with db() as conn: with db() as conn:
conn.execute( conn.execute(
"UPDATE wiki_rassen SET ki_enriched=2, ki_source='none' WHERE id=?", "UPDATE wiki_rassen SET ki_enriched=2, ki_source='none' WHERE id=?",
@ -298,7 +404,11 @@ async def enrich_breeds(limit: int = 10) -> int:
) )
logger.info("Kein Wikipedia-Artikel: %s → übersprungen", name) logger.info("Kein Wikipedia-Artikel: %s → übersprungen", name)
await asyncio.sleep(0.5) await asyncio.sleep(0.5)
continue 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 # 2. Haiku extrahiert Fakten aus dem Quelltext
prompt = _PROMPT.format(name=name, lang=wiki_lang.upper(), wiki_text=wiki_text) prompt = _PROMPT.format(name=name, lang=wiki_lang.upper(), wiki_text=wiki_text)
@ -307,14 +417,14 @@ async def enrich_breeds(limit: int = 10) -> int:
except Exception as e: except Exception as e:
logger.error("Haiku-Anfrage fehlgeschlagen für %s: %s", name, e) logger.error("Haiku-Anfrage fehlgeschlagen für %s: %s", name, e)
await asyncio.sleep(3) await asyncio.sleep(3)
continue return False
try: try:
data = _parse_json(raw) data = _parse_json(raw)
except ValueError as e: except ValueError as e:
logger.warning("JSON-Parsing fehlgeschlagen für %s: %s", name, e) logger.warning("JSON-Parsing fehlgeschlagen für %s: %s", name, e)
await asyncio.sleep(2) await asyncio.sleep(2)
continue return False
# 3. DB-Update # 3. DB-Update
updates = { updates = {
@ -335,14 +445,13 @@ async def enrich_breeds(limit: int = 10) -> int:
conn.execute(f"UPDATE wiki_rassen SET {cols} WHERE id=?", values) conn.execute(f"UPDATE wiki_rassen SET {cols} WHERE id=?", values)
logger.info("Rasse angereichert: %s (%d Felder, WP-%s)", logger.info("Rasse angereichert: %s (%d Felder, WP-%s)",
name, len(updates) - 2, wiki_lang.upper()) name, len(updates) - 2, wiki_lang.upper())
enriched_count += 1
except Exception as e: except Exception as e:
logger.error("DB-Update fehlgeschlagen für %s: %s", name, e) logger.error("DB-Update fehlgeschlagen für %s: %s", name, e)
await asyncio.sleep(2) await asyncio.sleep(2)
continue return False
# 4. Foto holen wenn noch keins vorhanden # 4. Foto holen wenn noch keins vorhanden
if not rasse["foto_url"]: if not rasse.get("foto_url"):
foto_url = await _fetch_wikimedia_photo(name) foto_url = await _fetch_wikimedia_photo(name)
if foto_url: if foto_url:
try: try:
@ -356,6 +465,68 @@ async def enrich_breeds(limit: int = 10) -> int:
logger.error("Foto-Update fehlgeschlagen für %s: %s", name, e) logger.error("Foto-Update fehlgeschlagen für %s: %s", name, e)
await asyncio.sleep(1) 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 return enriched_count
@ -405,14 +576,25 @@ if __name__ == "__main__":
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s") logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")
parser = argparse.ArgumentParser(description="Rassen-Anreicherung (Wikipedia-grounded)") 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", parser.add_argument("--reset-gemma", action="store_true",
help="Gemma-Einträge zurücksetzen bevor angereichert wird") 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() args = parser.parse_args()
if args.reset_gemma: if args.reset_gemma:
n = reset_gemma_entries() n = reset_gemma_entries()
print(f"Reset: {n} Gemma-Einträge zurückgesetzt") print(f"Reset: {n} Gemma-Einträge zurückgesetzt")
count = asyncio.run(enrich_breeds(args.limit)) if args.retry_missed:
print(f"Angereichert: {count} Rassen") 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")