banyaro/backend/routes/zucht_ki.py
rene 1ff66a7083 Sicherheit + Tests + A11y, SW by-v1118
PYDANTIC max_length (38 Routen, ~400 Field-Constraints):
Schützt vor DoS durch Riesen-Payloads (10MB Thread-Titel etc.).
Pragmatische Limits:
- Titel/Name: 200 · Beschreibung/Body: 10000 · Notiz: 5000
- Email: 254 (RFC 5321) · URL: 500 · Slug/Kategorie: 100
- Hund-Name/Rasse: 80 · Hund-Bio: 2000

Top-betroffen: forum.py, diary.py, health.py, dogs.py, expenses.py,
notes.py, auth.py, profile.py. Manuelle len()-Checks in profile,
chat, ki entfernt (jetzt durch Field abgedeckt).

PYTEST COVERAGE (+19 Tests, 37 grün + 1 xfail):
- test_security.py: require_owner (Places GET/PATCH/DELETE mit
  Fremduser → 403), JWT-Blacklist (Logout invalidiert Token),
  Login-Lockout (5 Fehlversuche → 429 + Retry-After Header)
- test_race.py: Invoice-Counter (20 parallele Threads, alle unique),
  Founder-Number (atomare Vergabe, voll bei 100)
- test_validation.py: Forum-Titel 30k Zeichen → 422, Diary-Text
  50k → 422 (verifiziert Pydantic max_length-Sweep)

A11Y (Tap-Targets ≥44×44 + Dark-Mode-Kontrast):
- #header-user-btn 36→44px, .header-back 40→44, .header-menu-btn 40→44
- dog-profile Wrapped-Slider Prev/Next 40→44
- forum-Lightbox Close 40→44
- --c-text-muted Light: #B0A090 (2.37:1 FAIL) → #7F6B58 (4.74:1 PASS)
- --c-text-muted Dark:  #806A58 (3.58:1 FAIL) → #A08878 (5.46:1 PASS)
- Branding-Farben unangetastet
2026-05-27 13:40:30 +02:00

506 lines
19 KiB
Python

