From c8ae514c011cd3adba3429e3c8974c47dd209d61 Mon Sep 17 00:00:00 2001 From: rene Date: Tue, 28 Apr 2026 19:49:54 +0200 Subject: [PATCH] =?UTF-8?q?Feature:=20Tierschutz-Check,=20KI-Z=C3=BCchter-?= =?UTF-8?q?Features,=20Export,=20SEO-Update?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- backend/database.py | 13 +- backend/ki.py | 48 ++- backend/main.py | 21 +- backend/requirements.txt | 1 + backend/routes/breeder.py | 24 +- backend/routes/breeder_export.py | 497 ++++++++++++++++++++++++++ backend/routes/litters.py | 83 ++++- backend/routes/zucht_hunde.py | 47 ++- backend/routes/zucht_ki.py | 468 ++++++++++++++++++++++++ backend/static/icons/phosphor.svg | 3 + backend/static/js/api.js | 34 +- backend/static/js/app.js | 2 +- backend/static/js/pages/litters.js | 191 +++++++++- backend/static/js/pages/settings.js | 91 ++++- backend/static/js/pages/zuchthunde.js | 233 +++++++++++- backend/static/landing.html | 91 ++++- backend/static/llms.txt | 257 ++++++------- backend/static/robots.txt | 3 + backend/static/sw.js | 2 +- backend/welfare_check.py | 220 ++++++++++++ 20 files changed, 2129 insertions(+), 200 deletions(-) create mode 100644 backend/routes/breeder_export.py create mode 100644 backend/routes/zucht_ki.py create mode 100644 backend/welfare_check.py diff --git a/backend/database.py b/backend/database.py index 2f8182d..3bea359 100644 --- a/backend/database.py +++ b/backend/database.py @@ -550,7 +550,18 @@ def _migrate(conn_factory): ("notes", "parent_label", "TEXT"), ("users", "notes_ki_enabled", "INTEGER NOT NULL DEFAULT 1"), # Züchter-Rolle - ("users", "breeder_status", "TEXT"), + ("users", "breeder_status", "TEXT"), + # Würfe: Verknüpfung mit Zuchtkartei-Hunden + Welfare + ("litters", "vater_id", "INTEGER"), + ("litters", "mutter_id", "INTEGER"), + ("litters", "welfare_level", "TEXT"), + ("litters", "welfare_acknowledged", "INTEGER NOT NULL DEFAULT 0"), + # KI-Züchter-Features (pro User an/abschaltbar, außer Tierschutz) + ("users", "ki_zucht_wurfankuendigung", "INTEGER NOT NULL DEFAULT 1"), + ("users", "ki_zucht_genetik", "INTEGER NOT NULL DEFAULT 1"), + ("users", "ki_zucht_paarung", "INTEGER NOT NULL DEFAULT 1"), + ("users", "ki_zucht_beschreibung", "INTEGER NOT NULL DEFAULT 1"), + ("users", "ki_zucht_jahresbericht", "INTEGER NOT NULL DEFAULT 1"), ] with conn_factory() as conn: for table, column, col_type in migrations: diff --git a/backend/ki.py b/backend/ki.py index 2c5fcaa..89e9065 100644 --- a/backend/ki.py +++ b/backend/ki.py @@ -82,6 +82,28 @@ def _track_usage(user_id: int | None, source: str) -> None: logger.warning(f"KI-Tracking fehlgeschlagen: {exc}") +def _is_cloud_priority_user(user_id: int | None) -> bool: + """Privilegierte Rollen (Admin, Moderator, Züchter, Manager) nutzen Cloud-KI primär.""" + if not user_id or not ANTHROPIC_KEY: + return False + try: + from database import db + with db() as conn: + user = conn.execute( + "SELECT rolle, is_moderator, is_social_media FROM users WHERE id=?", + (user_id,) + ).fetchone() + if not user: + return False + return bool( + user["rolle"] in ("admin", "breeder", "moderator") + or user["is_moderator"] + or user["is_social_media"] + ) + except Exception: + return False + + def _check_weekly_cloud_limit(user_id: int | None) -> None: """Wirft KIPremiumRequired wenn user_id das wöchentliche Cloud-Limit erreicht hat.""" if user_id is None or CLOUD_WEEKLY_LIMIT <= 0: @@ -92,9 +114,9 @@ def _check_weekly_cloud_limit(user_id: int | None) -> None: user = conn.execute( "SELECT rolle, is_moderator FROM users WHERE id=?", (user_id,) ).fetchone() - # Admins, Moderatoren und Media Manager haben kein Limit + # Admins, Moderatoren, Züchter und Media Manager haben kein Limit if user and ( - user["rolle"] in ("admin", "moderator", "media_manager") + user["rolle"] in ("admin", "breeder", "moderator", "media_manager") or user["is_moderator"] ): return @@ -137,8 +159,28 @@ async def complete( if requires_premium and not user_is_premium: raise KIPremiumRequired("Dieses Feature ist Teil von Ban Yaro Premium.") - # Immer lokal zuerst — Cloud ist Fallback wenn lokal nicht erreichbar if KI_MODE in ("local", "cloud"): + # Privilegierte Rollen (Admin, Moderator, Züchter, Manager) → Cloud zuerst + if _is_cloud_priority_user(user_id): + try: + _check_weekly_cloud_limit(user_id) + text = await _cloud_complete(prompt, system, max_tokens, json_mode) + _track_usage(user_id, "cloud") + if return_model: + return (text, CLOUD_MODEL) + return (text, "cloud") if return_source else text + except KIPremiumRequired: + raise + except Exception as e: + logger.warning(f"Cloud-KI nicht erreichbar für privilegierten User, Fallback lokal: {e}") + # Fallback auf lokales Modell + text = await _local_complete(prompt, system, max_tokens, json_mode) + _track_usage(user_id, "local") + if return_model: + return (text, LOCAL_MODEL) + return (text, "local") if return_source else text + + # Standard-User → lokal zuerst, Cloud als Fallback try: text = await _local_complete(prompt, system, max_tokens, json_mode) _track_usage(user_id, "local") diff --git a/backend/main.py b/backend/main.py index 92936ed..fbae790 100644 --- a/backend/main.py +++ b/backend/main.py @@ -160,6 +160,8 @@ from routes.breeder import router as breeder_router from routes.litters import router as litters_router from routes.breeder_photos import router as breeder_photos_router from routes.zucht_hunde import router as zucht_hunde_router +from routes.breeder_export import router as breeder_export_router +from routes.zucht_ki import router as zucht_ki_router app.include_router(auth_router, prefix="/api/auth", tags=["Auth"]) app.include_router(dogs_router, prefix="/api/dogs", tags=["Hunde"]) @@ -189,6 +191,8 @@ app.include_router(breeder_router, prefix="/api", tags=["Züchter" app.include_router(litters_router, prefix="/api", tags=["Würfe"]) app.include_router(breeder_photos_router, prefix="/api", tags=["Züchter-Fotos"]) app.include_router(zucht_hunde_router, prefix="/api", tags=["Zuchtkartei"]) +app.include_router(breeder_export_router, prefix="/api", tags=["Export"]) +app.include_router(zucht_ki_router, prefix="/api", tags=["Züchter-KI"]) app.include_router(webcal_router, prefix="/api/webcal", tags=["WebCal"]) app.include_router(profile_router, prefix="/api/profile", tags=["Profil"]) app.include_router(import_router, prefix="/api/import", tags=["Import"]) @@ -255,6 +259,7 @@ async def sitemap(): ("https://banyaro.app/info", "monthly", "0.9"), ("https://banyaro.app/wiki/rassen", "weekly", "0.8"), ("https://banyaro.app/knigge", "monthly", "0.8"), + ("https://banyaro.app/wurfboerse", "daily", "0.8"), ] try: @@ -262,8 +267,6 @@ async def sitemap(): rassen = conn.execute( "SELECT slug FROM wiki_rassen WHERE slug IS NOT NULL AND slug != '' LIMIT 500" ).fetchall() - if rassen: - urls.append(("https://banyaro.app/wiki/rassen", "weekly", "0.8")) for r in rassen: urls.append((f"https://banyaro.app/wiki/rasse/{r['slug']}", "monthly", "0.7")) @@ -272,6 +275,20 @@ async def sitemap(): ).fetchall() for e in events: urls.append((f"https://banyaro.app/api/events/{e['id']}", "weekly", "0.5")) + + # Öffentliche Züchter-Profile + breeders = conn.execute( + "SELECT bp.zwingername FROM breeder_profiles bp " + "JOIN users u ON u.id = bp.user_id " + "WHERE bp.verified_at IS NOT NULL AND u.rolle = 'breeder'" + ).fetchall() + for b in breeders: + if b["zwingername"]: + from urllib.parse import quote + urls.append(( + f"https://banyaro.app/breeder/{quote(b['zwingername'])}", + "weekly", "0.7" + )) except Exception: pass diff --git a/backend/requirements.txt b/backend/requirements.txt index 011f882..25c2274 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -11,3 +11,4 @@ openai==1.59.2 anthropic==0.49.0 pywebpush==2.0.0 apscheduler==3.10.4 +odfpy==1.4.1 diff --git a/backend/routes/breeder.py b/backend/routes/breeder.py index d3ea366..061c9a7 100644 --- a/backend/routes/breeder.py +++ b/backend/routes/breeder.py @@ -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 # ------------------------------------------------------------------ diff --git a/backend/routes/breeder_export.py b/backend/routes/breeder_export.py new file mode 100644 index 0000000..f75516e --- /dev/null +++ b/backend/routes/breeder_export.py @@ -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'{ergebnis}' + + +def _pedigree_node_html(node, gen): + if not node: + return '
Unbekannt
' + 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'
' + f'
{node["name"]}
' + + (f'
{nr}
' if nr else "") + + (f'
{geb}
' if geb else "") + + "
" + ) + + +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'
' + f'
' + ) + for c in cells: + node, gen, row, span = c["node"], c["gen"], c["row"], c["span"] + html += ( + f'
' + + _pedigree_node_html(node, gen) + + "
" + ) + html += "
" + 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'{r["test_typ"]}{_health_badge(r["test_typ"], r["ergebnis"])}' + f'{_fmt_date(r["untersuch_am"])}{r.get("labor","") or ""}' + f'{r.get("untersucher","") or ""}' + for r in h_health + ) or "Keine Einträge" + + genetic_rows = "".join( + f'{r["marker_name"]}{_health_badge("DNA", r["ergebnis_klasse"])}' + f'{_fmt_date(r["getestet_am"])}{r.get("labor","") or ""}' + for r in h_genetic + ) or "Keine Einträge" + + title_rows = "".join( + f'{r["titel_name"]}{r["titel_typ"]}' + f'{_fmt_date(r["verliehen_am"])}{r.get("ort","") or ""}' + f'{r.get("richter","") or ""}' + for r in sorted(h_titles, key=lambda x: x["verliehen_am"] or "", reverse=True) + ) or "Keine Einträge" + + geschlecht = {"maennlich": "Rüde", "weiblich": "Hündin"}.get(h.get("geschlecht",""), "") + + hunde_html += f""" +
+

