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>
234 lines
8.8 KiB
Python
234 lines
8.8 KiB
Python
#!/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()
|