From 180de32e57aa7f6bf739defd9853972eb1bc04f2 Mon Sep 17 00:00:00 2001 From: rene Date: Tue, 21 Apr 2026 19:38:20 +0200 Subject: [PATCH] Session 2026-04-21: SEO, Wiki-Anreicherung, Training, Lober MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SEO & Crawler: - robots.txt, llms.txt, sitemap.xml (508 Seiten bei Google) - SSR-Seiten: /info, /wiki/rassen, /wiki/rasse/{slug}, /knigge - Open Graph, JSON-LD, Breadcrumbs in index.html Navigation: - Training unter "Mein Hund", Wissen collapsible - Welcome-Seite und Landing-Page auf 5-Gruppen-Struktur Wiki: - KI-Anreicherung (Claude API): beschreibung, vorkommen_de, Steckbrief - "So einen hab ich" / Züchter-Verzeichnis - Scheduler: 50 Rassen beim Start, 20/Nacht Training: - Session-Logging (Erfolgsquote, Stimmung, Zufriedenheit) - Virtueller KI-Trainer (6h-Cache) - Trainingskalender (Habit-Tracker) - Top-Training → automatischer Tagebucheintrag - Gamification ohne Druck: Badges, Streak, Stats Fortschritts-Lober: - Jeden Montag 09:00: Claude schreibt Lob-Text pro Hund - Push + Karte im Tagebuch Monitoring: - 4× täglich Status-Mail mit Scheduler-Status + Wiki-Fortschritt --- PROJEKT.md | 49 ++ backend/database.py | 99 +++ backend/main.py | 597 ++++++++++++++++- backend/routes/admin.py | 33 + backend/routes/praise.py | 35 + backend/routes/training.py | 460 ++++++++++++- backend/routes/wiki.py | 240 ++++++- backend/scheduler.py | 399 +++++++++++- backend/scraper/breed_enricher.py | 168 +++++ backend/static/css/components.css | 73 +++ backend/static/css/layout.css | 42 ++ backend/static/index.html | 103 ++- backend/static/js/app.js | 31 +- backend/static/js/pages/diary.js | 56 ++ backend/static/js/pages/trainingsplaene.js | 154 +++++ backend/static/js/pages/uebungen.js | 628 ++++++++++++++---- backend/static/js/pages/welcome.js | 48 +- backend/static/js/pages/wiki.js | 407 +++++++++++- backend/static/landing.html | 717 +++++++++++++++++++++ backend/static/llms.txt | 167 +++++ backend/static/robots.txt | 32 + backend/static/sw.js | 2 +- 22 files changed, 4351 insertions(+), 189 deletions(-) create mode 100644 backend/routes/praise.py create mode 100644 backend/scraper/breed_enricher.py create mode 100644 backend/static/landing.html create mode 100644 backend/static/llms.txt create mode 100644 backend/static/robots.txt diff --git a/PROJEKT.md b/PROJEKT.md index 09bf6e2..8abc71d 100644 --- a/PROJEKT.md +++ b/PROJEKT.md @@ -46,6 +46,55 @@ Maps: Leaflet.js + OpenStreetMap (kostenlos, kein Google-Lock) --- +## Implementierungsstand (aktuell: 2026-04-21, SW by-v279, APP_VER 267) + +### Fertig implementiert ✅ + +#### SEO & Auffindbarkeit +- `robots.txt`, `llms.txt` (für KI-Crawler), dynamische `sitemap.xml` +- Server-gerenderte Seiten (für Google/Crawler): `/info`, `/wiki/rassen`, `/wiki/rasse/{slug}`, `/knigge` +- Open Graph + Twitter Card + JSON-LD in index.html +- Breadcrumb-Structured-Data auf Rassen-Seiten +- Google Search Console eingerichtet, 508 Seiten indexiert + +#### Navigation +- Bottom-Nav: Karte · Routen · + · Tagebuch · Forum +- Sidebar: Mein Hund (Tagebuch, Gesundheit, Übungen, Pläne) / Entdecken / Soziales / Community / Wissen (collapsible) +- Welcome-Seite und Landing-Page auf gleiche 5-Gruppen-Struktur abgestimmt + +#### Wiki-Rassen +- 500+ Rassen aus Wikidata/TheDogAPI +- KI-Anreicherung via Claude API: Charakter, Vorkommen DE, technische Daten (Scheduler: 02:30 täglich, 20 Rassen) +- "So einen hab ich" / "Interessiert mich" Community-Feature +- Züchter-Verzeichnis (community-basiert, mit Moderation) +- SSR-Detailseiten mit OG-Tags für jede Rasse + +#### Training +- Session-Logging: Wiederholungen, Erfolgsquote (0-100%), Hundestimmung, Zufriedenheit (1-5) +- Automatischer Tagebucheintrag bei Top-Training (≥80% + ≥4★) +- Virtueller KI-Trainer: analysiert letzte 20 Sessions, 6h-gecacht +- Trainingskalender (Habit-Tracker-Style, aktueller + letzter Monat) +- Gamification ohne Druck: Badges, Stats-Banner, nur vorwärts zählende Streaks +- Stats-API: Gesamteinheiten, Ø Erfolg, Streak, beste Übung + +#### Fortschritts-Lober +- Jeden Montag 09:00: Claude API schreibt 2-3 Sätze Lob für die Vorwoche +- Basiert auf: Tagebuch, Training, Gesundheitseinträge, Wochen-Gesamtzahl +- Nur Lob, kein Rat, kein Druck — spricht über den Hund, nicht den Besitzer +- Push-Notification + Karte im Tagebuch (dezent, wegklickbar) + +#### Hintergrund-Scheduler +- Gesundheits-Reminder (08:00) +- Giftköder-Archiv (03:00) +- Wetter-Alert (07:30) +- Meilenstein-Check (00:05) +- Event-Import VDH (So 02:00) +- Wiki KI-Anreicherung (02:30 täglich + Startup) +- Wöchentlicher Lober (Mo 09:00) +- 4× täglicher Status-Report per Mail (07:00 / 13:00 / 19:00 / 01:00) + +--- + ## Feature-Roadmap ### Phase 1 — MVP (Version 1.0) diff --git a/backend/database.py b/backend/database.py index 5e4be39..0292c32 100644 --- a/backend/database.py +++ b/backend/database.py @@ -910,3 +910,102 @@ def _migrate(conn_factory): logger.info("Migration: real_name Spalte hinzugefügt, %d User migriert.", len(rows)) except Exception: pass + + # Wiki: Rassen-Anreicherung + for col, typedef in [ + ("beschreibung", "TEXT"), + ("vorkommen_de", "TEXT"), + ("wikipedia_url_de","TEXT"), + ("ki_enriched", "INTEGER DEFAULT 0"), + ]: + try: + conn.execute(f"ALTER TABLE wiki_rassen ADD COLUMN {col} {typedef}") + except Exception: + pass + logger.info("Migration: wiki_rassen Anreicherungs-Felder bereit.") + + # Wiki: Züchter-Verzeichnis + conn.executescript(""" + CREATE TABLE IF NOT EXISTS wiki_zuchter ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + rasse_slug TEXT NOT NULL, + name TEXT NOT NULL, + zwingername TEXT, + ort TEXT, + plz TEXT, + bundesland TEXT, + vdh_mitglied INTEGER DEFAULT 0, + website TEXT, + telefon TEXT, + beschreibung TEXT, + verified INTEGER DEFAULT 0, + user_id INTEGER REFERENCES users(id) ON DELETE SET NULL, + created_at TEXT DEFAULT (datetime('now')) + ); + CREATE INDEX IF NOT EXISTS idx_wiki_zuchter_rasse ON wiki_zuchter(rasse_slug, verified); + """) + logger.info("Migration: wiki_zuchter Tabelle bereit.") + + # Wiki: Rasse-Interesse ("So einen hab ich" / "Interessiert mich") + conn.executescript(""" + CREATE TABLE IF NOT EXISTS wiki_breed_interest ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + rasse_slug TEXT NOT NULL, + typ TEXT NOT NULL DEFAULT 'hat', + created_at TEXT DEFAULT (datetime('now')), + UNIQUE(user_id, rasse_slug) + ); + CREATE INDEX IF NOT EXISTS idx_wbi_rasse ON wiki_breed_interest(rasse_slug, typ); + CREATE INDEX IF NOT EXISTS idx_wbi_user ON wiki_breed_interest(user_id); + """) + logger.info("Migration: wiki_breed_interest Tabelle bereit.") + + # Training: Session-Protokoll + conn.executescript(""" + CREATE TABLE IF NOT EXISTS training_sessions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + dog_id INTEGER NOT NULL REFERENCES dogs(id) ON DELETE CASCADE, + exercise_id TEXT NOT NULL, + exercise_name TEXT NOT NULL, + datum TEXT NOT NULL DEFAULT (date('now')), + wiederholungen INTEGER DEFAULT 1, + erfolgsquote INTEGER DEFAULT 50, + hund_stimmung TEXT DEFAULT 'aufmerksam', + zufriedenheit INTEGER DEFAULT 3, + notiz TEXT, + ist_top INTEGER DEFAULT 0, + diary_entry_id INTEGER REFERENCES diary(id) ON DELETE SET NULL, + created_at TEXT DEFAULT (datetime('now')) + ); + CREATE INDEX IF NOT EXISTS idx_ts_user_dog ON training_sessions(user_id, dog_id, datum DESC); + CREATE INDEX IF NOT EXISTS idx_ts_exercise ON training_sessions(exercise_id, user_id); + """) + logger.info("Migration: training_sessions Tabelle bereit.") + + # Training: KI-Feedback-Cache + conn.executescript(""" + CREATE TABLE IF NOT EXISTS training_ki_cache ( + dog_id INTEGER PRIMARY KEY, + feedback TEXT NOT NULL, + generated_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + """) + logger.info("Migration: training_ki_cache Tabelle bereit.") + + # Fortschritts-Lober: wöchentliche Lob-Karten + conn.executescript(""" + CREATE TABLE IF NOT EXISTS weekly_praise ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + dog_id INTEGER NOT NULL REFERENCES dogs(id) ON DELETE CASCADE, + week_key TEXT NOT NULL, -- ISO-Woche, z.B. "2026-W17" + praise_text TEXT NOT NULL, + stats_json TEXT, -- JSON der gesammelten Stats (für Debugging) + generated_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE(dog_id, week_key) + ); + CREATE INDEX IF NOT EXISTS idx_wp_dog_week ON weekly_praise(dog_id, week_key DESC); + """) + logger.info("Migration: weekly_praise Tabelle bereit.") diff --git a/backend/main.py b/backend/main.py index 9ed099e..a6c16aa 100644 --- a/backend/main.py +++ b/backend/main.py @@ -100,6 +100,7 @@ from routes.sitting_access import router as sitting_access_router from routes.stats import router as stats_router from routes.achievements import router as achievements_router from routes.training import router as training_router +from routes.praise import router as praise_router app.include_router(auth_router, prefix="/api/auth", tags=["Auth"]) app.include_router(dogs_router, prefix="/api/dogs", tags=["Hunde"]) @@ -137,6 +138,7 @@ app.include_router(sitting_access_router, prefix="/api/sitting-access", tags=[" app.include_router(stats_router, prefix="/api/stats", tags=["Stats"]) app.include_router(achievements_router, prefix="/api/achievements", tags=["Achievements"]) app.include_router(training_router, prefix="/api/training", tags=["Training"]) +app.include_router(praise_router, prefix="/api/praise", tags=["Praise"]) # ------------------------------------------------------------------ @@ -165,6 +167,439 @@ MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media") os.makedirs(MEDIA_DIR, exist_ok=True) app.mount("/media", StaticFiles(directory=MEDIA_DIR), name="media") +@app.get("/robots.txt") +async def robots(): + return FileResponse(f"{STATIC_DIR}/robots.txt", media_type="text/plain") + +@app.get("/llms.txt") +async def llms(): + return FileResponse(f"{STATIC_DIR}/llms.txt", media_type="text/plain") + +@app.get("/sitemap.xml") +async def sitemap(): + from fastapi.responses import Response + from database import db as _db + from datetime import date + + today = date.today().isoformat() + urls = [ + ("https://banyaro.app/", "weekly", "1.0"), + ("https://banyaro.app/info", "monthly", "0.9"), + ("https://banyaro.app/wiki/rassen", "weekly", "0.8"), + ("https://banyaro.app/knigge", "monthly", "0.8"), + ] + + try: + with _db() as conn: + rassen = conn.execute( + "SELECT slug FROM wiki_rassen WHERE slug IS NOT NULL AND slug != '' LIMIT 500" + ).fetchall() + if rassen: + urls.append(("https://banyaro.app/wiki/rassen", "weekly", "0.8")) + for r in rassen: + urls.append((f"https://banyaro.app/wiki/rasse/{r['slug']}", "monthly", "0.7")) + + events = conn.execute( + "SELECT id FROM events WHERE datum >= date('now') LIMIT 200" + ).fetchall() + for e in events: + urls.append((f"https://banyaro.app/api/events/{e['id']}", "weekly", "0.5")) + except Exception: + pass + + entries = "\n".join( + f""" + {loc} + {today} + {freq} + {prio} + """ + for loc, freq, prio in urls + ) + + xml = f""" + +{entries} +""" + return Response(content=xml, media_type="application/xml") + +@app.get("/info") +async def info_page(): + return FileResponse(f"{STATIC_DIR}/landing.html", headers={"Cache-Control": "max-age=3600"}) + + +# ------------------------------------------------------------------ +# SEO: Server-gerenderete Wiki-Rassen-Übersicht /wiki/rassen +# ------------------------------------------------------------------ +@app.get("/wiki/rassen") +async def wiki_rassen_page(): + from fastapi.responses import HTMLResponse + from database import db as _db + + with _db() as conn: + rows = conn.execute( + """SELECT name, slug, gruppe, groesse, aktivitaet, foto_url, kinder_geeignet, wohnung_geeignet + FROM wiki_rassen WHERE slug IS NOT NULL + ORDER BY name ASC""" + ).fetchall() + + rassen = [dict(r) for r in rows] + total = len(rassen) + + groessen_map = {"klein": "Klein", "mittel": "Mittel", "gross": "Groß", "sehr_gross": "Sehr groß"} + aktivitaet_map = {"niedrig": "Niedrig", "mittel": "Mittel", "hoch": "Hoch", "sehr_hoch": "Sehr hoch"} + + cards = "" + for r in rassen: + foto = r["foto_url"] or "" + img = f'{r[' if foto else '
🐕
' + groesse = groessen_map.get(r.get("groesse") or "", r.get("groesse") or "") + kinder = "✓ Kinder" if r.get("kinder_geeignet") else "" + wohnung = "✓ Wohnung" if r.get("wohnung_geeignet") else "" + tags = " ".join(f'{t}' for t in [groesse, kinder, wohnung] if t) + cards += f""" + {img} +
+

{r['name']}

+

{r.get('gruppe') or ''}

+
{tags}
+
+
\n""" + + html = f""" + + + + + Hunderassen-Wiki — {total} Rassen im Überblick | Ban Yaro + + + + + + + + + + + + +
+

Hunderassen-Wiki

+

{total} Rassen — Charakter, Eignung, Pflege auf einen Blick

