Files
GerbilManager/tools/import/rpro3_lookup.py
Gulum 45b8533f18
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 1m11s
CI / Frontend Tests (Node/Vite) (push) Failing after 4m59s
CI / Docker Build & Push (push) Has been skipped
CI / Deploy auf TrueNAS (Custom App) (push) Has been skipped
feat(deploy): TrueNAS Custom-App + Auto-Deploy, plus aufgelaufene Arbeit
Deployment:
- custom-app.compose.yaml: self-contained Compose fuer TrueNAS "Custom App"
  (absolute Host-Bind-Pfade, postgres:18, pull_policy always, Port 8090)
- scripts/truenas-deploy.sh: Host-Skript create/redeploy via midclt (App
  bleibt unter Apps sichtbar) inkl. Image-Pull + Health-Check
- ci.yml Deploy-Job: laeuft auf ubuntu-latest-Runner, kopiert Deploy-Dateien
  per SSH auf den NAS-Host und triggert truenas-deploy.sh (statt runs-on goldeye)
- compose.yaml/.env.example: postgres:18 (Locale-Match zur Quell-DB), Port 8090
- .gitignore: .agents/, tools/rag/, deploy/truenas/.env (Secrets/Scratch)

Aufgelaufene Feature-Arbeit (verified/Freeze, Migrationen, Import-Triage):
- GerbilOverride/VerifiedGerbil-Endpoints + GerbilSnapshotService + Tests
- EF-Migrationen (ShowInChronicle, Stillborn, BirthOrder, ManualFlag, DSGVO)
- Frontend VerifizierteTierePage + verified-API + e2e-Spec
- diverse Import-/Triage-Skripte und -Tests

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 09:19:11 +02:00

245 lines
9.1 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
class LettersClass:
def __getitem__(self, i):
res = ""
val = i
while val >= 0:
res = chr(65 + (val % 26)) + res
val = (val // 26) - 1
return res
def __contains__(self, item):
return isinstance(item, str) and item.isalpha() and item.isupper()
LETTERS = LettersClass()
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()