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