Social Media Manager: Route, DB, KI-Prompts, Frontend, Rolle; SW by-v338

This commit is contained in:
rene 2026-04-24 19:13:30 +02:00
parent d90d4f1eeb
commit 0df6d569c1
9 changed files with 784 additions and 6 deletions

View file

@ -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

327
backend/routes/social.py Normal file
View file

@ -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, 2045 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": <Zahl 1-5, geschätztes Engagement-Potenzial>
}}
'''
_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]