banyaro/backend/routes/social.py
rene 1cb0c2df77 Social: Trainingstipp-Generator, Übungen in DB, 3 Stil-Varianten
- training_exercises Tabelle mit 13 Übungen aus App-Bibliothek
- POST /social/training-tip: Stil-Varianten tutorial/community/aspirational
- exercise_id in social_content für Wiederholungs-Tracking
- Admin-Stats: Social-Media-Sektion mit Status-Übersicht + letzte 10 Posts
- SW by-v349, APP_VER 336
2026-04-24 20:13:22 +02:00

857 lines
37 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
BAN YARO — Social Media Manager
KI-gestützte Content-Erstellung für TikTok & Instagram.
"""
import json
import logging
import random
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
from pydantic import BaseModel
from auth import get_current_user, require_social_media
from database import db
# ------------------------------------------------------------------
# Übungs-Bibliothek (gespiegelt aus uebungen.js)
# ------------------------------------------------------------------
_UEBUNGEN = [
{"id": "grundkommandos_sitz", "name": "Sitz", "kat": "Grundkommando", "schwierigkeit": "Anfänger", "alter": "Ab 8 Wochen", "dauer": "35 Min",
"beschreibung": "Das erste Kommando — Basis für alles weitere. Leckerli über den Kopf führen, Hinterteil senkt sich, sofort belohnen.",
"schritte": ["Leckerli vor die Nase halten", "Langsam nach oben/hinten führen", "Hinterteil senkt sich → sofort Markerwort + Leckerli", "Erst nach 10x Wort 'Sitz' einführen"],
"tipp": "Nie zu früh das Kommandowort einführen — erst wenn die Bewegung sicher klappt."},
{"id": "grundkommandos_platz", "name": "Platz", "kat": "Grundkommando", "schwierigkeit": "Anfänger", "alter": "Ab 10 Wochen", "dauer": "35 Min",
"beschreibung": "Hund legt sich auf Signal hin. Leckerli senkrecht nach unten zwischen Vorderpfoten führen.",
"schritte": ["Hund ins Sitz", "Leckerli nach unten zwischen die Pfoten führen", "Ellenbogen + Hinterteil am Boden → Markerwort + Leckerli", "Wort 'Platz' nach 1015 erfolgreichen Wiederholungen"],
"tipp": "Klappt nicht? Leckerli unter dein angewinkeltes Knie halten — Hund kriecht drunter durch."},
{"id": "grundkommandos_bleib", "name": "Bleib", "kat": "Grundkommando", "schwierigkeit": "AnfängerFortgeschr.", "alter": "Ab 12 Wochen", "dauer": "5 Min",
"beschreibung": "Drei Dimensionen: Dauer, Distanz, Ablenkung — immer nur eine steigern!",
"schritte": ["Hund ins Sitz", "2 Sekunden warten → Markerwort + Leckerli", "Freigabewort einführen: 'Okay'", "Dauer schrittweise: 2 → 5 → 10 → 30 Sekunden", "Erst dann einen Schritt zurücktreten"],
"tipp": "Immer zum Hund zurückkehren und belohnen — nie den Hund zu sich kommen lassen beim 'Bleib'."},
{"id": "grundkommandos_hier", "name": "Hier / Komm", "kat": "Grundkommando", "schwierigkeit": "Anfänger", "alter": "Ab 8 Wochen", "dauer": "5 Min",
"beschreibung": "Lebensrettend wichtig. Niemals rufen und dann etwas Unangenehmes tun.",
"schritte": ["In der Wohnung auf 23 Meter beginnen", "Hinknien, Arme öffnen, freudige Stimme: 'Hier!'", "Ankommen = große Freude + Leckerli", "Nur einmal rufen — wer mehrfach ruft, trainiert Ignorieren"],
"tipp": "Schleppleine im Garten: Hund kann nicht wegbleiben, Erfolg ist garantiert."},
{"id": "grundkommandos_fuss", "name": "Fuß", "kat": "Grundkommando", "schwierigkeit": "Fortgeschr. Anfänger", "alter": "Ab 12 Wochen", "dauer": "510 Min",
"beschreibung": "Hund läuft ruhig neben dir ohne zu ziehen — die Leine hängt locker durch.",
"schritte": ["Hund links positionieren, Leckerli auf Hüfthöhe halten", "Schritt vorwärts → Hund folgt", "Leine locker = belohnen", "Zieht er: stehen bleiben oder Richtung wechseln"],
"tipp": "Nie mitziehen lassen. Leine locker = Belohnung, Leine straff = Stopp."},
{"id": "grundkommandos_aus", "name": "Aus / Lass es", "kat": "Grundkommando", "schwierigkeit": "Anfänger", "alter": "Ab 10 Wochen", "dauer": "35 Min",
"beschreibung": "Hund lässt Gegenstände auf Kommando los — wichtig für Sicherheit.",
"schritte": ["Hund hält Spielzeug", "Leckerli vor Nase → er lässt los", "Markerwort + Leckerli", "Gegenstand zurückgeben → Loslassen lohnt sich!"],
"tipp": "Nie einfach wegnehmen nach 'Aus' — sonst gibt er nächstes Mal nicht mehr her."},
{"id": "tricks_pfote", "name": "Pfote / Handschlag", "kat": "Trick", "schwierigkeit": "Anfänger", "alter": "Ab 12 Wochen", "dauer": "3 Min",
"beschreibung": "Klassischer Trick — sieht toll aus und ist leicht zu lernen.",
"schritte": ["Leckerli in der Faust verstecken, Faust auf Kniehöhe", "Hund schnuppert und kratzt irgendwann → sofort öffnen", "Auf flache Hand umstellen", "Wort 'Pfote' einführen"],
"tipp": "Auf flacher Hand wird aus dem Kratzen ein elegantes Ablegen der Pfote."},
{"id": "tricks_dreh", "name": "Dreh / Kreis", "kat": "Trick", "schwierigkeit": "Anfänger", "alter": "Ab 12 Wochen", "dauer": "35 Min",
"beschreibung": "Hund dreht eine Runde auf Handzeichen — sieht aus wie Tanzen!",
"schritte": ["Leckerli vor Nase", "Kreis in der Luft führen", "Volle Drehung → Markerwort + Leckerli", "'Dreh' = links, 'Runde' = rechts"],
"tipp": "Handbewegung schrittweise kleiner machen bis nur noch ein Fingerzeig reicht."},
{"id": "tricks_such", "name": "Suchspiel / Nasenarbeit", "kat": "Trick", "schwierigkeit": "Anfänger", "alter": "Ab 8 Wochen", "dauer": "510 Min",
"beschreibung": "Mentale Auslastung pur — 10 Min Suchen = 1 Stunde Spaziergang!",
"schritte": ["Leckerli unter Becher verstecken", "Hund findet es → belohnen", "Mehrere Becher, einer hat Leckerli", "Später im Gras oder draußen"],
"tipp": "Perfekt für Regentage oder wenn körperliche Aktivität nicht möglich ist."},
{"id": "tricks_decke", "name": "Platz auf Decke", "kat": "Trick", "schwierigkeit": "Fortgeschr. Anfänger", "alter": "Ab 4 Monate", "dauer": "510 Min",
"beschreibung": "Hund geht auf sein Platz und liegt ruhig — perfekt für Restaurant oder Besuche.",
"schritte": ["Decke hinlegen, Leckerli drauf werfen", "Jedes Betreten belohnen", "Auf das Hinlegen warten → große Belohnung", "Decke schrittweise weiter wegstellen"],
"tipp": "Ideal für den Alltag: Hund hat seinen sicheren Platz überall."},
{"id": "problem_springen", "name": "Nicht springen", "kat": "Problemverhalten","schwierigkeit": "Anfänger", "alter": "Ab 8 Wochen", "dauer": "Bei jeder Begrüßung",
"beschreibung": "Hund begrüßt mit allen vier Pfoten am Boden.",
"schritte": ["Springt er: keine Reaktion", "Alle vier Pfoten unten → sofort Markerwort + Leckerli", "Alle im Haushalt müssen gleich reagieren"],
"tipp": "Konsequenz ist alles — ein Mal hochspringen lassen reicht, um es wieder zu lernen."},
{"id": "problem_alleine", "name": "Alleine bleiben", "kat": "Problemverhalten","schwierigkeit": "Mittel", "alter": "Ab 10 Wochen", "dauer": "Mehrmals täglich",
"beschreibung": "Hund bleibt ruhig allein — ohne Stress, Bellen oder Zerstören.",
"schritte": ["Kong mit Futter füllen", "10 Sekunden rausgehen → ruhig zurückkommen", "Zeit schrittweise erhöhen", "Nie dramatisch verabschieden oder begrüßen"],
"tipp": "Welpen max. 12 Stunden, erwachsene Hunde max. 46 Stunden allein lassen."},
{"id": "problem_bellen", "name": "Weniger Bellen", "kat": "Problemverhalten","schwierigkeit": "Mittel", "alter": "Ab 8 Wochen", "dauer": "510 Min täglich",
"beschreibung": "Hund beruhigt sich auf Signal hin — kein übermäßiges Kläffen mehr.",
"schritte": ["Ursache kennen: Alarm, Frust, Aufmerksamkeit oder Angst?", "Aufmerksamkeits-Bellen nie belohnen", "Kurze Pause abwarten → sofort markieren + belohnen", "'Ruhig' Kommando einführen"],
"tipp": "Nie schreien oder mitbellen — der Hund denkt du machst mit!"},
]
_TRAINING_STILE = [
"tutorial", # "So geht das:"
"community", # "Sind grad dran das zu lernen"
"aspirational",# "Das könnte euer Hund auch"
]
_PROMPT_TRAINING = '''\
Erstelle einen Social-Media-Post über den Hundetraining-Tipp "{name}" für Ban Yaro (banyaro.app).
Kategorie: {kat}
Schwierigkeit: {schwierigkeit}
Alter: {alter}
Dauer: {dauer}
Beschreibung: {beschreibung}
Schritte: {schritte}
Profi-Tipp: {tipp}
Stil dieses Posts: "{stil}"
- "tutorial""Schaut, so geht das: [Schritte knapp erklärt]"
- "community""Sind grad dran das zu lernen 🙋 Wer kennt das?"
- "aspirational""Das könnte euer Hund auch — in nur [Dauer]!"
Antworte NUR als JSON:
{{
"caption": "Post-Text passend zum Stil, mit Emojis, max 600 Zeichen, lehrreich aber locker",
"hashtags": "10 Hashtags kommagetrennt ohne #: hundetraining, hundeschule, positivreinforcement, {name_lower}, banyaro + passende",
"hook": "Erste Zeile die sofort Aufmerksamkeit fängt",
"cta": "Frage ans Publikum: z.B. 'Übt ihr das auch gerade?' oder 'Zeigt uns eure Videos!'",
"visual_brief": "Was im Video/Foto zu sehen sein sollte: Person mit Hund beim Training, Leckerli sichtbar, bestimmte Körperhaltung",
"canva_notes": "Text-Overlay: Übungsname + 1 Key-Tipp als Grafik",
"unsplash_query": "dog training {name_en} positive reinforcement",
"ai_score": <1-5>,
"category": "training",
"coaching": "Tipp für die Creatorin: Warum spricht dieser Post die Zielgruppe an? Wie filmt man das am besten? (1-2 Sätze)"
}}
'''
logger = logging.getLogger(__name__)
router = APIRouter()
_PLATFORMS = {"tiktok", "instagram", "both"}
_FORMATS = {"reel", "story", "post", "carousel"}
_STATUSES = {"idea", "draft", "scheduled", "published", "archived"}
_SYSTEM = (
"Du bist Luna, der freundliche Social-Media-Coach von Ban Yaro (banyaro.app). "
"Du begleitest eine junge Content-Creatorin (15 Jahre) dabei, für die Hunde-App "
"auf TikTok und Instagram zu wachsen. "
"Erkläre immer kurz WARUM etwas funktioniert — sie soll lernen, nicht nur kopieren. "
"Ton: locker, ermutigend, auf Augenhöhe — nie von oben herab. "
"Zielgruppe der App: Hundebesitzer in DACH, 2045 Jahre, Fokus Frauen. "
"Antworte immer auf Deutsch."
)
_PROMPT_SUGGESTIONS = '''\
Schlage 5 aktuelle, kreative Content-Ideen für Ban Yaro (Hunde-App) vor.
Heute: {datum} ({saison}).
Bereits verwendete Themen (nicht wiederholen):
{used_topics}
Antworte NUR als JSON-Array:
[
{{
"thema": "Konkretes Thema für einen Post (1 Satz)",
"warum": "Kurze Erklärung warum das gerade gut funktioniert (1-2 Sätze, als Coach)",
"format": "reel|post|story|carousel",
"platform": "tiktok|instagram|both",
"emoji": "1 passendes Emoji"
}}
]
Misch Formate und Plattformen. Aktuelle Trends, Saisonales und Evergreen-Themen kombinieren.
'''
_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, ähnliche Inhalte VERMEIDEN:
{used_topics}
Kategorie-Verteilung der letzten Posts (vermeide überfüllte Kategorien):
{category_stats}
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>,
"category": "Eine Kategorie aus: tipps|humor|rasse|community|gesundheit|training|saisonal|emotional|behind-the-scenes|quiz",
"coaching": "2-3 Sätze für die Creatorin: Warum dieser Hook? Warum diese Hashtags? Was macht diesen Post stark? Locker und ermutigend formuliert."
}}
'''
_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 _category_stats(limit: int = 30) -> str:
with db() as conn:
rows = conn.execute(
"""SELECT category, COUNT(*) as n FROM social_content
WHERE category IS NOT NULL
ORDER BY created_at DESC LIMIT ?""",
(limit,),
).fetchall()
if not rows:
return "(noch keine Kategorien)"
from collections import Counter
counts = Counter(r["category"] for r in rows)
total = sum(counts.values())
lines = [f"- {cat}: {n}x ({round(n/total*100)}%)" for cat, n in counts.most_common()]
return "\n".join(lines)
def _diversity_warning(limit: int = 20) -> dict | None:
"""Gibt Warnung zurück wenn eine Kategorie >40% der letzten Posts dominiert."""
with db() as conn:
rows = conn.execute(
"""SELECT category FROM social_content
WHERE category IS NOT NULL
ORDER BY created_at DESC LIMIT ?""",
(limit,),
).fetchall()
if len(rows) < 5:
return None
from collections import Counter
counts = Counter(r["category"] for r in rows)
total = len(rows)
for cat, n in counts.most_common(1):
if n / total > 0.4:
_CAT_DE = {
"tipps": "Tipps", "humor": "Humor/Spaß", "rasse": "Rassen-Info",
"community": "Community", "gesundheit": "Gesundheit",
"training": "Training", "saisonal": "Saison-Content",
"emotional": "Emotionale Posts", "behind-the-scenes": "Behind the Scenes",
"quiz": "Quiz/Fragen",
}
missing = [c for c in _CAT_DE if c not in counts]
return {
"dominant": cat,
"dominant_de": _CAT_DE.get(cat, cat),
"pct": round(n / total * 100),
"suggestions": missing[:3],
"suggestions_de": [_CAT_DE.get(c, c) for c in missing[:3]],
}
return None
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/diversity — Kreativitäts-Check
# ------------------------------------------------------------------
@router.get("/diversity")
async def get_diversity(user=Depends(require_social_media)):
warning = _diversity_warning()
with db() as conn:
rows = conn.execute(
"""SELECT category, COUNT(*) as n FROM social_content
WHERE category IS NOT NULL
ORDER BY n DESC""",
).fetchall()
return {
"warning": warning,
"distribution": [dict(r) for r in rows],
}
# ------------------------------------------------------------------
# GET /api/social/suggestions — KI schlägt 5 Themen vor
# ------------------------------------------------------------------
@router.get("/suggestions")
async def get_suggestions(user=Depends(require_social_media)):
from datetime import date
import calendar
today = date.today()
month = today.month
saison = (
"Frühling" if month in (3,4,5) else
"Sommer" if month in (6,7,8) else
"Herbst" if month in (9,10,11) else "Winter"
)
prompt = _PROMPT_SUGGESTIONS.format(
datum=today.strftime("%d.%m.%Y"),
saison=saison,
used_topics=_used_topics(20),
)
try:
raw = await _ki_complete(prompt)
import re
m = re.search(r"\[[\s\S]+\]", raw)
data = json.loads(m.group(0)) if m else []
return data
except Exception as e:
logger.error("Suggestions-Fehler: %s", e)
raise HTTPException(500, f"KI-Fehler: {e}")
# ------------------------------------------------------------------
# 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(),
category_stats=_category_stats(),
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, coaching, category)
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, data.get("coaching"), data.get("category"),
),
)
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,))
# ------------------------------------------------------------------
# Übungen in DB seeden (einmalig beim Import)
# ------------------------------------------------------------------
def _seed_exercises():
with db() as conn:
for u in _UEBUNGEN:
conn.execute(
"""INSERT OR IGNORE INTO training_exercises
(exercise_id, name, kategorie, schwierigkeit, alter_ab,
dauer, beschreibung, schritte, tipp)
VALUES (?,?,?,?,?,?,?,?,?)""",
(u["id"], u["name"], u["kat"], u["schwierigkeit"],
u.get("alter"), u.get("dauer"), u.get("beschreibung"),
json.dumps(u.get("schritte", []), ensure_ascii=False),
u.get("tipp")),
)
try:
_seed_exercises()
except Exception:
pass
# ------------------------------------------------------------------
# POST /api/social/training-tip — Trainingstipp-Post generieren
# ------------------------------------------------------------------
@router.post("/training-tip")
async def training_tip(user=Depends(require_social_media)):
# Übung wählen die noch nicht als Social-Post verwendet wurde
with db() as conn:
used = {r["exercise_id"] for r in conn.execute(
"SELECT exercise_id FROM social_content WHERE exercise_id IS NOT NULL"
).fetchall()}
all_ex = conn.execute(
"SELECT * FROM training_exercises ORDER BY RANDOM()"
).fetchall()
unused = [e for e in all_ex if e["exercise_id"] not in used]
pool = unused if unused else list(all_ex) # Reset wenn alle durch
if not pool:
raise HTTPException(404, "Keine Übungen gefunden.")
ex = dict(pool[0])
stil = random.choice(_TRAINING_STILE)
schritte_list = json.loads(ex["schritte"] or "[]")
schritte_text = "\n".join(f" {i+1}. {s}" for i, s in enumerate(schritte_list[:4]))
prompt = _PROMPT_TRAINING.format(
name=ex["name"],
kat=ex["kategorie"],
schwierigkeit=ex["schwierigkeit"] or "Anfänger",
alter=ex["alter_ab"] or "Ab 8 Wochen",
dauer=ex["dauer"] or "5 Min",
beschreibung=ex["beschreibung"] or ex["name"],
schritte=schritte_text,
tipp=ex["tipp"] or "",
stil=stil,
name_lower=ex["name"].lower().replace(" ", ""),
name_en=ex["name"],
)
try:
raw = await _ki_complete(prompt)
data = _parse_json(raw)
except Exception as e:
raise HTTPException(500, f"KI-Fehler: {e}")
stil_label = {
"tutorial": f"So geht's: {ex['name']}",
"community": f"Lernen gerade: {ex['name']} 🙋",
"aspirational": f"Das kann dein Hund auch: {ex['name']}",
}[stil]
with db() as conn:
cur = conn.execute(
"""INSERT INTO social_content
(created_by, platform, format, topic, caption, hashtags,
visual_brief, canva_notes, hook, cta, unsplash_query,
ai_score, source, coaching, category, exercise_id)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
(user["id"], "both", "reel" if stil == "tutorial" else "post",
stil_label, data.get("caption"), data.get("hashtags"),
data.get("visual_brief"), data.get("canva_notes"),
data.get("hook"), data.get("cta"), data.get("unsplash_query"),
data.get("ai_score"), "generated", data.get("coaching"),
"training", ex["exercise_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["exercise_name"] = ex["name"]
result["exercise_kat"] = ex["kategorie"]
result["stil"] = stil
return result
# ------------------------------------------------------------------
# GET /api/social/exercises — alle Übungen mit Nutzungsstatistik
# ------------------------------------------------------------------
@router.get("/exercises")
async def get_exercises(user=Depends(require_social_media)):
with db() as conn:
rows = conn.execute(
"""SELECT e.*,
(SELECT COUNT(*) FROM social_content s
WHERE s.exercise_id = e.exercise_id) as posts_count
FROM training_exercises e
ORDER BY e.kategorie, e.name"""
).fetchall()
return [dict(r) for r in rows]
# ------------------------------------------------------------------
# GET /api/social/breeds — alle Rassen für Autocomplete
# ------------------------------------------------------------------
@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]
# ------------------------------------------------------------------
# GET /api/social/unused-breeds — noch nie in Post verwendete Rassen
# ------------------------------------------------------------------
@router.get("/unused-breeds")
async def unused_breeds(limit: int = 6, user=Depends(require_social_media)):
with db() as conn:
rows = conn.execute(
"""SELECT r.id, r.name FROM wiki_rassen r
WHERE r.ki_enriched = 1
AND r.id NOT IN (
SELECT breed_id FROM social_content
WHERE breed_id IS NOT NULL
)
ORDER BY RANDOM()
LIMIT ?""",
(limit,),
).fetchall()
return [dict(r) for r in rows]
# ------------------------------------------------------------------
# POST /api/social/breed-of-day — "Wusstest du schon?" Post generieren
# ------------------------------------------------------------------
_PROMPT_BREED_DAY = '''\
Erstelle einen begeisternden Social-Media-Post über die Hunderasse "{name}" für Ban Yaro (banyaro.app).
Rassen-Daten aus unserer Datenbank:
- Herkunft: {herkunft}
- Größe: {groesse}
- Gewicht: {gewicht_min}{gewicht_max} kg
- Lebensdauer: {lebensdauer}
- Aktivität: {aktivitaet}
- Erfahrung: {erfahrung}
- Temperament: {temperament}
- Für Kinder geeignet: {kinder}
- Wohnungsgeeignet: {wohnung}
- Beschreibung: {beschreibung}
Antworte NUR als JSON:
{{
"caption": "Post-Text der mit 🐾 Wusstest du schon, wie cool der/die {name} ist? beginnt. Fakten aus den Daten, mit Emojis, max 800 Zeichen.",
"hashtags": "10-12 Hashtags kommagetrennt ohne #: Rassenname, hundeleben, banyaro, passende Eigenschaften",
"hook": "Erste Zeile als Aufmerksamkeitsfänger",
"cta": "Frage ans Publikum passend zur Rasse",
"visual_brief": "Beschreibung: typisches Bild dieser Rasse, Stimmung, Setting",
"canva_notes": "Textüberlagerungen für das Rassenfoto: Rassenname groß, 1-2 Fakten",
"unsplash_query": "2-3 englische Suchbegriffe für diese Rasse",
"ai_score": <1-5>,
"category": "rasse",
"coaching": "Tipp für die Creatorin: Warum ist diese Rasse spannend für die Zielgruppe? (1-2 Sätze)"
}}
'''
@router.post("/breed-of-day")
async def breed_of_day(user=Depends(require_social_media)):
with db() as conn:
rasse = conn.execute(
"""SELECT r.id, r.name, r.herkunft, r.groesse,
r.gewicht_min_kg, r.gewicht_max_kg, r.lebensdauer,
r.aktivitaet, r.erfahrung, r.temperament,
r.kinder_geeignet, r.wohnung_geeignet,
r.beschreibung, r.foto_url
FROM wiki_rassen r
WHERE r.ki_enriched = 1
AND r.beschreibung IS NOT NULL
AND r.id NOT IN (
SELECT breed_id FROM social_content WHERE breed_id IS NOT NULL
)
ORDER BY RANDOM()
LIMIT 1""",
).fetchone()
if not rasse:
raise HTTPException(404, "Alle Rassen wurden bereits verwendet — Reset nötig.")
def _yn(v): return "Ja" if v else "Nein"
prompt = _PROMPT_BREED_DAY.format(
name=rasse["name"],
herkunft=rasse["herkunft"] or "unbekannt",
groesse=rasse["groesse"] or "unbekannt",
gewicht_min=rasse["gewicht_min_kg"] or "?",
gewicht_max=rasse["gewicht_max_kg"] or "?",
lebensdauer=rasse["lebensdauer"] or "unbekannt",
aktivitaet=rasse["aktivitaet"] or "mittel",
erfahrung=rasse["erfahrung"] or "fortgeschritten",
temperament=rasse["temperament"] or "unbekannt",
kinder=_yn(rasse["kinder_geeignet"]),
wohnung=_yn(rasse["wohnung_geeignet"]),
beschreibung=(rasse["beschreibung"] or "")[:400],
)
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, hook, cta,
unsplash_query, ai_score, source, breed_id, coaching,
category, media_url)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
(
user["id"], "both", "post",
f"Rasse des Tages: {rasse['name']}",
data.get("caption"), data.get("hashtags"),
data.get("visual_brief"), None,
data.get("canva_notes"), data.get("hook"), data.get("cta"),
data.get("unsplash_query"), data.get("ai_score"),
"generated", rasse["id"], data.get("coaching"),
"rasse", rasse["foto_url"],
),
)
entry_id = cur.lastrowid
with db() as conn:
row = conn.execute("SELECT * FROM social_content WHERE id=?", (entry_id,)).fetchone()
result = dict(row)
result["breed_foto"] = rasse["foto_url"]
return result
# ------------------------------------------------------------------
# GET /api/social/stats — Level / XP
# ------------------------------------------------------------------
_LEVELS = [
(0, 50, "🌱 Rookie", "Anfängerin"),
(50, 150, "✏️ Creator", "Content Creatorin"),
(150, 400, "🌟 Influencer", "Influencerin"),
(400, 800, "🏆 Pro Creator", "Profi"),
(800, 99999,"👑 Star", "Social Media Star"),
]
@router.get("/stats")
async def get_stats(user=Depends(require_social_media)):
with db() as conn:
generated = conn.execute(
"SELECT COUNT(*) FROM social_content WHERE source='generated'"
).fetchone()[0]
published = conn.execute(
"SELECT COUNT(*) FROM social_content WHERE status='published'"
).fetchone()[0]
evaluated = conn.execute(
"SELECT COUNT(*) FROM social_content WHERE source='user'"
).fetchone()[0]
five_star = conn.execute(
"SELECT COUNT(*) FROM social_content WHERE ai_score=5"
).fetchone()[0]
xp = generated * 10 + published * 20 + evaluated * 5 + five_star * 15
lvl = _LEVELS[0]
for l in _LEVELS:
if xp >= l[0]:
lvl = l
idx = _LEVELS.index(lvl)
nxt = _LEVELS[idx + 1] if idx < len(_LEVELS) - 1 else None
return {
"xp": xp, "generated": generated, "published": published,
"evaluated": evaluated, "five_star": five_star,
"level": lvl[2], "level_desc": lvl[3],
"xp_current_min": lvl[0], "xp_next": nxt[0] if nxt else xp,
"next_level": nxt[2] if nxt else None,
}
# ------------------------------------------------------------------
# POST /api/social/media — Medien-Upload (Foto/Video)
# ------------------------------------------------------------------
@router.post("/media")
async def upload_media(
file: UploadFile = File(...),
user=Depends(require_social_media),
):
import shutil, uuid, os
MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media")
social_dir = os.path.join(MEDIA_DIR, "social")
os.makedirs(social_dir, exist_ok=True)
ext = os.path.splitext(file.filename or "")[-1].lower() or ".jpg"
fname = f"{uuid.uuid4().hex}{ext}"
path = os.path.join(social_dir, fname)
with open(path, "wb") as f:
shutil.copyfileobj(file.file, f)
return {"url": f"/media/social/{fname}", "filename": fname}