diff --git a/MARKETING.md b/MARKETING.md index 735a061..63fcbc0 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 | 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) | +| SEO / KI-Auffindbarkeit | đĄ technisch optimiert | 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,9 +41,7 @@ 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" 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] Landing-Promotion fĂŒr âBan Yaro Go" gebaut (Hero-Badge + iOS-Abschnitt) â 09.06., develop (URL-Platzhalter offen) - [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 7fc461a..4c7cefe 100644 --- a/Makefile +++ b/Makefile @@ -296,9 +296,7 @@ dev: KI_MODE=off ENV=development \ JWT_SECRET=dev-secret \ DB_PATH=./dev.db \ - 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} + uvicorn main:app --reload --port 8001 # ---------------------------------------------------------- # REPORTS â Quartalsberichte generieren und committen diff --git a/VERSION b/VERSION index 364aacb..d1e5ab4 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1292 \ No newline at end of file +1278 \ No newline at end of file diff --git a/backend/database.py b/backend/database.py index 4f3ce10..8065d48 100644 --- a/backend/database.py +++ b/backend/database.py @@ -1303,23 +1303,6 @@ 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, @@ -2690,44 +2673,6 @@ 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 7e258c8..188056f 100644 --- a/backend/ki.py +++ b/backend/ki.py @@ -25,7 +25,6 @@ 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 21a5069..22fe399 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=(self), geolocation=(self)" + response.headers["Permissions-Policy"] = "camera=(), microphone=(), geolocation=(self)" response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains" response.headers["Content-Security-Policy"] = ( "default-src 'self'; " @@ -114,7 +114,6 @@ 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'; " @@ -238,7 +237,6 @@ 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 @@ -308,7 +306,6 @@ 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"]) @@ -586,22 +583,6 @@ 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 6f70fa5..4fa6a82 100644 --- a/backend/media_utils.py +++ b/backend/media_utils.py @@ -6,9 +6,6 @@ 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 = [ @@ -54,34 +51,6 @@ 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. @@ -100,21 +69,6 @@ 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() @@ -168,44 +122,6 @@ 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 3155cd0..375cf3a 100644 --- a/backend/routes/admin.py +++ b/backend/routes/admin.py @@ -483,16 +483,6 @@ 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']})") @@ -738,7 +728,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, VISION_MODEL, ANTHROPIC_KEY + from ki import KI_MODE, LOCAL_BASE_URL, LOCAL_MODEL, CLOUD_MODEL, ANTHROPIC_KEY result = { "mode": KI_MODE, @@ -747,7 +737,6 @@ 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), } @@ -955,7 +944,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, user_id=user["id"]) + return await evaluate_enrichment(sample_size=sample) # ------------------------------------------------------------------ diff --git a/backend/routes/diary.py b/backend/routes/diary.py index c122e92..2dfcccb 100644 --- a/backend/routes/diary.py +++ b/backend/routes/diary.py @@ -122,14 +122,9 @@ 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 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", + 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", entry_ids ).fetchall() result = {} @@ -140,8 +135,6 @@ 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 @@ -641,9 +634,6 @@ 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) ) @@ -802,16 +792,9 @@ 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 a2a9bc3..5169634 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=ki_module.VISION_MODEL, + model="claude-opus-4-7", max_tokens=500, messages=[{ "role": "user", diff --git a/backend/routes/media.py b/backend/routes/media.py deleted file mode 100644 index 09e20d2..0000000 --- a/backend/routes/media.py +++ /dev/null @@ -1,243 +0,0 @@ -""" -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 f7dac31..5a70c2b 100644 --- a/backend/routes/notes.py +++ b/backend/routes/notes.py @@ -1,33 +1,24 @@ """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, UploadFile, File +from fastapi import APIRouter, Depends, HTTPException, Query 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): - # 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) + text: str = Field(..., min_length=1, 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) @@ -44,81 +35,16 @@ class NoteUpdate(BaseModel): # ------------------------------------------------------------------ # Hilfsfunktionen # ------------------------------------------------------------------ -def _serialize(row, media_map: Optional[dict] = None) -> dict: +def _serialize(row) -> 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) @@ -171,9 +97,8 @@ 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, media_map) for r in rows] + return [_serialize(r) for r in rows] @router.get("/all/0") @@ -184,8 +109,7 @@ async def list_all_notes(user=Depends(get_current_user)): "SELECT * FROM notes WHERE user_id=? ORDER BY created_at DESC", (user["id"],) ).fetchall() - media_map = _fetch_note_media(conn, [r["id"] for r in rows]) - return [_serialize(r, media_map) for r in rows] + return [_serialize(r) for r in rows] # ------------------------------------------------------------------ @@ -245,99 +169,6 @@ 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. @@ -353,8 +184,7 @@ async def list_notes(parent_type: str, parent_id: str, ORDER BY created_at DESC""", (user["id"], parent_type, parent_id) ).fetchall() - media_map = _fetch_note_media(conn, [r["id"] for r in rows]) - return [_serialize(r, media_map) for r in rows] + return [_serialize(r) for r in rows] # ------------------------------------------------------------------ @@ -363,6 +193,9 @@ 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) @@ -379,7 +212,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, {}) # frisch erstellt â media_items=[]; Upload folgt separat + return _serialize(row) # ------------------------------------------------------------------ @@ -397,7 +230,8 @@ async def update_note(note_id: int, data: NoteUpdate, updates = {} if data.text is not None: - # Leer erlaubt â Medien können die Notiz tragen. + if not data.text.strip(): + raise HTTPException(400, "Notiz darf nicht leer sein.") updates["text"] = data.text.strip() if data.meta_json is not None: updates["meta_json"] = json.dumps(data.meta_json) @@ -407,16 +241,14 @@ async def update_note(note_id: int, data: NoteUpdate, updates["parent_label"] = data.parent_label if not updates: - media_map = _fetch_note_media(conn, [note_id]) - return _serialize(note, media_map) + return _serialize(note) 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() - media_map = _fetch_note_media(conn, [note_id]) - return _serialize(row, media_map) + return _serialize(row) # ------------------------------------------------------------------ @@ -430,8 +262,5 @@ 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 bf16ce0..f840ec8 100644 --- a/backend/routes/profile.py +++ b/backend/routes/profile.py @@ -216,16 +216,6 @@ 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 6f7968b..ee3bac7 100644 --- a/backend/routes/routen.py +++ b/backend/routes/routen.py @@ -480,12 +480,6 @@ 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 c296701..8c8009c 100644 --- a/backend/scheduler.py +++ b/backend/scheduler.py @@ -989,7 +989,6 @@ 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" @@ -1030,30 +1029,16 @@ 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} - -Fokus dieser Woche: {ton} +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) -- 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""" @@ -1066,24 +1051,11 @@ Regeln (unbedingt einhalten): ) return text.strip() except Exception: - # 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)] + # 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" # ------------------------------------------------------------------ diff --git a/backend/scraper/breed_enricher.py b/backend/scraper/breed_enricher.py index 9d0cb97..8aed584 100644 --- a/backend/scraper/breed_enricher.py +++ b/backend/scraper/breed_enricher.py @@ -360,47 +360,30 @@ async def _fetch_wikimedia_photo(name: str) -> str | None: return None -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. +async def _haiku_complete(prompt: str) -> str: + """Claude Haiku direkt aufrufen (immer Cloud, fĂŒr maximale Genauigkeit).""" + import anthropic - 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") - # 1. Bevorzugt: Claude Haiku direkt (gĂŒnstigstes Cloud-Modell) - if key: - try: - import anthropic + 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}], + ) - 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 + loop = asyncio.get_event_loop() + resp = await loop.run_in_executor(None, _call) + return resp.content[0].text.strip() async def _enrich_one(rasse, dry_run: bool = False) -> bool: @@ -428,12 +411,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. KI extrahiert Fakten aus dem Quelltext (Haiku, sonst lokaler Fallback) + # 2. Haiku extrahiert Fakten aus dem Quelltext prompt = _PROMPT.format(name=name, lang=wiki_lang.upper(), wiki_text=wiki_text) try: - raw, used_model = await _haiku_complete(prompt) + raw = await _haiku_complete(prompt) except Exception as e: - logger.error("KI-Anfrage fehlgeschlagen fĂŒr %s: %s", name, e) + logger.error("Haiku-Anfrage fehlgeschlagen fĂŒr %s: %s", name, e) await asyncio.sleep(3) return False @@ -452,7 +435,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"] = used_model + updates["ki_model"] = _HAIKU_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 30c352f..c78bbfd 100644 --- a/backend/scraper/breed_evaluator.py +++ b/backend/scraper/breed_evaluator.py @@ -43,23 +43,19 @@ AktivitĂ€t zur Erfahrung)? ''' -async def evaluate_enrichment(sample_size: int = 20, user_id: int | None = None) -> dict: +async def evaluate_enrichment(sample_size: int = 20) -> dict: """ - 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. + Bewertet `sample_size` zufĂ€llig gewĂ€hlte angereicherte Rassen via Claude. 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 - if ki.KI_MODE == "off": - raise RuntimeError("KI ist deaktiviert (KI_MODE=off) â Evaluierung nicht möglich.") + ANTHROPIC_KEY = os.getenv("ANTHROPIC_API_KEY", "") + if not ANTHROPIC_KEY: + raise RuntimeError("ANTHROPIC_API_KEY nicht gesetzt â Evaluierung benötigt Cloud.") with db() as conn: rassen = conn.execute( @@ -69,7 +65,8 @@ async def evaluate_enrichment(sample_size: int = 20, user_id: int | None = None) wohnung_geeignet, temperament, ki_model FROM wiki_rassen WHERE ki_enriched = 1 - AND (ki_model IS NULL OR ki_model NOT LIKE 'claude%') + AND ki_model IS NOT NULL + AND ki_model NOT LIKE 'claude%' ORDER BY RANDOM() LIMIT ?""", (sample_size,), @@ -78,10 +75,10 @@ async def evaluate_enrichment(sample_size: int = 20, user_id: int | None = None) if not rassen: return {"error": "Keine angereicherten Rassen gefunden."} - _EVAL_SYSTEM = "Du bist ein prĂ€ziser QualitĂ€tsprĂŒfer. Antworte ausschlieĂlich als JSON." + import anthropic + client = anthropic.Anthropic(api_key=ANTHROPIC_KEY) results = [] - sources = set() totals = {"vollstaendigkeit": 0, "korrektheit": 0, "sprachqualitaet": 0, "konsistenz": 0, "gesamt": 0} @@ -105,17 +102,22 @@ async def evaluate_enrichment(sample_size: int = 20, user_id: int | None = None) data=json.dumps(data, ensure_ascii=False, indent=2), ) try: - 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) + 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() - # JSON extrahieren (lokale Modelle wrappen gern in ```json ⊠```) + # JSON extrahieren import re match = re.search(r"\{[\s\S]+\}", raw) scores = json.loads(match.group(0)) if match else {} @@ -134,12 +136,9 @@ async def evaluate_enrichment(sample_size: int = 20, user_id: int | None = None) 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 a96b2ab..8d537f5 100644 --- a/backend/static/css/components.css +++ b/backend/static/css/components.css @@ -3088,23 +3088,12 @@ html.modal-open { } .rdr-play svg { width: 14px; height: 14px; } .rdr-play:active { background: var(--c-border); } -.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-slider { flex: 1; min-width: 0; height: 4px; accent-color: var(--c-primary); cursor: pointer; } .rdr-time { flex-shrink: 0; font-size: 11px; font-weight: 600; font-variant-numeric: tabular-nums; - width: 112px; /* FESTE Breite â Regler bleibt immer gleich lang */ - white-space: nowrap; overflow: hidden; - text-align: right; color: var(--c-text-secondary); + min-width: 74px; 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 31e2dab..8d5a58c 100644 --- a/backend/static/datenschutz.html +++ b/backend/static/datenschutz.html @@ -85,7 +85,7 @@
- Stand: Juni 2026 · Version 6 + Stand: Juni 2026 · Version 4
diff --git a/backend/static/index.html b/backend/static/index.html index 830ff54..385e4ae 100644 --- a/backend/static/index.html +++ b/backend/static/index.html @@ -86,14 +86,14 @@${UI.escape(_truncate(note.text))}
` : ''} - - ${_noteMediaStrip(note)} +${UI.escape(_truncate(note.text))}
${microBadges.length ? ` @@ -525,20 +495,7 @@ window.Page_notes = (() => { ${note.parent_label ? `${UI.escape(note.text)}
` : ''} - - ${(note.media_items && note.media_items.length) ? ` -${UI.escape(note.text || '')}
${microBadges.length ? `