feat(import): RennmausPro-III-Analyse — Abgleich, Dedup, Feature-Gap, Ticket-Generierung

- compare_rpro3.py: RPRO3-SQLite ↔ aktueller Stand (Dedup Name+DOB+Farbe+Herkunft)
- rpro3-vergleich.md / rpro3-feature-gap.md: erzeugte Reports
- rpro3_tickets.py / rpro3_post_tickets.py: 413 Rückfrage-Tickets mit Herkunft + Links

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-22 22:38:24 +02:00
parent c15dd78bc7
commit 4e30dc7fe3
5 changed files with 1239 additions and 0 deletions

View File

@@ -0,0 +1,162 @@
#!/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/<id>)), 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> <scratch-dir mit cur_gerbils.json> [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()