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:
162
tools/import/rpro3_tickets.py
Normal file
162
tools/import/rpro3_tickets.py
Normal 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()
|
||||
Reference in New Issue
Block a user