{h['name']}

+ {f'
{h["rufname"]}
' if h.get('rufname') else ''} + + + + + +
Geschlecht{geschlecht}Geburtsdatum{_fmt_date(h.get('geburtsdatum'))}
Chip-Nr.{h.get('chip_nr') or '—'}Zuchtbuchnr.{h.get('zuchtbuchnummer') or '—'}
Farbe{h.get('farbe') or '—'}Tätowierung{h.get('taetowiernummer') or '—'}
Züchter{h.get('zuechter_name') or '—'}Eigentümer{h.get('eigentuemer_name') or '—'}
+ +

Stammbaum

+ {_pedigree_html(tree) if tree else '

Keine Elterntiere eingetragen.

'} + +

Gesundheitstests

+ + + {health_rows} +
TestErgebnisDatumLaborUntersucher
+ +

Genetische Tests

+ + + {genetic_rows} +
MarkerErgebnisDatumLabor
+ +

Titel & Auszeichnungen

+ + + {title_rows} +
TitelTypDatumOrtRichter
+
""" + + 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'{p.get("name") or "—"}' + f'{"Rüde" if p.get("geschlecht")=="maennlich" else "Hündin" if p.get("geschlecht")=="weiblich" else "—"}' + f'{p.get("farbe") or "—"}{p.get("chip_nr") or "—"}' + f'{p.get("status") or "—"}' + for p in l_puppies + ) or "Keine Welpen eingetragen" + + 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""" +
+
+

{eltern}

+ {datum} · {status_label} +
+ {f'

{l["beschreibung"]}

' if l.get("beschreibung") else ''} + + + {puppy_rows} +
NameGeschlechtFarbeChip-Nr.Status
+
""" + + return f""" + + + + Zuchtkartei — {p.get('zwingername','')} + + + +
+

{p.get('zwingername','Mein Zwinger')}

+
Exportiert am {today} · banyaro.app
+ + + + {f'' if p.get('website') else ''} +
Rasse{p.get('rasse_text','—')}Verein{p.get('verein','—')}
Stadt{p.get('stadt','—')}VDH{'Ja' if p.get('vdh_mitglied') else 'Nein'}
Website{p["website"]}
+ {f'

{p["beschreibung"]}

' if p.get('beschreibung') else ''} +
+ +

Hunde ({len(data['hunde'])})

+ {hunde_html or '

Keine Hunde eingetragen.

'} + +

Würfe ({len(data['litters'])})

+ {litters_html or '

Keine Würfe eingetragen.

'} + + + +""" + + +# ------------------------------------------------------------------ +# 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"'}, + ) diff --git a/backend/routes/litters.py b/backend/routes/litters.py index 4ea557b..f47c809 100644 --- a/backend/routes/litters.py +++ b/backend/routes/litters.py @@ -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""" +

Tierschutz-Hinweis bestätigt

+

Züchter {zuechter} (Zwinger: {zwinger}) hat einen Wurf mit + kritischen Tierschutz-Hinweisen trotzdem angelegt.

+

Vater: {eltern['vater_name'] or '—'}  ·  Mutter: {eltern['mutter_name'] or '—'}

+

Wurf-ID: {litter_id}

+

Im Admin-Bereich prüfen

+ """ + 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."} # ------------------------------------------------------------------ diff --git a/backend/routes/zucht_hunde.py b/backend/routes/zucht_hunde.py index 3945737..8ef8c72 100644 --- a/backend/routes/zucht_hunde.py +++ b/backend/routes/zucht_hunde.py @@ -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, } diff --git a/backend/routes/zucht_ki.py b/backend/routes/zucht_ki.py new file mode 100644 index 0000000..0d366c2 --- /dev/null +++ b/backend/routes/zucht_ki.py @@ -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} diff --git a/backend/static/icons/phosphor.svg b/backend/static/icons/phosphor.svg index 7bc767e..9b4843c 100644 --- a/backend/static/icons/phosphor.svg +++ b/backend/static/icons/phosphor.svg @@ -177,6 +177,9 @@ + + + diff --git a/backend/static/js/api.js b/backend/static/js/api.js index 01e8bb0..acc1e60 100644 --- a/backend/static/js/api.js +++ b/backend/static/js/api.js @@ -615,6 +615,7 @@ const API = (() => { profile(zwingername) { return get(`/breeder/profil/${encodeURIComponent(zwingername)}`); }, mapMarkers() { return get('/breeder/map'); }, updateProfile(data) { return put('/breeder/profile', data); }, + adminCreateProfile() { return post('/admin/breeder/create-profile', {}); }, pendingList() { return get('/admin/breeders/pending'); }, documents(userId) { return get(`/admin/breeder/${userId}/documents`); }, documentUrl(userId, docId) { return `/api/admin/breeder/${userId}/document/${docId}`; }, @@ -631,6 +632,7 @@ const API = (() => { create(data) { return post('/litters', data); }, update(id, data) { return put(`/litters/${id}`, data); }, remove(id) { return del(`/litters/${id}`); }, + welfareConfirm(id) { return post(`/litters/${id}/welfare-confirm`, {}); }, // Welpen puppies(id) { return get(`/litters/${id}/puppies`); }, addPuppy(id, data) { return post(`/litters/${id}/puppies`, data); }, @@ -653,43 +655,51 @@ const API = (() => { remove(id) { return del(`/breeder/photos/${id}`); }, }; - // Öffentliche API - return { - get, post, put, patch, del, upload, - auth, dogs, diary, health, tieraerzte, poison, - places, routes, walks, events, sitting, forum, lost, knigge, weather, push, - friends, chat, webcal, importData, sharing, widget, notifications, services, ratings, sittingAccess, training, notes, // ---------------------------------------------------------- // ZUCHTKARTEI (Hunde-Stammdaten, Gesundheit, Genetik, Titel) // ---------------------------------------------------------- const zuchthunde = { - // Hunde list() { return get('/zuchthunde'); }, get(id) { return get(`/zuchthunde/${id}`); }, create(data) { return post('/zuchthunde', data); }, update(id, data) { return put(`/zuchthunde/${id}`, data); }, remove(id) { return del(`/zuchthunde/${id}`); }, pedigree(id, gen=4) { return get(`/zuchthunde/${id}/pedigree?generations=${gen}`); }, - // Gesundheitstests healthTests(id) { return get(`/zuchthunde/${id}/health-tests`); }, addHealthTest(id, data) { return post(`/zuchthunde/${id}/health-tests`, data); }, updateHealthTest(tid, data) { return put(`/zuchthunde/health-tests/${tid}`, data); }, deleteHealthTest(tid) { return del(`/zuchthunde/health-tests/${tid}`); }, - // Gentests geneticTests(id) { return get(`/zuchthunde/${id}/genetic-tests`); }, addGeneticTest(id, data) { return post(`/zuchthunde/${id}/genetic-tests`, data); }, updateGeneticTest(tid, data) { return put(`/zuchthunde/genetic-tests/${tid}`, data); }, deleteGeneticTest(tid) { return del(`/zuchthunde/genetic-tests/${tid}`); }, - // Titel titles(id) { return get(`/zuchthunde/${id}/titles`); }, addTitle(id, data) { return post(`/zuchthunde/${id}/titles`, data); }, updateTitle(tid, data) { return put(`/zuchthunde/titles/${tid}`, data); }, deleteTitle(tid) { return del(`/zuchthunde/titles/${tid}`); }, - // Probeverpaarung trialMating(vaterId, mutterId) { return post('/zuchthunde/trial-mating', { vater_id: vaterId, mutter_id: mutterId }); }, }; - breeder, litters, breederPhotos, zuchthunde, + // ---------------------------------------------------------- + // ZÜCHTER-KI + // ---------------------------------------------------------- + const zuchtKi = { + wurfankuendigung(litterId) { return post('/zucht-ki/wurfankuendigung', { litter_id: litterId }); }, + genetikErklaerung(litterId, ziel) { return post('/zucht-ki/genetik-erklaerung', { litter_id: litterId, zielgruppe: ziel }); }, + paarungAnalyse(vaterId, mutterId, ik, welfareLevel) { + return post('/zucht-ki/paarung-analyse', { vater_id: vaterId, mutter_id: mutterId, ik_prozent: ik, welfare_level: welfareLevel }); + }, + hundBeschreibung(hundId) { return post('/zucht-ki/hund-beschreibung', { hund_id: hundId }); }, + jahresbericht() { return post('/zucht-ki/jahresbericht', {}); }, + }; + + // Öffentliche API + return { + get, post, put, patch, del, upload, + auth, dogs, diary, health, tieraerzte, poison, + places, routes, walks, events, sitting, forum, lost, knigge, weather, push, + friends, chat, webcal, importData, sharing, widget, notifications, services, ratings, sittingAccess, training, notes, + breeder, litters, breederPhotos, zuchthunde, zuchtKi, subscribeToPush, getLocation, clientNow, APIError, }; diff --git a/backend/static/js/app.js b/backend/static/js/app.js index 4b84ee1..7a1d0d4 100644 --- a/backend/static/js/app.js +++ b/backend/static/js/app.js @@ -3,7 +3,7 @@ Router, State-Management, Navigation, Initialisierung. ============================================================ */ -const APP_VER = '444'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen +const APP_VER = '451'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen const App = (() => { diff --git a/backend/static/js/pages/litters.js b/backend/static/js/pages/litters.js index ed7d900..1f60e0f 100644 --- a/backend/static/js/pages/litters.js +++ b/backend/static/js/pages/litters.js @@ -192,6 +192,13 @@ window.Page_litters = (() => { }); }); + el.querySelectorAll('.litters-ki-announce-btn').forEach(btn => { + btn.addEventListener('click', () => { + const id = parseInt(btn.dataset.id); + _showKiAnnouncement(id); + }); + }); + el.querySelectorAll('.litters-add-puppy-btn').forEach(btn => { btn.addEventListener('click', () => { const id = parseInt(btn.dataset.id); @@ -249,6 +256,11 @@ window.Page_litters = (() => { title="Elterntier-Fotos verwalten"> ${UI.icon('users')} Eltern + ${_appState.user?.ki_zucht_wurfankuendigung !== 0 ? ` + ` : ''} + + ` : ` + `, + }); + + document.getElementById('welfare-back-btn')?.addEventListener('click', () => { + UI.modal.close?.(); + const litter = _litters.find(l => l.id === litterId); + API.litters.remove(litterId).catch(() => {}); + _litters = _litters.filter(l => l.id !== litterId); + _renderList(); + setTimeout(() => _showLitterForm(null), 150); + }); + + document.getElementById('welfare-confirm-btn')?.addEventListener('click', async () => { + await API.litters.welfareConfirm(litterId).catch(() => {}); + UI.modal.close?.(); + UI.toast.info('Wurf gespeichert.'); + }); + } + + // ---------------------------------------------------------- + // KI: Wurfankündigung + // ---------------------------------------------------------- + async function _showKiAnnouncement(litterId) { + UI.modal.open({ + title: `${UI.icon('sparkle')} KI-Wurfankündigung`, + body: `

+ KI schreibt Wurfankündigung… +

`, + footer: '', + }); + + let text = ''; + try { + const result = await API.zuchtKi.wurfankuendigung(litterId); + text = result.text || result.content || result.ankuendigung || JSON.stringify(result); + } catch (err) { + UI.modal.open({ + title: `${UI.icon('sparkle')} KI-Wurfankündigung`, + body: `

${_esc(err.message || 'Fehler beim Generieren.')}

`, + footer: ``, + }); + return; + } + + UI.modal.open({ + title: `${UI.icon('sparkle')} KI-Wurfankündigung`, + body: `
${_esc(text)}
`, + footer: ` + + `, + }); + + document.getElementById('ki-announce-copy')?.addEventListener('click', async () => { + try { + await navigator.clipboard.writeText(text); + UI.toast.success('Text kopiert.'); + } catch { + UI.toast.error('Kopieren nicht möglich.'); + } + }); + + document.getElementById('ki-announce-use')?.addEventListener('click', async () => { + const btn = document.getElementById('ki-announce-use'); + await UI.asyncButton(btn, async () => { + await API.litters.update(litterId, { beschreibung: text }); + UI.modal.close(); + UI.toast.success('Beschreibung aktualisiert.'); + await _loadLitters(); + }); + }); + } + return { init, refresh, onDogChange }; })(); diff --git a/backend/static/js/pages/settings.js b/backend/static/js/pages/settings.js index 19e82ad..85846e7 100644 --- a/backend/static/js/pages/settings.js +++ b/backend/static/js/pages/settings.js @@ -685,6 +685,30 @@ window.Page_settings = (() => { _loadBreederCard(); } + // ---------------------------------------------------------- + // KI-Toggle-Zeile (Hilfsfunktion für Züchter-Card) + // ---------------------------------------------------------- + function _kiToggleRow(key, label, user) { + const active = user[key] !== 0; + return ` +
+ ${_esc(label)} + +
`; + } + // ---------------------------------------------------------- // ZÜCHTER-CARD — asynchron laden und in Slot rendern // ---------------------------------------------------------- @@ -722,7 +746,30 @@ window.Page_settings = (() => { ${rolle === 'breeder' && profile ? ` ` : ''}`; + ` : ''} + ${rolle === 'admin' && !profile ? ` + ` : ''} + ${rolle === 'admin' && profile ? ` + ` : ''} + ${profile ? ` +
+
+ KI-Züchter-Assistenz +
+ ${_kiToggleRow('ki_zucht_wurfankuendigung', 'Wurfankündigungen schreiben', _appState.user || {})} + ${_kiToggleRow('ki_zucht_genetik', 'Genetik-Erklärung für Käufer', _appState.user || {})} + ${_kiToggleRow('ki_zucht_paarung', 'Paarungsanalyse', _appState.user || {})} + ${_kiToggleRow('ki_zucht_beschreibung', 'Hunde-Beschreibungen', _appState.user || {})} + ${_kiToggleRow('ki_zucht_jahresbericht', 'Jahresauswertung', _appState.user || {})} +
+ ${UI.icon('info')} Der Tierschutz-Check läuft immer automatisch und ist nicht abschaltbar. +
+
` : ''}`; } else if (breeder_status === 'pending') { statusBadge = ` ${UI.icon('hourglass')} Antrag wird geprüft @@ -770,6 +817,48 @@ window.Page_settings = (() => { slot.querySelector('#breeder-edit-profile-btn')?.addEventListener('click', () => _openBreederEditModal(profile) ); + + slot.querySelector('#breeder-admin-create-btn')?.addEventListener('click', async (e) => { + const btn = e.currentTarget; + btn.disabled = true; + btn.textContent = 'Wird angelegt…'; + try { + await API.breeder.adminCreateProfile(); + UI.toast.success('Admin-Züchterprofil angelegt. Bitte Seite neu laden.'); + _loadBreederCard(); + } catch (err) { + UI.toast.error(err.message || 'Fehler beim Anlegen.'); + btn.disabled = false; + btn.innerHTML = `${UI.icon('plus')} Admin-Züchterprofil anlegen`; + } + }); + + // KI-Toggle-Handler + slot.querySelectorAll('.ki-toggle-btn').forEach(btn => { + btn.addEventListener('click', async () => { + const key = btn.dataset.key; + const active = btn.dataset.active === '1'; + const newVal = active ? 0 : 1; + + // Optimistisches UI-Update + btn.dataset.active = newVal ? '1' : '0'; + btn.style.background = newVal ? 'var(--c-primary)' : 'var(--c-border)'; + const thumb = btn.querySelector('.by-toggle-thumb'); + if (thumb) thumb.style.left = newVal ? '22px' : '2px'; + + try { + const updated = await API.patch('/profile', { [key]: newVal }); + if (_appState?.user) _appState.user[key] = newVal; + UI.toast.success(newVal ? 'KI-Feature aktiviert.' : 'KI-Feature deaktiviert.'); + } catch (err) { + // Revert + btn.dataset.active = active ? '1' : '0'; + btn.style.background = active ? 'var(--c-primary)' : 'var(--c-border)'; + if (thumb) thumb.style.left = active ? '22px' : '2px'; + UI.toast.error(err?.message || 'Einstellung konnte nicht gespeichert werden.'); + } + }); + }); } // ---------------------------------------------------------- diff --git a/backend/static/js/pages/zuchthunde.js b/backend/static/js/pages/zuchthunde.js index 6c27b34..0b515cc 100644 --- a/backend/static/js/pages/zuchthunde.js +++ b/backend/static/js/pages/zuchthunde.js @@ -108,8 +108,16 @@ window.Page_zuchthunde = (() => { ${UI.icon('plus')} Hund anlegen + + ${UI.icon('download-simple')} Export + + ${_appState?.user?.ki_zucht_jahresbericht !== 0 ? ` + + ${UI.icon('chart-bar')} Jahresbericht + ` : ''}
{ document.getElementById('zh-new-btn')?.addEventListener('click', () => _showHundForm(null)); document.getElementById('zh-trial-btn')?.addEventListener('click', () => _showTrialMatingModal()); + document.getElementById('zh-jahresbericht-btn')?.addEventListener('click', () => _showJahresbericht()); document.getElementById('zh-search')?.addEventListener('input', e => { _query = e.target.value.toLowerCase().trim(); @@ -215,6 +224,13 @@ window.Page_zuchthunde = (() => { }); }); + el.querySelectorAll('.zh-ki-desc-btn').forEach(btn => { + btn.addEventListener('click', () => { + const id = parseInt(btn.dataset.id); + _showKiDesc(id); + }); + }); + // Offene Sektionen wiederherstellen Object.entries(_openSections).forEach(([id, sec]) => { if (sec) _openSection(parseInt(id), sec); @@ -259,6 +275,10 @@ window.Page_zuchthunde = (() => { title="Stammbaum"> ${UI.icon('tree-structure')} Stammbaum + ${_appState.user?.ki_zucht_beschreibung !== 0 ? ` + ` : ''} ` + : ''; + const footer = ` - - `; +
+ ${kiPaarungBtn} +
+ + +
+
`; UI.modal.open({ title: `${UI.icon('dna')} Ergebnis Probeverpaarung`, @@ -1173,6 +1238,164 @@ window.Page_zuchthunde = (() => { document.getElementById('zhresult-close')?.addEventListener('click', UI.modal.close); document.getElementById('zhresult-back')?.addEventListener('click', () => _showTrialMatingModal()); + document.getElementById('trial-ki-btn')?.addEventListener('click', () => _showKiPaarung(result)); + } + + // ---------------------------------------------------------- + // KI: Hund-Beschreibung + // ---------------------------------------------------------- + async function _showKiDesc(hundId) { + UI.modal.open({ + title: `${UI.icon('sparkle')} KI-Hunde-Beschreibung`, + body: `

KI erstellt Beschreibung…

`, + footer: '', + }); + + let text = ''; + try { + const result = await API.zuchtKi.hundBeschreibung(hundId); + text = result.text || result.content || result.beschreibung || JSON.stringify(result); + } catch (err) { + UI.modal.open({ + title: `${UI.icon('sparkle')} KI-Hunde-Beschreibung`, + body: `

${_esc(err.message || 'Fehler beim Generieren.')}

`, + footer: ``, + }); + return; + } + + UI.modal.open({ + title: `${UI.icon('sparkle')} KI-Hunde-Beschreibung`, + body: `
${_esc(text)}
`, + footer: ` + + `, + }); + + document.getElementById('ki-desc-copy')?.addEventListener('click', async () => { + try { + await navigator.clipboard.writeText(text); + UI.toast.success('Text kopiert.'); + } catch { + UI.toast.error('Kopieren nicht möglich.'); + } + }); + } + + // ---------------------------------------------------------- + // KI: Jahresbericht + // ---------------------------------------------------------- + async function _showJahresbericht() { + UI.modal.open({ + title: `${UI.icon('chart-bar')} KI-Jahresbericht`, + body: `

KI analysiert deine Zuchtkartei…

`, + footer: '', + }); + + let text = ''; + try { + const result = await API.zuchtKi.jahresbericht(); + text = result.text || result.content || result.bericht || JSON.stringify(result); + } catch (err) { + UI.modal.open({ + title: `${UI.icon('chart-bar')} KI-Jahresbericht`, + body: `

${_esc(err.message || 'Fehler beim Generieren.')}

`, + footer: ``, + }); + return; + } + + UI.modal.open({ + title: `${UI.icon('chart-bar')} KI-Jahresbericht`, + body: `
${_esc(text)}
`, + footer: ` + + `, + }); + + document.getElementById('ki-bericht-copy')?.addEventListener('click', async () => { + try { + await navigator.clipboard.writeText(text); + UI.toast.success('Bericht kopiert.'); + } catch { + UI.toast.error('Kopieren nicht möglich.'); + } + }); + } + + // ---------------------------------------------------------- + // KI: Paarungsanalyse + // ---------------------------------------------------------- + async function _showKiPaarung(trialResult) { + UI.modal.open({ + title: `${UI.icon('sparkle')} KI-Paarungsanalyse`, + body: `

KI analysiert die Verpaarung…

`, + footer: '', + }); + + let result; + try { + result = await API.zuchtKi.paarungAnalyse( + trialResult.vater_id, + trialResult.mutter_id, + trialResult.ik_prozent, + trialResult.welfare?.level + ); + } catch (err) { + UI.modal.open({ + title: `${UI.icon('sparkle')} KI-Paarungsanalyse`, + body: `

${_esc(err.message || 'Fehler beim Generieren.')}

`, + footer: ``, + }); + return; + } + + const empfehlung = result.empfehlung || result.recommendation || ''; + const text = result.text || result.content || result.analyse || JSON.stringify(result); + + const empfehlungColor = { + empfohlen: '#16a34a', + bedingt: '#f59e0b', + nicht_empfohlen: '#dc2626', + }[empfehlung] || '#6b7280'; + + const empfehlungLabel = { + empfohlen: 'Empfohlen', + bedingt: 'Bedingt empfohlen', + nicht_empfohlen: 'Nicht empfohlen', + }[empfehlung] || empfehlung; + + UI.modal.open({ + title: `${UI.icon('sparkle')} KI-Paarungsanalyse`, + body: ` +
+ ${empfehlung ? ` +
+ ${UI.icon('check-circle')} ${_esc(empfehlungLabel)} +
` : ''} +
${_esc(text)}
+
`, + footer: ` + + `, + }); + + document.getElementById('ki-paarung-copy')?.addEventListener('click', async () => { + try { + await navigator.clipboard.writeText(text); + UI.toast.success('Analyse kopiert.'); + } catch { + UI.toast.error('Kopieren nicht möglich.'); + } + }); } // ---------------------------------------------------------- diff --git a/backend/static/landing.html b/backend/static/landing.html index 291da65..77e3513 100644 --- a/backend/static/landing.html +++ b/backend/static/landing.html @@ -3,15 +3,16 @@ - Ban Yaro — Die deutschsprachige Hunde-Plattform - + Ban Yaro — Hunde-App für Besitzer, Züchter & Welpen-Käufer + + - - + + @@ -19,8 +20,8 @@ - - + + @@ -30,7 +31,7 @@ "@type": "MobileApplication", "name": "Ban Yaro", "alternateName": "Ban Yaro — Die Hunde-Plattform", - "description": "Ban Yaro ist die kostenlose, deutschsprachige All-in-One Hunde-App. Digitales Tagebuch, Impfpass, Giftköder-Alarm, Gassi-Community, Hundesitting und mehr — DSGVO-konform, ohne App Store.", + "description": "Ban Yaro ist die kostenlose, deutschsprachige All-in-One Hunde-App für Hundebesitzer und Züchter. Tagebuch, Impfpass, Wurfbörse, Stammbaum, Inzucht-Koeffizient, Tierschutz-Check, Giftköder-Alarm, Gassi-Community — DSGVO-konform, ohne App Store.", "url": "https://banyaro.app", "applicationCategory": "LifestyleApplication", "applicationSubCategory": "PetApplication", @@ -69,11 +70,19 @@ "Virtueller KI-Trainer mit täglichen Übungsempfehlungen und Fortschrittsprognose", "Wöchentlicher KI-Lober — jeden Montag 2-3 Sätze Lob für die Vorwoche", "Trainings-Gamification: Streaks, Abzeichen, Trainingskalender", - "Kommandos & Fähigkeiten im Hundeprofil — praktisch für Hundesitter" + "Kommandos & Fähigkeiten im Hundeprofil — praktisch für Hundesitter", + "Wurfbörse — öffentliche Wurfankündigungen mit Filtersuche nach Rasse und Status", + "Züchter-Profile mit verifizierten Gesundheitstests und Gentests", + "Stammbaum-Visualisierung bis 4 Generationen", + "Inzucht-Koeffizient nach Wright's Formel mit Ampel-Bewertung", + "Probeverpaarung mit IK-Simulation und genetischer Risikoanalyse", + "Tierschutz-Check automatisch bei jeder Verpaarung — nicht abschaltbar", + "KI-Züchter-Assistenz: Wurfankündigungen, Genetik-Erklärung, Paarungsanalyse", + "Datenexport als HTML und ODS — keine Datenfalle" ], "screenshot": "https://banyaro.app/icons/icon-512.png", - "softwareVersion": "2.0", - "datePublished": "2026-04-25", + "softwareVersion": "2.1", + "datePublished": "2026-04-28", "areaServed": ["DE", "AT", "CH"], "audience": { "@type": "Audience", @@ -386,6 +395,7 @@
Ban Yaro Funktionen + Züchter Vergleich Preise Warum Ban Yaro? @@ -516,6 +526,67 @@
+
+
Für Züchter
+
+
+ 🐾 +

Wurfbörse

Öffentliche Wurfankündigungen mit Filter nach Rasse und Status. Interessenten schreiben direkt per Nachricht an. Für Käufer kostenlos.

Züchter
+
+
+ 🌳 +

Stammbaum

4 Generationen visuell dargestellt. Klickbare Knoten öffnen das Hunde-Profil. Teilen per Link für Käufer-Dokumentation.

Züchter
+
+
+ 🧬 +

Inzucht-Koeffizient

Automatische Berechnung nach Wright's Formel. Ampel-Bewertung: optimal unter 2,5%, kritisch ab 12,5%. Probeverpaarung simuliert jeden beliebigen Anpaarungspartner.

Züchter
+
+
+ 🩺 +

Gesundheitsdokumentation

HD, ED, Augen, Herz, DNA-Tests — alle Nachweise strukturiert erfasst. Farbcodierte Ergebnis-Badges auf einen Blick.

Züchter
+
+
+ 🛡️ +

Tierschutz-Check

Automatische Prüfung bei jeder Verpaarung: Alter, Wurfhäufigkeit, Deckpause, genetische Risiken. Nicht abschaltbar — weil die Tiere zählen.

Züchter
+
+
+ 🤖 +

KI-Assistenz

Wurfankündigungen schreiben, Genetik-Erklärungen für Käufer formulieren, Paarungsanalyse mit Empfehlung, Jahresauswertung. Nutzt Claude Sonnet direkt.

Züchter
+
+
+ 📊 +

Datenexport

Alle Zuchtkartei-Daten als HTML-Dossier (druckbar, mit Stammbaum-Visualisierung) und ODS-Tabelle (editierbar in LibreOffice/Excel). Keine Datenfalle.

Züchter
+
+
+ 📄 +

Kaufvertrag

Automatisch ausgefüllter Kaufvertrag pro Welpe als druckbares Dokument — mit Chip-Nummer, Geburtsdatum, Käufer- und Züchterdaten.

Züchter
+
+
+
+ + + + +
+
+

Die Plattform für verantwortungsvolle Züchter

+

Ban Yaro ist die erste Hunde-App die Zucht-Management, Tierschutz-Checks und KI-Assistenz in einer Plattform verbindet — gedacht für Züchter die ihre Tiere ernst nehmen.

+ +
+ +
+
🐕‍🦺
+

Für Käufer

+

Finde deinen Welpen in der Wurfbörse mit vollem Einblick in Gesundheitstests, Gentests und Stammbaum der Eltern. Schreibe direkt mit dem Züchter. Keine versteckten Händler.

+
+ +
+
+

Transparenz als Standard

+

Verifizierte Züchter-Profile mit öffentlich sichtbaren Gesundheitsdaten. Der Tierschutz-Check läuft bei jeder Verpaarung automatisch — Ergebnisse gehen direkt an den Admin wenn kritische Grenzen überschritten werden.

+
+ +
diff --git a/backend/static/llms.txt b/backend/static/llms.txt index 5f1cbf1..02c4ec2 100644 --- a/backend/static/llms.txt +++ b/backend/static/llms.txt @@ -1,6 +1,6 @@ # Ban Yaro — Die deutschsprachige Hunde-Plattform # https://banyaro.app -# Letzte Aktualisierung: 2026-04-25 +# Letzte Aktualisierung: 2026-04-28 ## Was ist Ban Yaro? @@ -15,11 +15,12 @@ Ban Yaro ist kostenlos nutzbar (Freemium-Modell). Die App ist auf allen Smartpho ## Zielgruppe - Deutschsprachige Hundebesitzer (Deutschland, Österreich, Schweiz) +- Verantwortungsvolle Hundezüchter (VDH und andere Verbände) +- Welpen-Interessenten und Käufer - Hundeschulen und Hundetrainer - Tierärzte und Praxen -- Züchter -## Funktionen (aktuell verfügbar) +## Funktionen ### Hunde-Profil & Tagebuch - Digitales Hunde-Profil (Name, Rasse, Geburtstag, Foto, Chip-Nummer) @@ -37,175 +38,148 @@ Ban Yaro ist kostenlos nutzbar (Freemium-Modell). Die App ist auf allen Smartpho - Printbarer Heimtierausweis (PDF) ### Pflege-System -- 43 rassenspezifische Pflegetipps in 10 Kategorien: Fell, Krallen, Zähne, Ohren, Augen, Pfoten, Parasiten, Saisonal, Gesundheitsvorsorge, Welpen-Pflege -- Fell-Typ-Unterscheidung: kurz / lang / lockig / Doppelmantel -- Unterscheidung Schneiden vs. Trimmen -- Tipp des Tages automatisch nach Rasse und Fell-Typ ausgewählt -- Rassen-Autocomplete im Profil verknüpft mit Pflege-Tipps +- 43 rassenspezifische Pflegetipps in 10 Kategorien +- Fell-Typ-Unterscheidung, Schneiden vs. Trimmen +- Tipp des Tages automatisch nach Rasse ausgewählt ### Training & KI-Trainer -- Tägliches Trainings-Tagebuch (Wiederholungen, Erfolgsquote 0–100%, Hundestimmung, Zufriedenheit) -- Übungsfortschritt in 5 Stufen — von "noch nicht gezeigt" bis "sitzt sicher" -- Virtueller KI-Trainer: analysiert letzte 20 Sessions, empfiehlt täglich welche Übungen anstehen -- Fortschrittsprognose bis zur Meisterschaft (Trendanalyse) -- 104 Übungen in 7 Kategorien -- KI-Trainingsplan erstellen (Plus-Feature) -- Trainingskalender im Habit-Tracker-Stil -- Gamification: Streaks, Abzeichen, XP -- Kommandos & Fähigkeiten sichtbar im Hunde-Profil (für Hundesitter) +- Tägliches Trainings-Tagebuch (Wiederholungen, Erfolgsquote, Hundestimmung) +- Übungsfortschritt in 5 Stufen, 104 Übungen in 7 Kategorien +- Virtueller KI-Trainer: analysiert letzte 20 Sessions, tägliche Empfehlung +- Fortschrittsprognose bis zur Meisterschaft +- Gamification: Streaks, Abzeichen, Trainingskalender -### Wöchentlicher Lober (KI) -- Jeden Montag schreibt die KI automatisch 2-3 Sätze Lob für die Trainingsvorwoche -- Nur Lob, kein Rat, kein Druck — positive Bestärkung -- Basiert auf den geloggten Trainingseinheiten der Vorwoche +### Züchter-Plattform (vollständig) -### Wetter & Zecken-Warnung -- Wetter-Chip direkt in der App (Open-Meteo API, ohne API-Key) -- Zecken-Warnung regelbasiert: aktiv März–Oktober bei Temperatur >7°C -- Push-Benachrichtigungen für Zecken-Saison +Ban Yaro ist die erste Hunde-App mit vollständiger Züchter-Unterstützung: -### Giftköder-Alarm -- Giftköder-Meldungen mit GPS-Koordinaten und Foto -- Push-Benachrichtigung für alle Nutzer im konfigurierbaren Umkreis -- Interaktive Karte (OpenStreetMap/Leaflet) -- Automatisches Ablaufdatum nach 7 Tagen +**Züchter-Verifizierung:** +- Antrag mit Dokumenten-Upload (VDH-Ausweis, Zuchtzulassung) +- Admin-Prüfung und Freischaltung +- Verifiziertes Züchter-Profil mit öffentlicher Seite (banyaro.app/breeder/{zwingername}) -### Sicherheit & Community-Alerts -- Verlorener Hund: Alert mit Foto und letzter GPS-Position -- Nearby-Alerts: Push-Benachrichtigungen für Ereignisse in der Nähe +**Wurfbörse:** +- Öffentliche Wurfankündigungen für alle Nutzer zugänglich (banyaro.app/wurfboerse) +- Filtersuche nach Rasse und Status (geplant / verfügbar / geboren) +- Käufer schreiben direkt per integriertem Chat an den Züchter +- Vollständige Eltern-Dokumentation sichtbar: Gesundheitstests, Gentests, Stammbaum -### NFC-Halsband-Tags -- Jeder Hund hat eine öffentliche URL (ohne Login sichtbar) -- "Ich habe diesen Hund gefunden"-Button → Besitzer bekommt Push-Benachrichtigung -- Notfallkontakt ohne Telefonnummer preiszugeben -- Physische NFC-Tags erhältlich (Shop) +**Wurfverwaltung:** +- CRUD für Würfe und einzelne Welpen +- Gewichtsverlauf pro Welpe +- Foto-System mit Sichtbarkeits-Stufen: öffentlich / nach Anfrage / privat +- Automatisch ausgefüllter Kaufvertrag als druckbares HTML-Dokument -### Gassi-Community -- Gassi-Treffen erstellen und beitreten -- GPS-Routen aufzeichnen und teilen (mit Anti-Cheat-Validierung) -- Routen bewerten (Untergrund, Schatten, Leinenpflicht, Sicherheit) -- Beliebte Routen entdecken +**Zuchtkartei:** +- Hunde-Stammdaten: Name, Rufname, Chip, Zuchtbuchnummer, Eltern (Vater/Mutter-Verknüpfung) +- Gesundheitstests: HD, ED, OCD, Augen, Herz, Patella, ZTP — mit farbigen Ergebnis-Badges +- Genetische Tests: MDR1, PRA, DM, vWD und weitere DNA-Marker (clear/carrier/affected) +- Titel & Auszeichnungen: CAC, CACIB, BOB, IPO, BH — chronologisch mit Richter und Ort -### Hundesitting-Netzwerk -- Sitter-Profile mit Erfahrung und Bewertungen -- Buchungsanfragen und Kalender -- Nur 8% Provision (vs. 20% bei Rover/Pawshake) -- Bewertungen verifizierter Buchungen +**Stammbaum:** +- Visualisierung bis 4 Generationen als horizontales CSS-Grid +- Klickbare Knoten navigieren zum jeweiligen Hunde-Profil +- Teilen-Link für Käufer-Dokumentation +- Öffentliches Hunde-Profil (banyaro.app/zucht-profil?id={id}) -### Forum -- Rassen-basierte Foren -- KI-Zusammenfassung langer Threads -- Experten-Badge (Tierarzt, Trainer) +**Inzucht-Koeffizient:** +- Automatische Berechnung nach Wright's Formel (bis 8 Generationen) +- Ampel-Bewertung: optimal <2,5% / akzeptabel <6,25% / erhöht <12,5% / kritisch ≥12,5% +- Probeverpaarung: simuliert beliebige Anpaarung ohne Speicherung -### Hunde-Wiki — Rassendatenbank -- 1003 Hunderassen, 97,6% KI-angereichert via Wikipedia-grounded Recherche -- Inhalte: Charakter, Größe, Aktivität, Eignung, Lebensdauer, Temperament -- Community-Fotos im Wiki: User können Fotos einreichen (mit Bildrechte-Bestätigung) -- Moderatoren geben Community-Fotos frei, anschließend Galerie-Ansicht -- Wiki-Foto-Badge als Gamification-Belohnung für Foto-Einreicher -- "Passt diese Rasse zu mir?" Quiz für angehende Hundebesitzer +**Tierschutz-Check (immer aktiv, nicht abschaltbar):** +- Läuft automatisch bei jeder Verpaarung und jedem neuen Wurf +- Prüft: IK, Alter der Zuchthündin (min. 18 Monate), Deckpause (min. 12 Monate), + Wurfanzahl (max. 4 empfohlen, kritisch ab 6), genetische Risiken +- Farbcodierte Rückmeldung: grün (alles ok) / gelb (Hinweis) / rot (kritisch) +- Bei "trotzdem fortfahren" auf rotem Befund: automatische Admin-Benachrichtigung +- Philosophie: informieren statt blockieren, aber volle Transparenz und Accountability -### Hunde-Knigge -- Ratgeber für Begegnungen (fremder Hund, Kinder, Radfahrer) -- Regeln in ÖPNV und öffentlichen Orten -- Haftpflicht-Ratgeber +**KI-Züchter-Assistenz:** +- Wurfankündigungen schreiben (KI generiert Text aus Eltern-Profilen) +- Genetik-Erklärung für Käufer (verständliche Sprache) und Züchter (fachlich) +- Paarungsanalyse mit Empfehlung (empfohlen / bedingt / nicht empfohlen) +- Hunde-Beschreibungen für öffentliche Profile +- Jahresbericht mit Trends und Empfehlungen +- Privilegierte Rollen (Züchter, Moderatoren, Admins) nutzen Claude Sonnet direkt -### Events & Kultur -- Agility-Turniere und Hundeausstellungen (VDH-Import) +**Datenexport:** +- Vollständiger Export als ZIP: HTML-Dossier (druckbar, Stammbaum-Visualisierung) + und ODS-Tabelle (editierbar in LibreOffice/Excel) +- 7 Tabellenblätter: Hunde, Gesundheitstests, Gentests, Titel, Würfe, Welpen, Gewichte +- Keine Datenfalle: Züchter können jederzeit alle eigenen Daten exportieren + +### Community-Features +- Giftköder-Alarm mit Push-Benachrichtigungen +- Verlorener Hund Alarm +- Gassi-Treffen organisieren und finden +- GPS-Routen aufzeichnen, teilen, bewerten +- Hundesitting-Netzwerk (nur 8% Provision vs. 20% bei Rover/Pawshake) +- Forum mit Rassen-basierten Unterforen +- Direktnachrichten / Chat +- Freundschaften und Nutzer-Profile + +### Wissen +- Hunde-Wiki: 1003 Hunderassen, Wikipedia-grounded, KI-angereichert +- Community-Fotos mit Bildrechte-Bestätigung und Moderation +- Hunde-Knigge (Begegnungen, ÖPNV, Haftpflicht) - Hundefilme-Datenbank mit "Stirbt der Hund?"-Rubrik -- Veranstaltungskalender - -### Hundefreundliche Orte -- Crowd-sourced Datenbank hundefreundlicher Orte -- Restaurants, Parks, Geschäfte -- Detaillierte Bewertungen - -### Gamification & Push -- Badges, Streaks, XP — trägt zur Nutzerbindung bei -- Wiki-Foto-Badge für Community-Foto-Beiträge -- Push-Notifications für Alerts, Erinnerungen, Wöchentlicher Lober -- Offline-Modus via Service Worker +- Erste Hilfe Notfallratgeber ## KI-Integration -Ban Yaro nutzt KI an mehreren Stellen der Plattform: - -- **Lokale KI**: LM Studio (Gemma-4-31B) auf eigenem Server — für datenschutzkritische Anfragen -- **Cloud-KI**: Claude (Anthropic, Modell: claude-sonnet-4-6) als Fallback und für rechenintensive Aufgaben -- **Symptom-Checker**: KI-gestützte Ersteinschätzung (kostenlos) -- **Virtueller KI-Trainer**: Analysiert letzte 20 Trainings-Sessions, erstellt täglich priorisierte Übungsempfehlung -- **Wöchentlicher Lober**: Vollautomatisch jeden Montag per APScheduler, lobt die Vorwoche in 2-3 Sätzen -- **Breed-Enricher**: Wikipedia-grounded Anreicherung von 1003 Rassen-Datensätzen (97,6% abgeschlossen) -- **KI-Trainingsplan** (Plus-Feature): Erstellt individuellen Trainingsplan auf Basis von Hund und Fortschritt +Ban Yaro nutzt KI an mehreren Stellen: +- **Privilegierte Nutzer** (Züchter, Moderatoren, Admins): Claude Sonnet (Anthropic) primär +- **Standard-Nutzer**: Lokales LLM (LM Studio, Gemma-4-31B) primär, Claude als Fallback +- **Tierschutz-Check**: Regelbasiert, keine KI — läuft immer zuverlässig +- **Symptom-Checker, KI-Trainer, Lober**: Für alle kostenfrei +- **Züchter-KI**: Wurfankündigungen, Genetik-Erklärungen, Paarungsanalyse, Jahresbericht ## Technologie - Progressive Web App (PWA) — installierbar ohne App Store -- Offline-fähig via Service Worker (Cache-Strategie mit Versionierung) - Backend: Python/FastAPI + SQLite - Frontend: Vanilla JS, kein Framework -- Karten: Leaflet.js + OpenStreetMap (kein Google Maps, kein API-Key) -- Wetter: Open-Meteo (kein API-Key erforderlich) +- Karten: Leaflet.js + OpenStreetMap - Hosting: Deutschland (DSGVO-konform) - Analytics: Umami v2 (cookieless, DSGVO-konform) -- KI lokal: LM Studio (Gemma-4-31B) auf eigenem Server +- KI lokal: LM Studio (Gemma-4-31B) - KI Cloud: Claude API (claude-sonnet-4-6, Anthropic) -- Push-Notifications: Web Push (VAPID) ## Monetarisierung -**Kostenlos (immer):** -- Hunde-Profile -- Tagebuch (unbegrenzte Einträge) -- Pflege-System (43 rassenspezifische Tipps) -- Symptom-Checker (KI) -- Giftköder-Alarm & Zecken-Warnung -- Verlorener Hund Alarm -- Wiki & Knigge (1003 Rassen) -- Training-Logging & KI-Trainer -- Wöchentlicher Lober -- Forum & Community -- Gassi-Treffen & Routen -- NFC-Halsband-Profil -- Heimtierausweis (Druck) +**Kostenlos:** +- Alle Basis-Features inkl. Züchter-Antrag, Wurfverwaltung, Stammbaum, Tierschutz-Check -**Ban Yaro Plus (ca. 4,99 €/Monat) — in Entwicklung:** -- Alles aus Kostenlos -- KI-Trainingsplan erstellen -- Erweiterte Statistiken & Fortschrittsanalyse +**Züchter-Provision** (geplant): Wurfbörse bleibt für Käufer kostenlos -**Provisionen:** -- Hundesitting: 8% Provision (Rover/Pawshake: 20%) +**Ban Yaro Plus** (ca. 4,99 €/Monat, in Entwicklung): +- KI-Trainingsplan, erweiterte Statistiken -**Physische Produkte:** -- NFC-Halsband-Tags (ab ca. 6 €) +**Hundesitting**: 8% Provision -## Community-Features +## Öffentliche Seiten (ohne Login) -- Forum mit Rassen-basierten Unterforen -- Community-Fotos im Rassen-Wiki (Einreichung, Moderation, Freigabe) -- Gassi-Treffen organisieren und finden -- GPS-Routen teilen und bewerten -- Hundesitting-Netzwerk (Bewertungen, verifizierte Buchungen) -- Gamification: Badges, Streaks, XP, Wiki-Foto-Badge +- https://banyaro.app — Landing Page +- https://banyaro.app/info — Landing Page (Alias) +- https://banyaro.app/wiki/rassen — Alle Hunderassen +- https://banyaro.app/wiki/rasse/{slug} — Rassen-Detail +- https://banyaro.app/wurfboerse — Öffentliche Wurfbörse (Welpen suchen) +- https://banyaro.app/breeder/{zwingername} — Öffentliches Züchter-Profil +- https://banyaro.app/knigge — Hunde-Knigge +- https://banyaro.app/hund/{id} — Öffentliches Hunde-Profil (NFC-Tag) -## Vergleich mit Konkurrenz +## Öffentliche APIs -| Funktion | Ban Yaro | Dogorama | PetDesk | Tractive | -|----------|----------|----------|---------|----------| -| Kostenlos nutzbar | Ja | Begrenzt | Nein | Nein | -| DSGVO / EU-Hosting | Ja | Nein | Nein | Teilweise | -| Giftköder-Alarm | Ja | Nein | Nein | Nein | -| Gassi-Community | Ja | Ja | Nein | Nein | -| Hundesitting | Ja (8%) | Nein | Nein | Nein | -| Digitaler Impfpass | Ja | Nein | Ja | Nein | -| NFC-Halsband-Tag | Ja | Nein | Nein | Nein | -| Pflege-Tipps rassenspezifisch | Ja | Nein | Nein | Nein | -| Rassen-Wiki (1003, KI-angereichert) | Ja | Nein | Nein | Nein | -| Symptom-Checker (kostenlos) | Ja | Nein | Nein | Nein | -| Offline-Modus | Ja | Nein | Nein | Nein | -| Kein App Store | Ja | Nein | Nein | Nein | -| Sitting-Provision | 8% | – | – | – | +- GET https://banyaro.app/api/wiki/rassen — Liste aller Hunderassen +- GET https://banyaro.app/api/wiki/rassen/{slug} — Rassen-Detail +- GET https://banyaro.app/api/litters — Öffentliche Wurfankündigungen +- GET https://banyaro.app/api/breeder/profil/{zwingername} — Züchter-Profil +- GET https://banyaro.app/api/events — Aktuelle Hundeevents +- GET https://banyaro.app/api/poison — Aktuelle Giftköder-Meldungen +- GET https://banyaro.app/api/lost — Aktuelle Vermisst-Meldungen +- GET https://banyaro.app/api/knigge/articles — Hunde-Knigge Artikel +- GET https://banyaro.app/api/stats — Community-Statistiken ## Domains @@ -216,14 +190,3 @@ Ban Yaro nutzt KI an mehreren Stellen der Plattform: Website: https://banyaro.app E-Mail: Über das Kontaktformular in der App - -## Öffentliche Daten-APIs (keine Authentifizierung nötig) - -- GET https://banyaro.app/api/wiki/rassen — Liste aller Hunderassen (1003 Einträge) -- GET https://banyaro.app/api/wiki/rassen/{slug} — Details zu einer Rasse -- GET https://banyaro.app/api/events — Aktuelle Hundeevents -- GET https://banyaro.app/api/poison — Aktuelle Giftköder-Meldungen -- GET https://banyaro.app/api/lost — Aktuelle Vermisst-Meldungen -- GET https://banyaro.app/api/knigge/articles — Hunde-Knigge Artikel -- GET https://banyaro.app/api/movies/list — Hundefilme-Datenbank -- GET https://banyaro.app/api/stats — Community-Statistiken diff --git a/backend/static/robots.txt b/backend/static/robots.txt index 84f4af4..8637aa5 100644 --- a/backend/static/robots.txt +++ b/backend/static/robots.txt @@ -4,6 +4,9 @@ Allow: /info Allow: /wiki/rassen Allow: /wiki/rasse/ Allow: /hund/ +Allow: /breeder/ +Allow: /wurfboerse +Allow: /knigge Disallow: /api/ Disallow: /ausweis/ Disallow: /teilen/ diff --git a/backend/static/sw.js b/backend/static/sw.js index 38abe6d..a405935 100644 --- a/backend/static/sw.js +++ b/backend/static/sw.js @@ -3,7 +3,7 @@ Offline-Cache + Push Notifications + Tile-Cache ============================================================ */ -const CACHE_VERSION = 'by-v465'; +const CACHE_VERSION = 'by-v474'; const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten diff --git a/backend/welfare_check.py b/backend/welfare_check.py new file mode 100644 index 0000000..802ab51 --- /dev/null +++ b/backend/welfare_check.py @@ -0,0 +1,220 @@ +"""BAN YARO — Tierschutz-Check für Züchter + +Regelbasierte Prüfung bei Verpaarungen und Wurfanlage. +Läuft immer automatisch, ist nicht abschaltbar. +""" +import logging +from datetime import date + +logger = logging.getLogger(__name__) + +# VDH-Richtwerte (Orientierung, nicht Gesetz) +MIN_ALTER_MONATE = 18 # Mindestalter Zuchthündin +MAX_ALTER_JAHRE = 8 # Empfohlenes Höchstalter +MAX_WUERFE_LIFETIME = 4 # VDH-Empfehlung +MIN_PAUSE_TAGE_WARN = 365 # 12 Monate Mindestpause (Warnung) +MIN_PAUSE_TAGE_KRIT = 270 # 9 Monate (Kritisch) +IK_WARN_PROZENT = 6.25 +IK_KRIT_PROZENT = 12.5 + + +def _level_max(a, b): + order = {"ok": 0, "info": 1, "warning": 2, "critical": 3} + return a if order.get(a, 0) >= order.get(b, 0) else b + + +def check_welfare(conn, breeder_id: int, + vater_id: int = None, + mutter_id: int = None, + ik_prozent: float = None, + genetic_risks: list = None) -> dict: + """ + Gibt zurück: + { + "level": "ok" | "info" | "warning" | "critical", + "issues": [{"code": str, "level": str, "text": str}], + "ok_points": [str] # Positive Punkte + } + """ + issues = [] + ok_pts = [] + level = "ok" + + # ------------------------------------------------------------------ + # 1. Inzuchtkoeffizient + # ------------------------------------------------------------------ + if ik_prozent is not None: + if ik_prozent >= IK_KRIT_PROZENT: + issues.append({ + "code": "IK_KRITISCH", + "level": "critical", + "text": f"Inzuchtkoeffizient {ik_prozent:.1f}% — kritisch (≥{IK_KRIT_PROZENT}%). " + "Das erhöht das Risiko für erbliche Erkrankungen und Vitalitätsverlust erheblich.", + }) + elif ik_prozent >= IK_WARN_PROZENT: + issues.append({ + "code": "IK_ERHOEHT", + "level": "warning", + "text": f"Inzuchtkoeffizient {ik_prozent:.1f}% — erhöht (≥{IK_WARN_PROZENT}%). " + "VDH-Empfehlung liegt unter 6,25%.", + }) + elif ik_prozent < 2.5: + ok_pts.append(f"Sehr niedriger Inzuchtkoeffizient ({ik_prozent:.1f}%) — genetische Vielfalt ist gut.") + + # ------------------------------------------------------------------ + # 2. Genetische Risiken (Träger × Träger / Affected) + # ------------------------------------------------------------------ + if genetic_risks: + for r in genetic_risks: + if r.get("offspring_risk") and "betroffen" in r.get("offspring_risk", ""): + pct_text = r["offspring_risk"] + if "100%" in pct_text: + issues.append({ + "code": f"GENETIK_KRITISCH_{r['marker']}", + "level": "critical", + "text": f"Genetisches Risiko {r['marker']}: {pct_text}. " + "Alle Nachkommen wären betroffen.", + }) + elif "50%" in pct_text: + issues.append({ + "code": f"GENETIK_HOCH_{r['marker']}", + "level": "critical", + "text": f"Genetisches Risiko {r['marker']}: {pct_text}.", + }) + elif "25%" in pct_text: + issues.append({ + "code": f"GENETIK_WARN_{r['marker']}", + "level": "warning", + "text": f"Genetisches Risiko {r['marker']}: {pct_text}. " + "Jeder 4. Welpe könnte betroffen sein.", + }) + + # ------------------------------------------------------------------ + # 3. Zuchthündin-Checks + # ------------------------------------------------------------------ + if mutter_id: + try: + hund = conn.execute( + "SELECT name, geburtsdatum FROM zucht_hunde WHERE id=?", (mutter_id,) + ).fetchone() + + if hund: + hund_name = hund["name"] or "Zuchthündin" + + # Alterscheck + if hund["geburtsdatum"]: + try: + geb = date.fromisoformat(str(hund["geburtsdatum"])[:10]) + alter_tage = (date.today() - geb).days + alter_monate = alter_tage / 30.44 + + if alter_monate < MIN_ALTER_MONATE: + issues.append({ + "code": "MUTTER_ZU_JUNG", + "level": "critical", + "text": f"{hund_name} ist erst {int(alter_monate)} Monate alt. " + f"Mindestalter für Erstzucht: {MIN_ALTER_MONATE} Monate. " + "Frühzucht belastet Körper und Psyche des Tieres erheblich.", + }) + elif alter_monate > MAX_ALTER_JAHRE * 12: + issues.append({ + "code": "MUTTER_ZU_ALT", + "level": "warning", + "text": f"{hund_name} ist {int(alter_monate / 12)} Jahre alt " + f"(empfohlenes Höchstalter: {MAX_ALTER_JAHRE} Jahre). " + "Ältere Hündinnen tragen ein höheres Geburtsrisiko.", + }) + else: + ok_pts.append(f"{hund_name} ist in einem geeigneten Zuchtalter ({int(alter_monate)} Monate).") + except ValueError: + pass + + # Wurfanzahl + wuerfe_gesamt = conn.execute( + "SELECT COUNT(*) FROM litters WHERE breeder_id=? AND mutter_id=?", + (breeder_id, mutter_id) + ).fetchone()[0] + + if wuerfe_gesamt > 5: + issues.append({ + "code": "ZU_VIELE_WUERFE", + "level": "critical", + "text": f"{hund_name} hatte bereits {wuerfe_gesamt} Würfe. " + f"Die VDH-Empfehlung liegt bei maximal {MAX_WUERFE_LIFETIME} Würfen pro Hündin.", + }) + elif wuerfe_gesamt >= MAX_WUERFE_LIFETIME: + issues.append({ + "code": "WUERFE_GRENZE", + "level": "warning", + "text": f"{hund_name} hat bereits {wuerfe_gesamt} Würfe — " + f"das entspricht der VDH-Empfehlung von max. {MAX_WUERFE_LIFETIME} Würfen.", + }) + elif wuerfe_gesamt == 0: + ok_pts.append(f"{hund_name} ist noch ohne Vorwürfe.") + + # Pause seit letztem Wurf + letzter = conn.execute( + "SELECT MAX(geburt_datum) FROM litters " + "WHERE breeder_id=? AND mutter_id=? AND geburt_datum IS NOT NULL", + (breeder_id, mutter_id) + ).fetchone()[0] + + if letzter: + try: + letzter_date = date.fromisoformat(str(letzter)[:10]) + abstand = (date.today() - letzter_date).days + if abstand < MIN_PAUSE_TAGE_KRIT: + issues.append({ + "code": "PAUSE_KRITISCH", + "level": "critical", + "text": f"Letzter Wurf von {hund_name} liegt erst {abstand} Tage zurück " + f"({abstand // 30} Monate). " + "Für die Erholung von Körper und Hormonhaushalt werden " + "mindestens 12–15 Monate empfohlen.", + }) + elif abstand < MIN_PAUSE_TAGE_WARN: + issues.append({ + "code": "PAUSE_KURZ", + "level": "warning", + "text": f"Letzter Wurf von {hund_name}: {abstand // 30} Monate her. " + "VDH empfiehlt mindestens 12–15 Monate Pause zwischen Würfen.", + }) + else: + ok_pts.append(f"Ausreichende Pause seit letztem Wurf von {hund_name} ({abstand // 30} Monate).") + except ValueError: + pass + + except Exception as e: + logger.warning(f"Welfare-Check Mutter fehlgeschlagen: {e}") + + # ------------------------------------------------------------------ + # Vater-Alterscheck (weniger kritisch, aber der Vollständigkeit halber) + # ------------------------------------------------------------------ + if vater_id: + try: + rüde = conn.execute( + "SELECT name, geburtsdatum FROM zucht_hunde WHERE id=?", (vater_id,) + ).fetchone() + if rüde and rüde["geburtsdatum"]: + geb = date.fromisoformat(str(rüde["geburtsdatum"])[:10]) + alter_monate = (date.today() - geb).days / 30.44 + if alter_monate < 12: + issues.append({ + "code": "VATER_ZU_JUNG", + "level": "warning", + "text": f"Deckrüde {rüde['name']} ist erst {int(alter_monate)} Monate alt. " + "Empfohlenes Mindestalter: 12 Monate.", + }) + except Exception: + pass + + # ------------------------------------------------------------------ + # Gesamtlevel + # ------------------------------------------------------------------ + for issue in issues: + level = _level_max(level, issue["level"]) + + if level == "ok" and not ok_pts: + ok_pts.append("Alle geprüften Tierschutz-Kriterien sind erfüllt.") + + return {"level": level, "issues": issues, "ok_points": ok_pts}