Feature: Tierschutz-Check, KI-Züchter-Features, Export, SEO-Update

Tierschutz-System (immer aktiv, nicht abschaltbar):
- welfare_check.py: regelbasierte Prüfung IK, Alter, Deckpause, Wurfanzahl, Genetik
- Grün/Gelb/Rot-Modal bei Wurf anlegen + Probeverpaarung
- Bei kritischem Befund + "Trotzdem fortfahren" → automatische Admin-Mail
- Tierschutz-Check nie durch Nutzer deaktivierbar

KI-Züchter-Features (pro User an/abschaltbar außer Tierschutz):
- routes/zucht_ki.py: 5 Endpunkte — Wurfankündigung, Genetik-Erklärung,
  Paarungsanalyse, Hund-Beschreibung, Jahresbericht
- Toggles in Einstellungen (ki_zucht_* Felder)
- KI-Buttons in litters.js + zuchthunde.js

KI-Routing: Privilegierte Rollen (Admin, Züchter, Moderator, Manager)
nutzen Claude Sonnet primär, lokales LLM als Fallback

Datenexport: routes/breeder_export.py — ZIP mit HTML-Dossier + ODS
(odfpy hinzugefügt in requirements.txt)

Admin-Profil: POST /admin/breeder/create-profile für Schnellprofil ohne
Antragsprozess; Admin-Rolle bleibt erhalten

Wurfformular: Dropdown aus Zuchtkartei für Vater/Mutter mit Auto-Fill;
litters.vater_id + mutter_id als FK auf zucht_hunde

Probeverpaarung: heart-fill Icon + Welfare-Block im Ergebnis

Landing Page: Züchter-Section + Feature-Gruppe, Meta-Tags, JSON-LD,
keywords, softwareVersion 2.1

SEO: llms.txt vollständig überarbeitet, robots.txt Züchter-Pfade,
sitemap.xml um Wurfbörse + Züchter-Profile erweitert

SW by-v474, APP_VER 451
This commit is contained in:
rene 2026-04-28 19:49:54 +02:00
parent 91340be5a3
commit c8ae514c01
20 changed files with 2129 additions and 200 deletions

View file

