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