- 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
857 lines
37 KiB
Python
857 lines
37 KiB
Python
"""
|
||
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": "3–5 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": "3–5 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 10–15 erfolgreichen Wiederholungen"],
|
||
"tipp": "Klappt nicht? Leckerli unter dein angewinkeltes Knie halten — Hund kriecht drunter durch."},
|
||
{"id": "grundkommandos_bleib", "name": "Bleib", "kat": "Grundkommando", "schwierigkeit": "Anfänger–Fortgeschr.", "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 2–3 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": "5–10 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": "3–5 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": "3–5 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": "5–10 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": "5–10 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. 1–2 Stunden, erwachsene Hunde max. 4–6 Stunden allein lassen."},
|
||
{"id": "problem_bellen", "name": "Weniger Bellen", "kat": "Problemverhalten","schwierigkeit": "Mittel", "alter": "Ab 8 Wochen", "dauer": "5–10 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, 20–45 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}
|