IMPORT-POLISH: 4 Importer-Fixes nach Re-Import #2
FIX-1 decision-matching: apply_conflict_decisions/apply_dob_remaps nutzen jetzt canon_pair(name)[0] als Match-Key (Dedup-Identitaet: call-name ohne Zucht, v.d.<->von den gefaltet). Workaround-Spelling v.d. in Victoria Welbys Decision bleibt erhalten; beide Formen matchen jetzt. Kommentar im decision-Eintrag aktualisiert. FIX-2 specific-wins: _alleles_compatible aendert '? vs x = False' -> '? vs x = True' (spezifischer Wert gewinnt). C- vs CC, G- vs Gg, P? vs PP sind kein Konflikt mehr. Echte Wert-Widersprueche (DD vs Dd, Ee vs ee, PP vs Pp) bleiben Konflikte. Loest Enya, Ella, Zac automatisch (Konflikte 8->5 erwartet). 2 bestehende Tests angepasst, 7 neue Tests. FIX-3 parent-FK backfill: nach dem Wurfchronik-Rueckverknuepfungs- Block iteriert ImportService.RunAsync ueber bereits importierte Wuerfe mit null Father/MotherId und setzt fehlende FKs wenn das Elterntier jetzt ladbar ist. Trockenlauf zaehlt, Execute schreibt. LitterSummary.ParentFksBackfilled + 2 neue C#-Tests (SQLite). FIX-4 Skarlett-Artefakt: parse_detail() strippt trailing / +YEAR aus dem Genotyp-Tail (re.sub). Sterbejahr bleibt als death-Date erhalten -> Skarlett erscheint als reiner Sterbedatum-Konflikt. 2 neue Python-Tests. Gate: 124/124 C#-Tests, Python test_extract/test_genotype ALL PASS, has-pending-model-changes = No. Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -321,6 +321,116 @@ namespace GerbilManager.Tests
|
|||||||
Assert.Equal("aa Ccchm ?? eef ?? ?? ?? ??", ImportService.ComposeGenotype(g));
|
Assert.Equal("aa Ccchm ?? eef ?? ?? ?? ??", ImportService.ComposeGenotype(g));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ParentFkBackfill_fills_null_litter_parent_on_reimport()
|
||||||
|
{
|
||||||
|
// Run 1: litter "Wurf A" has sire "Vater" (conflict=true — not loaded) and dam "Mutter"
|
||||||
|
// (conflict=false — loaded). After run 1: litter.FatherId = null.
|
||||||
|
// Run 2: sire "Vater" is no longer in conflict. Backfill must set litter.FatherId.
|
||||||
|
var dir = Path.Combine(Path.GetTempPath(), "backfill-" + Guid.NewGuid().ToString("N"));
|
||||||
|
Directory.CreateDirectory(dir);
|
||||||
|
using var conn = new SqliteConnection("DataSource=:memory:");
|
||||||
|
conn.Open();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var littersJson = """
|
||||||
|
[{"id":"L-A","litterId":"A","date":"01.05.2023","damName":"Mutter [ZdkC]","sireName":"Vater [ZdkC]","totalBorn":3,"zuchtnummer":"","note":""}]
|
||||||
|
""";
|
||||||
|
// Run 1: Vater is in conflict -> not loaded
|
||||||
|
var animals1 = """
|
||||||
|
[
|
||||||
|
{"id":"mutter","name":"Mutter [ZdkC]","dob":"01.01.2021","death":"","farbschlag":"","gender":"female","zuchtCanon":"kleinechaote",
|
||||||
|
"genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":false},
|
||||||
|
{"id":"vater","name":"Vater [ZdkC]","dob":"02.02.2021","death":"","farbschlag":"","gender":"male","zuchtCanon":"kleinechaote",
|
||||||
|
"genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":true},
|
||||||
|
{"id":"kind","name":"Kind [ZdkC]","dob":"01.05.2023","death":"","farbschlag":"","gender":null,"zuchtCanon":"kleinechaote",
|
||||||
|
"genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":false,
|
||||||
|
"litterRef":{"litterId":"L-A","method":"geburtsdatum+eltern","confidence":"hoch"}}
|
||||||
|
]
|
||||||
|
""";
|
||||||
|
File.WriteAllText(Path.Combine(dir, "litters.json"), littersJson);
|
||||||
|
File.WriteAllText(Path.Combine(dir, "animals.json"), animals1);
|
||||||
|
|
||||||
|
var opts = new DbContextOptionsBuilder<ApplicationContext>().UseSqlite(conn).Options;
|
||||||
|
using var db = new ApplicationContext(opts);
|
||||||
|
await db.Database.EnsureCreatedAsync();
|
||||||
|
|
||||||
|
var report1 = await new ImportService(db, dir, dir).RunAsync(execute: true);
|
||||||
|
Assert.Equal(0, report1.Litters.ParentFksBackfilled);
|
||||||
|
var litter1 = await db.Litters.SingleAsync(l => l.Name == "Wurf A");
|
||||||
|
Assert.Null(litter1.FatherId); // Vater was quarantined -> null FK
|
||||||
|
Assert.NotNull(litter1.MotherId); // Mutter was loaded -> set
|
||||||
|
|
||||||
|
// Run 2: Vater is now conflict=false
|
||||||
|
var animals2 = """
|
||||||
|
[
|
||||||
|
{"id":"mutter","name":"Mutter [ZdkC]","dob":"01.01.2021","death":"","farbschlag":"","gender":"female","zuchtCanon":"kleinechaote",
|
||||||
|
"genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":false},
|
||||||
|
{"id":"vater","name":"Vater [ZdkC]","dob":"02.02.2021","death":"","farbschlag":"","gender":"male","zuchtCanon":"kleinechaote",
|
||||||
|
"genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":false},
|
||||||
|
{"id":"kind","name":"Kind [ZdkC]","dob":"01.05.2023","death":"","farbschlag":"","gender":null,"zuchtCanon":"kleinechaote",
|
||||||
|
"genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":false,
|
||||||
|
"litterRef":{"litterId":"L-A","method":"geburtsdatum+eltern","confidence":"hoch"}}
|
||||||
|
]
|
||||||
|
""";
|
||||||
|
File.WriteAllText(Path.Combine(dir, "animals.json"), animals2);
|
||||||
|
|
||||||
|
var report2 = await new ImportService(db, dir, dir).RunAsync(execute: true);
|
||||||
|
Assert.Equal(1, report2.Litters.ParentFksBackfilled); // backfill happened
|
||||||
|
var vater = await db.Gerbils.SingleAsync(g => g.ExternalRef == "vater");
|
||||||
|
var litter2 = await db.Litters.SingleAsync(l => l.Name == "Wurf A");
|
||||||
|
Assert.Equal(vater.Id, litter2.FatherId); // FK now set
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
try { Directory.Delete(dir, recursive: true); } catch { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ParentFkBackfill_dry_run_counts_without_writing()
|
||||||
|
{
|
||||||
|
// Dry-run on a DB with an existing null-parent litter should predict the backfill count.
|
||||||
|
var dir = Path.Combine(Path.GetTempPath(), "backfill-dr-" + Guid.NewGuid().ToString("N"));
|
||||||
|
Directory.CreateDirectory(dir);
|
||||||
|
using var conn = new SqliteConnection("DataSource=:memory:");
|
||||||
|
conn.Open();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var littersJson = """
|
||||||
|
[{"id":"L-B","litterId":"B","date":"15.06.2023","damName":"Mami [ZdkC]","sireName":"Papi [ZdkC]","totalBorn":2,"zuchtnummer":"","note":""}]
|
||||||
|
""";
|
||||||
|
var animals1 = """
|
||||||
|
[
|
||||||
|
{"id":"mami","name":"Mami [ZdkC]","dob":"03.03.2021","death":"","farbschlag":"","gender":"female","zuchtCanon":"kleinechaote",
|
||||||
|
"genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":false},
|
||||||
|
{"id":"papi","name":"Papi [ZdkC]","dob":"04.04.2021","death":"","farbschlag":"","gender":"male","zuchtCanon":"kleinechaote",
|
||||||
|
"genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":true}
|
||||||
|
]
|
||||||
|
""";
|
||||||
|
File.WriteAllText(Path.Combine(dir, "litters.json"), littersJson);
|
||||||
|
File.WriteAllText(Path.Combine(dir, "animals.json"), animals1);
|
||||||
|
|
||||||
|
var opts = new DbContextOptionsBuilder<ApplicationContext>().UseSqlite(conn).Options;
|
||||||
|
using var db = new ApplicationContext(opts);
|
||||||
|
await db.Database.EnsureCreatedAsync();
|
||||||
|
await new ImportService(db, dir, dir).RunAsync(execute: true); // run 1
|
||||||
|
|
||||||
|
// Run 2 dry-run with papi un-quarantined
|
||||||
|
var animals2 = animals1.Replace("\"conflict\":true", "\"conflict\":false");
|
||||||
|
File.WriteAllText(Path.Combine(dir, "animals.json"), animals2);
|
||||||
|
var dry = await new ImportService(db, dir, dir).RunAsync(execute: false);
|
||||||
|
|
||||||
|
Assert.Equal(1, dry.Litters.ParentFksBackfilled); // predicted but not written
|
||||||
|
var litter = await db.Litters.SingleAsync(l => l.Name == "Wurf B");
|
||||||
|
Assert.Null(litter.FatherId); // not written in dry-run
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
try { Directory.Delete(dir, recursive: true); } catch { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
[Theory]
|
[Theory]
|
||||||
[InlineData("01.02.2020", 2020, 2, 1)]
|
[InlineData("01.02.2020", 2020, 2, 1)]
|
||||||
[InlineData("5.3.21", 2021, 3, 5)]
|
[InlineData("5.3.21", 2021, 3, 5)]
|
||||||
|
|||||||
@@ -86,7 +86,8 @@ namespace GerbilManagerWebAPI.Import
|
|||||||
public sealed record ResidencySummary(int Resident, int External, int FlippedByParentRule);
|
public sealed record ResidencySummary(int Resident, int External, int FlippedByParentRule);
|
||||||
|
|
||||||
public sealed record LitterSummary(int InSource, int Created, int AlreadyImported,
|
public sealed record LitterSummary(int InSource, int Created, int AlreadyImported,
|
||||||
int DerivedFromChart = 0, int DerivedSkipped = 0, int ParentFksDropped = 0);
|
int DerivedFromChart = 0, int DerivedSkipped = 0, int ParentFksDropped = 0,
|
||||||
|
int ParentFksBackfilled = 0);
|
||||||
|
|
||||||
public sealed record AnimalSummary(
|
public sealed record AnimalSummary(
|
||||||
int InSource,
|
int InSource,
|
||||||
|
|||||||
@@ -399,10 +399,55 @@ namespace GerbilManagerWebAPI.Import
|
|||||||
await _db.SaveChangesAsync();
|
await _db.SaveChangesAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PARENT-FK BACKFILL (idempotent re-run): already-imported Wurfchronik litters that
|
||||||
|
// have null Father/MotherId because the parent was previously quarantined may now be
|
||||||
|
// resolvable if that parent is loadable in this run. Counted for dry-run too.
|
||||||
|
int parentFksBackfilled = 0;
|
||||||
|
{
|
||||||
|
var existingWithNullParent = await _db.Litters
|
||||||
|
.Where(l => l.FatherId == null || l.MotherId == null)
|
||||||
|
.Select(l => new { l.Id, l.Name, l.FatherId, l.MotherId })
|
||||||
|
.ToListAsync();
|
||||||
|
var sourceByName = litters
|
||||||
|
.GroupBy(sl => $"Wurf {sl.LitterId}".Trim())
|
||||||
|
.ToDictionary(g => g.Key, g => g.First());
|
||||||
|
foreach (var el in existingWithNullParent)
|
||||||
|
{
|
||||||
|
if (!sourceByName.TryGetValue(el.Name, out var sl)) continue;
|
||||||
|
Guid? newF = null, newM = null;
|
||||||
|
if (el.FatherId == null)
|
||||||
|
{
|
||||||
|
var n = Normalize(StripZucht(sl.SireName));
|
||||||
|
if (n.Length > 0 && createdAnimalByName.TryGetValue(n, out var fid) && persisted.Contains(fid))
|
||||||
|
newF = fid;
|
||||||
|
}
|
||||||
|
if (el.MotherId == null)
|
||||||
|
{
|
||||||
|
var n = Normalize(StripZucht(sl.DamName));
|
||||||
|
if (n.Length > 0 && createdAnimalByName.TryGetValue(n, out var mid) && persisted.Contains(mid))
|
||||||
|
newM = mid;
|
||||||
|
}
|
||||||
|
if (newF is null && newM is null) continue;
|
||||||
|
parentFksBackfilled++;
|
||||||
|
if (execute)
|
||||||
|
{
|
||||||
|
var row = await _db.Litters.FirstOrDefaultAsync(l => l.Id == el.Id);
|
||||||
|
if (row is not null)
|
||||||
|
{
|
||||||
|
if (newF is not null) row.FatherId = newF;
|
||||||
|
if (newM is not null) row.MotherId = newM;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (execute && parentFksBackfilled > 0) await _db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
notes.Add("Quarantäne (kein Import): Konflikte + Stubs ohne Geburtsdatum + unsichere Wurf-Zuordnungen — warten auf die Prüfung durch die Züchterin.");
|
notes.Add("Quarantäne (kein Import): Konflikte + Stubs ohne Geburtsdatum + unsichere Wurf-Zuordnungen — warten auf die Prüfung durch die Züchterin.");
|
||||||
if (parentLinksAdded > 0)
|
if (parentLinksAdded > 0)
|
||||||
notes.Add($"Stammbaum-Diagramm: {parentLinksAdded} Tiere über Eltern-Verknüpfung einem (abgeleiteten) Wurf zugeordnet ({derivedLitters} abgeleitete Würfe).");
|
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($"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.");
|
||||||
|
if (parentFksBackfilled > 0)
|
||||||
|
notes.Add($"Parent-FK-Backfill: {parentFksBackfilled} bereits importierte Würfe haben jetzt eine Eltern-Verknüpfung (Elternteil war zuvor in Quarantäne, jetzt geladen).");
|
||||||
notes.Add($"Bestand/Herkunft: {residentTotal} im Bestand (Clan Kleine Chaoten), {externalTotal} externe Ahnen ({flippedByParentRule} davon über die Eltern-Regel als Bestand erkannt).");
|
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);
|
int conflictsResolvedByDecision = loadable.Count(a => a.ResolvedByDecision);
|
||||||
if (conflictsResolvedByDecision > 0)
|
if (conflictsResolvedByDecision > 0)
|
||||||
@@ -411,7 +456,7 @@ namespace GerbilManagerWebAPI.Import
|
|||||||
|
|
||||||
return new ImportReport(
|
return new ImportReport(
|
||||||
Executed: execute,
|
Executed: execute,
|
||||||
Litters: new LitterSummary(litters.Count, littersCreated, littersExisting, derivedLitters, derivedLittersSkipped, litterParentFksDropped),
|
Litters: new LitterSummary(litters.Count, littersCreated, littersExisting, derivedLitters, derivedLittersSkipped, litterParentFksDropped, parentFksBackfilled),
|
||||||
Animals: new AnimalSummary(
|
Animals: new AnimalSummary(
|
||||||
animals.Count, animalsCreated, linked, fbMatched, fbUnmatched, animalsExisting,
|
animals.Count, animalsCreated, linked, fbMatched, fbUnmatched, animalsExisting,
|
||||||
new QuarantineSummary(conflicts, stubs, dateOnly, ambiguous, conflicts + stubs),
|
new QuarantineSummary(conflicts, stubs, dateOnly, ambiguous, conflicts + stubs),
|
||||||
|
|||||||
@@ -81,7 +81,7 @@
|
|||||||
{
|
{
|
||||||
"name": "Victoria Welby gen. Welby v.d. Kleinen Chaoten",
|
"name": "Victoria Welby gen. Welby v.d. Kleinen Chaoten",
|
||||||
"dob": "16.01.2023",
|
"dob": "16.01.2023",
|
||||||
"decision": "E-locus = ee[f] (Fuchs). NOTE: this is the mother of animal 'C' (c-29042024) — un-quarantining her links C's second parent. Name kept in the merged record's v.d. spelling: extract.py decision matching uses norm_name (no v.d.<->von den fold) — workaround until the canon_pair matching fix lands.",
|
"decision": "E-locus = ee[f] (Fuchs). This is the mother of animal 'C' (c-29042024) — un-quarantining her links C's second parent. Name in v.d. spelling (workaround from Re-Import #2); both spellings now match after FIX-1 (canon_pair identity).",
|
||||||
"genotype": "Aa CC D- ee[f] Gg pp Spsp [DP]",
|
"genotype": "Aa CC D- ee[f] Gg pp Spsp [DP]",
|
||||||
"source": "Julian 2026-06-06 — HUMANQUESTION D4"
|
"source": "Julian 2026-06-06 — HUMANQUESTION D4"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -158,6 +158,10 @@ def parse_detail(text):
|
|||||||
tail = text[dob.end():]
|
tail = text[dob.end():]
|
||||||
tail = re.sub(r"^\s*/?\+?\s?\d[\d.]*", "", tail) # drop any /+death remnant
|
tail = re.sub(r"^\s*/?\+?\s?\d[\d.]*", "", tail) # drop any /+death remnant
|
||||||
tail = tail.lstrip(" ,").strip()
|
tail = tail.lstrip(" ,").strip()
|
||||||
|
# FIX-4 (Skarlett): strip trailing "/ +YEAR" death-year artifacts leaked from compact
|
||||||
|
# chart cells (e.g. "… rere / +2018"). The DEATH regex still captures the year from
|
||||||
|
# the full cell text, so it appears as a death-date conflict — not a genotype conflict.
|
||||||
|
tail = re.sub(r"\s*/\s*\+\d{4}\s*$", "", tail).strip()
|
||||||
if gt.looks_like_genotype(tail):
|
if gt.looks_like_genotype(tail):
|
||||||
geno = tail
|
geno = tail
|
||||||
return (dob.group(1) if dob else "",
|
return (dob.group(1) if dob else "",
|
||||||
@@ -518,10 +522,11 @@ def _alleles_compatible(a, b):
|
|||||||
if a == b:
|
if a == b:
|
||||||
return True
|
return True
|
||||||
if a == "?" or b == "?":
|
if a == "?" or b == "?":
|
||||||
return False # unknown vs filled = contradiction (D- vs DD)
|
return True # specific-wins: unknown allele is compatible with any
|
||||||
|
# specified value (C- vs CC -> CC; G- vs Gg -> Gg)
|
||||||
(ba, ma), (bb, mb) = _split_allele(a), _split_allele(b)
|
(ba, ma), (bb, mb) = _split_allele(a), _split_allele(b)
|
||||||
if ba != bb:
|
if ba != bb:
|
||||||
return False # different base allele = real value diff (E vs e)
|
return False # different base allele = real value diff (E vs e, D vs d)
|
||||||
return ma == "" or mb == "" # same base, modifier present-vs-absent -> presence wins
|
return ma == "" or mb == "" # same base, modifier present-vs-absent -> presence wins
|
||||||
|
|
||||||
|
|
||||||
@@ -887,21 +892,22 @@ def apply_dob_remaps(raw_animals, path):
|
|||||||
"""PRE-dedup: a conflict-decision carrying `correctDob` marks a record as a DUPLICATE with a
|
"""PRE-dedup: a conflict-decision carrying `correctDob` marks a record as a DUPLICATE with a
|
||||||
wrong birthdate — remap that raw record's DOB to correctDob so dedup MERGES it into the
|
wrong birthdate — remap that raw record's DOB to correctDob so dedup MERGES it into the
|
||||||
canonical same-named animal (e.g. Chelsea *15.10.2021 -> *02.04.2021). Match =
|
canonical same-named animal (e.g. Chelsea *15.10.2021 -> *02.04.2021). Match =
|
||||||
norm_name(name)+norm_dob(dob). Tolerates a missing/garbled file. Returns the remap count.
|
canon_pair(name)[0]+norm_dob(dob) (same identity as dedup — strips zucht suffix, folds
|
||||||
|
v.d.<->von den). Tolerates a missing/garbled file. Returns the remap count.
|
||||||
Must run BEFORE dedup (it changes the dedup identity). (god/HUMANQUESTION D — Dubletten.)"""
|
Must run BEFORE dedup (it changes the dedup identity). (god/HUMANQUESTION D — Dubletten.)"""
|
||||||
remaps = {}
|
remaps = {}
|
||||||
try:
|
try:
|
||||||
with open(path, encoding="utf-8") as fh:
|
with open(path, encoding="utf-8") as fh:
|
||||||
for r in (json.load(fh).get("resolutions") or []):
|
for r in (json.load(fh).get("resolutions") or []):
|
||||||
if r.get("correctDob"):
|
if r.get("correctDob"):
|
||||||
remaps[(norm_name(r.get("name", "")), norm_dob(r.get("dob", "")))] = r["correctDob"]
|
remaps[(canon_pair(r.get("name", ""))[0], norm_dob(r.get("dob", "")))] = r["correctDob"]
|
||||||
except (OSError, ValueError):
|
except (OSError, ValueError):
|
||||||
return 0
|
return 0
|
||||||
if not remaps:
|
if not remaps:
|
||||||
return 0
|
return 0
|
||||||
n = 0
|
n = 0
|
||||||
for a in raw_animals:
|
for a in raw_animals:
|
||||||
new = remaps.get((norm_name(a.get("name", "")), norm_dob(a.get("dob", ""))))
|
new = remaps.get((canon_pair(a.get("name", ""))[0], norm_dob(a.get("dob", ""))))
|
||||||
if new and a.get("dob") != new:
|
if new and a.get("dob") != new:
|
||||||
a["dob"] = new
|
a["dob"] = new
|
||||||
n += 1
|
n += 1
|
||||||
@@ -911,15 +917,18 @@ def apply_dob_remaps(raw_animals, path):
|
|||||||
def apply_conflict_decisions(merged, conflicts, path):
|
def apply_conflict_decisions(merged, conflicts, path):
|
||||||
"""Consume human conflict resolutions (tools/import/conflict-decisions.json) so the wife's
|
"""Consume human conflict resolutions (tools/import/conflict-decisions.json) so the wife's
|
||||||
answers UN-QUARANTINE animals. Schema: {"resolutions":[{name, dob, decision, genotype?,
|
answers UN-QUARANTINE animals. Schema: {"resolutions":[{name, dob, decision, genotype?,
|
||||||
farbschlag?, source}]}. Match = norm_name(name)+norm_dob(dob) (same identity as dedup). A
|
farbschlag?, source}]}. Match = canon_pair(name)[0]+norm_dob(dob) — the same dedup identity
|
||||||
matching animal: clear its conflict, mark resolvedByDecision; an explicit `genotype`
|
(call-name only, zucht stripped, v.d.<->von den folded). A matching animal: clear its
|
||||||
(breeder notation) is parsed and becomes authoritative, `farbschlag` overrides too. Tolerates
|
conflict, mark resolvedByDecision; an explicit `genotype` (breeder notation) is parsed and
|
||||||
a missing/empty/garbled file. Returns the number of conflicts resolved. (god/HUMANQUESTION D.)"""
|
becomes authoritative, `farbschlag` overrides too. Tolerates a missing/empty/garbled file.
|
||||||
|
Returns the number of conflicts resolved. (god/HUMANQUESTION D.)"""
|
||||||
decisions = {}
|
decisions = {}
|
||||||
try:
|
try:
|
||||||
with open(path, encoding="utf-8") as fh:
|
with open(path, encoding="utf-8") as fh:
|
||||||
for r in (json.load(fh).get("resolutions") or []):
|
for r in (json.load(fh).get("resolutions") or []):
|
||||||
decisions[(norm_name(r.get("name", "")), norm_dob(r.get("dob", "")))] = r
|
# FIX-1: use dedup identity (call-name only, zucht stripped) so that e.g.
|
||||||
|
# a decision written as "von den" matches a merged record with "v.d." spelling.
|
||||||
|
decisions[(canon_pair(r.get("name", ""))[0], norm_dob(r.get("dob", "")))] = r
|
||||||
except (OSError, ValueError):
|
except (OSError, ValueError):
|
||||||
return 0
|
return 0
|
||||||
if not decisions:
|
if not decisions:
|
||||||
@@ -927,7 +936,7 @@ def apply_conflict_decisions(merged, conflicts, path):
|
|||||||
|
|
||||||
resolved = 0
|
resolved = 0
|
||||||
for a in merged:
|
for a in merged:
|
||||||
d = decisions.get((norm_name(a["name"]), norm_dob(a["dob"])))
|
d = decisions.get((canon_pair(a["name"])[0], norm_dob(a["dob"])))
|
||||||
if not d:
|
if not d:
|
||||||
continue
|
continue
|
||||||
a["resolvedByDecision"] = True
|
a["resolvedByDecision"] = True
|
||||||
|
|||||||
@@ -102,6 +102,39 @@ check("apply_conflict_decisions returns resolved count", n == 2)
|
|||||||
check("missing decisions file tolerated (returns 0)",
|
check("missing decisions file tolerated (returns 0)",
|
||||||
e.apply_conflict_decisions([], [], os.path.join(tempfile.gettempdir(), "does-not-exist.json")) == 0)
|
e.apply_conflict_decisions([], [], os.path.join(tempfile.gettempdir(), "does-not-exist.json")) == 0)
|
||||||
|
|
||||||
|
# FIX-1: decision matching uses canon_pair identity -> 'von den' decision matches 'v.d.' record
|
||||||
|
dec_vd = os.path.join(tempfile.gettempdir(), "decisions-vd.json")
|
||||||
|
_json.dump({"resolutions": [
|
||||||
|
{"name": "Victoria Welby gen. Welby von den Kleinen Chaoten", # written with 'von den'
|
||||||
|
"dob": "16.01.2023", "decision": "E-locus = ee[f]",
|
||||||
|
"genotype": "Aa CC D- ee[f] Gg pp Spsp", "source": "test"},
|
||||||
|
]}, open(dec_vd, "w", encoding="utf-8"))
|
||||||
|
merged_vd = [
|
||||||
|
{"id": "vw", "name": "Victoria Welby gen. Welby v.d. Kleinen Chaoten", # record has 'v.d.'
|
||||||
|
"dob": "16.01.2023", "conflict": True, "farbschlag": "", "death": "",
|
||||||
|
"genotype": {"mapped8locus": {}, "rawGenotype": "", "unmappedTokens": []}},
|
||||||
|
]
|
||||||
|
conflicts_vd = [{"id": "vw"}]
|
||||||
|
n_vd = e.apply_conflict_decisions(merged_vd, conflicts_vd, dec_vd)
|
||||||
|
check("FIX-1: 'von den' decision matches 'v.d.' record (canon_pair identity)", n_vd == 1)
|
||||||
|
check("FIX-1: conflict cleared for v.d. record", merged_vd[0]["conflict"] is False)
|
||||||
|
# Also verify the workaround spelling (v.d. in decision) matches a 'von den' record
|
||||||
|
_json.dump({"resolutions": [
|
||||||
|
{"name": "Victoria Welby gen. Welby v.d. Kleinen Chaoten", # workaround: v.d. in decision
|
||||||
|
"dob": "16.01.2023", "decision": "E-locus = ee[f]",
|
||||||
|
"genotype": "Aa CC D- ee[f] Gg pp Spsp", "source": "test"},
|
||||||
|
]}, open(dec_vd, "w", encoding="utf-8"))
|
||||||
|
merged_vd2 = [
|
||||||
|
{"id": "vw2", "name": "Victoria Welby gen. Welby von den Kleinen Chaoten", # record 'von den'
|
||||||
|
"dob": "16.01.2023", "conflict": True, "farbschlag": "", "death": "",
|
||||||
|
"genotype": {"mapped8locus": {}, "rawGenotype": "", "unmappedTokens": []}},
|
||||||
|
]
|
||||||
|
conflicts_vd2 = [{"id": "vw2"}]
|
||||||
|
n_vd2 = e.apply_conflict_decisions(merged_vd2, conflicts_vd2, dec_vd)
|
||||||
|
check("FIX-1: v.d. decision also matches 'von den' record (both spellings match)", n_vd2 == 1)
|
||||||
|
try: os.remove(dec_vd)
|
||||||
|
except OSError: pass
|
||||||
|
|
||||||
# --- correctDob: a wrong-birthdate duplicate is remapped BEFORE dedup so it merges ---
|
# --- correctDob: a wrong-birthdate duplicate is remapped BEFORE dedup so it merges ---
|
||||||
dec2 = os.path.join(tempfile.gettempdir(), "decisions-dob.json")
|
dec2 = os.path.join(tempfile.gettempdir(), "decisions-dob.json")
|
||||||
_json.dump({"resolutions": [
|
_json.dump({"resolutions": [
|
||||||
@@ -128,23 +161,52 @@ except OSError: pass
|
|||||||
try: os.remove(dec_path)
|
try: os.remove(dec_path)
|
||||||
except OSError: pass
|
except OSError: pass
|
||||||
|
|
||||||
# --- "presence wins" conflict rule (Julian) ---
|
# --- "presence wins" + "specific wins" conflict rules (Julian) ---
|
||||||
# present-vs-absent (whole locus or [f] modifier) is NOT a conflict; differing filled values are.
|
# present-vs-absent (whole locus or [f] modifier) is NOT a conflict; differing FILLED values are.
|
||||||
|
# FIX-2 (specific-wins): unknown allele '?' vs any specified value is also NOT a conflict —
|
||||||
|
# the specific value wins (C- vs CC -> CC; G- vs Gg -> Gg; P? vs PP -> PP).
|
||||||
check("spsp present vs locus absent -> no conflict",
|
check("spsp present vs locus absent -> no conflict",
|
||||||
not e._genotype_conflict([{"Sp": ["sp", "sp"]}, {}]))
|
not e._genotype_conflict([{"Sp": ["sp", "sp"]}, {}]))
|
||||||
check("ee[f] vs ee ([f] modifier present/absent) -> no conflict",
|
check("ee[f] vs ee ([f] modifier present/absent) -> no conflict",
|
||||||
not e._genotype_conflict([{"E": ["e", "e^f"]}, {"E": ["e", "e"]}]))
|
not e._genotype_conflict([{"E": ["e", "e^f"]}, {"E": ["e", "e"]}]))
|
||||||
check("DD vs D- (unknown vs filled) -> conflict",
|
# FIX-2: '?' vs specified = specific wins (was: contradiction)
|
||||||
e._genotype_conflict([{"D": ["D", "D"]}, {"D": ["D", "?"]}]))
|
check("FIX-2: DD vs D- (specific wins: DD wins) -> NOT conflict",
|
||||||
|
not e._genotype_conflict([{"D": ["D", "D"]}, {"D": ["D", "?"]}]))
|
||||||
|
check("FIX-2: C- vs Cc[h] (specific wins: c^h wins) -> NOT conflict",
|
||||||
|
not e._genotype_conflict([{"C": ["C", "?"]}, {"C": ["C", "c^h"]}]))
|
||||||
|
check("FIX-2: C- vs CC (specific wins: CC) -> NOT conflict",
|
||||||
|
not e._genotype_conflict([{"C": ["C", "?"]}, {"C": ["C", "C"]}]))
|
||||||
|
check("FIX-2: G- vs Gg (specific wins) -> NOT conflict",
|
||||||
|
not e._genotype_conflict([{"G": ["G", "?"]}, {"G": ["G", "g"]}]))
|
||||||
|
check("FIX-2: PP vs P? (specific wins: PP) -> NOT conflict",
|
||||||
|
not e._genotype_conflict([{"P": ["P", "P"]}, {"P": ["P", "?"]}]))
|
||||||
|
# Genuine value contradictions (both alleles specified but different) still quarantine
|
||||||
check("Ee vs ee (different base allele) -> conflict",
|
check("Ee vs ee (different base allele) -> conflict",
|
||||||
e._genotype_conflict([{"E": ["E", "e"]}, {"E": ["e", "e"]}]))
|
e._genotype_conflict([{"E": ["E", "e"]}, {"E": ["e", "e"]}]))
|
||||||
check("C- vs Cc[h] -> conflict",
|
check("DD vs Dd (both specified, D vs d) -> conflict",
|
||||||
e._genotype_conflict([{"C": ["C", "?"]}, {"C": ["C", "c^h"]}]))
|
e._genotype_conflict([{"D": ["D", "D"]}, {"D": ["D", "d"]}]))
|
||||||
check("c[h] vs c[chm] (different modifiers) -> conflict",
|
check("PP vs Pp (both specified) -> conflict",
|
||||||
|
e._genotype_conflict([{"P": ["P", "P"]}, {"P": ["P", "p"]}]))
|
||||||
|
check("c[h] vs c[chm] (different modifiers, both specified) -> conflict",
|
||||||
not e._alleles_compatible("c^h", "c^chm"))
|
not e._alleles_compatible("c^h", "c^chm"))
|
||||||
check("identical genotypes -> no conflict",
|
check("identical genotypes -> no conflict",
|
||||||
not e._genotype_conflict([{"A": ["A", "a"]}, {"A": ["A", "a"]}]))
|
not e._genotype_conflict([{"A": ["A", "a"]}, {"A": ["A", "a"]}]))
|
||||||
|
|
||||||
|
# --- FIX-4: Skarlett parse artifact — trailing "/ +YEAR" stripped from geno, death captured ---
|
||||||
|
dob4, death4, geno4 = e.parse_detail("Skarlett,*17.04.2016, aa C- DD ee Gg PP spsp rere / +2018")
|
||||||
|
check("FIX-4: '/ +YEAR' artifact stripped from geno tail",
|
||||||
|
geno4 == "aa C- DD ee Gg PP spsp rere")
|
||||||
|
check("FIX-4: death year still captured from full cell text",
|
||||||
|
death4 == "2018")
|
||||||
|
check("FIX-4: DOB still correct",
|
||||||
|
dob4 == "17.04.2016")
|
||||||
|
# Without artifact — must be unchanged
|
||||||
|
dob5, death5, geno5 = e.parse_detail("*01.01.2020, aa C- DD ee Gg PP spsp rere")
|
||||||
|
check("FIX-4: no artifact -> geno unchanged",
|
||||||
|
geno5 == "aa C- DD ee Gg PP spsp rere")
|
||||||
|
check("FIX-4: no artifact -> no spurious death",
|
||||||
|
death5 == "")
|
||||||
|
|
||||||
# --- name-bleed guard (a parent name is not a Farbschlag) ---
|
# --- 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("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"))
|
check("gen.+v.d. name rejected", e.looks_like_animal_name("Victoria Welby gen. Welby v.d. Kleinen Chaoten"))
|
||||||
|
|||||||
Reference in New Issue
Block a user