fix(import): Farbschlag aus Genotyp ableiten + Mamta-Eltern — Ticket-Triage

genotype.py: Python-Port von genotypeToFarbschlag (0 Abw. über 3402 Genotypen).
resolve_color_and_genotype: bei vorhandenem Genotyp gewinnt der berechnete Farbschlag
(Goldfuchs≠Gold, Dilute Agouti/Anthrazit, Blaufuchs statt -schimmel bei (schimmel),
spsp statt Schecke). Mamta Mini: Ee + Eltern Geely×Gaida am Wurf. Regressionstests je Fall.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-23 10:08:13 +02:00
parent 26977d73ff
commit 6b264a9b71
6 changed files with 532 additions and 31 deletions

View File

@@ -5,6 +5,8 @@ import uuid
import sys
from datetime import datetime
import genotype as gt
# Prevent encoding crashes on Windows consoles when printing unicode
if sys.platform.startswith('win'):
try:
@@ -422,33 +424,39 @@ def get_dedup_name_key(name):
return "".join(c for c in n if c.isalnum())
def clean_color_name(c_desc):
"""Normalise a free-text colour label to a catalog key + Schecke flag.
Returns (clean_name, is_schecke). A PARENTHETICAL „(schimmel)" is NOT a
definitive Schimmel — the breeder writes it to mean „könnte sich später als
Schimmel entpuppen" (ticket e22764aa). So we STRIP the „(…)" instead of
folding it into the name (which used to turn „Blaufuchs(schimmel)" into the
wrong „blaufuchsschimmel"); the still-uncertain Schimmel-modifier is carried
by the genotype (ee[-] = Fuchs, Schimmel unknown), not the colour label.
"""
if not c_desc:
return "", False
# Lowercase and strip
c = c_desc.lower().strip()
# Check for Schecke
is_schecke = False
if re.search(r'\bsp\b|\bsp\d|\bsp[*(²³]|\bspotted|\bschecke|[- ]sp\b|\w+sp\b', c):
is_schecke = True
# Standardize parentheticals for schimmel
c = c.replace("(schimmel)", "schimmel")
c = c.replace("(schimmel-hell)", "schimmel hell")
c = c.replace("(schimmel hell)", "schimmel hell")
# Strip schecke/sp markers and any trailing text starting from sp
c = re.sub(r'\([- ]?sp(otted)?\)', '', c) # handles (-sp)
c = re.sub(r'[- ]?sp(otted)?\b.*', '', c) # handles -sp(k), -sp*(k), -sp, etc.
c = re.sub(r'[- ]?schecke\b.*', '', c)
c = re.sub(r'[- ]?spotted\b.*', '', c)
# Strip any other parentheticals, symbols, or trailing stars/numbers
# Strip any other parentheticals (incl. „(schimmel)" = „möglich/unbestimmt"),
# symbols, or trailing stars/numbers. The parenthetical is deliberately NOT
# promoted to a definitive part of the colour name (ticket e22764aa).
c = re.sub(r'\s*\(.*?\)\s*', ' ', c)
c = re.sub(r'[²³*]', '', c)
c = c.strip()
# Mapping table for abbreviations, typos, and specific combinations
mapping = {
"antra": "anthrazit",
@@ -476,27 +484,87 @@ def clean_color_name(c_desc):
return c, is_schecke
def resolve_color_and_genotype(color_val, existing_genotype, variety_map, variety_genotypes):
if not color_val:
return None, existing_genotype
color_str = str(color_val).strip()
clean_name, is_schecke = clean_color_name(color_str)
# Match color in variety_map
color_variety_id = None
def _match_color_label(clean_name, variety_map):
"""Map a cleaned colour label to a ColorVariety id (text-only path).
Exact name wins; otherwise pick the LONGEST/most-specific substring match
(ticket 3f5942a2 — the old code broke on the FIRST substring hit, so „Goldfuchs"
matched the shorter „Gold" first). Among substring candidates the longest seed
name wins, then the longest clean_name overlap; ties broken deterministically.
"""
if not clean_name:
return None
if clean_name in variety_map:
color_variety_id = variety_map[clean_name]
else:
for seed_name, seed_id in variety_map.items():
if seed_name in clean_name or clean_name in seed_name:
color_variety_id = seed_id
break
# Update genotype if it's a Schecke
return variety_map[clean_name]
candidates = []
for seed_name, seed_id in variety_map.items():
if not seed_name:
continue
if seed_name in clean_name or clean_name in seed_name:
# Specificity score: prefer the longer seed name (more specific),
# then the closeness of lengths so „goldfuchs" beats „gold" for the
# label „goldfuchs".
candidates.append((len(seed_name), -abs(len(seed_name) - len(clean_name)),
seed_name, seed_id))
if not candidates:
return None
candidates.sort(reverse=True)
return candidates[0][3]
def resolve_color_and_genotype(color_val, existing_genotype, variety_map, variety_genotypes):
"""Resolve a gerbil's stored ColorVariety id + genotype.
GENOTYPE WINS (ticket cluster genetics-farbschlag): when a parseable genotype
is present and the genetics engine (genotype.genotype_to_farbschlag — a faithful
Python mirror of catalog.ts) computes a KNOWN catalog variety, that variety is
authoritative for colorVarietyId. The free-text colour label is only a fallback
(no genotype, or genotype resolves to „Unbekannt"). This fixes the imports where
the source label ignored a locus (dd → „Agouti" instead of „Dilute Agouti",
ee → „Gold" instead of „Goldfuchs", parenthetical „(schimmel)", …).
Returns (color_variety_id, genotype). `genotype` is the (possibly Schecke-
annotated) genotype STRING — never silently flips an explicit spsp to Spsp.
"""
if not color_val and not existing_genotype:
return None, existing_genotype
clean_name, is_schecke = clean_color_name(str(color_val).strip()) if color_val else ("", False)
# 1) Genotype-derived variety (authoritative when it resolves to a known name).
# GUARD (VORSICHTIG): only trust the genotype when it parsed CLEANLY enough to
# decide a colour — both the C and E loci must be mapped. The breeder sometimes
# writes the genotype in the COMPACT catalog notation („cchmcchm", „efef",
# „chch") which this parser leaves UNMAPPED (it expects the bracketed „c[chm]"
# form); a dropped C/E locus would silently read as wild-type and mis-recolour
# an otherwise-correct animal (e.g. Marder→Schwarz, Orangeschimmel→Agouti). When
# the parse is incomplete we keep the source text label instead.
color_variety_id = None
geno_name = None
if existing_genotype:
try:
mapped = gt.parse(existing_genotype).get("mapped8locus") or {}
except Exception:
mapped = {}
if mapped.get("C") and mapped.get("E"):
fs = gt.genotype_to_farbschlag(mapped)
if fs and fs != gt.UNKNOWN_FARBSCHLAG:
geno_name = fs
color_variety_id = variety_map.get(fs.strip().lower())
# 2) Fall back to the text label when the genotype gave nothing usable.
if not color_variety_id:
color_variety_id = _match_color_label(clean_name, variety_map)
# Update genotype if the LABEL says Schecke — but never override an explicit
# Sp-locus already present in the source genotype (ticket e09d6f22: a source
# „spsp" must NOT be flipped to „Spsp" just because the label looked scheckig;
# the source genotype is authoritative for the Sp-locus). Only ADD Spsp when
# the genotype carries no Sp token at all.
genotype = existing_genotype
if is_schecke:
if genotype:
if "spsp" in genotype:
genotype = genotype.replace("spsp", "Spsp")
elif "Spsp" not in genotype and "Sp" not in genotype:
if "Sp" not in genotype and "sp" not in genotype:
genotype = f"{genotype} Spsp".strip()
else:
canonical = variety_genotypes.get(color_variety_id)