+
+ +
+
+{cards} +
+
+ + +""" + return HTMLResponse(content=html, headers={"Cache-Control": "max-age=3600"}) + + +# ------------------------------------------------------------------ +# SEO: Server-gerenderete Wiki-Rassen-Detailseite /wiki/rasse/{slug} +# ------------------------------------------------------------------ +@app.get("/wiki/rasse/{slug}") +async def wiki_rasse_page(slug: str): + from fastapi.responses import HTMLResponse + from database import db as _db + + def esc(s): + if not s: return "" + return str(s).replace("&","&").replace("<","<").replace(">",">").replace('"',""") + + with _db() as conn: + rasse = conn.execute("SELECT * FROM wiki_rassen WHERE slug=?", (slug,)).fetchone() + if not rasse: + return HTMLResponse("

Rasse nicht gefunden

Alle Rassen", status_code=404) + + berichte = conn.execute( + """SELECT wb.titel, wb.text, wb.created_at, u.name AS autor + FROM wiki_berichte wb JOIN users u ON u.id=wb.user_id + WHERE wb.rasse=? ORDER BY wb.created_at DESC LIMIT 20""", + (slug,) + ).fetchall() + + r = dict(rasse) + + try: + dogs_count = conn.execute( + "SELECT COUNT(DISTINCT d.user_id) FROM dogs d WHERE LOWER(d.rasse) LIKE ?", + (f"%{r.get('name','').lower()}%",) + ).fetchone()[0] + except Exception: + dogs_count = 0 + + try: + zuchter_count = conn.execute( + "SELECT COUNT(*) FROM wiki_zuchter WHERE rasse_slug=? AND verified=1", + (slug,) + ).fetchone()[0] + except Exception: + zuchter_count = 0 + + berichte = [dict(b) for b in berichte] + berichte_count = len(berichte) + + groessen_map = {"klein":"Klein","mittel":"Mittel","gross":"Groß","sehr_gross":"Sehr groß"} + aktivitaet_map = {"niedrig":"Niedrig","mittel":"Mittel","hoch":"Hoch","sehr_hoch":"Sehr hoch"} + erfahrung_map = {"anfaenger":"Anfänger geeignet","fortgeschritten":"Für Erfahrene","experte":"Nur für Experten"} + + groesse = groessen_map.get(r.get("groesse") or "", r.get("groesse") or "–") + aktivitaet = aktivitaet_map.get(r.get("aktivitaet") or "", r.get("aktivitaet") or "–") + erfahrung = erfahrung_map.get(r.get("erfahrung") or "", r.get("erfahrung") or "–") + kinder = "Ja" if r.get("kinder_geeignet") else "Bedingt" + wohnung = "Ja" if r.get("wohnung_geeignet") else "Besser Haus mit Garten" + + gewicht = "" + if r.get("gewicht_min_kg") and r.get("gewicht_max_kg"): + gewicht = f"{r['gewicht_min_kg']}–{r['gewicht_max_kg']} kg" + elif r.get("gewicht_max_kg"): + gewicht = f"bis {r['gewicht_max_kg']} kg" + + foto_html = f'{esc(r[' if r.get("foto_url") else '
🐕
' + + facts = [ + ("Gruppe / FCI", esc(r.get("gruppe") or "")), + ("Herkunft", esc(r.get("herkunft") or "")), + ("Größe", groesse), + ("Gewicht", gewicht), + ("Lebensdauer", esc(r.get("lebensdauer") or "")), + ("Aktivitätslevel", aktivitaet), + ("Für Anfänger", erfahrung), + ("Kinder geeignet", kinder), + ("Wohnungsgeeignet", wohnung), + ("Ursprüngliche Aufgabe", esc(r.get("bred_for") or "")), + ] + facts_html = "".join( + f'
{label}{val}
' + for label, val in facts if val + ) + + temperament_html = "" + if r.get("temperament"): + tags = [t.strip() for t in str(r["temperament"]).split(",") if t.strip()] + temperament_html = ( + '
' + + "".join( + f'{esc(t)}' + for t in tags + ) + + '
' + ) + + berichte_html = "" + if berichte: + for b in berichte: + datum = b.get("created_at","")[:10] if b.get("created_at") else "" + berichte_html += f"""
+
{esc(b.get('autor',''))} · {datum}
+

{esc(b.get('titel',''))}

+

{esc(b.get('text',''))}

+
""" + + beschreibung_html = "" + if r.get("beschreibung"): + beschreibung_html = ( + '
' + '

Charakter & Wesen

' + f'

{esc(r["beschreibung"])}

' + '
' + ) + + vorkommen_html = "" + if r.get("vorkommen_de"): + vorkommen_html = ( + '
' + '

Vorkommen in Deutschland

' + f'

{esc(r["vorkommen_de"])}

' + '
' + ) + + stats_html = ( + '
' + f'🐕 {dogs_count} Nutzer haben diesen Hund' + f'🏆 {zuchter_count} Züchter eingetragen' + f'💬 {berichte_count} Community-Berichte' + '
' + ) + + name = esc(r.get("name","")) + gruppe = esc(r.get("gruppe") or "") + herkunft = esc(r.get("herkunft") or "") + temp_str = esc(r.get("temperament") or "") + beschr_str = esc(r.get("beschreibung") or "") + if beschr_str: + desc = f"{name} — {beschr_str[:160]}".strip().rstrip(".") + else: + desc = f"{name} — Hunderasse aus {herkunft}. Größe: {groesse}. Aktivität: {aktivitaet}. {temp_str[:120] if temp_str else ''}".strip().rstrip(".") + json_ld = f"""{{ + "@context":"https://schema.org", + "@type":"Article", + "headline":"{name} — Rasse-Profil", + "description":"{desc}", + "url":"https://banyaro.app/wiki/rasse/{slug}", + "inLanguage":"de", + "publisher":{{"@type":"Organization","name":"Ban Yaro","url":"https://banyaro.app"}}, + "mainEntityOfPage":{{"@type":"WebPage","@id":"https://banyaro.app/wiki/rasse/{slug}"}} + }}""" + + html = f""" + + + + + {name} — Hunderasse Profil | Ban Yaro Wiki + + + + + + + + + + + + + + + + + + + + +
+ + +
+ {foto_html} +
+

{name}

+ {'

' + gruppe + '

' if gruppe else ''} + {temperament_html} + In der App öffnen +
+
+ + {beschreibung_html} + + {vorkommen_html} + +
+

Steckbrief

+
{facts_html}
+
+ + {stats_html} + + {'

Erfahrungsberichte der Community (' + str(berichte_count) + ')

' + berichte_html + '
' if berichte else ''} + +
+

In der App

+

+ Ban Yaro ist die kostenlose Hunde-App für Deutschland, Österreich und die Schweiz. + Tagebuch, Impfpass, Giftköder-Alarm, Gassi-Community und mehr — DSGVO-konform, ohne App Store. +

+ Kostenlos starten +
+
+ + +""" + return HTMLResponse(content=html, headers={"Cache-Control": "max-age=3600"}) + + @app.get("/favicon.ico") async def favicon(): return FileResponse(f"{STATIC_DIR}/icons/favicon.ico") @@ -193,12 +628,45 @@ async def share_target(request: Request): # Öffentliche Hunde-Profilseite (für NFC-Tags, kein Login nötig) @app.get("/hund/{dog_id}") async def public_dog_page(dog_id: int): + from database import db as _db + _og_name = "Hunde-Profil" + _og_desc = "Hunde-Profil auf Ban Yaro — der deutschen Hunde-Plattform" + _og_img = "https://banyaro.app/icons/icon-512.png" + try: + with _db() as conn: + _dog = conn.execute( + "SELECT name, rasse, foto_url, bio FROM dogs WHERE id=? AND is_public=1", + (dog_id,) + ).fetchone() + if _dog: + _og_name = _dog["name"] + _rasse = f" · {_dog['rasse']}" if _dog.get("rasse") else "" + _og_desc = f"{_dog['name']}{_rasse} — Profil auf Ban Yaro" + if _dog.get("bio"): + _og_desc = f"{_dog['name']}{_rasse}: {str(_dog['bio'])[:120]}" + if _dog.get("foto_url"): + _og_img = f"https://banyaro.app{_dog['foto_url']}" + except Exception: + pass + html = f""" - Hunde-Profil — BAN YARO + {_og_name} — Ban Yaro + + + + + + + + + + + + + + +
+

Hunde-Knigge

+

Regeln, Tipps und häufige Fragen für Hundebesitzer — im Alltag, im ÖPNV und in der Community

+
+ +
+
+

Begegnungen im Alltag

+""" + begs_html + """ +
+
+

Häufige Fragen & Regeln

+""" + szen_html + """ +
+
+

Mehr Tipps in der Ban Yaro App

+

Community-Abstimmungen zu kniffligen Situationen, KI-Situationsberater und alle Hunde-Funktionen kostenlos nutzen.

+ Kostenlos starten +
+
+ + +""" + return HTMLResponse(content=html, headers={"Cache-Control": "max-age=7200"}) + + # SPA Fallback — ALLE nicht-API-Routen gehen zur index.html @app.get("/{full_path:path}") async def spa_fallback(full_path: str): diff --git a/backend/routes/admin.py b/backend/routes/admin.py index 0a805c1..7168bd2 100644 --- a/backend/routes/admin.py +++ b/backend/routes/admin.py @@ -86,6 +86,9 @@ class UserPatch(BaseModel): is_banned: Optional[int] = None ban_reason: Optional[str] = None +class WikiEnrichBody(BaseModel): + limit: int = 10 + class ThreadAdminPatch(BaseModel): is_pinned: Optional[int] = None is_locked: Optional[int] = None @@ -550,3 +553,33 @@ async def get_analytics(user=Depends(require_mod)): "pageviews": r_pv.json(), "top_pages": _to_list(r_pages), } + + +# ------------------------------------------------------------------ +# POST /api/admin/wiki/enrich — KI-Rassen-Anreicherung anstoßen +# ------------------------------------------------------------------ +@router.post("/wiki/enrich") +async def wiki_enrich(data: WikiEnrichBody, user=Depends(require_mod)): + from scraper.breed_enricher import enrich_breeds + limit = max(1, min(data.limit, 100)) + enriched = await enrich_breeds(limit) + with db() as conn: + remaining = conn.execute( + "SELECT COUNT(*) FROM wiki_rassen WHERE ki_enriched=0" + ).fetchone()[0] + return {"enriched": enriched, "remaining": remaining} + + +# ------------------------------------------------------------------ +# DELETE /api/admin/wiki/zuchter/{id} — Züchter-Eintrag löschen (Admin/Mod) +# ------------------------------------------------------------------ +@router.delete("/wiki/zuchter/{zuchter_id}", status_code=204) +async def admin_delete_zuchter(zuchter_id: int, user=Depends(require_mod)): + with db() as conn: + row = conn.execute( + "SELECT id FROM wiki_zuchter WHERE id=?", (zuchter_id,) + ).fetchone() + if not row: + raise HTTPException(404, "Züchter nicht gefunden.") + conn.execute("DELETE FROM wiki_zuchter WHERE id=?", (zuchter_id,)) + _audit(conn, user, "wiki_zuchter_delete", f"zuchter:{zuchter_id}") diff --git a/backend/routes/praise.py b/backend/routes/praise.py new file mode 100644 index 0000000..790a722 --- /dev/null +++ b/backend/routes/praise.py @@ -0,0 +1,35 @@ +"""BAN YARO — Fortschritts-Lober""" +from fastapi import APIRouter, Depends, Query +from auth import get_current_user +from database import db +from datetime import date + +router = APIRouter() + +def _current_week_key(): + d = date.today() + return f"{d.isocalendar()[0]}-W{d.isocalendar()[1]:02d}" + +@router.get("/current") +async def get_current_praise(dog_id: int = Query(...), user=Depends(get_current_user)): + """Gibt den Lob-Text der aktuellen Woche zurück.""" + week_key = _current_week_key() + with db() as conn: + # Sicherheitscheck: Hund gehört dem User? + dog = conn.execute( + """SELECT d.id, d.name FROM dogs d + LEFT JOIN dog_shares ds ON ds.dog_id=d.id AND ds.shared_with_id=? + WHERE d.id=? AND (d.user_id=? OR ds.id IS NOT NULL)""", + (user["id"], dog_id, user["id"]) + ).fetchone() + if not dog: + return {"praise": None} + + row = conn.execute( + "SELECT praise_text, generated_at FROM weekly_praise WHERE dog_id=? AND week_key=?", + (dog_id, week_key) + ).fetchone() + + if not row: + return {"praise": None, "week_key": week_key} + return {"praise": row["praise_text"], "week_key": week_key, "generated_at": row["generated_at"]} diff --git a/backend/routes/training.py b/backend/routes/training.py index 91163b1..b05c802 100644 --- a/backend/routes/training.py +++ b/backend/routes/training.py @@ -1,8 +1,10 @@ """BAN YARO — Übungs- & Trainingsfortschritt""" -from fastapi import APIRouter, Depends +from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel from typing import Optional +import datetime +import ki from database import db from auth import get_current_user @@ -154,3 +156,459 @@ async def get_suggestions(user=Depends(get_current_user)): }) return suggestions[:2] # max 2 Empfehlungen + + +# ------------------------------------------------------------------ +# Training: Session-Protokoll +# ------------------------------------------------------------------ + +STIMMUNGS_LABELS = { + "aufmerksam": "aufmerksam", + "muede": "müde", + "abgelenkt": "abgelenkt", + "super": "super motiviert", +} + +TRAINING_BADGES = [ + ("training_first", 1, "Erste Trainingseinheit", + "Ihr habt gemeinsam die erste Einheit abgeschlossen \U0001f43e"), + ("training_5", 5, "5 Einheiten", + "5 Trainingseinheiten \u2014 ihr seid dabei!"), + ("training_10", 10, "10 Einheiten", + "10 Einheiten! {name} macht gro\u00dfartige Fortschritte."), + ("training_25", 25, "25 Einheiten", + "25 Einheiten \u2014 eine echte Trainingspartnerschaft!"), + ("training_50", 50, "50 Einheiten", + "50 Einheiten! {name} und du seid ein echtes Team."), + ("training_top_5", 5, "5 Top-Trainings", + "5 Top-Trainings \u2014 {name} ist ein Schnellerner!"), +] + + +def _check_badges(conn, user_id: int, dog_name: str) -> list: + """Prüft und vergibt Trainings-Badges. Gibt neu verdiente Badges zurück.""" + total = conn.execute( + "SELECT COUNT(*) FROM training_sessions WHERE user_id=?", + (user_id,) + ).fetchone()[0] + top_count = conn.execute( + "SELECT COUNT(*) FROM training_sessions WHERE user_id=? AND ist_top=1", + (user_id,) + ).fetchone()[0] + + existing = { + r[0] for r in conn.execute( + "SELECT badge_id FROM user_badges WHERE user_id=?", (user_id,) + ).fetchall() + } + + new_badges = [] + for badge_id, threshold, title, desc_tpl in TRAINING_BADGES: + if badge_id in existing: + continue + count = top_count if badge_id == "training_top_5" else total + if count >= threshold: + desc = desc_tpl.format(name=dog_name) + conn.execute( + "INSERT OR IGNORE INTO user_badges (user_id, badge_id) VALUES (?,?)", + (user_id, badge_id) + ) + new_badges.append({"id": badge_id, "title": title, "desc": desc}) + + return new_badges + + +class SessionCreate(BaseModel): + dog_id: int + exercise_id: str + exercise_name: str + datum: Optional[str] = None + wiederholungen: int = 1 + erfolgsquote: int = 50 + hund_stimmung: str = "aufmerksam" + zufriedenheit: int = 3 + notiz: Optional[str] = None + tagebuch_eintrag: bool = False + + +@router.post("/sessions") +async def log_session(body: SessionCreate, user=Depends(get_current_user)): + uid = user["id"] + + with db() as conn: + # Hund-Zugehörigkeit prüfen + dog = conn.execute( + "SELECT id, name FROM dogs WHERE id=? AND user_id=?", + (body.dog_id, uid) + ).fetchone() + if not dog: + raise HTTPException(404, "Hund nicht gefunden.") + + dog_name = dog["name"] + datum = body.datum or datetime.date.today().isoformat() + ist_top = int(body.erfolgsquote >= 80 and body.zufriedenheit >= 4) + + cur = conn.execute( + """ + INSERT INTO training_sessions + (user_id, dog_id, exercise_id, exercise_name, datum, + wiederholungen, erfolgsquote, hund_stimmung, zufriedenheit, + notiz, ist_top) + VALUES (?,?,?,?,?,?,?,?,?,?,?) + """, + (uid, body.dog_id, body.exercise_id, body.exercise_name, datum, + body.wiederholungen, body.erfolgsquote, body.hund_stimmung, + body.zufriedenheit, body.notiz, ist_top) + ) + session_id = cur.lastrowid + + # Badges prüfen + new_badges = _check_badges(conn, uid, dog_name) + + # Tagebucheintrag erstellen? + diary_entry_id = None + if body.tagebuch_eintrag or ist_top: + stimmung_label = STIMMUNGS_LABELS.get(body.hund_stimmung, body.hund_stimmung) + if ist_top: + titel = f"\U0001f3af {body.exercise_name} \u2014 Top-Training!" + else: + titel = f"\U0001f3af Training: {body.exercise_name}" + text_parts = [ + f"{body.wiederholungen} Wiederholungen \u00b7 " + f"Erfolgsquote: {body.erfolgsquote}% \u00b7 " + f"Stimmung: {stimmung_label}" + ] + if body.notiz: + text_parts.append(f"\n\n{body.notiz}") + eintrag_text = "".join(text_parts) + + diary_cur = conn.execute( + """ + INSERT INTO diary (dog_id, datum, typ, titel, text) + VALUES (?,?,?,?,?) + """, + (body.dog_id, datum, "training", titel, eintrag_text) + ) + diary_entry_id = diary_cur.lastrowid + + conn.execute( + "INSERT OR IGNORE INTO diary_dogs (diary_id, dog_id) VALUES (?,?)", + (diary_entry_id, body.dog_id) + ) + + conn.execute( + "UPDATE training_sessions SET diary_entry_id=? WHERE id=?", + (diary_entry_id, session_id) + ) + + session = { + "id": session_id, + "user_id": uid, + "dog_id": body.dog_id, + "exercise_id": body.exercise_id, + "exercise_name": body.exercise_name, + "datum": datum, + "wiederholungen": body.wiederholungen, + "erfolgsquote": body.erfolgsquote, + "hund_stimmung": body.hund_stimmung, + "zufriedenheit": body.zufriedenheit, + "notiz": body.notiz, + "ist_top": bool(ist_top), + "diary_entry_id": diary_entry_id, + } + + return { + "session": session, + "ist_top": bool(ist_top), + "badges": new_badges, + "diary_entry_id": diary_entry_id, + } + + +@router.get("/sessions") +async def get_sessions( + dog_id: int, + limit: int = 50, + offset: int = 0, + user=Depends(get_current_user) +): + uid = user["id"] + with db() as conn: + dog = conn.execute( + "SELECT id FROM dogs WHERE id=? AND user_id=?", (dog_id, uid) + ).fetchone() + if not dog: + raise HTTPException(404, "Hund nicht gefunden.") + + rows = conn.execute( + """ + SELECT * FROM training_sessions + WHERE user_id=? AND dog_id=? + ORDER BY datum DESC, created_at DESC + LIMIT ? OFFSET ? + """, + (uid, dog_id, limit, offset) + ).fetchall() + return [dict(r) for r in rows] + + +@router.get("/calendar") +async def get_calendar( + dog_id: int, + year: Optional[int] = None, + month: Optional[int] = None, + user=Depends(get_current_user) +): + uid = user["id"] + today = datetime.date.today() + year = year or today.year + month = month or today.month + + month_str = f"{year:04d}-{month:02d}" + + with db() as conn: + dog = conn.execute( + "SELECT id FROM dogs WHERE id=? AND user_id=?", (dog_id, uid) + ).fetchone() + if not dog: + raise HTTPException(404, "Hund nicht gefunden.") + + rows = conn.execute( + """ + SELECT datum, + COUNT(*) AS count, + MAX(ist_top) AS top + FROM training_sessions + WHERE user_id=? AND dog_id=? + AND datum LIKE ? + GROUP BY datum + """, + (uid, dog_id, month_str + "-%") + ).fetchall() + + days = { + r["datum"]: {"count": r["count"], "top": bool(r["top"])} + for r in rows + } + return {"days": days} + + +@router.get("/stats") +async def get_stats(dog_id: int, user=Depends(get_current_user)): + uid = user["id"] + with db() as conn: + dog = conn.execute( + "SELECT id FROM dogs WHERE id=? AND user_id=?", (dog_id, uid) + ).fetchone() + if not dog: + raise HTTPException(404, "Hund nicht gefunden.") + + total_sessions = conn.execute( + "SELECT COUNT(*) FROM training_sessions WHERE user_id=? AND dog_id=?", + (uid, dog_id) + ).fetchone()[0] + + total_exercises = conn.execute( + "SELECT COUNT(DISTINCT exercise_id) FROM training_sessions WHERE user_id=? AND dog_id=?", + (uid, dog_id) + ).fetchone()[0] + + avg_row = conn.execute( + "SELECT AVG(erfolgsquote) FROM training_sessions WHERE user_id=? AND dog_id=?", + (uid, dog_id) + ).fetchone() + avg_erfolgsquote = round(avg_row[0], 1) if avg_row[0] is not None else 0.0 + + best_row = conn.execute( + """ + SELECT exercise_name, AVG(erfolgsquote) AS avg_e + FROM training_sessions + WHERE user_id=? AND dog_id=? + GROUP BY exercise_id + ORDER BY avg_e DESC + LIMIT 1 + """, + (uid, dog_id) + ).fetchone() + best_exercise = ( + {"name": best_row["exercise_name"], "avg_erfolg": round(best_row["avg_e"], 1)} + if best_row else None + ) + + training_days = conn.execute( + "SELECT COUNT(DISTINCT datum) FROM training_sessions WHERE user_id=? AND dog_id=?", + (uid, dog_id) + ).fetchone()[0] + + top_sessions = conn.execute( + "SELECT COUNT(*) FROM training_sessions WHERE user_id=? AND dog_id=? AND ist_top=1", + (uid, dog_id) + ).fetchone()[0] + + today = datetime.date.today() + week_start = (today - datetime.timedelta(days=today.weekday())).isoformat() + this_week = conn.execute( + "SELECT COUNT(*) FROM training_sessions WHERE user_id=? AND dog_id=? AND datum >= ?", + (uid, dog_id, week_start) + ).fetchone()[0] + + month_start = today.replace(day=1).isoformat() + this_month = conn.execute( + "SELECT COUNT(*) FROM training_sessions WHERE user_id=? AND dog_id=? AND datum >= ?", + (uid, dog_id, month_start) + ).fetchone()[0] + + # Streak: aufeinanderfolgende Tage bis heute + day_rows = conn.execute( + """ + SELECT DISTINCT datum FROM training_sessions + WHERE user_id=? AND dog_id=? + ORDER BY datum DESC + """, + (uid, dog_id) + ).fetchall() + streak_days = 0 + check = today + for row in day_rows: + d = datetime.date.fromisoformat(row["datum"]) + if d == check: + streak_days += 1 + check -= datetime.timedelta(days=1) + else: + break + + return { + "total_sessions": total_sessions, + "total_exercises": total_exercises, + "avg_erfolgsquote": avg_erfolgsquote, + "best_exercise": best_exercise, + "training_days": training_days, + "top_sessions": top_sessions, + "this_week": this_week, + "this_month": this_month, + "streak_days": streak_days, + } + + +class KiFeedbackRequest(BaseModel): + dog_id: int + + +@router.post("/ki-feedback") +async def ki_feedback(body: KiFeedbackRequest, user=Depends(get_current_user)): + uid = user["id"] + + with db() as conn: + dog = conn.execute( + "SELECT id, name, rasse, geburtstag FROM dogs WHERE id=? AND user_id=?", + (body.dog_id, uid) + ).fetchone() + if not dog: + raise HTTPException(404, "Hund nicht gefunden.") + + # Cache prüfen + cache_row = conn.execute( + "SELECT feedback, generated_at FROM training_ki_cache WHERE dog_id=?", + (body.dog_id,) + ).fetchone() + + if cache_row: + generated_at = datetime.datetime.fromisoformat(cache_row["generated_at"]) + age_hours = (datetime.datetime.utcnow() - generated_at).total_seconds() / 3600 + + # Neue Sessions seit letzter Generierung? + new_since = conn.execute( + "SELECT COUNT(*) FROM training_sessions WHERE dog_id=? AND created_at > ?", + (body.dog_id, cache_row["generated_at"]) + ).fetchone()[0] + + if age_hours < 6 and new_since == 0: + return {"feedback": cache_row["feedback"], "cached": True} + + # Hund-Alter berechnen + alter_str = "unbekannt" + if dog["geburtstag"]: + try: + geb = datetime.date.fromisoformat(dog["geburtstag"]) + alter_jahre = (datetime.date.today() - geb).days // 365 + alter_str = f"{alter_jahre} Jahre" + except Exception: + pass + + # Letzte 20 Sessions laden + sessions = conn.execute( + """ + SELECT datum, exercise_name, wiederholungen, erfolgsquote, + hund_stimmung, zufriedenheit, notiz, ist_top + FROM training_sessions + WHERE dog_id=? + ORDER BY datum DESC, created_at DESC + LIMIT 20 + """, + (body.dog_id,) + ).fetchall() + + # Übungsfortschritt laden + progress_rows = conn.execute( + "SELECT exercise_id, status FROM exercise_progress WHERE user_id=?", + (uid,) + ).fetchall() + + # Prompt zusammenbauen + dog_name = dog["name"] + dog_rasse = dog["rasse"] or "unbekannter Rasse" + + sessions_text = "\n".join( + f" {r['datum']}: {r['exercise_name']}, " + f"{r['wiederholungen']}x, {r['erfolgsquote']}% Erfolg, " + f"Stimmung: {STIMMUNGS_LABELS.get(r['hund_stimmung'], r['hund_stimmung'])}, " + f"Zufriedenheit: {r['zufriedenheit']}/5" + + (f", Notiz: {r['notiz']}" if r["notiz"] else "") + + (" [TOP]" if r["ist_top"] else "") + for r in sessions + ) or " (noch keine Sessions)" + + progress_text = "\n".join( + f" {r['exercise_id']}: {r['status']}" + for r in progress_rows + ) or " (kein Fortschritt erfasst)" + + prompt = ( + f"Hund: {dog_name}, {dog_rasse}, {alter_str}\n" + f"Letzte Sessions:\n{sessions_text}\n" + f"Übungsfortschritt:\n{progress_text}\n\n" + "Antworte mit maximal 3 kurzen Abschnitten:\n" + "1. Was gut läuft (1-2 Sätze, immer positiv beginnen)\n" + "2. Konkrete Empfehlung (1-2 Sätze, spezifisch auf die Daten bezogen)\n" + "3. Kleiner motivierender Abschluss (1 Satz)\n\n" + "Sprich über den Hund, nicht über den Besitzer. Kein Druck, keine Forderungen." + ) + system = ( + "Du bist ein einfühlsamer, positiver Hundetrainer. " + "Analysiere die Trainingshistorie und gib kurzes, motivierendes Feedback auf Deutsch." + ) + + try: + feedback_text = await ki.complete( + prompt, + system=system, + max_tokens=400, + requires_premium=False, + user_is_premium=user.get("is_premium", False), + ) + except (ki.KIUnavailableError, ki.KIPremiumRequired) as e: + raise HTTPException(503, str(e)) + + # Cache speichern + with db() as conn: + conn.execute( + """ + INSERT INTO training_ki_cache (dog_id, feedback, generated_at) + VALUES (?,?,datetime('now')) + ON CONFLICT(dog_id) DO UPDATE + SET feedback=excluded.feedback, generated_at=excluded.generated_at + """, + (body.dog_id, feedback_text) + ) + + return {"feedback": feedback_text, "cached": False} diff --git a/backend/routes/wiki.py b/backend/routes/wiki.py index edf98e5..f3d5d69 100644 --- a/backend/routes/wiki.py +++ b/backend/routes/wiki.py @@ -7,7 +7,7 @@ import logging from fastapi import APIRouter, Depends, HTTPException, Query, UploadFile, File from pydantic import BaseModel from database import db -from auth import get_current_user +from auth import get_current_user, get_current_user_optional logger = logging.getLogger(__name__) MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media") @@ -437,3 +437,241 @@ async def review_submission(sub_id: int, data: ReviewModel, user=Depends(get_cur pass return {"ok": True} + + +# ------------------------------------------------------------------ +# Hilfsfunktion: Stats für eine Rasse zusammenstellen +# ------------------------------------------------------------------ +def _build_stats(conn, rasse_slug: str, user_id=None) -> dict: + dogs_count = conn.execute( + "SELECT COUNT(DISTINCT user_id) FROM dogs WHERE LOWER(rasse) = LOWER(?)", + (rasse_slug,), + ).fetchone()[0] + + hat_count = conn.execute( + "SELECT COUNT(*) FROM wiki_breed_interest WHERE rasse_slug=? AND typ='hat'", + (rasse_slug,), + ).fetchone()[0] + + will_count = conn.execute( + "SELECT COUNT(*) FROM wiki_breed_interest WHERE rasse_slug=? AND typ='will'", + (rasse_slug,), + ).fetchone()[0] + + zuchter_count = conn.execute( + "SELECT COUNT(*) FROM wiki_zuchter WHERE rasse_slug=? AND verified=1", + (rasse_slug,), + ).fetchone()[0] + + berichte_count = conn.execute( + "SELECT COUNT(*) FROM wiki_berichte WHERE rasse=?", + (rasse_slug,), + ).fetchone()[0] + + user_interest = None + if user_id: + row = conn.execute( + "SELECT typ FROM wiki_breed_interest WHERE user_id=? AND rasse_slug=?", + (user_id, rasse_slug), + ).fetchone() + if row: + user_interest = row["typ"] + + return { + "dogs_count": dogs_count, + "hat_count": hat_count, + "will_count": will_count, + "zuchter_count": zuchter_count, + "berichte_count":berichte_count, + "user_interest": user_interest, + } + + +# ------------------------------------------------------------------ +# GET /api/wiki/rassen/{slug}/stats +# ------------------------------------------------------------------ +@router.get("/rassen/{slug}/stats") +async def get_rasse_stats(slug: str, user=Depends(get_current_user_optional)): + with db() as conn: + rasse = conn.execute( + "SELECT slug FROM wiki_rassen WHERE slug=?", (slug,) + ).fetchone() + if not rasse: + raise HTTPException(404, "Rasse nicht gefunden.") + return _build_stats(conn, slug, user["id"] if user else None) + + +# ------------------------------------------------------------------ +# Schemas für Interesse und Züchter +# ------------------------------------------------------------------ +class InteresseCreate(BaseModel): + typ: str # "hat" oder "will" + +class ZuchterCreate(BaseModel): + rasse_slug: str + name: str + zwingername: str = "" + ort: str = "" + plz: str = "" + bundesland: str = "" + vdh_mitglied: int = 0 + website: str = "" + telefon: str = "" + beschreibung: str = "" + + +# ------------------------------------------------------------------ +# POST /api/wiki/rassen/{slug}/interesse +# ------------------------------------------------------------------ +@router.post("/rassen/{slug}/interesse") +async def set_interesse(slug: str, data: InteresseCreate, user=Depends(get_current_user)): + if data.typ not in ("hat", "will"): + raise HTTPException(400, "typ muss 'hat' oder 'will' sein.") + with db() as conn: + rasse = conn.execute( + "SELECT slug FROM wiki_rassen WHERE slug=?", (slug,) + ).fetchone() + if not rasse: + raise HTTPException(404, "Rasse nicht gefunden.") + conn.execute( + """INSERT INTO wiki_breed_interest (user_id, rasse_slug, typ) + VALUES (?, ?, ?) + ON CONFLICT(user_id, rasse_slug) DO UPDATE SET typ=excluded.typ""", + (user["id"], slug, data.typ), + ) + return _build_stats(conn, slug, user["id"]) + + +# ------------------------------------------------------------------ +# DELETE /api/wiki/rassen/{slug}/interesse +# ------------------------------------------------------------------ +@router.delete("/rassen/{slug}/interesse") +async def delete_interesse(slug: str, user=Depends(get_current_user)): + with db() as conn: + conn.execute( + "DELETE FROM wiki_breed_interest WHERE user_id=? AND rasse_slug=?", + (user["id"], slug), + ) + return _build_stats(conn, slug, user["id"]) + + +# ------------------------------------------------------------------ +# GET /api/wiki/rassen/{slug}/zuchter +# ------------------------------------------------------------------ +@router.get("/rassen/{slug}/zuchter") +async def get_zuchter_fuer_rasse( + slug: str, + limit: int = Query(50, ge=1, le=200), + offset: int = Query(0, ge=0), +): + with db() as conn: + rows = conn.execute( + """SELECT z.id, z.rasse_slug, z.name, z.zwingername, z.ort, z.plz, + z.bundesland, z.vdh_mitglied, z.website, z.telefon, + z.beschreibung, z.created_at + FROM wiki_zuchter z + WHERE z.rasse_slug=? AND z.verified=1 + ORDER BY z.bundesland ASC, z.ort ASC + LIMIT ? OFFSET ?""", + (slug, limit, offset), + ).fetchall() + total = conn.execute( + "SELECT COUNT(*) FROM wiki_zuchter WHERE rasse_slug=? AND verified=1", + (slug,), + ).fetchone()[0] + return {"zuchter": [dict(r) for r in rows], "total": total} + + +# ------------------------------------------------------------------ +# POST /api/wiki/zuchter — Züchter einreichen +# ------------------------------------------------------------------ +@router.post("/zuchter", status_code=201) +async def create_zuchter(data: ZuchterCreate, user=Depends(get_current_user)): + if not data.name.strip(): + raise HTTPException(400, "Name darf nicht leer sein.") + with db() as conn: + rasse = conn.execute( + "SELECT slug FROM wiki_rassen WHERE slug=?", (data.rasse_slug,) + ).fetchone() + if not rasse: + raise HTTPException(400, "Ungültige Rasse.") + cur = conn.execute( + """INSERT INTO wiki_zuchter + (rasse_slug, name, zwingername, ort, plz, bundesland, + vdh_mitglied, website, telefon, beschreibung, verified, user_id) + VALUES (?,?,?,?,?,?,?,?,?,?,0,?)""", + ( + data.rasse_slug, data.name.strip(), + data.zwingername.strip() or None, + data.ort.strip() or None, + data.plz.strip() or None, + data.bundesland.strip() or None, + data.vdh_mitglied, + data.website.strip() or None, + data.telefon.strip() or None, + data.beschreibung.strip() or None, + user["id"], + ), + ) + row = conn.execute( + "SELECT * FROM wiki_zuchter WHERE id=?", (cur.lastrowid,) + ).fetchone() + return dict(row) + + +# ------------------------------------------------------------------ +# DELETE /api/wiki/zuchter/{id} — eigene Einreichung löschen +# ------------------------------------------------------------------ +@router.delete("/zuchter/{zuchter_id}") +async def delete_zuchter(zuchter_id: int, user=Depends(get_current_user)): + with db() as conn: + row = conn.execute( + "SELECT id, user_id FROM wiki_zuchter WHERE id=?", (zuchter_id,) + ).fetchone() + if not row: + raise HTTPException(404, "Züchter nicht gefunden.") + is_admin = user.get("rolle") == "admin" or user.get("is_moderator") + if row["user_id"] != user["id"] and not is_admin: + raise HTTPException(403, "Nicht erlaubt.") + conn.execute("DELETE FROM wiki_zuchter WHERE id=?", (zuchter_id,)) + return {"ok": True} + + +# ------------------------------------------------------------------ +# GET /api/wiki/zuchter/pending — unverifizierte Einreichungen (Mod/Admin) +# ------------------------------------------------------------------ +@router.get("/zuchter/pending") +async def list_zuchter_pending(user=Depends(get_current_user)): + if not (user.get("is_moderator") or user.get("rolle") in ("admin", "moderator")): + raise HTTPException(403, "Nur Moderatoren.") + with db() as conn: + rows = conn.execute( + """SELECT z.*, u.name AS user_name + FROM wiki_zuchter z + LEFT JOIN users u ON u.id = z.user_id + WHERE z.verified=0 + ORDER BY z.created_at ASC""", + ).fetchall() + return [dict(r) for r in rows] + + +# ------------------------------------------------------------------ +# PATCH /api/wiki/zuchter/{id}/verify — Züchter freigeben (Mod/Admin) +# ------------------------------------------------------------------ +@router.patch("/zuchter/{zuchter_id}/verify") +async def verify_zuchter(zuchter_id: int, user=Depends(get_current_user)): + if not (user.get("is_moderator") or user.get("rolle") in ("admin", "moderator")): + raise HTTPException(403, "Nur Moderatoren.") + with db() as conn: + row = conn.execute( + "SELECT id FROM wiki_zuchter WHERE id=?", (zuchter_id,) + ).fetchone() + if not row: + raise HTTPException(404, "Züchter nicht gefunden.") + conn.execute( + "UPDATE wiki_zuchter SET verified=1 WHERE id=?", (zuchter_id,) + ) + result = conn.execute( + "SELECT * FROM wiki_zuchter WHERE id=?", (zuchter_id,) + ).fetchone() + return dict(result) diff --git a/backend/scheduler.py b/backend/scheduler.py index a476ce9..4702f89 100644 --- a/backend/scheduler.py +++ b/backend/scheduler.py @@ -19,6 +19,9 @@ logger = logging.getLogger(__name__) _scheduler = AsyncIOScheduler(timezone="Europe/Berlin") +# In-Memory Job-Protokoll: {job_id: {"last_run": datetime, "result": str, "status": "ok"|"error"}} +_job_log: dict = {} + def start(): _scheduler.add_job( @@ -87,8 +90,41 @@ def start(): id="seed_wikidata_startup", replace_existing=True, ) + # Täglich 02:30 Uhr — KI-Anreicherung für 20 noch nicht angereicherte Rassen + _scheduler.add_job( + _job_wiki_enrich, + CronTrigger(hour=2, minute=30), + id="wiki_enrich_nightly", + replace_existing=True, + misfire_grace_time=3600, + ) + # Jeden Montag 09:00 — Wöchentlicher Fortschritts-Lober + _scheduler.add_job( + _job_weekly_praise, + CronTrigger(day_of_week='mon', hour=9, minute=0), + id="weekly_praise", + replace_existing=True, + misfire_grace_time=3600, + ) + # 4× täglich Status-Report per Mail (07:00, 13:00, 19:00, 01:00) + for _h in [7, 13, 19, 1]: + _scheduler.add_job( + _job_status_report, + CronTrigger(hour=_h, minute=0), + id=f"status_report_{_h:02d}", + replace_existing=True, + misfire_grace_time=1800, + ) + # Einmalig beim Start (nach 90s) — erste 50 Rassen sofort anreichern + _scheduler.add_job( + _job_wiki_enrich_startup, + 'date', + run_date=datetime.now(tz=_TZ) + timedelta(seconds=90), + id="wiki_enrich_startup", + replace_existing=True, + ) _scheduler.start() - logger.info("Scheduler gestartet — Health-Reminder 08:00, Giftköder-Archiv 03:00, Wetter-Alert 07:30, Meilenstein-Check 00:05, Event-Import So 02:00, Rassen-Seed beim Start.") + logger.info("Scheduler gestartet — Health-Reminder 08:00, Giftköder-Archiv 03:00, Wetter-Alert 07:30, Meilenstein-Check 00:05, Event-Import So 02:00, Rassen-Seed beim Start, Wiki-KI-Anreicherung 02:30.") def stop(): @@ -150,6 +186,7 @@ async def _job_health_reminders(): logger.info(f"Reminder Push: user={r['user_id']} entry={r['id']} delta={delta}d") logger.info(f"Health-Reminder Job fertig — {len(rows)} Einträge, {sent_total} Push gesendet.") + _log_job("health_reminders", "ok", f"{len(rows)} Einträge, {sent_total} Push") # ------------------------------------------------------------------ @@ -176,6 +213,7 @@ async def _job_poison_archive(): AND expires_at < ? """, (now,)) count = result.rowcount + _log_job("poison_archive", "ok", f"{count} Meldungen archiviert") if count: logger.info(f"Giftköder-Archiv: {count} abgelaufene Meldungen archiviert.") @@ -202,6 +240,7 @@ async def _job_weather_alert(): thunderstorm = summary["thunderstorm"] if max_temp >= 28: + _log_job("weather_alert", "ok", f"Hitze-Push: {max_temp:.0f}°C") sent = send_push_to_all({ "type": "weather_heat", "title": "☀️ Heißer Asphalt heute", @@ -222,6 +261,7 @@ async def _job_weather_alert(): return logger.info("Wetter-Alert: Keine Warnung nötig heute.") + _log_job("weather_alert", "ok", "Keine Warnung") # ------------------------------------------------------------------ @@ -301,6 +341,7 @@ async def _job_milestone_check(): created_total += 1 logger.info(f"Meilenstein-Check fertig — {created_total} Einträge erstellt.") + _log_job("milestone_check", "ok", f"{created_total} Meilensteine erstellt") # ------------------------------------------------------------------ @@ -348,6 +389,7 @@ async def _job_import_events(): logger.warning(f"Event-Import: Fehler beim Speichern von '{ev.get('titel')}': {e}") logger.info(f"Event-Import: {imported} neue Events importiert (von {len(events)} geparsten).") + _log_job("import_events", "ok", f"{imported} neue von {len(events)} Events") # ------------------------------------------------------------------ @@ -390,8 +432,10 @@ async def _job_seed_wikidata_breeds(): from scraper.wikipedia_photos import fetch_wikipedia_photos wp_count = await fetch_wikipedia_photos() logger.info(f"Wikipedia photo fetch done: {wp_count} Fotos") + _log_job("seed_wikidata_startup", "ok", f"{count} Rassen, {mirrored}+{wp_count} Fotos") except Exception as e: logger.error(f"Wikidata-Seed: Fehler: {e}") + _log_job("seed_wikidata_startup", "error", str(e)) # ------------------------------------------------------------------ @@ -575,6 +619,359 @@ async def _job_prewarm_cities(): await _send_progress("abgeschlossen ✓", cities_done, total_cities) +# ------------------------------------------------------------------ +# Hilfsfunktion: Job-Protokoll aktualisieren +# ------------------------------------------------------------------ +def _log_job(job_id: str, status: str, result: str): + _job_log[job_id] = { + "last_run": datetime.now(tz=_TZ), + "status": status, + "result": result, + } + + +# ------------------------------------------------------------------ +# JOB: KI-Anreicherung der Rassen-Daten (nächtlich) +# ------------------------------------------------------------------ +async def _job_wiki_enrich(): + """Reichert 20 noch nicht angereicherte Rassen mit KI-Daten an.""" + try: + from scraper.breed_enricher import enrich_breeds + enriched = await enrich_breeds(limit=20) + msg = f"{enriched} Rassen angereichert" + logger.info(f"Wiki-KI-Anreicherung (nächtlich): {msg}.") + _log_job("wiki_enrich_nightly", "ok", msg) + except Exception as e: + logger.error(f"Wiki-KI-Anreicherung: Fehler: {e}") + _log_job("wiki_enrich_nightly", "error", str(e)) + + +async def _job_wiki_enrich_startup(): + """Beim Start: erste 50 Rassen sofort anreichern.""" + try: + from scraper.breed_enricher import enrich_breeds + enriched = await enrich_breeds(limit=50) + msg = f"{enriched} Rassen angereichert (Startup)" + logger.info(f"Wiki-KI-Anreicherung (Startup): {msg}.") + _log_job("wiki_enrich_startup", "ok", msg) + except Exception as e: + logger.error(f"Wiki-KI-Anreicherung (Startup): Fehler: {e}") + _log_job("wiki_enrich_startup", "error", str(e)) + + +# ------------------------------------------------------------------ +# Hilfsfunktion: Lob-Text für einen Hund generieren +# ------------------------------------------------------------------ +async def _generate_praise_for_dog(dog: dict, user_id: int) -> str: + """Generiert einen Lob-Text für einen Hund basierend auf der letzten Woche.""" + from ki import complete, KIUnavailableError + import json as _json + from datetime import date, timedelta + + since = (date.today() - timedelta(days=7)).isoformat() + name = dog["name"] + rasse = dog.get("rasse") or "Hund" + + stats = {} + try: + with db() as conn: + stats["diary"] = conn.execute("SELECT COUNT(*) FROM diary WHERE dog_id=? AND datum>=?", (dog["id"], since)).fetchone()[0] + stats["training"] = conn.execute("SELECT COUNT(*) FROM training_sessions WHERE dog_id=? AND datum>=?", (dog["id"], since)).fetchone()[0] + stats["top_training"] = conn.execute("SELECT COUNT(*) FROM training_sessions WHERE dog_id=? AND datum>=? AND ist_top=1", (dog["id"], since)).fetchone()[0] + stats["health"] = conn.execute("SELECT COUNT(*) FROM health WHERE dog_id=? AND datum>=?", (dog["id"], since)).fetchone()[0] + stats["days_active"] = conn.execute( + "SELECT COUNT(DISTINCT datum) FROM diary WHERE dog_id=? AND datum>=?", (dog["id"], since) + ).fetchone()[0] + # Wie viele Wochen ist der User dabei? + first = conn.execute("SELECT MIN(datum) FROM diary WHERE dog_id=?", (dog["id"],)).fetchone()[0] + if first: + weeks_total = max(1, (date.today() - date.fromisoformat(first)).days // 7) + else: + weeks_total = 1 + stats["weeks_total"] = weeks_total + except Exception: + pass + + # Prompt aufbauen + aktivitaet_parts = [] + if stats.get("diary", 0): + aktivitaet_parts.append(f"{stats['diary']} Tagebuch-Eintr\u00e4ge") + if stats.get("training", 0): + t = f"{stats['training']} Trainingseinheiten" + if stats.get("top_training", 0): + t += f" (davon {stats['top_training']} Top-Training)" + aktivitaet_parts.append(t) + if stats.get("health", 0): + aktivitaet_parts.append(f"{stats['health']} Gesundheitseintr\u00e4ge") + + if not aktivitaet_parts: + aktivitaet_text = "Diese Woche war ruhig \u2014 keine erfassten Aktivit\u00e4ten." + else: + aktivitaet_text = ", ".join(aktivitaet_parts) + + prompt = f"""Du bist ein warmer, wohlwollender Begleiter f\u00fcr Hundebesitzer. Schreibe eine kurze pers\u00f6nliche Lob-Nachricht (2-3 S\u00e4tze) f\u00fcr die vergangene Woche. + +Hund: {name} ({rasse}) +Letzte 7 Tage: {aktivitaet_text} +Dabei seit: {stats.get('weeks_total', 1)} Wochen + +Regeln (unbedingt einhalten): +- Nur loben, NIEMALS Ratschl\u00e4ge geben oder auf Fehlendes hinweisen +- Sprich \u00fcber den Hund: "{name} hatte eine tolle Woche" \u2014 nicht \u00fcber den Besitzer +- Auch bei 0 Aktivit\u00e4ten: positive Formulierung (\u201eAuch ruhige Wochen geh\u00f6ren dazu\u201c) +- Maximal 3 kurze S\u00e4tze +- Warm, pers\u00f6nlich, keine Floskeln +- Kein "Du solltest...", kein "Vergiss nicht...", keine Empfehlungen""" + + try: + text = await complete( + prompt, + system="Du schreibst kurze, warme Lob-Nachrichten f\u00fcr Hundebesitzer. Nur Lob, keine Ratschl\u00e4ge.", + max_tokens=150, + ) + return text.strip() + except Exception: + # Fallback wenn KI nicht verfügbar + if aktivitaet_parts: + return f"{name} hatte eine aktive Woche \u2014 {aktivitaet_text}. Das ist toll! \U0001f43e" + else: + return f"Auch ruhige Wochen geh\u00f6ren dazu. {name} wei\u00df, dass du f\u00fcr ihn da bist. \U0001f43e" + + +# ------------------------------------------------------------------ +# JOB: Wöchentlicher Fortschritts-Lober +# ------------------------------------------------------------------ +async def _job_weekly_praise(): + """Jeden Montag: Lob-Text f\u00fcr alle aktiven Hunde generieren + Push senden.""" + from datetime import date + import json as _json + + today = date.today() + d = today.isocalendar() + week_key = f"{d[0]}-W{d[1]:02d}" + + logger.info(f"Weekly Praise Job startet f\u00fcr Woche {week_key}") + + # Alle Hunde laden, für die noch kein Lob diese Woche existiert + with db() as conn: + dogs = conn.execute(""" + SELECT d.id, d.name, d.rasse, d.user_id, d.foto_url + FROM dogs d + WHERE NOT EXISTS ( + SELECT 1 FROM weekly_praise wp + WHERE wp.dog_id=d.id AND wp.week_key=? + ) + ORDER BY d.id + """, (week_key,)).fetchall() + + dogs = [dict(d) for d in dogs] + logger.info(f"Weekly Praise: {len(dogs)} Hunde ohne Lob diese Woche.") + + import asyncio + generated = 0 + for dog in dogs: + try: + praise = await _generate_praise_for_dog(dog, dog["user_id"]) + with db() as conn: + conn.execute(""" + INSERT OR IGNORE INTO weekly_praise (user_id, dog_id, week_key, praise_text) + VALUES (?,?,?,?) + """, (dog["user_id"], dog["id"], week_key, praise)) + + # Push-Notification — erste 100 Zeichen als Preview + preview = praise[:100] + "\u2026" if len(praise) > 100 else praise + send_push_to_user(dog["user_id"], { + "type": "weekly_praise", + "title": f"\U0001f43e R\u00fcckblick f\u00fcr {dog['name']}", + "body": preview, + "data": {"page": "diary"}, + "tag": f"weekly-praise-{dog['id']}-{week_key}", + }) + + generated += 1 + await asyncio.sleep(2) # Rate limiting für KI + except Exception as e: + logger.error(f"Weekly Praise: Fehler f\u00fcr Hund {dog['id']}: {e}") + + logger.info(f"Weekly Praise Job fertig \u2014 {generated}/{len(dogs)} Lob-Texte generiert.") + _log_job("weekly_praise", "ok", f"{generated} Lob-Texte f\u00fcr KW {d[1]}") + + +# ------------------------------------------------------------------ +# JOB: Status-Report per Mail (4× täglich) +# ------------------------------------------------------------------ +async def _job_status_report(): + """Sendet einen HTML-Status-Report an ADMIN_EMAIL.""" + import os + from mailer import send_email + + admin = os.getenv("ADMIN_EMAIL", "") + if not admin: + logger.info("Status-Report: ADMIN_EMAIL nicht gesetzt, übersprungen.") + return + + now_str = datetime.now(tz=_TZ).strftime("%d.%m.%Y %H:%M") + + # --- DB-Metriken abrufen --- + metrics = {} + try: + with db() as conn: + # Rassen-Anreicherung + metrics["rassen_total"] = conn.execute("SELECT COUNT(*) FROM wiki_rassen").fetchone()[0] + metrics["rassen_enriched"] = conn.execute("SELECT COUNT(*) FROM wiki_rassen WHERE ki_enriched=1").fetchone()[0] + metrics["rassen_mit_foto"] = conn.execute("SELECT COUNT(*) FROM wiki_rassen WHERE foto_url IS NOT NULL AND foto_url NOT LIKE 'http%'").fetchone()[0] + metrics["rassen_mit_desc"] = conn.execute("SELECT COUNT(*) FROM wiki_rassen WHERE beschreibung IS NOT NULL AND beschreibung != ''").fetchone()[0] + + # Züchter + try: + metrics["zuchter_pending"] = conn.execute("SELECT COUNT(*) FROM wiki_zuchter WHERE verified=0").fetchone()[0] + metrics["zuchter_verified"] = conn.execute("SELECT COUNT(*) FROM wiki_zuchter WHERE verified=1").fetchone()[0] + except Exception: + metrics["zuchter_pending"] = metrics["zuchter_verified"] = 0 + + # Community + metrics["users"] = conn.execute("SELECT COUNT(*) FROM users").fetchone()[0] + metrics["dogs"] = conn.execute("SELECT COUNT(*) FROM dogs").fetchone()[0] + metrics["diary_entries"] = conn.execute("SELECT COUNT(*) FROM diary").fetchone()[0] + metrics["poison_active"] = conn.execute("SELECT COUNT(*) FROM poison WHERE geloest=0").fetchone()[0] + metrics["lost_active"] = conn.execute("SELECT COUNT(*) FROM lost WHERE gefunden=0").fetchone()[0] + + # Wiki-Interesse + try: + metrics["interesse_hat"] = conn.execute("SELECT COUNT(*) FROM wiki_breed_interest WHERE typ='hat'").fetchone()[0] + metrics["interesse_will"] = conn.execute("SELECT COUNT(*) FROM wiki_breed_interest WHERE typ='will'").fetchone()[0] + except Exception: + metrics["interesse_hat"] = metrics["interesse_will"] = 0 + except Exception as e: + logger.error(f"Status-Report: DB-Fehler: {e}") + return + + # --- Wiki-Fortschritt berechnen --- + total = metrics["rassen_total"] or 1 + enriched = metrics["rassen_enriched"] + pct = round(enriched / total * 100) + remaining = total - enriched + nights_left = (remaining + 19) // 20 # bei 20/Nacht + + bar_filled = round(pct / 5) + progress_bar = "█" * bar_filled + "░" * (20 - bar_filled) + + # --- Job-Log-Tabelle --- + job_labels = { + "health_reminders": "Gesundheits-Erinnerungen", + "poison_archive": "Giftköder-Archiv", + "weather_alert": "Wetter-Alert", + "milestone_check": "Meilenstein-Check", + "import_events": "Event-Import (VDH)", + "wiki_enrich_nightly": "Wiki KI-Anreicherung (nächtlich)", + "wiki_enrich_startup": "Wiki KI-Anreicherung (Startup)", + "seed_breeds_startup": "Rassen-Seed (TheDogAPI)", + "seed_wikidata_startup":"Rassen-Seed (Wikidata)", + "weekly_praise": "Wöchentlicher Lober (Mo 09:00)", + } + job_rows_html = "" + job_rows_txt = "" + for jid, label in job_labels.items(): + log = _job_log.get(jid) + if log: + ts = log["last_run"].strftime("%d.%m. %H:%M") + status = "✅" if log["status"] == "ok" else "❌" + result = log["result"] + color = "#16a34a" if log["status"] == "ok" else "#dc2626" + job_rows_html += f'{label}{ts}{status} {result}' + job_rows_txt += f" {status} {label}: {ts} — {result}\n" + else: + job_rows_html += f'{label}— noch nicht gelaufen' + job_rows_txt += f" — {label}: noch nicht gelaufen\n" + + html = f"""\ + + + + +
+ + +
+
🐾 Ban Yaro — Status-Report
+
{now_str} Uhr
+
+ + +
+
Wiki KI-Anreicherung
+
+ {progress_bar} {pct}%
+ ✅ Angereichert: {enriched} / {total}
+ ⏳ Verbleibend: {remaining} Rassen (~{nights_left} Nächte)
+ 📷 Mit lokalem Foto: {metrics['rassen_mit_foto']}
+ 📝 Mit Beschreibung: {metrics['rassen_mit_desc']} +
+
+ + +
+
Scheduler-Jobs
+ + {job_rows_html} +
+
+ + +
+
Community
+
+ {"".join(f'
{v}
{k}
' for k,v in [ + ("Nutzer",metrics["users"]), + ("Hunde",metrics["dogs"]), + ("Tagebuch-Einträge",metrics["diary_entries"]), + ("Aktive Giftköder",metrics["poison_active"]), + ("Vermisste Hunde",metrics["lost_active"]), + ("'So einen hab ich'",metrics["interesse_hat"]), + ("'Interessiert mich'",metrics["interesse_will"]), + ("Züchter (pending)",metrics["zuchter_pending"]), + ])} +
+
+ + +
+ Ban Yaro · banyaro.app · Nächster Report in ~6h +
+ +
+ +""" + + plain = f"""Ban Yaro Status-Report — {now_str} + +=== Wiki KI-Anreicherung === +{progress_bar} {pct}% +Angereichert: {enriched}/{total} +Verbleibend: {remaining} Rassen (~{nights_left} Nächte à 20/Nacht) +Mit Foto: {metrics['rassen_mit_foto']} +Mit Beschreibung: {metrics['rassen_mit_desc']} + +=== Scheduler-Jobs === +{job_rows_txt} +=== Community === +Nutzer: {metrics['users']} +Hunde: {metrics['dogs']} +Tagebuch-Einträge: {metrics['diary_entries']} +Aktive Giftköder: {metrics['poison_active']} +Vermisste Hunde: {metrics['lost_active']} +'So einen hab ich': {metrics['interesse_hat']} +'Interessiert mich': {metrics['interesse_will']} +Züchter (pending): {metrics['zuchter_pending']} +""" + + try: + await send_email(admin, f"Ban Yaro Status {now_str}", html, plain) + logger.info(f"Status-Report gesendet an {admin}.") + except Exception as e: + logger.error(f"Status-Report: Mail-Fehler: {e}") + + def _compute_milestone(today: date, bday: date, dog_name: str): """ Gibt (titel, text) zurück wenn heute ein Meilenstein-Tag ist, diff --git a/backend/scraper/breed_enricher.py b/backend/scraper/breed_enricher.py new file mode 100644 index 0000000..593341c --- /dev/null +++ b/backend/scraper/breed_enricher.py @@ -0,0 +1,168 @@ +""" +BAN YARO — Rassen-Anreicherung via KI + +Nutzt ki.complete() um fehlende Rassen-Daten (Beschreibung, Vorkommen, etc.) +per Claude API anzureichern und in wiki_rassen zurückzuschreiben. +""" + +import asyncio +import json +import logging +import re +import sys +import os + +# Pfad zum Backend-Verzeichnis sicherstellen (beim direkten Aufruf) +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from database import db +from ki import complete, KIUnavailableError + +logger = logging.getLogger(__name__) + +_SYSTEM = "Du bist ein Hunde-Experte." + +_PROMPT_TEMPLATE = '''\ +Gib mir strukturierte Informationen über die Hunderasse "{name}" (Herkunft: {herkunft}) auf Deutsch. +Antworte NUR mit einem JSON-Objekt, keine Erklärung darum. + +Format: +{{ + "beschreibung": "3-5 Sätze über Charakter und Wesen der Rasse", + "vorkommen_de": "1-2 Sätze wie verbreitet die Rasse in Deutschland/DACH ist", + "groesse": "klein|mittel|gross|sehr_gross", + "gewicht_min_kg": Zahl_oder_null, + "gewicht_max_kg": Zahl_oder_null, + "lebensdauer": "X-Y Jahre oder null", + "aktivitaet": "niedrig|mittel|hoch|sehr_hoch", + "erfahrung": "anfaenger|fortgeschritten|experte", + "kinder_geeignet": true_oder_false, + "wohnung_geeignet": true_oder_false, + "temperament": "kommagetrennte Eigenschaftsliste auf Deutsch, z.B. freundlich, verspielt, loyal" +}} +''' + +# Felder die direkt in wiki_rassen geschrieben werden (wenn nicht null) +_DIRECT_FIELDS = { + "beschreibung", "vorkommen_de", + "groesse", "gewicht_min_kg", "gewicht_max_kg", + "lebensdauer", "aktivitaet", "erfahrung", + "kinder_geeignet", "wohnung_geeignet", "temperament", +} + + +def _parse_json(raw: str) -> dict: + """JSON aus KI-Antwort extrahieren — toleriert ```json ... ``` Wrapper.""" + # Versuche direkt + try: + return json.loads(raw) + except json.JSONDecodeError: + pass + + # Suche nach ```json ... ``` Block + match = re.search(r"```(?:json)?\s*([\s\S]+?)\s*```", raw) + if match: + try: + return json.loads(match.group(1)) + except json.JSONDecodeError: + pass + + # Suche nach erstem { ... } Block + match = re.search(r"\{[\s\S]+\}", raw) + if match: + try: + return json.loads(match.group(0)) + except json.JSONDecodeError: + pass + + raise ValueError(f"Kein gültiges JSON in Antwort gefunden: {raw[:200]}") + + +async def enrich_breeds(limit: int = 10) -> int: + """ + Reichert bis zu `limit` Rassen an, bei denen ki_enriched = 0. + + Returns: + Anzahl erfolgreich angereicherter Rassen. + """ + with db() as conn: + rassen = conn.execute( + """SELECT id, name, slug, herkunft FROM wiki_rassen + WHERE ki_enriched = 0 + ORDER BY name ASC + LIMIT ?""", + (limit,), + ).fetchall() + + if not rassen: + logger.info("Keine Rassen zur Anreicherung gefunden (alle ki_enriched=1).") + return 0 + + enriched_count = 0 + + for rasse in rassen: + name = rasse["name"] + herkunft = rasse["herkunft"] or "unbekannt" + rasse_id = rasse["id"] + + prompt = _PROMPT_TEMPLATE.format(name=name, herkunft=herkunft) + + try: + raw = await complete( + prompt, + system=_SYSTEM, + max_tokens=600, + requires_premium=False, + ) + except KIUnavailableError as e: + logger.warning("KI nicht verfügbar, Anreicherung abgebrochen: %s", e) + break + except Exception as e: + logger.error("Fehler bei KI-Anfrage für %s: %s", name, e) + await asyncio.sleep(2) + continue + + try: + data = _parse_json(raw) + except ValueError as e: + logger.warning("JSON-Parsing fehlgeschlagen für %s: %s", name, e) + await asyncio.sleep(2) + continue + + # Nur bekannte Felder mit nicht-None-Wert übernehmen + updates = { + k: v for k, v in data.items() + if k in _DIRECT_FIELDS and v is not None + } + updates["ki_enriched"] = 1 + + cols = ", ".join(f"{k}=?" for k in updates) + values = list(updates.values()) + [rasse_id] + + try: + with db() as conn: + conn.execute( + f"UPDATE wiki_rassen SET {cols} WHERE id=?", + values, + ) + logger.info("Rasse angereichert: %s (%d Felder)", name, len(updates) - 1) + enriched_count += 1 + except Exception as e: + logger.error("DB-Update fehlgeschlagen für %s: %s", name, e) + + await asyncio.sleep(2) + + return enriched_count + + +if __name__ == "__main__": + import argparse + + logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s") + + parser = argparse.ArgumentParser(description="Rassen-Anreicherung via KI") + parser.add_argument("--limit", type=int, default=10, help="Anzahl Rassen (default: 10)") + args = parser.parse_args() + + count = asyncio.run(enrich_breeds(args.limit)) + print(f"Angereichert: {count} Rassen") diff --git a/backend/static/css/components.css b/backend/static/css/components.css index 6a20924..958a606 100644 --- a/backend/static/css/components.css +++ b/backend/static/css/components.css @@ -4824,6 +4824,79 @@ html.modal-open { color: var(--c-text-secondary); } +/* Detail Hero (neues Layout) */ +.wiki-detail-hero-photo-wrap { + width: 100%; + max-height: 240px; + overflow: hidden; + border-radius: var(--radius-lg); + margin-bottom: var(--space-3); + background: var(--c-surface-2); +} +.wiki-detail-hero-photo-wrap .wiki-detail-photo { + width: 100%; + height: 240px; + object-fit: cover; + object-position: center top; + margin-bottom: 0; + border-radius: 0; +} + +/* Steckbrief-Grid */ +.wiki-steckbrief-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1px; + background: var(--c-border-light); + border: 1px solid var(--c-border-light); + border-radius: var(--radius-md); + overflow: hidden; + margin-bottom: var(--space-4); +} +.wiki-steckbrief-item { + display: flex; + flex-direction: column; + gap: 2px; + padding: var(--space-2) var(--space-3); + background: var(--c-surface); +} +.wiki-steckbrief-label { + font-size: var(--text-xs); + color: var(--c-text-muted); + font-weight: var(--weight-medium); +} +.wiki-steckbrief-value { + font-size: var(--text-sm); + color: var(--c-text); + font-weight: var(--weight-semibold); +} + +/* Interesse-Section */ +.wiki-interesse-section { + background: var(--c-surface-2); + border-radius: var(--radius-md); + padding: var(--space-3); +} +.wiki-interesse-btn { + border: 1px solid var(--c-border); + border-radius: var(--radius-md); + background: var(--c-surface); + color: var(--c-text); + font-size: var(--text-sm); + padding: var(--space-2) var(--space-3); + cursor: pointer; + transition: background 0.15s, color 0.15s, border-color 0.15s; +} +.wiki-interesse-btn:hover { + border-color: var(--c-primary); + color: var(--c-primary); +} + +/* Züchter-Karten */ +.wiki-zuchter-card { + transition: background 0.15s; +} + .hdm-vote-rasse { font-size: var(--text-xs); color: var(--c-text-secondary); diff --git a/backend/static/css/layout.css b/backend/static/css/layout.css index ea0770d..2ba5c74 100644 --- a/backend/static/css/layout.css +++ b/backend/static/css/layout.css @@ -393,6 +393,48 @@ margin-top: var(--space-2); } +.sidebar-section-toggle { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + background: none; + border: none; + cursor: pointer; + font-size: var(--text-xs); + font-weight: var(--weight-semibold); + color: var(--c-text-muted); + text-transform: uppercase; + letter-spacing: 0.08em; + padding: var(--space-3) var(--space-3) var(--space-1); + margin-top: var(--space-2); + border-radius: var(--radius-sm); + transition: color var(--transition-fast); + -webkit-tap-highlight-color: transparent; +} +.sidebar-section-toggle:hover { color: var(--c-text); } +.sidebar-section-toggle .wissen-caret { + transition: transform 0.2s ease; + flex-shrink: 0; +} +.sidebar-section-toggle[aria-expanded="true"] .wissen-caret { + transform: rotate(90deg); +} + +.sidebar-section-body { + display: flex; + flex-direction: column; + gap: var(--space-1); + overflow: hidden; + max-height: 0; + opacity: 0; + transition: max-height 0.25s ease, opacity 0.2s ease; +} +.sidebar-section-body.open { + max-height: 300px; + opacity: 1; +} + .sidebar-item { display: flex; align-items: center; diff --git a/backend/static/index.html b/backend/static/index.html index fdab76d..0fad607 100644 --- a/backend/static/index.html +++ b/backend/static/index.html @@ -4,7 +4,64 @@ - + + + + + + + + + + + + + + + + + + + + + @@ -61,6 +118,13 @@ + + + Entdecken - Training - - - - Wissen - - - - + + +
+ +
+ ${[['😓','0'],['😐','25'],['🙂','50'],['😊','75'],['🎉','100']].map(([emoji, val]) => ` + + `).join('')} +
+
+ + +
+ +
+ ${[['🎯','aufmerksam'],['😴','müde'],['🌪️','abgelenkt'],['⚡','super']].map(([emoji, val]) => ` + + `).join('')} +
+
+ + +
+ +
+ ${[1,2,3,4,5].map(n => ` + + `).join('')} +
+
+ + +
+ + +
+ + + + + + + +
+ + +
+ + `; + + document.body.appendChild(overlay); + + // State + let wiederholungen = 5; + let erfolgsquote = null; // must be selected + let stimmung = null; + let zufriedenheit = null; + + // Close helpers + function _closeModal() { overlay.remove(); } + overlay.addEventListener('click', e => { if (e.target === overlay) _closeModal(); }); + overlay.querySelector('#ueb-log-cancel').addEventListener('click', _closeModal); + + // Stepper + const repVal = overlay.querySelector('#ueb-rep-val'); + overlay.querySelector('#ueb-rep-minus').addEventListener('click', () => { + if (wiederholungen > 1) { wiederholungen--; repVal.textContent = wiederholungen; } + }); + overlay.querySelector('#ueb-rep-plus').addEventListener('click', () => { + wiederholungen++; + repVal.textContent = wiederholungen; + }); + + // Erfolg-Buttons + overlay.querySelectorAll('.ueb-erfolg-btn').forEach(btn => { + btn.addEventListener('click', () => { + erfolgsquote = parseInt(btn.dataset.val, 10); + overlay.querySelectorAll('.ueb-erfolg-btn').forEach(b => { + b.style.background = 'var(--c-surface-2)'; + b.style.borderColor = 'var(--c-border)'; + b.style.transform = ''; + }); + btn.style.background = 'var(--c-primary-subtle)'; + btn.style.borderColor = 'var(--c-primary)'; + btn.style.transform = 'scale(1.15)'; + _checkMilestoneVisibility(); + }); + }); + + // Stimmung-Buttons + overlay.querySelectorAll('.ueb-stimmung-btn').forEach(btn => { + btn.addEventListener('click', () => { + stimmung = btn.dataset.val; + overlay.querySelectorAll('.ueb-stimmung-btn').forEach(b => { + b.style.background = 'var(--c-surface-2)'; + b.style.borderColor = 'var(--c-border)'; + }); + btn.style.background = 'var(--c-primary-subtle)'; + btn.style.borderColor = 'var(--c-primary)'; + }); + }); + + // Stern-Buttons + overlay.querySelectorAll('.ueb-stern-btn').forEach(btn => { + btn.addEventListener('click', () => { + zufriedenheit = parseInt(btn.dataset.val, 10); + overlay.querySelectorAll('.ueb-stern-btn').forEach(b => { + b.style.opacity = parseInt(b.dataset.val, 10) <= zufriedenheit ? '1' : '0.35'; + }); + _checkMilestoneVisibility(); + }); + }); + + function _checkMilestoneVisibility() { + const wrap = overlay.querySelector('#ueb-log-milestone-wrap'); + if (!wrap) return; + const show = erfolgsquote != null && erfolgsquote >= 75 && zufriedenheit != null && zufriedenheit >= 4; + wrap.hidden = !show; + } + + // Save + overlay.querySelector('#ueb-log-save').addEventListener('click', async () => { + const dogId = _dogId(); + if (!dogId) { UI.toast('Kein Hund ausgewählt.', 'warning'); return; } + if (erfolgsquote === null) { UI.toast('Bitte wähle aus, wie es gelaufen ist.', 'warning'); return; } + + const saveBtn = overlay.querySelector('#ueb-log-save'); + saveBtn.disabled = true; + saveBtn.textContent = 'Speichern…'; + + const exerciseId = `${tab}_${exerciseName.replace(/[\s/]+/g, '_')}`; + const today = new Date().toISOString().slice(0, 10); + const tagebuch = !overlay.querySelector('#ueb-log-milestone-wrap').hidden && + overlay.querySelector('#ueb-log-milestone').checked; + + const body = { + dog_id: dogId, + exercise_id: exerciseId, + exercise_name: exerciseName, + datum: today, + wiederholungen: wiederholungen, + erfolgsquote: erfolgsquote, + hund_stimmung: stimmung || null, + zufriedenheit: zufriedenheit || null, + notiz: overlay.querySelector('#ueb-log-notiz').value.trim() || null, + tagebuch_eintrag: tagebuch, + }; + + try { + const resp = await _apiPost('/api/training/sessions', body); + _closeModal(); + if (!resp) { UI.toast.error('Speichern fehlgeschlagen.'); return; } + + if (resp.ist_top) { + UI.toast.success('🎉 Top-Training! Das war eine eurer besten Einheiten.'); + } else { + UI.toast.success('Einheit gespeichert!'); + } + + if (resp.badges && resp.badges.length) { + resp.badges.forEach((badge, idx) => { + setTimeout(() => { + UI.toast.success(`🏅 Neues Abzeichen: "${badge.name || badge}"!`); + }, 1000 * (idx + 1)); + }); + } + + if (resp.diary_entry_id) { + setTimeout(() => { + UI.toast.success('📖 Als Meilenstein im Tagebuch gespeichert.'); + }, resp.badges?.length ? (resp.badges.length + 1) * 1000 : 1000); + } + + // Stats-Banner aktualisieren + _statsData = null; + _loadStatsAndBadges(); + } catch (err) { + saveBtn.disabled = false; + saveBtn.textContent = 'Einheit speichern'; + UI.toast.error('Speichern fehlgeschlagen.'); + } + }); + } + + // ---------------------------------------------------------- + // KI-TRAINER (neu: hundebasiertes Feedback) // ---------------------------------------------------------- function _renderKiTrainer() { - return ` -
+ const dogId = _dogId(); + if (!dogId) { + return ` +
+ +

Wähle einen Hund aus um den KI-Trainer zu nutzen.

+
+ `; + } - -
-
-
+ + +
+
+ -
-

- KI-Hundetrainer -

-

- Beschreibe ein konkretes Problem oder Verhalten deines Hundes — - du bekommst individuelle Trainingstipps. -

+
+
+
+ KI-Trainer +
+
+ Personalisiertes Feedback basierend auf deinen Trainingseinheiten
- -
- -
- - -
+ +
+ +

Lade KI-Feedback…

+
- - -
- 0 / 1000 -
- - -

- KI-Tipps ersetzen keinen professionellen Hundetrainer. Bei Aggression oder starker Angst - wende dich an einen zertifizierten Trainer vor Ort. + KI-Tipps ersetzen keinen professionellen Hundetrainer.

+ `; } - function _bindKiTrainer() { - const textarea = _container.querySelector('#ki-problem'); - const charCount = _container.querySelector('#ki-char-count'); - const submitBtn = _container.querySelector('#ki-submit'); - const result = _container.querySelector('#ki-result'); - if (!textarea || !submitBtn) return; + async function _loadKiTrainerFeedback(forceRefresh) { + const loading = _container.querySelector('#ki-loading'); + const noSessions = _container.querySelector('#ki-no-sessions'); + const feedbackCard = _container.querySelector('#ki-feedback-card'); + const feedbackText = _container.querySelector('#ki-feedback-text'); + const feedbackMeta = _container.querySelector('#ki-feedback-meta'); + const regenBtn = _container.querySelector('#ki-regenerate'); + if (!loading) return; // not on ki-trainer tab - textarea.addEventListener('input', () => { - charCount.textContent = `${textarea.value.length} / 1000`; - }); + const dogId = _dogId(); + if (!dogId) return; - submitBtn.addEventListener('click', async () => { - const problem = textarea.value.trim(); - if (problem.length < 10) { - UI.toast('Bitte beschreibe das Problem etwas genauer.', 'warning'); + // Show loading + loading.hidden = false; + noSessions.hidden = true; + feedbackCard.hidden = true; + + // Check if there are any sessions + const stats = _statsData || await _apiGet(`/api/training/stats?dog_id=${dogId}`); + if (!stats || !stats.total_sessions) { + loading.hidden = true; + noSessions.hidden = false; + return; + } + + try { + const resp = await _apiPost('/api/training/ki-feedback', { dog_id: dogId }); + loading.hidden = true; + if (!resp || !resp.feedback) { + noSessions.hidden = false; return; } - const rasse = _container.querySelector('#ki-rasse')?.value.trim() || null; - const alter = _container.querySelector('#ki-alter')?.value.trim() || null; - - submitBtn.disabled = true; - submitBtn.innerHTML = ` Denke nach…`; - - result.hidden = true; - result.innerHTML = ''; - - try { - const resp = await API.post('/ki/training', { problem, rasse, alter }); - const text = resp.antwort || ''; - - // Render with simple markdown-like formatting (text already escaped by API) - const safeText = text - .replace(/&/g, '&').replace(//g, '>'); - const html = safeText - .replace(/\*\*(.*?)\*\*/g, '$1') - .replace(/^(\d+)\. (.+)$/gm, '
  • $1. $2
  • ') - .replace(/^- (.+)$/gm, '
  • $1
  • ') - .replace(/\n\n/g, '

    ') - .replace(/\n/g, '
    '); - - result.innerHTML = ` -

    -
    - - - Empfehlung des KI-Trainers - -
    -
    -

    ${html}

    -
    -
    - `; - result.hidden = false; - } catch (err) { - UI.toast(err.message || 'KI momentan nicht verfügbar.', 'error'); - } finally { - submitBtn.disabled = false; - submitBtn.innerHTML = ` Tipps holen`; + feedbackText.textContent = resp.feedback; + const cachedInfo = resp.cached ? 'Gespeichertes Feedback' : 'Gerade generiert'; + const sessionInfo = stats.total_sessions + ? ` · Basiert auf ${stats.total_sessions} Einheit${stats.total_sessions !== 1 ? 'en' : ''}` + : ''; + feedbackMeta.textContent = `Aktualisiert alle 6 Stunden · ${cachedInfo}${sessionInfo}`; + if (regenBtn) { + regenBtn.textContent = resp.cached ? 'Neu generieren' : 'Aktualisieren'; + regenBtn.style.opacity = resp.cached ? '0.6' : '1'; } - }); + feedbackCard.hidden = false; + } catch (err) { + loading.hidden = true; + noSessions.hidden = false; + } + + // Bind regenerate button + if (regenBtn) { + regenBtn.addEventListener('click', async () => { + regenBtn.disabled = true; + regenBtn.textContent = 'Generiere…'; + loading.hidden = false; + feedbackCard.hidden = true; + await _loadKiTrainerFeedback(true); + regenBtn.disabled = false; + }, { once: true }); + } } function _bindAccordions() { diff --git a/backend/static/js/pages/welcome.js b/backend/static/js/pages/welcome.js index 855eef8..36360f9 100644 --- a/backend/static/js/pages/welcome.js +++ b/backend/static/js/pages/welcome.js @@ -70,27 +70,41 @@ window.Page_welcome = (() => { ${_featureCard('Mein Hund', [ - ['book-open', 'Tagebuch', 'Momente, Fotos und Meilensteine festhalten', 'diary'], - ['syringe', 'Gesundheit', 'Impfungen, Tierarztbesuche & Medikamente', 'health'], - ['target', 'Training', 'Übungen, Pläne und KI-Trainer', 'uebungen'], - ['books', 'Wiki & Wissen', 'Rassen, Ernährung, Erste Hilfe', 'wiki'], - ])} - - - ${_featureCard('Community', [ - ['users', 'Freunde', 'Verbinde dich mit anderen Hundebesitzern', 'friends'], - ['chat-circle-dots', 'Nachrichten', 'Private Chats mit deinen Freunden', 'chat'], - ['push-pin', 'Forum', 'Diskussionen, Tipps und Austausch', 'forum'], - ['paw-print', 'Gassi-Treffen', 'Hunde-Dates mit anderen Besitzern', 'walks'], - ['house-line', 'Sitting', 'Dogsitter finden oder selbst anbieten', 'sitting'], - ['magnifying-glass', 'Verlorene Hunde','Hilf gesuchte Hunde zu finden', 'lost'], + ['book-open', 'Tagebuch', 'Momente, Fotos und Meilensteine festhalten', 'diary'], + ['first-aid', 'Gesundheit', 'Impfungen, Tierarztbesuche & Medikamente', 'health'], + ['target', 'Übungen', 'Trainingsübungen mit KI-Unterstützung', 'uebungen'], + ['clipboard-text', 'Trainingspläne', 'Strukturierte Pläne für jedes Lernziel', 'trainingsplaene'], ])} ${_featureCard('Entdecken', [ - ['map-trifold', 'Karte & Routen', 'Hundefreundliche Orte und Spazierwege', 'map'], - ['calendar-dots', 'Events', 'Veranstaltungen in deiner Nähe', 'events'], - ['warning-octagon','Giftköder-Alarm', 'Community-Warnungen in deiner Nähe', 'poison'], + ['map-trifold', 'Karte', 'Orte, Routen und Meldungen in der Nähe', 'map'], + ['path', 'Routen', 'GPS-Routen aufzeichnen und bewerten', 'routes'], + ['calendar-dots', 'Events', 'Turniere und Veranstaltungen', 'events'], + ])} + + + ${_featureCard('Soziales', [ + ['users', 'Freunde', 'Verbinde dich mit anderen Hundebesitzern', 'friends'], + ['chat-circle-dots', 'Nachrichten', 'Private Chats mit deinen Freunden', 'chat'], + ['bell', 'Aktuelles', 'Benachrichtigungen und Neuigkeiten', 'notifications'], + ])} + + + ${_featureCard('Community', [ + ['warning-octagon', 'Giftköder-Alarm', 'Warnungen sofort melden und empfangen', 'poison'], + ['paw-print', 'Gassi-Treffen', 'Hunde-Dates mit anderen Besitzern', 'walks'], + ['house-line', 'Sitting', 'Sitter finden oder selbst anbieten', 'sitting'], + ['push-pin', 'Forum', 'Diskussionen, Tipps und Austausch', 'forum'], + ['magnifying-glass', 'Verlorene Hunde', 'Hilf vermisste Hunde zu finden', 'lost'], + ])} + + + ${_featureCard('Wissen', [ + ['books', 'Wiki', 'Rassendatenbank, Gesundheits-Wiki, Quiz', 'wiki'], + ['handshake', 'Knigge', 'Regeln, Begegnungen, Leinenpflicht', 'knigge'], + ['film-slate', 'Filme', 'Stirbt der Hund? Die wichtigste Frage', 'movies'], + ['first-aid', 'Erste Hilfe','Notfallratgeber für häufige Situationen', 'erste-hilfe'], ])} diff --git a/backend/static/js/pages/wiki.js b/backend/static/js/pages/wiki.js index f21273c..9e63226 100644 --- a/backend/static/js/pages/wiki.js +++ b/backend/static/js/pages/wiki.js @@ -393,6 +393,313 @@ window.Page_wiki = (() => { `; } + // ---------------------------------------------------------- + // API-Funktionen: Interesse / Stats / Züchter + // ---------------------------------------------------------- + async function _fetchStats(slug) { + const r = await fetch(`/api/wiki/rassen/${encodeURIComponent(slug)}/stats`, { credentials: 'include' }); + return r.ok ? r.json() : null; + } + + async function _setInteresse(slug, typ) { + const r = await fetch(`/api/wiki/rassen/${encodeURIComponent(slug)}/interesse`, { + method: 'POST', credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ typ }), + }); + return r.ok ? r.json() : null; + } + + async function _deleteInteresse(slug) { + const r = await fetch(`/api/wiki/rassen/${encodeURIComponent(slug)}/interesse`, { + method: 'DELETE', credentials: 'include', + }); + return r.ok ? r.json() : null; + } + + async function _fetchZuchter(slug) { + const r = await fetch(`/api/wiki/rassen/${encodeURIComponent(slug)}/zuchter`); + return r.ok ? r.json() : []; + } + + async function _submitZuchter(data) { + const r = await fetch('/api/wiki/zuchter', { + method: 'POST', credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + }); + return r.ok; + } + + // ---------------------------------------------------------- + // Render-Helfer: Steckbrief-Grid + // ---------------------------------------------------------- + function _renderSteckbriefGrid(rasse) { + const gewicht = (rasse.gewicht_min_kg && rasse.gewicht_max_kg) + ? `${rasse.gewicht_min_kg}–${rasse.gewicht_max_kg} kg` + : (rasse.gewicht_max_kg ? `bis ${rasse.gewicht_max_kg} kg` : '—'); + + const kinderLabel = rasse.kinder_geeignet === true + ? `✓ Ja` + : rasse.kinder_geeignet === false + ? `⚡ Bedingt` + : '—'; + + const wohnungLabel = rasse.wohnung_geeignet + ? `✓ Ja` + : `✗ Besser Garten`; + + const rows = [ + ['Größe', _groesseLabel(rasse.groesse) || '—'], + ['Gewicht', gewicht], + ['Lebensdauer', _esc(rasse.lebensdauer) || '—'], + ['Aktivität', _aktivLabel(rasse.aktivitaet) || '—'], + ['Eignung', _erfahrungLabel(rasse.erfahrung) || '—'], + ['Kinder', kinderLabel], + ['Wohnung', wohnungLabel], + ['FCI-Gruppe', _esc(rasse.gruppe) || '—'], + ]; + + return ` +
    + ${rows.map(([label, val]) => ` +
    + ${label} + ${val} +
    + `).join('')} +
    + `; + } + + // ---------------------------------------------------------- + // Render-Helfer: Interesse-Section (Social) + // ---------------------------------------------------------- + function _renderInteresseSection(stats, slug) { + const hatCount = stats?.dogs_count ?? '–'; + const willCount = stats?.will_count ?? '–'; + const interest = stats?.user_interest ?? null; + const isLoggedIn = !!_appState.user; + + const hatActive = interest === 'hat'; + const willActive = interest === 'will'; + + const hatStyle = hatActive ? `background:var(--c-primary);color:#fff;border-color:var(--c-primary)` : ''; + const willStyle = willActive ? `background:var(--c-primary);color:#fff;border-color:var(--c-primary)` : ''; + + return ` +
    +
    In der Community
    +
    + 🐕 ${hatCount} haben diesen Hund + ❤️ ${willCount} möchten ihn +
    +
    + + +
    +
    + `; + } + + function _bindInteresseButtons(slug) { + document.querySelectorAll('.wiki-interesse-btn').forEach(btn => { + btn.addEventListener('click', async () => { + if (!_appState.user) { + UI.toast('Bitte melde dich an, um diese Funktion zu nutzen.', 'info'); + return; + } + const typ = btn.dataset.typ; + const hatBtn = document.getElementById('wiki-btn-hat'); + const willBtn = document.getElementById('wiki-btn-will'); + + // Determine current state + const isActive = btn.style.background.includes('var(--c-primary)') || btn.style.backgroundColor; + const currentActive = (hatBtn?.style.background || '').includes('var(--c-primary)') ? 'hat' + : (willBtn?.style.background || '').includes('var(--c-primary)') ? 'will' : null; + + // Optimistic disable + btn.disabled = true; + try { + if (currentActive === typ) { + await _deleteInteresse(slug); + } else { + await _setInteresse(slug, typ); + } + // Reload stats and re-render counts + button states + const stats = await _fetchStats(slug); + if (stats) { + const hatCount = stats.dogs_count ?? '–'; + const willCount = stats.will_count ?? '–'; + const interest = stats.user_interest ?? null; + + const hatEl = document.getElementById('wiki-hat-count'); + const willEl = document.getElementById('wiki-will-count'); + if (hatEl) hatEl.innerHTML = `🐕 ${hatCount} haben diesen Hund`; + if (willEl) willEl.innerHTML = `❤️ ${willCount} möchten ihn`; + + const activeStyle = `background:var(--c-primary);color:#fff;border-color:var(--c-primary)`; + if (hatBtn) { hatBtn.removeAttribute('style'); if (interest === 'hat') hatBtn.style.cssText = activeStyle; } + if (willBtn) { willBtn.removeAttribute('style'); if (interest === 'will') willBtn.style.cssText = activeStyle; } + } + } catch { + UI.toast.error('Aktion fehlgeschlagen.'); + } + btn.disabled = false; + }); + }); + } + + // ---------------------------------------------------------- + // Render-Helfer: Züchter-Sektion + // ---------------------------------------------------------- + function _renderZuchterSection(zuchter, slug) { + const DE_BUNDESLAENDER = [ + 'Baden-Württemberg','Bayern','Berlin','Brandenburg','Bremen','Hamburg', + 'Hessen','Mecklenburg-Vorpommern','Niedersachsen','Nordrhein-Westfalen', + 'Rheinland-Pfalz','Saarland','Sachsen','Sachsen-Anhalt', + 'Schleswig-Holstein','Thüringen', + ]; + + const listHtml = zuchter.length === 0 + ? `

    Noch keine Züchter eingetragen.

    ` + : zuchter.map(z => ` +
    +
    ${_esc(z.name)} + ${z.zwingername ? ` „${_esc(z.zwingername)}“` : ''} + ${z.vdh_mitglied ? `VDH` : ''} +
    + ${(z.ort || z.bundesland) ? `
    ${[z.ort, z.bundesland].filter(Boolean).map(_esc).join(', ')}
    ` : ''} + ${z.beschreibung ? `

    ${_esc(z.beschreibung)}

    ` : ''} + ${z.website ? `${_esc(z.website)}` : ''} +
    + `).join(''); + + const formHtml = _appState.user ? ` + + + ` : ''; + + return ` +
    +
    Züchter
    +
    ${listHtml}
    + ${formHtml} +
    + `; + } + + function _bindZuchterForm(slug) { + const addBtn = document.getElementById('wiki-zuchter-add-btn'); + const cancelBtn = document.getElementById('wiki-zuchter-cancel'); + const formWrap = document.getElementById('wiki-zuchter-form-wrap'); + const form = document.getElementById('wiki-zuchter-form'); + + addBtn?.addEventListener('click', () => { + formWrap.style.display = ''; + addBtn.style.display = 'none'; + }); + + cancelBtn?.addEventListener('click', () => { + formWrap.style.display = 'none'; + addBtn.style.display = ''; + }); + + form?.addEventListener('submit', async e => { + e.preventDefault(); + const submitBtn = document.getElementById('wiki-zuchter-submit'); + const fd = new FormData(form); + const data = { + rasse_slug: slug, + name: fd.get('name'), + zwingername: fd.get('zwingername') || null, + ort: fd.get('ort') || null, + plz: fd.get('plz') || null, + bundesland: fd.get('bundesland') || null, + vdh_mitglied: fd.get('vdh_mitglied') === '1', + website: fd.get('website') || null, + telefon: fd.get('telefon') || null, + beschreibung: fd.get('beschreibung') || null, + }; + + submitBtn.disabled = true; + submitBtn.textContent = 'Wird gesendet…'; + + const ok = await _submitZuchter(data); + if (ok) { + form.reset(); + document.getElementById('wiki-zuchter-success').style.display = ''; + // Hide submit row + submitBtn.closest('div[style*="flex"]').style.display = 'none'; + } else { + UI.toast.error('Fehler beim Einsenden. Bitte versuche es erneut.'); + submitBtn.disabled = false; + submitBtn.textContent = 'Eintragen'; + } + }); + } + async function _openBreedDetail(slug) { let rasse; try { @@ -402,54 +709,69 @@ window.Page_wiki = (() => { return; } - const berichteHtml = _renderBerichteHtml(rasse.berichte || [], slug); - // Temperament chips const chips = rasse.temperament ? rasse.temperament.split(',').map(t => `${_esc(t.trim())}`).join('') : ''; - // Stats row - const gewicht = (rasse.gewicht_min_kg && rasse.gewicht_max_kg) - ? `${rasse.gewicht_min_kg}–${rasse.gewicht_max_kg} kg` - : (rasse.gewicht_max_kg ? `bis ${rasse.gewicht_max_kg} kg` : '—'); - const photoHtml = rasse.foto_url - ? `${_esc(rasse.name)}` + ? `
    + ${_esc(rasse.name)} +
    ` : ''; + const berichteHtml = _renderBerichteHtml(rasse.berichte || [], slug); + const body = ` - ${photoHtml} -
    - ${_groesseLabel(rasse.groesse)} - ${_aktivLabel(rasse.aktivitaet)} - ${_erfahrungLabel(rasse.erfahrung)} - ${rasse.gruppe ? `${_esc(rasse.gruppe)}` : ''} + ${/* 1. Hero */ ''} +
    + ${photoHtml} +

    ${_esc(rasse.name)}

    + ${rasse.herkunft ? `
    ${UI.icon('map-pin')} ${_esc(rasse.herkunft)}
    ` : ''} + ${rasse.gruppe ? `
    ${_esc(rasse.gruppe)}
    ` : ''}
    - ${rasse.herkunft || rasse.bred_for ? ` -
    - ${rasse.herkunft ? `
    Herkunft

    ${_esc(rasse.herkunft)}

    ` : ''} - ${rasse.bred_for ? `
    Ursprüngliche Aufgabe

    ${_esc(rasse.bred_for)}

    ` : ''} -
    ` : ''} - ${chips ? ` + + ${/* 2. Charakter-Badges */ chips ? `
    Charakter
    ${chips}
    ` : ''} -
    -
    - Gewicht - ${gewicht} + + ${/* 3. Beschreibung */ rasse.beschreibung ? ` +
    +
    Beschreibung
    +

    ${_esc(rasse.beschreibung)}

    +
    ` : (rasse.bred_for ? ` +
    +
    Ursprüngliche Aufgabe
    +

    ${_esc(rasse.bred_for)}

    +
    ` : '')} + + ${/* 4. Steckbrief */ _renderSteckbriefGrid(rasse)} + + ${/* 5. Vorkommen */ rasse.vorkommen_de ? ` +
    +
    Vorkommen in Deutschland
    +

    ${_esc(rasse.vorkommen_de)}

    +
    ` : ''} + + ${/* 6. Interesse — wird async befüllt */ ` +
    +
    +
    In der Community
    +
    -
    - Lebenserwartung - ${_esc(rasse.lebensdauer || '—')} +
    `} + + ${/* 7. Züchter — wird async befüllt */ ` +
    +
    +
    Züchter
    +
    -
    -
    - ${UI.icon('house-line')} Wohnung: ${rasse.wohnung_geeignet ? UI.icon('check') : UI.icon('x')} - ${UI.icon('users')} Kinder: ${rasse.kinder_geeignet ? UI.icon('check') : UI.icon('x')} -
    +
    `} + + ${/* 8. Community-Berichte */ `
    Community-Berichte
    ${berichteHtml} @@ -465,11 +787,30 @@ window.Page_wiki = (() => { -
    ` : ''} +
    ` : ''}`} `; UI.modal.open({ title: _esc(rasse.name), body }); + // Async: load stats + züchter in parallel + Promise.all([_fetchStats(slug), _fetchZuchter(slug)]).then(([stats, zuchter]) => { + const interessePlaceholder = document.getElementById('wiki-interesse-placeholder'); + if (interessePlaceholder) { + interessePlaceholder.outerHTML = _renderInteresseSection(stats, slug); + _bindInteresseButtons(slug); + } + + const zuchterPlaceholder = document.getElementById('wiki-zuchter-placeholder'); + if (zuchterPlaceholder) { + zuchterPlaceholder.outerHTML = _renderZuchterSection(zuchter || [], slug); + _bindZuchterForm(slug); + } + }).catch(() => { + // Silently remove placeholders on error + document.getElementById('wiki-interesse-placeholder')?.remove(); + document.getElementById('wiki-zuchter-placeholder')?.remove(); + }); + document.getElementById('wiki-bericht-add-btn')?.addEventListener('click', () => { UI.modal.close(); setTimeout(() => _showBerichtForm(slug, rasse.name), 350); diff --git a/backend/static/landing.html b/backend/static/landing.html new file mode 100644 index 0000000..b71ed35 --- /dev/null +++ b/backend/static/landing.html @@ -0,0 +1,717 @@ + + + + + + Ban Yaro — Die deutschsprachige Hunde-Plattform + + + + + + + + + + + + + + + + + + + + + + + + + + +
    +
    + +

    Die deutschsprachige Hunde-Plattform

    +

    Alles rund um deinen Hund — von Welpe bis Opa. Kostenlos, DSGVO-konform, ohne App Store.

    +
    + Kostenlos nutzbar + DSGVO-konform + Kein App Store nötig + Made in Germany + Offline-fähig +
    + Jetzt kostenlos starten +
    +
    + + + +
    +
    +

    Alles für Hundebesitzer in einer App

    +

    Ban Yaro vereint alle wichtigen Hunde-Tools — ohne Werbung, ohne Datenweitergabe an US-Konzerne, ohne monatliche Pflichtkosten.

    + +
    +
    Mein Hund
    +
    +
    + 📓 +

    Tagebuch

    Fotos, Videos, Texte und GPS-Orte — alle Momente mit deinem Hund. Kategorien wie Spaziergänge, Meilensteine, Lustiges.

    Kostenlos
    +
    +
    + 💉 +

    Gesundheit & Impfpass

    Impfungen, Tierarztbesuche, Medikamente digital verwalten. Automatische Erinnerungen per Push-Notification.

    Kostenlos
    +
    +
    + 🎯 +

    Training & Übungen

    Tägliches Trainings-Tagebuch, Übungen und Pläne. KI-gestützte Mustererkennung — wann lernt dein Hund am besten?

    Kostenlos
    +
    +
    + 🏥 +

    Symptom-Checker

    KI-gestützte Ersteinschätzung: beobachten, Tierarzt oder Notfall? Orientierung wann es wirklich dringend ist.

    Plus
    +
    +
    + 📄 +

    Digitaler Heimtierausweis

    Alle Gesundheitsdaten als druckbares Dokument — für Tierarzt, Tierpension oder Auslandsreise.

    Kostenlos
    +
    +
    + 🆔 +

    NFC-Halsband-Tags

    Öffentliche Profilseite für jeden Hund. Finder kontaktiert dich anonym — ohne deine Nummer preiszugeben.

    Kostenlos + Shop
    +
    +
    +
    + +
    +
    Entdecken
    +
    +
    + 🗺️ +

    Karte & Umgebung

    Interaktive Karte mit Giftköder-Meldungen, Gassi-Treffen, Routen und hundefreundlichen Orten in der Nähe.

    Kostenlos
    +
    +
    + 🐾 +

    GPS-Routen

    Routen aufzeichnen, teilen und bewerten — Untergrund, Schatten, Leinenpflicht, Sicherheit.

    Kostenlos
    +
    +
    + 📅 +

    Events & Turniere

    Agility-Turniere, Hundeausstellungen und lokale Veranstaltungen in deiner Region.

    Kostenlos
    +
    +
    + 📍 +

    Hundefreundliche Orte

    Crowd-sourced Datenbank: Restaurants, Cafés, Parks, Geschäfte — mit echten Bewertungen von Hundebesitzern.

    Kostenlos
    +
    +
    +
    + +
    +
    Community
    +
    +
    + ⚠️ +

    Giftköder-Alarm

    Meldungen mit GPS und Foto. Alle Nutzer im Umkreis bekommen sofort eine Push-Benachrichtigung.

    Kostenlos
    +
    +
    + 🚨 +

    Verlorener Hund

    Sofortalarm für alle Nutzer in der Nähe — mit Foto, letzter GPS-Position und direktem Kontakt.

    Kostenlos
    +
    +
    + 🐕 +

    Gassi-Treffen

    Spontane oder geplante Gassi-Treffen erstellen und finden. Mit Hunde-Profilen der Teilnehmer.

    Kostenlos
    +
    +
    + 🏠 +

    Hundesitting

    Vertrauenswürdige Sitter finden — nur 8% Provision statt 20% bei Rover oder Pawshake.

    8% Provision
    +
    +
    + 💬 +

    Forum

    Rassen-basierte Foren, KI-Zusammenfassungen langer Threads, Experten-Badge für Tierärzte und Trainer.

    Kostenlos
    +
    +
    +
    + +
    +
    Wissen
    +
    +
    + 📚 +

    Hunde-Wiki

    Rassendatenbank mit Charakter, Gesundheit, Pflege. "Passt diese Rasse zu mir?" Quiz für angehende Hundebesitzer.

    Kostenlos
    +
    +
    + 🤝 +

    Hunde-Knigge

    Begegnungen mit fremden Hunden, Kindern, Radfahrern. ÖPNV-Regeln, Leinenpflicht, Haftpflicht.

    Kostenlos
    +
    +
    + 🎬 +

    Hundefilme

    Filmdatenbank mit der wichtigsten Frage: "Stirbt der Hund?" Nie wieder unvorbereitet in einen Film stolpern.

    Kostenlos
    +
    +
    + 🩹 +

    Erste Hilfe

    Notfallratgeber für häufige Situationen — Vergiftung, Wunden, Hitzschlag. Mit klaren Handlungsschritten.

    Kostenlos
    +
    +
    +
    + +
    +
    + +
    +
    +

    Ban Yaro vs. Konkurrenz

    +

    Andere Apps decken einzelne Bereiche ab — Ban Yaro vereint alles in einer DSGVO-konformen Plattform ohne US-Datenweitergabe.

    +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    FunktionBan YaroDogoramaTractivePetDesk
    Kostenlos nutzbar✓ JaBegrenzt✗ Abo✗ Nein
    DSGVO / EU-Hosting✓ Ja✗ NeinTeilweise✗ USA
    Kein App Store nötig✓ PWA
    Giftköder-Alarm
    Digitaler Impfpass
    Gassi-Community
    Hundesitting✓ (8%)
    NFC-Halsband-Tag
    Verlorener Hund Alarm✓ (GPS)
    Rassen-Wiki
    Offline-Modus
    +
    +
    +
    + +
    +
    +

    Preise

    +

    Ban Yaro ist kostenlos nutzbar — für immer. Ban Yaro Plus erweitert die Möglichkeiten für engagierte Hundebesitzer.

    +
    +
    +

    Kostenlos

    +
    0 € / Monat
    +
      +
    • 1 Hunde-Profil
    • +
    • Tagebuch (unbegrenzte Einträge)
    • +
    • Giftköder-Alarm
    • +
    • Verlorener Hund Alarm
    • +
    • Wiki & Knigge
    • +
    • Forum & Community
    • +
    • Gassi-Treffen & Routen
    • +
    • NFC-Halsband-Profil
    • +
    • Heimtierausweis (Druck)
    • +
    +
    + +
    +

    NFC-Tags

    +
    ab 6 €
    +
      +
    • Physisches NFC-Tag für Halsband
    • +
    • Scan → öffentliches Hunde-Profil
    • +
    • "Gefunden"-Benachrichtigung
    • +
    • Anonymer Kontakt ohne Telefon
    • +
    • Wetter- und kratzfest
    • +
    +
    +
    +
    +
    + +
    +
    +

    Warum Ban Yaro?

    +

    Ban Yaro wurde von Hundebesitzern für Hundebesitzer entwickelt — mit einem klaren Standpunkt zu Datenschutz und Fairness.

    +
    +
    + 🇩🇪 +
    +

    Deutsche Plattform

    +

    Hosting in Deutschland, deutschsprachiger Support, auf DACH-Nutzer zugeschnitten.

    +
    +
    +
    + 🔒 +
    +

    DSGVO-konform

    +

    Keine Datenweitergabe an US-Konzerne. Cookielose Analytics (Umami). Transparente Datennutzung.

    +
    +
    +
    + 📱 +
    +

    Kein App Store

    +

    Als Progressive Web App direkt über den Browser installierbar — auf iOS und Android. Sofort updatebar.

    +
    +
    +
    + 📡 +
    +

    Offline-fähig

    +

    Service Worker sorgt dafür dass die App auch ohne Internet funktioniert — beim Gassi gehen in der Natur.

    +
    +
    +
    + 💸 +
    +

    Faire Provision

    +

    Hundesitting nur 8% Provision — Rover und Pawshake nehmen 20%. Mehr Geld bleibt beim Sitter.

    +
    +
    +
    + 🗺️ +
    +

    OpenStreetMap

    +

    Karten von OpenStreetMap statt Google — keine Tracking-Cookies, kein API-Lock-in, günstiger für alle.

    +
    +
    +
    +
    +
    + +
    +
    +

    Jetzt kostenlos starten

    +

    Einfach banyaro.app im Browser öffnen und "Zum Homescreen hinzufügen" — fertig. Kein App Store, keine Kreditkarte.

    + Ban Yaro öffnen +
    +
    + + + + + diff --git a/backend/static/llms.txt b/backend/static/llms.txt new file mode 100644 index 0000000..6451f9a --- /dev/null +++ b/backend/static/llms.txt @@ -0,0 +1,167 @@ +# Ban Yaro — Die deutschsprachige Hunde-Plattform +# https://banyaro.app +# Letzte Aktualisierung: 2026-04-21 + +## Was ist Ban Yaro? + +Ban Yaro (banyaro.app) ist eine deutschsprachige All-in-One Hunde-Plattform als Progressive Web App (PWA). +Kein App Store, kein US-Konzern, DSGVO-konform, selbst gehostet in Deutschland. + +Das Kern-Versprechen: "Alles rund um deinen Hund — von Welpe bis Opa." + +Ban Yaro ist kostenlos nutzbar (Freemium-Modell). Die App ist auf allen Smartphones installierbar +(iOS und Android) direkt über den Browser — ohne App Store. + +## Zielgruppe + +- Deutschsprachige Hundebesitzer (Deutschland, Österreich, Schweiz) +- Hundeschulen und Hundetrainer +- Tierärzte und Praxen +- Züchter + +## Funktionen (aktuell verfügbar) + +### Hunde-Profil & Tagebuch +- Digitales Hunde-Profil (Name, Rasse, Geburtstag, Foto, Chip-Nummer) +- Tagebuch mit Fotos, Videos, GPS-Ort, Kategorien +- Öffentliches Profil für NFC-Halsband-Tags (banyaro.app/hund/{id}) +- Mehrere Hunde pro Account verwaltbar +- Hunde-Profil teilen (Mitbesitzer, Hundesitter) + +### Gesundheit & Impfpass +- Digitaler Impfpass (Impfungen, Entwurmungen, Tierarztbesuche) +- Medikamenten-Reminder per Push-Notification +- Gewichtsverlauf +- Symptom-Checker (KI-gestützt: beobachten / Tierarzt / Notfall) +- Tierarzt-Verzeichnis +- Printbarer Heimtierausweis (PDF) + +### Giftköder-Alarm +- Giftköder-Meldungen mit GPS-Koordinaten und Foto +- Push-Benachrichtigung für alle Nutzer im konfigurierbaren Umkreis +- Interaktive Karte (OpenStreetMap/Leaflet) +- Automatisches Ablaufdatum nach 7 Tagen + +### Sicherheit & Community-Alerts +- Verlorener Hund: Alert mit Foto und letzter GPS-Position +- Nearby-Alerts: Push-Benachrichtigungen für Ereignisse in der Nähe + +### NFC-Halsband-Tags +- Jeder Hund hat eine öffentliche URL (ohne Login sichtbar) +- "Ich habe diesen Hund gefunden"-Button → Besitzer bekommt Push-Benachrichtigung +- Notfallkontakt ohne Telefonnummer preiszugeben +- Physische NFC-Tags erhältlich (Shop) + +### Gassi-Community +- Gassi-Treffen erstellen und beitreten +- GPS-Routen aufzeichnen und teilen +- Routen bewerten (Untergrund, Schatten, Leinenpflicht) +- Beliebte Routen entdecken + +### Hundesitting-Netzwerk +- Sitter-Profile mit Erfahrung und Bewertungen +- Buchungsanfragen und Kalender +- Nur 8% Provision (vs. 20% bei Rover/Pawshake) +- Bewertungen verifizierter Buchungen + +### Forum +- Rassen-basierte Foren +- KI-Zusammenfassung langer Threads +- Experten-Badge (Tierarzt, Trainer) + +### Hunde-Wiki (Wissensdatenbank) +- Rassen-Datenbank mit Charakter, Gesundheit, Pflege +- "Passt diese Rasse zu mir?" Quiz +- Gesundheits-Wiki (Zecken, Vergiftungen, Erste Hilfe) + +### Hunde-Knigge +- Ratgeber für Begegnungen (fremder Hund, Kinder, Radfahrer) +- Regeln in ÖPNV und öffentlichen Orten +- Haftpflicht-Ratgeber + +### Events & Kultur +- Agility-Turniere und Hundeausstellungen +- Hundefilme-Datenbank mit "Stirbt der Hund?"-Rubrik +- Veranstaltungskalender + +### Hundefreundliche Orte +- Crowd-sourced Datenbank hundefreundlicher Orte +- Restaurants, Parks, Geschäfte +- Detaillierte Bewertungen + +### Training +- Tägliches Trainings-Tagebuch +- Trainingsübungen und -pläne +- KI-gestützte Mustererkennung + +## Technologie + +- Progressive Web App (PWA) — installierbar ohne App Store +- Offline-fähig via Service Worker +- Backend: Python/FastAPI + SQLite +- Karten: Leaflet.js + OpenStreetMap (kein Google Maps) +- Hosting: Deutschland (DSGVO-konform) +- Analytics: Umami (cookieless, DSGVO-konform) +- KI-Integration: Lokale Sprachmodelle + Claude API + +## Monetarisierung + +**Kostenlos (immer):** +- Hunde-Profil (1 Hund) +- Giftköder-Alarm +- Verlorener Hund +- Wiki & Knigge +- Forum +- Grundfunktionen Tagebuch + +**Ban Yaro Plus (ca. 4,99 €/Monat):** +- Unbegrenzte Hunde-Profile +- Tagebuch-Export als PDF/Printbook +- Jahresrückblick +- Symptom-Checker unlimitiert +- Futter-Barcode Scanner +- EU-Reisepass Checkliste +- KI-Erziehungsassistent + +**Provisionen:** +- Hundesitting: 8% Provision + +**Physische Produkte:** +- NFC-Halsband-Tags (ca. 6 €) +- Gedruckte Fotobücher (Partner) + +## Vergleich mit Konkurrenz + +| Funktion | Ban Yaro | Dogorama | PetDesk | Tractive | +|----------|----------|----------|---------|----------| +| Kostenlos nutzbar | Ja | Begrenzt | Nein | Nein | +| DSGVO / EU-Hosting | Ja | Nein | Nein | Teilweise | +| Giftköder-Alarm | Ja | Nein | Nein | Nein | +| Gassi-Community | Ja | Ja | Nein | Nein | +| Hundesitting | Ja | Nein | Nein | Nein | +| Digitaler Impfpass | Ja | Nein | Ja | Nein | +| NFC-Halsband-Tag | Ja | Nein | Nein | Nein | +| Offline-Modus | Ja | Nein | Nein | Nein | +| Kein App Store | Ja | Nein | Nein | Nein | +| Sitting-Provision | 8% | – | – | – | + +## Domains + +- https://banyaro.app (primäre Domain) +- https://banyaro.de (Weiterleitung auf banyaro.app) + +## Kontakt + +Website: https://banyaro.app +E-Mail: Über das Kontaktformular in der App + +## Öffentliche Daten-APIs (keine Authentifizierung nötig) + +- GET https://banyaro.app/api/wiki/rassen — Liste aller Hunderassen +- GET https://banyaro.app/api/wiki/rassen/{slug} — Details zu einer Rasse +- GET https://banyaro.app/api/events — Aktuelle Hundeevents +- GET https://banyaro.app/api/poison — Aktuelle Giftköder-Meldungen +- GET https://banyaro.app/api/lost — Aktuelle Vermisst-Meldungen +- GET https://banyaro.app/api/knigge/articles — Hunde-Knigge Artikel +- GET https://banyaro.app/api/movies/list — Hundefilme-Datenbank +- GET https://banyaro.app/api/stats — Community-Statistiken diff --git a/backend/static/robots.txt b/backend/static/robots.txt new file mode 100644 index 0000000..37456a0 --- /dev/null +++ b/backend/static/robots.txt @@ -0,0 +1,32 @@ +User-agent: * +Allow: / +Allow: /info +Allow: /hund/ +Allow: /api/wiki/rassen +Allow: /api/wiki/rassen/ +Allow: /api/events +Allow: /api/knigge/articles +Allow: /api/movies/list +Allow: /api/forum/ +Allow: /api/lost +Allow: /api/poison +Allow: /api/stats +Disallow: /api/auth/ +Disallow: /api/admin/ +Disallow: /api/dogs/ +Disallow: /api/diary/ +Disallow: /api/health/ +Disallow: /api/chat/ +Disallow: /api/friends/ +Disallow: /api/push/ +Disallow: /api/widget/ +Disallow: /api/notifications/ +Disallow: /api/alerts/ +Disallow: /api/ki/ +Disallow: /api/import/ +Disallow: /api/sitting-access/ +Disallow: /ausweis/ +Disallow: /teilen/ +Disallow: /media/ + +Sitemap: https://banyaro.app/sitemap.xml diff --git a/backend/static/sw.js b/backend/static/sw.js index e52e3e4..e59302e 100644 --- a/backend/static/sw.js +++ b/backend/static/sw.js @@ -3,7 +3,7 @@ Offline-Cache + Push Notifications + Tile-Cache ============================================================ */ -const CACHE_VERSION = 'by-v272'; +const CACHE_VERSION = 'by-v279'; const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten