feat: Fehler-melden + Datenherkunft auf Kontakte & Würfe erweitern

- Kontakt-Detailseite: „Fehler melden"- und „Datenherkunft"-Button.
- Wurf-Ansicht: „Datenherkunft"-Button (Feedback war bereits vorhanden).
- Feedback-Entity um loses, nullable ContactId erweitert (kein FK → übersteht
  Ingest-Wipe); Migration AddFeedbackContactId.
- Contact.Provenance + Litter.Provenance (nullable text); Migration
  AddContactLitterProvenance; im Ingest gemappt und in den DTOs zurückgegeben.
- Import: build_entity_provenance() generalisiert; Kontakte (sourceFiles,
  Züchter/Abnehmer-Hinweise) und Würfe (Wurfchronik vs. Diagramm-rekonstruiert,
  Geschwister-Merge) erhalten Herkunftsdaten in resolved_import.json.
- Frontend: ProvenanceDialog generalisiert (EntityProvenance + entityLabel).

Tests erweitert (Ingest-Round-trip Kontakt/Wurf, contact-scoped Feedback
übersteht Wipe). dotnet(212)/vitest(129)/playwright(36)/tsc/eslint grün.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-22 16:02:11 +02:00
parent 9c98c37d2a
commit 438c816820
27 changed files with 3474 additions and 34 deletions

View File

