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:
@@ -89,6 +89,73 @@ namespace GerbilManager.Tests
|
||||
Assert.Equal(2, r.Placeholders.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Dedup_force_merges_variants_declared_same_by_breeder()
|
||||
{
|
||||
// Zwei Varianten, die der Auto-Dedup wegen Farbkonflikt getrennt lässt …
|
||||
var animals = new List<Rpro3Animal>
|
||||
{
|
||||
Ext("u1", "Akiro", new DateOnly(2008, 2, 5), "Polarfuchs", "Privatzucht"), // Variante B
|
||||
Ext("u2", "Akiro", new DateOnly(2008, 2, 6), "Polarfuchs, hell", "unbekannt"), // Variante C
|
||||
};
|
||||
// ohne Entscheidung: getrennt (Farbe + DOB unterschiedlich → Konflikt)
|
||||
Assert.Empty(Rpro3Dedup.Run(animals).MergeClusters);
|
||||
|
||||
// … werden per „Same"-Entscheidung der Züchterin zusammengelegt.
|
||||
var decisions = new Rpro3Decisions
|
||||
{
|
||||
Decisions = { new Rpro3Decision { Name = "Akiro", Same = { new() { "u1", "u2" } } } }
|
||||
};
|
||||
var r = Rpro3Dedup.Run(animals, decisions);
|
||||
Assert.Single(r.MergeClusters);
|
||||
Assert.Equal(2, r.MergeClusters.Values.First().Count);
|
||||
Assert.Equal(r.RidToRoot["u1"], r.RidToRoot["u2"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Dedup_force_splits_variants_declared_different_by_breeder()
|
||||
{
|
||||
// Zwei Datensätze, die der Auto-Dedup zusammenlegen würde (kompatibel) …
|
||||
var animals = new List<Rpro3Animal>
|
||||
{
|
||||
Ext("u1", "Max", new DateOnly(2013, 2, 1), "Marder", "Clan A"),
|
||||
Ext("u2", "Max", new DateOnly(2013, 2, 1), "Marder", "Clan A"),
|
||||
};
|
||||
Assert.Single(Rpro3Dedup.Run(animals).MergeClusters);
|
||||
|
||||
// … bleiben durch „Different" getrennt.
|
||||
var decisions = new Rpro3Decisions
|
||||
{
|
||||
Decisions = { new Rpro3Decision { Name = "Max", Different = { new() { "u1" }, new() { "u2" } } } }
|
||||
};
|
||||
var r = Rpro3Dedup.Run(animals, decisions);
|
||||
Assert.Empty(r.MergeClusters);
|
||||
Assert.NotEqual(r.RidToRoot["u1"], r.RidToRoot["u2"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Rpro3Decisions_load_returns_empty_when_file_missing()
|
||||
{
|
||||
var d = Rpro3Decisions.Load(Path.Combine(Path.GetTempPath(), "does-not-exist-" + Guid.NewGuid().ToString("N") + ".json"));
|
||||
Assert.Empty(d.Decisions);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Rpro3Decisions_roundtrips_through_json()
|
||||
{
|
||||
var path = Path.Combine(Path.GetTempPath(), "rpro3-dec-" + Guid.NewGuid().ToString("N") + ".json");
|
||||
File.WriteAllText(path,
|
||||
"{\"decisions\":[{\"name\":\"Bura\",\"same\":[[\"228\",\"u2128\"]],\"fields\":{\"228\":{\"origin\":\"Sarah Wörz\"}}}]}");
|
||||
try
|
||||
{
|
||||
var d = Rpro3Decisions.Load(path);
|
||||
Assert.Single(d.Decisions);
|
||||
Assert.Equal("Sarah Wörz", d.BuildFieldIndex()["228"].Origin);
|
||||
Assert.Equal(new[] { "228", "u2128" }, d.SameGroups().First());
|
||||
}
|
||||
finally { File.Delete(path); }
|
||||
}
|
||||
|
||||
// ── Integration: Reader + Execute gegen eine Mini-RPRO3-DB ────
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -36,6 +36,12 @@
|
||||
<EmbeddedResource Include="Contracts\Templates\Abgabevertrag.docx" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- Manuelle RPRO3-Dubletten-Entscheidungen der Züchterin (Rpro3Decisions) — ins Output kopieren,
|
||||
damit der Importer sie zur Laufzeit findet. -->
|
||||
<Content Update="Import\Rpro3\rpro3-decisions.json" CopyToOutputDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- FEAT-12a: Tests prüfen interne Bausteine (z. B. die Completions-URL). -->
|
||||
<InternalsVisibleTo Include="GerbilManager.Tests" />
|
||||
|
||||
112
GerbilManagerWebAPI/Import/Rpro3/Rpro3Decisions.cs
Normal file
112
GerbilManagerWebAPI/Import/Rpro3/Rpro3Decisions.cs
Normal 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; }
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
223
GerbilManagerWebAPI/Import/Rpro3/rpro3-decisions.json
Normal file
223
GerbilManagerWebAPI/Import/Rpro3/rpro3-decisions.json
Normal 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)."
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
233
tools/import/rpro3_lookup.py
Normal file
233
tools/import/rpro3_lookup.py
Normal file
@@ -0,0 +1,233 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Triage-Helfer: Beantwortet die Gegenfragen der Züchterin zu RPRO3-Namensdubletten.
|
||||
|
||||
Liefert pro Variante (A/B/C … exakt wie im Rückfrage-Ticket) die Daten, die die
|
||||
Züchterin typischerweise sehen will: Geburtsdatum, Farbe, **Gencode (Fcode)**,
|
||||
Herkunft, Eltern (Vater/Mutter) sowie Nachzucht inkl. Co-Elternteil (Partner).
|
||||
|
||||
Die Variantenbildung/Label-Vergabe spiegelt rpro3_tickets.py + compare_rpro3.dedup
|
||||
exakt (Union-Find über gleiche Namen; Filter „informativ"; Sortierung
|
||||
(not is_own, -count) → A,B,C…). Im Gegensatz zum Ticket wird hier über den GESAMTEN
|
||||
Cluster aggregiert (rpro3_tickets kappt rids auf 4 – Nachzucht wäre sonst unvollständig).
|
||||
|
||||
Aufruf:
|
||||
python rpro3_lookup.py <_rpro3.db> "<Name>" [LETTERS]
|
||||
LETTERS optional, z. B. "B,C" → nur diese Varianten. Default: alle.
|
||||
python rpro3_lookup.py <_rpro3.db> "<Name>" --json → strukturiert (für Agenten)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import sys, json
|
||||
from collections import defaultdict
|
||||
import compare_rpro3 as C
|
||||
|
||||
LETTERS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
||||
|
||||
|
||||
def informative(recs):
|
||||
return any(r["dob"] or r["farbe"] or r["origin"] for r in recs)
|
||||
|
||||
|
||||
def build_clusters(animals):
|
||||
"""Spiegelt compare_rpro3.dedup: Union-Find je Name, gibt {name: [cluster_recs,...]}."""
|
||||
def compat(x, y):
|
||||
return (not x) or (not y) or (x == y)
|
||||
|
||||
for a in animals:
|
||||
a["namek"] = C.norm_name(a["name"])
|
||||
a["dobk"] = a["dob"]
|
||||
a["farbek"] = C.norm_farbe(a["farbe"])
|
||||
a["origink"] = C.norm_origin(a["origin"])
|
||||
|
||||
by_name = defaultdict(list)
|
||||
for a in animals:
|
||||
if a["namek"] in C.PLACEHOLDER_NAMES:
|
||||
continue
|
||||
by_name[a["namek"]].append(a)
|
||||
|
||||
parent = {}
|
||||
def find(x):
|
||||
while parent[x] != x:
|
||||
parent[x] = parent[parent[x]]
|
||||
x = parent[x]
|
||||
return x
|
||||
def union(x, y):
|
||||
parent.setdefault(x, x); parent.setdefault(y, y)
|
||||
parent[find(x)] = find(y)
|
||||
|
||||
def positive(a, b):
|
||||
agree = 0
|
||||
if a["dobk"] and b["dobk"] and a["dobk"] == b["dobk"]:
|
||||
agree += 1
|
||||
if a["farbek"] and b["farbek"] and a["farbek"] == b["farbek"]:
|
||||
agree += 1
|
||||
if a["origink"] and b["origink"] and a["origink"] == b["origink"]:
|
||||
agree += 1
|
||||
return agree
|
||||
|
||||
def conflict(a, b):
|
||||
c = 0
|
||||
if a["dobk"] and b["dobk"] and a["dobk"] != b["dobk"]:
|
||||
c += 1
|
||||
if a["farbek"] and b["farbek"] and a["farbek"] != b["farbek"]:
|
||||
c += 1
|
||||
if a["origink"] and b["origink"] and a["origink"] != b["origink"]:
|
||||
c += 1
|
||||
return c
|
||||
|
||||
for name, group in by_name.items():
|
||||
for a in group:
|
||||
parent.setdefault(a["rid"], a["rid"])
|
||||
n = len(group)
|
||||
for i in range(n):
|
||||
for j in range(i + 1, n):
|
||||
a, b = group[i], group[j]
|
||||
comp = (compat(a["dobk"], b["dobk"]) and compat(a["farbek"], b["farbek"])
|
||||
and compat(a["origink"], b["origink"]))
|
||||
if comp and positive(a, b) >= 1 and conflict(a, b) == 0:
|
||||
union(a["rid"], b["rid"])
|
||||
|
||||
# Cluster je Name sammeln
|
||||
name_clusters = {}
|
||||
by_rid = {a["rid"]: a for a in animals}
|
||||
for name, group in by_name.items():
|
||||
roots = defaultdict(list)
|
||||
for a in group:
|
||||
roots[find(a["rid"])].append(a)
|
||||
name_clusters[name] = list(roots.values())
|
||||
return name_clusters
|
||||
|
||||
|
||||
def variants_for(animals, display_name):
|
||||
"""Liefert [(letter, recs)] für einen Namen – Reihenfolge wie im Ticket."""
|
||||
namek = C.norm_name(display_name)
|
||||
clusters = build_clusters(animals).get(namek, [])
|
||||
info = [recs for recs in clusters if informative(recs)]
|
||||
# Sortierung exakt wie rpro3_tickets.py: eigene Tiere zuerst, dann größere Cluster
|
||||
info.sort(key=lambda recs: (not any(r["src"] == "stamm" for r in recs), -len(recs)))
|
||||
return [(LETTERS[i], recs) for i, recs in enumerate(info)]
|
||||
|
||||
|
||||
def aggregate(recs, animals):
|
||||
"""Aggregiert Daten + Nachzucht über alle recs eines Clusters."""
|
||||
names = {r["name"] for r in recs}
|
||||
dob = sorted({C.iso(r["dob"]) for r in recs if r["dob"]})
|
||||
farbe = sorted({r["farbe"] for r in recs if r["farbe"]})
|
||||
fcode = sorted({r["fcode"] for r in recs if r["fcode"]})
|
||||
origin = sorted({r["origin"] for r in recs if r["origin"]})
|
||||
fathers = sorted({r["father"] for r in recs if r["father"]})
|
||||
mothers = sorted({r["mother"] for r in recs if r["mother"]})
|
||||
is_own = any(r["src"] == "stamm" for r in recs)
|
||||
rids = [r["rid"] for r in recs]
|
||||
|
||||
# Nachzucht: alle Tiere, deren Eltern-rid auf eine rid dieses Clusters zeigt
|
||||
rid_set = set(rids)
|
||||
kids = []
|
||||
seen = set()
|
||||
for a in animals:
|
||||
if str(a.get("mid_raw")) in rid_set or str(a.get("pid_raw")) in rid_set:
|
||||
co = a["father"] if a["mother"] in names else a["mother"]
|
||||
key = (C.norm_name(a["name"]), C.iso(a["dob"]) if a["dob"] else "")
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
kids.append({"name": a["name"], "dob": C.iso(a["dob"]) if a["dob"] else None,
|
||||
"farbe": a["farbe"], "fcode": a["fcode"], "partner": co})
|
||||
return {"names": sorted(names), "rids": rids, "count": len(recs),
|
||||
"is_own": is_own, "dob": dob, "farbe": farbe, "fcode": fcode,
|
||||
"origin": origin, "fathers": fathers, "mothers": mothers, "kids": kids}
|
||||
|
||||
|
||||
def fmt_block(letter, agg):
|
||||
src = "eigenes Tier" if agg["is_own"] else "externer Ahn"
|
||||
L = []
|
||||
L.append(f"**{letter}** ({src}, {agg['count']}× in RennmausPro):")
|
||||
L.append(f"• Geburtsdatum: {', '.join(agg['dob']) or 'unbekannt'}")
|
||||
L.append(f"• Farbe: {', '.join(agg['farbe']) or 'unbekannt'}")
|
||||
L.append(f"• Gencode: {', '.join(agg['fcode']) or 'unbekannt'}")
|
||||
L.append(f"• Herkunft: {', '.join(agg['origin']) or 'unbekannt'}")
|
||||
vat = ', '.join(agg['fathers']) or 'unbekannt'
|
||||
mut = ', '.join(agg['mothers']) or 'unbekannt'
|
||||
L.append(f"• Eltern: Vater {vat} · Mutter {mut}")
|
||||
if agg["kids"]:
|
||||
L.append("• Nachzucht:")
|
||||
for k in agg["kids"]:
|
||||
d = k["dob"] or "?"
|
||||
f = k["farbe"] or "?"
|
||||
gc = f" [{k['fcode']}]" if k["fcode"] else ""
|
||||
p = f" — Partner: {k['partner']}" if k["partner"] else ""
|
||||
L.append(f" – {k['name']} (geb. {d}, {f}{gc}){p}")
|
||||
else:
|
||||
L.append("• Nachzucht: keine in RennmausPro hinterlegt")
|
||||
return "\n".join(L)
|
||||
|
||||
|
||||
def variants_by_rids(animals, rid_groups):
|
||||
"""rid_groups: Liste von rid-Listen (eine je Variante/Buchstabe). Aggregiert pro Gruppe
|
||||
über den GANZEN Auto-Cluster (eine rid zieht ihren Cluster mit). Für Platzhalter-Namen
|
||||
(„...") und Freitext-Tickets, wo der Name nicht greift, aber rids bekannt sind."""
|
||||
from collections import defaultdict
|
||||
# Auto-Cluster wie build_clusters, aber global über rids indizieren
|
||||
clusters = build_clusters(animals)
|
||||
rid_to_recs = {}
|
||||
for recs_list in clusters.values():
|
||||
for recs in recs_list:
|
||||
for r in recs:
|
||||
rid_to_recs[r["rid"]] = recs
|
||||
out = []
|
||||
for i, grp in enumerate(rid_groups):
|
||||
merged, seen = [], set()
|
||||
for rid in grp:
|
||||
for r in rid_to_recs.get(rid, []):
|
||||
if r["rid"] not in seen:
|
||||
seen.add(r["rid"]); merged.append(r)
|
||||
if merged:
|
||||
out.append((LETTERS[i], merged))
|
||||
return out
|
||||
|
||||
|
||||
def main():
|
||||
db = sys.argv[1]
|
||||
name = sys.argv[2]
|
||||
# --rids "u84,u83;u1" → Variante A=u84,u83 Variante B=u1 (per Semikolon getrennt)
|
||||
if name == "--rids":
|
||||
rid_spec = sys.argv[3]
|
||||
R = C.load_rpro3(db)
|
||||
groups = [[x.strip() for x in g.split(",") if x.strip()] for g in rid_spec.split(";") if g.strip()]
|
||||
vs = variants_by_rids(R["animals"], groups)
|
||||
print("=== (per rids) ===")
|
||||
for letter, recs in vs:
|
||||
print(fmt_block(letter, aggregate(recs, R["animals"])))
|
||||
print()
|
||||
return
|
||||
as_json = "--json" in sys.argv[3:]
|
||||
letters = None
|
||||
for a in sys.argv[3:]:
|
||||
if a != "--json":
|
||||
letters = {x.strip().upper() for x in a.split(",") if x.strip()}
|
||||
|
||||
R = C.load_rpro3(db)
|
||||
animals = R["animals"]
|
||||
vs = variants_for(animals, name)
|
||||
if not vs:
|
||||
print(f"(keine Varianten für „{name}“ gefunden)")
|
||||
return
|
||||
out = []
|
||||
for letter, recs in vs:
|
||||
if letters and letter not in letters:
|
||||
continue
|
||||
agg = aggregate(recs, animals)
|
||||
out.append((letter, agg))
|
||||
if as_json:
|
||||
print(json.dumps({"name": name, "variants": {l: a for l, a in out}},
|
||||
ensure_ascii=False, indent=1))
|
||||
else:
|
||||
print(f"=== {name} ===")
|
||||
for letter, agg in out:
|
||||
print(fmt_block(letter, agg))
|
||||
print()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user