feat(import): RPRO3-Dubletten-Entscheidungen der Züchterin als Override

Manuelle "gleich/verschieden?"-Entscheidungen aus den rpro3-import-Tickets
fließen jetzt in den RennmausPro-III-Import ein:

- Rpro3Decisions (Modell + JSON-Loader): same (Force-Merge), different
  (Force-Split), fields (Farbe/DOB/Herkunft/resident/Notiz). Schlüssel = rid.
- Rpro3Dedup.Run(animals, decisions): honoriert die Overrides nach dem
  automatischen Dedup (eine rid zieht ihren Cluster mit).
- Rpro3ImportService: lädt Import/Rpro3/rpro3-decisions.json, wendet
  Feld-Overrides im Plan-Builder an.
- rpro3-decisions.json: 14 bestätigte Entscheidungen (Eiji, Momo, Samuel,
  Female, Kennedy, Fegur, Tuli, Bura, Mister X, Zoey, Max, Akiro, Merlin,
  Snickers).
- tools/import/rpro3_lookup.py: Triage-Helfer (Farbe/Gencode/Eltern/
  Nachzucht+Partner aus _rpro3.db) für die Rückfragen an die Züchterin.
- 4 neue Tests (Force-Merge/Force-Split/Loader); Suite 268 grün.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-26 11:26:50 +02:00
parent bbd305861f
commit a333bce1b3
7 changed files with 699 additions and 4 deletions

View File

@@ -0,0 +1,112 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace GerbilManagerWebAPI.Import.Rpro3
{
/// <summary>
/// Manuelle Entscheidungen der Züchterin zu mehrdeutigen RPRO3-Namensdubletten
/// (aus den „Fehler melden"-Tickets, Kontext „rpro3-import"). Der automatische
/// <see cref="Rpro3Dedup"/> ist konservativ und legt nur bei positiver Evidenz zusammen;
/// diese Overrides erlauben der Züchterin, gezielt zu korrigieren:
///
/// • <c>Same</c> — Gruppen von RPRO3-Nummern (rids), die DASSELBE Tier sind
/// (zwingt ein Merge über Varianten hinweg; ein rid je Variante genügt,
/// der ganze Cluster wird mitgezogen).
/// • <c>Different</c>— Gruppen, die VERSCHIEDENE Tiere sind (verhindert ein automatisches
/// Zusammenlegen über Gruppengrenzen).
/// • <c>Fields</c> — Feld-Korrekturen am resultierenden Tier (Farbe/Geburtsdatum/Herkunft/
/// Bestands-Flag/Zusatznotiz), adressiert über IRGENDEINE rid des Clusters.
///
/// Datenquelle: <c>Import/Rpro3/rpro3-decisions.json</c> (siehe <see cref="Load"/>).
/// Stabiler Schlüssel ist die RPRO3-Nummer (rid), nicht der Variantenbuchstabe — Buchstaben
/// verschieben sich, sobald sich die Clusterbildung ändert.
/// </summary>
public sealed class Rpro3Decisions
{
public List<Rpro3Decision> Decisions { get; set; } = new();
public static Rpro3Decisions Empty { get; } = new();
/// <summary>Lädt die Entscheidungen aus JSON; fehlt die Datei oder ist sie leer/kaputt,
/// kommt eine leere Menge zurück (Import läuft dann mit reinem Auto-Dedup).</summary>
public static Rpro3Decisions Load(string? path)
{
if (string.IsNullOrWhiteSpace(path) || !File.Exists(path))
return Empty;
try
{
var json = File.ReadAllText(path);
var d = JsonSerializer.Deserialize<Rpro3Decisions>(json, JsonOpts);
return d ?? Empty;
}
catch (JsonException)
{
return Empty;
}
}
private static readonly JsonSerializerOptions JsonOpts = new(JsonSerializerDefaults.Web)
{
ReadCommentHandling = JsonCommentHandling.Skip,
AllowTrailingCommas = true,
};
/// <summary>Alle „Same"-rid-Gruppen (für Force-Merge).</summary>
public IEnumerable<IReadOnlyList<string>> SameGroups()
{
foreach (var d in Decisions)
foreach (var g in d.Same)
if (g.Count >= 2) yield return g;
}
/// <summary>rid → numerische Split-Gruppe je Entscheidung. Zwei rids mit unterschiedlicher
/// Split-Gruppe (gleiche Entscheidung) dürfen NICHT automatisch zusammengelegt werden.</summary>
public Dictionary<string, int> BuildSplitGroups()
{
var map = new Dictionary<string, int>(StringComparer.Ordinal);
int gid = 0;
foreach (var d in Decisions)
{
if (d.Different.Count < 2) continue;
foreach (var grp in d.Different)
{
gid++;
foreach (var rid in grp)
map[rid] = gid;
}
}
return map;
}
/// <summary>rid → Feld-Override (jede rid eines Clusters zeigt auf denselben Override).</summary>
public Dictionary<string, Rpro3FieldOverride> BuildFieldIndex()
{
var map = new Dictionary<string, Rpro3FieldOverride>(StringComparer.Ordinal);
foreach (var d in Decisions)
foreach (var (rid, ov) in d.Fields)
map[rid] = ov;
return map;
}
}
public sealed class Rpro3Decision
{
/// <summary>Anzeigename (nur zur Lesbarkeit der JSON; Logik nutzt rids).</summary>
public string? Name { get; set; }
/// <summary>Ticket-Id (Rückverfolgbarkeit).</summary>
public string? Ticket { get; set; }
public List<List<string>> Same { get; set; } = new();
public List<List<string>> Different { get; set; } = new();
public Dictionary<string, Rpro3FieldOverride> Fields { get; set; } = new();
}
public sealed class Rpro3FieldOverride
{
public string? Color { get; set; }
public string? Dob { get; set; } // ISO yyyy-MM-dd
public string? Origin { get; set; }
public bool? Resident { get; set; }
/// <summary>Zusatz, der an die Notizen des Tiers angehängt wird (z. B. abweichendes DOB).</summary>
public string? Note { get; set; }
}
}

