fix(import): Stammbaum-/Eltern-/isResident-Tickets der Züchterin (18)

Import-Logik:
- Externe Gründer (Zooladen/„von Privat"/„from …, <Land>") bekommen keine
  erfundenen Chart-Eltern mehr → Bill, Cooky, Zadar haben korrekt unbekannte
  Eltern (is_external_origin). [#5,#13,#28]
- Hagrid: externe Zucht wird nicht mehr als Bestand markiert (isResident=false)
  + Leerhüllen-Dedup → ein Datensatz mit Eltern Snickers × Milka. [#17,#18,#20a]
- Gender-Index: eindeutiges Geschlecht schlägt unbekanntes Duplikat → Rollen-
  Auflösung repariert (Vance→Mutter Enya, Zac→Vater Vance/Mutter Dorie). [#33,#35]
- „Eltern: X + Y"-Wurfnotizen werden geparst (~46 Würfe); v.d.↔von-den-Namens-
  kanon (Theodore→BlackFire). [#9,#11,#30]

Daten-Overrides (conflict-decisions.json, jetzt auch Gender + exakte Eltern):
- Mozart→weiblich [#2], Yuki=Camaro×Izumi [#23], Gold-Mutter=Chelsea [#36],
  Arya=Vance×Sansa (Geschwisterverpaarung) [#16], Tony→Sammy [#12],
  Odelia [#15], Jamie→Danny [#31], Silver=Taro×Beatrice [#11].

Frontend: GerbilDetailPage blendet für isResident=false Würfe + Charakter aus
(Hinweis nonResidentNote). [#17,#20b]

Tests: test_extract/test_merge_resolve erweitert; vitest 135, playwright tiere 34 grün.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-22 21:16:53 +02:00
parent 9c6ca2118a
commit ce1704c3b7
9 changed files with 465 additions and 43 deletions

View File

@@ -149,6 +149,38 @@ def is_clan_zucht(z):
return norm_zucht(z) == "kleinechaote"
# Name/breeder markers for an externally-acquired animal whose ancestry is NOT
# in the breeder's own charts: pet shops, "von Privat" private hobbyists and
# foreign catteries ("from … , Croatia"). Such founders have genuinely UNKNOWN
# parents — the chart-position heuristic must not fabricate parents for them.
_EXTERNAL_MARKERS = re.compile(
r"\b(zooladen|zoohandlung|obi|fressnapf|dehner|von\s+privat|privatkauf|"
r"vom\s+bauern|aus\s+der\s+zoohandlung)\b", re.IGNORECASE)
# Foreign acquisition: "<name> from <cattery>, <Country>" (a comma + country word).
_FOREIGN_FROM = re.compile(
r"\bfrom\b.+,\s*(croatia|kroatien|poland|polen|netherlands|niederlande|"
r"belgium|belgien|france|frankreich|austria|österreich|switzerland|schweiz|"
r"italy|italien|spain|spanien|czech|tschechien|hungary|ungarn)\b", re.IGNORECASE)
def is_external_origin(name, zucht=None, breeder=None):
"""True if an animal is an externally-acquired founder with genuinely unknown
ancestry (pet shop, private hobbyist, foreign cattery). For such animals the
positional chart heuristic must NOT invent parents — their parents are unknown.
NOT external: own stock ('… von den Kleinen Chaoten') and ordinary German
catteries that appear as in-chart ancestors (e.g. 'of Black Forest') — those
can legitimately have charted ancestors elsewhere; this gate is only for the
leaf/founder markers above.
"""
blob = " ".join(p for p in (name, zucht, breeder) if p)
if _EXTERNAL_MARKERS.search(blob):
return True
if _FOREIGN_FROM.search(name or ""):
return True
return False
def canon_pair(raw):
"""Full raw name -> (normalised call-name, canonical zucht)."""
name, zucht = split_name_zucht(raw)
@@ -319,6 +351,11 @@ def _reconstruct_parents(animals):
if not nxt:
continue
for a in group:
# Externally-acquired founders (pet shop / von Privat / foreign cattery)
# have genuinely UNKNOWN ancestry — do NOT invent chart-position parents
# for them (tickets #5 Bill von Privat, #13 Cooky vom Zooladen, #28 Zadar).
if is_external_origin(a.get("name"), a.get("zucht"), a.get("breeder")):
continue
r = a["_row"]
above = [x for x in nxt if x["_row"] <= r]
below = [x for x in nxt if x["_row"] > r]
@@ -1103,6 +1140,12 @@ def apply_conflict_decisions(merged, conflicts, path):
a["farbschlagVariants"] = [d["farbschlag"]]
if d.get("dateOfDeath"): # D5 death-date resolutions
a["death"] = norm_dob(d["dateOfDeath"])
if d.get("gender"): # gender override (box colour misread)
g = d["gender"].strip().lower()
g = {"m": "male", "männlich": "male", "w": "female",
"f": "female", "weiblich": "female"}.get(g, g)
if g in ("male", "female"):
a["gender"] = g
if "father" in d or "mother" in d:
new_refs = []
if d.get("father"):