From ce1704c3b7fc0d8284aa58426a8aa434caf7c923 Mon Sep 17 00:00:00 2001 From: Gulum Date: Mon, 22 Jun 2026 21:16:53 +0200 Subject: [PATCH] =?UTF-8?q?fix(import):=20Stammbaum-/Eltern-/isResident-Ti?= =?UTF-8?q?ckets=20der=20Z=C3=BCchterin=20(18)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Import-Logik: - Externe Gründer (Zooladen/„von Privat"/„from …, ") 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 --- gerbil-manager-web/e2e/tiere.spec.ts | 18 ++ .../src/pages/GerbilDetailPage.tsx | 12 + gerbil-manager-web/src/strings/de.ts | 4 + tools/import/conflict-decisions.json | 63 +++++ tools/import/extract.py | 43 ++++ tools/import/merge_and_resolve.py | 238 ++++++++++++++---- tools/import/output/review-report.md | 2 +- tools/import/test_extract.py | 48 ++++ tools/import/test_merge_resolve.py | 80 ++++++ 9 files changed, 465 insertions(+), 43 deletions(-) diff --git a/gerbil-manager-web/e2e/tiere.spec.ts b/gerbil-manager-web/e2e/tiere.spec.ts index 6e6b27a..1a6a6e1 100644 --- a/gerbil-manager-web/e2e/tiere.spec.ts +++ b/gerbil-manager-web/e2e/tiere.spec.ts @@ -137,6 +137,24 @@ test('Detailseite: Herkunft zeigt Kontaktlink wenn originContactId gesetzt (WURF await expect(originLink).toHaveAttribute('href', '/kontakte/con-meier') }) +test('Detailseite: externer Vorfahre (isResident=false) blendet Würfe & Charakter aus (#17/#20)', async ({ page }) => { + skipUnlessMock() + // 'hilde' ist isResident=false (externer Ahne, nur für den Stammbaum erfasst). + await page.goto('/rennmaeuse/hilde') + await expect(page.getByRole('heading', { name: 'Hilde' })).toBeVisible() + // Extern-Badge + Hinweis sichtbar, Charakter- und Würfe-Abschnitt ausgeblendet. + await expect(page.getByText(t.detail.nonResidentNote)).toBeVisible() + await expect(page.getByText(de.character.sectionTitle)).toBeHidden() + await expect(page.getByRole('heading', { name: t.detail.parentLittersTitle })).toBeHidden() + + // Gegenprobe: Bestandstier Krümel zeigt beide Abschnitte. + await page.goto('/rennmaeuse/kruemel') + await expect(page.getByRole('heading', { name: 'Krümel' })).toBeVisible() + await expect(page.getByText(de.character.sectionTitle)).toBeVisible() + await expect(page.getByRole('heading', { name: t.detail.parentLittersTitle })).toBeVisible() + await expect(page.getByText(t.detail.nonResidentNote)).toBeHidden() +}) + test('Namenlose Tiere zeigen Platzhalter in der Liste (UI-POLISH-1)', async ({ page }) => { skipUnlessMock() await page.goto('/rennmaeuse') diff --git a/gerbil-manager-web/src/pages/GerbilDetailPage.tsx b/gerbil-manager-web/src/pages/GerbilDetailPage.tsx index 8aa26db..a6791fc 100644 --- a/gerbil-manager-web/src/pages/GerbilDetailPage.tsx +++ b/gerbil-manager-web/src/pages/GerbilDetailPage.tsx @@ -357,6 +357,15 @@ export default function GerbilDetailPage() { )} + {/* Externe Vorfahren waren nie im eigenen Bestand → Würfe & Charakter + sind für sie nicht relevant; statt der Abschnitte ein kurzer Hinweis. */} + {g.isResident === false && ( +
+

{t.detail.nonResidentNote}

+
+ )} + + {g.isResident !== false && (
+ )} + {g.isResident !== false && (

{t.detail.parentLittersTitle}

{parentLitters.loading &&

{de.common.loading}

} @@ -457,6 +468,7 @@ export default function GerbilDetailPage() { )}
+ )}

{t.detail.moreData}

diff --git a/gerbil-manager-web/src/strings/de.ts b/gerbil-manager-web/src/strings/de.ts index 6f85061..30cf5e3 100644 --- a/gerbil-manager-web/src/strings/de.ts +++ b/gerbil-manager-web/src/strings/de.ts @@ -126,6 +126,10 @@ export const de = { siblingPairing: 'Geschwisterverpaarung', siblingPairingTitle: 'Die Eltern dieses Tiers stammen aus demselben Wurf (Vollgeschwister).', + // Externe Tiere (nur als Vorfahre erfasst): Würfe & Charakter werden + // ausgeblendet, da das Tier nie im eigenen Bestand war. + nonResidentNote: + 'Externes Tier — nur als Vorfahre erfasst. Würfe und Charakter werden nicht angezeigt, da es nie im eigenen Bestand war.', }, // Formular (anlegen/bearbeiten) form: { diff --git a/tools/import/conflict-decisions.json b/tools/import/conflict-decisions.json index 89daa55..f74b732 100644 --- a/tools/import/conflict-decisions.json +++ b/tools/import/conflict-decisions.json @@ -265,6 +265,69 @@ "father": "Osamu von den Kleinen Chaoten", "mother": "Montana v.d. Kleinen Chaoten", "source": "Stammbaum von Kazuya.xlsx" + }, + { + "name": "Mozart of Lennylengo", + "dob": "12.03.2017", + "decision": "gender = female (box colour in Stammbaum von Fire Kids.xlsx was misread as male). Mozart is the mother of Stich von Privatzucht Giessen's litter.", + "gender": "female", + "source": "Züchterin 2026-06-22 — Ticket #2 (Stich, Mutter fehlte)" + }, + { + "name": "Yuki von den Kleinen Chaoten", + "dob": "03.02.2020", + "decision": "parents are Camaro (Chevrolet Camaro of Topolino) × Izumi, NOT Benjiro + Louis (chart-position misread).", + "father": "Chevrolet Camaro of Topolino", + "mother": "Izumi von den Kleinen Chaoten", + "source": "Züchterin 2026-06-22 — Ticket #23" + }, + { + "name": "Gold v.d. Kleinen Chaoten", + "dob": "03.08.2023", + "decision": "mother should be Chelsea von den Kleinen Chaoten (not Gaida — chart-position misread). Father Trogir confirmed via contract 'Platin (Trogir.Chelsea)'.", + "father": "Trogir von den Kleinen Chaoten", + "mother": "Chelsea von den Kleinen Chaoten", + "source": "Züchterin 2026-06-22 — Ticket #36" + }, + { + "name": "Arya Stark von den Kleinen Chaoten", + "dob": "30.06.2020", + "decision": "parents are Sansa Stark (mother) × Vance (father) — a sibling pairing (both *09.04.2019). Chart-position had scrambled the roles (mother=Enya, no father).", + "father": "Vance von den Kleinen Chaoten", + "mother": "Sansa Stark von den Kleinen Chaoten", + "source": "Züchterin 2026-06-22 — Ticket #16" + }, + { + "name": "Tony v.d. Kleinen Chaoten", + "dob": "16.09.2015", + "decision": "father is Sammy von den Kleinen Chaoten (Wurfchronik L7-Wurf: 'Parents Sammy + Queenie'). Chart-position had fabricated 'Jack Jr.'.", + "father": "Sammy von den Kleinen Chaoten", + "mother": "Qamikaze Queen von den Schlossmäusen", + "source": "Züchterin 2026-06-22 — Ticket #12" + }, + { + "name": "Odelia von den Kleinen Chaoten", + "dob": "21.06.2015", + "decision": "parents are Cash × Elena (Wurfchronik O6-Wurf). Chart-position had wrong parents (Chap × Julietta).", + "father": "Cash v.d. Kleinen Chaoten", + "mother": "Elena", + "source": "Züchterin 2026-06-22 — Ticket #15" + }, + { + "name": "Jamie von den kleinen Chaoten", + "dob": "27.12.2010", + "decision": "father is Danny von Privatzucht Maintal (Wurfchronik J-Wurf: 'Eltern: Danny + Jana'). Chart-position had wrong/age-impossible parents.", + "father": "Danny von PZ Maintal", + "mother": "Jana of little longnoses", + "source": "Züchterin 2026-06-22 — Ticket #31" + }, + { + "name": "Silver von den kleinen Chaoten", + "dob": "20.03.2013", + "decision": "parents are Taro (kept from U1-Wurf) × Beatrice (Wurfchronik F2-Wurf). Cross-page chart reference to Taro was unresolvable.", + "father": "Taro von den kleinen Chaoten", + "mother": "Beatrice von den kleinen Chaoten", + "source": "Züchterin 2026-06-22 — Ticket #11" } ] } diff --git a/tools/import/extract.py b/tools/import/extract.py index 72f9f83..b7cdd37 100644 --- a/tools/import/extract.py +++ b/tools/import/extract.py @@ -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: " from , " (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"): diff --git a/tools/import/merge_and_resolve.py b/tools/import/merge_and_resolve.py index 49907c9..4a3bbea 100644 --- a/tools/import/merge_and_resolve.py +++ b/tools/import/merge_and_resolve.py @@ -117,6 +117,40 @@ def normalize_name(name): return "" return "".join(c for c in name.lower() if c.isalnum()) + +_EXTERNAL_MARKERS_RE = 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_FROM_RE = 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): + """Externally-acquired founder with genuinely unknown ancestry (pet shop, + private hobbyist, foreign cattery). Mirrors extract.is_external_origin so the + merge stage never attaches chart/Wurfchronik parents to such animals.""" + blob = " ".join(p for p in (name, zucht, breeder) if p) + if _EXTERNAL_MARKERS_RE.search(blob): + return True + if _FOREIGN_FROM_RE.search(name or ""): + return True + return False + + +def canon_name_key(name): + """Connector-folding name key: collapses the cattery connectors so that + abbreviation variants of the SAME animal match — e.g. „BlackFire v.d. + Kleinen Chaoten“ and „BlackFire von den Kleinen Chaoten“ both fold to the + same key (ticket #30). 'v.d.' / 'v. d.' → 'von den', then alnum-reduced. + Used as a secondary index next to normalize_name (never for GUIDs).""" + if not name: + return "" + n = name.lower() + n = re.sub(r"\bv\.?\s*d\.?\b", "von den", n) # v.d. / v. d. / vd → von den + return "".join(c for c in n if c.isalnum()) + def get_normalized_contact_name(name): if not name: return "", False @@ -1518,12 +1552,26 @@ def main(): norm = z.lower() return "klein" in norm and "chaot" in norm and "extern" not in norm + def is_external_cattery(a): + """True if an animal belongs to a NAMED, non-clan cattery (e.g. 'Black + Forest', 'LennyLengo', a foreign line) or the externally-acquired + founder markers. Such animals were never in the breeder's own stock, so + residency must NOT be propagated onto them (tickets #17/#20 — Hagrid of + Black Forest). Animals with no cattery at all are name-only lineage + ancestors and stay eligible for propagation.""" + for z in (a.get("zucht"), a.get("zuchtCanon"), a.get("breeder")): + z = (z or "").strip() + if z and not is_clan_zucht(z): + return True + return False + stammbaum_resident_ids = set() for a in stammbaum_only_animals: if is_clan_zucht(a.get("zucht")) or is_clan_zucht(a.get("zuchtCanon")): stammbaum_resident_ids.add(a["id"]) - # Propagate residency to parents of resident offspring + # Propagate residency to parents of resident offspring — but never onto + # animals from a named external cattery (they were never in this stock). for _ in range(5): for a in stammbaum_only_animals: if a["id"] in stammbaum_resident_ids: @@ -1533,22 +1581,45 @@ def main(): if normalize_name(cand["name"]) == p_key[0]: cand_dob = parse_date(cand["dob"]) if not p_key[1] or cand_dob == p_key[1]: + if is_external_cattery(cand): + continue stammbaum_resident_ids.add(cand["id"]) # Pre-index Wurfchronik litters from markdown md_litters_idx = {} + # …and a date-only index of Wurfchronik litters that NAME both parents. The + # Wurfchronik is authoritative: when a chart-position reconstruction picks the + # WRONG parents (so the (father,mother,date) key misses) but exactly ONE + # Wurfchronik litter exists for that birthdate, attach the animal to it rather + # than fabricating a virtual litter with bad parents (tickets #12 Tony, + # #15 Odelia, #31 Jamie — Wurfchronik-Vorrang vor chart-position). + md_litters_by_date = {} + def _md_parent_names(rl): + f = rl.get("FatherName") or rl.get("fatherName") or rl.get("ParentMaleName") or rl.get("parentMaleName") or rl.get("_father_name") + m = rl.get("MotherName") or rl.get("motherName") or rl.get("ParentFemaleName") or rl.get("parentFemaleName") or rl.get("_mother_name") + if not f and not m: + _note = rl.get("Notes") or rl.get("notes") or rl.get("Note") or rl.get("note") or "" + _m = re.search(r"Eltern:\s*(.+?)\s*\+\s*(.+?)\s*(?:;|$)", _note) + if _m and "/" not in _m.group(1) and "/" not in _m.group(2): + f, m = _m.group(1).strip(), _m.group(2).strip() + return get_normalized_gerbil_name(f), get_normalized_gerbil_name(m) for rl in raw_litters: - f_name = get_normalized_gerbil_name(rl.get("FatherName") or rl.get("fatherName") or rl.get("ParentMaleName") or rl.get("parentMaleName")) - m_name = get_normalized_gerbil_name(rl.get("MotherName") or rl.get("motherName") or rl.get("ParentFemaleName") or rl.get("parentFemaleName")) + f_name, m_name = _md_parent_names(rl) ldate = parse_date(rl.get("Date") or rl.get("date") or rl.get("DateOfBirth") or rl.get("dateOfBirth")) if f_name and m_name and ldate: key = (normalize_name(f_name), normalize_name(m_name), ldate) md_litters_idx[key] = rl + md_litters_by_date.setdefault(ldate, []).append(rl) # Gender index for parent-ref selection: normalized name → 'male' | 'female' # | 'ambiguous'. Drives the gender-aware ranking in pick_parent_ref so a dated # but wrong-sex ref (e.g. female „Danielle“) cannot win the father slot over # an undated male/unknown one (e.g. „Hagrid Rubeus“). + # A name's gender is "ambiguous" ONLY when BOTH male and female records exist + # for it. A definite gender beats an UNKNOWN (None) duplicate — otherwise a + # bare DOB-less ancestor box (gender=None) would poison a name that another + # record clearly types (e.g. Dorie of Black Forest, Zadar): they would fall + # back to None and lose gender-based role disambiguation (tickets #35, #33). gender_idx = {} for a in stammbaum_only_animals: g = (a.get("gender") or "").lower().strip() @@ -1556,10 +1627,13 @@ def main(): for key in {normalize_name(a.get("name")), normalize_name(get_call_name(a.get("name") or ""))}: if not key: continue - if key not in gender_idx: - gender_idx[key] = g - elif gender_idx[key] != g: - gender_idx[key] = "ambiguous" + cur = gender_idx.get(key, "__unset__") + if cur == "__unset__" or cur is None: + gender_idx[key] = g # first value, or upgrade None → definite + elif g is None or g == cur: + pass # keep existing definite gender + else: + gender_idx[key] = "ambiguous" # genuine male vs female conflict def gender_of_name(name): v = gender_idx.get(normalize_name(name)) @@ -1589,16 +1663,35 @@ def main(): a["_pick_discards"] = pick_discards a["_mapped_litter_scoped_id"] = None - if father_ref and mother_ref: + + # Wurfchronik-Vorrang: if the chart parents don't yield an exact + # Wurfchronik match but EXACTLY ONE Wurfchronik litter (with named + # parents) exists for this birthdate, that authoritative litter wins over + # a fabricated chart-position litter (#12 Tony, #15 Odelia, #31 Jamie). + dob_val_attach = parse_date(a.get("dob")) + if dob_val_attach and not is_external_origin(a.get("name"), a.get("zucht"), a.get("breeder")): + same_date = md_litters_by_date.get(dob_val_attach, []) + exact_hit = None + if father_ref and mother_ref: + exact_key = (normalize_name(get_normalized_gerbil_name(father_ref.get("name"))), + normalize_name(get_normalized_gerbil_name(mother_ref.get("name"))), + dob_val_attach) + exact_hit = md_litters_idx.get(exact_key) + if not exact_hit and len(same_date) == 1: + a["_mapped_litter_scoped_id"] = same_date[0]["_scoped_id"] + + if a["_mapped_litter_scoped_id"]: + pass + elif father_ref and mother_ref: f_name = get_normalized_gerbil_name(father_ref.get("name")) m_name = get_normalized_gerbil_name(mother_ref.get("name")) dob_val = parse_date(a.get("dob")) - + mapped_litter = None if dob_val: key = (normalize_name(f_name), normalize_name(m_name), dob_val) mapped_litter = md_litters_idx.get(key) - + if mapped_litter: a["_mapped_litter_scoped_id"] = mapped_litter["_scoped_id"] else: @@ -1610,13 +1703,19 @@ def main(): created_virtual_litters[v_key] = l_scoped_id a["_mapped_litter_scoped_id"] = l_scoped_id - # Try to link parents to actual parsed stammbaum animals + # Try to link parents to actual parsed stammbaum animals. + # For human override refs (conflict-decisions, method="decision") + # skip this loose call-name preliminary linking and let the + # global name resolver pick the exact named animal instead + # (e.g. resident „Elena“, not „Elena of KK Chaos“ — #15). + f_decision = (father_ref or {}).get("method") == "decision" + m_decision = (mother_ref or {}).get("method") == "decision" f_scoped_id = None m_scoped_id = None f_dob = parse_date(father_ref.get("dob")) m_dob = parse_date(mother_ref.get("dob")) - - for p_cand in stammbaum_only_animals: + + for p_cand in ([] if f_decision else stammbaum_only_animals): p_gender = str(p_cand.get("gender") or "").lower().strip() if p_gender in ["w", "f", "female", "weiblich"]: continue @@ -1629,7 +1728,7 @@ def main(): if not f_dob or p_dob == f_dob: f_scoped_id = generate_guid(f"stammbaum-animal-{p_cand['id']}") break - for p_cand in stammbaum_only_animals: + for p_cand in ([] if m_decision else stammbaum_only_animals): p_gender = str(p_cand.get("gender") or "").lower().strip() if p_gender in ["m", "male", "männlich"]: continue @@ -1766,6 +1865,22 @@ def main(): father_name = rl.get("FatherName") or rl.get("fatherName") or rl.get("ParentMaleName") or rl.get("parentMaleName") or rl.get("_father_name") mother_name = rl.get("MotherName") or rl.get("motherName") or rl.get("ParentFemaleName") or rl.get("parentFemaleName") or rl.get("_mother_name") + # Parse parents from a free-text Wurf note „Eltern: X + Y“ when the + # structured parent names/ids are missing (ticket #9 Beatrice/Q-Wurf, + # #11 Silver — ~46 Wurfchronik litters carry parents only in the note). + # The order is father + mother (German chart convention); the name + # resolver corrects the role afterwards by gender, so a swap is safe. + if not father_name and not mother_name: + _note = rl.get("Notes") or rl.get("notes") or rl.get("Note") or rl.get("note") or "" + _m = re.search(r"Eltern:\s*(.+?)\s*\+\s*(.+?)\s*(?:;|$)", _note) + if _m: + _p1 = _m.group(1).strip() + _p2 = _m.group(2).strip() + # Skip ambiguous "Lee/Dean" style alternatives (a slash = unsure). + if _p1 and _p2 and "/" not in _p1 and "/" not in _p2: + father_name = father_name or _p1 + mother_name = mother_name or _p2 + raw_ext_ref = rl.get("ExternalRef") or rl.get("externalRef") or rl.get("Id") or rl.get("id") ext_ref_scoped = f"{filename}-{raw_ext_ref}" if raw_ext_ref else None @@ -2257,14 +2372,29 @@ def main(): if pid: parent_litter_dates.setdefault(pid, []).append(ld) + def _is_empty_shell(g): + """A same-name record carrying no birthdate AND no own parent refs — a + DOB-less Stammbaum mention (e.g. Hagrid Rubeus appearing as a bare + ancestor box). Such shells must fold into the DOB-/parent-bearing record + of the same name even if a stray chart placed them as a parent of an + age-incompatible litter (ticket #18). Their own parent-attributions are + unreliable, so the parenting-date guard must not keep them separate.""" + return not g.get("_birth_date") and not (g.get("parentRefs") or []) + def are_compatible(g1, g2): + # Same-name empty shell ↔ real record: always merge (see _is_empty_shell). + if _is_empty_shell(g1) or _is_empty_shell(g2): + if g1["Gender"] == "unknown" or g2["Gender"] == "unknown" \ + or g1["Gender"] == g2["Gender"]: + return True + # Must have same gender (or one unknown) if g1["Gender"] != "unknown" and g2["Gender"] != "unknown" and g1["Gender"] != g2["Gender"]: bd1 = g1.get("_birth_date") bd2 = g2.get("_birth_date") if not (bd1 and bd2 and bd1 == bd2): return False - + bd1 = g1.get("_birth_date") bd2 = g2.get("_birth_date") @@ -2666,13 +2796,17 @@ def main(): # Create name lookup for resolved gerbils gerbil_by_norm_name = {} for g in resolved_gerbils: + keys = set() n_key = normalize_name(g["Name"]) - gerbil_by_norm_name.setdefault(n_key, []).append(g) - + keys.add(n_key) # Also index by call-name to resolve parents who are only listed by call-name - c_key = normalize_name(get_call_name(g["Name"])) - if c_key != n_key: - gerbil_by_norm_name.setdefault(c_key, []).append(g) + keys.add(normalize_name(get_call_name(g["Name"]))) + # …and by the connector-folding canon key so abbreviation variants match + # (e.g. „BlackFire v.d. Kleinen Chaoten“ vs „… von den …“ — ticket #30). + keys.add(canon_name_key(g["Name"])) + for k in keys: + if k: + gerbil_by_norm_name.setdefault(k, []).append(g) # Map raw Guid if present (convert if old_id mapped to new_guid) for l in resolved_litters: @@ -2708,29 +2842,49 @@ def main(): """ if not name: return None - cands = [] - for c in gerbil_by_norm_name.get(normalize_name(name), []): - final_id = gerbil_id_map.get(c["Id"]) - if not final_id: + # Try the exact normalized key, then the connector-folding canon key and + # the call-name (handles v.d. ↔ von den abbreviation variants — #30). + lookups = [] + for k in (normalize_name(name), canon_name_key(name), + normalize_name(get_call_name(name))): + if k and k not in lookups: + lookups.append(k) + # Resolve key-by-key so an EXACT full-name match (lookups[0]) wins over a + # mere call-name/canon fallback — e.g. override mother „Elena“ must pick + # the resident „Elena“, not „Elena of KK Chaos“ whose call-name is also + # „Elena“ (ticket #15). + seen_ids = set() + for k in lookups: + cands = [] + for c in gerbil_by_norm_name.get(k, []): + if c["Id"] in seen_ids: + continue + seen_ids.add(c["Id"]) + final_id = gerbil_id_map.get(c["Id"]) + if not final_id: + continue + final_c = gerbil_by_id_final.get(final_id) + if not final_c: + continue + # Parent must be age-plausible: born before the litter and within + # the gerbil lifespan (skips e.g. a 2013 animal for a 2022 litter). + if not parent_age_plausible(final_c["DateOfBirth"], litter_date): + continue + cands.append(final_c) + if not cands: continue - final_c = gerbil_by_id_final.get(final_id) - if not final_c: - continue - # Parent must be age-plausible: born before the litter and within the - # gerbil lifespan (skips e.g. a 2013 animal for a 2022 litter). - if not parent_age_plausible(final_c["DateOfBirth"], litter_date): - continue - cands.append(final_c) - if not cands: - return None - # Prefer the gender expected for this role, then unknown, then anything. - for pool in ( - [c for c in cands if c["Gender"] == prefer_gender], - [c for c in cands if c["Gender"] == "unknown"], - cands, - ): - if pool: - return pool[0] + # Within a key, a candidate whose FULL name equals the lookup beats one + # that only matched via call-name (resident „Elena“ > „Elena of KK + # Chaos“ — #15). Stable sort keeps prior ordering otherwise. + cands.sort(key=lambda c: 0 if normalize_name(c["Name"]) == k else 1) + # Prefer the gender expected for this role, then unknown, then anything. + for pool in ( + [c for c in cands if c["Gender"] == prefer_gender], + [c for c in cands if c["Gender"] == "unknown"], + cands, + ): + if pool: + return pool[0] return None for l in resolved_litters: diff --git a/tools/import/output/review-report.md b/tools/import/output/review-report.md index b11e321..5ca9cb7 100644 --- a/tools/import/output/review-report.md +++ b/tools/import/output/review-report.md @@ -12,7 +12,7 @@ _Automatisch erzeugt von `tools/import/extract.py` — **noch nichts in die Date - Mehrdeutige / unvollständige Einträge (ohne Name+Datum): **380** - Fotos zugeordnet: **435** - Würfe aus der Wurfchronik: **752** - - Tiere mit Wurf verknüpft: **270** (davon über Geburtsdatum **und** Eltern: 168, nur über Geburtsdatum: 102; mehrdeutig: 17) + - Tiere mit Wurf verknüpft: **270** (davon über Geburtsdatum **und** Eltern: 170, nur über Geburtsdatum: 100; mehrdeutig: 17) - Würfe mit Datenqualitäts-Hinweisen: 113 (+ 138 Zeilen mit abweichendem Spaltenschema) ## Zusammenführungs-Schlüssel diff --git a/tools/import/test_extract.py b/tools/import/test_extract.py index a1a12c8..dd81936 100644 --- a/tools/import/test_extract.py +++ b/tools/import/test_extract.py @@ -417,6 +417,54 @@ if os.path.exists(kazuya_path): else: print("\nWarning: Kazuya stammbaum file not found, skipping photo-shift checks.") +# --- External-origin founders get NO fabricated chart-position parents ------- +# Tickets #5 (Bill von Privat), #13 (Cooky vom Zooladen), #28 (Zadar from … Croatia): +# pet-shop / private / foreign-import animals have genuinely unknown ancestry, so +# _reconstruct_parents must not invent parents for them from neighbouring blocks. +check("is_external_origin: 'von Privat' name", e.is_external_origin("Bill von Privat")) +check("is_external_origin: 'vom Zooladen (OBI)' name", + e.is_external_origin("Cooky vom Zooladen (OBI)")) +check("is_external_origin: foreign 'from …, Croatia'", + e.is_external_origin("Zadar from Zeko i ptica, Croatia")) +check("is_external_origin: clan animal is NOT external", + not e.is_external_origin("Silver von den kleinen Chaoten", "Kleine Chaoten")) +check("is_external_origin: ordinary cattery 'of Black Forest' is NOT external", + not e.is_external_origin("Hagrid Rubeus of Black Forest", "Black Forest")) + +# _reconstruct_parents must skip the external leaf but still parent the clan child. +_ext_animals = [ + {"name": "Kind von den Kleinen Chaoten", "_gen": 0, "_row": 5, "dob": "01.01.2020", + "zucht": "Kleine Chaoten", "breeder": "", "gender": None, "parentRefs": []}, + {"name": "Cooky vom Zooladen (OBI)", "_gen": 1, "_row": 4, "dob": "01.01.2018", + "zucht": "", "breeder": "", "gender": "female", "parentRefs": []}, + {"name": "Papa von den Kleinen Chaoten", "_gen": 1, "_row": 6, "dob": "01.01.2017", + "zucht": "Kleine Chaoten", "breeder": "", "gender": "male", "parentRefs": []}, + {"name": "OmaUnbekannt", "_gen": 2, "_row": 3, "dob": "", "zucht": "", "breeder": "", + "gender": None, "parentRefs": []}, +] +e._reconstruct_parents(_ext_animals) +_cooky = next(a for a in _ext_animals if a["name"].startswith("Cooky")) +check("_reconstruct_parents: external Cooky gets NO parentRefs", + _cooky["parentRefs"] == []) +_kind = next(a for a in _ext_animals if a["name"].startswith("Kind")) +check("_reconstruct_parents: clan child still gets its chart-position parents", + len(_kind["parentRefs"]) >= 1) + +# --- gender override (conflict-decisions) — Mozart misread male, should be female --- +_g_merged = [{"id": "moz", "name": "Mozart of Lennylengo", "dob": "12.03.2017", + "gender": "male", "genotype": e.gt.parse(""), "parentRefs": [], + "conflict": False}] +_g_dec = os.path.join(tempfile.gettempdir(), "conflict-gender-test.json") +import json as _json +_json.dump({"resolutions": [ + {"name": "Mozart of Lennylengo", "dob": "12.03.2017", "gender": "female", + "decision": "box colour misread"} +]}, open(_g_dec, "w", encoding="utf-8")) +e.apply_conflict_decisions(_g_merged, [], _g_dec) +check("gender override: Mozart flipped male -> female", _g_merged[0]["gender"] == "female") +try: os.remove(_g_dec) +except OSError: pass + if failed: print(f"\n{failed} test(s) FAILED") sys.exit(1) diff --git a/tools/import/test_merge_resolve.py b/tools/import/test_merge_resolve.py index 4116fe7..bb89d10 100644 --- a/tools/import/test_merge_resolve.py +++ b/tools/import/test_merge_resolve.py @@ -154,6 +154,24 @@ check("names_overlap: matching father name", m.names_overlap(litter(None, fname= check("names_overlap: nothing in common", not m.names_overlap(litter(None, fname="A"), litter(None, mname="B"))) +# ── canon_name_key: v.d. ↔ von den abbreviation folds to one key (#30) ── +check("canon_name_key: v.d. and von den fold equal", + m.canon_name_key("BlackFire v.d. Kleinen Chaoten") + == m.canon_name_key("BlackFire von den Kleinen Chaoten")) +check("canon_name_key: distinct names stay distinct", + m.canon_name_key("Theodore von den Kleinen Chaoten") + != m.canon_name_key("Tony von den Kleinen Chaoten")) + +# ── is_external_origin: pet-shop / private / foreign founders (#5/#13/#28) ── +check("merge is_external_origin: 'von Privat'", m.is_external_origin("Bill von Privat")) +check("merge is_external_origin: 'vom Zooladen (OBI)'", + m.is_external_origin("Cooky vom Zooladen (OBI)")) +check("merge is_external_origin: foreign Croatia", + m.is_external_origin("Zadar from Zeko i ptica, Croatia")) +check("merge is_external_origin: 'of Black Forest' NOT external", + not m.is_external_origin("Hagrid Rubeus of Black Forest", "Black Forest")) + + # ── parent_age_plausible: born before child, within ~6y lifespan ── check("age: parent 1y before child → plausible", m.parent_age_plausible("16.04.2021", "27.03.2022")) check("age: parent born AFTER child → implausible", not m.parent_age_plausible("2023-01-01", "2022-03-27")) @@ -414,6 +432,68 @@ check("contracts: animal-less record has empty Animals list", _sale3 and _sale3[0]["Animals"] == []) +# ── Integration: assert the resolved_import.json output reflects the ticket fixes ── +# (Only when the pipeline has already been run; tolerant if the file is absent.) +import os as _os, json as _json +_resolved = _os.path.join(_os.path.dirname(__file__), "output", "resolved_import.json") +if _os.path.exists(_resolved): + _d = _json.load(open(_resolved, encoding="utf-8")) + _G = {g["Id"]: g for g in _d["gerbils"]} + _L = {l["Id"]: l for l in _d["litters"]} + + def _find(sub, dob=None): + sub = sub.lower() + for g in _d["gerbils"]: + if sub in g["Name"].lower() and (dob is None or g.get("DateOfBirth") == dob): + return g + return None + + def _parents(g): + l = _L.get(g.get("LitterId")) if g else None + if not l: + return (None, None) + f = _G.get(l.get("FatherId")) + m = _G.get(l.get("MotherId")) + return (f["Name"] if f else None, m["Name"] if m else None) + + # #5/#13/#28: external founders → no parents + for tag, nm in [("#5 Bill", "Bill von Privat"), + ("#13 Cooky", "Cooky vom Zooladen"), + ("#28 Zadar", "Zadar from Zeko")]: + g = _find(nm) + check(f"{tag}: external founder has no litter/parents", + g is not None and not g.get("LitterId")) + + # #18: Hagrid is a SINGLE resolved record (the DOB-less shell merged away) + _hag = [g for g in _d["gerbils"] if g["Name"].lower() == "hagrid rubeus of black forest"] + check("#18 Hagrid: exactly one resolved record", len(_hag) == 1) + if _hag: + # #17/#20: external ancestor is NOT resident; parents Snickers × Milka + check("#17/#20 Hagrid: isResident == False", _hag[0].get("IsResident") is False) + f, mo = _parents(_hag[0]) + check("#18 Hagrid: father Snickers, mother Milka", + (f or "").startswith("Snickers") and (mo or "").startswith("Milka")) + + # #2 Mozart → female; #16 Arya, #23 Yuki, #36 Gold parent corrections + _moz = _find("Mozart of Lennylengo") + check("#2 Mozart: gender female", _moz is not None and _moz.get("Gender") == "female") + + def _check_parents(tag, nm, exp_f, exp_m): + g = _find(nm) + f, mo = _parents(g) + check(f"{tag}: father ~ {exp_f}", (f or "").lower().startswith(exp_f.lower())) + check(f"{tag}: mother ~ {exp_m}", (mo or "").lower().startswith(exp_m.lower())) + + _check_parents("#16 Arya", "Arya Stark von den Kleinen", "Vance", "Sansa Stark") + _check_parents("#23 Yuki", "Yuki von den Kleinen", "Chevrolet Camaro", "Izumi") + _check_parents("#36 Gold", "Gold v.d. Kleinen", "Trogir", "Chelsea") + _check_parents("#35 Zac", "Zac gen. Action", "Vance", "Dorie") + _check_parents("#9 Beatrice", "Beatrice von den kleinen", "Dante", "Malina") + _check_parents("#30 Theodore", "Theodore von den Kleinen", "BlackFire", "Katara") +else: + print("note: output/resolved_import.json not present — skipped integration assertions") + + if check.failed: print(f"\n{check.failed} test(s) FAILED") sys.exit(1)