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

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

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

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

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

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

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

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

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

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

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

View file

@ -550,7 +550,18 @@ def _migrate(conn_factory):
("notes", "parent_label", "TEXT"), ("notes", "parent_label", "TEXT"),
("users", "notes_ki_enabled", "INTEGER NOT NULL DEFAULT 1"), ("users", "notes_ki_enabled", "INTEGER NOT NULL DEFAULT 1"),
# Züchter-Rolle # 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: with conn_factory() as conn:
for table, column, col_type in migrations: for table, column, col_type in migrations:

View file

@ -82,6 +82,28 @@ def _track_usage(user_id: int | None, source: str) -> None:
logger.warning(f"KI-Tracking fehlgeschlagen: {exc}") 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: def _check_weekly_cloud_limit(user_id: int | None) -> None:
"""Wirft KIPremiumRequired wenn user_id das wöchentliche Cloud-Limit erreicht hat.""" """Wirft KIPremiumRequired wenn user_id das wöchentliche Cloud-Limit erreicht hat."""
if user_id is None or CLOUD_WEEKLY_LIMIT <= 0: 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( user = conn.execute(
"SELECT rolle, is_moderator FROM users WHERE id=?", (user_id,) "SELECT rolle, is_moderator FROM users WHERE id=?", (user_id,)
).fetchone() ).fetchone()
# Admins, Moderatoren und Media Manager haben kein Limit # Admins, Moderatoren, Züchter und Media Manager haben kein Limit
if user and ( if user and (
user["rolle"] in ("admin", "moderator", "media_manager") user["rolle"] in ("admin", "breeder", "moderator", "media_manager")
or user["is_moderator"] or user["is_moderator"]
): ):
return return
@ -137,8 +159,28 @@ async def complete(
if requires_premium and not user_is_premium: if requires_premium and not user_is_premium:
raise KIPremiumRequired("Dieses Feature ist Teil von Ban Yaro 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"): 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: try:
text = await _local_complete(prompt, system, max_tokens, json_mode) text = await _local_complete(prompt, system, max_tokens, json_mode)
_track_usage(user_id, "local") _track_usage(user_id, "local")

View file

@ -160,6 +160,8 @@ from routes.breeder import router as breeder_router
from routes.litters import router as litters_router from routes.litters import router as litters_router
from routes.breeder_photos import router as breeder_photos_router from routes.breeder_photos import router as breeder_photos_router
from routes.zucht_hunde import router as zucht_hunde_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(auth_router, prefix="/api/auth", tags=["Auth"])
app.include_router(dogs_router, prefix="/api/dogs", tags=["Hunde"]) 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(litters_router, prefix="/api", tags=["Würfe"])
app.include_router(breeder_photos_router, prefix="/api", tags=["Züchter-Fotos"]) 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(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(webcal_router, prefix="/api/webcal", tags=["WebCal"])
app.include_router(profile_router, prefix="/api/profile", tags=["Profil"]) app.include_router(profile_router, prefix="/api/profile", tags=["Profil"])
app.include_router(import_router, prefix="/api/import", tags=["Import"]) 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/info", "monthly", "0.9"),
("https://banyaro.app/wiki/rassen", "weekly", "0.8"), ("https://banyaro.app/wiki/rassen", "weekly", "0.8"),
("https://banyaro.app/knigge", "monthly", "0.8"), ("https://banyaro.app/knigge", "monthly", "0.8"),
("https://banyaro.app/wurfboerse", "daily", "0.8"),
] ]
try: try:
@ -262,8 +267,6 @@ async def sitemap():
rassen = conn.execute( rassen = conn.execute(
"SELECT slug FROM wiki_rassen WHERE slug IS NOT NULL AND slug != '' LIMIT 500" "SELECT slug FROM wiki_rassen WHERE slug IS NOT NULL AND slug != '' LIMIT 500"
).fetchall() ).fetchall()
if rassen:
urls.append(("https://banyaro.app/wiki/rassen", "weekly", "0.8"))
for r in rassen: for r in rassen:
urls.append((f"https://banyaro.app/wiki/rasse/{r['slug']}", "monthly", "0.7")) urls.append((f"https://banyaro.app/wiki/rasse/{r['slug']}", "monthly", "0.7"))
@ -272,6 +275,20 @@ async def sitemap():
).fetchall() ).fetchall()
for e in events: for e in events:
urls.append((f"https://banyaro.app/api/events/{e['id']}", "weekly", "0.5")) 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: except Exception:
pass pass

View file

@ -11,3 +11,4 @@ openai==1.59.2
anthropic==0.49.0 anthropic==0.49.0
pywebpush==2.0.0 pywebpush==2.0.0
apscheduler==3.10.4 apscheduler==3.10.4
odfpy==1.4.1

View file

