#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ Erzeugt aus dem RPRO3-Dedup (compare_rpro3.dedup) Rückfrage-Tickets für die Züchterin: mehrdeutige Namen mit >=2 informativen Varianten (echte „gleich/verschieden?"-Entscheidungen). Pro Variante wird die HERKUNFT hinterlegt (RennmausPro-Datensatz-Nr., eigenes Tier vs. externer Ahn, Züchter/Herkunft). Wo ein passendes BESTEHENDES Tier im aktuellen Bestand gefunden wird, kommt ein Hyperlink in die Frage ([Name ansehen](/rennmaeuse/)), den die Tickets-UI klickbar rendert. Das Ticket bekommt zusätzlich GerbilId (für den „Datensatz ansehen"-Link in der Kopfzeile) und ein internes AgentContext (Varianten + rids + gefundene Tier-IDs) für den späteren Importer. Postet NICHTS — schreibt nur die Kandidaten-JSON. Das Posten macht rpro3_post_tickets.py. Aufruf: python rpro3_tickets.py <_rpro3.db> [out.json] """ from __future__ import annotations import sys, json, re, datetime import compare_rpro3 as C LETTERS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" SUFFIX_RE = re.compile(r"\s+(von den |von der |von dem |vom |von |of |gen\.\s|v\.\s|de\s|du\s)", re.IGNORECASE) def informative(v): return bool(v["dob"] or v["farbe"] or v["origin"]) def strip_suffix(name): """Zucht-Suffix abtrennen (z. B. „Blacky von den Kleinen Chaoten" -> „blacky").""" n = name or "" m = SUFFIX_RE.search(n) if m: n = n[:m.start()] return C.norm_name(n) def parse_dob(s): if not s: return None try: return datetime.date.fromisoformat(str(s)[:10]) except ValueError: return None def build_current_index(scratch): g = json.load(open(f"{scratch}\\cur_gerbils.json", encoding="utf-8")) g = g["items"] if isinstance(g, dict) else g idx_full, idx_strip = {}, {} for x in g: rec = {"id": x["id"], "name": x["name"], "dob": parse_dob(x.get("dateOfBirth"))} idx_full.setdefault(C.norm_name(x["name"]), []).append(rec) idx_strip.setdefault(strip_suffix(x["name"]), []).append(rec) return idx_full, idx_strip def match_current(name, dob, idx_full, idx_strip): """Eindeutiges bestehendes Tier zu (Name, DOB) finden — sonst None (keine falschen Links).""" key = C.norm_name(name) cands = idx_full.get(key) or idx_strip.get(strip_suffix(name)) or [] if not cands: return None if dob: near = [c for c in cands if c["dob"] and abs((c["dob"] - dob).days) <= 31] if len(near) == 1: return near[0] if near: return None # mehrdeutig -> nicht verlinken if len(cands) == 1: return cands[0] return None # mehrdeutig -> nicht verlinken def source_label(v): rid = v["rids"][0] if v["rids"] else "?" if v["is_own"]: return f"eigenes Tier (RennmausPro-Nr. {rid})" return f"externer Ahn (RennmausPro-Nr. {rid})" def fmt_variant(letter, v, match): dob = ", ".join(v["dob"]) or "unbekannt" farbe = ", ".join(v["farbe"]) or "unbekannt" orig = ", ".join(v["origin"]) or "unbekannt" cnt = f", {v['n']}× im Import" if v["n"] > 1 else "" link = f" — [Tier ansehen](/rennmaeuse/{match['id']})" if match else "" return (f"• **{letter}**: {source_label(v)} — geboren {dob}, Farbe {farbe}, " f"Herkunft {orig}{cnt}{link}") def build_question(name, rows): lines = [ f"Beim Import aus RennmausPro gibt es mehrere Tiere mit dem Namen „{name}\". " "Manchmal hat RennmausPro dasselbe Tier mehrfach gespeichert, manchmal sind es " "wirklich verschiedene Tiere mit zufällig gleichem Namen.", "", "Welche davon sind **dasselbe Tier**, welche sind **verschiedene**? " "Die Herkunft jeder Variante steht dabei:", "", ] for letter, v, match in rows: lines.append(fmt_variant(letter, v, match)) lines += [ "", "Antworte am einfachsten mit den Buchstaben, z. B.: " "„A und B sind dasselbe Tier, C ist ein anderes\" · „alle dasselbe\" · „alle verschieden\".", ] return "\n".join(lines) def main(): db = sys.argv[1] scratch = sys.argv[2] out = sys.argv[3] if len(sys.argv) > 3 else None R = C.load_rpro3(db) _, ambiguous, _ = C.dedup(R["animals"]) idx_full, idx_strip = build_current_index(scratch) tickets = [] for name, variants in ambiguous: info = [v for v in variants if informative(v)] if len(info) < 2: continue info.sort(key=lambda v: (not v["is_own"], -v["n"])) rows, matches = [], [] for i, v in enumerate(info): dob = parse_dob(v["dob"][0]) if v["dob"] else None m = match_current(name, dob, idx_full, idx_strip) rows.append((LETTERS[i], v, m)) matches.append(m) # Ticket-GerbilId: bevorzugt das gematchte eigene Tier, sonst erster Treffer. primary = next((m for (l, v, m) in rows if v["is_own"] and m), None) or next((m for m in matches if m), None) agent_ctx = { "source": "rpro3-ambiguity", "name": name, "variants": [ {"label": LETTERS[i], "rids": v["rids"], "count": v["n"], "dob": v["dob"], "farbe": v["farbe"], "origin": v["origin"], "is_own": v["is_own"], "matched_gerbil_id": (matches[i]["id"] if matches[i] else None)} for i, v in enumerate(info) ], } tickets.append({ "Message": f"RennmausPro-Import: Name „{name}\" kommt mehrfach vor " f"({len(info)} Varianten) — dasselbe Tier oder verschiedene?", "Context": "rpro3-import", "EntityName": name, "GerbilId": primary["id"] if primary else None, "Question": build_question(name, rows), "AgentContext": json.dumps(agent_ctx, ensure_ascii=False), "_own": any(v["is_own"] for v in info), "_nvars": len(info), "_linked": sum(1 for m in matches if m), }) tickets.sort(key=lambda t: (not t["_own"], -t["_nvars"])) linked = sum(t["_linked"] for t in tickets) print(f"Ticket-Kandidaten: {len(tickets)} (eigenes Tier beteiligt: {sum(1 for t in tickets if t['_own'])})") print(f"Varianten mit Tier-Hyperlink: {linked}") print(f"Tickets mit GerbilId (Kopf-Link): {sum(1 for t in tickets if t['GerbilId'])}") if out: json.dump(tickets, open(out, "w", encoding="utf-8"), ensure_ascii=False, indent=2) print(f"geschrieben: {out}") if __name__ == "__main__": main()