Files
GerbilManager/tools/import/rpro3_lookup.py
Gulum a333bce1b3 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>
2026-06-26 11:26:50 +02:00

234 lines
8.8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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()