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:
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user