diff --git a/MARKETING.md b/MARKETING.md index 63fcbc0..735a061 100644 --- a/MARKETING.md +++ b/MARKETING.md @@ -18,7 +18,7 @@ _Stand: 2026-06-09_ | Influencer | đĄ 2 Runden (Mai), kaum Resonanz | Runde 3 erst ab ~50 aktiven Usern â jetzt mit Partner-Paket als konkretem Angebot | | Presse / Blogs | đĄ 1 Runde, kaum Resonanz | keine Massenwelle; Nische zuerst | | Verzeichnisse / Listings | ⏠offen | Product Hunt, PWA-Dirs, Google Business EBE | -| SEO / KI-Auffindbarkeit | đĄ technisch optimiert | Backlinks (Blog-Testberichte) | +| SEO / KI-Auffindbarkeit | đĄ technisch optimiert | Rechtsseiten crawlbar (v1278) + 3 URLs (datenschutz/agb/impressum) am 09.06. in GSC zur Indexierung eingereicht â in ~Tagen auf âindexiert" prĂŒfen; llms.txt aktuell. NĂ€chster echter Hebel: Backlinks (Blog-Testberichte) | | Landing Page | đĄ Redesign-Briefing da | 3 Einstiege, Outcomes statt Features | | App Store (iOS) | đą **LIVE im App Store** (09.06., Apple-ID 6775012705) | Landing bewirbt âBan Yaro Go" (Hero + iOS-Abschnitt `#ios-app`) + Profil-Hinweis (Settings â App installieren). Offizielles âLaden im App Store"-Badge nachgebaut als `/img/appstore-badge-de.svg` (brauner Rand #C4843A). **LIVE auf Produktion v1276** (banyaro.app/.de, 09.06.) â Hero-Badge bewusst weggelassen (sonst Eindruck: ganze App im Store) | | Play Store (Android) | đŽ ON HOLD | 12 Closed-Tester / 14 Tage fehlen | @@ -41,7 +41,9 @@ Legende: đą lĂ€uft/erledigt · đĄ angefangen · ⏠offen · đĄ Idee · ## â Erledigt - [x] 1000 Flyer A5 (zweiseitig) gedruckt â 03.06.2026 - [x] iOS-App nativ gebaut + **im App Store freigegeben** (Ban Yaro Go, 09.06.) â Details im Repo `banyaro-ios` -- [x] Landing-Promotion fĂŒr âBan Yaro Go" gebaut (Hero-Badge + iOS-Abschnitt) â 09.06., develop (URL-Platzhalter offen) +- [x] Landing-Promotion fĂŒr âBan Yaro Go" LIVE (iOS-Abschnitt + Profil, eigenes braunes App-Store-Badge; Hero bewusst ohne Badge) â 09.06., Prod v1278 +- [x] Datenschutz v4 + AGB v3 (iOS-App-Verarbeitung, kein App-Store-IAP) â 09.06., Prod +- [x] Rechtsseiten crawlbar gemacht (/datenschutz /agb /impressum, einzige Quelle static/*.html) + 3 URLs in GSC zur Indexierung eingereicht â 09.06., Prod v1278 - [x] Influencer-Outreach Runde 1 (5) + Runde 2 (13) â Mai 2026 - [x] SEO-Grundlagen (llms.txt, Landing About-Section) diff --git a/Makefile b/Makefile index 4c7cefe..7fc461a 100644 --- a/Makefile +++ b/Makefile @@ -296,7 +296,9 @@ dev: KI_MODE=off ENV=development \ JWT_SECRET=dev-secret \ DB_PATH=./dev.db \ - uvicorn main:app --reload --port 8001 + MEDIA_DIR=$${MEDIA_DIR:-/tmp/banyaro-media} \ + BREEDER_DOCS_DIR=$${BREEDER_DOCS_DIR:-/tmp/banyaro-breeder} \ + uvicorn main:app --reload --port 8001 --host $${HOST:-127.0.0.1} # ---------------------------------------------------------- # REPORTS â Quartalsberichte generieren und committen diff --git a/VERSION b/VERSION index d1e5ab4..364aacb 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1278 \ No newline at end of file +1292 \ No newline at end of file diff --git a/backend/database.py b/backend/database.py index 8065d48..4f3ce10 100644 --- a/backend/database.py +++ b/backend/database.py @@ -1303,6 +1303,23 @@ def _migrate(conn_factory): """) logger.info("Migration: notes Tabelle bereit.") + # Notizen: mehrere Mediendateien pro Notiz (Bild/Video/Audio/Datei) + conn.executescript(""" + CREATE TABLE IF NOT EXISTS note_media ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + note_id INTEGER NOT NULL REFERENCES notes(id) ON DELETE CASCADE, + url TEXT NOT NULL, + media_type TEXT NOT NULL DEFAULT 'image', -- image|video|audio|pdf|file + sort_order INTEGER NOT NULL DEFAULT 0, + img_width INTEGER, + img_height INTEGER, + duration_s INTEGER, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + CREATE INDEX IF NOT EXISTS idx_note_media_note ON note_media(note_id, sort_order); + """) + logger.info("Migration: note_media Tabelle bereit.") + conn.execute(""" CREATE TABLE IF NOT EXISTS ki_health_reports ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -2673,6 +2690,44 @@ def _migrate(conn_factory): """) logger.info("Migration: failed_emails Tabelle bereit.") + # iOS-Voll-App M0: Media-Registry â Originale liegen in der privaten + # CloudKit-DB des Nutzers, der Server hĂ€lt nur Previews. Die Registry ist + # die Quelle der Wahrheit ĂŒber Existenz + Speicherort (storage: server|icloud). + # ON DELETE CASCADE â Self-Delete (FK-Introspektion in profile.py) und + # Admin-Delete (finaler users-DELETE) rĂ€umen automatisch mit ab. + conn.executescript(""" + CREATE TABLE IF NOT EXISTS media_registry ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + url TEXT NOT NULL UNIQUE, + storage TEXT NOT NULL DEFAULT 'icloud', + ck_record_name TEXT, + ck_state TEXT NOT NULL DEFAULT 'pending', + kind TEXT NOT NULL DEFAULT 'image', + context TEXT, + context_id INTEGER, + bytes_original INTEGER, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + CREATE INDEX IF NOT EXISTS idx_media_registry_user ON media_registry(user_id); + CREATE INDEX IF NOT EXISTS idx_media_registry_ctx ON media_registry(context, context_id); + """) + logger.info("Migration: media_registry Tabelle bereit (iCloud-Hybrid).") + + # daily_photo_cache wurde bisher NUR lazy in routes/dogs.py (Tagesfoto) + # angelegt â delete_diary/delete_media_item referenzieren sie aber auch. + # Auf einer frischen DB ohne Tagesfoto-Abruf warf Löschen daher 500. + # Struktur identisch zum Lazy-CREATE in dogs.py:241. + conn.executescript(""" + CREATE TABLE IF NOT EXISTS daily_photo_cache ( + dog_id INTEGER NOT NULL, + datum TEXT NOT NULL, + photo_url TEXT NOT NULL, + PRIMARY KEY (dog_id, datum) + ); + """) + logger.info("Migration: daily_photo_cache Tabelle bereit.") + # Second-Pass der ALTER-TABLE-Migrations: # Manche Migrations (z.B. diary_media.img_width) referenzieren Tabellen, # die erst weiter unten in dieser Funktion per CREATE IF NOT EXISTS angelegt diff --git a/backend/ki.py b/backend/ki.py index 188056f..7e258c8 100644 --- a/backend/ki.py +++ b/backend/ki.py @@ -25,6 +25,7 @@ KI_MODE = os.getenv("KI_MODE", "local") # off | local | cl LOCAL_BASE_URL = os.getenv("KI_LOCAL_URL", "http://10.47.11.70:11435/v1") LOCAL_MODEL = os.getenv("KI_LOCAL_MODEL", "gemma-4-31b-it") CLOUD_MODEL = os.getenv("KI_CLOUD_MODEL", "claude-sonnet-4-6") +VISION_MODEL = os.getenv("KI_VISION_MODEL", "claude-opus-4-8") # Bild-Analyse (Rassenerkennung) ANTHROPIC_KEY = os.getenv("ANTHROPIC_API_KEY", "") CLOUD_WEEKLY_LIMIT = int(os.getenv("KI_CLOUD_WEEKLY_LIMIT", "20")) diff --git a/backend/main.py b/backend/main.py index 22fe399..21a5069 100644 --- a/backend/main.py +++ b/backend/main.py @@ -106,7 +106,7 @@ class SecurityHeadersMiddleware(BaseHTTPMiddleware): response = await call_next(request) response.headers["X-Content-Type-Options"] = "nosniff" response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin" - response.headers["Permissions-Policy"] = "camera=(), microphone=(), geolocation=(self)" + response.headers["Permissions-Policy"] = "camera=(), microphone=(self), geolocation=(self)" response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains" response.headers["Content-Security-Policy"] = ( "default-src 'self'; " @@ -114,6 +114,7 @@ class SecurityHeadersMiddleware(BaseHTTPMiddleware): "worker-src 'self' blob:; " # 'self' = Service Worker (sw.js); blob: = MapLibre-GL-Worker "style-src 'self' 'unsafe-inline'; " # Inline-Styles bleiben (zu viele Fundstellen fĂŒr jetzt) "img-src 'self' data: blob: https:; " + "media-src 'self' blob:; " # Audio/Video-Wiedergabe + lokale blob:-Vorschau (Sprachnotizen) "connect-src 'self' https:; " "frame-ancestors 'none'; " "base-uri 'self'; " @@ -237,6 +238,7 @@ from routes.wiki import router as wiki_router from routes.movies import router as movies_router from routes.friends import router as friends_router from routes.chat import router as chat_router +from routes.media import router as media_router from routes.admin import router as admin_router from routes.webcal import router as webcal_router from routes.profile import router as profile_router @@ -306,6 +308,7 @@ app.include_router(wiki_router, prefix="/api/wiki", tags=["Wiki"]) app.include_router(movies_router, prefix="/api/movies", tags=["Filme"]) app.include_router(friends_router, prefix="/api/friends", tags=["Freunde"]) app.include_router(chat_router, prefix="/api/chat", tags=["Chat"]) +app.include_router(media_router, prefix="/api/media", tags=["Medien"]) app.include_router(admin_router, prefix="/api/admin", tags=["Admin"]) app.include_router(breeder_router, prefix="/api", tags=["ZĂŒchter"]) app.include_router(litters_router, prefix="/api", tags=["WĂŒrfe"]) @@ -583,6 +586,22 @@ async def serve_media(path: str, request: _Request): filepath = _resolve_media_path(path) if not filepath: + # iCloud-Hybrid (M0): Original liegt in der privaten iCloud des Besitzers, + # auf dem Server existiert nur das Preview. Marker-404, damit Clients + # unterscheiden können â Web fĂ€llt via onerror aufs Preview zurĂŒck. + # Lookup nur im Miss-Pfad â keine Kosten fĂŒr normale Medien-Requests. + from database import db as _db + from fastapi.responses import JSONResponse as _JSONResponse + with _db() as conn: + reg = conn.execute( + "SELECT storage FROM media_registry WHERE url=?", ("/media/" + path,) + ).fetchone() + if reg and reg["storage"] == "icloud": + return _JSONResponse( + {"detail": "Original liegt in der iCloud des Besitzers.", + "storage": "icloud"}, + status_code=404, + ) raise _HE(404, "Nicht gefunden.") return _media_response(filepath) diff --git a/backend/media_utils.py b/backend/media_utils.py index 4fa6a82..6f70fa5 100644 --- a/backend/media_utils.py +++ b/backend/media_utils.py @@ -6,6 +6,9 @@ from typing import Tuple _HEIC_EXTS = {".heic", ".heif"} _VIDEO_EXTS = {".mov", ".avi", ".m4v"} +# Audio-Endungen, die bereits AAC in einem MP4-Container sind (iOS-Recorder +# liefert audio/mp4) â keine Transkodierung nötig. +_AUDIO_AAC_EXTS = {".m4a", ".aac", ".mp4"} # Magic-Byte-Signaturen erlaubter Medientypen _IMAGE_MAGIC = [ @@ -51,6 +54,34 @@ def validate_upload(data: bytes, filename: str) -> None: # HEIC, MOV, AVI, M4V: Pillow/FFmpeg prĂŒfen beim Konvertieren +def validate_audio(data: bytes, content_type: str) -> None: + """ + PrĂŒft Magic Bytes einer Audio-Datei gegen den vom Client gemeldeten content_type. + Wirft ValueError bei Mismatch. WICHTIG: Audio-WebM und Video-WebM teilen sich + dieselbe Magic-Byte-Signatur (Matroska) â die Unterscheidung ist NUR ĂŒber den + content_type möglich, deshalb diese eigene Funktion (validate_upload hat keinen). + """ + if not data: + raise ValueError("Leere Audiodatei.") + ct = (content_type or "").lower().split(";")[0].strip() + if ct in ("audio/mp4", "audio/aac", "audio/x-m4a", "audio/m4a"): + if not (len(data) >= 8 and data[4:8] in (b'ftyp', b'mdat', b'moov', b'free')): + raise ValueError("Datei ist kein gĂŒltiges MP4/AAC-Audio.") + elif ct == "audio/webm": + if not data[:4] == b'\x1a\x45\xdf\xa3': + raise ValueError("Datei ist kein gĂŒltiges WebM-Audio.") + elif ct in ("audio/ogg", "audio/opus"): + if not data[:4] == b'OggS': + raise ValueError("Datei ist kein gĂŒltiges Ogg-Audio.") + elif ct == "audio/mpeg": + if not (data[:3] == b'ID3' or (len(data) >= 2 and data[0] == 0xFF and (data[1] & 0xE0) == 0xE0)): + raise ValueError("Datei ist kein gĂŒltiges MP3.") + elif ct in ("audio/wav", "audio/x-wav", "audio/wave"): + if not (data[:4] == b'RIFF' and data[8:12] == b'WAVE'): + raise ValueError("Datei ist kein gĂŒltiges WAV.") + # Andere/unbekannte Audio-Typen: ffmpeg prĂŒft beim Transkodieren. + + def safe_media_path(media_dir: str, url: str) -> str | None: """ Konstruiert einen sicheren Dateipfad aus einer gespeicherten URL. @@ -69,6 +100,21 @@ def safe_media_path(media_dir: str, url: str) -> str | None: return candidate +def delete_media_files(media_dir: str, urls) -> None: + """Löscht mehrere Mediendateien samt _preview.webp/_thumb.jpg-Leichen von Disk + (best-effort, Path-Traversal-sicher). FĂŒr Cascade-Cleanup bei Lösch-Operationen.""" + for url in urls or []: + fp = safe_media_path(media_dir, url) + if not fp: + continue + base = os.path.splitext(fp)[0] + for path in (fp, base + "_preview.webp", base + "_thumb.jpg"): + try: + os.remove(path) + except OSError: + pass + + def to_jpeg_if_heic(data: bytes, filename: str) -> Tuple[bytes, str]: """Convert HEIC/HEIF to JPEG; return (data, ext) unchanged for all other types.""" ext = os.path.splitext(filename or "")[1].lower() @@ -122,6 +168,44 @@ def to_mp4_if_needed(data: bytes, filename: str) -> Tuple[bytes, str]: pass +def to_m4a(data: bytes, src_ext: str) -> Tuple[bytes, str]: + """Transkodiert Audio nach m4a/AAC â universell abspielbar, auch auf iOS. + Nötig, weil Chrome/Firefox Opus-in-WebM/Ogg aufnehmen, das iOS Safari NICHT + abspielen kann. Bereits-AAC-Container (.m4a/.aac/.mp4, u.a. iOS-Recorder) + werden ohne Transkodierung durchgereicht. Bei ffmpeg-Fehler: Original zurĂŒck.""" + ext = (src_ext or "").lower() + if not ext.startswith("."): + ext = ("." + ext) if ext else ".webm" + if ext in _AUDIO_AAC_EXTS: + return data, ".m4a" + src_path = dst_path = None + try: + with tempfile.NamedTemporaryFile(suffix=ext, delete=False) as src: + src.write(data) + src_path = src.name + dst_path = src_path[: -len(ext)] + ".m4a" + result = subprocess.run( + ["ffmpeg", "-i", src_path, + "-vn", "-c:a", "aac", "-b:a", "128k", + "-movflags", "+faststart", + "-y", dst_path], + capture_output=True, timeout=120, + ) + if result.returncode == 0: + with open(dst_path, "rb") as f: + return f.read(), ".m4a" + return data, ext + except Exception: + return data, ext + finally: + for p in [src_path, dst_path]: + if p: + try: + os.unlink(p) + except OSError: + pass + + def extract_gps_from_exif(data: bytes) -> tuple | None: """EXIF-GPS aus Bilddaten lesen. Gibt (lat, lon) zurĂŒck oder None.""" try: diff --git a/backend/routes/admin.py b/backend/routes/admin.py index 375cf3a..3155cd0 100644 --- a/backend/routes/admin.py +++ b/backend/routes/admin.py @@ -483,6 +483,16 @@ async def delete_user(uid: int, user=Depends(require_admin)): conn.execute("DELETE FROM push_subscriptions WHERE user_id=?", (uid,)) conn.execute("DELETE FROM notifications WHERE user_id=?", (uid,)) conn.execute("DELETE FROM forum_posts WHERE user_id=?", (uid,)) + # Notiz-Medien: erst Dateien von Disk, dann DB-Zeilen (note_media + notes). + import os as _os + from media_utils import delete_media_files + _nm_urls = [r["url"] for r in conn.execute( + "SELECT nm.url FROM note_media nm JOIN notes n ON n.id = nm.note_id WHERE n.user_id=?", + (uid,) + ).fetchall()] + delete_media_files(_os.getenv("MEDIA_DIR", "/data/media"), _nm_urls) + conn.execute("DELETE FROM note_media WHERE note_id IN (SELECT id FROM notes WHERE user_id=?)", (uid,)) + conn.execute("DELETE FROM notes WHERE user_id=?", (uid,)) conn.execute("DELETE FROM users WHERE id=?", (uid,)) _audit(conn, user, "user_delete", f"user:{uid} ({target['name']})") @@ -728,7 +738,7 @@ async def ki_history(user=Depends(require_mod)): @router.get("/ki/status") async def ki_status(user=Depends(require_mod)): import httpx - from ki import KI_MODE, LOCAL_BASE_URL, LOCAL_MODEL, CLOUD_MODEL, ANTHROPIC_KEY + from ki import KI_MODE, LOCAL_BASE_URL, LOCAL_MODEL, CLOUD_MODEL, VISION_MODEL, ANTHROPIC_KEY result = { "mode": KI_MODE, @@ -737,6 +747,7 @@ async def ki_status(user=Depends(require_mod)): "local_reachable": False, "local_model_loaded": None, "cloud_model": CLOUD_MODEL, + "vision_model": VISION_MODEL, "cloud_key_set": bool(ANTHROPIC_KEY), } @@ -944,7 +955,7 @@ async def wiki_enrich(data: WikiEnrichBody, user=Depends(require_mod)): async def wiki_evaluate(sample: int = 20, user=Depends(require_mod)): from scraper.breed_evaluator import evaluate_enrichment sample = max(5, min(sample, 50)) - return await evaluate_enrichment(sample_size=sample) + return await evaluate_enrichment(sample_size=sample, user_id=user["id"]) # ------------------------------------------------------------------ diff --git a/backend/routes/diary.py b/backend/routes/diary.py index 2dfcccb..c122e92 100644 --- a/backend/routes/diary.py +++ b/backend/routes/diary.py @@ -122,9 +122,14 @@ def _fetch_media_items(conn, entry_ids: list[int]) -> dict: if not entry_ids: return {} ph = ",".join("?" * len(entry_ids)) + # LEFT JOIN media_registry: iCloud-Hybrid-Medien tragen storage='icloud' + + # ck_record_name â die iOS-App holt das Original dann aus CloudKit statt + # von der (Phantom-)URL. Server-Medien: storage NULL/'server'. rows = conn.execute( - f"SELECT id, diary_id, url, media_type, sort_order, is_cover FROM diary_media " - f"WHERE diary_id IN ({ph}) ORDER BY diary_id, sort_order", + f"SELECT dm.id, dm.diary_id, dm.url, dm.media_type, dm.sort_order, dm.is_cover, " + f" mr.storage AS storage, mr.ck_record_name AS ck_record_name " + f"FROM diary_media dm LEFT JOIN media_registry mr ON mr.url = dm.url " + f"WHERE dm.diary_id IN ({ph}) ORDER BY dm.diary_id, dm.sort_order", entry_ids ).fetchall() result = {} @@ -135,6 +140,8 @@ def _fetch_media_items(conn, entry_ids: list[int]) -> dict: "preview_url": preview_url_from(url), "media_type": r["media_type"], "sort_order": r["sort_order"], "is_cover": r["is_cover"], + "storage": r["storage"] or "server", + "ck_record_name": r["ck_record_name"], }) return result @@ -634,6 +641,9 @@ async def delete_diary(dog_id: int, entry_id: int, user=Depends(get_current_user ).fetchall()] for u in media_urls: conn.execute("DELETE FROM daily_photo_cache WHERE photo_url=?", (u,)) + # iCloud-Hybrid: Registry-Row mitlöschen â der nĂ€chste App-Sync + # (GET /api/media/mine) rĂ€umt dann den verwaisten CKRecord ab. + conn.execute("DELETE FROM media_registry WHERE url=?", (u,)) conn.execute( "DELETE FROM diary WHERE id=? AND dog_id=?", (entry_id, dog_id) ) @@ -792,9 +802,16 @@ async def delete_media_item(dog_id: int, entry_id: int, media_id: int, if file_path: try: os.remove(file_path) except OSError: pass + # Preview/Thumb mit-entfernen â bei iCloud-Medien die einzige + # Server-Datei, bei Server-Medien lagen sie bisher als Leichen herum. + base = os.path.splitext(file_path)[0] + for leftover in (base + "_preview.webp", base + "_thumb.jpg"): + try: os.remove(leftover) + except OSError: pass # daily_photo_cache mit-bereinigen falls das Bild als Tagesfoto # gewĂ€hlt war (sonst lĂ€dt der Client 404). conn.execute("DELETE FROM daily_photo_cache WHERE photo_url=?", (row["url"],)) + conn.execute("DELETE FROM media_registry WHERE url=?", (row["url"],)) conn.execute("DELETE FROM diary_media WHERE id=?", (media_id,)) diff --git a/backend/routes/ki.py b/backend/routes/ki.py index 5169634..a2a9bc3 100644 --- a/backend/routes/ki.py +++ b/backend/routes/ki.py @@ -298,7 +298,7 @@ Falls kein Hund erkennbar: ist_hund=false und leeres rassen-Array.""" def _sync_call(): client = anthropic.Anthropic(api_key=api_key) return client.messages.create( - model="claude-opus-4-7", + model=ki_module.VISION_MODEL, max_tokens=500, messages=[{ "role": "user", diff --git a/backend/routes/media.py b/backend/routes/media.py new file mode 100644 index 0000000..09e20d2 --- /dev/null +++ b/backend/routes/media.py @@ -0,0 +1,243 @@ +""" +Media-Registry â iOS-Voll-App M0 (iCloud-Hybrid, Plan: banyaro-ios/PLAN_VOLLAPP.md). + +Originale liegen in der privaten CloudKit-DB des Nutzers (die App lĂ€dt sie dorthin), +der Server speichert nur Previews. Die Registry ist die Quelle der Wahrheit ĂŒber +Existenz und Speicherort jedes Mediums; bei storage='icloud' sieht der Server die +Original-Bytes nie (Apple erlaubt Server-to-Server-Zugriff nur auf Public-DBs). + +Zwei Kontext-Modi: +- diary: Phantom-Original-URL (Datei existiert nicht) + echtes _preview.webp bzw. + _thumb.jpg daneben. Original-Request â 404 + {"storage":"icloud"} + (serve_media in main.py), Web fĂ€llt via onerror aufs Preview zurĂŒck. +- route: Das 800px-Preview wird ALS Datei unter der foto_url gespeichert â + Routen-Fotos haben keine Preview-Konvention, Web bleibt unverĂ€ndert. + +Geteilte Inhalte (GassiTreffen-Fotos, Forum, Challenges) bleiben bewusst komplett +auf dem Server â fremde Nutzer können nicht in eine private iCloud schauen. + +Quota-Fallback: Ist die iCloud des Nutzers voll (CKError.quotaExceeded), lĂ€dt die +App das Original klassisch ĂŒber POST /api/media/{id}/original nach â storage='server'. +""" +import asyncio +import json +import os +import uuid + +from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile +from pydantic import BaseModel + +from auth import get_current_user +from database import db +from media_utils import ( + convert_media, generate_preview, preview_url_from, safe_media_path, validate_upload, +) + +router = APIRouter() + +MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media") + +_PREVIEW_MAX_BYTES = 2 * 1024 * 1024 # Previews/Poster sind â€800px-JPEGs +_CONTEXTS = {"diary", "route"} +_SUBDIR = {"diary": "diary", "route": "routes"} + + +def _own_diary_entry(conn, entry_id: int, user_id: int): + """Eintrag gehört dem User (direkt ĂŒber dog_id oder via diary_dogs).""" + return conn.execute( + """SELECT d.id FROM diary d + WHERE d.id=? AND ( + d.dog_id IN (SELECT id FROM dogs WHERE user_id=?) + OR EXISTS (SELECT 1 FROM diary_dogs dd + JOIN dogs g ON g.id = dd.dog_id + WHERE dd.diary_id = d.id AND g.user_id=?))""", + (entry_id, user_id, user_id) + ).fetchone() + + +def _own_registry_row(conn, media_id: int, user_id: int): + row = conn.execute( + "SELECT * FROM media_registry WHERE id=? AND user_id=?", (media_id, user_id) + ).fetchone() + if not row: + raise HTTPException(404, "Medium nicht gefunden.") + return row + + +def _write_bytes(path: str, data: bytes) -> None: + os.makedirs(os.path.dirname(path), exist_ok=True) + with open(path, "wb") as f: + f.write(data) + + +@router.post("/register", status_code=201) +async def register_media( + context: str = Form(...), + context_id: int = Form(...), + kind: str = Form("image"), + img_width: int | None = Form(None), + img_height: int | None = Form(None), + gps_lat: float | None = Form(None), + gps_lon: float | None = Form(None), + bytes_original: int | None = Form(None), + preview: UploadFile = File(...), + user=Depends(get_current_user), +): + """Registriert ein iCloud-Medium: Metadaten + Preview zum Server, Original â CloudKit.""" + if context not in _CONTEXTS: + raise HTTPException(422, "Unbekannter Kontext.") + if kind not in ("image", "video"): + raise HTTPException(422, "kind muss image oder video sein.") + if context == "route" and kind != "image": + raise HTTPException(422, "Routen-Fotos sind Bilder.") + + raw = await preview.read() + if len(raw) > _PREVIEW_MAX_BYTES: + raise HTTPException(413, "Preview zu groĂ (max 2 MB).") + try: + validate_upload(raw, preview.filename or "preview.jpg") + except ValueError as e: + raise HTTPException(415, str(e)) + prev_ext = os.path.splitext(preview.filename or "")[1].lower() or ".jpg" + + with db() as conn: + if context == "diary": + if not _own_diary_entry(conn, context_id, user["id"]): + raise HTTPException(404, "Eintrag nicht gefunden.") + else: + row = conn.execute( + "SELECT user_id FROM routes WHERE id=?", (context_id,) + ).fetchone() + if not row: + raise HTTPException(404, "Route nicht gefunden.") + if row["user_id"] != user["id"]: + raise HTTPException(403, "Nicht berechtigt.") + + orig_ext = ".jpg" if kind == "image" else ".mp4" + filename = f"{context}_{context_id}_{uuid.uuid4().hex[:8]}{orig_ext}" + subdir = _SUBDIR[context] + url = f"/media/{subdir}/{filename}" + disk = os.path.join(MEDIA_DIR, subdir, filename) + base = os.path.splitext(disk)[0] + + loop = asyncio.get_event_loop() + if context == "diary": + if kind == "image": + webp = await loop.run_in_executor(None, lambda: generate_preview(raw, prev_ext)) + if not webp: + raise HTTPException(415, "Preview konnte nicht verarbeitet werden.") + await loop.run_in_executor(None, lambda: _write_bytes(base + "_preview.webp", webp)) + else: + # Poster-Frame des Videos â gleiche Konvention wie extract_video_thumb() + await loop.run_in_executor(None, lambda: _write_bytes(base + "_thumb.jpg", raw)) + else: + # route: das Preview wird die Datei selbst (Web kennt dort keine Previews) + await loop.run_in_executor(None, lambda: _write_bytes(disk, raw)) + + with db() as conn: + cur = conn.execute( + "INSERT INTO media_registry (user_id, url, storage, ck_state, kind, context, context_id, bytes_original) " + "VALUES (?,?,?,?,?,?,?,?)", + (user["id"], url, "icloud", "pending", kind, context, context_id, bytes_original) + ) + rid = cur.lastrowid + ck_name = f"media-{rid}" + conn.execute("UPDATE media_registry SET ck_record_name=? WHERE id=?", (ck_name, rid)) + + if context == "diary": + max_order = conn.execute( + "SELECT COALESCE(MAX(sort_order), -1) FROM diary_media WHERE diary_id=?", + (context_id,) + ).fetchone()[0] + # Erstes Item eines Eintrags wird automatisch Cover (wie diary.upload_media) + is_cover = 1 if max_order == -1 else 0 + cur2 = conn.execute( + "INSERT INTO diary_media (diary_id, url, media_type, sort_order, is_cover, img_width, img_height) " + "VALUES (?,?,?,?,?,?,?)", + (context_id, url, kind, max_order + 1, is_cover, img_width, img_height) + ) + dm_id = cur2.lastrowid + # GPS vom GerĂ€t â wie der EXIF-Pfad in diary.upload_media, aber ohne + # Wetter/POI-Nachladen (iOS-EintrĂ€ge bringen i.d.R. Track-GPS mit). + if gps_lat is not None and gps_lon is not None: + existing = conn.execute( + "SELECT gps_lat FROM diary WHERE id=?", (context_id,) + ).fetchone() + if existing and existing["gps_lat"] is None: + conn.execute( + "UPDATE diary SET gps_lat=?, gps_lon=? WHERE id=?", + (gps_lat, gps_lon, context_id) + ) + return {"id": dm_id, "url": url, "preview_url": preview_url_from(url), + "media_type": kind, "sort_order": max_order + 1, + "is_cover": is_cover, "media_id": rid, "ck_record_name": ck_name} + + # route: foto_urls-Append wie routen.add_route_photo + row = conn.execute("SELECT foto_urls FROM routes WHERE id=?", (context_id,)).fetchone() + urls = json.loads(dict(row)["foto_urls"] or "[]") + urls.append(url) + conn.execute("UPDATE routes SET foto_urls=? WHERE id=?", (json.dumps(urls), context_id)) + return {"foto_url": url, "foto_urls": urls, "media_id": rid, "ck_record_name": ck_name} + + +@router.get("/mine") +async def my_media(user=Depends(get_current_user)): + """Sync-Grundlage der App: sie gleicht ihre CloudKit-Zone gegen diese Liste ab + und löscht verwaiste CKRecords (Web-Deletes kennt CloudKit sonst nicht).""" + with db() as conn: + rows = conn.execute( + "SELECT id, url, storage, ck_record_name, ck_state, kind, context, context_id, created_at " + "FROM media_registry WHERE user_id=? ORDER BY id", (user["id"],) + ).fetchall() + return [dict(r) for r in rows] + + +class ConfirmBody(BaseModel): + ck_record_name: str | None = None + + +@router.patch("/{media_id}") +async def confirm_media(media_id: int, body: ConfirmBody, user=Depends(get_current_user)): + """BestĂ€tigt den erfolgreichen CloudKit-Upload des Originals.""" + with db() as conn: + row = _own_registry_row(conn, media_id, user["id"]) + ck_name = body.ck_record_name or row["ck_record_name"] + conn.execute( + "UPDATE media_registry SET ck_state='confirmed', ck_record_name=? WHERE id=?", + (ck_name, media_id) + ) + return {"id": media_id, "storage": "icloud", "ck_state": "confirmed", + "ck_record_name": ck_name} + + +@router.post("/{media_id}/original", status_code=201) +async def upload_original(media_id: int, file: UploadFile = File(...), + user=Depends(get_current_user)): + """Quota-Fallback: iCloud des Nutzers voll â Original klassisch zum Server. + Schreibt die Datei an die registrierte URL (diary: fĂŒllt die Phantom-URL, + route: ersetzt das Preview durch das Original).""" + with db() as conn: + row = _own_registry_row(conn, media_id, user["id"]) + + raw = await file.read() + try: + validate_upload(raw, file.filename or "") + except ValueError as e: + raise HTTPException(415, str(e)) + + loop = asyncio.get_event_loop() + raw, _ext = await loop.run_in_executor( + None, lambda: convert_media(raw, file.filename or "") + ) + + disk = safe_media_path(MEDIA_DIR, row["url"]) + if not disk: + raise HTTPException(400, "UngĂŒltiger Medienpfad.") + await loop.run_in_executor(None, lambda: _write_bytes(disk, raw)) + + with db() as conn: + conn.execute( + "UPDATE media_registry SET storage='server', ck_state='none' WHERE id=?", + (media_id,) + ) + return {"id": media_id, "storage": "server", "url": row["url"]} diff --git a/backend/routes/notes.py b/backend/routes/notes.py index 5a70c2b..f7dac31 100644 --- a/backend/routes/notes.py +++ b/backend/routes/notes.py @@ -1,24 +1,33 @@ """BAN YARO â Notizen Routes""" +import os import json +import uuid +import asyncio import logging from datetime import datetime -from fastapi import APIRouter, Depends, HTTPException, Query +from fastapi import APIRouter, Depends, HTTPException, Query, UploadFile, File from pydantic import BaseModel, Field from typing import Optional, Any, List from database import db from auth import get_current_user from timeutils import safe_client_time +from media_utils import (convert_media, extract_video_thumb, safe_media_path, + validate_upload, validate_audio, to_m4a, + generate_preview, get_image_size) router = APIRouter() logger = logging.getLogger(__name__) +MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media") # ------------------------------------------------------------------ # Schemas # ------------------------------------------------------------------ class NoteCreate(BaseModel): - text: str = Field(..., min_length=1, max_length=5000) + # Leerer Text erlaubt: eine reine Medien-Notiz (nur Foto/Sprachnachricht) + # wird zuerst leer angelegt, dann werden die Medien angehĂ€ngt. + text: str = Field("", max_length=5000) meta_json: Optional[Any] = None location_name: Optional[str] = Field(None, max_length=300) parent_label: Optional[str] = Field(None, max_length=200) @@ -35,16 +44,81 @@ class NoteUpdate(BaseModel): # ------------------------------------------------------------------ # Hilfsfunktionen # ------------------------------------------------------------------ -def _serialize(row) -> dict: +def _serialize(row, media_map: Optional[dict] = None) -> dict: d = dict(row) if d.get("meta_json") and isinstance(d["meta_json"], str): try: d["meta_json"] = json.loads(d["meta_json"]) except Exception: pass + if media_map is not None: + d["media_items"] = media_map.get(d["id"], []) return d +def _fetch_note_media(conn, note_ids: list) -> dict: + """LĂ€dt alle Medien zu den gegebenen Notiz-IDs als {note_id: [items]}.""" + if not note_ids: + return {} + placeholders = ",".join("?" * len(note_ids)) + rows = conn.execute( + f"""SELECT id, note_id, url, media_type, sort_order, img_width, img_height, duration_s + FROM note_media WHERE note_id IN ({placeholders}) + ORDER BY sort_order, id""", + note_ids + ).fetchall() + out: dict = {} + for r in rows: + out.setdefault(r["note_id"], []).append(dict(r)) + return out + + +def _guess_note_media_type(content_type: str, filename: str) -> str: + ct = (content_type or "").lower() + if ct == "application/pdf" or (filename or "").lower().endswith(".pdf"): + return "pdf" + if ct.startswith("audio/"): + return "audio" + if ct.startswith("video/"): + return "video" + if ct.startswith("image/"): + return "image" + ext = os.path.splitext(filename or "")[1].lower() + if ext in {".mp4", ".mov", ".webm", ".m4v", ".avi"}: + return "video" + if ext in {".m4a", ".aac", ".mp3", ".ogg", ".oga", ".wav", ".opus"}: + return "audio" + if ext in {".jpg", ".jpeg", ".png", ".gif", ".webp", ".heic", ".heif"}: + return "image" + return "file" + + +def _delete_note_media_file(url: str) -> None: + """Löscht eine Mediendatei + zugehörige Preview/Thumb-Leichen von Disk.""" + file_path = safe_media_path(MEDIA_DIR, url) + if not file_path: + return + try: + os.remove(file_path) + except OSError: + pass + base = os.path.splitext(file_path)[0] + for leftover in (base + "_preview.webp", base + "_thumb.jpg"): + try: + os.remove(leftover) + except OSError: + pass + + +def _own_note(note_id: int, user_id: int, conn): + row = conn.execute( + "SELECT id FROM notes WHERE id=? AND user_id=?", (note_id, user_id) + ).fetchone() + if not row: + raise HTTPException(404, "Notiz nicht gefunden.") + return row + + # ------------------------------------------------------------------ # GET /api/notes â Gesamt-Notizblock mit Filtern # Alias: GET /api/notes/all/0 (RĂŒckwĂ€rtskompatibilitĂ€t) @@ -97,8 +171,9 @@ async def list_all_notes_filtered( f"SELECT * FROM notes WHERE {where} ORDER BY {order}", params ).fetchall() + media_map = _fetch_note_media(conn, [r["id"] for r in rows]) - return [_serialize(r) for r in rows] + return [_serialize(r, media_map) for r in rows] @router.get("/all/0") @@ -109,7 +184,8 @@ async def list_all_notes(user=Depends(get_current_user)): "SELECT * FROM notes WHERE user_id=? ORDER BY created_at DESC", (user["id"],) ).fetchall() - return [_serialize(r) for r in rows] + media_map = _fetch_note_media(conn, [r["id"] for r in rows]) + return [_serialize(r, media_map) for r in rows] # ------------------------------------------------------------------ @@ -169,6 +245,99 @@ async def ki_analyse(user=Depends(get_current_user)): return {"suggestions": suggestions, "note_count": note_count} +# ------------------------------------------------------------------ +# Medien-AnhĂ€nge an Notizen (Bild/Video/Audio/Datei) +# WICHTIG: Diese Routen MĂSSEN vor /{parent_type}/{parent_id} stehen, +# sonst matcht POST /123/media als parent_type=123, parent_id="media"! +# ------------------------------------------------------------------ +@router.post("/{note_id}/media") +async def upload_note_media(note_id: int, + file: UploadFile = File(...), + user=Depends(get_current_user)): + with db() as conn: + _own_note(note_id, user["id"], conn) + + ct = (file.content_type or "").lower().split(";")[0].strip() + raw_data = await file.read() + loop = asyncio.get_event_loop() + + if ct.startswith("audio/"): + media_type = "audio" + try: + validate_audio(raw_data, ct) + except ValueError as e: + raise HTTPException(415, str(e)) + src_ext = os.path.splitext(file.filename or "")[1].lower() or ".webm" + raw_data, ext = await loop.run_in_executor(None, lambda: to_m4a(raw_data, src_ext)) + else: + # Bild/Video/PDF/sonstige Datei â gleiche Pipeline wie Tagebuch. + try: + validate_upload(raw_data, file.filename or "") + except ValueError as e: + raise HTTPException(415, str(e)) + media_type = _guess_note_media_type(ct, file.filename or "") + raw_data, ext = await loop.run_in_executor( + None, lambda: convert_media(raw_data, file.filename or "") + ) + if not ext: + ext = ".bin" + + filename = f"note_{note_id}_{uuid.uuid4().hex[:8]}{ext}" + path = os.path.join(MEDIA_DIR, "notes", filename) + os.makedirs(os.path.dirname(path), exist_ok=True) + + def _write_bytes(p: str, data: bytes) -> None: + with open(p, "wb") as f: + f.write(data) + + await loop.run_in_executor(None, lambda: _write_bytes(path, raw_data)) + + img_size = None + if media_type == "video": + await loop.run_in_executor(None, lambda: extract_video_thumb(path)) + elif media_type == "image": + preview_bytes = await loop.run_in_executor(None, lambda: generate_preview(raw_data, ext)) + if preview_bytes: + preview_path = os.path.splitext(path)[0] + "_preview.webp" + await loop.run_in_executor(None, lambda: _write_bytes(preview_path, preview_bytes)) + img_size = await loop.run_in_executor(None, lambda: get_image_size(raw_data)) + + media_url = f"/media/notes/{filename}" + with db() as conn: + max_order = conn.execute( + "SELECT COALESCE(MAX(sort_order), -1) FROM note_media WHERE note_id=?", + (note_id,) + ).fetchone()[0] + conn.execute( + """INSERT INTO note_media (note_id, url, media_type, sort_order, img_width, img_height) + VALUES (?,?,?,?,?,?)""", + (note_id, media_url, media_type, max_order + 1, + img_size[0] if img_size else None, img_size[1] if img_size else None) + ) + row = conn.execute( + """SELECT id, note_id, url, media_type, sort_order, img_width, img_height, duration_s + FROM note_media WHERE note_id=? ORDER BY id DESC LIMIT 1""", + (note_id,) + ).fetchone() + return dict(row) + + +@router.delete("/{note_id}/media/{media_id}", status_code=204) +async def delete_note_media(note_id: int, media_id: int, + user=Depends(get_current_user)): + with db() as conn: + _own_note(note_id, user["id"], conn) + row = conn.execute( + "SELECT id, url FROM note_media WHERE id=? AND note_id=?", + (media_id, note_id) + ).fetchone() + if not row: + raise HTTPException(404, "Medium nicht gefunden.") + _delete_note_media_file(row["url"]) + conn.execute("DELETE FROM note_media WHERE id=?", (media_id,)) + return None + + # ------------------------------------------------------------------ # GET /api/notes/{parent_type}/{parent_id} # parent_id kann ein Integer oder ein String-SchlĂŒssel sein. @@ -184,7 +353,8 @@ async def list_notes(parent_type: str, parent_id: str, ORDER BY created_at DESC""", (user["id"], parent_type, parent_id) ).fetchall() - return [_serialize(r) for r in rows] + media_map = _fetch_note_media(conn, [r["id"] for r in rows]) + return [_serialize(r, media_map) for r in rows] # ------------------------------------------------------------------ @@ -193,9 +363,6 @@ async def list_notes(parent_type: str, parent_id: str, @router.post("/{parent_type}/{parent_id}", status_code=201) async def create_note(parent_type: str, parent_id: str, data: NoteCreate, user=Depends(get_current_user)): - if not data.text.strip(): - raise HTTPException(400, "Notiz darf nicht leer sein.") - meta_str = json.dumps(data.meta_json) if data.meta_json is not None else None now = safe_client_time(data.client_time) @@ -212,7 +379,7 @@ async def create_note(parent_type: str, parent_id: str, data: NoteCreate, "SELECT * FROM notes WHERE user_id=? AND parent_type=? AND parent_id=? ORDER BY id DESC LIMIT 1", (user["id"], parent_type, parent_id) ).fetchone() - return _serialize(row) + return _serialize(row, {}) # frisch erstellt â media_items=[]; Upload folgt separat # ------------------------------------------------------------------ @@ -230,8 +397,7 @@ async def update_note(note_id: int, data: NoteUpdate, updates = {} if data.text is not None: - if not data.text.strip(): - raise HTTPException(400, "Notiz darf nicht leer sein.") + # Leer erlaubt â Medien können die Notiz tragen. updates["text"] = data.text.strip() if data.meta_json is not None: updates["meta_json"] = json.dumps(data.meta_json) @@ -241,14 +407,16 @@ async def update_note(note_id: int, data: NoteUpdate, updates["parent_label"] = data.parent_label if not updates: - return _serialize(note) + media_map = _fetch_note_media(conn, [note_id]) + return _serialize(note, media_map) updates["updated_at"] = datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S") set_clause = ", ".join(f"{k}=?" for k in updates) values = list(updates.values()) + [note_id] conn.execute(f"UPDATE notes SET {set_clause} WHERE id=?", values) row = conn.execute("SELECT * FROM notes WHERE id=?", (note_id,)).fetchone() - return _serialize(row) + media_map = _fetch_note_media(conn, [note_id]) + return _serialize(row, media_map) # ------------------------------------------------------------------ @@ -262,5 +430,8 @@ async def delete_note(note_id: int, user=Depends(get_current_user)): ).fetchone() if not note: raise HTTPException(404, "Notiz nicht gefunden.") + # Medien-Dateien von Disk rĂ€umen (FK-Cascade löscht nur die DB-Zeilen). + for m in conn.execute("SELECT url FROM note_media WHERE note_id=?", (note_id,)).fetchall(): + _delete_note_media_file(m["url"]) conn.execute("DELETE FROM notes WHERE id=?", (note_id,)) return None diff --git a/backend/routes/profile.py b/backend/routes/profile.py index f840ec8..bf16ce0 100644 --- a/backend/routes/profile.py +++ b/backend/routes/profile.py @@ -216,6 +216,16 @@ async def delete_account(user=Depends(get_current_user)): if col in cols and (tbl, col) not in handled_fk_cols and (tbl, col) not in _ACTOR_COLUMNS: conn.execute(f"DELETE FROM {tbl} WHERE {col}=?", (uid,)) + # note_media-Dateien von Disk rĂ€umen â der FK-Cascade beim users-DELETE + # entfernt nur die DB-Zeilen, nicht die Dateien. + import os as _os + from media_utils import delete_media_files + _note_media_urls = [r["url"] for r in conn.execute( + "SELECT nm.url FROM note_media nm JOIN notes n ON n.id = nm.note_id WHERE n.user_id=?", + (uid,) + ).fetchall()] + delete_media_files(_os.getenv("MEDIA_DIR", "/data/media"), _note_media_urls) + # RĂ€umt alle verbliebenen ON-DELETE-CASCADE-Tabellen automatisch ab. conn.execute("DELETE FROM users WHERE id=?", (uid,)) return {"status": "deleted"} diff --git a/backend/routes/routen.py b/backend/routes/routen.py index ee3bac7..6f7968b 100644 --- a/backend/routes/routen.py +++ b/backend/routes/routen.py @@ -480,6 +480,12 @@ async def delete_route(route_id: int, user=Depends(get_current_user)): raise HTTPException(404, "Route nicht gefunden.") if row['user_id'] != user['id']: raise HTTPException(403, "Nicht berechtigt.") + # iCloud-Hybrid: Registry-Rows der Routen-Fotos mitlöschen â der nĂ€chste + # App-Sync (GET /api/media/mine) rĂ€umt dann die verwaisten CKRecords ab. + conn.execute( + "DELETE FROM media_registry WHERE context='route' AND context_id=?", + (route_id,) + ) conn.execute("DELETE FROM routes WHERE id = ?", (route_id,)) diff --git a/backend/scheduler.py b/backend/scheduler.py index 8c8009c..c296701 100644 --- a/backend/scheduler.py +++ b/backend/scheduler.py @@ -989,6 +989,7 @@ async def _generate_praise_for_dog(dog: dict, user_id: int) -> str: from datetime import date, timedelta since = (date.today() - timedelta(days=7)).isoformat() + week_num = date.today().isocalendar()[1] name = dog["name"] rasse = dog.get("rasse") or "Hund" @@ -1029,16 +1030,30 @@ async def _generate_praise_for_dog(dog: dict, user_id: int) -> str: else: aktivitaet_text = ", ".join(aktivitaet_parts) + # W\u00f6chentlich rotierender Fokus \u2192 die KI klingt nicht jede Woche gleich. + _toene = [ + "Betone die enge Verbundenheit zwischen {name} und dir.", + "Hebe die kleinen Abenteuer und besonderen Momente hervor.", + "W\u00fcrdige die sch\u00f6ne gemeinsame Routine und Verl\u00e4sslichkeit.", + "Feiere das gemeinsame Wachsen und die Fortschritte.", + "Betone Ruhe, Geborgenheit und Vertrauen.", + "Schreibe verspielt und mit einem Augenzwinkern.", + ] + ton = _toene[week_num % len(_toene)].replace("{name}", name) + 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 + +Fokus dieser Woche: {ton} 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) +- Variiere Einstieg und Wortwahl \u2014 klinge NICHT wie letzte Woche +- Erw\u00e4hne KEINE Wochenzahl und keine nackten Statistik-Zahlen - Maximal 3 kurze S\u00e4tze - Warm, pers\u00f6nlich, keine Floskeln - Kein "Du solltest...", kein "Vergiss nicht...", keine Empfehlungen""" @@ -1051,11 +1066,24 @@ Regeln (unbedingt einhalten): ) 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" + # Fallback wenn KI nicht verfĂŒgbar â Varianten-Pool, deterministisch pro + # Woche+Hund gewĂ€hlt, damit der Text nicht jede Woche identisch klingt. + aktiv_varianten = [ + f"{name} hatte eine richtig aktive Woche â {aktivitaet_text}. Stark! đŸ", + f"Was fĂŒr eine Woche, {name}! {aktivitaet_text} â das kann sich sehen lassen. đ", + f"{name} war diese Woche voll dabei: {aktivitaet_text}. Weiter so! đ¶", + f"Tolle Woche mit {name} â {aktivitaet_text}. Ihr seid ein super Team! đŸ", + f"{aktivitaet_text} â dafĂŒr hat sich {name} eine extra Streicheleinheit verdient. âš", + ] + ruhig_varianten = [ + f"Auch ruhige Wochen gehören dazu. {name} weiĂ, dass du fĂŒr ihn da bist. đŸ", + f"Diese Woche war's gemĂŒtlich â und das ist völlig okay. {name} genieĂt die Zeit mit dir. đż", + f"Nicht jede Woche muss voll sein. {name} fĂŒhlt sich bei dir einfach wohl. âïž", + f"Eine entspannte Woche mit {name} â manchmal ist genau das das Schönste. đŸ", + f"{name} und du â auch ohne groĂes Programm seid ihr ein eingespieltes Team. đ¶", + ] + pool = aktiv_varianten if aktivitaet_parts else ruhig_varianten + return pool[(week_num + dog["id"]) % len(pool)] # ------------------------------------------------------------------ diff --git a/backend/scraper/breed_enricher.py b/backend/scraper/breed_enricher.py index 8aed584..9d0cb97 100644 --- a/backend/scraper/breed_enricher.py +++ b/backend/scraper/breed_enricher.py @@ -360,30 +360,47 @@ async def _fetch_wikimedia_photo(name: str) -> str | None: return None -async def _haiku_complete(prompt: str) -> str: - """Claude Haiku direkt aufrufen (immer Cloud, fĂŒr maximale Genauigkeit).""" - import anthropic +async def _haiku_complete(prompt: str) -> tuple[str, str]: + """ + Fakten-Extraktion. Bevorzugt Claude Haiku (gĂŒnstig + genau); ist kein + Cloud-Key gesetzt oder die Cloud nicht erreichbar, fĂ€llt es sauber auf das + lokale Modell (LM Studio) zurĂŒck, statt hart abzubrechen. + Returns (text, model) â model flieĂt in wiki_rassen.ki_model, damit der + Evaluator lokal-angereicherte Rassen weiterhin zur QC erkennt. + """ key = os.getenv("ANTHROPIC_API_KEY", "") - if not key: - raise RuntimeError("ANTHROPIC_API_KEY nicht gesetzt") - def _call(): - client = anthropic.Anthropic(api_key=key) - return client.messages.create( - model=_HAIKU_MODEL, - max_tokens=700, - system=[{ - "type": "text", - "text": _SYSTEM, - "cache_control": {"type": "ephemeral"}, - }], - messages=[{"role": "user", "content": prompt}], - ) + # 1. Bevorzugt: Claude Haiku direkt (gĂŒnstigstes Cloud-Modell) + if key: + try: + import anthropic - loop = asyncio.get_event_loop() - resp = await loop.run_in_executor(None, _call) - return resp.content[0].text.strip() + def _call(): + client = anthropic.Anthropic(api_key=key) + return client.messages.create( + model=_HAIKU_MODEL, + max_tokens=700, + system=[{ + "type": "text", + "text": _SYSTEM, + "cache_control": {"type": "ephemeral"}, + }], + messages=[{"role": "user", "content": prompt}], + ) + + loop = asyncio.get_event_loop() + resp = await loop.run_in_executor(None, _call) + return resp.content[0].text.strip(), _HAIKU_MODEL + except Exception as e: + logger.warning("Haiku (Cloud) nicht erreichbar, Fallback lokal: %s", e) + + # 2. Fallback: lokales Modell ĂŒber die zentrale KI-Abstraktion + import ki + if ki.KI_MODE == "off": + raise RuntimeError("Kein Cloud-Key und KI_MODE=off â Anreicherung nicht möglich.") + text = await ki._local_complete(prompt, _SYSTEM, max_tokens=700, json_mode=False) + return text, ki.LOCAL_MODEL async def _enrich_one(rasse, dry_run: bool = False) -> bool: @@ -411,12 +428,12 @@ async def _enrich_one(rasse, dry_run: bool = False) -> bool: logger.info("[DRY-RUN] Gefunden: %s (WP-%s, %d Zeichen)", name, wiki_lang.upper(), len(wiki_text)) return True - # 2. Haiku extrahiert Fakten aus dem Quelltext + # 2. KI extrahiert Fakten aus dem Quelltext (Haiku, sonst lokaler Fallback) prompt = _PROMPT.format(name=name, lang=wiki_lang.upper(), wiki_text=wiki_text) try: - raw = await _haiku_complete(prompt) + raw, used_model = await _haiku_complete(prompt) except Exception as e: - logger.error("Haiku-Anfrage fehlgeschlagen fĂŒr %s: %s", name, e) + logger.error("KI-Anfrage fehlgeschlagen fĂŒr %s: %s", name, e) await asyncio.sleep(3) return False @@ -435,7 +452,7 @@ async def _enrich_one(rasse, dry_run: bool = False) -> bool: if "temperament" in updates: updates["temperament"] = translate_temperament(updates["temperament"]) updates["ki_enriched"] = 1 - updates["ki_model"] = _HAIKU_MODEL + updates["ki_model"] = used_model updates["ki_source"] = f"wikipedia_{wiki_lang}" cols = ", ".join(f"{k}=?" for k in updates) diff --git a/backend/scraper/breed_evaluator.py b/backend/scraper/breed_evaluator.py index c78bbfd..30c352f 100644 --- a/backend/scraper/breed_evaluator.py +++ b/backend/scraper/breed_evaluator.py @@ -43,19 +43,23 @@ AktivitĂ€t zur Erfahrung)? ''' -async def evaluate_enrichment(sample_size: int = 20) -> dict: +async def evaluate_enrichment(sample_size: int = 20, user_id: int | None = None) -> dict: """ - Bewertet `sample_size` zufĂ€llig gewĂ€hlte angereicherte Rassen via Claude. + Bewertet `sample_size` zufĂ€llig gewĂ€hlte angereicherte Rassen als LLM-as-Judge. + + LĂ€uft ĂŒber die zentrale KI-Abstraktion (ki.complete). Admins/Moderatoren werden + dort Cloud-priorisiert (Claude); ist die Cloud nicht erreichbar, fĂ€llt die + Bewertung sauber auf das lokale Modell zurĂŒck, statt hart abzubrechen. Returns dict mit aggregierten Scores und Einzelergebnissen. """ import sys, os sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) from database import db + import ki - ANTHROPIC_KEY = os.getenv("ANTHROPIC_API_KEY", "") - if not ANTHROPIC_KEY: - raise RuntimeError("ANTHROPIC_API_KEY nicht gesetzt â Evaluierung benötigt Cloud.") + if ki.KI_MODE == "off": + raise RuntimeError("KI ist deaktiviert (KI_MODE=off) â Evaluierung nicht möglich.") with db() as conn: rassen = conn.execute( @@ -65,8 +69,7 @@ async def evaluate_enrichment(sample_size: int = 20) -> dict: wohnung_geeignet, temperament, ki_model FROM wiki_rassen WHERE ki_enriched = 1 - AND ki_model IS NOT NULL - AND ki_model NOT LIKE 'claude%' + AND (ki_model IS NULL OR ki_model NOT LIKE 'claude%') ORDER BY RANDOM() LIMIT ?""", (sample_size,), @@ -75,10 +78,10 @@ async def evaluate_enrichment(sample_size: int = 20) -> dict: if not rassen: return {"error": "Keine angereicherten Rassen gefunden."} - import anthropic - client = anthropic.Anthropic(api_key=ANTHROPIC_KEY) + _EVAL_SYSTEM = "Du bist ein prĂ€ziser QualitĂ€tsprĂŒfer. Antworte ausschlieĂlich als JSON." results = [] + sources = set() totals = {"vollstaendigkeit": 0, "korrektheit": 0, "sprachqualitaet": 0, "konsistenz": 0, "gesamt": 0} @@ -102,22 +105,17 @@ async def evaluate_enrichment(sample_size: int = 20) -> dict: data=json.dumps(data, ensure_ascii=False, indent=2), ) try: - def _call(): - return client.messages.create( - model="claude-haiku-4-5-20251001", - max_tokens=256, - system=[{ - "type": "text", - "text": "Du bist ein prĂ€ziser QualitĂ€tsprĂŒfer. Antworte ausschlieĂlich als JSON.", - "cache_control": {"type": "ephemeral"}, - }], - messages=[{"role": "user", "content": prompt}], - ) - loop = asyncio.get_event_loop() - resp = await loop.run_in_executor(None, _call) - raw = resp.content[0].text.strip() + raw, source = await ki.complete( + prompt, + system=_EVAL_SYSTEM, + max_tokens=256, + json_mode=True, + user_id=user_id, + return_source=True, + ) + sources.add(source) - # JSON extrahieren + # JSON extrahieren (lokale Modelle wrappen gern in ```json ⊠```) import re match = re.search(r"\{[\s\S]+\}", raw) scores = json.loads(match.group(0)) if match else {} @@ -136,9 +134,12 @@ async def evaluate_enrichment(sample_size: int = 20) -> dict: count = len([r for r in results if "error" not in r]) averages = {k: round(v / count, 2) for k, v in totals.items()} if count else {} + judge_source = "/".join(sorted(sources)) if sources else "unbekannt" + return { "sample_size": len(rassen), "evaluated": count, "averages": averages, + "judge_source": judge_source, # "cloud" (Claude) oder "local" (LM Studio) "results": results, } diff --git a/backend/static/css/components.css b/backend/static/css/components.css index 8d537f5..a96b2ab 100644 --- a/backend/static/css/components.css +++ b/backend/static/css/components.css @@ -3088,12 +3088,23 @@ html.modal-open { } .rdr-play svg { width: 14px; height: 14px; } .rdr-play:active { background: var(--c-border); } -.rdr-slider { flex: 1; min-width: 0; height: 4px; accent-color: var(--c-primary); cursor: pointer; } +.rdr-track-wrap { position: relative; flex: 1; min-width: 0; display: flex; align-items: center; } +.rdr-slider { width: 100%; min-width: 0; height: 4px; accent-color: var(--c-primary); cursor: pointer; } +/* "Jetzt"-Markierung in der Mitte der Zeitleiste (Fangpunkt) */ +.rdr-now-tick { + position: absolute; left: 50%; top: 50%; + transform: translate(-50%, -50%); + width: 2px; height: 13px; + background: var(--c-primary); opacity: 0.45; + border-radius: 1px; pointer-events: none; z-index: 1; +} .rdr-time { flex-shrink: 0; font-size: 11px; font-weight: 600; font-variant-numeric: tabular-nums; - min-width: 74px; text-align: right; color: var(--c-text-secondary); + width: 112px; /* FESTE Breite â Regler bleibt immer gleich lang */ + white-space: nowrap; overflow: hidden; + text-align: right; color: var(--c-text-secondary); } .rdr-time.is-forecast { color: var(--c-primary); } /* Nowcast/Vorhersage-Frames hervorgehoben */ diff --git a/backend/static/datenschutz.html b/backend/static/datenschutz.html index 8d5a58c..31e2dab 100644 --- a/backend/static/datenschutz.html +++ b/backend/static/datenschutz.html @@ -85,7 +85,7 @@
- Stand: Juni 2026 · Version 4 + Stand: Juni 2026 · Version 6
diff --git a/backend/static/index.html b/backend/static/index.html index 385e4ae..830ff54 100644 --- a/backend/static/index.html +++ b/backend/static/index.html @@ -86,14 +86,14 @@${UI.escape(_truncate(note.text))}
+ ${note.text ? `${UI.escape(_truncate(note.text))}
` : ''} + + ${_noteMediaStrip(note)} ${microBadges.length ? ` @@ -495,7 +525,20 @@ window.Page_notes = (() => { ${note.parent_label ? `${UI.escape(note.text || '')}
+ ${note.text ? `${UI.escape(note.text)}
` : ''} + + ${(note.media_items && note.media_items.length) ? ` +