From df13136955f8384c3c4b01f2780817ac7bebaa46 Mon Sep 17 00:00:00 2001 From: Gulum Date: Sat, 6 Jun 2026 11:55:45 +0200 Subject: [PATCH] Importer consumes conflict-decisions.json to un-quarantine (HUMANQUESTION D) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit god maintains tools/import/conflict-decisions.json as Julian/his wife answer the D-conflicts. extract.py now consumes it (apply_conflict_decisions): for an animal matching normalize(name)+dob, it clears the conflict, marks resolvedByDecision, and — when the decision carries a `genotype` (breeder notation, parsed via genotype.py) and/or `farbschlag` — treats those as AUTHORITATIVE. Tolerates a missing/empty/garbled file. Genuinely-unresolved conflicts stay quarantined. Loader (ImportService): SourceAnimal.ResolvedByDecision flows through; the report surfaces Animals.ConflictsResolvedByDecision + a German note. Result on real data: the 2 current decisions (Firefly D-/PP, WildFire PP) un-quarantine → Konflikte 21 → 19. As god appends entries the count grows; nothing else needed from me. Tests: python test_extract (decision clears conflict + genotype authoritative + removes from conflicts list + tolerates missing file) and a C# loader test (a resolved animal loads and is counted). Folded into the EXTRACT-BANDS branch so the next re-extract applies band-aware Farbschlag + these decisions in one pass. No schema change (JSON DTO fields). python + dotnet 121/121 green; has-pending clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- GerbilManager.Tests/ImportServiceTests.cs | 27 ++++++++++++++ GerbilManagerWebAPI/Import/ImportModels.cs | 7 +++- GerbilManagerWebAPI/Import/ImportService.cs | 5 ++- tools/import/extract.py | 40 ++++++++++++++++++++- tools/import/output/review-report.md | 6 ++-- tools/import/test_extract.py | 23 ++++++++++++ 6 files changed, 101 insertions(+), 7 deletions(-) diff --git a/GerbilManager.Tests/ImportServiceTests.cs b/GerbilManager.Tests/ImportServiceTests.cs index 4dd10b3..16f1bda 100644 --- a/GerbilManager.Tests/ImportServiceTests.cs +++ b/GerbilManager.Tests/ImportServiceTests.cs @@ -278,6 +278,33 @@ namespace GerbilManager.Tests finally { try { Directory.Delete(dir, recursive: true); } catch { } } } + [Fact] + public async Task Decision_resolved_animal_loads_and_is_counted() + { + // extract.py clears the conflict + sets resolvedByDecision when a human conflict-decision + // un-quarantines an animal; the loader must then LOAD it and surface the count. + var dir = Path.Combine(Path.GetTempPath(), "dec-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(dir); + try + { + File.WriteAllText(Path.Combine(dir, "litters.json"), "[]"); + File.WriteAllText(Path.Combine(dir, "animals.json"), """ + [ + {"id":"firefly","name":"Firefly","dob":"18.12.2019","death":"","farbschlag":"Agouti", + "genotype":{"mapped8locus":{"A":["A","a"],"D":["D","?"]},"rawGenotype":"Aa D-","unmappedTokens":[]}, + "conflict":false,"resolvedByDecision":true} + ] + """); + using var db = NewDb(); + var report = await new ImportService(db, dir, dir).RunAsync(execute: true); + + Assert.Equal(1, report.Animals.ConflictsResolvedByDecision); + Assert.True(await db.Gerbils.AnyAsync(g => g.ExternalRef == "firefly")); // loaded, not quarantined + Assert.Equal(0, report.Animals.Quarantined.Conflicts); + } + finally { try { Directory.Delete(dir, recursive: true); } catch { } } + } + [Fact] public void ComposeGenotype_strips_carets_and_fills_missing_loci() { diff --git a/GerbilManagerWebAPI/Import/ImportModels.cs b/GerbilManagerWebAPI/Import/ImportModels.cs index 79019f7..51a76cf 100644 --- a/GerbilManagerWebAPI/Import/ImportModels.cs +++ b/GerbilManagerWebAPI/Import/ImportModels.cs @@ -27,6 +27,10 @@ namespace GerbilManagerWebAPI.Import // provenance/breeding tags (WFNZ/RV/GV/DP) — neither is genotype. public bool? Deaf { get; set; } public List Tags { get; set; } = new(); + + // Set by extract.py when a human conflict-decision (conflict-decisions.json) un-quarantined + // this animal (its genotype/farbschlag are then authoritative). For reporting. + public bool ResolvedByDecision { get; set; } } public sealed class SourceGenotype @@ -92,7 +96,8 @@ namespace GerbilManagerWebAPI.Import int FarbschlagUnmatched, int AlreadyImported, QuarantineSummary Quarantined, - int ParentLinksFromChart = 0); + int ParentLinksFromChart = 0, + int ConflictsResolvedByDecision = 0); public sealed record QuarantineSummary( int Conflicts, diff --git a/GerbilManagerWebAPI/Import/ImportService.cs b/GerbilManagerWebAPI/Import/ImportService.cs index 0971fe0..f664126 100644 --- a/GerbilManagerWebAPI/Import/ImportService.cs +++ b/GerbilManagerWebAPI/Import/ImportService.cs @@ -404,6 +404,9 @@ namespace GerbilManagerWebAPI.Import notes.Add($"Stammbaum-Diagramm: {parentLinksAdded} Tiere über Eltern-Verknüpfung einem (abgeleiteten) Wurf zugeordnet ({derivedLitters} abgeleitete Würfe)."); notes.Add($"FK-Integrität: {litterParentFksDropped} Eltern-Verknüpfung(en) verworfen (Elternteil nicht ladbar), {derivedLittersSkipped} abgeleitete Würfe übersprungen (kein ladbares Elternteil). Bei 0/0 ist /import/execute FK-sicher."); notes.Add($"Bestand/Herkunft: {residentTotal} im Bestand (Clan Kleine Chaoten), {externalTotal} externe Ahnen ({flippedByParentRule} davon über die Eltern-Regel als Bestand erkannt)."); + int conflictsResolvedByDecision = loadable.Count(a => a.ResolvedByDecision); + if (conflictsResolvedByDecision > 0) + notes.Add($"Konfliktauflösungen: {conflictsResolvedByDecision} Tier(e) anhand von conflict-decisions.json un-quarantänet (Genotyp/Farbschlag der Züchterin ist maßgeblich)."); if (!execute) notes.Add("DRY-RUN: nichts gespeichert. /import/execute lädt die konfliktfreien Daten."); return new ImportReport( @@ -412,7 +415,7 @@ namespace GerbilManagerWebAPI.Import Animals: new AnimalSummary( animals.Count, animalsCreated, linked, fbMatched, fbUnmatched, animalsExisting, new QuarantineSummary(conflicts, stubs, dateOnly, ambiguous, conflicts + stubs), - parentLinksAdded), + parentLinksAdded, conflictsResolvedByDecision), Photos: new PhotoSummary(photosAttached, photosMissing), Samples: samples, Notes: notes, diff --git a/tools/import/extract.py b/tools/import/extract.py index ffe43ce..604b2e1 100644 --- a/tools/import/extract.py +++ b/tools/import/extract.py @@ -834,6 +834,41 @@ def write_report(merged, conflicts, orphans, raw_count, litters, photo_count, # ------------------------------------------------------------------------ main +def apply_conflict_decisions(merged, conflicts, path): + """Consume human conflict resolutions (tools/import/conflict-decisions.json) so the wife's + answers UN-QUARANTINE animals. Schema: {"resolutions":[{name, dob, decision, genotype?, + farbschlag?, source}]}. Match = norm_name(name)+norm_dob(dob) (same identity as dedup). A + matching animal: clear its conflict, mark resolvedByDecision; an explicit `genotype` + (breeder notation) is parsed and becomes authoritative, `farbschlag` overrides too. Tolerates + a missing/empty/garbled file. Returns the number of conflicts resolved. (god/HUMANQUESTION D.)""" + decisions = {} + try: + with open(path, encoding="utf-8") as fh: + for r in (json.load(fh).get("resolutions") or []): + decisions[(norm_name(r.get("name", "")), norm_dob(r.get("dob", "")))] = r + except (OSError, ValueError): + return 0 + if not decisions: + return 0 + + resolved = 0 + for a in merged: + d = decisions.get((norm_name(a["name"]), norm_dob(a["dob"]))) + if not d: + continue + a["resolvedByDecision"] = True + if d.get("genotype"): + a["genotype"] = gt.parse(d["genotype"]) + if d.get("farbschlag"): + a["farbschlag"] = d["farbschlag"] + a["farbschlagVariants"] = [d["farbschlag"]] + if a.get("conflict"): + a["conflict"] = False + conflicts[:] = [c for c in conflicts if c.get("id") != a["id"]] + resolved += 1 + return resolved + + def main(): try: sys.stdout.reconfigure(encoding="utf-8", errors="replace") @@ -868,6 +903,8 @@ def main(): print(f"Wurfchronik: {len(litters)} Würfe") merged, conflicts, orphans, zucht_splits = dedup(raw_animals) + decisions_path = os.path.join(HERE, "conflict-decisions.json") + resolved_by_decision = apply_conflict_decisions(merged, conflicts, decisions_path) match_stats = match_litters(merged, litters) photo_count = sum(len(a["photos"]) for a in merged) @@ -884,7 +921,8 @@ def main(): zucht_splits, match_stats) print(f"\nRoh: {len(raw_animals)} → eindeutig: {len(merged)} " - f"| Konflikte: {len(conflicts)} | Zucht-Splits: {len(zucht_splits)} " + f"| Konflikte: {len(conflicts)} | per Entscheidung gelöst: {resolved_by_decision} " + f"| Zucht-Splits: {len(zucht_splits)} " f"| Orphans: {len(orphans)} | Fotos: {photo_count}") print(f"Wurf-Verknüpfung: {match_stats['parents']} (Datum+Eltern), " f"{match_stats['dateOnly']} (nur Datum), {match_stats['ambiguous']} mehrdeutig " diff --git a/tools/import/output/review-report.md b/tools/import/output/review-report.md index 0f109ca..167b53a 100644 --- a/tools/import/output/review-report.md +++ b/tools/import/output/review-report.md @@ -8,7 +8,7 @@ _Automatisch erzeugt von `tools/import/extract.py` — **noch nichts in die Date - Nach Zusammenführung (eindeutige Tiere): **622** - davon mit Geburtsdatum: 327 - in mehreren Dateien gefunden (Dubletten zusammengeführt): 158 -- Konflikte zur Klärung: **21** +- Konflikte zur Klärung: **19** - Mehrdeutige / unvollständige Einträge (ohne Name+Datum): **310** - Fotos zugeordnet: **137** - Würfe aus der Wurfchronik: **752** @@ -27,9 +27,7 @@ Gleiches Tier (Name+Datum), aber widersprüchliche Angaben in verschiedenen Date |---|---|---|---|---|---| | Ella | 10.06.2019 | Aa C D- ee[f] GG P- spsp // Aa Cc[chm] D- ee[f] UwUw P- spsp | Algierfuchsschimmel, hell | 03.02.2023 | Stammbaum von Akio Kids, Stammbaum von CP-Fuchs, CP-Sa Sp von Unity, Stammbaum von Valentino Firehearts Kids | | Louis von den Kleinen Chaoten | 15.07.2017 | Aa Cc[] D- Ee Gg P- spsp // Aa Cc[chm] D- Ee Uwuw[d] P- spsp | — | 01.07.2020 | Stammbaum von Akio Kids, Stammbaum von CP-Fuchs, CP-Sa Sp von Unity | -| Firefly von den Kleinen Chaoten | 18.12.2019 | /+, Aa c[chm]c[chm] D- Ee Gg PP Spsp // Aa c[chm]c[chm] DD Ee Gg PP Spsp | — | 2024 | Stammbaum von Akio Kids, Stammbaum von CP-Fuchs, CP-Sa Sp von Unity, Stammbaum von Fire Kids, Stammbaum von Valentino Firehearts Kids | | Zuleika von den Kleinen Chaoten | 24.10.2015 | aa c[chm]c[h] D- E G P- spsp // aa c[chm]c[h] D- Ee Gg P- spsp // aa c[chm]c[h] DD Ee Gg P- spsp | — | 24.02.2019 | Stammbaum von Akio Kids, Stammbaum von CP-Fuchs, CP-Sa Sp von Unity, Stammbaum von Valentino Firehearts Kids | -| WildFire von den Kleinen Chaoten | 05.10.2017 | aa c[chm]c[chm] D- Ee gg P- spsp // aa c[chm]c[chm] D- Ee gg PP spsp | — | — | Stammbaum von Akio Kids, Stammbaum von CP-Fuchs, CP-Sa Sp von Unity, Stammbaum von Fire Kids, Stammbaum von Valentino Firehearts Kids | | Vestra von den Schlossmäusen | 08.02.2019 | Aa Cc[chm] D- EE GG PP Spsp [WP] // Aa Cc[chm] DD EE GG PP Spsp [WP] | — | 26.05.2023 | Stammbaum von Akio Kids, Stammbaum von CP-Fuchs, CP-Sa Sp von Unity, Stammbaum von Fire Kids, Stammbaum von Valentino Firehearts Kids | | Flint von den Kleinen Chaoten | 23.12.2017 | aa Cc[chm] D- ee Gg P- spsp | — | 10.05.2021 // 10.05.2022 | Stammbaum von Akio Kids, Stammbaum von CP-Fuchs, CP-Sa Sp von Unity | | Kazu von den Kleinen Chaoten | 23.04.2013 | Aa Cc[chm] DD e[f]e[f] Gg P Spsp // Aa Cc[chm] DD ee[f] UwUw PP Spsp | — | 03.09.2017 | Stammbaum von Akio Kids, Stammbaum von Vance | @@ -142,7 +140,7 @@ Diese Tokens stehen weiter in `rawGenotype`/`unmappedTokens` — Entscheidung (M | Token | Vorkommen | Bedeutung (Vermutung) | |---|---|---| -| `/+` | 8 | ? | +| `/+` | 7 | ? | | `-g` | 2 | ? | | `C(C)` | 2 | Schreibweise (C trägt c) | | `chmchm` | 2 | Schreibweise (c[chm]c[chm]) | diff --git a/tools/import/test_extract.py b/tools/import/test_extract.py index 72c4187..cdb6d8e 100644 --- a/tools/import/test_extract.py +++ b/tools/import/test_extract.py @@ -71,6 +71,29 @@ gen = e.gen_of check("gen_of: early bands < 2 (E,H)", gen(5) < 2 and gen(8) < 2) check("gen_of: deep bands >= 2 (K,N,Q)", gen(11) >= 2 and gen(14) >= 2) +# --- conflict-decisions consumption (HUMANQUESTION D / C6) --- +dec_path = os.path.join(tempfile.gettempdir(), "conflict-decisions-test.json") +import json as _json +_json.dump({"resolutions": [ + {"name": "Firefly von den Kleinen Chaoten", "dob": "18.12.2019", + "decision": "D-locus = D-", "genotype": "Aa c[chm]c[chm] D- Ee Gg PP Spsp", + "source": "test"}, +]}, open(dec_path, "w", encoding="utf-8")) +merged = [{"id": "x1", "name": "Firefly von den Kleinen Chaoten", "dob": "18.12.2019", + "conflict": True, "farbschlag": "", + "genotype": {"mapped8locus": {"D": ["D", "D"]}, "rawGenotype": "DD", "unmappedTokens": []}}] +conflicts = [{"id": "x1", "name": "Firefly von den Kleinen Chaoten", "dob": "18.12.2019"}] +n = e.apply_conflict_decisions(merged, conflicts, dec_path) +check("decision un-quarantines (conflict cleared)", merged[0]["conflict"] is False) +check("decision marks resolvedByDecision", merged[0].get("resolvedByDecision") is True) +check("decision genotype is authoritative (D- not DD)", merged[0]["genotype"]["mapped8locus"]["D"] == ["D", "?"]) +check("decision removes entry from conflicts list", conflicts == []) +check("apply_conflict_decisions returns resolved count", n == 1) +check("missing decisions file tolerated (returns 0)", + e.apply_conflict_decisions([], [], os.path.join(tempfile.gettempdir(), "does-not-exist.json")) == 0) +try: os.remove(dec_path) +except OSError: pass + # --- name-bleed guard (a parent name is not a Farbschlag) --- check("v.d. name rejected", e.looks_like_animal_name("Tennessee von den Kleinen Chaoten")) check("gen.+v.d. name rejected", e.looks_like_animal_name("Victoria Welby gen. Welby v.d. Kleinen Chaoten"))