feat(import): RPRO3-Dubletten-Entscheidungen der Züchterin als Override

Manuelle "gleich/verschieden?"-Entscheidungen aus den rpro3-import-Tickets
fließen jetzt in den RennmausPro-III-Import ein:

- Rpro3Decisions (Modell + JSON-Loader): same (Force-Merge), different
  (Force-Split), fields (Farbe/DOB/Herkunft/resident/Notiz). Schlüssel = rid.
- Rpro3Dedup.Run(animals, decisions): honoriert die Overrides nach dem
  automatischen Dedup (eine rid zieht ihren Cluster mit).
- Rpro3ImportService: lädt Import/Rpro3/rpro3-decisions.json, wendet
  Feld-Overrides im Plan-Builder an.
- rpro3-decisions.json: 14 bestätigte Entscheidungen (Eiji, Momo, Samuel,
  Female, Kennedy, Fegur, Tuli, Bura, Mister X, Zoey, Max, Akiro, Merlin,
  Snickers).
- tools/import/rpro3_lookup.py: Triage-Helfer (Farbe/Gencode/Eltern/
  Nachzucht+Partner aus _rpro3.db) für die Rückfragen an die Züchterin.
- 4 neue Tests (Force-Merge/Force-Split/Loader); Suite 268 grün.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-26 11:26:50 +02:00
parent bbd305861f
commit a333bce1b3
7 changed files with 699 additions and 4 deletions

View File