@ -52,7 +52,7 @@ async def breeder_status(user=Depends(get_current_user)):
if not row:
raise HTTPException(404, "User nicht gefunden.")
profile = None
if row["rolle"] == "breeder":
if row["rolle"] in ("breeder", "admin"):
profile = conn.execute(
"SELECT zwingername, rasse_text, verein, vdh_mitglied, stadt, website, beschreibung, verified_at "
"FROM breeder_profiles WHERE user_id=?",
@ -318,6 +318,28 @@ async def breeder_public_profile(zwingername: str):
return dict(row)
# ------------------------------------------------------------------
# POST /api/admin/breeder/create-profile — Admin-Schnellprofil
# ------------------------------------------------------------------
@router.post("/admin/breeder/create-profile")
async def admin_create_profile(admin=Depends(require_admin)):
with db() as conn:
existing = conn.execute(
"SELECT id FROM breeder_profiles WHERE user_id=?", (admin["id"],)
).fetchone()
if existing:
return {"message": "Profil existiert bereits.", "profile_id": existing["id"]}
cur = conn.execute(
"INSERT INTO breeder_profiles (user_id, zwingername, rasse_text, verein, stadt, verified_at) "
"VALUES (?, ?, ?, ?, ?, datetime('now'))",
(admin["id"], "Admin-Zwinger", "Alle Rassen", "Admin", "Überall")
)
conn.execute(
"UPDATE users SET breeder_status='approved' WHERE id=?", (admin["id"],)
)
return {"message": "Admin-Züchterprofil angelegt.", "profile_id": cur.lastrowid}
# ------------------------------------------------------------------
# PUT /api/breeder/profile — eigenes Profil bearbeiten
# ------------------------------------------------------------------

View file

@ -0,0 +1,497 @@
"""BAN YARO — Züchter-Datenexport (HTML + ODS)"""
import io
import zipfile
import logging
from datetime import date, datetime
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import StreamingResponse
from database import db
from auth import get_current_user
router = APIRouter()
logger = logging.getLogger(__name__)
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
# ------------------------------------------------------------------
# Datenabruf
# ------------------------------------------------------------------
def _collect_data(user_id: int) -> dict:
with db() as conn:
profile = conn.execute(
"SELECT bp.*, u.name AS user_name, u.email "
"FROM breeder_profiles bp JOIN users u ON u.id = bp.user_id "
"WHERE bp.user_id=?", (user_id,)
).fetchone()
if not profile:
return None
bid = profile["id"]
hunde = conn.execute(
"SELECT * FROM zucht_hunde WHERE breeder_id=? ORDER BY name", (bid,)
).fetchall()
hund_ids = [h["id"] for h in hunde]
def rows_for(table, id_col):
if not hund_ids:
return []
placeholders = ",".join("?" * len(hund_ids))
return conn.execute(
f"SELECT * FROM {table} WHERE {id_col} IN ({placeholders}) ORDER BY {id_col}",
hund_ids
).fetchall()
health = rows_for("dog_health_tests", "hund_id")
genetic = rows_for("dog_genetic_tests", "hund_id")
titles = rows_for("dog_titles", "hund_id")
litters = conn.execute(
"SELECT * FROM litters WHERE breeder_id=? ORDER BY created_at DESC", (bid,)
).fetchall()
litter_ids = [l["id"] for l in litters]
puppies, weights = [], []
if litter_ids:
placeholders = ",".join("?" * len(litter_ids))
puppies = conn.execute(
f"SELECT * FROM puppies WHERE wurf_id IN ({placeholders}) ORDER BY wurf_id",
litter_ids
).fetchall()
puppy_ids = [p["id"] for p in puppies]
if puppy_ids:
ph2 = ",".join("?" * len(puppy_ids))
weights = conn.execute(
f"SELECT * FROM puppy_weights WHERE welpe_id IN ({ph2}) ORDER BY welpe_id, gemessen_am",
puppy_ids
).fetchall()
# Stammbaum pro Hund (3 Generationen, flach)
def pedigree_flat(hund_id, depth=3):
if not hund_id or depth == 0:
return {}
row = conn.execute("SELECT id, name, zuchtbuchnummer, geburtsdatum FROM zucht_hunde WHERE id=?", (hund_id,)).fetchone()
if not row:
return {}
result = {"name": row["name"], "nr": row["zuchtbuchnummer"] or "", "geb": row["geburtsdatum"] or ""}
row2 = conn.execute("SELECT vater_id, mutter_id FROM zucht_hunde WHERE id=?", (hund_id,)).fetchone()
if row2:
result["vater"] = pedigree_flat(row2["vater_id"], depth - 1)
result["mutter"] = pedigree_flat(row2["mutter_id"], depth - 1)
return result
pedigrees = {h["id"]: pedigree_flat(h["id"]) for h in hunde}
return {
"profile": dict(profile),
"hunde": [dict(h) for h in hunde],
"health": [dict(r) for r in health],
"genetic": [dict(r) for r in genetic],
"titles": [dict(r) for r in titles],
"litters": [dict(l) for l in litters],
"puppies": [dict(p) for p in puppies],
"weights": [dict(w) for w in weights],
"pedigrees": pedigrees,
}
# ------------------------------------------------------------------
# HTML-Export
# ------------------------------------------------------------------
def _fmt_date(val):
if not val:
return ""
try:
return datetime.strptime(str(val)[:10], "%Y-%m-%d").strftime("%d.%m.%Y")
except Exception:
return str(val)
def _health_badge(test_typ, ergebnis):
color = "#6B7280"
e = (ergebnis or "").upper()
if test_typ == "HD":
color = {"A1": "#16a34a", "A2": "#16a34a", "B1": "#86efac", "B2": "#86efac",
"C": "#eab308", "C1": "#eab308", "C2": "#eab308",
"D": "#f97316", "D1": "#f97316", "D2": "#f97316",
"E": "#dc2626", "E1": "#dc2626", "E2": "#dc2626"}.get(e, "#6B7280")
elif test_typ == "ED":
color = {"0": "#16a34a", "1": "#eab308", "2": "#f97316", "3": "#dc2626"}.get(e, "#6B7280")
elif ergebnis in ("clear", "frei"):
color = "#16a34a"
elif ergebnis == "carrier":
color = "#eab308"
elif ergebnis == "affected":
color = "#dc2626"
return f'<span style="background:{color};color:#fff;padding:2px 8px;border-radius:4px;font-size:11px;font-weight:600">{ergebnis}</span>'
def _pedigree_node_html(node, gen):
if not node:
return '<div style="background:#f3f4f6;border:1px dashed #d1d5db;border-radius:6px;padding:6px 8px;color:#9ca3af;font-size:11px;height:100%;box-sizing:border-box">Unbekannt</div>'
geb = f"*{node['geb'][:4]}" if node.get("geb") else ""
nr = node.get("nr", "")
bg = "#7c3aed" if gen == 1 else ("#ede9fe" if gen == 2 else ("#f5f3ff" if gen == 3 else "#faf9ff"))
col = "#fff" if gen == 1 else "#1f2937"
return (
f'<div style="background:{bg};color:{col};border-radius:6px;padding:6px 8px;'
f'font-size:11px;height:100%;box-sizing:border-box;border:1px solid #e5e7eb">'
f'<div style="font-weight:700;margin-bottom:2px">{node["name"]}</div>'
+ (f'<div style="opacity:.7">{nr}</div>' if nr else "")
+ (f'<div style="opacity:.7">{geb}</div>' if geb else "")
+ "</div>"
)
def _pedigree_html(tree):
# 4-Gen horizontaler Stammbaum als Tabelle
def nodes(t, gen, max_gen=4):
if gen > max_gen:
return []
item = [{"node": t, "gen": gen}]
item += nodes(t.get("vater") if t else None, gen + 1, max_gen)
item += nodes(t.get("mutter") if t else None, gen + 1, max_gen)
return item
rows = 8 # 2^3 für 4 Generationen
cols = 4
def collect(node, gen, row_start, row_span):
if gen > cols:
return []
items = [{"node": node, "gen": gen, "row": row_start, "span": row_span}]
half = row_span // 2
items += collect(node.get("vater") if node else None, gen + 1, row_start, half)
items += collect(node.get("mutter") if node else None, gen + 1, row_start + half, half)
return items
cells = collect(tree, 1, 0, rows)
gen_labels = {1: "Proband", 2: "Eltern", 3: "Großeltern", 4: "Urgroßeltern"}
# Grid als Tabelle (row_height = 50px)
row_h = 52
total_h = rows * row_h
html = (
f'<div style="overflow-x:auto;margin:12px 0">'
f'<div style="display:grid;grid-template-columns:repeat({cols},1fr);'
f'grid-template-rows:repeat({rows},{row_h}px);gap:4px;min-width:{cols*160}px">'
)
for c in cells:
node, gen, row, span = c["node"], c["gen"], c["row"], c["span"]
html += (
f'<div style="grid-column:{gen};grid-row:{row+1}/span {span};padding:2px">'
+ _pedigree_node_html(node, gen)
+ "</div>"
)
html += "</div></div>"
return html
def _generate_html(data: dict) -> str:
p = data["profile"]
today = date.today().strftime("%d.%m.%Y")
hunde_html = ""
for h in data["hunde"]:
hid = h["id"]
h_health = [r for r in data["health"] if r["hund_id"] == hid]
h_genetic = [r for r in data["genetic"] if r["hund_id"] == hid]
h_titles = [r for r in data["titles"] if r["hund_id"] == hid]
tree = data["pedigrees"].get(hid, {})
health_rows = "".join(
f'<tr><td>{r["test_typ"]}</td><td>{_health_badge(r["test_typ"], r["ergebnis"])}</td>'
f'<td>{_fmt_date(r["untersuch_am"])}</td><td>{r.get("labor","") or ""}</td>'
f'<td>{r.get("untersucher","") or ""}</td></tr>'
for r in h_health
) or "<tr><td colspan=5 style='color:#9ca3af'>Keine Einträge</td></tr>"
genetic_rows = "".join(
f'<tr><td>{r["marker_name"]}</td><td>{_health_badge("DNA", r["ergebnis_klasse"])}</td>'
f'<td>{_fmt_date(r["getestet_am"])}</td><td>{r.get("labor","") or ""}</td></tr>'
for r in h_genetic
) or "<tr><td colspan=4 style='color:#9ca3af'>Keine Einträge</td></tr>"
title_rows = "".join(
f'<tr><td><b>{r["titel_name"]}</b></td><td>{r["titel_typ"]}</td>'
f'<td>{_fmt_date(r["verliehen_am"])}</td><td>{r.get("ort","") or ""}</td>'
f'<td>{r.get("richter","") or ""}</td></tr>'
for r in sorted(h_titles, key=lambda x: x["verliehen_am"] or "", reverse=True)
) or "<tr><td colspan=5 style='color:#9ca3af'>Keine Einträge</td></tr>"
geschlecht = {"maennlich": "Rüde", "weiblich": "Hündin"}.get(h.get("geschlecht",""), "")
hunde_html += f"""
<div class="dog-card">
<h2 style="color:#7c3aed;margin:0 0 4px">{h['name']}</h2>
{f'<div style="color:#6b7280;margin-bottom:12px"><em>{h["rufname"]}</em></div>' if h.get('rufname') else ''}
<table class="info-table">
<tr><th>Geschlecht</th><td>{geschlecht}</td><th>Geburtsdatum</th><td>{_fmt_date(h.get('geburtsdatum'))}</td></tr>
<tr><th>Chip-Nr.</th><td>{h.get('chip_nr') or ''}</td><th>Zuchtbuchnr.</th><td>{h.get('zuchtbuchnummer') or ''}</td></tr>
<tr><th>Farbe</th><td>{h.get('farbe') or ''}</td><th>Tätowierung</th><td>{h.get('taetowiernummer') or ''}</td></tr>
<tr><th>Züchter</th><td>{h.get('zuechter_name') or ''}</td><th>Eigentümer</th><td>{h.get('eigentuemer_name') or ''}</td></tr>
</table>
<h3>Stammbaum</h3>
{_pedigree_html(tree) if tree else '<p style="color:#9ca3af">Keine Elterntiere eingetragen.</p>'}
<h3>Gesundheitstests</h3>
<table class="data-table">
<tr><th>Test</th><th>Ergebnis</th><th>Datum</th><th>Labor</th><th>Untersucher</th></tr>
{health_rows}
</table>
<h3>Genetische Tests</h3>
<table class="data-table">
<tr><th>Marker</th><th>Ergebnis</th><th>Datum</th><th>Labor</th></tr>
{genetic_rows}
</table>
<h3>Titel &amp; Auszeichnungen</h3>
<table class="data-table">
<tr><th>Titel</th><th>Typ</th><th>Datum</th><th>Ort</th><th>Richter</th></tr>
{title_rows}
</table>
</div>"""
litters_html = ""
for l in data["litters"]:
lid = l["id"]
l_puppies = [p for p in data["puppies"] if p["wurf_id"] == lid]
puppy_rows = "".join(
f'<tr><td>{p.get("name") or ""}</td>'
f'<td>{"Rüde" if p.get("geschlecht")=="maennlich" else "Hündin" if p.get("geschlecht")=="weiblich" else ""}</td>'
f'<td>{p.get("farbe") or ""}</td><td>{p.get("chip_nr") or ""}</td>'
f'<td>{p.get("status") or ""}</td></tr>'
for p in l_puppies
) or "<tr><td colspan=5 style='color:#9ca3af'>Keine Welpen eingetragen</td></tr>"
eltern = " × ".join(filter(None, [l.get("vater_name"), l.get("mutter_name")])) or ""
datum = _fmt_date(l.get("geburt_datum") or l.get("erwartetes_datum"))
status_label = {"geplant": "Geplant", "geboren": "Geboren", "verfuegbar": "Verfügbar", "abgeschlossen": "Abgeschlossen"}.get(l.get("status",""), l.get("status",""))
litters_html += f"""
<div class="litter-card">
<div style="display:flex;align-items:baseline;gap:12px;margin-bottom:8px">
<h3 style="margin:0">{eltern}</h3>
<span style="color:#6b7280;font-size:13px">{datum} · {status_label}</span>
</div>
{f'<p style="margin:0 0 12px;color:#374151">{l["beschreibung"]}</p>' if l.get("beschreibung") else ''}
<table class="data-table">
<tr><th>Name</th><th>Geschlecht</th><th>Farbe</th><th>Chip-Nr.</th><th>Status</th></tr>
{puppy_rows}
</table>
</div>"""
return f"""<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<title>Zuchtkartei {p.get('zwingername','')}</title>
<style>
@media print {{
.no-print {{ display:none }}
body {{ font-size:11pt }}
.dog-card, .litter-card {{ page-break-inside:avoid }}
}}
* {{ box-sizing:border-box }}
body {{ font-family:'Segoe UI',Arial,sans-serif;color:#1f2937;margin:0;padding:24px;max-width:1100px;margin:0 auto }}
h1 {{ color:#7c3aed;margin:0 0 4px }}
h2 {{ color:#7c3aed;border-bottom:2px solid #ede9fe;padding-bottom:6px }}
h3 {{ color:#374151;font-size:14px;margin:16px 0 8px }}
.header {{ background:#f5f3ff;border:1px solid #ddd6fe;border-radius:8px;padding:20px 24px;margin-bottom:24px }}
.dog-card, .litter-card {{
border:1px solid #e5e7eb;border-radius:8px;padding:20px 24px;margin-bottom:20px
}}
.info-table {{ border-collapse:collapse;width:100%;margin-bottom:8px }}
.info-table th {{ text-align:left;color:#6b7280;font-size:12px;padding:3px 8px 3px 0;width:120px }}
.info-table td {{ padding:3px 8px 3px 0;font-size:13px }}
.data-table {{ border-collapse:collapse;width:100%;font-size:12px }}
.data-table th {{ background:#f9fafb;padding:6px 10px;text-align:left;border-bottom:2px solid #e5e7eb;color:#374151 }}
.data-table td {{ padding:5px 10px;border-bottom:1px solid #f3f4f6 }}
.data-table tr:last-child td {{ border-bottom:none }}
.badge-group {{ display:flex;gap:4px;flex-wrap:wrap }}
footer {{ color:#9ca3af;font-size:11px;margin-top:32px;border-top:1px solid #e5e7eb;padding-top:12px }}
</style>
</head>
<body>
<div class="header">
<h1>{p.get('zwingername','Mein Zwinger')}</h1>
<div style="color:#6b7280;margin-bottom:12px">Exportiert am {today} · banyaro.app</div>
<table class="info-table">
<tr><th>Rasse</th><td>{p.get('rasse_text','')}</td><th>Verein</th><td>{p.get('verein','')}</td></tr>
<tr><th>Stadt</th><td>{p.get('stadt','')}</td><th>VDH</th><td>{'Ja' if p.get('vdh_mitglied') else 'Nein'}</td></tr>
{f'<tr><th>Website</th><td colspan=3><a href="{p["website"]}">{p["website"]}</a></td></tr>' if p.get('website') else ''}
</table>
{f'<p style="margin:12px 0 0;color:#374151">{p["beschreibung"]}</p>' if p.get('beschreibung') else ''}
</div>
<h2>Hunde ({len(data['hunde'])})</h2>
{hunde_html or '<p style="color:#9ca3af">Keine Hunde eingetragen.</p>'}
<h2>Würfe ({len(data['litters'])})</h2>
{litters_html or '<p style="color:#9ca3af">Keine Würfe eingetragen.</p>'}
<footer>Erstellt mit Banyaro Die App für Hunde und ihre Menschen · banyaro.app</footer>
</body>
</html>"""
# ------------------------------------------------------------------
# ODS-Export
# ------------------------------------------------------------------
def _generate_ods(data: dict) -> bytes:
from odf.opendocument import OpenDocumentSpreadsheet
from odf.style import Style, TextProperties, TableColumnProperties
from odf.text import P
from odf.table import Table, TableColumn, TableRow, TableCell
doc = OpenDocumentSpreadsheet()
def add_style(name, bold=False, bg=None, color=None):
style = Style(name=name, family="table-cell")
tp = TextProperties()
if bold:
tp.setAttribute("fo:font-weight", "bold")
if color:
tp.setAttribute("fo:color", color)
style.addElement(tp)
doc.styles.addElement(style)
return style
header_style = add_style("Header", bold=True, color="#ffffff")
normal_style = add_style("Normal")
def cell(value, style=None):
tc = TableCell()
if style:
tc.setAttribute("table:style-name", style.getAttribute("style:name"))
tc.addElement(P(text=str(value) if value is not None else ""))
return tc
def header_row(sheet, cols):
tr = TableRow()
for c in cols:
tr.addElement(cell(c, header_style))
sheet.addElement(tr)
def data_row(sheet, vals):
tr = TableRow()
for v in vals:
tr.addElement(cell(v))
sheet.addElement(tr)
# Sheet: Hunde
sheet_hunde = Table(name="Hunde")
header_row(sheet_hunde, ["Name", "Rufname", "Geschlecht", "Geburtsdatum", "Chip-Nr.", "Zuchtbuchnummer", "Farbe", "Tätowierung", "Züchter", "Eigentümer", "Notiz"])
for h in data["hunde"]:
g = {"maennlich": "Rüde", "weiblich": "Hündin"}.get(h.get("geschlecht",""), "")
data_row(sheet_hunde, [h["name"], h.get("rufname",""), g, h.get("geburtsdatum",""),
h.get("chip_nr",""), h.get("zuchtbuchnummer",""), h.get("farbe",""),
h.get("taetowiernummer",""), h.get("zuechter_name",""),
h.get("eigentuemer_name",""), h.get("notiz","")])
doc.spreadsheet.addElement(sheet_hunde)
# Sheet: Gesundheitstests
sheet_health = Table(name="Gesundheitstests")
header_row(sheet_health, ["Hund", "Test-Typ", "Test-Name", "Ergebnis", "Datum", "Gültig bis", "Untersucher", "Labor", "Zertifikat-Nr."])
hund_map = {h["id"]: h["name"] for h in data["hunde"]}
for r in data["health"]:
data_row(sheet_health, [hund_map.get(r["hund_id"],""), r["test_typ"], r.get("test_name",""),
r["ergebnis"], r.get("untersuch_am",""), r.get("gueltig_bis",""),
r.get("untersucher",""), r.get("labor",""), r.get("zertifikat_nr","")])
doc.spreadsheet.addElement(sheet_health)
# Sheet: Gentests
sheet_gen = Table(name="Gentests")
header_row(sheet_gen, ["Hund", "Marker", "Kategorie", "Genotyp", "Ergebnis", "Getestet am", "Labor", "Zertifikat-Nr."])
for r in data["genetic"]:
data_row(sheet_gen, [hund_map.get(r["hund_id"],""), r["marker_name"], r.get("marker_kategorie",""),
r.get("genotyp",""), r.get("ergebnis_klasse",""), r.get("getestet_am",""),
r.get("labor",""), r.get("zertifikat_nr","")])
doc.spreadsheet.addElement(sheet_gen)
# Sheet: Titel
sheet_titles = Table(name="Titel")
header_row(sheet_titles, ["Hund", "Titel", "Typ", "Verliehen am", "Ort", "Ausstellung", "Richter", "Formwert"])
for r in data["titles"]:
data_row(sheet_titles, [hund_map.get(r["hund_id"],""), r["titel_name"], r["titel_typ"],
r.get("verliehen_am",""), r.get("ort",""), r.get("ausstellung",""),
r.get("richter",""), r.get("formwert","")])
doc.spreadsheet.addElement(sheet_titles)
# Sheet: Würfe
sheet_litters = Table(name="Würfe")
header_row(sheet_litters, ["Vater", "Mutter", "Geburtsdatum", "Erwartetes Datum", "Status", "Welpen gesamt", "Welpen verfügbar", "Preisspanne", "Gesundheitstests", "Beschreibung"])
for l in data["litters"]:
status = {"geplant":"Geplant","geboren":"Geboren","verfuegbar":"Verfügbar","abgeschlossen":"Abgeschlossen"}.get(l.get("status",""), l.get("status",""))
data_row(sheet_litters, [l.get("vater_name",""), l.get("mutter_name",""),
l.get("geburt_datum",""), l.get("erwartetes_datum",""),
status, l.get("welpen_gesamt",""), l.get("welpen_verfuegbar",""),
l.get("preis_spanne",""), l.get("gesundheitstests",""),
l.get("beschreibung","")])
doc.spreadsheet.addElement(sheet_litters)
# Sheet: Welpen
sheet_puppies = Table(name="Welpen")
header_row(sheet_puppies, ["Wurf (Vater × Mutter)", "Name", "Geschlecht", "Farbe", "Chip-Nr.", "Geburtsgewicht (g)", "Status", "Notiz"])
litter_map = {l["id"]: f"{l.get('vater_name','?')} × {l.get('mutter_name','?')}" for l in data["litters"]}
for p in data["puppies"]:
g = {"maennlich":"Rüde","weiblich":"Hündin"}.get(p.get("geschlecht",""),"")
data_row(sheet_puppies, [litter_map.get(p["wurf_id"],""), p.get("name",""), g,
p.get("farbe",""), p.get("chip_nr",""),
p.get("geburtsgewicht",""), p.get("status",""), p.get("notiz","")])
doc.spreadsheet.addElement(sheet_puppies)
# Sheet: Gewichte
sheet_weights = Table(name="Gewichtsverlauf")
header_row(sheet_weights, ["Welpe", "Gewicht (g)", "Gemessen am"])
puppy_map = {p["id"]: p.get("name","") or f"Welpe #{p['id']}" for p in data["puppies"]}
for w in data["weights"]:
data_row(sheet_weights, [puppy_map.get(w["welpe_id"],""), w["gewicht_g"], w.get("gemessen_am","")])
doc.spreadsheet.addElement(sheet_weights)
buf = io.BytesIO()
doc.save(buf)
return buf.getvalue()
# ------------------------------------------------------------------
# GET /api/breeder/export — ZIP mit HTML + ODS
# ------------------------------------------------------------------
@router.get("/breeder/export")
async def export_breeder_data(user=Depends(_require_breeder)):
data = _collect_data(user["id"])
if not data:
raise HTTPException(404, "Kein Züchter-Profil vorhanden.")
zwinger = data["profile"].get("zwingername", "export").replace(" ", "_")
today = date.today().strftime("%Y-%m-%d")
name = f"banyaro_{zwinger}_{today}"
try:
html_bytes = _generate_html(data).encode("utf-8")
ods_bytes = _generate_ods(data)
except Exception as e:
logger.error(f"Export-Fehler: {e}", exc_info=True)
raise HTTPException(500, "Fehler beim Erstellen des Exports.")
zip_buf = io.BytesIO()
with zipfile.ZipFile(zip_buf, "w", zipfile.ZIP_DEFLATED) as zf:
zf.writestr(f"{name}.html", html_bytes)
zf.writestr(f"{name}.ods", ods_bytes)
zip_buf.seek(0)
return StreamingResponse(
zip_buf,
media_type="application/zip",
headers={"Content-Disposition": f'attachment; filename="{name}.zip"'},
)

View file

@ -29,6 +29,8 @@ def _require_breeder(user=Depends(get_current_user)):
class LitterCreate(BaseModel):
vater_name: Optional[str] = None
mutter_name: Optional[str] = None
vater_id: Optional[int] = None # FK zucht_hunde
mutter_id: Optional[int] = None # FK zucht_hunde
geburt_datum: Optional[str] = None # YYYY-MM-DD
erwartetes_datum: Optional[str] = None # YYYY-MM-DD
welpen_gesamt: Optional[int] = None
@ -44,6 +46,8 @@ class LitterCreate(BaseModel):
class LitterUpdate(BaseModel):
vater_name: Optional[str] = None
mutter_name: Optional[str] = None
vater_id: Optional[int] = None
mutter_id: Optional[int] = None
geburt_datum: Optional[str] = None
erwartetes_datum: Optional[str] = None
welpen_gesamt: Optional[int] = None
@ -185,14 +189,17 @@ async def create_litter(body: LitterCreate, user=Depends(_require_breeder)):
cur = conn.execute(
"""INSERT INTO litters
(breeder_id, vater_name, mutter_name, geburt_datum, erwartetes_datum,
(breeder_id, vater_name, mutter_name, vater_id, mutter_id,
geburt_datum, erwartetes_datum,
welpen_gesamt, welpen_verfuegbar, beschreibung, gesundheitstests,
preis_spanne, status, sichtbar, sichtbar_bis)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)""",
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
(
profile["id"],
body.vater_name,
body.mutter_name,
body.vater_id,
body.mutter_id,
body.geburt_datum,
body.erwartetes_datum,
body.welpen_gesamt,
@ -205,10 +212,78 @@ async def create_litter(body: LitterCreate, user=Depends(_require_breeder)):
body.sichtbar_bis,
)
)
litter_id = cur.lastrowid
row = conn.execute(
"SELECT * FROM litters WHERE id=?", (cur.lastrowid,)
"SELECT * FROM litters WHERE id=?", (litter_id,)
).fetchone()
return dict(row)
# Tierschutz-Check
from welfare_check import check_welfare
welfare = check_welfare(
conn, profile["id"],
vater_id=body.vater_id,
mutter_id=body.mutter_id,
)
# Welfare-Level speichern
conn.execute(
"UPDATE litters SET welfare_level=? WHERE id=?",
(welfare["level"], litter_id)
)
result = dict(row)
result["welfare"] = welfare
return result
# ------------------------------------------------------------------
# POST /api/litters/{id}/welfare-confirm — Tierschutz-Hinweis bestätigt
# ------------------------------------------------------------------
@router.post("/litters/{litter_id}/welfare-confirm")
async def welfare_confirm(litter_id: int, user=Depends(_require_breeder)):
from mailer import send_email
import os, logging as _log
_logger = _log.getLogger(__name__)
with db() as conn:
litter = _check_litter_owner(litter_id, user, conn)
conn.execute(
"UPDATE litters SET welfare_acknowledged=1 WHERE id=?", (litter_id,)
)
welfare_level = litter.get("welfare_level", "")
if welfare_level == "critical":
# Admin benachrichtigen
profile = conn.execute(
"SELECT bp.zwingername, u.name, u.email "
"FROM breeder_profiles bp JOIN users u ON u.id=bp.user_id "
"WHERE bp.user_id=?", (user["id"],)
).fetchone()
admin_email = os.getenv("ADMIN_EMAIL", "mail@motocamp.de")
app_url = os.getenv("APP_URL", "https://banyaro.app")
zuechter = profile["name"] if profile else user.get("name", "Unbekannt")
zwinger = profile["zwingername"] if profile else ""
eltern = conn.execute(
"SELECT vater_name, mutter_name FROM litters WHERE id=?", (litter_id,)
).fetchone()
html = f"""
<h2>Tierschutz-Hinweis bestätigt</h2>
<p>Züchter <b>{zuechter}</b> (Zwinger: {zwinger}) hat einen Wurf mit
kritischen Tierschutz-Hinweisen trotzdem angelegt.</p>
<p>Vater: {eltern['vater_name'] or ''} &nbsp;·&nbsp; Mutter: {eltern['mutter_name'] or ''}</p>
<p>Wurf-ID: {litter_id}</p>
<p><a href="{app_url}/admin">Im Admin-Bereich prüfen</a></p>
"""
try:
await send_email(
admin_email,
f"[Banyaro Tierschutz] Kritischer Hinweis bestätigt — {zwinger}",
html,
f"Züchter {zuechter} hat Wurf #{litter_id} trotz kritischer Tierschutz-Hinweise angelegt.",
)
except Exception as e:
_logger.warning(f"Tierschutz-Admin-Mail fehlgeschlagen: {e}")
return {"message": "Bestätigt."}
# ------------------------------------------------------------------

View file

@ -356,10 +356,53 @@ async def trial_mating(body: TrialMatingBody, user=Depends(_require_breeder)):
gemeinsame_vorfahren.sort(key=lambda x: x["gen_vater"] + x["gen_mutter"])
# Genetische Risiken für Welfare-Check
genetic_risks = []
try:
vater_gen = conn.execute(
"SELECT marker_name, ergebnis_klasse FROM dog_genetic_tests WHERE hund_id=?", (body.vater_id,)
).fetchall()
mutter_gen = conn.execute(
"SELECT marker_name, ergebnis_klasse FROM dog_genetic_tests WHERE hund_id=?", (body.mutter_id,)
).fetchall()
mutter_map = {r["marker_name"]: r["ergebnis_klasse"] for r in mutter_gen}
RISIKO = {
("carrier","carrier"): "25% betroffen, 50% Träger",
("carrier","affected"): "50% betroffen, 50% Träger",
("affected","carrier"): "50% betroffen, 50% Träger",
("affected","affected"): "100% betroffen",
("clear","affected"): "0% betroffen, 100% Träger",
("affected","clear"): "0% betroffen, 100% Träger",
}
for vg in vater_gen:
ms = mutter_map.get(vg["marker_name"])
if ms:
risk = RISIKO.get((vg["ergebnis_klasse"], ms))
if risk:
genetic_risks.append({"marker": vg["marker_name"], "offspring_risk": risk})
except Exception:
pass
# Züchter-Profil für Welfare
profile = conn.execute(
"SELECT id FROM breeder_profiles WHERE user_id=?", (user["id"],)
).fetchone()
bid = profile["id"] if profile else None
from welfare_check import check_welfare
welfare = check_welfare(
conn, bid or 0,
vater_id=body.vater_id,
mutter_id=body.mutter_id,
ik_prozent=ik_prozent,
genetic_risks=genetic_risks,
)
return {
"ik_prozent": ik_prozent,
"ik_rating": rating,
"ik_prozent": ik_prozent,
"ik_rating": rating,
"gemeinsame_vorfahren": gemeinsame_vorfahren,
"welfare": welfare,
}

468
backend/routes/zucht_ki.py Normal file
View file

@ -0,0 +1,468 @@
"""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
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] = None
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"],
)
return {"text": text}
except Exception as e:
logger.warning(f"KI nicht verfügbar: {e}")
return {"text": _FALLBACK}