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' ' 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
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Ban Yaro
+ Über die App
+ Knigge
+ App öffnen
+
+
+
+
+"""
+ 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' ' 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
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Ban Yaro
+ Alle Rassen
+ Knigge
+ Über die App
+ App öffnen
+
+
+
+
+
+ {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
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Ban Yaro
+ Rassen-Wiki
+ Über die App
+ App öffnen
+
+
+
+ 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']}
+
+
+
+
+
+
+
+
+
Community
+
+ {"".join(f'
' 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 @@
+
+
+
-
-
-
-
-
-
-
-
-