"""BAN YARO — KI-Features für Züchter (5 Endpunkte)"""
import logging
from datetime import date, timedelta
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from typing import Optional, Literal
from database import db
from auth import get_current_user
import ki
router = APIRouter()
logger = logging.getLogger(__name__)
_FALLBACK = "KI-Analyse momentan nicht verfügbar. Bitte versuche es später erneut."
# ------------------------------------------------------------------
# Dependency: nur verifizierte Züchter + Admins
# ------------------------------------------------------------------
def _require_breeder(user=Depends(get_current_user)):
if user["rolle"] not in ("breeder", "admin"):
raise HTTPException(403, "Nur für Züchter.")
return user
# ------------------------------------------------------------------
# Schemas
# ------------------------------------------------------------------
class WurfankuendigungBody(BaseModel):
litter_id: int
class GenetikErklaerungBody(BaseModel):
litter_id: int
zielgruppe: Literal["kaeufer", "zuechter"] = "kaeufer"
class PaarungAnalyseBody(BaseModel):
vater_id: int
mutter_id: int
ik_prozent: Optional[float] = None
welfare_level: Optional[str] = Field(None, max_length=50)
class HundBeschreibungBody(BaseModel):
hund_id: int
# ------------------------------------------------------------------
# Hilfsfunktionen: DB-Daten laden
# ------------------------------------------------------------------
def _load_hund(conn, hund_id: int) -> dict:
row = conn.execute("SELECT * FROM zucht_hunde WHERE id=?", (hund_id,)).fetchone()
if not row:
raise HTTPException(404, f"Hund {hund_id} nicht gefunden.")
return dict(row)
def _load_gesundheitstests(conn, hund_id: int) -> list[dict]:
rows = conn.execute(
"SELECT test_typ, test_name, ergebnis, untersuch_am FROM dog_health_tests WHERE hund_id=?",
(hund_id,),
).fetchall()
return [dict(r) for r in rows]
def _load_gentests(conn, hund_id: int) -> list[dict]:
rows = conn.execute(
"SELECT marker_name, marker_kategorie, genotyp, ergebnis_klasse FROM dog_genetic_tests WHERE hund_id=?",
(hund_id,),
).fetchall()
return [dict(r) for r in rows]
def _load_titel(conn, hund_id: int) -> list[dict]:
rows = conn.execute(
"SELECT titel_typ, titel_name, verliehen_am FROM dog_titles WHERE hund_id=?",
(hund_id,),
).fetchall()
return [dict(r) for r in rows]
def _fmt_gesundheit(tests: list[dict]) -> str:
if not tests:
return " (keine Einträge)"
return "\n".join(f" - {t['test_name'] or t['test_typ']}: {t['ergebnis']} ({t['untersuch_am']})" for t in tests)
def _fmt_gentests(tests: list[dict]) -> str:
if not tests:
return " (keine Einträge)"
return "\n".join(f" - {t['marker_name']} ({t['marker_kategorie'] or ''}): {t['genotyp']}{t['ergebnis_klasse'] or ''}" for t in tests)
def _fmt_titel(titel: list[dict]) -> str:
if not titel:
return " (keine Titel)"
return "\n".join(f" - {t['titel_name']} ({t['titel_typ']}, {t['verliehen_am']})" for t in titel)
def _hund_block(hund: dict, gesundheit: list, gentests: list, titel: list, label: str) -> str:
return (
f"=== {label} ===\n"
f"Name: {hund.get('name')} (Rufname: {hund.get('rufname') or ''})\n"
f"Geschlecht: {hund.get('geschlecht') or ''}\n"
f"Zuchtbuchnummer: {hund.get('zuchtbuchnummer') or ''}\n"
f"Geburtsdatum: {hund.get('geburtsdatum') or ''}\n"
f"Farbe: {hund.get('farbe') or ''}\n"
f"\nGesundheitstests:\n{_fmt_gesundheit(gesundheit)}\n"
f"\nGentests:\n{_fmt_gentests(gentests)}\n"
f"\nTitel:\n{_fmt_titel(titel)}"
)
# ------------------------------------------------------------------
# 1. POST /api/zucht-ki/wurfankuendigung
# ------------------------------------------------------------------
@router.post("/zucht-ki/wurfankuendigung")
async def wurfankuendigung(body: WurfankuendigungBody, user=Depends(_require_breeder)):
if not user.get("ki_zucht_wurfankuendigung", 1):
raise HTTPException(403, "KI-Feature 'Wurfankündigung' ist für diesen Account deaktiviert.")
with db() as conn:
wurf = conn.execute("SELECT * FROM litters WHERE id=?", (body.litter_id,)).fetchone()
if not wurf:
raise HTTPException(404, "Wurf nicht gefunden.")
wurf = dict(wurf)
vater = _load_hund(conn, wurf["vater_id"]) if wurf.get("vater_id") else None
mutter = _load_hund(conn, wurf["mutter_id"]) if wurf.get("mutter_id") else None
vater_gesundheit = _load_gesundheitstests(conn, vater["id"]) if vater else []
vater_gentests = _load_gentests(conn, vater["id"]) if vater else []
vater_titel = _load_titel(conn, vater["id"]) if vater else []
mutter_gesundheit = _load_gesundheitstests(conn, mutter["id"]) if mutter else []
mutter_gentests = _load_gentests(conn, mutter["id"]) if mutter else []
mutter_titel = _load_titel(conn, mutter["id"]) if mutter else []
# Elternteil-Blöcke aufbauen
vater_block = (
_hund_block(vater, vater_gesundheit, vater_gentests, vater_titel, "VATER")
if vater
else f"=== VATER ===\nName: {wurf.get('vater_name') or ''} (nicht in Zuchtkartei)"
)
mutter_block = (
_hund_block(mutter, mutter_gesundheit, mutter_gentests, mutter_titel, "MUTTER")
if mutter
else f"=== MUTTER ===\nName: {wurf.get('mutter_name') or ''} (nicht in Zuchtkartei)"
)
system = (
"Du bist ein Experte für Hundezucht und hilfst Züchtern dabei, "
"professionelle Texte für ihre Wurfbörse zu erstellen. "
"Antworte ausschließlich auf Deutsch."
)
prompt = f"""
Schreibe eine professionelle, einladende Wurfankündigung für die Wurfbörse einer Hunde-App.
Basiere dich NUR auf den gegebenen Daten. Max. 4 kurze Absätze.
Ton: sachlich und herzlich. Keine Übertreibungen. Auf Deutsch.
=== WURF-DATEN ===
Geburtsdatum: {wurf.get('geburt_datum') or wurf.get('erwartetes_datum') or ''}
Welpen gesamt: {wurf.get('welpen_gesamt') or ''}
Preis: {wurf.get('preis_spanne') or ''}
Beschreibung: {wurf.get('beschreibung') or ''}
{vater_block}
{mutter_block}
"""
try:
text = await ki.complete(
prompt=prompt,
system=system,
max_tokens=600,
requires_premium=False,
user_id=user["id"],
)
return {"text": text}
except Exception as e:
logger.warning(f"KI nicht verfügbar: {e}")
return {"text": _FALLBACK}
# ------------------------------------------------------------------
# 2. POST /api/zucht-ki/genetik-erklaerung
# ------------------------------------------------------------------
@router.post("/zucht-ki/genetik-erklaerung")
async def genetik_erklaerung(body: GenetikErklaerungBody, user=Depends(_require_breeder)):
if not user.get("ki_zucht_genetik", 1):
raise HTTPException(403, "KI-Feature 'Genetik-Erklärung' ist für diesen Account deaktiviert.")
with db() as conn:
wurf = conn.execute("SELECT * FROM litters WHERE id=?", (body.litter_id,)).fetchone()
if not wurf:
raise HTTPException(404, "Wurf nicht gefunden.")
wurf = dict(wurf)
vater = _load_hund(conn, wurf["vater_id"]) if wurf.get("vater_id") else None
mutter = _load_hund(conn, wurf["mutter_id"]) if wurf.get("mutter_id") else None
vater_gentests = _load_gentests(conn, vater["id"]) if vater else []
mutter_gentests = _load_gentests(conn, mutter["id"]) if mutter else []
# Risiko-Marker sammeln (vereinfacht: alles was nicht "frei" ist)
risks = []
for t in vater_gentests + mutter_gentests:
klasse = (t.get("ergebnis_klasse") or "").lower()
if klasse and klasse not in ("frei", "clear", "negativ", ""):
risks.append(f"{t['marker_name']}: {t['ergebnis_klasse']}")
vater_gt_block = _fmt_gentests(vater_gentests)
mutter_gt_block = _fmt_gentests(mutter_gentests)
if body.zielgruppe == "kaeufer":
anweisung = (
"Erkläre diese Gentestergebnisse für einen Welpen-Käufer ohne Fachkenntnisse. "
"Beantworte konkret: Was bedeutet das für meinen Welpen im Alltag? "
"Welche Vorsichtsmaßnahmen gibt es? Max. 200 Wörter, sehr verständlich."
)
else:
anweisung = (
"Analysiere diese Gentestergebnisse aus züchterischer Sicht. "
"Bewerte mögliche genetische Kombinationen beim Nachwuchs. "
"Welche Marker sind kritisch? Was bedeutet das für künftige Paarungen? "
"Max. 200 Wörter, fachlich präzise."
)
system = (
"Du bist ein Experte für Hundegenetik und Tierschutz. "
"Antworte ausschließlich auf Deutsch."
)
prompt = f"""
{anweisung}
=== GENTESTS VATER ({(vater or {}).get('name', wurf.get('vater_name', ''))}) ===
{vater_gt_block}
=== GENTESTS MUTTER ({(mutter or {}).get('name', wurf.get('mutter_name', ''))}) ===
{mutter_gt_block}
"""
try:
text = await ki.complete(
prompt=prompt,
system=system,
max_tokens=500,
requires_premium=False,
user_id=user["id"],
)
return {"text": text, "risks": risks}
except Exception as e:
logger.warning(f"KI nicht verfügbar: {e}")
return {"text": _FALLBACK, "risks": risks}
# ------------------------------------------------------------------
# 3. POST /api/zucht-ki/paarung-analyse
# ------------------------------------------------------------------
@router.post("/zucht-ki/paarung-analyse")
async def paarung_analyse(body: PaarungAnalyseBody, user=Depends(_require_breeder)):
if not user.get("ki_zucht_paarung", 1):
raise HTTPException(403, "KI-Feature 'Paarungs-Analyse' ist für diesen Account deaktiviert.")
with db() as conn:
vater = _load_hund(conn, body.vater_id)
mutter = _load_hund(conn, body.mutter_id)
vater_gesundheit = _load_gesundheitstests(conn, body.vater_id)
vater_gentests = _load_gentests(conn, body.vater_id)
vater_titel = _load_titel(conn, body.vater_id)
mutter_gesundheit = _load_gesundheitstests(conn, body.mutter_id)
mutter_gentests = _load_gentests(conn, body.mutter_id)
mutter_titel = _load_titel(conn, body.mutter_id)
ik_info = f"{body.ik_prozent:.1f}%" if body.ik_prozent is not None else "nicht berechnet"
welfare_info = body.welfare_level or "nicht angegeben"
system = (
"Du bist ein erfahrener Zuchtwart und Experte für verantwortungsvolle Hundezucht. "
"Antworte ausschließlich auf Deutsch."
)
prompt = f"""
Bewerte diese Hundepaarung aus züchterischer Sicht.
Berücksichtige: Inzuchtkoeffizient, Gesundheitsprofil, Ausstellungserfolge, Stärken und Schwächen.
Gib eine konkrete Empfehlung. Max. 150 Wörter. Sachlich und direkt.
Inzuchtkoeffizient (IK): {ik_info}
Welfare-Level: {welfare_info}
{_hund_block(vater, vater_gesundheit, vater_gentests, vater_titel, "VATER")}
{_hund_block(mutter, mutter_gesundheit, mutter_gentests, mutter_titel, "MUTTER")}
"""
try:
text = await ki.complete(
prompt=prompt,
system=system,
max_tokens=400,
requires_premium=False,
user_id=user["id"],
)
# Empfehlung aus Text ableiten
text_lower = text.lower()
if any(w in text_lower for w in ("nicht empfohlen", "abraten", "nicht zu empfehlen", "nicht empfehle")):
empfehlung = "nicht_empfohlen"
elif any(w in text_lower for w in ("bedingt", "vorbehalt", "einschränkung", "vorsicht")):
empfehlung = "bedingt"
else:
empfehlung = "empfohlen"
return {"text": text, "empfehlung": empfehlung}
except Exception as e:
logger.warning(f"KI nicht verfügbar: {e}")
return {"text": _FALLBACK, "empfehlung": "bedingt"}
# ------------------------------------------------------------------
# 4. POST /api/zucht-ki/hund-beschreibung
# ------------------------------------------------------------------
@router.post("/zucht-ki/hund-beschreibung")
async def hund_beschreibung(body: HundBeschreibungBody, user=Depends(_require_breeder)):
if not user.get("ki_zucht_beschreibung", 1):
raise HTTPException(403, "KI-Feature 'Hund-Beschreibung' ist für diesen Account deaktiviert.")
with db() as conn:
hund = _load_hund(conn, body.hund_id)
gesundheit = _load_gesundheitstests(conn, body.hund_id)
gentests = _load_gentests(conn, body.hund_id)
titel = _load_titel(conn, body.hund_id)
system = (
"Du bist ein Experte für Hundezucht und hilfst Züchtern, "
"ansprechende Profilbeschreibungen für ihre Hunde zu schreiben. "
"Antworte ausschließlich auf Deutsch."
)
prompt = f"""
Schreibe eine ansprechende Beschreibung für das öffentliche Hunde-Profil.
Hebe Stärken hervor (Gesundheit, Titel, Charakter falls beschrieben).
Max. 3 kurze Absätze. Professionell aber warmherzig. Keine Erfindungen.
{_hund_block(hund, gesundheit, gentests, titel, "HUND")}
Notiz des Züchters: {hund.get('notiz') or ''}
"""
try:
text = await ki.complete(
prompt=prompt,
system=system,
max_tokens=500,
requires_premium=False,
user_id=user["id"],
)
return {"text": text}
except Exception as e:
logger.warning(f"KI nicht verfügbar: {e}")
return {"text": _FALLBACK}
# ------------------------------------------------------------------
# 5. POST /api/zucht-ki/jahresbericht
# ------------------------------------------------------------------
@router.post("/zucht-ki/jahresbericht")
async def jahresbericht(user=Depends(_require_breeder)):
if not user.get("ki_zucht_jahresbericht", 1):
raise HTTPException(403, "KI-Feature 'Jahresbericht' ist für diesen Account deaktiviert.")
zwei_jahre_ago = (date.today() - timedelta(days=730)).isoformat()
with db() as conn:
# Breeder-Profil
bp = conn.execute(
"SELECT id, zwingername, rasse_text FROM breeder_profiles WHERE user_id=?",
(user["id"],),
).fetchone()
if not bp:
raise HTTPException(404, "Kein Züchter-Profil gefunden.")
breeder_id = bp["id"]
zwingername = bp["zwingername"] or "Unbekannt"
rasse_text = bp["rasse_text"] or ""
# Würfe der letzten 2 Jahre
wuerfe = conn.execute(
"""SELECT geburt_datum, welpen_gesamt, welpen_verfuegbar, status, welfare_level
FROM litters
WHERE breeder_id=? AND geburt_datum >= ?
ORDER BY geburt_datum DESC""",
(breeder_id, zwei_jahre_ago),
).fetchall()
wuerfe = [dict(w) for w in wuerfe]
# Eigene Zuchthunde
hunde = conn.execute(
"SELECT id, name, geschlecht FROM zucht_hunde WHERE breeder_id=?",
(breeder_id,),
).fetchall()
hunde = [dict(h) for h in hunde]
hund_ids = [h["id"] for h in hunde]
# Gesundheitstests aller Hunde aggregieren
alle_gesundheitstests: list[dict] = []
for hid in hund_ids:
tests = _load_gesundheitstests(conn, hid)
for t in tests:
t["hund_id"] = hid
alle_gesundheitstests.extend(tests)
# Zusammenfassung für den Prompt aufbauen
wurf_zeilen = []
for w in wuerfe:
zeile = f" - {w['geburt_datum'] or '?'}: {w['welpen_gesamt'] or '?'} Welpen, Status: {w['status']}"
if w.get("welfare_level"):
zeile += f", Welfare: {w['welfare_level']}"
wurf_zeilen.append(zeile)
wuerfe_text = "\n".join(wurf_zeilen) if wurf_zeilen else " (keine Würfe im Zeitraum)"
# Gesundheitstrend: welche Tests wurden gemacht?
test_typen: dict[str, int] = {}
for t in alle_gesundheitstests:
key = t.get("test_name") or t.get("test_typ") or "Unbekannt"
test_typen[key] = test_typen.get(key, 0) + 1
gesundheit_text = (
"\n".join(f" - {name}: {anz}x" for name, anz in sorted(test_typen.items(), key=lambda x: -x[1]))
if test_typen else " (keine Gesundheitstests erfasst)"
)
hunde_text = (
"\n".join(f" - {h['name']} ({h['geschlecht'] or ''})" for h in hunde)
if hunde else " (keine Hunde in der Zuchtkartei)"
)
system = (
"Du bist ein erfahrener Zuchtwart und Berater für verantwortungsvolle Hundezucht. "
"Erstelle konstruktive, sachliche Auswertungen für Züchter auf Deutsch."
)
prompt = f"""
Erstelle eine kurze züchterische Jahresauswertung.
Analysiere: Anzahl Würfe, Gesundheitstrends, Stärken und Verbesserungspotenziale.
Max. 200 Wörter. Konstruktiv und sachlich. Mit 2-3 konkreten Empfehlungen.
Zwingername: {zwingername}
Rasse: {rasse_text}
Zeitraum: letzte 2 Jahre (bis {date.today().isoformat()})
=== WÜRFE ===
{wuerfe_text}
=== ZUCHTHUNDE ===
{hunde_text}
=== GESUNDHEITSTESTS (Häufigkeit) ===
{gesundheit_text}
"""
try:
text = await ki.complete(
prompt=prompt,
system=system,
max_tokens=600,
requires_premium=False,
user_id=user["id"],
)
except Exception as e:
logger.warning(f"KI nicht verfügbar: {e}")
text = _FALLBACK
# Bericht speichern
jahr = date.today().year
with db() as conn:
bp2 = conn.execute("SELECT id FROM breeder_profiles WHERE user_id=?", (user["id"],)).fetchone()
if bp2:
conn.execute(
"INSERT INTO breeder_jahresberichte (user_id, breeder_id, jahr, text) VALUES (?,?,?,?)",
(user["id"], bp2["id"], jahr, text)
)
saved_id = conn.execute("SELECT last_insert_rowid()").fetchone()[0]
else:
saved_id = None
return {"text": text, "saved_id": saved_id, "jahr": jahr}
# GET gespeicherte Berichte
# ------------------------------------------------------------------
@router.get("/zucht-ki/jahresbericht")
async def jahresbericht_list(user=Depends(_require_breeder)):
with db() as conn:
rows = conn.execute(
"SELECT id, jahr, created_at FROM breeder_jahresberichte WHERE user_id=? ORDER BY created_at DESC LIMIT 20",
(user["id"],)
).fetchall()
return [{"id": r["id"], "jahr": r["jahr"], "created_at": r["created_at"]} for r in rows]
@router.get("/zucht-ki/jahresbericht/{bericht_id}")
async def jahresbericht_get(bericht_id: int, user=Depends(_require_breeder)):
with db() as conn:
row = conn.execute(
"SELECT id, jahr, text, created_at FROM breeder_jahresberichte WHERE id=? AND user_id=?",
(bericht_id, user["id"])
).fetchone()
if not row:
raise HTTPException(404, "Bericht nicht gefunden.")
return dict(row)