@@ -0,0 +1,233 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Triage-Helfer: Beantwortet die Gegenfragen der Züchterin zu RPRO3-Namensdubletten.
Liefert pro Variante (A/B/C … exakt wie im Rückfrage-Ticket) die Daten, die die
Züchterin typischerweise sehen will: Geburtsdatum, Farbe, **Gencode (Fcode)**,
Herkunft, Eltern (Vater/Mutter) sowie Nachzucht inkl. Co-Elternteil (Partner).
Die Variantenbildung/Label-Vergabe spiegelt rpro3_tickets.py + compare_rpro3.dedup
exakt (Union-Find über gleiche Namen; Filter „informativ"; Sortierung
(not is_own, -count) → A,B,C…). Im Gegensatz zum Ticket wird hier über den GESAMTEN
Cluster aggregiert (rpro3_tickets kappt rids auf 4 Nachzucht wäre sonst unvollständig).
Aufruf:
python rpro3_lookup.py <_rpro3.db> "<Name>" [LETTERS]
LETTERS optional, z. B. "B,C" → nur diese Varianten. Default: alle.
python rpro3_lookup.py <_rpro3.db> "<Name>" --json → strukturiert (für Agenten)
"""
from __future__ import annotations
import sys, json
from collections import defaultdict
import compare_rpro3 as C
LETTERS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
def informative(recs):
return any(r["dob"] or r["farbe"] or r["origin"] for r in recs)
def build_clusters(animals):
"""Spiegelt compare_rpro3.dedup: Union-Find je Name, gibt {name: [cluster_recs,...]}."""
def compat(x, y):
return (not x) or (not y) or (x == y)
for a in animals:
a["namek"] = C.norm_name(a["name"])
a["dobk"] = a["dob"]
a["farbek"] = C.norm_farbe(a["farbe"])
a["origink"] = C.norm_origin(a["origin"])
by_name = defaultdict(list)
for a in animals:
if a["namek"] in C.PLACEHOLDER_NAMES:
continue
by_name[a["namek"]].append(a)
parent = {}
def find(x):
while parent[x] != x:
parent[x] = parent[parent[x]]
x = parent[x]
return x
def union(x, y):
parent.setdefault(x, x); parent.setdefault(y, y)
parent[find(x)] = find(y)
def positive(a, b):
agree = 0
if a["dobk"] and b["dobk"] and a["dobk"] == b["dobk"]:
agree += 1
if a["farbek"] and b["farbek"] and a["farbek"] == b["farbek"]:
agree += 1
if a["origink"] and b["origink"] and a["origink"] == b["origink"]:
agree += 1
return agree
def conflict(a, b):
c = 0
if a["dobk"] and b["dobk"] and a["dobk"] != b["dobk"]:
c += 1
if a["farbek"] and b["farbek"] and a["farbek"] != b["farbek"]:
c += 1
if a["origink"] and b["origink"] and a["origink"] != b["origink"]:
c += 1
return c
for name, group in by_name.items():
for a in group:
parent.setdefault(a["rid"], a["rid"])
n = len(group)
for i in range(n):
for j in range(i + 1, n):
a, b = group[i], group[j]
comp = (compat(a["dobk"], b["dobk"]) and compat(a["farbek"], b["farbek"])
and compat(a["origink"], b["origink"]))
if comp and positive(a, b) >= 1 and conflict(a, b) == 0:
union(a["rid"], b["rid"])
# Cluster je Name sammeln
name_clusters = {}
by_rid = {a["rid"]: a for a in animals}
for name, group in by_name.items():
roots = defaultdict(list)
for a in group:
roots[find(a["rid"])].append(a)
name_clusters[name] = list(roots.values())
return name_clusters
def variants_for(animals, display_name):
"""Liefert [(letter, recs)] für einen Namen Reihenfolge wie im Ticket."""
namek = C.norm_name(display_name)
clusters = build_clusters(animals).get(namek, [])
info = [recs for recs in clusters if informative(recs)]
# Sortierung exakt wie rpro3_tickets.py: eigene Tiere zuerst, dann größere Cluster
info.sort(key=lambda recs: (not any(r["src"] == "stamm" for r in recs), -len(recs)))
return [(LETTERS[i], recs) for i, recs in enumerate(info)]
def aggregate(recs, animals):
"""Aggregiert Daten + Nachzucht über alle recs eines Clusters."""
names = {r["name"] for r in recs}
dob = sorted({C.iso(r["dob"]) for r in recs if r["dob"]})
farbe = sorted({r["farbe"] for r in recs if r["farbe"]})
fcode = sorted({r["fcode"] for r in recs if r["fcode"]})
origin = sorted({r["origin"] for r in recs if r["origin"]})
fathers = sorted({r["father"] for r in recs if r["father"]})
mothers = sorted({r["mother"] for r in recs if r["mother"]})
is_own = any(r["src"] == "stamm" for r in recs)
rids = [r["rid"] for r in recs]
# Nachzucht: alle Tiere, deren Eltern-rid auf eine rid dieses Clusters zeigt
rid_set = set(rids)
kids = []
seen = set()
for a in animals:
if str(a.get("mid_raw")) in rid_set or str(a.get("pid_raw")) in rid_set:
co = a["father"] if a["mother"] in names else a["mother"]
key = (C.norm_name(a["name"]), C.iso(a["dob"]) if a["dob"] else "")
if key in seen:
continue
seen.add(key)
kids.append({"name": a["name"], "dob": C.iso(a["dob"]) if a["dob"] else None,
"farbe": a["farbe"], "fcode": a["fcode"], "partner": co})
return {"names": sorted(names), "rids": rids, "count": len(recs),
"is_own": is_own, "dob": dob, "farbe": farbe, "fcode": fcode,
"origin": origin, "fathers": fathers, "mothers": mothers, "kids": kids}
def fmt_block(letter, agg):
src = "eigenes Tier" if agg["is_own"] else "externer Ahn"
L = []
L.append(f"**{letter}** ({src}, {agg['count']}× in RennmausPro):")
L.append(f"• Geburtsdatum: {', '.join(agg['dob']) or 'unbekannt'}")
L.append(f"• Farbe: {', '.join(agg['farbe']) or 'unbekannt'}")
L.append(f"• Gencode: {', '.join(agg['fcode']) or 'unbekannt'}")
L.append(f"• Herkunft: {', '.join(agg['origin']) or 'unbekannt'}")
vat = ', '.join(agg['fathers']) or 'unbekannt'
mut = ', '.join(agg['mothers']) or 'unbekannt'
L.append(f"• Eltern: Vater {vat} · Mutter {mut}")
if agg["kids"]:
L.append("• Nachzucht:")
for k in agg["kids"]:
d = k["dob"] or "?"
f = k["farbe"] or "?"
gc = f" [{k['fcode']}]" if k["fcode"] else ""
p = f" — Partner: {k['partner']}" if k["partner"] else ""
L.append(f" {k['name']} (geb. {d}, {f}{gc}){p}")
else:
L.append("• Nachzucht: keine in RennmausPro hinterlegt")
return "\n".join(L)
def variants_by_rids(animals, rid_groups):
"""rid_groups: Liste von rid-Listen (eine je Variante/Buchstabe). Aggregiert pro Gruppe
über den GANZEN Auto-Cluster (eine rid zieht ihren Cluster mit). Für Platzhalter-Namen
(„...") und Freitext-Tickets, wo der Name nicht greift, aber rids bekannt sind."""
from collections import defaultdict
# Auto-Cluster wie build_clusters, aber global über rids indizieren
clusters = build_clusters(animals)
rid_to_recs = {}
for recs_list in clusters.values():
for recs in recs_list:
for r in recs:
rid_to_recs[r["rid"]] = recs
out = []
for i, grp in enumerate(rid_groups):
merged, seen = [], set()
for rid in grp:
for r in rid_to_recs.get(rid, []):
if r["rid"] not in seen:
seen.add(r["rid"]); merged.append(r)
if merged:
out.append((LETTERS[i], merged))
return out
def main():
db = sys.argv[1]
name = sys.argv[2]
# --rids "u84,u83;u1" → Variante A=u84,u83 Variante B=u1 (per Semikolon getrennt)
if name == "--rids":
rid_spec = sys.argv[3]
R = C.load_rpro3(db)
groups = [[x.strip() for x in g.split(",") if x.strip()] for g in rid_spec.split(";") if g.strip()]
vs = variants_by_rids(R["animals"], groups)
print("=== (per rids) ===")
for letter, recs in vs:
print(fmt_block(letter, aggregate(recs, R["animals"])))
print()
return
as_json = "--json" in sys.argv[3:]
letters = None
for a in sys.argv[3:]:
if a != "--json":
letters = {x.strip().upper() for x in a.split(",") if x.strip()}
R = C.load_rpro3(db)
animals = R["animals"]
vs = variants_for(animals, name)
if not vs:
print(f"(keine Varianten für „{name}“ gefunden)")
return
out = []
for letter, recs in vs:
if letters and letter not in letters:
continue
agg = aggregate(recs, animals)
out.append((letter, agg))
if as_json:
print(json.dumps({"name": name, "variants": {l: a for l, a in out}},
ensure_ascii=False, indent=1))
else:
print(f"=== {name} ===")
for letter, agg in out:
print(fmt_block(letter, agg))
print()
if __name__ == "__main__":
main()