View File

@@ -65,9 +65,19 @@ namespace GerbilManagerWebAPI.Import.Rpro3
return UnknownValues.Contains(v) ? "" : v;
}
public static DedupResult Run(IReadOnlyList<Rpro3Animal> animals)
public static DedupResult Run(IReadOnlyList<Rpro3Animal> animals) => Run(animals, null);
/// <summary>
/// Wie <see cref="Run(IReadOnlyList{Rpro3Animal})"/>, berücksichtigt aber manuelle
/// Entscheidungen der Züchterin (<paramref name="decisions"/>): „Different"-Gruppen
/// verhindern automatisches Zusammenlegen, „Same"-Gruppen erzwingen ein Merge über
/// Varianten hinweg (jede rid zieht ihren ganzen Auto-Cluster mit).
/// </summary>
public static DedupResult Run(IReadOnlyList<Rpro3Animal> animals, Rpro3Decisions? decisions)
{
var result = new DedupResult();
decisions ??= Rpro3Decisions.Empty;
var splitGroup = decisions.BuildSplitGroups();
foreach (var a in animals)
{
@@ -123,12 +133,31 @@ namespace GerbilManagerWebAPI.Import.Rpro3
for (int j = i + 1; j < group.Count; j++)
{
var a = group[i]; var b = group[j];
// Force-Split: von der Züchterin als verschieden markierte Tiere nie zusammenlegen.
if (splitGroup.TryGetValue(a.Rid, out var ga) && splitGroup.TryGetValue(b.Rid, out var gb) && ga != gb)
continue;
bool comp = CompatDob(a.Dob, b.Dob) && Compat(a.FarbeKey, b.FarbeKey) && Compat(a.OriginKey, b.OriginKey);
if (comp && Positive(a, b) >= 1 && Conflict(a, b) == 0)
Union(a.Rid, b.Rid);
}
}
// Force-Merge: von der Züchterin als dasselbe Tier bestätigte Varianten zusammenlegen
// (eine rid je Variante genügt — Find/Union zieht den ganzen Auto-Cluster mit). Nur rids,
// die es im Datensatz auch gibt, werden berücksichtigt.
var knownRids = new HashSet<string>(animals.Select(a => a.Rid), StringComparer.Ordinal);
foreach (var grp in decisions.SameGroups())
{
string? anchor = null;
foreach (var rid in grp)
{
if (!knownRids.Contains(rid)) continue;
parent.TryAdd(rid, rid);
if (anchor is null) anchor = rid;
else Union(anchor, rid);
}
}
// Cluster sammeln
var clusters = new Dictionary<string, List<Rpro3Animal>>();
foreach (var a in animals)

View File

@@ -20,20 +20,30 @@ namespace GerbilManagerWebAPI.Import.Rpro3
{
private readonly ApplicationContext _db;
private readonly string _photoRoot;
private readonly Rpro3Decisions _decisions;
private Dictionary<string, Rpro3FieldOverride>? _fieldIndex;
public Rpro3ImportService(ApplicationContext db, IConfiguration config, IWebHostEnvironment? env)
{
_db = db;
var contentRoot = env?.ContentRootPath ?? Directory.GetCurrentDirectory();
_photoRoot = config["Photos:RootPath"] ?? Path.Combine(contentRoot, "photo-storage");
// Manuelle Dubletten-Entscheidungen der Züchterin (siehe Rpro3Decisions). Pfad
// überschreibbar per Config; Default: neben dem Importer-Code (wird mit ins Output kopiert).
var decPath = config["Rpro3:DecisionsPath"]
?? Path.Combine(contentRoot, "Import", "Rpro3", "rpro3-decisions.json");
_decisions = Rpro3Decisions.Load(decPath);
}
private Dictionary<string, Rpro3FieldOverride> FieldIndex =>
_fieldIndex ??= _decisions.BuildFieldIndex();
// ───────────────────────── ANALYZE ─────────────────────────
public async Task<Rpro3AnalyzeResult> AnalyzeAsync(
Rpro3Data data, bool photosProvided, IReadOnlySet<string>? availablePhotoFiles)
{
var dedup = Rpro3Dedup.Run(data.Animals);
var dedup = Rpro3Dedup.Run(data.Animals, _decisions);
var plan = BuildPlan(data, dedup);
// Abgleich gegen Bestand: Match über separator-insensitiven NameSearch + DOB-Toleranz.
@@ -122,7 +132,7 @@ namespace GerbilManagerWebAPI.Import.Rpro3
public async Task<Rpro3ExecuteResult> ExecuteAsync(
Rpro3Data data, string workDir, bool photosProvided, IReadOnlyDictionary<string, string>? photoSourcePaths)
{
var dedup = Rpro3Dedup.Run(data.Animals);
var dedup = Rpro3Dedup.Run(data.Animals, _decisions);
var plan = BuildPlan(data, dedup);
// Change-Tracker leeren: ExecuteDelete/Update umgehen den Tracker; bei wiederholtem
@@ -407,6 +417,19 @@ namespace GerbilManagerWebAPI.Import.Rpro3
var origin = members.Select(m => m.Origin).FirstOrDefault(o => Rpro3Dedup.NormValue(o).Length > 0) ?? rep.Origin;
var herkId = members.Select(m => m.OriginHerkId).FirstOrDefault(h => h is not null and not 1);
// Manuelle Feld-Korrektur der Züchterin (Rpro3Decisions): adressiert über IRGENDEINE
// rid des Clusters. Überschreibt das automatisch gewählte Feld; "Note" wird angehängt.
Rpro3FieldOverride? ov = null;
foreach (var m in members)
if (FieldIndex.TryGetValue(m.Rid, out ov)) break;
bool? residentOverride = ov?.Resident;
if (ov is not null)
{
if (!string.IsNullOrWhiteSpace(ov.Color)) farbe = ov.Color;
if (!string.IsNullOrWhiteSpace(ov.Origin)) origin = ov.Origin;
if (!string.IsNullOrWhiteSpace(ov.Dob) && DateOnly.TryParse(ov.Dob, out var od)) dob = od;
}
var gp = new GerbilPlan
{
Id = id,
@@ -419,7 +442,7 @@ namespace GerbilManagerWebAPI.Import.Rpro3
Genotype = CleanGenotype(fcode),
OriginBreeder = herkId == 1 ? "eigene Zucht" : (Rpro3Dedup.NormValue(origin).Length > 0 ? origin : null),
OriginContactId = HerkContactId(herkId),
IsResident = resident,
IsResident = residentOverride ?? resident,
IsCastrated = members.Any(m => m.IsCastrated),
MotherRid = rep.MidRaw,
FatherRid = rep.PidRaw,
@@ -440,6 +463,8 @@ namespace GerbilManagerWebAPI.Import.Rpro3
}
gp.Notes = BuildNotes(members);
if (!string.IsNullOrWhiteSpace(ov?.Note))
gp.Notes = string.IsNullOrWhiteSpace(gp.Notes) ? ov!.Note : $"{gp.Notes}\n{ov!.Note}";
gp.Provenance = BuildProvenance(data, members, dedup);
plan.Gerbils[id] = gp;
}

View File

@@ -0,0 +1,223 @@
{
"decisions": [
{
"name": "Eiji",
"ticket": "6aad4527-0401-43de-bda9-f649042bd1db",
"same": [
[
"u735",
"u469"
]
]
},
{
"name": "Momo",
"ticket": "7937b283-2056-435d-8821-c68ada8b1bd2",
"same": [
[
"u7453",
"u7345"
]
],
"different": [
[
"u1014"
],
[
"u7453"
]
]
},
{
"name": "Samuel",
"ticket": "6b3f28c6-47a5-4752-be94-93cda5d6d29c",
"same": [
[
"u2207",
"u263"
]
],
"different": [
[
"u2207"
],
[
"u144"
]
]
},
{
"name": "Female",
"ticket": "2d77eefe-f3a1-4be4-a8ee-7cb73cee14ab",
"different": [
[
"u649"
],
[
"u683"
],
[
"u647"
],
[
"u685"
]
]
},
{
"name": "Kennedy",
"ticket": "e5fc2c01-d443-445b-8530-ff5b86be27b1",
"same": [
[
"354",
"u6929"
]
],
"fields": {
"354": {
"color": "Blau, meliert-Starkschecke"
}
}
},
{
"name": "Fegur",
"ticket": "746a965b-a2e7-4d88-a497-640784b66818",
"same": [
[
"231",
"u6395"
]
]
},
{
"name": "Tuli",
"ticket": "06dd2e3d-5bed-469a-b975-d698712fd8ac",
"same": [
[
"229",
"u2255"
]
]
},
{
"name": "Bura",
"ticket": "7dbeb108-453d-4930-b615-a1054f0df8ad",
"same": [
[
"228",
"u2128"
]
],
"fields": {
"228": {
"origin": "Sarah Wörz"
}
}
},
{
"name": "Mister X",
"ticket": "ebd80367-a80c-426e-9541-1325274ba206",
"same": [
[
"227",
"u7065"
]
],
"fields": {
"227": {
"origin": "Sarah Wörz"
}
}
},
{
"name": "Zoey",
"ticket": "3ad458f7-645f-447f-b217-7b3b7382f380",
"different": [
[
"224"
],
[
"u2887"
]
],
"fields": {
"224": {
"origin": "Sarah Wörz"
}
}
},
{
"name": "Max",
"ticket": "625a86ab-51d8-4612-8292-5dbd93be2a5c",
"different": [
[
"133"
],
[
"u7533"
]
]
},
{
"name": "Akiro",
"ticket": "9216ab27-407d-4f4c-abb2-51160fa33683",
"same": [
[
"u1227",
"u1865"
]
],
"different": [
[
"u1339"
],
[
"u1227"
]
],
"fields": {
"u1227": {
"dob": "2008-02-05",
"color": "Polarfuchs, hell",
"note": "Abweichendes Geburtsdatum in RennmausPro: 2008-02-06 (laut Variante C)."
}
}
},
{
"name": "Merlin",
"ticket": "d748b16a-18b5-48ec-b8a6-a4c8df66ad80",
"same": [
[
"u2859",
"u2626"
],
[
"u7537",
"u7536"
]
],
"fields": {
"u7537": {
"dob": "2021-02-10",
"note": "Abweichendes Geburtsdatum in RennmausPro: 2012-08-07 (laut Variante G)."
}
}
},
{
"name": "Snickers",
"ticket": "f469352a-644b-43a1-9fb2-884600e4b9d3",
"same": [
[
"384",
"u6937"
]
],
"fields": {
"384": {
"resident": false,
"note": "Reiner Zuchtvorfahre (kein eigenes Zuchttier)."
}
}
}
]
}