From 0df6d569c147da1fccdf5259f3f78848c2a797c6 Mon Sep 17 00:00:00 2001 From: rene Date: Fri, 24 Apr 2026 19:13:30 +0200 Subject: [PATCH] Social Media Manager: Route, DB, KI-Prompts, Frontend, Rolle; SW by-v338 --- backend/auth.py | 7 + backend/database.py | 32 +++ backend/main.py | 2 + backend/routes/admin.py | 9 +- backend/routes/social.py | 327 +++++++++++++++++++++++++ backend/static/index.html | 9 + backend/static/js/app.js | 8 +- backend/static/js/pages/social.js | 394 ++++++++++++++++++++++++++++++ backend/static/sw.js | 2 +- 9 files changed, 784 insertions(+), 6 deletions(-) create mode 100644 backend/routes/social.py create mode 100644 backend/static/js/pages/social.js diff --git a/backend/auth.py b/backend/auth.py index 2b60a52..9e01700 100644 --- a/backend/auth.py +++ b/backend/auth.py @@ -128,3 +128,10 @@ def require_admin(user=Depends(get_current_user)): if user["rolle"] != "admin": raise HTTPException(status.HTTP_403_FORBIDDEN, "Kein Zugriff.") return user + + +def require_social_media(user=Depends(get_current_user)): + """Dependency: Social-Media-Manager oder Admin.""" + if not (user.get("is_social_media") or user["rolle"] == "admin"): + raise HTTPException(status.HTTP_403_FORBIDDEN, "Kein Zugriff.") + return user diff --git a/backend/database.py b/backend/database.py index 0df593c..ba76919 100644 --- a/backend/database.py +++ b/backend/database.py @@ -522,6 +522,8 @@ def _migrate(conn_factory): ("users", "current_streak", "INTEGER NOT NULL DEFAULT 0"), ("users", "max_streak", "INTEGER NOT NULL DEFAULT 0"), ("users", "last_activity_date","TEXT"), + # Social Media Manager + ("users", "is_social_media", "INTEGER NOT NULL DEFAULT 0"), ] with conn_factory() as conn: for table, column, col_type in migrations: @@ -535,6 +537,36 @@ def _migrate(conn_factory): ) logger.info(f"Migration: {table}.{column} hinzugefügt.") + # Social Media Manager + conn.executescript(""" + CREATE TABLE IF NOT EXISTS social_content ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + created_by INTEGER REFERENCES users(id) ON DELETE SET NULL, + platform TEXT NOT NULL DEFAULT 'both', + format TEXT NOT NULL DEFAULT 'post', + topic TEXT NOT NULL, + caption TEXT, + hashtags TEXT, + visual_brief TEXT, + image_prompt TEXT, + canva_notes TEXT, + script TEXT, + hook TEXT, + cta TEXT, + unsplash_query TEXT, + ai_score INTEGER, + status TEXT NOT NULL DEFAULT 'idea', + scheduled_at TEXT, + published_at TEXT, + source TEXT NOT NULL DEFAULT 'generated', + breed_id INTEGER REFERENCES wiki_rassen(id) ON DELETE SET NULL, + notes TEXT + ); + CREATE INDEX IF NOT EXISTS idx_social_content_status + ON social_content(status); + """) + # Knigge: Community-Votes conn.executescript(""" CREATE TABLE IF NOT EXISTS knigge_votes ( diff --git a/backend/main.py b/backend/main.py index 13b5d3d..2865269 100644 --- a/backend/main.py +++ b/backend/main.py @@ -120,6 +120,7 @@ from routes.achievements import router as achievements_router from routes.training import router as training_router from routes.praise import router as praise_router from routes.weather import router as weather_router +from routes.social import router as social_router app.include_router(auth_router, prefix="/api/auth", tags=["Auth"]) app.include_router(dogs_router, prefix="/api/dogs", tags=["Hunde"]) @@ -136,6 +137,7 @@ app.include_router(events_router, prefix="/api/events", tags=["Events"]) app.include_router(sitting_router, prefix="/api/sitting", tags=["Sitting"]) app.include_router(osm_router, prefix="/api/osm", tags=["OSM"]) app.include_router(weather_router, prefix="/api/weather", tags=["Wetter"]) +app.include_router(social_router, prefix="/api/social", tags=["Social"]) app.include_router(forum_router, prefix="/api/forum", tags=["Forum"]) app.include_router(lost_router, prefix="/api/lost", tags=["Verlorener Hund"]) app.include_router(knigge_router, prefix="/api/knigge", tags=["Knigge"]) diff --git a/backend/routes/admin.py b/backend/routes/admin.py index 9391ff4..c24b351 100644 --- a/backend/routes/admin.py +++ b/backend/routes/admin.py @@ -81,10 +81,11 @@ def require_admin(user=Depends(get_current_user)): # Schemas # ------------------------------------------------------------------ class UserPatch(BaseModel): - rolle: Optional[str] = None # user | moderator | admin - is_moderator: Optional[int] = None - is_banned: Optional[int] = None - ban_reason: Optional[str] = None + rolle: Optional[str] = None # user | moderator | admin + is_moderator: Optional[int] = None + is_banned: Optional[int] = None + ban_reason: Optional[str] = None + is_social_media: Optional[int] = None class WikiEnrichBody(BaseModel): limit: int = 10 diff --git a/backend/routes/social.py b/backend/routes/social.py new file mode 100644 index 0000000..803aa41 --- /dev/null +++ b/backend/routes/social.py @@ -0,0 +1,327 @@ +""" +BAN YARO — Social Media Manager +KI-gestützte Content-Erstellung für TikTok & Instagram. +""" + +import json +import logging +from typing import Optional + +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel + +from auth import get_current_user, require_social_media +from database import db + +logger = logging.getLogger(__name__) +router = APIRouter() + +_PLATFORMS = {"tiktok", "instagram", "both"} +_FORMATS = {"reel", "story", "post", "carousel"} +_STATUSES = {"idea", "draft", "scheduled", "published", "archived"} + +_SYSTEM = ( + "Du bist ein erfahrener Social-Media-Stratege für Ban Yaro, " + "die deutschsprachige Hunde-App (banyaro.app). " + "Zielgruppe: Hundebesitzer in DACH, 20–45 Jahre, Fokus Frauen. " + "Ton: warmherzig, authentisch, informativ — nie aufdringlich werblich. " + "Antworte immer auf Deutsch." +) + +_PROMPT_GENERATE = '''\ +Erstelle einen vollständigen Social-Media-Content-Plan für folgenden Post. + +Plattform: {platform} +Format: {format} +Thema: {topic} +{breed_info} +Bereits verwendete Themen (nicht wiederholen): +{used_topics} + +Antworte NUR mit einem JSON-Objekt: +{{ + "caption": "Post-Text (plattformgerecht, mit Emojis, max 2200 Zeichen für Instagram / 150 für TikTok)", + "hashtags": "kommagetrennte Hashtags ohne #, 5-15 Stück, mix aus groß/nische", + "hook": "Ersten 1-3 Sätze / Sekunden — sofortiger Aufmerksamkeitsfänger", + "cta": "Call-to-Action am Ende (Frage, Aufforderung zum Kommentieren/Teilen)", + "visual_brief": "Detaillierte Beschreibung was auf dem Foto/Video zu sehen sein soll (Motiv, Stimmung, Licht, Perspektive)", + "image_prompt": "Englischer DALL-E/Midjourney Prompt für ein passendes Illustration/Cartoon (wenn kein eigenes Foto verfügbar)", + "canva_notes": "Empfehlung für Canva: Template-Typ, Textüberlagerungen, Farbstimmung", + "script": {script_field}, + "unsplash_query": "2-3 englische Suchbegriffe für Unsplash-Stockfotos", + "ai_score": +}} +''' + +_SCRIPT_FIELD_REEL = '''"Videostruktur: Hook (0-3s): ... | Hauptteil (3-25s): Schritt 1... Schritt 2... | CTA (25-30s): ..."''' +_SCRIPT_FIELD_OTHER = "null" + +_PROMPT_EVALUATE = '''\ +Bewerte und verbessere diesen Social-Media-Entwurf für Ban Yaro (Hunde-App). + +Plattform: {platform} +Format: {format} +Entwurf: +{draft} + +Antworte NUR mit einem JSON-Objekt: +{{ + "caption": "Verbesserter Post-Text", + "hashtags": "Optimierte Hashtags, kommagetrennt ohne #", + "hook": "Verbesserter Hook", + "cta": "Verbesserter CTA", + "visual_brief": "Empfehlung für passendes Bildmaterial", + "image_prompt": "DALL-E/Midjourney Prompt für Illustration", + "canva_notes": "Canva-Tipps", + "script": null, + "unsplash_query": "Unsplash-Suchbegriffe", + "ai_score": <1-5>, + "feedback": "Kurzes Feedback was gut war und was verbessert wurde (2-3 Sätze)" +}} +''' + + +class GenerateRequest(BaseModel): + platform: str = "both" + format: str = "post" + topic: str + breed_id: Optional[int] = None + + +class EvaluateRequest(BaseModel): + platform: str = "instagram" + format: str = "post" + draft: str + + +class StatusUpdate(BaseModel): + status: Optional[str] = None + scheduled_at: Optional[str] = None + published_at: Optional[str] = None + notes: Optional[str] = None + + +def _used_topics(limit: int = 30) -> str: + with db() as conn: + rows = conn.execute( + "SELECT topic FROM social_content ORDER BY created_at DESC LIMIT ?", + (limit,), + ).fetchall() + if not rows: + return "(noch keine)" + return "\n".join(f"- {r['topic']}" for r in rows) + + +def _breed_info(breed_id: int | None) -> str: + if not breed_id: + return "" + with db() as conn: + r = conn.execute( + "SELECT name, beschreibung, temperament, groesse FROM wiki_rassen WHERE id=?", + (breed_id,), + ).fetchone() + if not r: + return "" + parts = [f"Rasse: {r['name']}"] + if r["beschreibung"]: + parts.append(f"Info: {r['beschreibung'][:300]}") + if r["temperament"]: + parts.append(f"Temperament: {r['temperament']}") + return "\n".join(parts) + + +async def _ki_complete(prompt: str) -> str: + import ki as ki_module + return await ki_module.complete( + prompt, + system=_SYSTEM, + max_tokens=1200, + requires_premium=False, + ) + + +def _parse_json(raw: str) -> dict: + import re + try: + return json.loads(raw) + except json.JSONDecodeError: + pass + m = re.search(r"```(?:json)?\s*([\s\S]+?)\s*```", raw) + if m: + try: + return json.loads(m.group(1)) + except json.JSONDecodeError: + pass + m = re.search(r"\{[\s\S]+\}", raw) + if m: + try: + return json.loads(m.group(0)) + except json.JSONDecodeError: + pass + raise ValueError(f"Kein JSON in Antwort: {raw[:200]}") + + +# ------------------------------------------------------------------ +# GET /api/social/content — alle Einträge +# ------------------------------------------------------------------ +@router.get("/content") +async def list_content( + status: Optional[str] = None, + user=Depends(require_social_media), +): + with db() as conn: + if status: + rows = conn.execute( + "SELECT * FROM social_content WHERE status=? ORDER BY created_at DESC", + (status,), + ).fetchall() + else: + rows = conn.execute( + "SELECT * FROM social_content ORDER BY created_at DESC LIMIT 200", + ).fetchall() + return [dict(r) for r in rows] + + +# ------------------------------------------------------------------ +# POST /api/social/generate — KI-Content generieren +# ------------------------------------------------------------------ +@router.post("/generate") +async def generate_content(req: GenerateRequest, user=Depends(require_social_media)): + if req.platform not in _PLATFORMS: + raise HTTPException(400, f"Ungültige Plattform: {req.platform}") + if req.format not in _FORMATS: + raise HTTPException(400, f"Ungültiges Format: {req.format}") + if not req.topic.strip(): + raise HTTPException(400, "Thema darf nicht leer sein.") + + script_field = _SCRIPT_FIELD_REEL if req.format == "reel" else _SCRIPT_FIELD_OTHER + prompt = _PROMPT_GENERATE.format( + platform=req.platform, + format=req.format, + topic=req.topic, + breed_info=_breed_info(req.breed_id), + used_topics=_used_topics(), + script_field=script_field, + ) + + try: + raw = await _ki_complete(prompt) + data = _parse_json(raw) + except Exception as e: + logger.error("Social-Media-Generierung fehlgeschlagen: %s", e) + raise HTTPException(500, f"KI-Fehler: {e}") + + with db() as conn: + cur = conn.execute( + """INSERT INTO social_content + (created_by, platform, format, topic, caption, hashtags, + visual_brief, image_prompt, canva_notes, script, hook, cta, + unsplash_query, ai_score, source, breed_id) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", + ( + user["id"], req.platform, req.format, req.topic, + data.get("caption"), data.get("hashtags"), + data.get("visual_brief"), data.get("image_prompt"), + data.get("canva_notes"), data.get("script"), + data.get("hook"), data.get("cta"), + data.get("unsplash_query"), data.get("ai_score"), + "generated", req.breed_id, + ), + ) + entry_id = cur.lastrowid + + with db() as conn: + row = conn.execute( + "SELECT * FROM social_content WHERE id=?", (entry_id,) + ).fetchone() + result = dict(row) + result["feedback"] = data.get("feedback") + return result + + +# ------------------------------------------------------------------ +# POST /api/social/evaluate — eigenen Entwurf bewerten + verbessern +# ------------------------------------------------------------------ +@router.post("/evaluate") +async def evaluate_content(req: EvaluateRequest, user=Depends(require_social_media)): + if not req.draft.strip(): + raise HTTPException(400, "Entwurf darf nicht leer sein.") + + prompt = _PROMPT_EVALUATE.format( + platform=req.platform, + format=req.format, + draft=req.draft, + ) + + try: + raw = await _ki_complete(prompt) + data = _parse_json(raw) + except Exception as e: + raise HTTPException(500, f"KI-Fehler: {e}") + + with db() as conn: + cur = conn.execute( + """INSERT INTO social_content + (created_by, platform, format, topic, caption, hashtags, + visual_brief, image_prompt, canva_notes, script, hook, cta, + unsplash_query, ai_score, source, notes) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", + ( + user["id"], req.platform, req.format, + req.draft[:80] + ("…" if len(req.draft) > 80 else ""), + data.get("caption"), data.get("hashtags"), + data.get("visual_brief"), data.get("image_prompt"), + data.get("canva_notes"), data.get("script"), + data.get("hook"), data.get("cta"), + data.get("unsplash_query"), data.get("ai_score"), + "user", data.get("feedback"), + ), + ) + entry_id = cur.lastrowid + + with db() as conn: + row = conn.execute( + "SELECT * FROM social_content WHERE id=?", (entry_id,) + ).fetchone() + result = dict(row) + result["feedback"] = data.get("feedback") + return result + + +# ------------------------------------------------------------------ +# PATCH /api/social/content/{id} — Status / Notizen aktualisieren +# ------------------------------------------------------------------ +@router.patch("/content/{cid}") +async def update_content(cid: int, data: StatusUpdate, user=Depends(require_social_media)): + updates = {k: v for k, v in data.model_dump().items() if v is not None} + if not updates: + raise HTTPException(400, "Keine Felder zum Aktualisieren.") + if "status" in updates and updates["status"] not in _STATUSES: + raise HTTPException(400, f"Ungültiger Status: {updates['status']}") + + cols = ", ".join(f"{k}=?" for k in updates) + values = list(updates.values()) + [cid] + with db() as conn: + conn.execute(f"UPDATE social_content SET {cols} WHERE id=?", values) + return {"ok": True} + + +# ------------------------------------------------------------------ +# DELETE /api/social/content/{id} +# ------------------------------------------------------------------ +@router.delete("/content/{cid}", status_code=204) +async def delete_content(cid: int, user=Depends(require_social_media)): + with db() as conn: + conn.execute("DELETE FROM social_content WHERE id=?", (cid,)) + + +# ------------------------------------------------------------------ +# GET /api/social/breeds — Rassen für Dropdown +# ------------------------------------------------------------------ +@router.get("/breeds") +async def social_breeds(user=Depends(require_social_media)): + with db() as conn: + rows = conn.execute( + "SELECT id, name FROM wiki_rassen WHERE ki_enriched=1 ORDER BY name ASC" + ).fetchall() + return [dict(r) for r in rows] diff --git a/backend/static/index.html b/backend/static/index.html index 6fa08ec..444a728 100644 --- a/backend/static/index.html +++ b/backend/static/index.html @@ -180,6 +180,11 @@ Erste Hilfe + +