feat(rennmausakte): Datenherkunft/Nachverfolgung pro Rennmaus

Neuer „Datenherkunft"-Button in der Rennmausakte öffnet einen Dialog, der zeigt,
aus welchen Quellen der Eintrag erzeugt wurde: Quelldateien (Stammbäume +
Wurfchronik), Anzahl zusammengeführter Datensätze, Eltern-Herleitung
(Methode/Konfidenz) und Hinweise (z. B. „per manueller Entscheidung zugeordnet",
„Konflikt gelöst", „aus N Datensätzen zusammengeführt").

Import: build_provenance() in merge_and_resolve.py sammelt die Herkunft über alle
deduplizierten Datensätze und schreibt sie als Provenance-JSON je Tier in
resolved_import.json. Backend: Gerbil.Provenance (nullable text) + Migration
AddGerbilProvenance, gemappt im Ingest und im GerbilDto zurückgegeben. Frontend:
ProvenanceDialog + Typen + Strings.

Tests: Ingest-Round-trip (vorhanden/abwesend), e2e provenance.spec.ts.
dotnet(212)/vitest(129)/playwright/tsc/eslint grün.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-22 15:45:15 +02:00
parent b9e5b031c4
commit 9c98c37d2a
16 changed files with 2126 additions and 6 deletions

View File

@@ -1520,6 +1520,8 @@ def main():
"IsResident": a_id in stammbaum_resident_ids,
"parentRefs": a.get("parentRefs", []),
"_photos": a.get("photos", []),
"_conflict": bool(a.get("conflict")),
"_resolved_by_decision": bool(a.get("resolvedByDecision")),
"_old_scoped_litter_id": scoped_litter_id,
"_eff_dob": dob_val or "2010-01-01",
"_birth_date": dob_val,
@@ -1678,6 +1680,80 @@ def main():
return True
def _record_source_files(g):
"""All distinct source files a single raw record drew from.
ImportSource is either a comma-joined Stammbaum file list, a single
Wurfchronik filename, or the per-litter filename; _filename is the
primary file. We union both so nothing is lost."""
files = set()
imp = g.get("ImportSource")
if imp:
for part in str(imp).split(","):
part = part.strip()
if part:
files.add(part)
fn = g.get("_filename")
if fn:
files.add(fn)
return files
def build_provenance(records, best_g, extra_notes=None):
"""Aggregate data-provenance across every raw record merged into one
resolved gerbil. Returns a JSON string (stored on the Gerbil entity as a
nullable text column) so the Rennmausakte can show where the entry came
from. `records` is the list of raw records that became this gerbil;
`best_g` is the chosen primary record."""
source_files = set()
from_wurfchronik = False
any_conflict = False
any_decision = False
for r in records:
source_files |= _record_source_files(r)
for f in _record_source_files(r):
if "wurfchronik" in f.lower():
from_wurfchronik = True
if r.get("_conflict"):
any_conflict = True
if r.get("_resolved_by_decision"):
any_decision = True
notes = []
merged_count = len(records)
if merged_count > 1:
notes.append(f"aus {merged_count} Datensätzen zusammengeführt")
if any_decision:
notes.append("per manueller Entscheidung zugeordnet")
if any_conflict:
notes.append("Konflikt per Entscheidung gelöst")
# Parent derivation: surface the strongest parentRef method/confidence
# the primary record carries (chart-position etc.).
parent_method = None
parent_confidence = None
for ref in best_g.get("parentRefs", []) or []:
if ref.get("method") and not parent_method:
parent_method = ref.get("method")
if ref.get("confidence") and not parent_confidence:
parent_confidence = ref.get("confidence")
if extra_notes:
for n in extra_notes:
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)
# Group gerbils by name to perform deduplication
gerbil_groups = {}
for g in all_processed_gerbils:
@@ -1701,6 +1777,7 @@ def main():
if is_placeholder:
# Placeholders: do NOT merge, keep all separate
for g in group:
g["Provenance"] = build_provenance([g], g)
resolved_gerbils.append(g)
gerbil_id_map[g["Id"]] = g["Id"]
continue
@@ -1721,6 +1798,7 @@ def main():
for sub in sub_groups:
if len(sub) == 1:
g = sub[0]
g["Provenance"] = build_provenance([g], g)
resolved_gerbils.append(g)
gerbil_id_map[g["Id"]] = g["Id"]
continue
@@ -1806,6 +1884,7 @@ def main():
merged_notes.append(g["Notes"])
# Reconcile fields based on number of source files supporting them
conflict_notes = []
for field in ["DateOfBirth", "DateOfDeath", "Gender", "Genotype", "ColorVarietyId"]:
votes = {}
for g in sub:
@@ -1816,6 +1895,19 @@ def main():
votes[val] = votes.get(val, 0) + sources_count
if votes:
best_val = max(votes, key=votes.get)
# If the records disagreed on a field, the merge had to pick a
# winner — record that as a provenance note.
if len(votes) > 1:
FIELD_LABEL = {
"DateOfBirth": "Geburtsdatum",
"DateOfDeath": "Sterbedatum",
"Gender": "Geschlecht",
"Genotype": "Genotyp",
"ColorVarietyId": "Farbschlag",
}
conflict_notes.append(
f"Konflikt bei {FIELD_LABEL[field]} per Mehrheitsentscheidung gelöst"
)
best_g[field] = best_val
# Keep helper fields in sync if we changed DateOfBirth
if field == "DateOfBirth":
@@ -1824,12 +1916,13 @@ def main():
if merged_notes:
best_g["Notes"] = " | ".join(merged_notes)
best_g["_photos"] = merged_photos
best_g["Provenance"] = build_provenance(sub, best_g, extra_notes=conflict_notes)
# Print merge trace
print(f"Deduplicated same-animal name '{best_g['Name']}': merged {len(sub)} entries across files: {', '.join(sources)}")
resolved_gerbils.append(best_g)
gerbil_id_map[best_g["Id"]] = best_g["Id"]
@@ -1885,6 +1978,8 @@ def main():
del g["_birth_date"]
del g["_filename"]
del g["_old_id"]
g.pop("_conflict", None)
g.pop("_resolved_by_decision", None)
# Gather final valid gerbil IDs
valid_gerbil_ids = {g["Id"] for g in resolved_gerbils}