@ -52,7 +52,7 @@ async def breeder_status(user=Depends(get_current_user)):
if not row: if not row:
raise HTTPException(404, "User nicht gefunden.") raise HTTPException(404, "User nicht gefunden.")
profile = None profile = None
if row["rolle"] == "breeder": if row["rolle"] in ("breeder", "admin"):
profile = conn.execute( profile = conn.execute(
"SELECT zwingername, rasse_text, verein, vdh_mitglied, stadt, website, beschreibung, verified_at " "SELECT zwingername, rasse_text, verein, vdh_mitglied, stadt, website, beschreibung, verified_at "
"FROM breeder_profiles WHERE user_id=?", "FROM breeder_profiles WHERE user_id=?",
@ -318,6 +318,28 @@ async def breeder_public_profile(zwingername: str):
return dict(row) 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 # PUT /api/breeder/profile — eigenes Profil bearbeiten
# ------------------------------------------------------------------ # ------------------------------------------------------------------

View file

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

View file

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

View file

@ -356,10 +356,53 @@ async def trial_mating(body: TrialMatingBody, user=Depends(_require_breeder)):
gemeinsame_vorfahren.sort(key=lambda x: x["gen_vater"] + x["gen_mutter"]) 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 { return {
"ik_prozent": ik_prozent, "ik_prozent": ik_prozent,
"ik_rating": rating, "ik_rating": rating,
"gemeinsame_vorfahren": gemeinsame_vorfahren, "gemeinsame_vorfahren": gemeinsame_vorfahren,
"welfare": welfare,
} }

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

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

View file

@ -177,6 +177,9 @@
<rect width="256" height="256" fill="none"/> <rect width="256" height="256" fill="none"/>
<path d="M208,32H184V24a8,8,0,0,0-16,0v8H88V24a8,8,0,0,0-16,0v8H48A16,16,0,0,0,32,48V208a16,16,0,0,0,16,16H208a16,16,0,0,0,16-16V48A16,16,0,0,0,208,32ZM152,160H136v16a8,8,0,0,1-16,0V160H104a8,8,0,0,1,0-16h16V128a8,8,0,0,1,16,0v16h16a8,8,0,0,1,0,16ZM48,80V48H72v8a8,8,0,0,0,16,0V48h80v8a8,8,0,0,0,16,0V48h24V80Z"/> <path d="M208,32H184V24a8,8,0,0,0-16,0v8H88V24a8,8,0,0,0-16,0v8H48A16,16,0,0,0,32,48V208a16,16,0,0,0,16,16H208a16,16,0,0,0,16-16V48A16,16,0,0,0,208,32ZM152,160H136v16a8,8,0,0,1-16,0V160H104a8,8,0,0,1,0-16h16V128a8,8,0,0,1,16,0v16h16a8,8,0,0,1,0,16ZM48,80V48H72v8a8,8,0,0,0,16,0V48h80v8a8,8,0,0,0,16,0V48h24V80Z"/>
</symbol> </symbol>
<symbol id="heart-fill" viewBox="0 0 256 256">
<path d="M240,102c0,70-103.79,126.66-108.21,129a8,8,0,0,1-7.58,0C119.79,228.66,16,172,16,102A62.07,62.07,0,0,1,78,40c20.65,0,38.73,8.88,50,23.89C139.27,48.88,157.35,40,178,40A62.07,62.07,0,0,1,240,102Z"/>
</symbol>
<symbol id="tree-structure" viewBox="0 0 256 256"> <symbol id="tree-structure" viewBox="0 0 256 256">
<path d="M144,96V80H128a8,8,0,0,0-8,8v80a8,8,0,0,0,8,8h16V160a16,16,0,0,1,16-16h48a16,16,0,0,1,16,16v48a16,16,0,0,1-16,16H160a16,16,0,0,1-16-16V192H128a24,24,0,0,1-24-24V136H72v8a16,16,0,0,1-16,16H24A16,16,0,0,1,8,144V112A16,16,0,0,1,24,96H56a16,16,0,0,1,16,16v8h32V88a24,24,0,0,1,24-24h16V48a16,16,0,0,1,16-16h48a16,16,0,0,1,16,16V96a16,16,0,0,1-16,16H160A16,16,0,0,1,144,96Z"/> <path d="M144,96V80H128a8,8,0,0,0-8,8v80a8,8,0,0,0,8,8h16V160a16,16,0,0,1,16-16h48a16,16,0,0,1,16,16v48a16,16,0,0,1-16,16H160a16,16,0,0,1-16-16V192H128a24,24,0,0,1-24-24V136H72v8a16,16,0,0,1-16,16H24A16,16,0,0,1,8,144V112A16,16,0,0,1,24,96H56a16,16,0,0,1,16,16v8h32V88a24,24,0,0,1,24-24h16V48a16,16,0,0,1,16-16h48a16,16,0,0,1,16,16V96a16,16,0,0,1-16,16H160A16,16,0,0,1,144,96Z"/>
</symbol> </symbol>

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Before After
Before After

View file

@ -615,6 +615,7 @@ const API = (() => {
profile(zwingername) { return get(`/breeder/profil/${encodeURIComponent(zwingername)}`); }, profile(zwingername) { return get(`/breeder/profil/${encodeURIComponent(zwingername)}`); },
mapMarkers() { return get('/breeder/map'); }, mapMarkers() { return get('/breeder/map'); },
updateProfile(data) { return put('/breeder/profile', data); }, updateProfile(data) { return put('/breeder/profile', data); },
adminCreateProfile() { return post('/admin/breeder/create-profile', {}); },
pendingList() { return get('/admin/breeders/pending'); }, pendingList() { return get('/admin/breeders/pending'); },
documents(userId) { return get(`/admin/breeder/${userId}/documents`); }, documents(userId) { return get(`/admin/breeder/${userId}/documents`); },
documentUrl(userId, docId) { return `/api/admin/breeder/${userId}/document/${docId}`; }, documentUrl(userId, docId) { return `/api/admin/breeder/${userId}/document/${docId}`; },
@ -631,6 +632,7 @@ const API = (() => {
create(data) { return post('/litters', data); }, create(data) { return post('/litters', data); },
update(id, data) { return put(`/litters/${id}`, data); }, update(id, data) { return put(`/litters/${id}`, data); },
remove(id) { return del(`/litters/${id}`); }, remove(id) { return del(`/litters/${id}`); },
welfareConfirm(id) { return post(`/litters/${id}/welfare-confirm`, {}); },
// Welpen // Welpen
puppies(id) { return get(`/litters/${id}/puppies`); }, puppies(id) { return get(`/litters/${id}/puppies`); },
addPuppy(id, data) { return post(`/litters/${id}/puppies`, data); }, addPuppy(id, data) { return post(`/litters/${id}/puppies`, data); },
@ -653,43 +655,51 @@ const API = (() => {
remove(id) { return del(`/breeder/photos/${id}`); }, 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) // ZUCHTKARTEI (Hunde-Stammdaten, Gesundheit, Genetik, Titel)
// ---------------------------------------------------------- // ----------------------------------------------------------
const zuchthunde = { const zuchthunde = {
// Hunde
list() { return get('/zuchthunde'); }, list() { return get('/zuchthunde'); },
get(id) { return get(`/zuchthunde/${id}`); }, get(id) { return get(`/zuchthunde/${id}`); },
create(data) { return post('/zuchthunde', data); }, create(data) { return post('/zuchthunde', data); },
update(id, data) { return put(`/zuchthunde/${id}`, data); }, update(id, data) { return put(`/zuchthunde/${id}`, data); },
remove(id) { return del(`/zuchthunde/${id}`); }, remove(id) { return del(`/zuchthunde/${id}`); },
pedigree(id, gen=4) { return get(`/zuchthunde/${id}/pedigree?generations=${gen}`); }, pedigree(id, gen=4) { return get(`/zuchthunde/${id}/pedigree?generations=${gen}`); },
// Gesundheitstests
healthTests(id) { return get(`/zuchthunde/${id}/health-tests`); }, healthTests(id) { return get(`/zuchthunde/${id}/health-tests`); },
addHealthTest(id, data) { return post(`/zuchthunde/${id}/health-tests`, data); }, addHealthTest(id, data) { return post(`/zuchthunde/${id}/health-tests`, data); },
updateHealthTest(tid, data) { return put(`/zuchthunde/health-tests/${tid}`, data); }, updateHealthTest(tid, data) { return put(`/zuchthunde/health-tests/${tid}`, data); },
deleteHealthTest(tid) { return del(`/zuchthunde/health-tests/${tid}`); }, deleteHealthTest(tid) { return del(`/zuchthunde/health-tests/${tid}`); },
// Gentests
geneticTests(id) { return get(`/zuchthunde/${id}/genetic-tests`); }, geneticTests(id) { return get(`/zuchthunde/${id}/genetic-tests`); },
addGeneticTest(id, data) { return post(`/zuchthunde/${id}/genetic-tests`, data); }, addGeneticTest(id, data) { return post(`/zuchthunde/${id}/genetic-tests`, data); },
updateGeneticTest(tid, data) { return put(`/zuchthunde/genetic-tests/${tid}`, data); }, updateGeneticTest(tid, data) { return put(`/zuchthunde/genetic-tests/${tid}`, data); },
deleteGeneticTest(tid) { return del(`/zuchthunde/genetic-tests/${tid}`); }, deleteGeneticTest(tid) { return del(`/zuchthunde/genetic-tests/${tid}`); },
// Titel
titles(id) { return get(`/zuchthunde/${id}/titles`); }, titles(id) { return get(`/zuchthunde/${id}/titles`); },
addTitle(id, data) { return post(`/zuchthunde/${id}/titles`, data); }, addTitle(id, data) { return post(`/zuchthunde/${id}/titles`, data); },
updateTitle(tid, data) { return put(`/zuchthunde/titles/${tid}`, data); }, updateTitle(tid, data) { return put(`/zuchthunde/titles/${tid}`, data); },
deleteTitle(tid) { return del(`/zuchthunde/titles/${tid}`); }, deleteTitle(tid) { return del(`/zuchthunde/titles/${tid}`); },
// Probeverpaarung
trialMating(vaterId, mutterId) { return post('/zuchthunde/trial-mating', { vater_id: vaterId, mutter_id: mutterId }); }, 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, subscribeToPush, getLocation, clientNow,
APIError, APIError,
}; };

View file

@ -3,7 +3,7 @@
Router, State-Management, Navigation, Initialisierung. 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 = (() => { const App = (() => {

View file

@ -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 => { el.querySelectorAll('.litters-add-puppy-btn').forEach(btn => {
btn.addEventListener('click', () => { btn.addEventListener('click', () => {
const id = parseInt(btn.dataset.id); const id = parseInt(btn.dataset.id);
@ -249,6 +256,11 @@ window.Page_litters = (() => {
title="Elterntier-Fotos verwalten"> title="Elterntier-Fotos verwalten">
${UI.icon('users')} Eltern ${UI.icon('users')} Eltern
</button> </button>
${_appState.user?.ki_zucht_wurfankuendigung !== 0 ? `
<button class="btn btn-ghost btn-sm litters-ki-announce-btn" data-id="${l.id}"
title="KI: Wurfankündigung schreiben">
${UI.icon('sparkle')} Ankündigung
</button>` : ''}
<button class="btn btn-ghost btn-sm litters-edit-btn" data-id="${l.id}" <button class="btn btn-ghost btn-sm litters-edit-btn" data-id="${l.id}"
title="Bearbeiten"> title="Bearbeiten">
${UI.icon('pencil-simple')} ${UI.icon('pencil-simple')}
@ -477,24 +489,42 @@ window.Page_litters = (() => {
// ---------------------------------------------------------- // ----------------------------------------------------------
// Wurf-Formular (neu / bearbeiten) // Wurf-Formular (neu / bearbeiten)
// ---------------------------------------------------------- // ----------------------------------------------------------
function _showLitterForm(litter) { async function _showLitterForm(litter) {
const isEdit = !!litter; const isEdit = !!litter;
const v = litter || {}; const v = litter || {};
const today = new Date().toISOString().slice(0, 10); const today = new Date().toISOString().slice(0, 10);
// Zuchtkartei laden für Elterntier-Auswahl
let zuchthunde = [];
try { zuchthunde = await API.zuchthunde.list(); } catch {}
const maennlich = zuchthunde.filter(h => h.geschlecht !== 'weiblich');
const weiblich = zuchthunde.filter(h => h.geschlecht !== 'maennlich');
const buildSelect = (name, idName, list, currentId, currentName, placeholder) => {
const opts = list.map(h => {
const label = h.name + (h.rufname ? ` (${h.rufname})` : '') + (h.zuchtbuchnummer ? ` · ${h.zuchtbuchnummer}` : '');
return `<option value="${h.id}" data-name="${_esc(h.name)}" ${currentId == h.id ? 'selected' : ''}>${_esc(label)}</option>`;
}).join('');
return `
<select class="form-control" name="${idName}" id="${idName}-sel" style="margin-bottom:var(--space-2)">
<option value=""> ${placeholder} </option>
${opts}
</select>
<input class="form-control" type="text" name="${name}" id="${name}-txt"
value="${_esc(currentName || '')}" placeholder="oder Namen frei eingeben">`;
};
const body = ` const body = `
<form id="litter-form" autocomplete="off"> <form id="litter-form" autocomplete="off">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)"> <div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
<div class="form-group"> <div class="form-group">
<label class="form-label">Vatername</label> <label class="form-label">Vater</label>
<input class="form-control" type="text" name="vater_name" ${buildSelect('vater_name', 'vater_id', maennlich, v.vater_id, v.vater_name, 'Aus Zuchtkartei')}
value="${_esc(v.vater_name || '')}" placeholder="Name des Vaters">
</div> </div>
<div class="form-group"> <div class="form-group">
<label class="form-label">Muttername</label> <label class="form-label">Mutter</label>
<input class="form-control" type="text" name="mutter_name" ${buildSelect('mutter_name', 'mutter_id', weiblich, v.mutter_id, v.mutter_name, 'Aus Zuchtkartei')}
value="${_esc(v.mutter_name || '')}" placeholder="Name der Mutter">
</div> </div>
</div> </div>
@ -583,6 +613,16 @@ window.Page_litters = (() => {
document.getElementById('lf-cancel')?.addEventListener('click', UI.modal.close); document.getElementById('lf-cancel')?.addEventListener('click', UI.modal.close);
// Auto-Fill: Dropdown → Namenfeld befüllen
['vater', 'mutter'].forEach(role => {
document.getElementById(`${role}_id-sel`)?.addEventListener('change', e => {
const sel = e.target;
const opt = sel.options[sel.selectedIndex];
const txt = document.getElementById(`${role}_name-txt`);
if (txt) txt.value = opt.value ? (opt.dataset.name || '') : '';
});
});
document.getElementById('litter-form')?.addEventListener('submit', async e => { document.getElementById('litter-form')?.addEventListener('submit', async e => {
e.preventDefault(); e.preventDefault();
const btn = document.getElementById('lf-submit'); const btn = document.getElementById('lf-submit');
@ -591,6 +631,8 @@ window.Page_litters = (() => {
const payload = { const payload = {
vater_name: fd.get('vater_name')?.trim() || null, vater_name: fd.get('vater_name')?.trim() || null,
mutter_name: fd.get('mutter_name')?.trim() || null, mutter_name: fd.get('mutter_name')?.trim() || null,
vater_id: fd.get('vater_id') ? parseInt(fd.get('vater_id')) : null,
mutter_id: fd.get('mutter_id') ? parseInt(fd.get('mutter_id')) : null,
geburt_datum: fd.get('geburt_datum') || null, geburt_datum: fd.get('geburt_datum') || null,
erwartetes_datum: fd.get('erwartetes_datum') || null, erwartetes_datum: fd.get('erwartetes_datum') || null,
welpen_gesamt: fd.get('welpen_gesamt') ? parseInt(fd.get('welpen_gesamt')) : null, welpen_gesamt: fd.get('welpen_gesamt') ? parseInt(fd.get('welpen_gesamt')) : null,
@ -608,14 +650,16 @@ window.Page_litters = (() => {
const updated = await API.litters.update(litter.id, payload); const updated = await API.litters.update(litter.id, payload);
const idx = _litters.findIndex(l => l.id === litter.id); const idx = _litters.findIndex(l => l.id === litter.id);
if (idx !== -1) _litters[idx] = updated; if (idx !== -1) _litters[idx] = updated;
UI.modal.close();
UI.toast.success('Wurf aktualisiert.'); UI.toast.success('Wurf aktualisiert.');
_renderList();
} else { } else {
const created = await API.litters.create(payload); const created = await API.litters.create(payload);
_litters.unshift(created); _litters.unshift(created);
UI.toast.success('Wurf angelegt.'); UI.modal.close();
_renderList();
_showWelfareModal(created.welfare, created.id);
} }
UI.modal.close();
_renderList();
}); });
}); });
} }
@ -967,6 +1011,133 @@ window.Page_litters = (() => {
}); });
} }
// ----------------------------------------------------------
// Tierschutz-Check Modal
// ----------------------------------------------------------
function _showWelfareModal(welfare, litterId) {
if (!welfare) return;
const color = { ok: '#16a34a', info: '#3b82f6', warning: '#f59e0b', critical: '#dc2626' }[welfare.level] || '#6b7280';
const title = { ok: 'Alles prima', info: 'Hinweis', warning: 'Bitte beachten', critical: 'Kritischer Hinweis' }[welfare.level];
const icon = { ok: 'check-circle', info: 'info', warning: 'warning', critical: 'warning-circle' }[welfare.level];
const issueHTML = (welfare.issues || []).map(i => `
<div style="display:flex;gap:8px;padding:8px 0;border-bottom:1px solid rgba(0,0,0,.06)">
<span style="color:${color};flex-shrink:0">${UI.icon('warning')}</span>
<span style="font-size:var(--text-sm)">${_esc(i.text)}</span>
</div>`).join('');
const okHTML = (welfare.ok_points || []).map(p => `
<div style="display:flex;gap:8px;padding:4px 0">
<span style="color:#16a34a;flex-shrink:0">${UI.icon('check')}</span>
<span style="font-size:var(--text-sm);color:var(--c-text-secondary)">${_esc(p)}</span>
</div>`).join('');
const isProblematic = welfare.level === 'warning' || welfare.level === 'critical';
UI.modal.open({
title: `<span style="color:${color}">${UI.icon(icon)} Tierschutz-Check: ${title}</span>`,
body: `
<div style="background:${color}18;border:1.5px solid ${color}40;border-radius:var(--radius-md);
padding:var(--space-4);margin-bottom:var(--space-4)">
${issueHTML || ''}
${okHTML}
</div>
${welfare.level === 'critical' ? `
<div style="font-size:var(--text-sm);color:var(--c-text-secondary);
background:var(--c-surface-2);border-radius:var(--radius-sm);
padding:var(--space-3)">
${UI.icon('info')} Wenn du fortfährst, wird der Administrator informiert.
</div>` : ''}
`,
footer: isProblematic ? `
<div style="display:flex;gap:var(--space-2);width:100%">
<button class="btn btn-secondary flex-1" id="welfare-back-btn">
${UI.icon('arrow-left')} Zurück
</button>
<button class="btn btn-ghost flex-1" id="welfare-confirm-btn"
style="color:${color}">
Trotzdem fortfahren
</button>
</div>` : `
<button class="btn btn-primary" data-modal-close style="width:100%">
${UI.icon('check')} Verstanden
</button>`,
});
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: `<p style="color:var(--c-text-secondary);text-align:center;padding:var(--space-6)">
KI schreibt Wurfankündigung
</p>`,
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: `<p style="color:var(--c-danger)">${_esc(err.message || 'Fehler beim Generieren.')}</p>`,
footer: `<button class="btn btn-secondary" data-modal-close>Schließen</button>`,
});
return;
}
UI.modal.open({
title: `${UI.icon('sparkle')} KI-Wurfankündigung`,
body: `<div style="white-space:pre-wrap;font-size:var(--text-sm);line-height:1.6">${_esc(text)}</div>`,
footer: `
<button class="btn btn-secondary flex-1" id="ki-announce-copy">
${UI.icon('clipboard-text')} Kopieren
</button>
<button class="btn btn-primary flex-1" id="ki-announce-use">
${UI.icon('check')} In Beschreibung übernehmen
</button>`,
});
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 }; return { init, refresh, onDogChange };
})(); })();

View file

@ -685,6 +685,30 @@ window.Page_settings = (() => {
_loadBreederCard(); _loadBreederCard();
} }
// ----------------------------------------------------------
// KI-Toggle-Zeile (Hilfsfunktion für Züchter-Card)
// ----------------------------------------------------------
function _kiToggleRow(key, label, user) {
const active = user[key] !== 0;
return `
<div style="display:flex;justify-content:space-between;align-items:center;
padding:var(--space-2) 0;font-size:var(--text-sm)">
<span>${_esc(label)}</span>
<button class="by-toggle ki-toggle-btn" data-key="${_esc(key)}"
data-active="${active ? '1' : '0'}"
style="position:relative;display:inline-block;width:44px;height:24px;
border:none;border-radius:12px;cursor:pointer;flex-shrink:0;
background:${active ? 'var(--c-primary)' : 'var(--c-border)'};
transition:background .2s">
<span class="by-toggle-thumb"
style="position:absolute;top:2px;left:${active ? '22px' : '2px'};
width:20px;height:20px;border-radius:50%;
background:#fff;transition:left .2s;
box-shadow:0 1px 3px rgba(0,0,0,.3)"></span>
</button>
</div>`;
}
// ---------------------------------------------------------- // ----------------------------------------------------------
// ZÜCHTER-CARD — asynchron laden und in Slot rendern // ZÜCHTER-CARD — asynchron laden und in Slot rendern
// ---------------------------------------------------------- // ----------------------------------------------------------
@ -722,7 +746,30 @@ window.Page_settings = (() => {
${rolle === 'breeder' && profile ? ` ${rolle === 'breeder' && profile ? `
<button class="btn btn-secondary btn-sm" id="breeder-edit-profile-btn" style="margin-top:var(--space-3)"> <button class="btn btn-secondary btn-sm" id="breeder-edit-profile-btn" style="margin-top:var(--space-3)">
${UI.icon('pencil-simple')} Profil bearbeiten ${UI.icon('pencil-simple')} Profil bearbeiten
</button>` : ''}`; </button>` : ''}
${rolle === 'admin' && !profile ? `
<button class="btn btn-primary btn-sm" id="breeder-admin-create-btn" style="margin-top:var(--space-3)">
${UI.icon('plus')} Admin-Züchterprofil anlegen
</button>` : ''}
${rolle === 'admin' && profile ? `
<button class="btn btn-secondary btn-sm" id="breeder-edit-profile-btn" style="margin-top:var(--space-3)">
${UI.icon('pencil-simple')} Profil bearbeiten
</button>` : ''}
${profile ? `
<div style="margin-top:var(--space-4);padding-top:var(--space-3);border-top:1px solid var(--c-border)">
<div style="font-size:var(--text-xs);font-weight:600;color:var(--c-text-secondary);
text-transform:uppercase;letter-spacing:0.05em;margin-bottom:var(--space-3)">
KI-Züchter-Assistenz
</div>
${_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 || {})}
<div style="font-size:var(--text-xs);color:var(--c-text-muted);margin-top:var(--space-2)">
${UI.icon('info')} Der Tierschutz-Check läuft immer automatisch und ist nicht abschaltbar.
</div>
</div>` : ''}`;
} else if (breeder_status === 'pending') { } else if (breeder_status === 'pending') {
statusBadge = `<span class="badge" style="background:#f59e0b;color:#fff"> statusBadge = `<span class="badge" style="background:#f59e0b;color:#fff">
${UI.icon('hourglass')} Antrag wird geprüft ${UI.icon('hourglass')} Antrag wird geprüft
@ -770,6 +817,48 @@ window.Page_settings = (() => {
slot.querySelector('#breeder-edit-profile-btn')?.addEventListener('click', () => slot.querySelector('#breeder-edit-profile-btn')?.addEventListener('click', () =>
_openBreederEditModal(profile) _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.');
}
});
});
} }
// ---------------------------------------------------------- // ----------------------------------------------------------

View file

@ -108,8 +108,16 @@ window.Page_zuchthunde = (() => {
${UI.icon('plus')} Hund anlegen ${UI.icon('plus')} Hund anlegen
</button> </button>
<button class="btn btn-secondary btn-sm" id="zh-trial-btn"> <button class="btn btn-secondary btn-sm" id="zh-trial-btn">
${UI.icon('dna')} Probeverpaarung ${UI.icon('heart-fill')} Probeverpaarung
</button> </button>
<a href="/api/breeder/export" download class="btn btn-ghost btn-sm" id="zh-export-btn"
title="Alle Daten herunterladen (HTML + ODS)">
${UI.icon('download-simple')} Export
</a>
${_appState?.user?.ki_zucht_jahresbericht !== 0 ? `
<a class="btn btn-ghost btn-sm" id="zh-jahresbericht-btn">
${UI.icon('chart-bar')} Jahresbericht
</a>` : ''}
</div> </div>
<div style="padding:0 0 var(--space-3)"> <div style="padding:0 0 var(--space-3)">
<input class="form-control" id="zh-search" type="search" <input class="form-control" id="zh-search" type="search"
@ -123,6 +131,7 @@ window.Page_zuchthunde = (() => {
document.getElementById('zh-new-btn')?.addEventListener('click', () => _showHundForm(null)); document.getElementById('zh-new-btn')?.addEventListener('click', () => _showHundForm(null));
document.getElementById('zh-trial-btn')?.addEventListener('click', () => _showTrialMatingModal()); document.getElementById('zh-trial-btn')?.addEventListener('click', () => _showTrialMatingModal());
document.getElementById('zh-jahresbericht-btn')?.addEventListener('click', () => _showJahresbericht());
document.getElementById('zh-search')?.addEventListener('input', e => { document.getElementById('zh-search')?.addEventListener('input', e => {
_query = e.target.value.toLowerCase().trim(); _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 // Offene Sektionen wiederherstellen
Object.entries(_openSections).forEach(([id, sec]) => { Object.entries(_openSections).forEach(([id, sec]) => {
if (sec) _openSection(parseInt(id), sec); if (sec) _openSection(parseInt(id), sec);
@ -259,6 +275,10 @@ window.Page_zuchthunde = (() => {
title="Stammbaum"> title="Stammbaum">
${UI.icon('tree-structure')} Stammbaum ${UI.icon('tree-structure')} Stammbaum
</button> </button>
${_appState.user?.ki_zucht_beschreibung !== 0 ? `
<button class="btn btn-ghost btn-sm zh-ki-desc-btn" data-id="${h.id}">
${UI.icon('sparkle')} Beschreibung
</button>` : ''}
<button class="btn btn-ghost btn-sm zh-link-btn" data-id="${h.id}" <button class="btn btn-ghost btn-sm zh-link-btn" data-id="${h.id}"
title="Profil-Link kopieren"> title="Profil-Link kopieren">
${UI.icon('link-simple')} ${UI.icon('link-simple')}
@ -1134,6 +1154,39 @@ window.Page_zuchthunde = (() => {
}).join('') }).join('')
: `<li style="color:var(--c-text-muted)">Keine gemeinsamen Vorfahren gefunden.</li>`; : `<li style="color:var(--c-text-muted)">Keine gemeinsamen Vorfahren gefunden.</li>`;
const welfare = result.welfare;
let welfareHTML = '';
if (welfare) {
const wColor = { ok: '#16a34a', info: '#3b82f6', warning: '#f59e0b', critical: '#dc2626' }[welfare.level] || '#6b7280';
const wTitle = { ok: 'Alles prima', info: 'Hinweis', warning: 'Bitte beachten', critical: 'Kritischer Hinweis' }[welfare.level];
const wIcon = { ok: 'check-circle', info: 'info', warning: 'warning', critical: 'warning-circle' }[welfare.level];
const wIssueHTML = (welfare.issues || []).map(i => `
<div style="display:flex;gap:8px;padding:6px 0;border-bottom:1px solid rgba(0,0,0,.06)">
<span style="color:${wColor};flex-shrink:0">${UI.icon('warning')}</span>
<span style="font-size:var(--text-sm)">${_esc(i.text)}</span>
</div>`).join('');
const wOkHTML = (welfare.ok_points || []).map(p => `
<div style="display:flex;gap:8px;padding:4px 0">
<span style="color:#16a34a;flex-shrink:0">${UI.icon('check')}</span>
<span style="font-size:var(--text-sm);color:var(--c-text-secondary)">${_esc(p)}</span>
</div>`).join('');
welfareHTML = `
<div>
<div style="font-weight:var(--weight-semibold);font-size:var(--text-sm);margin-bottom:var(--space-2);
color:${wColor}">
${UI.icon(wIcon)} Tierschutz-Check: ${wTitle}
</div>
<div style="background:${wColor}18;border:1.5px solid ${wColor}40;border-radius:var(--radius-md);
padding:var(--space-3)">
${wIssueHTML || ''}
${wOkHTML}
</div>
</div>`;
}
const body = ` const body = `
<div style="display:flex;flex-direction:column;gap:var(--space-4)"> <div style="display:flex;flex-direction:column;gap:var(--space-4)">
<div style="display:flex;align-items:center;gap:var(--space-3);padding:var(--space-3); <div style="display:flex;align-items:center;gap:var(--space-3);padding:var(--space-3);
@ -1149,6 +1202,7 @@ window.Page_zuchthunde = (() => {
</div> </div>
</div> </div>
</div> </div>
${welfareHTML}
<div> <div>
<div style="font-weight:var(--weight-semibold);font-size:var(--text-sm);margin-bottom:var(--space-2)"> <div style="font-weight:var(--weight-semibold);font-size:var(--text-sm);margin-bottom:var(--space-2)">
Gemeinsame Vorfahren Gemeinsame Vorfahren
@ -1159,11 +1213,22 @@ window.Page_zuchthunde = (() => {
</div> </div>
</div>`; </div>`;
const kiPaarungBtn = _appState?.user?.ki_zucht_paarung !== 0
? `<button type="button" class="btn btn-secondary btn-sm" id="trial-ki-btn">
${UI.icon('sparkle')} KI-Analyse anfordern
</button>`
: '';
const footer = ` const footer = `
<button type="button" class="btn btn-secondary flex-1" id="zhresult-back"> <div style="display:flex;flex-direction:column;gap:var(--space-2);width:100%">
${UI.icon('arrow-left')} Zurück ${kiPaarungBtn}
</button> <div style="display:flex;gap:var(--space-2)">
<button type="button" class="btn btn-primary flex-1" id="zhresult-close">Schließen</button>`; <button type="button" class="btn btn-secondary flex-1" id="zhresult-back">
${UI.icon('arrow-left')} Zurück
</button>
<button type="button" class="btn btn-primary flex-1" id="zhresult-close">Schließen</button>
</div>
</div>`;
UI.modal.open({ UI.modal.open({
title: `${UI.icon('dna')} Ergebnis Probeverpaarung`, 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-close')?.addEventListener('click', UI.modal.close);
document.getElementById('zhresult-back')?.addEventListener('click', () => _showTrialMatingModal()); 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: `<p style="color:var(--c-text-secondary);text-align:center;padding:var(--space-6)">KI erstellt Beschreibung…</p>`,
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: `<p style="color:var(--c-danger)">${_esc(err.message || 'Fehler beim Generieren.')}</p>`,
footer: `<button class="btn btn-secondary" data-modal-close>Schließen</button>`,
});
return;
}
UI.modal.open({
title: `${UI.icon('sparkle')} KI-Hunde-Beschreibung`,
body: `<div style="white-space:pre-wrap;font-size:var(--text-sm);line-height:1.6">${_esc(text)}</div>`,
footer: `
<button class="btn btn-secondary flex-1" id="ki-desc-copy">
${UI.icon('clipboard-text')} Kopieren
</button>
<button class="btn btn-primary flex-1" data-modal-close>Schließen</button>`,
});
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: `<p style="color:var(--c-text-secondary);text-align:center;padding:var(--space-6)">KI analysiert deine Zuchtkartei…</p>`,
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: `<p style="color:var(--c-danger)">${_esc(err.message || 'Fehler beim Generieren.')}</p>`,
footer: `<button class="btn btn-secondary" data-modal-close>Schließen</button>`,
});
return;
}
UI.modal.open({
title: `${UI.icon('chart-bar')} KI-Jahresbericht`,
body: `<div style="white-space:pre-wrap;font-size:var(--text-sm);line-height:1.6">${_esc(text)}</div>`,
footer: `
<button class="btn btn-secondary flex-1" id="ki-bericht-copy">
${UI.icon('clipboard-text')} Kopieren
</button>
<button class="btn btn-primary flex-1" data-modal-close>Schließen</button>`,
});
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: `<p style="color:var(--c-text-secondary);text-align:center;padding:var(--space-6)">KI analysiert die Verpaarung…</p>`,
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: `<p style="color:var(--c-danger)">${_esc(err.message || 'Fehler beim Generieren.')}</p>`,
footer: `<button class="btn btn-secondary" data-modal-close>Schließen</button>`,
});
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: `
<div style="display:flex;flex-direction:column;gap:var(--space-4)">
${empfehlung ? `
<div style="padding:var(--space-3);border-radius:var(--radius-md);
background:${empfehlungColor}18;border:1.5px solid ${empfehlungColor}40;
font-weight:var(--weight-semibold);color:${empfehlungColor}">
${UI.icon('check-circle')} ${_esc(empfehlungLabel)}
</div>` : ''}
<div style="white-space:pre-wrap;font-size:var(--text-sm);line-height:1.6">${_esc(text)}</div>
</div>`,
footer: `
<button class="btn btn-secondary flex-1" id="ki-paarung-copy">
${UI.icon('clipboard-text')} Kopieren
</button>
<button class="btn btn-primary flex-1" data-modal-close>Schließen</button>`,
});
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.');
}
});
} }
// ---------------------------------------------------------- // ----------------------------------------------------------

View file

@ -3,15 +3,16 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Ban Yaro — Die deutschsprachige Hunde-Plattform</title> <title>Ban Yaro — Hunde-App für Besitzer, Züchter & Welpen-Käufer</title>
<meta name="description" content="Ban Yaro ist die kostenlose All-in-One Hunde-App für Deutschland, Österreich und die Schweiz. Tagebuch, Impfpass, Giftköder-Alarm, Gassi-Community, Hundesitting, Trainings-Tracker — DSGVO-konform, ohne App Store."> <meta name="description" content="Ban Yaro: Die kostenlose All-in-One Hunde-App für Deutschland, Österreich und die Schweiz. Tagebuch, Impfpass, Wurfbörse, Stammbaum, Inzucht-Check, Giftköder-Alarm — DSGVO-konform, ohne App Store.">
<meta name="keywords" content="Hunde App, Wurfbörse, Züchter, Welpen kaufen, Stammbaum Hund, Inzuchtkoeffizient, Hundezucht, Impfpass Hund, Giftköder Alarm, Gassi Community">
<meta name="robots" content="index, follow"> <meta name="robots" content="index, follow">
<link rel="canonical" href="https://banyaro.app/info"> <link rel="canonical" href="https://banyaro.app/info">
<!-- Open Graph --> <!-- Open Graph -->
<meta property="og:type" content="website"> <meta property="og:type" content="website">
<meta property="og:title" content="Ban Yaro — Die deutschsprachige Hunde-Plattform"> <meta property="og:title" content="Ban Yaro — Hunde-App für Besitzer, Züchter & Welpen-Käufer">
<meta property="og:description" content="Alles rund um deinen Hund — Tagebuch, Impfpass, Giftköder-Alarm, Gassi-Community, Hundesitting. Kostenlos, DSGVO-konform, ohne App Store."> <meta property="og:description" content="Tagebuch, Impfpass, Wurfbörse, Stammbaum, Inzucht-Koeffizient, Giftköder-Alarm, Gassi-Community — alles in einer DSGVO-konformen App ohne App Store.">
<meta property="og:url" content="https://banyaro.app/info"> <meta property="og:url" content="https://banyaro.app/info">
<meta property="og:image" content="https://banyaro.app/icons/icon-512.png"> <meta property="og:image" content="https://banyaro.app/icons/icon-512.png">
<meta property="og:locale" content="de_DE"> <meta property="og:locale" content="de_DE">
@ -19,8 +20,8 @@
<!-- Twitter Card --> <!-- Twitter Card -->
<meta name="twitter:card" content="summary_large_image"> <meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="Ban Yaro — Die deutschsprachige Hunde-Plattform"> <meta name="twitter:title" content="Ban Yaro — Hunde-App für Besitzer, Züchter & Welpen-Käufer">
<meta name="twitter:description" content="Alles rund um deinen Hund — Tagebuch, Impfpass, Giftköder-Alarm, Gassi-Community. Kostenlos, DSGVO-konform."> <meta name="twitter:description" content="Wurfbörse, Stammbaum, Inzucht-Koeffizient, Tierschutz-Check — und alles rund um deinen Hund. Kostenlos, DSGVO-konform.">
<meta name="twitter:image" content="https://banyaro.app/icons/icon-512.png"> <meta name="twitter:image" content="https://banyaro.app/icons/icon-512.png">
<!-- Structured Data --> <!-- Structured Data -->
@ -30,7 +31,7 @@
"@type": "MobileApplication", "@type": "MobileApplication",
"name": "Ban Yaro", "name": "Ban Yaro",
"alternateName": "Ban Yaro — Die Hunde-Plattform", "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", "url": "https://banyaro.app",
"applicationCategory": "LifestyleApplication", "applicationCategory": "LifestyleApplication",
"applicationSubCategory": "PetApplication", "applicationSubCategory": "PetApplication",
@ -69,11 +70,19 @@
"Virtueller KI-Trainer mit täglichen Übungsempfehlungen und Fortschrittsprognose", "Virtueller KI-Trainer mit täglichen Übungsempfehlungen und Fortschrittsprognose",
"Wöchentlicher KI-Lober — jeden Montag 2-3 Sätze Lob für die Vorwoche", "Wöchentlicher KI-Lober — jeden Montag 2-3 Sätze Lob für die Vorwoche",
"Trainings-Gamification: Streaks, Abzeichen, Trainingskalender", "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", "screenshot": "https://banyaro.app/icons/icon-512.png",
"softwareVersion": "2.0", "softwareVersion": "2.1",
"datePublished": "2026-04-25", "datePublished": "2026-04-28",
"areaServed": ["DE", "AT", "CH"], "areaServed": ["DE", "AT", "CH"],
"audience": { "audience": {
"@type": "Audience", "@type": "Audience",
@ -386,6 +395,7 @@
<div class="container"> <div class="container">
<span class="nav-brand">Ban Yaro</span> <span class="nav-brand">Ban Yaro</span>
<a href="#funktionen">Funktionen</a> <a href="#funktionen">Funktionen</a>
<a href="#zuechter">Züchter</a>
<a href="#vergleich">Vergleich</a> <a href="#vergleich">Vergleich</a>
<a href="#preise">Preise</a> <a href="#preise">Preise</a>
<a href="#warum">Warum Ban Yaro?</a> <a href="#warum">Warum Ban Yaro?</a>
@ -516,6 +526,67 @@
</div> </div>
</div> </div>
<div class="feature-group">
<div class="feature-group-label">Für Züchter</div>
<div class="feature-grid">
<div class="feature-card">
<span class="feature-icon">🐾</span>
<div><h3>Wurfbörse</h3><p>Öffentliche Wurfankündigungen mit Filter nach Rasse und Status. Interessenten schreiben direkt per Nachricht an. Für Käufer kostenlos.</p><span class="feature-tag" style="background:#7c3aed22;color:#7c3aed">Züchter</span></div>
</div>
<div class="feature-card">
<span class="feature-icon">🌳</span>
<div><h3>Stammbaum</h3><p>4 Generationen visuell dargestellt. Klickbare Knoten öffnen das Hunde-Profil. Teilen per Link für Käufer-Dokumentation.</p><span class="feature-tag" style="background:#7c3aed22;color:#7c3aed">Züchter</span></div>
</div>
<div class="feature-card">
<span class="feature-icon">🧬</span>
<div><h3>Inzucht-Koeffizient</h3><p>Automatische Berechnung nach Wright's Formel. Ampel-Bewertung: optimal unter 2,5%, kritisch ab 12,5%. Probeverpaarung simuliert jeden beliebigen Anpaarungspartner.</p><span class="feature-tag" style="background:#7c3aed22;color:#7c3aed">Züchter</span></div>
</div>
<div class="feature-card">
<span class="feature-icon">🩺</span>
<div><h3>Gesundheitsdokumentation</h3><p>HD, ED, Augen, Herz, DNA-Tests — alle Nachweise strukturiert erfasst. Farbcodierte Ergebnis-Badges auf einen Blick.</p><span class="feature-tag" style="background:#7c3aed22;color:#7c3aed">Züchter</span></div>
</div>
<div class="feature-card">
<span class="feature-icon">🛡️</span>
<div><h3>Tierschutz-Check</h3><p>Automatische Prüfung bei jeder Verpaarung: Alter, Wurfhäufigkeit, Deckpause, genetische Risiken. Nicht abschaltbar — weil die Tiere zählen.</p><span class="feature-tag" style="background:#7c3aed22;color:#7c3aed">Züchter</span></div>
</div>
<div class="feature-card">
<span class="feature-icon">🤖</span>
<div><h3>KI-Assistenz</h3><p>Wurfankündigungen schreiben, Genetik-Erklärungen für Käufer formulieren, Paarungsanalyse mit Empfehlung, Jahresauswertung. Nutzt Claude Sonnet direkt.</p><span class="feature-tag" style="background:#7c3aed22;color:#7c3aed">Züchter</span></div>
</div>
<div class="feature-card">
<span class="feature-icon">📊</span>
<div><h3>Datenexport</h3><p>Alle Zuchtkartei-Daten als HTML-Dossier (druckbar, mit Stammbaum-Visualisierung) und ODS-Tabelle (editierbar in LibreOffice/Excel). Keine Datenfalle.</p><span class="feature-tag" style="background:#7c3aed22;color:#7c3aed">Züchter</span></div>
</div>
<div class="feature-card">
<span class="feature-icon">📄</span>
<div><h3>Kaufvertrag</h3><p>Automatisch ausgefüllter Kaufvertrag pro Welpe als druckbares Dokument — mit Chip-Nummer, Geburtsdatum, Käufer- und Züchterdaten.</p><span class="feature-tag" style="background:#7c3aed22;color:#7c3aed">Züchter</span></div>
</div>
</div>
</div>
</div>
</section>
<section id="zuechter" style="background: linear-gradient(135deg, #7c3aed08 0%, #a78bfa10 100%); border-top: 1px solid #ede9fe; border-bottom: 1px solid #ede9fe;">
<div class="container">
<h2>Die Plattform für verantwortungsvolle Züchter</h2>
<p class="section-intro">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.</p>
<div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 2rem; margin-top: 2rem;">
<div style="background: white; border-radius: 12px; padding: 1.5rem; border: 1px solid #ede9fe;">
<div style="font-size: 2rem; margin-bottom: 0.75rem;">🐕‍🦺</div>
<h3 style="color: #7c3aed; margin-bottom: 0.5rem;">Für Käufer</h3>
<p style="color: #4b5563; font-size: 0.95rem; line-height: 1.6;">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.</p>
</div>
<div style="background: white; border-radius: 12px; padding: 1.5rem; border: 1px solid #ede9fe;">
<div style="font-size: 2rem; margin-bottom: 0.75rem;"></div>
<h3 style="color: #7c3aed; margin-bottom: 0.5rem;">Transparenz als Standard</h3>
<p style="color: #4b5563; font-size: 0.95rem; line-height: 1.6;">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.</p>
</div>
</div>
</div> </div>
</section> </section>

View file

@ -1,6 +1,6 @@
# Ban Yaro — Die deutschsprachige Hunde-Plattform # Ban Yaro — Die deutschsprachige Hunde-Plattform
# https://banyaro.app # https://banyaro.app
# Letzte Aktualisierung: 2026-04-25 # Letzte Aktualisierung: 2026-04-28
## Was ist Ban Yaro? ## Was ist Ban Yaro?
@ -15,11 +15,12 @@ Ban Yaro ist kostenlos nutzbar (Freemium-Modell). Die App ist auf allen Smartpho
## Zielgruppe ## Zielgruppe
- Deutschsprachige Hundebesitzer (Deutschland, Österreich, Schweiz) - Deutschsprachige Hundebesitzer (Deutschland, Österreich, Schweiz)
- Verantwortungsvolle Hundezüchter (VDH und andere Verbände)
- Welpen-Interessenten und Käufer
- Hundeschulen und Hundetrainer - Hundeschulen und Hundetrainer
- Tierärzte und Praxen - Tierärzte und Praxen
- Züchter
## Funktionen (aktuell verfügbar) ## Funktionen
### Hunde-Profil & Tagebuch ### Hunde-Profil & Tagebuch
- Digitales Hunde-Profil (Name, Rasse, Geburtstag, Foto, Chip-Nummer) - 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) - Printbarer Heimtierausweis (PDF)
### Pflege-System ### Pflege-System
- 43 rassenspezifische Pflegetipps in 10 Kategorien: Fell, Krallen, Zähne, Ohren, Augen, Pfoten, Parasiten, Saisonal, Gesundheitsvorsorge, Welpen-Pflege - 43 rassenspezifische Pflegetipps in 10 Kategorien
- Fell-Typ-Unterscheidung: kurz / lang / lockig / Doppelmantel - Fell-Typ-Unterscheidung, Schneiden vs. Trimmen
- Unterscheidung Schneiden vs. Trimmen - Tipp des Tages automatisch nach Rasse ausgewählt
- Tipp des Tages automatisch nach Rasse und Fell-Typ ausgewählt
- Rassen-Autocomplete im Profil verknüpft mit Pflege-Tipps
### Training & KI-Trainer ### Training & KI-Trainer
- Tägliches Trainings-Tagebuch (Wiederholungen, Erfolgsquote 0100%, Hundestimmung, Zufriedenheit) - Tägliches Trainings-Tagebuch (Wiederholungen, Erfolgsquote, Hundestimmung)
- Übungsfortschritt in 5 Stufen — von "noch nicht gezeigt" bis "sitzt sicher" - Übungsfortschritt in 5 Stufen, 104 Übungen in 7 Kategorien
- Virtueller KI-Trainer: analysiert letzte 20 Sessions, empfiehlt täglich welche Übungen anstehen - Virtueller KI-Trainer: analysiert letzte 20 Sessions, tägliche Empfehlung
- Fortschrittsprognose bis zur Meisterschaft (Trendanalyse) - Fortschrittsprognose bis zur Meisterschaft
- 104 Übungen in 7 Kategorien - Gamification: Streaks, Abzeichen, Trainingskalender
- KI-Trainingsplan erstellen (Plus-Feature)
- Trainingskalender im Habit-Tracker-Stil
- Gamification: Streaks, Abzeichen, XP
- Kommandos & Fähigkeiten sichtbar im Hunde-Profil (für Hundesitter)
### Wöchentlicher Lober (KI) ### Züchter-Plattform (vollständig)
- 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
### Wetter & Zecken-Warnung Ban Yaro ist die erste Hunde-App mit vollständiger Züchter-Unterstützung:
- Wetter-Chip direkt in der App (Open-Meteo API, ohne API-Key)
- Zecken-Warnung regelbasiert: aktiv MärzOktober bei Temperatur >7°C
- Push-Benachrichtigungen für Zecken-Saison
### Giftköder-Alarm **Züchter-Verifizierung:**
- Giftköder-Meldungen mit GPS-Koordinaten und Foto - Antrag mit Dokumenten-Upload (VDH-Ausweis, Zuchtzulassung)
- Push-Benachrichtigung für alle Nutzer im konfigurierbaren Umkreis - Admin-Prüfung und Freischaltung
- Interaktive Karte (OpenStreetMap/Leaflet) - Verifiziertes Züchter-Profil mit öffentlicher Seite (banyaro.app/breeder/{zwingername})
- Automatisches Ablaufdatum nach 7 Tagen
### Sicherheit & Community-Alerts **Wurfbörse:**
- Verlorener Hund: Alert mit Foto und letzter GPS-Position - Öffentliche Wurfankündigungen für alle Nutzer zugänglich (banyaro.app/wurfboerse)
- Nearby-Alerts: Push-Benachrichtigungen für Ereignisse in der Nähe - 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 **Wurfverwaltung:**
- Jeder Hund hat eine öffentliche URL (ohne Login sichtbar) - CRUD für Würfe und einzelne Welpen
- "Ich habe diesen Hund gefunden"-Button → Besitzer bekommt Push-Benachrichtigung - Gewichtsverlauf pro Welpe
- Notfallkontakt ohne Telefonnummer preiszugeben - Foto-System mit Sichtbarkeits-Stufen: öffentlich / nach Anfrage / privat
- Physische NFC-Tags erhältlich (Shop) - Automatisch ausgefüllter Kaufvertrag als druckbares HTML-Dokument
### Gassi-Community **Zuchtkartei:**
- Gassi-Treffen erstellen und beitreten - Hunde-Stammdaten: Name, Rufname, Chip, Zuchtbuchnummer, Eltern (Vater/Mutter-Verknüpfung)
- GPS-Routen aufzeichnen und teilen (mit Anti-Cheat-Validierung) - Gesundheitstests: HD, ED, OCD, Augen, Herz, Patella, ZTP — mit farbigen Ergebnis-Badges
- Routen bewerten (Untergrund, Schatten, Leinenpflicht, Sicherheit) - Genetische Tests: MDR1, PRA, DM, vWD und weitere DNA-Marker (clear/carrier/affected)
- Beliebte Routen entdecken - Titel & Auszeichnungen: CAC, CACIB, BOB, IPO, BH — chronologisch mit Richter und Ort
### Hundesitting-Netzwerk **Stammbaum:**
- Sitter-Profile mit Erfahrung und Bewertungen - Visualisierung bis 4 Generationen als horizontales CSS-Grid
- Buchungsanfragen und Kalender - Klickbare Knoten navigieren zum jeweiligen Hunde-Profil
- Nur 8% Provision (vs. 20% bei Rover/Pawshake) - Teilen-Link für Käufer-Dokumentation
- Bewertungen verifizierter Buchungen - Öffentliches Hunde-Profil (banyaro.app/zucht-profil?id={id})
### Forum **Inzucht-Koeffizient:**
- Rassen-basierte Foren - Automatische Berechnung nach Wright's Formel (bis 8 Generationen)
- KI-Zusammenfassung langer Threads - Ampel-Bewertung: optimal <2,5% / akzeptabel <6,25% / erhöht <12,5% / kritisch ≥12,5%
- Experten-Badge (Tierarzt, Trainer) - Probeverpaarung: simuliert beliebige Anpaarung ohne Speicherung
### Hunde-Wiki — Rassendatenbank **Tierschutz-Check (immer aktiv, nicht abschaltbar):**
- 1003 Hunderassen, 97,6% KI-angereichert via Wikipedia-grounded Recherche - Läuft automatisch bei jeder Verpaarung und jedem neuen Wurf
- Inhalte: Charakter, Größe, Aktivität, Eignung, Lebensdauer, Temperament - Prüft: IK, Alter der Zuchthündin (min. 18 Monate), Deckpause (min. 12 Monate),
- Community-Fotos im Wiki: User können Fotos einreichen (mit Bildrechte-Bestätigung) Wurfanzahl (max. 4 empfohlen, kritisch ab 6), genetische Risiken
- Moderatoren geben Community-Fotos frei, anschließend Galerie-Ansicht - Farbcodierte Rückmeldung: grün (alles ok) / gelb (Hinweis) / rot (kritisch)
- Wiki-Foto-Badge als Gamification-Belohnung für Foto-Einreicher - Bei "trotzdem fortfahren" auf rotem Befund: automatische Admin-Benachrichtigung
- "Passt diese Rasse zu mir?" Quiz für angehende Hundebesitzer - Philosophie: informieren statt blockieren, aber volle Transparenz und Accountability
### Hunde-Knigge **KI-Züchter-Assistenz:**
- Ratgeber für Begegnungen (fremder Hund, Kinder, Radfahrer) - Wurfankündigungen schreiben (KI generiert Text aus Eltern-Profilen)
- Regeln in ÖPNV und öffentlichen Orten - Genetik-Erklärung für Käufer (verständliche Sprache) und Züchter (fachlich)
- Haftpflicht-Ratgeber - 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 **Datenexport:**
- Agility-Turniere und Hundeausstellungen (VDH-Import) - 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 - Hundefilme-Datenbank mit "Stirbt der Hund?"-Rubrik
- Veranstaltungskalender - Erste Hilfe Notfallratgeber
### 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
## KI-Integration ## KI-Integration
Ban Yaro nutzt KI an mehreren Stellen der Plattform: Ban Yaro nutzt KI an mehreren Stellen:
- **Privilegierte Nutzer** (Züchter, Moderatoren, Admins): Claude Sonnet (Anthropic) primär
- **Lokale KI**: LM Studio (Gemma-4-31B) auf eigenem Server — für datenschutzkritische Anfragen - **Standard-Nutzer**: Lokales LLM (LM Studio, Gemma-4-31B) primär, Claude als Fallback
- **Cloud-KI**: Claude (Anthropic, Modell: claude-sonnet-4-6) als Fallback und für rechenintensive Aufgaben - **Tierschutz-Check**: Regelbasiert, keine KI — läuft immer zuverlässig
- **Symptom-Checker**: KI-gestützte Ersteinschätzung (kostenlos) - **Symptom-Checker, KI-Trainer, Lober**: Für alle kostenfrei
- **Virtueller KI-Trainer**: Analysiert letzte 20 Trainings-Sessions, erstellt täglich priorisierte Übungsempfehlung - **Züchter-KI**: Wurfankündigungen, Genetik-Erklärungen, Paarungsanalyse, Jahresbericht
- **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
## Technologie ## Technologie
- Progressive Web App (PWA) — installierbar ohne App Store - Progressive Web App (PWA) — installierbar ohne App Store
- Offline-fähig via Service Worker (Cache-Strategie mit Versionierung)
- Backend: Python/FastAPI + SQLite - Backend: Python/FastAPI + SQLite
- Frontend: Vanilla JS, kein Framework - Frontend: Vanilla JS, kein Framework
- Karten: Leaflet.js + OpenStreetMap (kein Google Maps, kein API-Key) - Karten: Leaflet.js + OpenStreetMap
- Wetter: Open-Meteo (kein API-Key erforderlich)
- Hosting: Deutschland (DSGVO-konform) - Hosting: Deutschland (DSGVO-konform)
- Analytics: Umami v2 (cookieless, 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) - KI Cloud: Claude API (claude-sonnet-4-6, Anthropic)
- Push-Notifications: Web Push (VAPID)
## Monetarisierung ## Monetarisierung
**Kostenlos (immer):** **Kostenlos:**
- Hunde-Profile - Alle Basis-Features inkl. Züchter-Antrag, Wurfverwaltung, Stammbaum, Tierschutz-Check
- 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)
**Ban Yaro Plus (ca. 4,99 €/Monat) — in Entwicklung:** **Züchter-Provision** (geplant): Wurfbörse bleibt für Käufer kostenlos
- Alles aus Kostenlos
- KI-Trainingsplan erstellen
- Erweiterte Statistiken & Fortschrittsanalyse
**Provisionen:** **Ban Yaro Plus** (ca. 4,99 €/Monat, in Entwicklung):
- Hundesitting: 8% Provision (Rover/Pawshake: 20%) - KI-Trainingsplan, erweiterte Statistiken
**Physische Produkte:** **Hundesitting**: 8% Provision
- NFC-Halsband-Tags (ab ca. 6 €)
## Community-Features ## Öffentliche Seiten (ohne Login)
- Forum mit Rassen-basierten Unterforen - https://banyaro.app — Landing Page
- Community-Fotos im Rassen-Wiki (Einreichung, Moderation, Freigabe) - https://banyaro.app/info — Landing Page (Alias)
- Gassi-Treffen organisieren und finden - https://banyaro.app/wiki/rassen — Alle Hunderassen
- GPS-Routen teilen und bewerten - https://banyaro.app/wiki/rasse/{slug} — Rassen-Detail
- Hundesitting-Netzwerk (Bewertungen, verifizierte Buchungen) - https://banyaro.app/wurfboerse — Öffentliche Wurfbörse (Welpen suchen)
- Gamification: Badges, Streaks, XP, Wiki-Foto-Badge - 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 | - GET https://banyaro.app/api/wiki/rassen — Liste aller Hunderassen
|----------|----------|----------|---------|----------| - GET https://banyaro.app/api/wiki/rassen/{slug} — Rassen-Detail
| Kostenlos nutzbar | Ja | Begrenzt | Nein | Nein | - GET https://banyaro.app/api/litters — Öffentliche Wurfankündigungen
| DSGVO / EU-Hosting | Ja | Nein | Nein | Teilweise | - GET https://banyaro.app/api/breeder/profil/{zwingername} — Züchter-Profil
| Giftköder-Alarm | Ja | Nein | Nein | Nein | - GET https://banyaro.app/api/events — Aktuelle Hundeevents
| Gassi-Community | Ja | Ja | Nein | Nein | - GET https://banyaro.app/api/poison — Aktuelle Giftköder-Meldungen
| Hundesitting | Ja (8%) | Nein | Nein | Nein | - GET https://banyaro.app/api/lost — Aktuelle Vermisst-Meldungen
| Digitaler Impfpass | Ja | Nein | Ja | Nein | - GET https://banyaro.app/api/knigge/articles — Hunde-Knigge Artikel
| NFC-Halsband-Tag | Ja | Nein | Nein | Nein | - GET https://banyaro.app/api/stats — Community-Statistiken
| 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% | | | |
## Domains ## Domains
@ -216,14 +190,3 @@ Ban Yaro nutzt KI an mehreren Stellen der Plattform:
Website: https://banyaro.app Website: https://banyaro.app
E-Mail: Über das Kontaktformular in der 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

View file

@ -4,6 +4,9 @@ Allow: /info
Allow: /wiki/rassen Allow: /wiki/rassen
Allow: /wiki/rasse/ Allow: /wiki/rasse/
Allow: /hund/ Allow: /hund/
Allow: /breeder/
Allow: /wurfboerse
Allow: /knigge
Disallow: /api/ Disallow: /api/
Disallow: /ausweis/ Disallow: /ausweis/
Disallow: /teilen/ Disallow: /teilen/

View file

@ -3,7 +3,7 @@
Offline-Cache + Push Notifications + Tile-Cache Offline-Cache + Push Notifications + Tile-Cache
============================================================ */ ============================================================ */
const CACHE_VERSION = 'by-v465'; const CACHE_VERSION = 'by-v474';
const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_STATIC = `${CACHE_VERSION}-static`;
const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten

220
backend/welfare_check.py Normal file
View file

@ -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 1215 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 1215 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}