@@ -22,6 +22,30 @@ def generate_guid(key_str):
"""Generate a stable UUID string based on a key."""
return str(uuid.uuid5(uuid.NAMESPACE_DNS, key_str))
def build_entity_provenance(source_files, merged_record_count, notes=None,
from_wurfchronik=None, extra=None):
"""Generic data-provenance JSON builder shared by gerbils, contacts and
litters. Mirrors the GerbilProvenance frontend contract:
{ sourceFiles, mergedRecordCount, fromWurfchronik, notes, ... }
`source_files` is any iterable of filenames; `from_wurfchronik` is auto-
derived from the filenames when left as None. `extra` may carry entity-
specific keys (e.g. parentMethod/parentConfidence for gerbils). Returns a
JSON string (stored on the nullable Provenance text column)."""
files = sorted({f for f in source_files if f})
if from_wurfchronik is None:
from_wurfchronik = any("wurfchronik" in f.lower() for f in files)
prov = {
"sourceFiles": files,
"mergedRecordCount": merged_record_count,
"fromWurfchronik": bool(from_wurfchronik),
"notes": list(notes or []),
}
if extra:
for k, v in extra.items():
if v is not None:
prov[k] = v
return json.dumps(prov, ensure_ascii=False)
def to_valid_guid(val):
if not val:
return None
@@ -1101,6 +1125,7 @@ def main():
norm_name = normalize_name(canon_name)
rc_file = rc.get("_filename")
if norm_name not in contact_by_norm_name:
global_guid = generate_guid(f"contact-{norm_name}")
contact_by_norm_name[norm_name] = {
@@ -1109,7 +1134,10 @@ def main():
"Email": rc.get("Email") or rc.get("email"),
"Phone": rc.get("Phone") or rc.get("phone"),
"Address": rc.get("Address") or rc.get("address"),
"Notes": rc.get("Notes") or rc.get("notes") or rc.get("Note") or rc.get("note")
"Notes": rc.get("Notes") or rc.get("notes") or rc.get("Note") or rc.get("note"),
# Provenance accumulators (consumed below, stripped from helper keys).
"_source_files": set([rc_file]) if rc_file else set(),
"_merged_count": 1,
}
else:
gc = contact_by_norm_name[norm_name]
@@ -1121,6 +1149,9 @@ def main():
gc["Address"] = rc.get("Address") or rc.get("address")
if not gc["Notes"] and (rc.get("Notes") or rc.get("notes") or rc.get("Note") or rc.get("note")):
gc["Notes"] = rc.get("Notes") or rc.get("notes") or rc.get("Note") or rc.get("note")
if rc_file:
gc["_source_files"].add(rc_file)
gc["_merged_count"] += 1
if scoped_id:
contact_id_map[scoped_id] = contact_by_norm_name[norm_name]["Id"]
@@ -1189,7 +1220,13 @@ def main():
"LitterLetter": rl.get("LitterLetter") or rl.get("litterLetter"),
"_father_name": father_name,
"_mother_name": mother_name,
"_filename": filename
"_filename": filename,
# Provenance accumulators (canonical absorbs these during dedup below).
"_source_files": set([filename]) if filename else set(),
"_merged_count": 1,
# Virtual litters are reconstructed from a Stammbaum chart, not the
# Wurfchronik — flagged on the raw record's _filename == "Stammbaum".
"_virtual": filename == "Stammbaum",
}
resolved_litters.append(l_record)
litter_by_scoped_id[new_guid] = l_record
@@ -1236,6 +1273,10 @@ def main():
litter_dedup_canonical[l["Id"]] = canonical["Id"]
if l is not canonical:
litter_id_map[l["Id"]] = canonical["Id"]
canonical["_source_files"] |= l.get("_source_files", set())
canonical["_merged_count"] += l.get("_merged_count", 1)
if not l.get("_virtual"):
canonical["_virtual"] = False
deduped_litters.append(canonical)
if len(sub) > 1:
@@ -1742,17 +1783,13 @@ def main():
if n and n not in notes:
notes.append(n)
prov = {
"sourceFiles": sorted(source_files),
"mergedRecordCount": merged_count,
"fromWurfchronik": from_wurfchronik,
"notes": notes,
}
if parent_method:
prov["parentMethod"] = parent_method
if parent_confidence:
prov["parentConfidence"] = parent_confidence
return json.dumps(prov, ensure_ascii=False)
return build_entity_provenance(
source_files,
merged_count,
notes=notes,
from_wurfchronik=from_wurfchronik,
extra={"parentMethod": parent_method, "parentConfidence": parent_confidence},
)
# Group gerbils by name to perform deduplication
gerbil_groups = {}
@@ -2167,6 +2204,11 @@ def main():
canonical = max(sub, key=lambda l: len(gerbil_by_litter.get(l["Id"], [])))
for l in sub:
litter_remap2[l["Id"]] = canonical["Id"]
if l is not canonical:
canonical["_source_files"] |= l.get("_source_files", set())
canonical["_merged_count"] += l.get("_merged_count", 1)
if not l.get("_virtual"):
canonical["_virtual"] = False
deduped2.append(canonical)
if len(sub) > 1:
siblings = [g["Name"] for l in sub for g in gerbil_by_litter.get(l["Id"], []) if l is not canonical]
@@ -2186,6 +2228,29 @@ def main():
resolved_litters = deduped2
litter_by_scoped_id = {l["Id"]: l for l in resolved_litters}
# Datenherkunft for litters: which source files contributed, whether this is
# a Wurfchronik litter vs a Stammbaum-reconstructed ("virtual") litter, how
# many raw records merged into it, plus human-readable notes. Accumulators
# (_source_files/_merged_count/_virtual) were filled during the two dedup
# passes above; strip them after use.
for l in resolved_litters:
l_source_files = l.pop("_source_files", set())
l_merged_count = l.pop("_merged_count", 1)
is_virtual = l.pop("_virtual", False)
l_from_wurfchronik = any("wurfchronik" in str(f).lower() for f in l_source_files)
l_notes = []
if is_virtual and not l_from_wurfchronik:
l_notes.append("aus Stammbaum-Diagramm rekonstruiert")
elif l_from_wurfchronik:
l_notes.append("aus Wurfchronik")
if l_merged_count > 1:
l_notes.append(f"aus {l_merged_count} Datensätzen zusammengeführt")
l_notes.append("Geschwister-Würfe zusammengeführt")
l["Provenance"] = build_entity_provenance(
l_source_files, l_merged_count, notes=l_notes,
from_wurfchronik=l_from_wurfchronik,
)
# Set IsBreeder and IsReceiver flags on contacts
breeder_ids = {g["OriginContactId"] for g in resolved_gerbils if g.get("OriginContactId")}
receiver_ids = {g["ReceiverContactId"] for g in resolved_gerbils if g.get("ReceiverContactId")}
@@ -2198,6 +2263,20 @@ def main():
c["IsBreeder"] = is_breeder
c["IsReceiver"] = is_receiver
# Datenherkunft: where this (deduplicated) contact came from, plus the
# role we inferred. Accumulator keys (_source_files/_merged_count) were
# filled during the contact dedup above; strip them after use.
c_source_files = c.pop("_source_files", set())
c_merged_count = c.pop("_merged_count", 1)
c_notes = []
if c_merged_count > 1:
c_notes.append(f"aus {c_merged_count} Datensätzen zusammengeführt")
if is_breeder:
c_notes.append("als Züchter erkannt")
if is_receiver:
c_notes.append("als Abnehmer erkannt")
c["Provenance"] = build_entity_provenance(c_source_files, c_merged_count, notes=c_notes)
# Set and map gerbilPhotos
resolved_photos = []
for g in resolved_gerbils: