FEAT-8b: bake Julian's authoritative Wurfchronik semantics into the extractor

- survivedToGoHome: unlabeled Tabelle1 col E detected positionally; value-
  adaptive row parsing recovers it from schema-shifted Tabelle2 rows too
  (118 recovered, 79 confirmed by the E=F-TG-s identity)
- breakdown G -> males/females/stillborn/diedLater ('s' = died before Abgabe)
- validation: E = F - TG - s; 113 mismatches as German review-report warnings
- (name, Zucht) canonicalisation: [brackets] == of/von suffix ([ZdkC] ==
  von den Kleinen Chaoten); Zucht = dedup discriminator (0 splits in data)
- animal->litter matching via DOB+(Vater,Mutter): 95 high-confidence,
  40 date-only, 9 ambiguous; litterRef in animals.json
- regenerated report: 889 raw -> 574 unique (279 dated), 32 conflicts

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-06 00:56:14 +02:00
parent a9778ad1d2
commit 7ea2f97e72
3 changed files with 475 additions and 43 deletions

View File

@@ -83,6 +83,53 @@ def clean_name(raw):
return n
# --- (name, Zucht) canonicalisation -------------------------------------------
# Julian (FEAT-8 ruling): the [brackets] in Wurfchronik names ARE the Zucht
# (breeding line) and are equivalent to the Stammbaum "of/von <line>" suffix.
# Both fold into one canonical (call-name, zucht) pair; the Zucht acts as a
# dedup DISCRIMINATOR (same name+DOB but different Zucht = different animal).
_BRACKET_ZUCHT = re.compile(r"^(.*?)\s*\[([^\]]+)\]\s*$")
_SUFFIX_ZUCHT = re.compile(
r"^(.+?)\s+(?:of|von\s+den|von\s+der|v\.\s?d\.|von)\s+(.+)$", re.IGNORECASE)
# canonical values are post-norm_zucht (word-final 'n' folded: "kleinechaote")
ZUCHT_ALIASES = {
"zdkc": "kleinechaote", # "Zucht der kleinen Chaoten" (home cattery shorthand)
}
def split_name_zucht(raw):
"""'Luna [ZdkC]' -> ('Luna','ZdkC'); 'Pikachu of Black Forest' ->
('Pikachu','Black Forest'); plain names -> (name, '')."""
n = (raw or "").strip()
m = _BRACKET_ZUCHT.match(n)
if m:
return m.group(1).strip(), m.group(2).strip()
m = _SUFFIX_ZUCHT.match(n)
if m:
return m.group(1).strip(), m.group(2).strip()
return n, ""
def norm_zucht(z):
"""Canonical Zucht key: drops Zucht/von/der fillers, folds declension
('kleinen Chaoten' == 'kleine Chaoten'), resolves known shorthands."""
if not z:
return ""
n = z.lower()
n = re.sub(r"\bv\.\s?d\.\b", " ", n)
n = re.sub(r"\b(zucht|privatzucht|der|die|den|des|dem|von|of)\b", " ", n)
n = re.sub(r"[^a-z0-9äöüß ]", " ", n)
words = [w[:-1] if len(w) > 4 and w.endswith("n") else w for w in n.split()]
key = "".join(words)
return ZUCHT_ALIASES.get(key, key)
def canon_pair(raw):
"""Full raw name -> (normalised call-name, canonical zucht)."""
name, zucht = split_name_zucht(raw)
return norm_name(name), norm_zucht(zucht)
# --------------------------------------------------- Stammbaum extraction ----
def parse_detail(text):
"""From a string that contains *DOB and/or genotype, pull (dob, death, geno_str).
@@ -167,6 +214,10 @@ def extract_stammbaum(path):
g = parse_detail(t) if compact else (dob, death, geno)
genodict = gt.parse(geno)
# Zucht: from the name's [tag]/of-von suffix, else from the breeder line
_, zraw = split_name_zucht(name)
if not zraw and breeder:
zraw = breeder
animals.append({
"id": None, # assigned in dedup
"name": name,
@@ -177,6 +228,7 @@ def extract_stammbaum(path):
"farbschlag": farbschlag,
"genotype": genodict,
"breeder": breeder,
"zucht": zraw,
"parentRefs": [],
"photos": [],
"sourceFiles": [fname],
@@ -184,6 +236,7 @@ def extract_stammbaum(path):
"_col": c,
"_row": r,
"_file": fname,
"_zucht": norm_zucht(zraw),
})
# name-pair cells "X & Y" (deepest generation, names only)
@@ -194,12 +247,14 @@ def extract_stammbaum(path):
for part in t.split(" & "):
part = clean_name(part)
if part:
_, zraw = split_name_zucht(part)
animals.append({
"id": None, "name": part, "nameVariants": [],
"dob": "", "death": "", "gender": None, "farbschlag": "",
"genotype": gt.parse(""), "breeder": "", "parentRefs": [],
"photos": [], "sourceFiles": [fname],
"genotype": gt.parse(""), "breeder": "", "zucht": zraw,
"parentRefs": [], "photos": [], "sourceFiles": [fname],
"_gen": gen_of(c), "_col": c, "_row": r, "_file": fname,
"_zucht": norm_zucht(zraw),
})
_reconstruct_parents(animals)
@@ -297,7 +352,19 @@ def extract_wurfchronik(path):
col_ws = find("ws", "wurfstärke", "wurfstaerke")
col_breakdown = find("männchen", "maennchen", "weibchen")
col_zn = find("zuchtnummer")
col_note = find("bemerkung")
col_note = find("bemerkung", "anmerkung")
# Julian (authoritative): Tabelle1 has an UNLABELED numeric column between
# Vater and Wurfstärke = "Überlebende bis zum Abgabedatum" (survivors to
# go-home). Tabelle2 dropped it. Header-based find() cannot see it, so
# detect it positionally.
col_survived = None
if col_sire and col_ws and col_ws - col_sire > 1:
mapped = {col_id, col_date, col_dam, col_sire, col_ws,
col_breakdown, col_zn, col_note}
for c in range(col_sire + 1, col_ws):
if c not in mapped:
col_survived = c
break
sheet_name = os.path.basename(sp)
for r in sorted(rows):
if r == hdr_row: # skip the header row itself
@@ -310,25 +377,86 @@ def extract_wurfchronik(path):
if "jahr" in " ".join(row.values()).lower() and not DOB.search(txt_b):
continue
dob = DOB.search(txt_b)
bd = row.get(col_breakdown, "") if col_breakdown else ""
m = re.findall(r"\d+", bd)
breakdown = {}
if len(m) >= 1:
keys = ["maennchen", "weibchen", "totgeburt", "s"]
for k, val in zip(keys, m):
breakdown[k] = int(val)
lid = row.get(col_id, "")
datestr = dob.group(1) if dob else ""
dam_raw = row.get(col_dam, "") if col_dam else ""
sire_raw = row.get(col_sire, "") if col_sire else ""
_, dam_zucht = split_name_zucht(dam_raw)
# '&' = multiple sires possible (litter with uncertain/dual sire)
sire_names = [s.strip() for s in sire_raw.split("&") if s.strip()]
_, sire_zucht = split_name_zucht(sire_names[0] if sire_names else "")
# Numeric layout is read VALUE-ADAPTIVELY per row: a handful of
# Tabelle2 rows insert an extra numeric column (Überlebende, T1
# order E,F) before WS and shift the breakdown right of its header.
warnings = []
scan_end = (col_note or (col_sire or 4) + 6) + 1
bd_col = None
for c in range((col_sire or 4) + 1, scan_end):
if re.fullmatch(r"\d+(?:[,;]\d+){1,3}", row.get(c, "")):
bd_col = c
break
if bd_col and col_breakdown and bd_col != col_breakdown:
warnings.append(
f"Spaltenschema-Abweichung: Geschlechter-Aufschlüsselung in "
f"Spalte {xu.num_to_col(bd_col)} statt "
f"{xu.num_to_col(col_breakdown)} gefunden")
# single numeric cells between Vater and the breakdown: E (Überlebende)
# and/or F (Wurfstärke), in T1 order
singles = []
for c in range((col_sire or 4) + 1, bd_col or scan_end):
v = row.get(c, "")
if re.fullmatch(r"\d+", v):
singles.append(int(v))
if len(singles) >= 2:
survived, total_born = singles[0], singles[1]
if not col_survived:
warnings.append(
"Spaltenschema-Abweichung: zusätzliche Zahlenspalte als "
"„Überlebende“ interpretiert (bitte prüfen)")
elif len(singles) == 1:
total_born = singles[0]
survived = None
else:
total_born = _to_int(row.get(col_ws)) if col_ws else None
survived = _to_int(row.get(col_survived)) if col_survived else None
# breakdown "2,0,2,0" = Männchen, Weibchen, Totgeburt, später
# verstorben ('s' = died after birth, before Abgabe — Julian).
bd = row.get(bd_col, "") if bd_col else ""
m = re.findall(r"\d+", bd)
males = females = stillborn = died_later = None
if m:
vals = [int(x) for x in m] + [None] * 4
males, females, stillborn, died_later = vals[:4]
# Validation (Julian): Überlebende E should equal F TG s.
# Mismatch = data-quality signal for the review report, NOT a blocker.
if survived is not None and total_born is not None:
expected = total_born - (stillborn or 0) - (died_later or 0)
if survived != expected:
warnings.append(
f"Überlebende ({survived}) ≠ Wurfstärke ({total_born}) "
f" Totgeburten ({stillborn or 0}) später verstorben "
f"({died_later or 0}) = {expected}")
litters.append({
"id": f"{sheet_name.replace('.xml','')}-{lid}-{norm_dob(datestr)}",
"litterId": lid,
"date": datestr,
"damName": row.get(col_dam, "") if col_dam else "",
"sireName": row.get(col_sire, "") if col_sire else "",
"wurfstaerke": _to_int(row.get(col_ws)) if col_ws else None,
"sexBreakdown": breakdown,
"damName": dam_raw,
"damZucht": dam_zucht,
"sireName": sire_raw,
"sireNames": sire_names,
"sireZucht": sire_zucht,
"totalBorn": total_born,
"survivedToGoHome": survived,
"males": males,
"females": females,
"stillborn": stillborn,
"diedLater": died_later,
"breakdownRaw": bd,
"zuchtnummer": row.get(col_zn, "") if col_zn else "",
"note": row.get(col_note, "") if col_note else "",
"warnings": warnings,
"sourceFile": fname,
"sheet": sheet_name,
"row": r,
@@ -345,20 +473,48 @@ def _to_int(s):
# ------------------------------------------------------------- stage 2: dedup
def dedup(animals):
"""Merge by normalise(name)+DOB. Returns (merged, conflicts, orphans)."""
"""Merge by normalise(call-name)+DOB, with the canonical Zucht as
DISCRIMINATOR (Julian: same name+DOB but different Zucht = different
animal). Returns (merged, conflicts, orphans, zucht_splits)."""
groups = {}
orphans = []
for a in animals:
key = (norm_name(a["name"]), norm_dob(a["dob"]))
call, _ = split_name_zucht(a["name"])
key = (norm_name(call), norm_dob(a["dob"]))
if not key[0] or not key[1]:
orphans.append(a)
# orphans still get a stable id but are not merged
key = ("__orphan__", id(a))
groups.setdefault(key, []).append(a)
# split groups whose members carry DIFFERENT (non-empty) canonical Zuchten;
# members without a Zucht merge into the group only if it is unambiguous.
final_groups = []
zucht_splits = []
for key, grp in groups.items():
by_zucht = {}
for a in grp:
by_zucht.setdefault(a.get("_zucht", ""), []).append(a)
nonempty = sorted(z for z in by_zucht if z)
if len(nonempty) <= 1:
final_groups.append(grp)
continue
# genuine split: same call-name+DOB, different Zucht
for z in nonempty:
final_groups.append(by_zucht[z])
if "" in by_zucht:
# Zucht-less mentions cannot be attributed -> own (flagged) entry
final_groups.append(by_zucht[""])
zucht_splits.append({
"name": grp[0]["name"],
"dob": norm_dob(grp[0]["dob"]),
"zuechte": sorted(set(a["zucht"] for a in grp if a.get("zucht"))),
"files": sorted(set(f for a in grp for f in a["sourceFiles"])),
})
merged = []
conflicts = []
for key, grp in groups.items():
for grp in final_groups:
base = dict(grp[0])
variants = set([base["name"]])
files = set(base["sourceFiles"])
@@ -392,6 +548,8 @@ def dedup(animals):
"farbschlagVariants": sorted(farb),
"genotype": best,
"breeder": next((a["breeder"] for a in grp if a["breeder"]), ""),
"zucht": next((a["zucht"] for a in grp if a.get("zucht")), ""),
"zuchtCanon": next((a["_zucht"] for a in grp if a.get("_zucht")), ""),
"parentRefs": _dedup_parentrefs(parent_refs),
"photos": sorted(set(photos)),
"sourceFiles": sorted(files),
@@ -406,7 +564,64 @@ def dedup(animals):
"deaths": sorted(deaths), "files": sorted(files),
})
merged.sort(key=lambda a: (a["dob"], a["name"]))
return merged, conflicts, orphans
# ids must stay unique even when a Zucht split duplicates name+DOB slugs
seen_ids = {}
for a in merged:
if a["id"] in seen_ids:
seen_ids[a["id"]] += 1
a["id"] = f"{a['id']}-{seen_ids[a['id']]}"
else:
seen_ids[a["id"]] = 1
return merged, conflicts, orphans, zucht_splits
# ------------------------------------------- animal <-> litter matching ------
def match_litters(merged, litters):
"""Attach each animal to its Wurfchronik litter (Pam-validated build order:
litters are canonical, animals match onto them via DOB + (Vater, Mutter)).
Sets a['litterRef']; returns match statistics for the report."""
by_date = {}
for l in litters:
d = norm_dob(l["date"])
if d:
by_date.setdefault(d, []).append(l)
stats = {"parents": 0, "dateOnly": 0, "ambiguous": 0}
for a in merged:
if not a["dob"]:
continue
cands = by_date.get(a["dob"])
if not cands:
continue
a_parents = set()
for ref in a["parentRefs"]:
cn, _ = canon_pair(ref["name"])
if cn:
a_parents.add(cn)
def score(l):
s = 0
for nm in [l["damName"]] + l.get("sireNames", []):
cn, _ = canon_pair(nm)
if cn and cn in a_parents:
s += 1
return s
scored = sorted(((score(l), l["id"]) for l in cands), reverse=True)
best_score, best_id = scored[0]
if best_score > 0 and (len(scored) == 1 or scored[1][0] < best_score):
a["litterRef"] = {"litterId": best_id,
"method": "geburtsdatum+eltern", "confidence": "hoch"}
stats["parents"] += 1
elif len(cands) == 1:
a["litterRef"] = {"litterId": cands[0]["id"],
"method": "nur-geburtsdatum", "confidence": "niedrig"}
stats["dateOnly"] += 1
else:
a["litterRef"] = {"litterId": None, "method": "mehrdeutig",
"candidates": [l["id"] for l in cands]}
stats["ambiguous"] += 1
return stats
def _dedup_parentrefs(refs):
@@ -419,12 +634,13 @@ def _dedup_parentrefs(refs):
# ------------------------------------------------------------------ reporting
def write_report(merged, conflicts, orphans, raw_count, litters, photo_count):
keyset = set((a["dob"], norm_name(a["name"])) for a in merged)
def write_report(merged, conflicts, orphans, raw_count, litters, photo_count,
zucht_splits, match_stats):
multi = [a for a in merged if a["mentions"] > 1]
with_dob = [a for a in merged if a["dob"]]
lit_dates = set(norm_dob(l["date"]) for l in litters if l["date"])
joinable = [a for a in merged if a["dob"] and norm_dob(a["dob"]) in lit_dates]
val_warn = [l for l in litters if any("" in w for w in l["warnings"])]
schema_warn = [l for l in litters if any("Abweichung" in w for w in l["warnings"])]
matched = match_stats["parents"] + match_stats["dateOnly"]
L = []
L.append("# FEAT-8b — Import-Vorschau & Prüfbericht (Stammbäume + Wurfchronik)\n")
@@ -439,12 +655,32 @@ def write_report(merged, conflicts, orphans, raw_count, litters, photo_count):
L.append(f"- Mehrdeutige / unvollständige Einträge (ohne Name+Datum): **{len(orphans)}**")
L.append(f"- Fotos zugeordnet: **{photo_count}**")
L.append(f"- Würfe aus der Wurfchronik: **{len(litters)}**")
L.append(f" - Tiere, deren Geburtsdatum zu einem Wurf passt (verknüpfbar): {len(joinable)}\n")
L.append(f" - Tiere mit Wurf verknüpft: **{matched}** "
f"(davon über Geburtsdatum **und** Eltern: {match_stats['parents']}, "
f"nur über Geburtsdatum: {match_stats['dateOnly']}; "
f"mehrdeutig: {match_stats['ambiguous']})")
L.append(f" - Würfe mit Datenqualitäts-Hinweisen: {len(val_warn)} "
f"(+ {len(schema_warn)} Zeilen mit abweichendem Spaltenschema)\n")
L.append("## Zusammenführungs-Schlüssel\n")
L.append("Tiere wurden zusammengeführt über **normalisierter Name + Geburtsdatum**. "
"Namensvarianten (z. B. `v.d.` ↔ `von den`, `gen.`-Spitznamen, Zuchtsuffixe) "
"werden als `nameVariants` erhalten.\n")
L.append("Tiere wurden zusammengeführt über **normalisierter Rufname + Geburtsdatum**, "
"mit der **Zucht als Unterscheidungsmerkmal** (Julians Regel: die `[Klammern]` "
"in der Wurfchronik und das `of/von <Linie>`-Suffix der Stammbäume bezeichnen "
"beide die Zucht und werden zusammengeführt — z. B. `[ZdkC]` ≙ "
"`von den Kleinen Chaoten`). Namensvarianten (z. B. `v.d.` ↔ `von den`, "
"`gen.`-Spitznamen) werden als `nameVariants` erhalten.\n")
if zucht_splits:
L.append("### Gleicher Name + Geburtsdatum, aber unterschiedliche Zucht "
"(NICHT zusammengeführt — bitte prüfen)\n")
L.append("| Tier | Geburtsdatum | Zuchten | Dateien |")
L.append("|---|---|---|---|")
for s in zucht_splits[:50]:
L.append("| {} | {} | {} | {} |".format(
split_name_zucht(s["name"])[0], s["dob"],
" // ".join(s["zuechte"]),
", ".join(os.path.splitext(f)[0] for f in s["files"])))
L.append("")
L.append("## ⚠️ Konflikte (bitte prüfen)\n")
if conflicts:
@@ -506,6 +742,37 @@ def write_report(merged, conflicts, orphans, raw_count, litters, photo_count):
for t, n in tok.most_common(25):
L.append(f"| `{t}` | {n} | {hint.get(t, '?')} |")
# Wurfchronik data quality (Julian: E sollte = F TG s sein)
L.append("\n## Wurfchronik — Datenqualitäts-Hinweise\n")
L.append("Julians Spaltenregel: **Überlebende bis Abgabe (E) = Wurfstärke (F) "
"Totgeburten (TG) später verstorben (s)**. Bei diesen Würfen geht die "
"Rechnung nicht auf — kein Import-Hindernis, aber ein Hinweis auf "
"Tippfehler oder fehlende Einträge:\n")
if val_warn:
L.append("| Wurf | Datum | Mutter × Vater | Hinweis |")
L.append("|---|---|---|---|")
for l in val_warn[:120]:
L.append("| {} | {} | {} × {} | {} |".format(
l["litterId"], l["date"], l["damName"], l["sireName"],
"; ".join(w for w in l["warnings"] if "" in w)))
if len(val_warn) > 120:
L.append(f"\n… und {len(val_warn) - 120} weitere (siehe `litters.json`).")
else:
L.append("_Keine — alle Würfe sind rechnerisch konsistent._")
L.append("\n### Zeilen mit abweichendem Spaltenschema (automatisch interpretiert)\n")
L.append(f"{len(schema_warn)} Zeilen (überwiegend Tabelle2 ab 2014) tragen eine "
"zusätzliche Zahlenspalte vor der Wurfstärke bzw. eine verschobene "
"Geschlechter-Aufschlüsselung. Sie wurden nach dem Muster von Tabelle1 "
"gelesen (**Überlebende, Wurfstärke, Aufschlüsselung**) — bei "
f"{sum(1 for l in schema_warn if not any('' in w for w in l['warnings']))} "
"davon geht die Rechnung E = F TG s damit exakt auf, was die Lesart "
"bestätigt. Alle betroffenen Zeilen sind in `litters.json` mit "
"`warnings` markiert. Beispiele:\n")
for l in schema_warn[:8]:
L.append(f"- Wurf {l['litterId']} ({l['date']}): E={l['survivedToGoHome']}, "
f"F={l['totalBorn']}, Aufschlüsselung `{l['breakdownRaw']}`")
L.append("\n## Hinweise für den Import (Stufe 3, später)\n")
L.append("- **Wurfchronik = Quelle der Würfe** (Datum, Wurfstärke, Eltern, Zuchtnummer); "
"**Stammbäume = Abstammung + Genotyp + Fotos**. Verknüpfung über Geburtsdatum + Elternnamen.")
@@ -550,7 +817,8 @@ def main():
litters = extract_wurfchronik(args.wurfchronik)
print(f"Wurfchronik: {len(litters)} Würfe")
merged, conflicts, orphans = dedup(raw_animals)
merged, conflicts, orphans, zucht_splits = dedup(raw_animals)
match_stats = match_litters(merged, litters)
photo_count = sum(len(a["photos"]) for a in merged)
# strip private (_) fields from the JSON output
@@ -562,10 +830,15 @@ def main():
with open(os.path.join(OUT, "litters.json"), "w", encoding="utf-8") as f:
json.dump(litters, f, ensure_ascii=False, indent=2)
write_report(merged, conflicts, orphans, len(raw_animals), litters, photo_count)
write_report(merged, conflicts, orphans, len(raw_animals), litters, photo_count,
zucht_splits, match_stats)
print(f"\nRoh: {len(raw_animals)} → eindeutig: {len(merged)} "
f"| Konflikte: {len(conflicts)} | Orphans: {len(orphans)} | Fotos: {photo_count}")
f"| Konflikte: {len(conflicts)} | Zucht-Splits: {len(zucht_splits)} "
f"| Orphans: {len(orphans)} | Fotos: {photo_count}")
print(f"Wurf-Verknüpfung: {match_stats['parents']} (Datum+Eltern), "
f"{match_stats['dateOnly']} (nur Datum), {match_stats['ambiguous']} mehrdeutig "
f"| Wurf-Warnungen: {sum(1 for l in litters if l['warnings'])}")
print(f"Ausgabe in {OUT}")