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
This commit is contained in:
rene 2026-04-24 20:13:22 +02:00
parent ca0ce79815
commit 1cb0c2df77
8 changed files with 1497 additions and 337 deletions

View file

@ -561,12 +561,35 @@ def _migrate(conn_factory):
published_at TEXT, published_at TEXT,
source TEXT NOT NULL DEFAULT 'generated', source TEXT NOT NULL DEFAULT 'generated',
breed_id INTEGER REFERENCES wiki_rassen(id) ON DELETE SET NULL, breed_id INTEGER REFERENCES wiki_rassen(id) ON DELETE SET NULL,
notes TEXT coaching TEXT,
notes TEXT,
media_url TEXT,
category TEXT,
exercise_id TEXT
); );
CREATE INDEX IF NOT EXISTS idx_social_content_status CREATE INDEX IF NOT EXISTS idx_social_content_status
ON social_content(status); ON social_content(status);
""") """)
# Training-Übungen (für Social Media + Auswertung)
conn.executescript("""
CREATE TABLE IF NOT EXISTS training_exercises (
id INTEGER PRIMARY KEY AUTOINCREMENT,
exercise_id TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
kategorie TEXT NOT NULL,
schwierigkeit TEXT,
alter_ab TEXT,
dauer TEXT,
beschreibung TEXT,
schritte TEXT,
tipp TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_training_exercises_kat
ON training_exercises(kategorie);
""")
# Knigge: Community-Votes # Knigge: Community-Votes
conn.executescript(""" conn.executescript("""
CREATE TABLE IF NOT EXISTS knigge_votes ( CREATE TABLE IF NOT EXISTS knigge_votes (

View file

@ -163,6 +163,40 @@ async def stats(user=Depends(require_mod)):
except Exception: except Exception:
ki_today = ki_month = ki_users_today = 0 ki_today = ki_month = ki_users_today = 0
# Social Media Tracking
try:
social_total = conn.execute("SELECT COUNT(*) FROM social_content").fetchone()[0]
social_published = conn.execute(
"SELECT COUNT(*) FROM social_content WHERE status='published'"
).fetchone()[0]
social_scheduled = conn.execute(
"SELECT COUNT(*) FROM social_content WHERE status='scheduled'"
).fetchone()[0]
social_ideas = conn.execute(
"SELECT COUNT(*) FROM social_content WHERE status='idea'"
).fetchone()[0]
social_this_week = conn.execute(
"SELECT COUNT(*) FROM social_content WHERE status='published' "
"AND published_at >= datetime('now', '-7 days')"
).fetchone()[0]
social_by_cat = {
row[0]: row[1] for row in conn.execute(
"SELECT category, COUNT(*) FROM social_content "
"WHERE category IS NOT NULL GROUP BY category ORDER BY 2 DESC"
).fetchall()
}
social_recent = [dict(r) for r in conn.execute(
"""SELECT topic, status, platform, format, created_at,
published_at, category, ai_score
FROM social_content
ORDER BY created_at DESC LIMIT 10"""
).fetchall()]
except Exception:
social_total = social_published = social_scheduled = 0
social_ideas = social_this_week = 0
social_by_cat = {}
social_recent = []
return { return {
"users_total": users_total, "users_total": users_total,
"users_today": users_today, "users_today": users_today,
@ -183,6 +217,13 @@ async def stats(user=Depends(require_mod)):
"ki_today": ki_today, "ki_today": ki_today,
"ki_month": ki_month, "ki_month": ki_month,
"ki_users_today": ki_users_today, "ki_users_today": ki_users_today,
"social_total": social_total,
"social_published": social_published,
"social_scheduled": social_scheduled,
"social_ideas": social_ideas,
"social_this_week": social_this_week,
"social_by_cat": social_by_cat,
"social_recent": social_recent,
} }

View file

@ -5,6 +5,7 @@ KI-gestützte Content-Erstellung für TikTok & Instagram.
import json import json
import logging import logging
import random
from typing import Optional from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
@ -13,6 +14,101 @@ from pydantic import BaseModel
from auth import get_current_user, require_social_media from auth import get_current_user, require_social_media
from database import db 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__) logger = logging.getLogger(__name__)
router = APIRouter() router = APIRouter()
@ -56,9 +152,12 @@ Plattform: {platform}
Format: {format} Format: {format}
Thema: {topic} Thema: {topic}
{breed_info} {breed_info}
Bereits verwendete Themen (nicht wiederholen): Bereits verwendete Themen NICHT wiederholen, ähnliche Inhalte VERMEIDEN:
{used_topics} {used_topics}
Kategorie-Verteilung der letzten Posts (vermeide überfüllte Kategorien):
{category_stats}
Antworte NUR mit einem JSON-Objekt: Antworte NUR mit einem JSON-Objekt:
{{ {{
"caption": "Post-Text (plattformgerecht, mit Emojis, max 2200 Zeichen für Instagram / 150 für TikTok)", "caption": "Post-Text (plattformgerecht, mit Emojis, max 2200 Zeichen für Instagram / 150 für TikTok)",
@ -71,6 +170,7 @@ Antworte NUR mit einem JSON-Objekt:
"script": {script_field}, "script": {script_field},
"unsplash_query": "2-3 englische Suchbegriffe für Unsplash-Stockfotos", "unsplash_query": "2-3 englische Suchbegriffe für Unsplash-Stockfotos",
"ai_score": <Zahl 1-5, geschätztes Engagement-Potenzial>, "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." "coaching": "2-3 Sätze für die Creatorin: Warum dieser Hook? Warum diese Hashtags? Was macht diesen Post stark? Locker und ermutigend formuliert."
}} }}
''' '''
@ -134,6 +234,57 @@ def _used_topics(limit: int = 30) -> str:
return "\n".join(f"- {r['topic']}" for r in rows) 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: def _breed_info(breed_id: int | None) -> str:
if not breed_id: if not breed_id:
return "" return ""
@ -183,6 +334,24 @@ def _parse_json(raw: str) -> dict:
raise ValueError(f"Kein JSON in Antwort: {raw[:200]}") 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 # GET /api/social/suggestions — KI schlägt 5 Themen vor
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@ -254,6 +423,7 @@ async def generate_content(req: GenerateRequest, user=Depends(require_social_med
topic=req.topic, topic=req.topic,
breed_info=_breed_info(req.breed_id), breed_info=_breed_info(req.breed_id),
used_topics=_used_topics(), used_topics=_used_topics(),
category_stats=_category_stats(),
script_field=script_field, script_field=script_field,
) )
@ -269,8 +439,8 @@ async def generate_content(req: GenerateRequest, user=Depends(require_social_med
"""INSERT INTO social_content """INSERT INTO social_content
(created_by, platform, format, topic, caption, hashtags, (created_by, platform, format, topic, caption, hashtags,
visual_brief, image_prompt, canva_notes, script, hook, cta, visual_brief, image_prompt, canva_notes, script, hook, cta,
unsplash_query, ai_score, source, breed_id, coaching) unsplash_query, ai_score, source, breed_id, coaching, category)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
( (
user["id"], req.platform, req.format, req.topic, user["id"], req.platform, req.format, req.topic,
data.get("caption"), data.get("hashtags"), data.get("caption"), data.get("hashtags"),
@ -278,7 +448,7 @@ async def generate_content(req: GenerateRequest, user=Depends(require_social_med
data.get("canva_notes"), data.get("script"), data.get("canva_notes"), data.get("script"),
data.get("hook"), data.get("cta"), data.get("hook"), data.get("cta"),
data.get("unsplash_query"), data.get("ai_score"), data.get("unsplash_query"), data.get("ai_score"),
"generated", req.breed_id, data.get("coaching"), "generated", req.breed_id, data.get("coaching"), data.get("category"),
), ),
) )
entry_id = cur.lastrowid entry_id = cur.lastrowid
@ -369,7 +539,121 @@ async def delete_content(cid: int, user=Depends(require_social_media)):
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# GET /api/social/breeds — Rassen für Dropdown # Ü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") @router.get("/breeds")
async def social_breeds(user=Depends(require_social_media)): async def social_breeds(user=Depends(require_social_media)):
@ -380,6 +664,131 @@ async def social_breeds(user=Depends(require_social_media)):
return [dict(r) for r in rows] 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 # GET /api/social/stats — Level / XP
# ------------------------------------------------------------------ # ------------------------------------------------------------------

View file

@ -182,7 +182,7 @@
<div class="sidebar-item" data-page="social" id="sidebar-social" <div class="sidebar-item" data-page="social" id="sidebar-social"
style="display:none;color:var(--c-warning,#f59e0b)"> style="display:none;color:var(--c-warning,#f59e0b)">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#instagram-logo"></use></svg> Social Media <svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#camera"></use></svg> Social Media
</div> </div>
<div class="sidebar-item" data-page="admin" id="sidebar-admin" <div class="sidebar-item" data-page="admin" id="sidebar-admin"

View file

@ -3,7 +3,7 @@
Router, State-Management, Navigation, Initialisierung. Router, State-Management, Navigation, Initialisierung.
============================================================ */ ============================================================ */
const APP_VER = '325'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen const APP_VER = '336'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
const App = (() => { const App = (() => {

View file

@ -234,6 +234,59 @@ window.Page_admin = (() => {
</div> </div>
</div> </div>
<!-- Social Media Tracking -->
<div class="card" style="padding:var(--space-4)">
<p style="font-size:var(--text-sm);font-weight:600;margin:0 0 var(--space-3)">
📱 Social Media Tracking</p>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-2);
margin-bottom:var(--space-3)">
${[
['Gesamt generiert', s.social_total, 'var(--c-text-secondary)'],
['Veröffentlicht', s.social_published, 'var(--c-success)'],
['Geplant', s.social_scheduled, 'var(--c-primary)'],
['Ideen offen', s.social_ideas, 'var(--c-warning)'],
['Diese Woche live', s.social_this_week, 'var(--c-success)'],
].map(([label, val, color]) => `
<div style="background:var(--c-surface-2);border-radius:8px;padding:10px">
<div style="font-size:10px;color:var(--c-text-muted);margin-bottom:2px">${label}</div>
<div style="font-size:1.4em;font-weight:700;color:${color}">${val ?? 0}</div>
</div>`).join('')}
</div>
${Object.keys(s.social_by_cat||{}).length ? `
<div style="margin-bottom:var(--space-3)">
<div style="font-size:11px;color:var(--c-text-muted);margin-bottom:6px;
font-weight:600;text-transform:uppercase">Kategorien</div>
<div style="display:flex;gap:6px;flex-wrap:wrap">
${Object.entries(s.social_by_cat).map(([cat, n]) => `
<span style="background:var(--c-surface-2);border-radius:20px;
padding:2px 10px;font-size:11px">
${cat} <strong>${n}</strong></span>`).join('')}
</div>
</div>` : ''}
${s.social_recent?.length ? `
<div>
<div style="font-size:11px;color:var(--c-text-muted);margin-bottom:6px;
font-weight:600;text-transform:uppercase">Letzte 10 Posts</div>
<div style="display:flex;flex-direction:column;gap:6px">
${s.social_recent.map(p => `
<div style="display:flex;align-items:center;gap:8px;
font-size:var(--text-xs);padding:6px 0;
border-bottom:1px solid var(--c-border)">
<span style="padding:2px 7px;border-radius:4px;font-size:10px;
background:var(--c-surface-2);flex-shrink:0;
color:${{idea:'var(--c-text-muted)',draft:'var(--c-warning)',
scheduled:'var(--c-primary)',published:'var(--c-success)',
archived:'var(--c-text-muted)'}[p.status]||'inherit'}">
${p.status}</span>
<span style="flex:1;overflow:hidden;text-overflow:ellipsis;
white-space:nowrap">${p.topic}</span>
<span style="color:var(--c-text-muted);flex-shrink:0">
${(p.published_at||p.created_at||'').slice(0,10)}</span>
</div>`).join('')}
</div>
</div>` : ''}
</div>
<div class="card" style="padding:var(--space-4)"> <div class="card" style="padding:var(--space-4)">
<p style="font-size:var(--text-sm);color:var(--c-text-secondary);margin:0;line-height:1.6"> <p style="font-size:var(--text-sm);color:var(--c-text-secondary);margin:0;line-height:1.6">
<svg class="ph-icon" aria-hidden="true" style="color:var(--c-primary)"> <svg class="ph-icon" aria-hidden="true" style="color:var(--c-primary)">

File diff suppressed because it is too large Load diff

View file

@ -3,7 +3,7 @@
Offline-Cache + Push Notifications + Tile-Cache Offline-Cache + Push Notifications + Tile-Cache
============================================================ */ ============================================================ */
const CACHE_VERSION = 'by-v338'; const CACHE_VERSION = 'by-v349';
const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_STATIC = `${CACHE_VERSION}-static`;
const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten