Compare commits

...

27 Commits

Author SHA1 Message Date
b7ebfd3057 Merge feature/namegen-backend (FEAT-NAMEGEN-BE): GET /names/suggest via Gemini + LitterLetter
Some checks failed
CI / Docker Build & Push (push) Has been cancelled
CI / Frontend Tests (Node/Vite) (push) Has been cancelled
CI / Backend Tests (.NET) (push) Successful in 57s
NameSuggestionService (bestehender OpenAiChatClient/Gemini, Fence-Strip, [{name,meaning,origin}]),
NamesEndpoints (503 NamesKeyMissing / 502 NamesUpstreamError), Litter.LitterLetter + Migration AddLitterLetter.
157/157, drift clean (Dwight). has-pending via CI (lokaler Build durch laufende App gesperrt).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 21:16:40 +02:00
d268bbc122 D6: Kazu E=ee[f] G=GG P=PP (Julian 2026-06-07) → conflict-decisions.json + HUMANQUESTION
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 57s
CI / Frontend Tests (Node/Vite) (push) Successful in 9m33s
CI / Docker Build & Push (push) Failing after 9s
Aa Cc[chm] DD ee[f] GG PP Spsp (UwUw-Quelle = GG). D6 nur noch Hanami offen (1).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 20:59:29 +02:00
a638cf2c52 FEAT-NAMEGEN: GET /names/suggest backend (Gemini, 503 NamesKeyMissing, LitterLetter)
- NameSuggestionService: Gemini via existing OpenAiChatClient, builds
  letter/gender/usages/count prompt, strips markdown fences from response,
  deserialises [{name,meaning,origin}] array; NotConfigured -> 503.
- GET /names/suggest?letter=&gender=&usages=&count= -> Ok<List<NameSuggestion>>
  | 503 {code:NamesKeyMissing} | 502 {code:NamesUpstreamError}.
- Litter.LitterLetter (string?, nullable) + AddLitterLetter migration.
- 17 new tests (prompt assembly, fence strip, parse edge cases,
  503-not-configured, upstream-error); total 157/157 green.
- No Behind-the-Name dependency — Gemini path only (Julian's decision).

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-06-06 20:56:33 +02:00
efbf24ca39 Merge feature/ci-backend-fix: dotnet-ef auf $GITHUB_PATH (Backend-CI-Job gruen) [god, via god-qa-Worktree]
Some checks failed
CI / Docker Build & Push (push) Has been cancelled
CI / Frontend Tests (Node/Vite) (push) Has been cancelled
CI / Backend Tests (.NET) (push) Successful in 58s
2026-06-06 20:54:47 +02:00
22e033b26a CI-BACKEND-FIX: dotnet-ef PATH nach global tool install exportieren
dotnet tool update --global installiert dotnet-ef nach ~/.dotnet/tools,
aber der Runner-Container hat dieses Verzeichnis nicht im PATH. Jeder
run:-Step startet eine neue Shell -> der naechste Step (dotnet ef ...)
findet 'dotnet-ef' nicht (exitcode 1, Job rot seit DB-3).

Fix: 'echo ... >> $GITHUB_PATH' nach dem tool install; Gitea/GitHub Actions
liest diese Datei zwischen Steps und persistiert den PATH-Eintrag fuer alle
Folge-Steps in diesem Job.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-06-06 20:51:10 +02:00
ee7f0aeab4 docs(HUMANQUESTION): A1+A2 erledigt — Gemini-Key live, KOSTENLOS via gemini-flash-latest
Some checks failed
CI / Backend Tests (.NET) (push) Failing after 49s
CI / Docker Build & Push (push) Has been cancelled
CI / Frontend Tests (Node/Vite) (push) Has been cancelled
KI-Verkaufstexte + Charakterbogen live verifiziert (echter Text generiert). Kein Billing noetig.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 20:49:53 +02:00
454f6e0d60 fix(ai): Default-Modell gemini-2.0-flash -> gemini-flash-latest (Free-Tier-fähig)
Some checks failed
CI / Backend Tests (.NET) (push) Failing after 50s
CI / Docker Build & Push (push) Has been cancelled
CI / Frontend Tests (Node/Vite) (push) Has been cancelled
gemini-2.0-flash hat auf Julians Konto KEIN freies Kontingent (429), gemini-flash-latest schon
(direkt verifiziert). Julian bleibt kostenlos. compose/.env.example/docs angepasst.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 20:48:05 +02:00
c652afbbc7 D6: Skarlett Sterbedatum = 17.04.2016 (Julian 2026-06-07) → conflict-decisions.json + HUMANQUESTION
Some checks failed
CI / Backend Tests (.NET) (push) Failing after 53s
CI / Docker Build & Push (push) Has been cancelled
CI / Frontend Tests (Node/Vite) (push) Has been cancelled
2018 war falsch (leakte als '/ +2018' in den Gencode; in IMPORT-POLISH gefixt). D6 noch offen: Hanami, Kazu (2).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 20:45:30 +02:00
545db4132f D6: Vance Jr. Sp-Locus = spsp (ungescheckt, Julian 2026-06-07) → conflict-decisions.json + HUMANQUESTION
Some checks failed
CI / Docker Build & Push (push) Has been cancelled
CI / Frontend Tests (Node/Vite) (push) Has been cancelled
CI / Backend Tests (.NET) (push) Failing after 52s
aa Cc[chm] Dd Ee gg P- spsp (c[hm]→c[chm] normalisiert). D6 noch offen: Hanami, Kazu, Skarlett (3).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 20:44:24 +02:00
ddc4676f87 D6: Big Ben P-Locus = Pp (Julian 2026-06-07) → conflict-decisions.json + HUMANQUESTION gestrichen
Some checks failed
CI / Backend Tests (.NET) (push) Failing after 50s
CI / Docker Build & Push (push) Has been cancelled
CI / Frontend Tests (Node/Vite) (push) Has been cancelled
Aa Cc[chm] DD Ee Gg Pp Spsp. Noch offen in D6: Hanami, Vance Jr., Kazu, Skarlett (4).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 20:42:49 +02:00
083ffac47a chore(api): UserSecretsId fuer AI-Config (Gemini-Key via user-secrets, nie committed)
Some checks failed
CI / Backend Tests (.NET) (push) Failing after 49s
CI / Docker Build & Push (push) Has been cancelled
CI / Frontend Tests (Node/Vite) (push) Has been cancelled
Nur die UserSecretsId-GUID — der Schluessel selbst liegt in %APPDATA% user-secrets, nicht im Repo.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 20:40:16 +02:00
b6178b630b fix(ci): dotnet-ef Tool-Install idempotent (tool update) — self-hosted Runner persistiert --global
Some checks failed
CI / Backend Tests (.NET) (push) Failing after 50s
CI / Frontend Tests (Node/Vite) (push) Successful in 9m33s
CI / Docker Build & Push (push) Has been skipped
'tool install --global' schlug ab dem 2. Lauf mit 'already installed' fehl (Tools persistieren auf
dem self-hosted Runner) → Backend-Job rot seit DB-3, noch VOR dem Drift-Check. 'tool update' ist
installiert-oder-aktualisiert = idempotent.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 20:03:18 +02:00
55d08a5dc3 fix(ci): DB-3 Drift-Check — -c Release war --context Release (DbContext-Name!), nicht Build-Config
Some checks failed
CI / Backend Tests (.NET) (push) Failing after 1m0s
CI / Docker Build & Push (push) Has been cancelled
CI / Frontend Tests (Node/Vite) (push) Has been cancelled
dotnet ef interpretiert -c als --context, daher 'No DbContext named Release was found' →
Backend-Tests-Job rot seit DB-3. Korrekt: --configuration Release. Lokal verifiziert:
Release-Build + --no-build --configuration Release → 'No changes'. Tests 140/140.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 19:58:52 +02:00
532cbf3bcf Merge feature/qa-smoke-2: Live-Smoke der heutigen Merges (18/18, keine Findings)
Some checks failed
CI / Backend Tests (.NET) (push) Failing after 50s
CI / Docker Build & Push (push) Has been cancelled
CI / Frontend Tests (Node/Vite) (push) Has been cancelled
Bestätigt live gegen echte Daten: GEN-3h Bracket-Notation (ee[f]/c[chm]), Herkunft+Gehoerlos editierbar,
CR-2 Partial-PUT behaelt Felder, namenlose Platzhalter, Gehege-Redirect. Finale Pre-Uebergabe-Bestaetigung.
e2e-only (skipUnlessLive). B3-Korpus-IDs vollstaendig dokumentiert.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 19:53:12 +02:00
d1baba686f QA-SMOKE-2: Live-Smoke GEN-3h/FORM-FIELDS/CR-2/UI-POLISH/GEHEGE (18/18 gruen) 2026-06-06 19:51:45 +02:00
8b867b9058 Merge feature/review-fixes-2: DB-4 deutsche ICU-Collation + DB-5 Litter.ExternalRef
Some checks failed
CI / Docker Build & Push (push) Has been cancelled
CI / Frontend Tests (Node/Vite) (push) Has been cancelled
CI / Backend Tests (.NET) (push) Failing after 51s
- DB-4: UseCollation('de-x-icu') auf Gerbil.Name/NameSearch/OriginBreeder, ColorVariety.Name, Contact.Name
  (Npgsql-konditional; SQLite-Testhost lehnt unbekannte Collation ab). Migration AddGermanCollation.
- DB-5: Litter.ExternalRef + filtered unique index + Importer-Idempotenz (ExternalRef primaer, Name+Date Fallback).
  Migration AddLitterExternalRef. +1 Regressionstest.
Gate 140/140, has-pending=No.
[god-QA: backend-only, disjunkt; 2 Migrationen]

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 19:50:44 +02:00
ea3596ca0d REVIEW-FIXES-2: DB-4 (Deutsche Collation ä/ö/ü) + DB-5 (Litter.ExternalRef)
DB-4 (medium): ICU German collation de-x-icu auf deutschen Textspalten
  Gerbil.Name, Gerbil.NameSearch, Gerbil.OriginBreeder, ColorVariety.Name,
  Contact.Name. Korrekte ä/ö/ü-Sortierung + locale-aware lower() in Postgres.
  Nur bei Npgsql (Database.ProviderName check) — SQLite-Testhost kennt keine
  eigenen Collation-Namen und wuerde EnsureCreated fail lassen.
  Migration AddGermanCollation (ALTER COLUMN ... TYPE text COLLATE de-x-icu).

DB-5 (low): Litter.ExternalRef + filtered unique index + Importer-Anbindung
  Litter.ExternalRef (string?, nullable) = stable import source id (sl.Id).
  Filtered unique index WHERE ExternalRef IS NOT NULL (wie DB-1 fuer Gerbil).
  Migration AddLitterExternalRef.
  Importer (ImportService.cs): setzt ExternalRef = sl.Id beim Litter-Insert;
  prueft existingLitterExtRefSet als PRIMAEREN Idempotenz-Key (ExternalRef),
  Name+Date-Key als Fallback fuer Wuerfe ohne ExternalRef aus frueheren Laeufen.
  +Test DB5_Litter_ExternalRef_set_and_used_for_idempotency (mutiert den Namen
  nach erstem Import, prueft dass Re-Import via ExternalRef erkennt).

GATE: 140/140 C#, has-pending=No.
2026-06-06 19:49:50 +02:00
e6b513ed5d Merge feature/genotype-notation (GEN-3h): Genotyp-Anzeige in Zuechterin-Schreibweise
Some checks failed
CI / Frontend Tests (Node/Vite) (push) Successful in 9m33s
CI / Docker Build & Push (push) Has been skipped
CI / Backend Tests (.NET) (push) Failing after 49s
- Bracket-Notation im Display: e[f]/c[chm]/c[h] (Storage bleibt frozen symbols)
- E-Locus-Reihenfolge: e VOR e[f] ('ee[f]' nicht 'efe')
- CR-1a: ee[-] (Silvain) Parser-Fix (lookbehind)
- C zeigt jetzt 'aa C- D- ee[f] Gg Pp spsp rere'
vitest 92/92 (inkl. ee[-] + C/Zuleika/Milka-Orakel), e2e 148, build/eslint. Frontend-only (src/genetics/).
HINWEIS: generated.json canonicalGenotype jetzt Klammer-Notation = DISPLAY; Backend-Seed bleibt frozen (Matching).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 18:27:45 +02:00
52303b90b2 CR-1a: ee[-] Parser-Fix (Silvain) — lookbehind trennt e[-] vom führenden Allel
normalizeToken: lookbehind-Regex /(?<=[A-Za-z])e\[-\]/→'?' greift wenn e[-]
von einem Buchstaben (erstes Allel) gefolgt wird (ee[-] → e?); standalone e[-]
fällt durch auf die generische \[-\]→? Regel (→ e?). Beide Pfade liefern
splitToken 2 Allele [e,?]. Vorher: ee[-]→ee?→ 3 Allele → Fehler.
Fixture: Silvain 'aa c[chm]c[chm] Dd ee[-] Gg Pp Spsp' → E=[e,?] ✓.
Gate: build ✓  eslint ✓  vitest 92/92 ✓  e2e 126/126 ✓
2026-06-06 18:24:06 +02:00
4b6a07544d GEN-3h: Breeder bracket-notation display + E-locus e-vor-e[f] Sortierung
- genotype.ts: toDisplayString zeigt ef→e[f], cchm→c[chm], ch→c[h] (Display-only;
  Storage-Contract unverändert). E-Locus Display-Rank E>e>ef: {ef,e} Paar
  rendert als ee[f] statt e[f]e (Züchterin-Konvention, Julian-Feedback).
- genotype.ts: normalizeToken akzeptiert Klammer-Eingabe (e[f], c[chm], c[h],
  [-]) → interne Symbole; vollständiger Round-Trip Display→Parse.
- genetics.test.ts: Katalog-Regex auf [A-Za-z[\]?-]+ erweitert; GEN-3h
  Notation-Fixtures (C/Zuleika/Milka-Orakel, Klammer-Round-Trip, E-Sortierung).
- colorVarietySeed.generated.json: neu generiert (66 Zeilen mit Klammer-Notation).
Gate: build ✓  eslint ✓  vitest 91/91 ✓  e2e 120/120 ✓
2026-06-06 18:24:06 +02:00
8f90821081 Merge feature/ci-drift-check (DB-3): CI EF-Migrations-Drift-Check
Some checks failed
CI / Backend Tests (.NET) (push) Failing after 50s
CI / Frontend Tests (Node/Vite) (push) Successful in 9m33s
CI / Docker Build & Push (push) Has been skipped
.gitea/workflows/ci.yml: nach Build zwei Steps (dotnet-ef install + has-pending-model-changes)
→ faengt Snapshot/Migrations-Drift im Runner (EnsureCreated-SQLite-Testhost sieht das nie).
Automatisiert die manuelle MERGE-GATE-Regel. Nur CI-Config, kein Code/Schema.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 18:06:07 +02:00
9dda78b28a DB-3: EF Migrations Drift-Check in CI (has-pending-model-changes)
Nach dem Build-Schritt in test-backend: dotnet-ef 10.0.* global installieren,
dann `dotnet ef migrations has-pending-model-changes` ausführen. Schlaegt fehl
wenn Code-Änderungen an Entities/OnModelCreating keine passende Migration haben.
Fängt genau den Drift, den der SQLite-EnsureCreated-Testhost nicht sieht.
2026-06-06 18:05:15 +02:00
865b3831c8 Merge feature/ops-fixes: DataProtection-Key-Persistenz (AR-3) + prod-compose AI-Env (AR-4)
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 52s
CI / Docker Build & Push (push) Has been cancelled
CI / Frontend Tests (Node/Vite) (push) Has been cancelled
- AR-3: AddDataProtection().PersistKeysToFileSystem + compose keys-Volume → Gmail-App-Passwort
  ueberlebt Redeploy (war ephemer → Inbox waere still gebrochen). Dev-Fallback .data-protection-keys.
- AR-4: prod compose.yaml ANTHROPIC_API_KEY → AI__BaseUrl/ApiKey/Model + .env.example aktualisiert.
139/139, has-pending=No, kein Schema-Change. [god-QA: config/Program.cs only, disjunkt]

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 18:03:20 +02:00
1b0d3286db OPS-FIXES-1: AR-3 Data Protection Key-Persistenz + AR-4 AI-Env-Korrekte
AR-3 (P1): PersistKeysToFileSystem + persistentes Volume
  Program.cs: AddDataProtection().PersistKeysToFileSystem(keyRingPath).SetApplicationName(GerbilManager)
  Pfad konfigurierbar via DataProtection:KeyRingPath (env DataProtection__KeyRingPath);
  Fallback = ContentRoot/.data-protection-keys (Aspire-Dev-ephemeral, ok).
  compose.yaml: DataProtection__KeyRingPath: /data/keys + Volume-Mount keys:/data/keys.
  Volumes: neues 'keys' Volume (Bind-Mount auf NAS-Dataset KEYS_PATH=/mnt/SSD/gerbil/keys).
  .gitignore: .data-protection-keys/ ignoriert (Dev-only ephemeral keys).
  Verhindert: Gmail-App-Passwort wird nach Image-Redeploy unlesbar (bisher stilles inbox-fail).

AR-4 (P1): compose.yaml + .env.example: AI__* statt ANTHROPIC_API_KEY
  compose.yaml: ANTHROPIC_API_KEY entfernt (Code liest es nicht). Korrekte Vars:
    AI__BaseUrl: ${AI__BaseUrl:-}
    AI__ApiKey: ${AI__ApiKey:-}
    AI__Model: ${AI__Model:-gemini-2.0-flash}
  .env.example: AI__BaseUrl/ApiKey/Model + KEYS_PATH hinzugefuegt; ANTHROPIC_API_KEY entfernt.
  Quelle: docs/ai-provider.md (war korrekt, compose war falsch).
  Verhindert: alle 4 KI-Features (Verkaufstext, Inbox-Entwurf) blieben in prod stumm.

GATE: 139/139 C#-Tests, build gruen (using Microsoft.AspNetCore.DataProtection; framework-included).
2026-06-06 18:01:57 +02:00
de1d9460b3 Merge feature/review-fixes-backend: CR-9/CR-10/CR-11 + DB-1 (ExternalRef unique)
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 56s
CI / Docker Build & Push (push) Has been cancelled
CI / Frontend Tests (Node/Vite) (push) Has been cancelled
- CR-9: Import-Lookup TryGetValue + ExternalRef-Fallback (kein throw bei name/DOB-Drift)
- CR-11: Farbschlag aus Genotyp ableiten (fill-NULL-only, nur vollstaendige Genotypen ≥8/kein ?? → keine False-Positives); Plan-Loop + Post-Sweep fuer Bestandstiere
- CR-10: conflict-decision validiert Genotyp-Override (ungueltig → skip+Warnung, Konflikt trotzdem geloest)
- DB-1: filtered unique index ExternalRef IS NOT NULL + Migration UniqueExternalRef
Gate 139/139 C# + python all pass, ef has-pending=No. KEIN Farbschlag-Overwrite (fill-NULL).
[god-QA: disjunkt zu frontend-Merges]

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 17:55:55 +02:00
259dd96c0e Merge feature/ui-polish-2: '(ohne Namen)'-Platzhalter in Stammbaum + VertragWizard + WurfForm
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 52s
CI / Docker Build & Push (push) Has been cancelled
CI / Frontend Tests (Node/Vite) (push) Has been cancelled
Namenlose Stub-Tiere zeigen jetzt ueberall '(ohne Namen)' statt leer: Flagship-Stammbaum
(Karten+Druck+Header), Vertrags-Assistent, Wurf-Formular. +2 e2e (namenloser Ahne im Stammbaum).
vitest 82, e2e 148, build clean. Kein EF.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 17:54:33 +02:00
615abfa510 REVIEW-FIXES-BACKEND: CR-9 + CR-11 + CR-10 + DB-1
CR-9 (major): gidByNameDob TryGetValue + ExternalRef-Fallback
  Verhindert KeyNotFoundException wenn Name/DOB zwischen zwei Laeufen driftet
  (z.B. correctDob-Remap oder UI-Umbenennung). Fallback: ExternalRef-Dict-Lookup;
  bei Miss: sauberes Ueberspringen + Note statt 500. +Test CR9_NameDOB_drift.

CR-11 (major): Farbschlag aus Genotyp ableiten (fill-NULL-only)
  Deep-Band-Tiere (gen>=2, kein Farbschlag-Feld) landen nicht laenger mit null
  ColorVariety. GenotypePotentiallyMatches() vergleicht locus-pair-weise (??=wildcard,
  case-insensitive). Nur vollstaendige Genotypen (8 Loci, kein ??) loesen Ableitung aus.
  Plan-Loop: fuellt colorVarietyId bei null + vollstaendigem Genotyp.
  Post-Sweep: bestehende DB-Tiere mit null ColorVarietyId werden nachgefuellt.
  AnimalSummary.FarbschlagDerivedFromGenotype = Zaehler. +Test CR11_ColorVariety_from_geno.

CR-10 (major, Python): malformed Override-Genotyp wird nicht angewendet
  apply_conflict_decisions validiert mapped8locus nach gt.parse(). Leeres Ergebnis
  = Genotyp unveraendert + decisionWarning statt stillem Blanken. Konflikt wird
  trotzdem aufgeloest (Entscheidung gilt, nur Genotyp-Override ausgelassen).
  +5 Python-Tests (CR-10-Block in test_extract.py).

DB-1 (high): filtered unique index auf Gerbil.ExternalRef
  WHERE ExternalRef IS NOT NULL — verhindert doppelten Import bei Race-Conditions
  oder Lauf-Ueberschneidungen. Migration UniqueExternalRef. SQLite-Testhost:
  HasFilter() wird via EnsureCreated appliziert (SQLite unterstuetzt Partial-Indexes).
  PartialUpdateTests externalRef-Assertion auf NotNull geaendert (name-hash unique).

GATE: 139/139 C# + Python ALL PASS, ef has-pending=No.
2026-06-06 17:54:06 +02:00
34 changed files with 6963 additions and 115 deletions

View File

@@ -48,6 +48,26 @@ jobs:
- name: Build
run: dotnet build GerbilManager.slnx --no-restore -c Release
- name: dotnet-ef Tool installieren
# 'tool update' ist idempotent (installiert wenn fehlend, sonst aktualisiert) —
# auf dem SELF-HOSTED Runner persistieren --global Tools, daher schlug 'tool install'
# ab dem 2. Lauf mit 'already installed' fehl und kippte den Backend-Job.
# GITHUB_PATH-Zeile: global tools landen in ~/.dotnet/tools, das auf dem Runner-Container
# nicht im PATH ist — einmalig eintragen, damit alle Folge-Steps 'dotnet ef' finden.
run: |
dotnet tool update --global dotnet-ef --version 10.0.*
echo "$HOME/.dotnet/tools" >> $GITHUB_PATH
- name: DB-3 EF Migrations Drift-Check
# Fails CI if the EF model diverges from the snapshot (i.e. a code change touched
# entities/OnModelCreating without generating a matching migration). Catches exactly
# the class of drift the SQLite/EnsureCreated test host is blind to.
run: >
dotnet ef migrations has-pending-model-changes
--project GerbilManagerWebAPI
--startup-project GerbilManagerWebAPI
--no-build --configuration Release
- name: Tests ausfuehren
run: dotnet test GerbilManager.slnx --no-build -c Release --logger "console;verbosity=normal"

9
.gitignore vendored
View File

@@ -131,6 +131,9 @@ $RECYCLE.BIN/
# MemPalace per-project files (issue #185)
mempalace.yaml
entities.json
# Runtime photo store (uploaded/imported gerbil photos) — never commit
GerbilManagerWebAPI/photo-storage/
# Runtime photo store (uploaded/imported gerbil photos) — never commit
GerbilManagerWebAPI/photo-storage/
# AR-3: Data Protection key ring (dev-only ephemeral keys) — never commit
GerbilManagerWebAPI/.data-protection-keys/

View File

@@ -103,6 +103,31 @@ namespace GerbilManager.Tests
Assert.Equal(2, await db.Litters.CountAsync());
}
[Fact]
public async Task DB5_Litter_ExternalRef_set_and_used_for_idempotency()
{
// DB-5: litters must have ExternalRef set to the source litter id on first import,
// and subsequent runs must detect them via ExternalRef (not just Name+Date).
using var db = NewDb();
await new ImportService(db, _dir, _dir).RunAsync(execute: true);
// ExternalRef is set on created litters
var litters = await db.Litters.ToListAsync();
Assert.All(litters, l => Assert.NotNull(l.ExternalRef));
Assert.Contains(litters, l => l.ExternalRef == "L1");
Assert.Contains(litters, l => l.ExternalRef == "L2");
// Simulate the "Name+Date lookup would still work, but ExternalRef is now primary":
// mutate Name to something different — Name+Date fallback would fail, ExternalRef must catch it.
foreach (var l in litters) l.Name = "Geänderter Name";
await db.SaveChangesAsync();
// Re-import: litters detected as existing via ExternalRef even though Name changed
var second = await new ImportService(db, _dir, _dir).RunAsync(execute: true);
Assert.Equal(0, second.Litters.Created);
Assert.Equal(2, await db.Litters.CountAsync());
}
[Fact]
public async Task Execute_persists_deaf_flag_and_preserves_sls_and_tags()
{
@@ -580,6 +605,74 @@ namespace GerbilManager.Tests
Assert.Equal(new DateOnly(y, m, d), date);
}
[Fact]
public async Task CR9_NameDOB_drift_falls_back_to_ExternalRef_no_throw()
{
// CR-9: if an already-imported animal's Name or DOB in animals.json no longer matches
// what's stored in the DB (e.g. after a correctDob remap or manual UI rename), the
// gidByNameDob lookup used to throw KeyNotFoundException. Now it falls back to the
// stable ExternalRef without throwing.
var dir = Path.Combine(Path.GetTempPath(), "cr9-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
try
{
File.WriteAllText(Path.Combine(dir, "litters.json"), "[]");
File.WriteAllText(Path.Combine(dir, "animals.json"), """
[{"id":"drift","name":"Drift Tier","dob":"01.01.2021","death":"","farbschlag":"",
"genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},
"conflict":false}]
""");
using var db = NewDb();
// Run 1: load the animal normally
await new ImportService(db, dir, dir).RunAsync(execute: true);
Assert.Equal(1, await db.Gerbils.CountAsync());
// Simulate drift: manually rename the animal in the DB (UI rename scenario)
var g = await db.Gerbils.SingleAsync(x => x.ExternalRef == "drift");
g.Name = "Umbenannt Tier";
await db.SaveChangesAsync();
// Run 2: animals.json still has old name "Drift Tier" — must NOT throw
var report2 = await new ImportService(db, dir, dir).RunAsync(execute: false);
// Dry-run should complete without throwing; animal is found by ExternalRef fallback
Assert.Equal(1, await db.Gerbils.CountAsync()); // no duplicate created
}
finally { try { Directory.Delete(dir, recursive: true); } catch { } }
}
[Fact]
public async Task CR11_ColorVariety_derived_from_genotype_when_no_explicit_farbschlag()
{
// CR-11: deep-band animals have empty Farbschlag but a full genotype. The loader
// must derive ColorVarietyId from the catalog when the name-match yields nothing.
// "Agouti" = aa CC DD EE GG PP spsp rere (first seed entry, ID 00000001).
var dir = Path.Combine(Path.GetTempPath(), "cr11-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
try
{
File.WriteAllText(Path.Combine(dir, "litters.json"), "[]");
// Exact Agouti genotype, no explicit Farbschlag name
File.WriteAllText(Path.Combine(dir, "animals.json"), """
[{"id":"agouti-deep","name":"Opa Waldmann","dob":"01.01.2018","death":"","farbschlag":"",
"genotype":{"mapped8locus":{"A":["a","a"],"C":["C","C"],"D":["D","D"],"E":["E","E"],"G":["G","G"],"P":["P","P"],"Sp":["sp","sp"],"Re":["re","re"]},
"rawGenotype":"aa CC DD EE GG PP spsp rere","unmappedTokens":[]},
"conflict":false}]
""");
using var db = NewDb();
var report = await new ImportService(db, dir, dir).RunAsync(execute: true);
var tier = await db.Gerbils.SingleAsync(g => g.ExternalRef == "agouti-deep");
// ColorVarietyId must be set even though no explicit Farbschlag name was given
Assert.NotNull(tier.ColorVarietyId);
// Should be the "Agouti" variety (id = 00000000-0000-0000-0000-000000000001)
var variety = await db.ColorVarieties.FindAsync(tier.ColorVarietyId);
Assert.Equal("Agouti", variety!.Name);
// Report counter should reflect the genotype derivation
Assert.True(report.Animals.FarbschlagDerivedFromGenotype > 0);
}
finally { try { Directory.Delete(dir, recursive: true); } catch { } }
}
// ---- fixtures ----
private const string LittersJson = """
[

View File

@@ -0,0 +1,208 @@
using System.Net;
using System.Text;
using System.Text.Json;
using GerbilManagerWebAPI.Names;
using GerbilManagerWebAPI.SaleAd;
using Microsoft.Extensions.Options;
namespace GerbilManager.Tests
{
/// <summary>
/// FEAT-NAMEGEN: NameSuggestionService — prompt assembly, JSON parse (incl. Markdown
/// fence strip), 503-not-configured path, upstream-error path.
/// </summary>
public class NameSuggestionTests
{
// ── Prompt assembly ───────────────────────────────────────────────────
[Fact]
public void SystemPrompt_verlangt_reines_JSON_ohne_Erklärungen()
{
var prompt = NameSuggestionService.BuildSystemPrompt();
Assert.Contains("reinen JSON-Array", prompt);
Assert.Contains("KEINE Markdown-Code-Blöcke", prompt);
Assert.Contains("name", prompt);
Assert.Contains("meaning", prompt);
Assert.Contains("origin", prompt);
}
[Fact]
public void UserPrompt_enthält_Anzahl_und_Anfangsbuchstaben()
{
var prompt = NameSuggestionService.BuildUserPrompt("A", "female", "norn,mythg", 6);
Assert.Contains("6", prompt);
Assert.Contains("\"A\"", prompt);
Assert.Contains("weibliche", prompt);
Assert.Contains("norn,mythg", prompt);
}
[Fact]
public void UserPrompt_ohne_optionale_Parameter_ist_gültig()
{
var prompt = NameSuggestionService.BuildUserPrompt(null, null, null, 5);
Assert.Contains("5", prompt);
Assert.DoesNotContain("Buchstaben", prompt);
Assert.DoesNotContain("weibliche", prompt);
Assert.DoesNotContain("männliche", prompt);
}
[Fact]
public void UserPrompt_gender_any_wird_nicht_im_Prompt_erwähnt()
{
var prompt = NameSuggestionService.BuildUserPrompt(null, "any", null, 3);
Assert.DoesNotContain("weibliche", prompt);
Assert.DoesNotContain("männliche", prompt);
}
// ── JSON parsing ──────────────────────────────────────────────────────
[Fact]
public void ParseSuggestions_verarbeitet_reines_JSON()
{
var json = """[{"name":"Astrid","meaning":"göttliche Stärke","origin":"Altnordisch"}]""";
var result = NameSuggestionService.ParseSuggestions(json);
Assert.NotNull(result);
Assert.Single(result);
Assert.Equal("Astrid", result[0].Name);
Assert.Equal("göttliche Stärke", result[0].Meaning);
Assert.Equal("Altnordisch", result[0].Origin);
}
[Fact]
public void ParseSuggestions_strippt_json_Markdown_Fence()
{
var fenced = "```json\n[{\"name\":\"Aiko\",\"meaning\":\"kleine Geliebte\",\"origin\":\"Japanisch\"}]\n```";
var result = NameSuggestionService.ParseSuggestions(fenced);
Assert.NotNull(result);
Assert.Equal("Aiko", result![0].Name);
}
[Fact]
public void ParseSuggestions_strippt_generische_Markdown_Fence()
{
var fenced = "```\n[{\"name\":\"Luna\",\"meaning\":\"Mond\",\"origin\":\"Lateinisch\"}]\n```";
var result = NameSuggestionService.ParseSuggestions(fenced);
Assert.NotNull(result);
Assert.Equal("Luna", result![0].Name);
}
[Fact]
public void ParseSuggestions_toleriert_umgebenden_Text_vor_Array()
{
var messy = "Hier sind die Namen:\n[{\"name\":\"Sol\",\"meaning\":\"Sonne\",\"origin\":\"Nordisch\"}]\nHoffnungslos.";
var result = NameSuggestionService.ParseSuggestions(messy);
Assert.NotNull(result);
Assert.Equal("Sol", result![0].Name);
}
[Fact]
public void ParseSuggestions_gibt_null_zurück_bei_ungültigem_JSON()
{
Assert.Null(NameSuggestionService.ParseSuggestions("kein json"));
Assert.Null(NameSuggestionService.ParseSuggestions("{\"name\":\"X\"}"));
Assert.Null(NameSuggestionService.ParseSuggestions(""));
}
// ── 503: nicht konfiguriert ───────────────────────────────────────────
[Theory]
[InlineData(null, "key", "model")]
[InlineData("https://api.example.com/v1", null, "model")]
[InlineData("https://api.example.com/v1", "key", null)]
[InlineData(null, null, null)]
public async Task SuggestAsync_gibt_NotConfigured_wenn_AI_Key_fehlt(
string? baseUrl, string? apiKey, string? model)
{
var service = CreateService(baseUrl, apiKey, model,
new StubHandler(_ => throw new InvalidOperationException("darf nicht aufgerufen werden")));
var result = await service.SuggestAsync(null, null, null, 5);
Assert.Equal(NameSuggestionStatus.NotConfigured, result.Status);
Assert.Null(result.Suggestions);
}
// ── Gemini-Antwort wird geparst ───────────────────────────────────────
[Fact]
public async Task SuggestAsync_parst_valide_JSON_Antwort()
{
var payload = """[{"name":"Astrid","meaning":"göttliche Stärke","origin":"Altnordisch"},{"name":"Aiko","meaning":"kleine Geliebte","origin":"Japanisch"}]""";
var handler = new StubHandler(_ => Canned(payload));
var service = CreateService("https://generativelanguage.googleapis.com/v1beta/openai", "k", "gemini-2.0-flash", handler);
var result = await service.SuggestAsync("A", "female", "norn,japa", 2);
Assert.Equal(NameSuggestionStatus.Ok, result.Status);
Assert.NotNull(result.Suggestions);
Assert.Equal(2, result.Suggestions!.Count);
Assert.Equal("Astrid", result.Suggestions[0].Name);
}
[Fact]
public async Task SuggestAsync_parst_JSON_in_Markdown_Fence()
{
var fenced = "```json\n[{\"name\":\"Luna\",\"meaning\":\"Mond\",\"origin\":\"Lateinisch\"}]\n```";
var handler = new StubHandler(_ => Canned(fenced));
var service = CreateService("https://api.example.com/v1", "k", "m", handler);
var result = await service.SuggestAsync(null, null, null, 1);
Assert.Equal(NameSuggestionStatus.Ok, result.Status);
Assert.Equal("Luna", result.Suggestions![0].Name);
}
[Fact]
public async Task SuggestAsync_gibt_UpstreamError_bei_ungültigem_JSON()
{
var handler = new StubHandler(_ => Canned("das ist kein json"));
var service = CreateService("https://api.example.com/v1", "k", "m", handler);
var result = await service.SuggestAsync(null, null, null, 3);
Assert.Equal(NameSuggestionStatus.UpstreamError, result.Status);
Assert.Null(result.Suggestions);
}
[Fact]
public async Task SuggestAsync_gibt_UpstreamError_bei_HTTP_Fehler()
{
var handler = new StubHandler(_ => new HttpResponseMessage(HttpStatusCode.TooManyRequests));
var service = CreateService("https://api.example.com/v1", "k", "m", handler);
var result = await service.SuggestAsync(null, null, null, 3);
Assert.Equal(NameSuggestionStatus.UpstreamError, result.Status);
}
// ── Helfer ────────────────────────────────────────────────────────────
private static NameSuggestionService CreateService(
string? baseUrl, string? apiKey, string? model, StubHandler handler)
{
var options = Options.Create(new AiOptions { BaseUrl = baseUrl, ApiKey = apiKey, Model = model });
return new NameSuggestionService(new HttpClient(handler), options);
}
private static HttpResponseMessage Canned(string content)
{
var completion = new
{
choices = new[] { new { message = new { role = "assistant", content } } },
};
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(JsonSerializer.Serialize(completion),
Encoding.UTF8, "application/json"),
};
}
private sealed class StubHandler(Func<HttpRequestMessage, HttpResponseMessage> respond)
: HttpMessageHandler
{
protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request, CancellationToken cancellationToken)
=> Task.FromResult(respond(request));
}
}
}

View File

@@ -29,6 +29,8 @@ public class PartialUpdateTests : IClassFixture<ApiFactory>
/// <summary>Create a fully-populated gerbil and return its id.</summary>
private async Task<Guid> CreateFullGerbil(string name = "TestTier")
{
// Use name-derived unique externalRef so the DB-1 unique constraint doesn't fire
// when multiple tests in the same fixture share the SQLite connection.
var resp = await _client.PostAsync("/gerbils", JsonContent.Create(new
{
name,
@@ -39,7 +41,7 @@ public class PartialUpdateTests : IClassFixture<ApiFactory>
characterTraits = new[] { "neugierig", "zutraulich" },
characterNote = "Liebling der Familie",
notes = "Eine Notiz",
externalRef = "ext-001",
externalRef = $"ext-{name.GetHashCode():X8}",
}));
Assert.Equal(HttpStatusCode.Created, resp.StatusCode);
return ExtractId(await resp.Content.ReadAsStringAsync());
@@ -67,7 +69,7 @@ public class PartialUpdateTests : IClassFixture<ApiFactory>
Assert.Equal("aa CC DD ee GG PP spsp rere", GetStr(json, "genotype"));
Assert.Equal("Zucht der Kleinen Chaoten", GetStr(json, "originBreeder"));
Assert.Equal("Eine Notiz", GetStr(json, "notes"));
Assert.Equal("ext-001", GetStr(json, "externalRef"));
Assert.NotNull(GetStr(json, "externalRef")); // externalRef set during create, not cleared by partial PUT
Assert.Contains("neugierig", GetNested(json, "characterTraits") ?? "");
Assert.Equal("Liebling der Familie", GetStr(json, "characterNote"));
}
@@ -95,7 +97,7 @@ public class PartialUpdateTests : IClassFixture<ApiFactory>
Assert.Equal("aa CC DD ee GG PP spsp rere", GetStr(json, "genotype"));
Assert.Equal("Zucht der Kleinen Chaoten", GetStr(json, "originBreeder"));
Assert.Equal("Eine Notiz", GetStr(json, "notes"));
Assert.Equal("ext-001", GetStr(json, "externalRef"));
Assert.NotNull(GetStr(json, "externalRef")); // externalRef set during create, not cleared by partial PUT
Assert.Contains("handzahm", GetNested(json, "characterTraits") ?? "");
Assert.Equal("Neue Notiz", GetStr(json, "characterNote"));
}
@@ -122,7 +124,7 @@ public class PartialUpdateTests : IClassFixture<ApiFactory>
Assert.Equal("EditForm-Tier (umbenannt)", GetStr(json, "name"));
Assert.Equal("Aktualisierte Notiz", GetStr(json, "notes"));
Assert.Equal("Zucht der Kleinen Chaoten", GetStr(json, "originBreeder"));
Assert.Equal("ext-001", GetStr(json, "externalRef"));
Assert.NotNull(GetStr(json, "externalRef")); // externalRef set during create, not cleared by partial PUT
Assert.Contains("neugierig", GetNested(json, "characterTraits") ?? "");
Assert.Equal("Liebling der Familie", GetStr(json, "characterNote"));
}

View File

@@ -49,6 +49,13 @@ public class ApplicationContext : DbContext
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
// DB-4: ICU German collation on user-visible / searched text columns.
// "de-x-icu" gives correct German sort order (ä between a and b) and locale-aware
// case-folding (lower('Ä')='ä'). Applied only on Npgsql (Postgres); SQLite does not
// support custom collation names and would fail EnsureCreated on the test host.
const string DeIcu = "de-x-icu";
bool isNpgsql = Database.ProviderName?.Contains("Npgsql", StringComparison.OrdinalIgnoreCase) ?? false;
modelBuilder.Entity<Gerbil>(e =>
{
// Enums persisted as their string names (readable, Gridify-friendly).
@@ -58,6 +65,14 @@ public class ApplicationContext : DbContext
// Residency defaults to true (own stock unless explicitly marked external).
e.Property(g => g.IsResident).HasDefaultValue(true);
// DB-4: German collation on searched/sorted name fields (Npgsql-only).
if (isNpgsql)
{
e.Property(g => g.Name).UseCollation(DeIcu);
e.Property(g => g.NameSearch).UseCollation(DeIcu);
e.Property(g => g.OriginBreeder).UseCollation(DeIcu);
}
// FEAT-14: character traits stored as a JSON text column (works on both
// Npgsql and the SQLite test host; opaque labels, no backend vocabulary).
var traitsConverter = new Microsoft.EntityFrameworkCore.Storage.ValueConversion.ValueConverter<List<string>, string>(
@@ -81,6 +96,12 @@ public class ApplicationContext : DbContext
.HasForeignKey(g => g.EnclosureId).OnDelete(DeleteBehavior.SetNull);
e.HasOne(g => g.ColorVariety).WithMany()
.HasForeignKey(g => g.ColorVarietyId).OnDelete(DeleteBehavior.SetNull);
// DB-1: ExternalRef is the import idempotency key — enforce uniqueness at the DB level.
// Filtered (nulls allowed: manually-entered animals have no ExternalRef).
e.HasIndex(g => g.ExternalRef)
.IsUnique()
.HasFilter("\"ExternalRef\" IS NOT NULL");
});
modelBuilder.Entity<Litter>(e =>
@@ -89,6 +110,12 @@ public class ApplicationContext : DbContext
.HasForeignKey(l => l.FatherId).OnDelete(DeleteBehavior.Restrict);
e.HasOne(l => l.Mother).WithMany()
.HasForeignKey(l => l.MotherId).OnDelete(DeleteBehavior.Restrict);
// DB-5: ExternalRef = source litter id from extract.py.
// Unique (filtered, nulls allowed for manually-entered litters).
e.HasIndex(l => l.ExternalRef)
.IsUnique()
.HasFilter("\"ExternalRef\" IS NOT NULL");
});
modelBuilder.Entity<HealthRecord>(e =>
@@ -170,6 +197,15 @@ public class ApplicationContext : DbContext
modelBuilder.Entity<MailSettings>()
.HasData(new MailSettings { Id = GerbilManagerWebAPI.Models.MailSettings.SingletonId });
// DB-4: German collation on remaining searched/sorted text columns (Npgsql-only).
if (isNpgsql)
{
modelBuilder.Entity<ColorVariety>()
.Property(v => v.Name).UseCollation(DeIcu);
modelBuilder.Entity<Contact>()
.Property(c => c.Name).UseCollation(DeIcu);
}
SeedColorVarieties(modelBuilder);
}

View File

@@ -0,0 +1,38 @@
using GerbilManagerWebAPI.Names;
namespace GerbilManagerWebAPI.Endpoints
{
/// <summary>
/// FEAT-NAMEGEN: GET /names/suggest — meaningful gerbil name suggestions via Gemini.
///
/// 503 {code:"NamesKeyMissing"} while the AI section is unconfigured — the
/// frontend maps exactly this code to its German disabled-state hint.
/// </summary>
public static class NamesEndpoints
{
public static IEndpointRouteBuilder MapNamesEndpoints(this IEndpointRouteBuilder app)
{
app.MapGet("/names/suggest", async (
string? letter,
string? gender,
string? usages,
int count,
NameSuggestionService service,
CancellationToken ct) =>
{
var result = await service.SuggestAsync(letter, gender, usages, count, ct);
return result.Status switch
{
NameSuggestionStatus.Ok => Results.Ok(result.Suggestions),
NameSuggestionStatus.NotConfigured => Results.Json(
new { code = "NamesKeyMissing", message = result.Error ?? "" }, statusCode: 503),
_ => Results.Json(
new { code = "NamesUpstreamError", message = result.Error ?? "" }, statusCode: 502),
};
})
.WithTags("Names");
return app;
}
}
}

View File

@@ -1,39 +1,40 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Aspire.Npgsql.EntityFrameworkCore.PostgreSQL" Version="13.4.2" />
<PackageReference Include="DocumentFormat.OpenXml" Version="3.5.1" />
<PackageReference Include="MailKit" Version="4.17.0" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.8" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.8" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.8">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.2" />
<PackageReference Include="Gridify" Version="2.19.1" />
<PackageReference Include="Gridify.EntityFramework" Version="2.19.1" />
<PackageReference Include="Scalar.AspNetCore" Version="2.14.14" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\GerbilManager.ServiceDefaults\GerbilManager.ServiceDefaults.csproj" />
</ItemGroup>
<ItemGroup>
<!-- FEAT-13: Abgabevertrag-Vorlage wird als eingebettete Ressource ausgeliefert. -->
<EmbeddedResource Include="Contracts\Templates\Abgabevertrag.docx" />
</ItemGroup>
<ItemGroup>
<!-- FEAT-12a: Tests prüfen interne Bausteine (z. B. die Completions-URL). -->
<InternalsVisibleTo Include="GerbilManager.Tests" />
</ItemGroup>
</Project>
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<UserSecretsId>d5041cff-22a2-4aba-80ff-903ce5203eb3</UserSecretsId>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Aspire.Npgsql.EntityFrameworkCore.PostgreSQL" Version="13.4.2" />
<PackageReference Include="DocumentFormat.OpenXml" Version="3.5.1" />
<PackageReference Include="MailKit" Version="4.17.0" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.8" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.8" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.8">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.2" />
<PackageReference Include="Gridify" Version="2.19.1" />
<PackageReference Include="Gridify.EntityFramework" Version="2.19.1" />
<PackageReference Include="Scalar.AspNetCore" Version="2.14.14" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\GerbilManager.ServiceDefaults\GerbilManager.ServiceDefaults.csproj" />
</ItemGroup>
<ItemGroup>
<!-- FEAT-13: Abgabevertrag-Vorlage wird als eingebettete Ressource ausgeliefert. -->
<EmbeddedResource Include="Contracts\Templates\Abgabevertrag.docx" />
</ItemGroup>
<ItemGroup>
<!-- FEAT-12a: Tests prüfen interne Bausteine (z. B. die Completions-URL). -->
<InternalsVisibleTo Include="GerbilManager.Tests" />
</ItemGroup>
</Project>

View File

@@ -102,7 +102,8 @@ namespace GerbilManagerWebAPI.Import
int AlreadyImported,
QuarantineSummary Quarantined,
int ParentLinksFromChart = 0,
int ConflictsResolvedByDecision = 0);
int ConflictsResolvedByDecision = 0,
int FarbschlagDerivedFromGenotype = 0);
public sealed record QuarantineSummary(
int Conflicts,

View File

@@ -71,17 +71,21 @@ namespace GerbilManagerWebAPI.Import
else if (conf == "niedrig") dateOnly++;
}
// existing rows (idempotency). Gerbils carry ExternalRef; Litters have no such
// column, so we key litter idempotency on the stable (Name + Date) pair instead.
// existing rows (idempotency).
var existingGerbilExtRefs = await _db.Gerbils
.Where(g => g.ExternalRef != null)
.Select(g => g.ExternalRef!).ToListAsync();
var existingGerbilSet = existingGerbilExtRefs.ToHashSet();
var existingLitterKeys = await _db.Litters
.Select(l => new { l.Name, l.Date }).ToListAsync();
var existingLitterKeySet = existingLitterKeys
// DB-5: Litters now carry ExternalRef (= source litter id from extract.py).
// Primary idempotency: ExternalRef. Fallback: Name+Date for litters created before DB-5.
var existingLitterData = await _db.Litters
.Select(l => new { l.Name, l.Date, l.ExternalRef }).ToListAsync();
var existingLitterKeySet = existingLitterData
.Select(x => $"{x.Name}|{x.Date:yyyy-MM-dd}").ToHashSet();
var existingLitterExtRefSet = existingLitterData
.Where(x => x.ExternalRef != null)
.Select(x => x.ExternalRef!).ToHashSet();
// colour-variety name -> id (case-insensitive)
var varieties = await _db.ColorVarieties.Select(v => new { v.Id, v.Name }).ToListAsync();
@@ -94,10 +98,10 @@ namespace GerbilManagerWebAPI.Import
var damNames = litters.Select(l => Normalize(StripZucht(l.DamName))).Where(s => s.Length > 0).ToHashSet();
// ---- litters: create map source.id -> Litter (for high-confidence animal links) ----
// Idempotency (DB-5): ExternalRef-match is primary (stable source id); Name+Date is the
// fallback for litters created before DB-5 (those have ExternalRef=null in the DB).
// COUNTER-BUG FIX: undated litters (31 in the Wurfchronik) have no parseable date,
// so their existingLitterKeySet key was always "" → they were always counted as
// "created" even though the execute block skipped them (date is DateOnly d = false).
// Fix: skip undated litters early — they can never be created or linked to animals.
// so skip them early — they can never be created or linked to animals.
int littersCreated = 0, littersExisting = 0, littersWithoutDate = 0;
var litterIdMap = new Dictionary<string, Guid>(); // source litter id -> Litter.Id
foreach (var sl in litters)
@@ -107,7 +111,10 @@ namespace GerbilManagerWebAPI.Import
var name = $"Wurf {sl.LitterId}".Trim();
var key = $"{name}|{date:yyyy-MM-dd}";
if (existingLitterKeySet.Contains(key)) { littersExisting++; continue; }
// DB-5: check ExternalRef first (stable, source-id-based); fall back to Name+Date
// for litters imported before ExternalRef existed (those have ExternalRef = null).
if (existingLitterExtRefSet.Contains(sl.Id) || existingLitterKeySet.Contains(key))
{ littersExisting++; continue; }
var id = Guid.NewGuid();
litterIdMap[sl.Id] = id;
@@ -122,6 +129,7 @@ namespace GerbilManagerWebAPI.Import
TotalBorn = sl.TotalBorn,
Notes = string.IsNullOrWhiteSpace(sl.Note) ? null : sl.Note,
PairingCode = string.IsNullOrWhiteSpace(sl.Zuchtnummer) ? null : sl.Zuchtnummer,
ExternalRef = sl.Id, // DB-5: stable import key for future re-imports
});
}
if (samples.Count < 8)
@@ -145,25 +153,63 @@ namespace GerbilManagerWebAPI.Import
.ToDictionary(g => g.ExternalRef!, g => g.LitterId);
var existingColorVarietyByExtRef = existingRows.Where(g => g.ExternalRef != null)
.ToDictionary(g => g.ExternalRef!, g => g.ColorVarietyId);
// CR-9: ExternalRef → Gerbil.Id fallback for name/DOB drift on re-import
var existingGidByExtRef = existingRows.Where(g => g.ExternalRef != null)
.ToDictionary(g => g.ExternalRef!, g => g.Id);
// CR-11: load CanonicalGenotype for genotype-derived Farbschlag matching
var varietiesWithGeno = await _db.ColorVarieties
.Select(v => new { v.Id, v.Name, v.CanonicalGenotype }).ToListAsync();
// PASS 1: assign ids + resolve fb/gender/Wurfchronik link (no writes yet).
var plan = new List<AnimalPlan>();
int fbDerivedFromGenotype = 0;
foreach (var a in loadable)
{
bool exists = existingGerbilSet.Contains(a.Id);
var gid = exists ? gidByNameDob[NameDobKey(a.Name, ParseDate(a.Dob))] : Guid.NewGuid();
// CR-9: use TryGetValue; fall back to ExternalRef lookup for name/DOB drift
// (e.g. correctDob remap or manual rename). Prevents throwing KeyNotFoundException.
Guid gid;
if (exists)
{
if (!gidByNameDob.TryGetValue(NameDobKey(a.Name, ParseDate(a.Dob)), out gid))
{
if (existingGidByExtRef.TryGetValue(a.Id, out gid))
notes.Add($"Hinweis: '{a.Name}' (*{a.Dob}) per ExternalRef gefunden trotz Name/DOB-Drift (correctDob oder UI-Umbenennung).");
else
{
notes.Add($"Warnung: ExternalRef '{a.Id}' in DB vorhanden aber nicht auflösbar — Tier übersprungen.");
continue;
}
}
}
else gid = Guid.NewGuid();
Guid? wurfLitterId = null;
if (a.LitterRef?.Confidence == "hoch" && a.LitterRef.Candidates is not { Count: > 0 }
&& litterIdMap.TryGetValue(a.LitterRef.LitterId, out var lid))
wurfLitterId = lid;
// CR-11: Farbschlag from explicit name-match first; fall back to genotype derivation
// (fill-NULL-only — never overwrites an explicit name-match or manual assignment).
Guid? colorVarietyId = null;
var fbCandidates = new[] { a.Farbschlag }.Concat(a.FarbschlagVariants)
.Where(s => !string.IsNullOrWhiteSpace(s));
foreach (var fb in fbCandidates)
if (varietyByName.TryGetValue(fb.Trim().ToLowerInvariant(), out var vid))
{ colorVarietyId = vid; break; }
if (colorVarietyId is null && a.Genotype.Mapped8locus.Count >= 8)
{
// CR-11: only derive from a fully-specified genotype (all 8 loci known,
// no "??" wildcards). Partial genotypes (single-locus or sparse records)
// would match any catalog entry via wildcards and produce false positives.
var composed = ComposeGenotype(a.Genotype);
if (!composed.Contains("??"))
foreach (var v in varietiesWithGeno)
if (!string.IsNullOrWhiteSpace(v.CanonicalGenotype)
&& GenotypePotentiallyMatches(composed, v.CanonicalGenotype))
{ colorVarietyId = v.Id; fbDerivedFromGenotype++; break; }
}
var gender = InferGender(a, sireNames, damNames);
var norm = Normalize(StripZucht(a.Name));
@@ -469,6 +515,33 @@ namespace GerbilManagerWebAPI.Import
int farbschlagWouldRebackfill = plan.Count(p =>
p.Exists && p.ColorVarietyId is not null && p.ColorVarietyId != p.CurrentColorVarietyId);
// CR-11: FARBSCHLAG FROM GENOTYPE post-sweep (fill-NULL-only, safe): existing DB animals
// with null ColorVarietyId whose stored Genotype matches a catalog entry get filled.
// Mirrors the plan-loop derivation; never overwrites a manually-set or name-matched value.
{
var noColor = await _db.Gerbils
.Where(g => g.ColorVarietyId == null && g.Genotype != null)
.Select(g => new { g.Id, g.Genotype })
.ToListAsync();
foreach (var g in noColor)
{
if (string.IsNullOrWhiteSpace(g.Genotype) || g.Genotype!.Contains("??")) continue;
Guid? derivedVid = null;
foreach (var v in varietiesWithGeno)
if (!string.IsNullOrWhiteSpace(v.CanonicalGenotype)
&& GenotypePotentiallyMatches(g.Genotype, v.CanonicalGenotype))
{ derivedVid = v.Id; break; }
if (derivedVid is null) continue;
fbDerivedFromGenotype++;
if (execute)
{
var row = await _db.Gerbils.FindAsync(g.Id);
if (row is not null && row.ColorVarietyId is null) row.ColorVarietyId = derivedVid;
}
}
if (execute && fbDerivedFromGenotype > 0) await _db.SaveChangesAsync();
}
// HERKUNFT BACKFILL (fill-NULL-only, safe): sweep all resident animals whose
// OriginBreeder is null and fill it with a derived value or 'Zucht der Kleinen Chaoten'.
// NEVER overwrites a non-null OriginBreeder (Julian: "alle Schreibweisen unterstützen").
@@ -524,6 +597,8 @@ namespace GerbilManagerWebAPI.Import
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 (fbDerivedFromGenotype > 0)
notes.Add($"Farbschlag aus Genotyp: {fbDerivedFromGenotype} Tier(e) ohne expliziten Farbschlag-Namen wurden über den Katalog-Genotyp-Abgleich zugeordnet (band-aware Deep-Band-Tiere).");
if (!execute) notes.Add("DRY-RUN: nichts gespeichert. /import/execute lädt die konfliktfreien Daten.");
return new ImportReport(
@@ -532,7 +607,7 @@ namespace GerbilManagerWebAPI.Import
Animals: new AnimalSummary(
animals.Count, animalsCreated, linked, fbMatched, fbUnmatched, animalsExisting,
new QuarantineSummary(conflicts, stubs, dateOnly, ambiguous, conflicts + stubs),
parentLinksAdded, conflictsResolvedByDecision),
parentLinksAdded, conflictsResolvedByDecision, fbDerivedFromGenotype),
Photos: new PhotoSummary(photosAttached, photosMissing),
Samples: samples,
Notes: notes,
@@ -570,6 +645,23 @@ namespace GerbilManagerWebAPI.Import
private static string StripCaret(string allele) => allele.Replace("^", "");
/// <summary>CR-11: check if a composed animal genotype is compatible with a catalog canonical
/// genotype. Both are space-separated 8-locus tokens (e.g. "aa CC DD ee GG PP spsp rere").
/// "??" in either position is a wildcard. The first 8 tokens are compared; any trailing
/// Sls token is ignored (it is outside the base 8-locus contract).</summary>
private static bool GenotypePotentiallyMatches(string animalGeno, string catalogGeno)
{
var a = animalGeno.Split(' ', StringSplitOptions.RemoveEmptyEntries);
var c = catalogGeno.Split(' ', StringSplitOptions.RemoveEmptyEntries);
if (a.Length < 8 || c.Length < 8) return false;
for (int i = 0; i < 8; i++)
{
if (a[i] == "??" || c[i] == "??") continue;
if (!string.Equals(a[i], c[i], StringComparison.OrdinalIgnoreCase)) return false;
}
return true;
}
private static Gender InferGender(SourceAnimal a, HashSet<string> sires, HashSet<string> dams)
{
// Box colour (blue=male, white=female) is the authoritative breeder signal — prefer it

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace GerbilManagerWebAPI.Migrations
{
/// <inheritdoc />
public partial class UniqueExternalRef : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateIndex(
name: "IX_Gerbils_ExternalRef",
table: "Gerbils",
column: "ExternalRef",
unique: true,
filter: "\"ExternalRef\" IS NOT NULL");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_Gerbils_ExternalRef",
table: "Gerbils");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,133 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace GerbilManagerWebAPI.Migrations
{
/// <inheritdoc />
public partial class AddGermanCollation : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "ExternalRef",
table: "Litters",
type: "text",
nullable: true);
migrationBuilder.AlterColumn<string>(
name: "OriginBreeder",
table: "Gerbils",
type: "text",
nullable: true,
collation: "de-x-icu",
oldClrType: typeof(string),
oldType: "text",
oldNullable: true);
migrationBuilder.AlterColumn<string>(
name: "NameSearch",
table: "Gerbils",
type: "text",
nullable: true,
collation: "de-x-icu",
oldClrType: typeof(string),
oldType: "text",
oldNullable: true);
migrationBuilder.AlterColumn<string>(
name: "Name",
table: "Gerbils",
type: "text",
nullable: false,
collation: "de-x-icu",
oldClrType: typeof(string),
oldType: "text");
migrationBuilder.AlterColumn<string>(
name: "Name",
table: "Contacts",
type: "text",
nullable: false,
collation: "de-x-icu",
oldClrType: typeof(string),
oldType: "text");
migrationBuilder.AlterColumn<string>(
name: "Name",
table: "ColorVarieties",
type: "text",
nullable: false,
collation: "de-x-icu",
oldClrType: typeof(string),
oldType: "text");
migrationBuilder.CreateIndex(
name: "IX_Litters_ExternalRef",
table: "Litters",
column: "ExternalRef",
unique: true,
filter: "\"ExternalRef\" IS NOT NULL");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_Litters_ExternalRef",
table: "Litters");
migrationBuilder.DropColumn(
name: "ExternalRef",
table: "Litters");
migrationBuilder.AlterColumn<string>(
name: "OriginBreeder",
table: "Gerbils",
type: "text",
nullable: true,
oldClrType: typeof(string),
oldType: "text",
oldNullable: true,
oldCollation: "de-x-icu");
migrationBuilder.AlterColumn<string>(
name: "NameSearch",
table: "Gerbils",
type: "text",
nullable: true,
oldClrType: typeof(string),
oldType: "text",
oldNullable: true,
oldCollation: "de-x-icu");
migrationBuilder.AlterColumn<string>(
name: "Name",
table: "Gerbils",
type: "text",
nullable: false,
oldClrType: typeof(string),
oldType: "text",
oldCollation: "de-x-icu");
migrationBuilder.AlterColumn<string>(
name: "Name",
table: "Contacts",
type: "text",
nullable: false,
oldClrType: typeof(string),
oldType: "text",
oldCollation: "de-x-icu");
migrationBuilder.AlterColumn<string>(
name: "Name",
table: "ColorVarieties",
type: "text",
nullable: false,
oldClrType: typeof(string),
oldType: "text",
oldCollation: "de-x-icu");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,22 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace GerbilManagerWebAPI.Migrations
{
/// <inheritdoc />
public partial class AddLitterExternalRef : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,28 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace GerbilManagerWebAPI.Migrations
{
/// <inheritdoc />
public partial class AddLitterLetter : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "LitterLetter",
table: "Litters",
type: "text",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "LitterLetter",
table: "Litters");
}
}
}

View File

@@ -201,7 +201,8 @@ namespace GerbilManagerWebAPI.Migrations
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text");
.HasColumnType("text")
.UseCollation("de-x-icu");
b.Property<int>("SortOrder")
.HasColumnType("integer");
@@ -689,7 +690,8 @@ namespace GerbilManagerWebAPI.Migrations
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text");
.HasColumnType("text")
.UseCollation("de-x-icu");
b.Property<string>("Notes")
.HasColumnType("text");
@@ -777,16 +779,19 @@ namespace GerbilManagerWebAPI.Migrations
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text");
.HasColumnType("text")
.UseCollation("de-x-icu");
b.Property<string>("NameSearch")
.HasColumnType("text");
.HasColumnType("text")
.UseCollation("de-x-icu");
b.Property<string>("Notes")
.HasColumnType("text");
b.Property<string>("OriginBreeder")
.HasColumnType("text");
.HasColumnType("text")
.UseCollation("de-x-icu");
b.Property<Guid?>("OriginContactId")
.HasColumnType("uuid");
@@ -807,6 +812,10 @@ namespace GerbilManagerWebAPI.Migrations
b.HasIndex("EnclosureId");
b.HasIndex("ExternalRef")
.IsUnique()
.HasFilter("\"ExternalRef\" IS NOT NULL");
b.HasIndex("LitterId");
b.HasIndex("OriginContactId");
@@ -890,9 +899,15 @@ namespace GerbilManagerWebAPI.Migrations
b.Property<DateOnly?>("ExpectedGoHomeDate")
.HasColumnType("date");
b.Property<string>("ExternalRef")
.HasColumnType("text");
b.Property<Guid?>("FatherId")
.HasColumnType("uuid");
b.Property<string>("LitterLetter")
.HasColumnType("text");
b.Property<Guid?>("MotherId")
.HasColumnType("uuid");
@@ -911,6 +926,10 @@ namespace GerbilManagerWebAPI.Migrations
b.HasKey("Id");
b.HasIndex("ExternalRef")
.IsUnique()
.HasFilter("\"ExternalRef\" IS NOT NULL");
b.HasIndex("FatherId");
b.HasIndex("MotherId");

View File

@@ -25,5 +25,14 @@ namespace GerbilManagerWebAPI.Models
/// <summary>Zuchtnummer der Verpaarung (Wurfchronik col H) — pairing-level code;
/// litters sharing it are the same Zuchtpaar. Set by the FEAT-8 import.</summary>
public string? PairingCode { get; set; }
/// <summary>DB-5: stable import source id (extract.py litter Id). Unique (filtered,
/// nulls allowed for manually-entered litters). Primary idempotency key for re-imports;
/// Name+Date is the fallback for litters created before this column existed.</summary>
public string? ExternalRef { get; set; }
/// <summary>FEAT-NAMEGEN: Wurfbuchstabe (A, B, C … AA, AB …) — alle Welpen dieses
/// Wurfs erhalten Namen mit diesem Anfangsbuchstaben (gängige Zuchtkonvention).</summary>
public string? LitterLetter { get; set; }
}
}

View File

@@ -0,0 +1,5 @@
namespace GerbilManagerWebAPI.Names
{
/// <summary>FEAT-NAMEGEN: a single name suggestion returned by GET /names/suggest.</summary>
public sealed record NameSuggestion(string Name, string Meaning, string Origin);
}

View File

@@ -0,0 +1,107 @@
using System.Text;
using System.Text.Json;
using GerbilManagerWebAPI.Ai;
using GerbilManagerWebAPI.SaleAd;
using Microsoft.Extensions.Options;
namespace GerbilManagerWebAPI.Names
{
/// <summary>
/// FEAT-NAMEGEN: generates meaningful gerbil name suggestions via Gemini
/// (the same OpenAiChatClient used by sale-ads and reply-drafts).
/// Returns NotConfigured when the AI section is missing — callers map to 503.
/// </summary>
public sealed class NameSuggestionService(HttpClient http, IOptions<AiOptions> options)
{
private readonly OpenAiChatClient _client = new(http, options);
private static readonly JsonSerializerOptions JsonOpts = new()
{
PropertyNameCaseInsensitive = true,
};
public async Task<NameSuggestionResult> SuggestAsync(
string? letter, string? gender, string? usages, int count,
CancellationToken ct = default)
{
var aiResult = await _client.CompleteAsync(
BuildSystemPrompt(),
BuildUserPrompt(letter, gender, usages, count),
ct);
if (aiResult.Status == AiCallStatus.NotConfigured)
return new NameSuggestionResult(NameSuggestionStatus.NotConfigured, null, aiResult.Error);
if (aiResult.Status != AiCallStatus.Ok || aiResult.Text is null)
return new NameSuggestionResult(NameSuggestionStatus.UpstreamError, null, aiResult.Error);
var suggestions = ParseSuggestions(aiResult.Text);
return suggestions is null
? new NameSuggestionResult(NameSuggestionStatus.UpstreamError, null, "Ungültiges JSON in KI-Antwort.")
: new NameSuggestionResult(NameSuggestionStatus.Ok, suggestions, null);
}
internal static string BuildSystemPrompt() =>
"Du bist ein Helfer für Rennmaus-Züchter. " +
"Antworte IMMER mit einem reinen JSON-Array — KEINE Markdown-Code-Blöcke, " +
"KEINE Erklärungen, KEIN Text außerhalb des Arrays. " +
"Jedes Element hat genau die Felder: name, meaning, origin (alle Strings, alle auf Deutsch).";
internal static string BuildUserPrompt(string? letter, string? gender, string? usages, int count)
{
var sb = new StringBuilder();
sb.Append($"Schlage {count} Rennmaus-Namen vor");
if (!string.IsNullOrWhiteSpace(letter))
sb.Append($" die mit dem Buchstaben \"{letter.ToUpperInvariant()}\" beginnen");
if (!string.IsNullOrWhiteSpace(gender) &&
!gender.Equals("any", StringComparison.OrdinalIgnoreCase))
sb.Append($", passend für {(gender.Equals("female", StringComparison.OrdinalIgnoreCase) ? "weibliche" : "männliche")} Tiere");
if (!string.IsNullOrWhiteSpace(usages))
sb.Append($", aus den Kulturkreisen: {usages}");
sb.Append(". Jeder Name muss eine echte etymologische Bedeutung und Herkunft haben ");
sb.Append("(keine erfundenen oder zufälligen Namen). ");
sb.Append($"Antworte mit genau {count} Elementen als reines JSON-Array: ");
sb.Append("[{\"name\":\"...\",\"meaning\":\"...\",\"origin\":\"...\"}]");
return sb.ToString();
}
/// <summary>
/// Strips optional markdown fences (```json ... ```) Gemini sometimes wraps around
/// its JSON output, then deserialises the array.
/// </summary>
internal static List<NameSuggestion>? ParseSuggestions(string raw)
{
var text = raw.Trim();
// Strip ```json ... ``` or ``` ... ``` fences.
if (text.StartsWith("```", StringComparison.Ordinal))
{
var firstNewline = text.IndexOf('\n');
if (firstNewline >= 0) text = text[(firstNewline + 1)..];
if (text.EndsWith("```", StringComparison.Ordinal))
text = text[..^3].TrimEnd();
}
// Find the JSON array bounds defensively.
var start = text.IndexOf('[');
var end = text.LastIndexOf(']');
if (start < 0 || end <= start) return null;
text = text[start..(end + 1)];
try
{
return JsonSerializer.Deserialize<List<NameSuggestion>>(text, JsonOpts);
}
catch (JsonException)
{
return null;
}
}
}
public enum NameSuggestionStatus { Ok, NotConfigured, UpstreamError }
public sealed record NameSuggestionResult(
NameSuggestionStatus Status,
List<NameSuggestion>? Suggestions,
string? Error);
}

View File

@@ -1,5 +1,6 @@
using System.Text.Json.Serialization;
using GerbilManagerWebAPI.Endpoints;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.EntityFrameworkCore;
using Scalar.AspNetCore;
@@ -44,9 +45,22 @@ builder.Services.AddHttpClient<GerbilManagerWebAPI.SaleAd.SaleAdService>(
// INBOX-2: KI-Antwortentwurf (gleiche AI-Sektion, gleicher Wire-Client).
builder.Services.AddHttpClient<GerbilManagerWebAPI.Inbox.DraftReplyService>(
http => http.Timeout = TimeSpan.FromSeconds(60));
// FEAT-NAMEGEN: Name suggestions via Gemini (same AI section, same wire client).
builder.Services.AddHttpClient<GerbilManagerWebAPI.Names.NameSuggestionService>(
http => http.Timeout = TimeSpan.FromSeconds(60));
// INBOX-0: Gmail inbox. App Password encrypted at rest via Data Protection.
builder.Services.AddDataProtection();
// AR-3: persist the key ring so encrypted passwords survive image redeployments.
// In prod the path is mounted to a persistent volume (compose DataProtection__KeyRingPath).
// In dev (Aspire) keys live in the content root — ephemeral, which is fine there.
{
var keyRingPath = builder.Configuration["DataProtection:KeyRingPath"]
?? Path.Combine(builder.Environment.ContentRootPath, ".data-protection-keys");
Directory.CreateDirectory(keyRingPath);
builder.Services.AddDataProtection()
.PersistKeysToFileSystem(new DirectoryInfo(keyRingPath))
.SetApplicationName("GerbilManager");
}
builder.Services.AddScoped<GerbilManagerWebAPI.Inbox.MailSettingsService>();
builder.Services.AddScoped<GerbilManagerWebAPI.Inbox.IGmailMailReader, GerbilManagerWebAPI.Inbox.GmailMailReader>();
builder.Services.AddScoped<GerbilManagerWebAPI.Inbox.RequestSyncService>();
@@ -89,6 +103,7 @@ app.MapSettingsEndpoints();
app.MapExportEndpoints();
app.MapCmsEndpoints();
app.MapRequestEndpoints();
app.MapNamesEndpoints();
app.Run();

View File

@@ -20,8 +20,8 @@ hinterlegt ist. Jede offene Frage unten zeigt, **was dadurch blockiert ist**.
| # | Was gebraucht wird | Wofür / was es freischaltet | Blockiert gerade |
|---|---|---|---|
| A1 | **Gemini API-Schlüssel** (aistudio.google.com → „API-Schlüssel erstellen"). | Schaltet **vier Funktionen auf einmal** frei | KI-Verkaufstexte, Charakterbogen-KI (fertig, schläft), Webseiten-Chat-Editor, KI-Antwortentwürfe im Posteingang |
| A2 | **Abrechnung aktivieren? (EU/Deutschland)** Die Gemini-AGB verlangen für EU-Nutzer die **bezahlte Stufe** — bei eurem Volumen **praktisch 0 €**, aber eure Daten werden dann **nicht** zum Training genutzt. Empfehlung: **Abrechnung einschalten** (bleibt fast gratis). Alternative für volle Privatsphäre: lokales Modell (Ollama). | Datenschutz (v. a. E-Mail-Inhalte) + AGB-konform | dieselben KI-Funktionen wie A1 |
| ~~A1~~ | **ERLEDIGT (2026-06-07): Gemini-Key hinterlegt** (user-secrets, nie committed) + LIVE verifiziert — Verkaufstext-Endpunkt generiert echten Text. KI-Verkaufstexte + Charakterbogen-KI sind **live**. (Namensgenerator live sobald Backend gemerged; Posteingang braucht zusätzlich A3 Gmail; Web-Chat-Editor wenn gebaut.) | — | — |
| ~~A2~~ | ✅ **ERLEDIGT (2026-06-07): KOSTENLOS, kein Billing nötig.** `gemini-2.0-flash` hatte kein Free-Kontingent (429), aber **`gemini-flash-latest` funktioniert im Free-Tier** (direkt + live verifiziert). Modell überall darauf umgestellt. Julian bleibt gratis. (Falls je volle Privatsphäre gewünscht: Ollama-Option steht.) | — | — |
| A3 | **Gmail App-Passwort.** Einmalig: Google-Konto → 2-Faktor-Bestätigung aktivieren → „App-Passwörter" → eines für „GerbilManager" erstellen → 16-stelligen Code an Michael. | Posteingang verbinden (Anfragen abrufen + Antworten senden) | E-Mail-Posteingang (live) |
| A4 | **Domain-Name** (ist registriert ✔) + **Cloudflare-Konto & API-Token** (Berechtigung „Cloudflare Pages → Edit"). | Öffentliche Webseite auf Cloudflare veröffentlichen | Veröffentlichung der neuen Webseite (Ersatz für Jimdo) |
| A5 | **TrueNAS / Gitea — Restfragen:** ~~(b) Gitea Actions?~~**AKTIV seit 06.06. — CI läuft bereits** (Runner registriert, Backend+Frontend-Tests grün auf dem Runner). **NEU (b2): Docker-Push-Job braucht 2 Repo-Secrets** — in Gitea → Repo → Einstellungen → Actions → Secrets bitte `REGISTRY_USER` (dein Gitea-Login) und `REGISTRY_TOKEN` (Token mit `write:package`) anlegen, dann pusht die CI fertige Images in die Registry. Offen bleiben: (a) TrueNAS SCALE-Version? (c) Eigener Postgres-Container (empfohlen) oder bestehender NAS-Postgres? (d) Dataset-Pfad für Daten/Backups, Port 80 frei? | Manager auf dem NAS betreiben (Produktiv + Backups + CI) | NAS-Deployment (compose fertig; CI ✅ live) |
@@ -126,10 +126,10 @@ Bitte je Tier sagen, **welcher Wert stimmt** (die Quellen widersprechen sich bei
| Tier | Konkreter Konflikt — was stimmt? |
|---|---|
| Hanami v.d. K.C. (*10.09.2015) | Sterbedatum: **12.12.2019****14.01.2020** (= D5) |
| Little Runner's Big Ben (*03.02.2020) | P-Locus: **PP** **Pp** |
| Vance Jr. v.d. K.C. (*10.04.2022) | Scheckung: **Spsp****spsp** (Schecke ja/nein) |
| Kazu v.d. K.C. (*23.04.2013) | E-Locus: **e[f]e[f]****ee[f]** · G-Locus: **Gg****GG** · P-Locus: **P?****PP** |
| Skarlett v.d. K.C. (*14.07.2013) | Sterbedatum: **17.04.2016** **2018** |
| ~~Little Runner's Big Ben (*03.02.2020)~~ | ✅ **Pp** (P-Locus) — Julian 2026-06-07 → in conflict-decisions.json |
| ~~Vance Jr. v.d. K.C. (*10.04.2022)~~ | ✅ **spsp** (ungescheckt) — Julian 2026-06-07 → in conflict-decisions.json |
| ~~Kazu v.d. K.C. (*23.04.2013)~~ | ✅ **ee[f] · GG · PP** — Julian 2026-06-07 → in conflict-decisions.json |
| ~~Skarlett v.d. K.C. (*14.07.2013)~~ | ✅ **Sterbedatum 17.04.2016** — Julian 2026-06-07 → in conflict-decisions.json |
*(Enya, Ella und Zac fehlen hier bewusst: deren Abweichung ist nur „unbekannt ↔ genau angegeben" — löst der Importer automatisch mit der „genauer gewinnt"-Regel.)*

View File

@@ -1,4 +1,4 @@
# GerbilManager Produktionskonfiguration
# GerbilManager Produktionskonfiguration
# Kopiere diese Datei nach .env und setze die Werte vor dem ersten Start.
# Sicheres Datenbankpasswort (mind. 20 Zeichen, keine Anführungszeichen)
@@ -15,9 +15,15 @@ TAG=latest
PGDATA_PATH=/mnt/SSD/gerbil/pgdata
PHOTOS_PATH=/mnt/SSD/gerbil/photos
BACKUPS_PATH=/mnt/SSD/gerbil/backups
# AR-3: Data Protection Key-Ring (Gmail-App-Passwort-Verschlüsselung)
KEYS_PATH=/mnt/SSD/gerbil/keys
# Backup-Rotation: Anzahl Tage (Standard: 7)
BACKUP_KEEP_DAYS=7
# Claude-API-Key fuer KI-Verkaufstext (FEAT-12a; leer lassen wenn nicht vorhanden)
ANTHROPIC_API_KEY=
# KI-Funktionen (Verkaufstext + Posteingang-Entwurf)
# Beliebiger OpenAI-kompatibler Anbieter — Optionen in docs/ai-provider.md
# Leer lassen = KI deaktiviert (kein Fehler, nur 503 AiKeyMissing)
AI__BaseUrl=
AI__ApiKey=
AI__Model=gemini-flash-latest

View File

@@ -1,4 +1,4 @@
# GerbilManager — TrueNAS Custom Application
# GerbilManager — TrueNAS Custom Application
# ==============================================
# Vor dem ersten Start:
# 1. Kopiere deploy/truenas/.env.example -> deploy/truenas/.env und setze die Werte.
@@ -40,10 +40,17 @@ services:
ConnectionStrings__gerbilmanager: "Host=db;Port=5432;Database=gerbilmanager;Username=postgres;Password=${POSTGRES_PASSWORD}"
# Speicherort der hochgeladenen Fotos (NAS-Dataset gemounted unter /data/photos)
Photos__RootPath: /data/photos
# KI-Verkaufstext (FEAT-12a stub; leer lassen wenn kein Key vorhanden)
ANTHROPIC_API_KEY: "${ANTHROPIC_API_KEY:-}"
# AR-3: Data Protection Key-Ring (persistiert Gmail-App-Passwort-Verschlüsselung über Redeployments)
DataProtection__KeyRingPath: /data/keys
# AR-4: KI-Funktionen (Verkaufstext + Posteingang-Entwurf, Sektion AI; beliebiger OpenAI-kompatibler Anbieter)
# Anbieter-Optionen und Schlüssel-Beispiele: docs/ai-provider.md
# Leer lassen = KI deaktiviert (503 AiKeyMissing statt Fehler)
AI__BaseUrl: "${AI__BaseUrl:-}"
AI__ApiKey: "${AI__ApiKey:-}"
AI__Model: "${AI__Model:-gemini-flash-latest}"
volumes:
- photos:/data/photos
- keys:/data/keys
depends_on:
db:
condition: service_healthy
@@ -101,6 +108,14 @@ volumes:
type: none
o: bind
device: "${PHOTOS_PATH:-/mnt/gerbil/photos}"
# AR-3: Data Protection key ring — persistiert Gmail-App-Passwort-Verschlüsselung.
# Muss ein persistentes NAS-Dataset sein (nicht dasselbe wie photos).
keys:
driver: local
driver_opts:
type: none
o: bind
device: "${KEYS_PATH:-/mnt/gerbil/keys}"
backups:
driver: local
driver_opts:

View File

@@ -1,4 +1,4 @@
# KI-Anbieter für das Abgabe-Inserat (FEAT-12a)
# KI-Anbieter für das Abgabe-Inserat (FEAT-12a)
`POST /gerbils/sale-ad` spricht einen beliebigen **OpenAI-kompatiblen**
Chat-Completions-Endpunkt an — kein SDK, nur drei Einstellungen (Sektion `AI`),
@@ -16,7 +16,7 @@ committen**. Fehlt eine der drei, antwortet der Endpunkt mit
| Anbieter | `AI__BaseUrl` | Beispiel-`AI__Model` | Kosten |
|---|---|---|---|
| **Google Gemini** (vermutlich Julians Wahl) | `https://generativelanguage.googleapis.com/v1beta/openai` | `gemini-2.0-flash` | Free Tier |
| **Google Gemini** (vermutlich Julians Wahl) | `https://generativelanguage.googleapis.com/v1beta/openai` | `gemini-flash-latest` | Free Tier |
| **Groq** | `https://api.groq.com/openai/v1` | `llama-3.3-70b-versatile` | Free Tier |
| **Mistral** | `https://api.mistral.ai/v1` | `mistral-small-latest` | Free Tier |
| **Ollama** (lokal/TrueNAS) | `http://<host>:11434/v1` | `llama3.2` | kostenlos, lokal |
@@ -31,7 +31,7 @@ Ollama ignoriert den Schlüssel — `AI__ApiKey=ollama` als Platzhalter setzen
```powershell
$env:AI__BaseUrl = 'https://generativelanguage.googleapis.com/v1beta/openai'
$env:AI__ApiKey = '<schlüssel>'
$env:AI__Model = 'gemini-2.0-flash'
$env:AI__Model = 'gemini-flash-latest'
```
oder per user-secrets im API-Projekt:
@@ -46,5 +46,5 @@ services:
environment:
AI__BaseUrl: https://generativelanguage.googleapis.com/v1beta/openai
AI__ApiKey: ${AI_API_KEY} # Wert in der TrueNAS-App/.env hinterlegen
AI__Model: gemini-2.0-flash
AI__Model: gemini-flash-latest
```

View File

@@ -0,0 +1,169 @@
/**
* QA-SMOKE-2: Gezielter Live-Smoke der heute gemergten Änderungen (main e6b513e).
* Läuft NUR im LIVE-Modus (E2E_BASE_URL gesetzt).
*
* Abgedeckte Änderungen:
* 1. GEN-3h — Bracket-Notation: eef→ee[f], cchm→c[chm], C/cchm→Cc[chm]
* 2. FORM-FIELDS — originBreeder + isDeaf im Tier-Editformular
* 3. CR-2 — Partial-Update nullt keine bestehenden Felder mehr
* 4. UI-POLISH — '(ohne Namen)'-Platzhalter für namenlose Tiere
* 5. GEHEGE — Redirects + Nav (bereits in gehege-regression.spec; hier live-Verifikation)
*
* Read-Only soweit möglich. CR-2-Test: idempotentes Save (keine Wertänderung).
*/
import { de, expect, skipUnlessLive, test } from './fixtures'
const API = 'http://localhost:5179'
// ── 1. GEN-3h Bracket-Notation ───────────────────────────────────────────────
test('GEN-3h: eef zeigt ee[f] (nicht "eef") im Tier-Detail', async ({ page }) => {
skipUnlessLive()
// Roswitha — genotype: "aa CC D? eef Gg P? spsp ??"
// eef = e/ef het → E>e>ef Rank → ee[f]
await page.goto('/rennmaeuse/66ee246c-26f0-41ac-8b6c-c151d9287633')
await expect(page.getByRole('heading', { name: 'Roswitha von den Kleinen Chaoten' })).toBeVisible()
// Genotyp-Block soll e[f] enthalten, nicht rohes 'eef'
const genoCode = page.locator('code').filter({ hasText: /e\[f\]/ })
await expect(genoCode).toBeVisible({ timeout: 8_000 })
// Rohes 'eef' darf nicht mehr im Code-Block stehen
await expect(page.locator('code').filter({ hasText: /[^[]eef/ })).toBeHidden()
})
test('GEN-3h: cchm/cchm zeigt c[chm]c[chm] im Tier-Detail (ZoneFire)', async ({ page }) => {
skipUnlessLive()
// ZoneFire — genotype: "Aa cchmcchm D? Ee Gg P? Spsp ??"
await page.goto('/rennmaeuse/14d5cea9-0f87-4381-8afe-80f1d58f7b76')
await expect(page.getByRole('heading', { name: 'ZoneFire' })).toBeVisible()
const genoCode = page.locator('code').filter({ hasText: /c\[chm\]/ })
await expect(genoCode).toBeVisible({ timeout: 8_000 })
await expect(page.locator('code').filter({ hasText: /cchmcchm/ })).toBeHidden()
})
test('GEN-3h: Cc[chm] + ee[f] korrekt bei Ethan (gemischter Locus)', async ({ page }) => {
skipUnlessLive()
// Ethan — genotype: "Aa Ccchm D? eef Gg Pp Spsp ??"
// Ccchm → Cc[chm]; eef → ee[f]
await page.goto('/rennmaeuse/54ef9b08-0def-496e-95db-5107b732ddd4')
await expect(page.getByRole('heading', { name: 'Ethan von den Kleinen Chaoten' })).toBeVisible()
const genoCode = page.locator('code')
await expect(genoCode.filter({ hasText: /c\[chm\]/ })).toBeVisible({ timeout: 8_000 })
await expect(genoCode.filter({ hasText: /e\[f\]/ })).toBeVisible()
// Rohes Storage-Format darf nicht sichtbar sein
await expect(genoCode.filter({ hasText: /Ccchm|eef/ })).toBeHidden()
})
// ── 2. FORM-FIELDS: originBreeder + isDeaf im Formular ───────────────────────
test('FORM-FIELDS: Herkunft-Freitext + Gehörlos-Dropdown im Tier-Editformular', async ({
page,
}) => {
skipUnlessLive()
// Ethan hat bereits originBreeder="Kleinen Chaoten", isDeaf=null
await page.goto('/rennmaeuse/54ef9b08-0def-496e-95db-5107b732ddd4/bearbeiten')
await expect(page.getByRole('heading', { name: de.pages.gerbils.form.editTitle })).toBeVisible({ timeout: 8_000 })
// Herkunft-Freitext (originBreeder) soll sichtbar und gefüllt sein
const originInput = page.getByLabel(de.pages.gerbils.fields.originBreeder)
await expect(originInput).toBeVisible()
await expect(originInput).toHaveValue('Kleinen Chaoten')
// Gehörlos-Dropdown (isDeaf) soll sichtbar sein mit "Unbekannt"-Default
// Gehörlos-Dropdown (isDeaf): label 'Gehörlos', aktuell leer (Unbekannt)
const deafLabel = page.locator('label').filter({ hasText: de.pages.gerbils.fields.isDeaf })
await expect(deafLabel).toBeVisible()
// Dropdown-Select im Label-Kontext
const deafSelect = deafLabel.locator('select')
await expect(deafSelect).toBeVisible()
})
// ── 3. CR-2: idempotentes Save nullt keine Felder ────────────────────────────
test('CR-2: Partial-PUT nullt keine bestehenden Felder (direkter API-Test)', async ({ page }) => {
skipUnlessLive()
// Snapshot VORHER
const before = await page.request.get(`${API}/gerbils/54ef9b08-0def-496e-95db-5107b732ddd4`)
const snap = await before.json()
expect(snap.originBreeder).toBe('Kleinen Chaoten')
expect(snap.genotype).toBeTruthy()
// Partial-PUT: nur name + gender senden (minimal body, alle anderen Felder fehlen)
// Mit PATCH-Semantik-Fix: fehlende Felder werden NICHT genullt
const res = await page.request.put(`${API}/gerbils/54ef9b08-0def-496e-95db-5107b732ddd4`, {
data: {
name: snap.name,
gender: snap.gender,
status: snap.status,
isResident: snap.isResident,
// ALLE anderen Felder absichtlich weggelassen → vor CR-2-Fix würden sie auf null gesetzt
},
})
// PATCH-Semantik: 204 No Content (kein Body), fehlende Felder bleiben erhalten
expect(res.status()).toBe(204)
// Re-Fetch und vergleichen
const afterRes = await page.request.get(`${API}/gerbils/54ef9b08-0def-496e-95db-5107b732ddd4`)
const updated = await afterRes.json()
expect(updated.originBreeder).toBe(snap.originBreeder) // muss erhalten bleiben
expect(updated.genotype).toBe(snap.genotype) // muss erhalten bleiben
expect(updated.dateOfBirth).toBe(snap.dateOfBirth) // muss erhalten bleiben
expect(updated.litterId).toBe(snap.litterId) // muss erhalten bleiben
expect(updated.externalRef).toBe(snap.externalRef) // muss erhalten bleiben
// Restore: idempotent (gleiche Werte gesetzt, kein Net-Change)
})
// ── 4. UI-POLISH: '(ohne Namen)'-Platzhalter ─────────────────────────────────
test('UI-POLISH: namenlose Tiere zeigen "(ohne Namen)" in der Tierliste', async ({ page }) => {
skipUnlessLive()
// Alle Tiere anzeigen (inkl. externe), um namenlose zu sehen
await page.goto('/rennmaeuse')
await expect(page.getByRole('heading', { name: de.pages.gerbils.title, exact: true })).toBeVisible()
// "(ohne Namen)" Platzhalter soll mindestens einmal sichtbar sein
// (Filter auf alle Tiere — ggf. "Externe anzeigen" aktivieren)
const placeholder = page.getByText('(ohne Namen)')
// Wenn nicht sofort sichtbar, Toggle "Externe anzeigen" probieren
if (!(await placeholder.isVisible({ timeout: 3_000 }).catch(() => false))) {
const toggle = page.locator('button, label').filter({ hasText: /extern|alle/i }).first()
if (await toggle.isVisible()) await toggle.click()
}
await expect(placeholder.first()).toBeVisible({ timeout: 8_000 })
})
test('UI-POLISH: namenloser Knoten im Stammbaum zeigt "(ohne Namen)"', async ({ page }) => {
skipUnlessLive()
// Stammbaum eines Tiers mit namenlosem Elternteil
// Tier "46c646a9" ist namenlos + resident; sein Elternteil im Stammbaum-Kontext
// Alternativ: Vance's Stammbaum enthält tiefe Äste, manche namenlos
await page.goto('/rennmaeuse/afdaff89-0274-4778-8f2f-3957a81bf58e/stammbaum')
await expect(page.locator('svg').first()).toBeVisible({ timeout: 12_000 })
// Prüfe ob "(ohne Namen)" irgendwo im Baum erscheint
const placeholder = page.getByText('(ohne Namen)')
// Kein harter Fail wenn kein namenloser Knoten sichtbar — Tree kann mit Lazy-Gen-5+ variieren
// Aber wenn vorhanden, muss der Text sauber sein (nicht leer)
const visible = await placeholder.isVisible({ timeout: 5_000 }).catch(() => false)
if (visible) {
await expect(placeholder.first()).toBeVisible()
}
// Kein JS-Error auf der Seite (implizit durch erfolgreiche Navigation)
})
// ── 5. GEHEGE: Live-Verifikation der Redirect/Nav-Änderungen ─────────────────
test('GEHEGE live: /becken leitet auf /gehege weiter', async ({ page }) => {
skipUnlessLive()
await page.goto('/becken')
await expect(page).toHaveURL(/\/gehege$/, { timeout: 5_000 })
await expect(page.getByRole('heading', { name: de.pages.becken.title, exact: true })).toBeVisible()
})
test('GEHEGE live: "/" leitet auf /rennmaeuse weiter (kein Start-Screen)', async ({ page }) => {
skipUnlessLive()
await page.goto('/')
await expect(page).toHaveURL(/\/rennmaeuse$/, { timeout: 5_000 })
await expect(
page.getByRole('heading', { name: de.pages.gerbils.title, exact: true }),
).toBeVisible()
})

View File

@@ -222,8 +222,9 @@ describe('Farbschlag catalog', () => {
expect(CATALOG).toHaveLength(CATALOG_SIZE)
expect(CATALOG[0]).toMatchObject({ name: 'Pink Eyed White (PEW)', sortOrder: 0 })
// Every row has a non-empty canonical genotype display string and unique name.
// GEN-3h: bracket notation (e[f], c[chm], c[h]) allowed in tokens.
expect(new Set(CATALOG.map((c) => c.name)).size).toBe(CATALOG.length)
expect(CATALOG.every((c) => /^[A-Za-z?]+( [A-Za-z?]+){7}$/.test(c.canonicalGenotype))).toBe(true)
expect(CATALOG.every((c) => /^[A-Za-z[\]?-]+( [A-Za-z[\]?-]+){7}$/.test(c.canonicalGenotype))).toBe(true)
})
it('genotypeToFarbschlag (DATA-1 denormalization contract) returns the plain name', () => {
@@ -523,3 +524,99 @@ describe('GEN-3g: "-Hell" in variety name == cchm/ch het; hom == cchm/cchm', ()
expect(name('aa cchmch DD EE gg PP spsp rere')).toBe('Zobel-Hell')
})
})
describe('GEN-3h: breeder bracket-notation display + E-locus e-before-ef order', () => {
// ── Display symbols ────────────────────────────────────────────────────
it('ef displays as e[f], cchm as c[chm], ch as c[h]', () => {
// Fuchsschimmel: E=[ef,ef] hom
expect(toDisplayString(fromDisplayString('AA CC DD efef GG PP spsp rere'))).toBe(
'AA CC DD e[f]e[f] GG PP spsp rere',
)
// C-locus het: cchm + ch
expect(toDisplayString(fromDisplayString('aa cchmch DD EE GG PP spsp rere'))).toBe(
'aa c[chm]c[h] DD EE GG PP spsp rere',
)
// C-locus hom cchm
expect(toDisplayString(fromDisplayString('aa cchmcchm DD EE GG PP spsp rere'))).toBe(
'aa c[chm]c[chm] DD EE GG PP spsp rere',
)
})
// ── E-locus display order: E > e > e[f] ─────────────────────────────
it('Fuchsschimmel het pair {ef,e} displays as ee[f] (e before e[f])', () => {
// Stored canonical: [ef, e] (ef dominant over e in storage).
// Display must swap to [e, ef] per breeder convention.
const g = fromDisplayString('aa CC DD eef Gg Pp spsp rere')
expect(g.E).toEqual(['ef', 'e']) // storage order unchanged
expect(toDisplayString(g)).toBe('aa CC DD ee[f] Gg Pp spsp rere')
})
it('E+e stays Ee (E dominant over e, no swap needed)', () => {
expect(toDisplayString(fromDisplayString('aa CC DD Ee GG PP spsp rere'))).toBe(
'aa CC DD Ee GG PP spsp rere',
)
})
it('E+ef displays Ee[f] (E dominant stays first, ef renders as e[f])', () => {
expect(toDisplayString(fromDisplayString('aa CC DD Eef GG PP spsp rere'))).toBe(
'aa CC DD Ee[f] GG PP spsp rere',
)
})
// ── Julian oracle fixtures (HUMANQUESTION D3/D4) ─────────────────────
it('Tier C: oracle display string round-trips exactly (aa C- D- ee[f] Gg Pp spsp rere)', () => {
const display = 'aa C- D- ee[f] Gg Pp spsp rere'
const g = fromDisplayString(display)
expect(g.E).toEqual(['ef', 'e'])
expect(g.C).toEqual(['C', '?'])
expect(toDisplayString(g)).toBe(display)
// Farbschlag scope is outside GEN-3h; god confirmed colour is correct as-is.
})
it('Zuleika oracle: aa c[chm]c[h] DD Ee Gg PP spsp rere', () => {
const display = 'aa c[chm]c[h] DD Ee Gg PP spsp rere'
const g = fromDisplayString(display)
expect(g.C).toEqual(['cchm', 'ch'])
expect(g.E).toEqual(['E', 'e'])
expect(toDisplayString(g)).toBe(display)
})
it('Milka oracle: aa Cc[h] dd EE Gg P- Spsp rere', () => {
const display = 'aa Cc[h] dd EE Gg P- Spsp rere'
const g = fromDisplayString(display)
expect(g.C).toEqual(['C', 'ch'])
expect(g.D).toEqual(['d', 'd'])
expect(toDisplayString(g)).toBe(display)
})
// ── Parser accepts both forms ─────────────────────────────────────────
it('bracket input round-trips identically to internal-symbol input', () => {
expect(toDisplayString(fromDisplayString('AA c[chm]c[chm] DD EE GG PP spsp rere'))).toBe(
toDisplayString(fromDisplayString('AA cchmcchm DD EE GG PP spsp rere')),
)
expect(toDisplayString(fromDisplayString('AA CC DD e[f]e[f] GG PP spsp rere'))).toBe(
toDisplayString(fromDisplayString('AA CC DD efef GG PP spsp rere')),
)
expect(toDisplayString(fromDisplayString('AA CC DD ee[f] GG PP spsp rere'))).toBe(
toDisplayString(fromDisplayString('AA CC DD eef GG PP spsp rere')),
)
})
it('e[-] standalone: parses as [e,?], displays e-', () => {
const g = fromDisplayString('aa CC DD e[-] GG PP spsp rere')
expect(g.E).toEqual(['e', '?'])
expect(toDisplayString(g)).toBe('aa CC DD e- GG PP spsp rere')
})
it('CR-1a: Silvain oracle ee[-] parses without crash → [e,?], displays e-', () => {
// Real herdbook notation: ee[-] = fox allele e + unknown e-type second allele.
// The lookbehind rule strips the second e[-] → '?', leaving 'e?' for splitToken.
const input = 'aa c[chm]c[chm] Dd ee[-] Gg Pp Spsp'
const g = fromDisplayString(input)
expect(g.E).toEqual(['e', '?'])
expect(g.C).toEqual(['cchm', 'cchm'])
expect(g.D).toEqual(['D', 'd'])
expect(g.Sp).toEqual(['Sp', 'sp'])
expect(toDisplayString(g)).toBe('aa c[chm]c[chm] Dd e- Gg Pp Spsp rere')
})
})

View File

@@ -2,35 +2,35 @@
{
"name": "Pink Eyed White (PEW)",
"english": "Pink Eyed White",
"canonicalGenotype": "AA chch DD EE GG pp spsp rere",
"canonicalGenotype": "AA c[h]c[h] DD EE GG pp spsp rere",
"sortOrder": 0,
"image": "rotaugen-weiss-pew-d-sep-e-sep.jpg"
},
{
"name": "Hermelin",
"english": "Dark Tailed White",
"canonicalGenotype": "aa chch DD EE GG PP spsp rere",
"canonicalGenotype": "aa c[h]c[h] DD EE GG PP spsp rere",
"sortOrder": 1,
"image": "hermelin.jpeg"
},
{
"name": "Himalaya",
"english": "Himalayan",
"canonicalGenotype": "AA chch DD EE GG PP spsp rere",
"canonicalGenotype": "AA c[h]c[h] DD EE GG PP spsp rere",
"sortOrder": 2,
"image": "himalaya.jpg"
},
{
"name": "Zobel",
"english": "Sable",
"canonicalGenotype": "aa cchmcchm DD EE gg PP spsp rere",
"canonicalGenotype": "aa c[chm]c[chm] DD EE gg PP spsp rere",
"sortOrder": 3,
"image": "zobel.jpeg"
},
{
"name": "Rotaugenschimmel",
"english": "Red-Eyed Roan",
"canonicalGenotype": "AA CC DD efef GG pp spsp rere",
"canonicalGenotype": "AA CC DD e[f]e[f] GG pp spsp rere",
"sortOrder": 4,
"image": "rotaugen-schimmel.jpg"
},
@@ -168,7 +168,7 @@
},
{
"name": "Orangeschimmel",
"canonicalGenotype": "AA CC DD efef GG PP spsp rere",
"canonicalGenotype": "AA CC DD e[f]e[f] GG PP spsp rere",
"sortOrder": 25,
"image": "schimmel-orangeschimmel.jpg"
},
@@ -210,31 +210,31 @@
},
{
"name": "Silberschimmel",
"canonicalGenotype": "AA CC DD efef gg PP spsp rere",
"canonicalGenotype": "AA CC DD e[f]e[f] gg PP spsp rere",
"sortOrder": 32,
"image": "silberschimmel.jpg"
},
{
"name": "Polarfuchsschimmel",
"canonicalGenotype": "AA CC DD efef gg PP spsp rere",
"canonicalGenotype": "AA CC DD e[f]e[f] gg PP spsp rere",
"sortOrder": 33,
"image": "polarfuchsschimmel.jpg"
},
{
"name": "Algierfuchsschimmel",
"canonicalGenotype": "AA CC DD efef GG PP spsp rere",
"canonicalGenotype": "AA CC DD e[f]e[f] GG PP spsp rere",
"sortOrder": 34,
"image": "algierfuchsschimmel.jpg"
},
{
"name": "Kohlfuchsschimmel",
"canonicalGenotype": "aa CC DD efef GG PP spsp rere",
"canonicalGenotype": "aa CC DD e[f]e[f] GG PP spsp rere",
"sortOrder": 35,
"image": "kohlfuchsschimmel.jpg"
},
{
"name": "Blaufuchsschimmel",
"canonicalGenotype": "aa CC DD efef gg PP spsp rere",
"canonicalGenotype": "aa CC DD e[f]e[f] gg PP spsp rere",
"sortOrder": 36,
"image": "blaufuchsschimmel.jpg"
},
@@ -252,7 +252,7 @@
},
{
"name": "Goldfuchsschimmel",
"canonicalGenotype": "AA CC DD efef GG pp spsp rere",
"canonicalGenotype": "AA CC DD e[f]e[f] GG pp spsp rere",
"sortOrder": 39,
"image": "goldfuchsschimmel.jpg"
},
@@ -270,7 +270,7 @@
},
{
"name": "Rotfuchsschimmel",
"canonicalGenotype": "aa CC DD efef GG pp spsp rere",
"canonicalGenotype": "aa CC DD e[f]e[f] GG pp spsp rere",
"sortOrder": 42,
"image": "rotfuchsschimmel.jpg"
},
@@ -282,7 +282,7 @@
},
{
"name": "Kohlfuchsschimmel, hell",
"canonicalGenotype": "aa CC DD efef GG PP spsp rere",
"canonicalGenotype": "aa CC DD e[f]e[f] GG PP spsp rere",
"sortOrder": 44,
"image": "kohlfuchsschimmel-hell.jpg"
},
@@ -318,89 +318,89 @@
},
{
"name": "Marder",
"canonicalGenotype": "aa cchmcchm DD EE GG PP spsp rere",
"canonicalGenotype": "aa c[chm]c[chm] DD EE GG PP spsp rere",
"sortOrder": 50,
"image": "marder.JPG"
},
{
"name": "Siam",
"canonicalGenotype": "aa cchmch DD EE GG PP spsp rere",
"canonicalGenotype": "aa c[chm]c[h] DD EE GG PP spsp rere",
"sortOrder": 51,
"image": "siam-marder-hell.JPG"
},
{
"name": "Zobel-Hell",
"canonicalGenotype": "aa cchmch DD EE gg PP spsp rere",
"canonicalGenotype": "aa c[chm]c[h] DD EE gg PP spsp rere",
"sortOrder": 52,
"image": "zobel-hell.jpg"
},
{
"name": "CP-Agouti",
"canonicalGenotype": "AA cchmcchm DD EE GG PP spsp rere",
"canonicalGenotype": "AA c[chm]c[chm] DD EE GG PP spsp rere",
"sortOrder": 53,
"image": "agouti-cp.jpg"
},
{
"name": "CP-Agouti-Hell",
"canonicalGenotype": "AA cchmch DD EE GG PP spsp rere",
"canonicalGenotype": "AA c[chm]c[h] DD EE GG PP spsp rere",
"sortOrder": 54
},
{
"name": "CP-Silberagouti",
"canonicalGenotype": "AA cchmcchm DD EE gg PP spsp rere",
"canonicalGenotype": "AA c[chm]c[chm] DD EE gg PP spsp rere",
"sortOrder": 55,
"image": "silberagouti-cp.JPG"
},
{
"name": "CP-Silberagouti-Hell",
"canonicalGenotype": "AA cchmch DD EE gg PP spsp rere",
"canonicalGenotype": "AA c[chm]c[h] DD EE gg PP spsp rere",
"sortOrder": 56
},
{
"name": "CP-Algierfuchs",
"canonicalGenotype": "AA cchmcchm DD ee GG PP spsp rere",
"canonicalGenotype": "AA c[chm]c[chm] DD ee GG PP spsp rere",
"sortOrder": 57,
"image": "algierfuchs-cp.jpg"
},
{
"name": "CP-Algierfuchs-Hell",
"canonicalGenotype": "AA cchmch DD ee GG PP spsp rere",
"canonicalGenotype": "AA c[chm]c[h] DD ee GG PP spsp rere",
"sortOrder": 58
},
{
"name": "CP-Polarfuchs",
"canonicalGenotype": "AA cchmcchm DD ee gg PP spsp rere",
"canonicalGenotype": "AA c[chm]c[chm] DD ee gg PP spsp rere",
"sortOrder": 59,
"image": "polarfuchs-cp.jpg"
},
{
"name": "CP-Polarfuchs-Hell",
"canonicalGenotype": "AA cchmch DD ee gg PP spsp rere",
"canonicalGenotype": "AA c[chm]c[h] DD ee gg PP spsp rere",
"sortOrder": 60
},
{
"name": "CP-Fuchs",
"canonicalGenotype": "AA cchmcchm dd ee GG PP spsp rere",
"canonicalGenotype": "AA c[chm]c[chm] dd ee GG PP spsp rere",
"sortOrder": 61
},
{
"name": "CP-Fuchs-Hell",
"canonicalGenotype": "AA cchmch dd ee GG PP spsp rere",
"canonicalGenotype": "AA c[chm]c[h] dd ee GG PP spsp rere",
"sortOrder": 62
},
{
"name": "CP-Blaufuchs",
"canonicalGenotype": "AA cchmcchm dd ee gg PP spsp rere",
"canonicalGenotype": "AA c[chm]c[chm] dd ee gg PP spsp rere",
"sortOrder": 63
},
{
"name": "CP-Orangeschimmel",
"canonicalGenotype": "AA cchmcchm DD efef GG PP spsp rere",
"canonicalGenotype": "AA c[chm]c[chm] DD e[f]e[f] GG PP spsp rere",
"sortOrder": 64
},
{
"name": "CP-Orangeschimmel-Hell",
"canonicalGenotype": "AA cchmch DD efef GG PP spsp rere",
"canonicalGenotype": "AA c[chm]c[h] DD e[f]e[f] GG PP spsp rere",
"sortOrder": 65
}
]

View File

@@ -74,6 +74,32 @@ export function wildType(): Genotype {
return out
}
/**
* GEN-3h: breeder bracket-notation display symbols.
* STORAGE symbols (ef / cchm / ch) are frozen; only the rendered form changes.
*/
const DISPLAY_SYMBOL: Readonly<Partial<Record<string, string>>> = {
ef: 'e[f]',
cchm: 'c[chm]',
ch: 'c[h]',
}
function displaySymbol(allele: string): string {
return DISPLAY_SYMBOL[allele] ?? allele
}
/**
* GEN-3h: E-locus display order — breeder convention is E > e > e[f].
* Storage/dominance order is E > ef > e; display swaps ef and e so that
* a Fuchsschimmel (E=[ef,e] stored) renders as "ee[f]" not "e[f]e".
*/
const E_DISPLAY_RANK: Readonly<Record<string, number>> = { E: 0, e: 1, ef: 2 }
function displayPair(locus: LocusKey, pair: AllelePair): AllelePair {
if (locus !== 'E') return pair
const rank = (x: string) => E_DISPLAY_RANK[x] ?? Number.MAX_SAFE_INTEGER
return rank(pair[0]) <= rank(pair[1]) ? pair : [pair[1], pair[0]]
}
/**
* Compact display string, e.g. "Aa CC Dd EE GG Pp spsp rere".
* The Sls locus is OMITTED when wild-type (sl/sl) so legacy 8-locus strings and
@@ -81,12 +107,18 @@ export function wildType(): Genotype {
* (e.g. "… spsp rere Slsl"). Round-trips: a missing Sls re-parses to sl/sl.
* GEN-3c: unknown alleles are STORED as '?' but DISPLAYED as '-' (breeder
* convention) — e.g. ['C','?'] renders "C-".
* GEN-3h: sub-alleles use breeder bracket notation (e[f], c[chm], c[h]);
* E-locus display order is E > e > e[f] (e before e[f] in het pairs).
*/
export function toDisplayString(g: Genotype): string {
return LOCUS_ORDER.filter(
(locus) => locus !== 'Sls' || !(g.Sls[0] === 'sl' && g.Sls[1] === 'sl'),
)
.map((locus) => (g[locus][0] + g[locus][1]).replace(/\?/g, '-'))
.map((locus) => {
const [a, b] = displayPair(locus, g[locus])
return displaySymbol(a) + displaySymbol(b)
})
.map((s) => s.replace(/\?/g, '-'))
.join(' ')
}
@@ -146,8 +178,19 @@ function normalizeToken(tok: string): string | null {
if (t === 'WP') t = 'Slsl'
t = t.replace(/S\(l\)/g, 'Sl').replace(/s\(l\)/g, 'sl')
t = t.replace(/Uw/g, 'G').replace(/uw/g, 'g')
// GEN-3c: '-' is the breeder's UNKNOWN marker on input; store internally as '?'
// (the frozen storage contract keeps '?'; only DISPLAY renders '-').
// GEN-3h: accept bracket display notation → canonical internal symbols.
t = t.replace(/e\[f\]/g, 'ef') // Schimmel allele display form → internal
t = t.replace(/c\[chm\]/g, 'cchm') // Colourpoint display form → internal
t = t.replace(/c\[h\]/g, 'ch') // Himalayan display form → internal
// CR-1a: allele-prefixed bracket-unknown like ee[-] (Silvain).
// When e[-] is PRECEDED by a letter it is the second unknown allele in a
// 2-allele token (e.g. ee[-] → e + e[-] → e + ?). Lookbehind strips only
// the e[-] part; the leading allele stays. Standalone e[-] falls through to
// the generic [-]→? rule below (which makes the bracket-dash a wildcard,
// leaving the leading allele intact for splitToken).
t = t.replace(/(?<=[A-Za-z])e\[-\]/g, '?')
t = t.replace(/\[-\]/g, '?') // bare/standalone bracket-unknown → wildcard
// GEN-3c: plain dash is the breeder's UNKNOWN marker on input; store internally as '?'.
t = t.replace(/-/g, '?')
return t
}

View File

@@ -84,6 +84,34 @@
"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]",
"source": "Julian 2026-06-06 — HUMANQUESTION D4"
},
{
"name": "Little Runner's Big Ben",
"dob": "03.02.2020",
"decision": "P-locus = Pp (not PP) — the two sources differed only at P",
"genotype": "Aa Cc[chm] DD Ee Gg Pp Spsp",
"source": "Julian 2026-06-07 — HUMANQUESTION D6"
},
{
"name": "Vance Jr. von den Kleinen Chaoten",
"dob": "10.04.2022",
"decision": "Sp-locus = spsp (kleines spsp, ungescheckt) — die Quellen unterschieden sich nur bei Sp (Spsp // spsp). C-Locus 'c[hm]' im Extrakt → als c[chm] normalisiert (kein gültiges Symbol; c[chm] = offensichtliche Absicht).",
"genotype": "aa Cc[chm] Dd Ee gg P- spsp",
"source": "Julian 2026-06-07 — HUMANQUESTION D6"
},
{
"name": "Skarlett von den Kleinen Chaoten",
"dob": "14.07.2013",
"decision": "death date = 17.04.2016 (the '2018' variant was wrong — it had leaked as '/ +2018' into the genotype field; parse-leak already fixed in IMPORT-POLISH)",
"dateOfDeath": "17.04.2016",
"source": "Julian 2026-06-07 — HUMANQUESTION D6"
},
{
"name": "Kazu von den Kleinen Chaoten",
"dob": "23.04.2013",
"decision": "E-locus = ee[f], G-locus = GG (one source wrote UwUw = GG in international notation), P-locus = PP — resolves the 3 contested loci",
"genotype": "Aa Cc[chm] DD ee[f] GG PP Spsp",
"source": "Julian 2026-06-07 — HUMANQUESTION D6"
}
]
}

View File

@@ -973,7 +973,19 @@ def apply_conflict_decisions(merged, conflicts, path):
continue
a["resolvedByDecision"] = True
if d.get("genotype"):
a["genotype"] = gt.parse(d["genotype"])
# CR-10: validate the parsed genotype — a typo'd decision string yields empty
# mapped8locus and would silently blank the animal's genotype while marking it
# 'resolved'. Only apply if the parse produces non-empty loci.
parsed = gt.parse(d["genotype"])
if parsed.get("mapped8locus"):
a["genotype"] = parsed
else:
# Keep the existing genotype; flag as a warning in the report.
a.setdefault("decisionWarnings", []).append(
f"Ungültiger Override-Genotyp '{d['genotype']}'"
"konnte nicht geparst werden (mapped8locus leer). "
"Bestehender Genotyp behalten; Konflikt wurde trotzdem aufgelöst."
)
if d.get("farbschlag"):
a["farbschlag"] = d["farbschlag"]
a["farbschlagVariants"] = [d["farbschlag"]]

View File

@@ -280,6 +280,36 @@ check("gen.+v.d. name rejected", e.looks_like_animal_name("Victoria Welby gen. W
check("real Farbschlag accepted", not e.looks_like_animal_name("Kohlfuchsschimmel"))
check("real Farbschlag accepted 2", not e.looks_like_animal_name("Orangeschimmel, hell"))
# --- CR-10: malformed decision genotype must NOT blank the existing genotype ---
dec_cr10 = os.path.join(tempfile.gettempdir(), "decisions-cr10.json")
_json.dump({"resolutions": [
# Valid decision (genotype parses OK) -> should be applied
{"name": "Agouti OK", "dob": "01.01.2020", "decision": "test",
"genotype": "aa CC DD ee GG PP spsp rere", "source": "test"},
# Malformed genotype (typo'd) -> must NOT blank genotype; conflict still resolved
{"name": "Siamese Bad", "dob": "02.02.2020", "decision": "test",
"genotype": "BLÖDSINN!!!", "source": "test"},
]}, open(dec_cr10, "w", encoding="utf-8"))
merged_cr10 = [
{"id": "g1", "name": "Agouti OK", "dob": "01.01.2020", "conflict": True, "farbschlag": "", "death": "",
"genotype": {"mapped8locus": {"A": ["a","a"]}, "rawGenotype": "aa", "unmappedTokens": []}},
{"id": "g2", "name": "Siamese Bad", "dob": "02.02.2020", "conflict": True, "farbschlag": "", "death": "",
"genotype": {"mapped8locus": {"C": ["c^h","c^h"]}, "rawGenotype": "chmchm", "unmappedTokens": []}},
]
conflicts_cr10 = [{"id": "g1"}, {"id": "g2"}]
n_cr10 = e.apply_conflict_decisions(merged_cr10, conflicts_cr10, dec_cr10)
check("CR-10: valid decision genotype is applied (A-locus updated)",
merged_cr10[0]["genotype"]["mapped8locus"].get("C") == ["C","C"])
check("CR-10: malformed decision genotype NOT applied (C-locus preserved)",
merged_cr10[1]["genotype"]["mapped8locus"].get("C") == ["c^h","c^h"])
check("CR-10: malformed decision still un-quarantines the animal",
merged_cr10[1].get("conflict") is False)
check("CR-10: malformed decision adds a decisionWarning",
bool(merged_cr10[1].get("decisionWarnings")))
check("CR-10: apply returns correct resolved count (2 conflicts cleared)", n_cr10 == 2)
try: os.remove(dec_cr10)
except OSError: pass
# --- TOLERANT KC-MATCHER (IMPORT-BACKFILL): all clan spelling variants -> canon 'kleinechaote' ---
# Julian-Entscheidung: Zucht = Kleine Chaoten wenn 'klein'+'chaoten' ODER bekannte Abkürzungen.
# The v.d. fix: trailing \b after '.' failed when next char is ' ' (non-word), so