feat: AI-Prompts flexibilisierung - Backend & Admin UI (Issue #28, Part 1)

Backend complete:
- Migration 017: Add category column to ai_prompts
- placeholder_resolver.py: 20+ placeholders with resolver functions
- Extended routers/prompts.py with CRUD endpoints:
  * POST /api/prompts (create)
  * PUT /api/prompts/:id (update)
  * DELETE /api/prompts/:id (delete)
  * POST /api/prompts/:id/duplicate
  * PUT /api/prompts/reorder
  * POST /api/prompts/preview
  * GET /api/prompts/placeholders
  * POST /api/prompts/generate (KI-assisted generation)
  * POST /api/prompts/:id/optimize (KI analysis)
- Extended models.py with PromptCreate, PromptUpdate, PromptGenerateRequest

Frontend:
- AdminPromptsPage.jsx: Full CRUD UI with category filter, reordering

Meta-Features:
- KI generates prompts from goal description + example data
- KI analyzes and optimizes existing prompts

Next: PromptEditModal, PromptGenerator, api.js integration

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Lars 2026-03-24 15:32:25 +01:00
parent 5796c6a21a
commit 500de132b9
5 changed files with 1089 additions and 18 deletions

View File

@ -0,0 +1,22 @@
-- Migration 017: AI Prompts Flexibilisierung (Issue #28)
-- Add category column to ai_prompts for better organization and filtering
-- Add category column
ALTER TABLE ai_prompts ADD COLUMN IF NOT EXISTS category VARCHAR(20) DEFAULT 'ganzheitlich';
-- Create index for category filtering
CREATE INDEX IF NOT EXISTS idx_ai_prompts_category ON ai_prompts(category);
-- Add comment
COMMENT ON COLUMN ai_prompts.category IS 'Prompt category: körper, ernährung, training, schlaf, vitalwerte, ziele, ganzheitlich';
-- Update existing prompts with appropriate categories
-- Based on slug patterns and content
UPDATE ai_prompts SET category = 'körper' WHERE slug IN ('koerperkomposition', 'gewichtstrend', 'umfaenge', 'caliper');
UPDATE ai_prompts SET category = 'ernährung' WHERE slug IN ('ernaehrung', 'kalorienbilanz', 'protein', 'makros');
UPDATE ai_prompts SET category = 'training' WHERE slug IN ('aktivitaet', 'trainingsanalyse', 'erholung', 'leistung');
UPDATE ai_prompts SET category = 'schlaf' WHERE slug LIKE '%schlaf%';
UPDATE ai_prompts SET category = 'vitalwerte' WHERE slug IN ('vitalwerte', 'herzfrequenz', 'ruhepuls', 'hrv');
UPDATE ai_prompts SET category = 'ziele' WHERE slug LIKE '%ziel%' OR slug LIKE '%goal%';
-- Pipeline prompts remain 'ganzheitlich' (default)

View File

@ -127,3 +127,30 @@ class AdminProfileUpdate(BaseModel):
ai_enabled: Optional[int] = None ai_enabled: Optional[int] = None
ai_limit_day: Optional[int] = None ai_limit_day: Optional[int] = None
export_enabled: Optional[int] = None export_enabled: Optional[int] = None
# ── Prompt Models (Issue #28) ────────────────────────────────────────────────
class PromptCreate(BaseModel):
name: str
slug: str
description: Optional[str] = None
template: str
category: str = 'ganzheitlich'
active: bool = True
sort_order: int = 0
class PromptUpdate(BaseModel):
name: Optional[str] = None
description: Optional[str] = None
template: Optional[str] = None
category: Optional[str] = None
active: Optional[bool] = None
sort_order: Optional[int] = None
class PromptGenerateRequest(BaseModel):
goal: str
data_categories: list[str]
example_output: Optional[str] = None

View File

@ -0,0 +1,308 @@
"""
Placeholder Resolver for AI Prompts
Provides a registry of placeholder functions that resolve to actual user data.
Used for prompt templates and preview functionality.
"""
import re
from datetime import datetime, timedelta
from typing import Dict, List, Optional, Callable
from db import get_db, get_cursor, r2d
# ── Helper Functions ──────────────────────────────────────────────────────────
def get_profile_data(profile_id: str) -> Dict:
"""Load profile data for a user."""
with get_db() as conn:
cur = get_cursor(conn)
cur.execute("SELECT * FROM profiles WHERE id=%s", (profile_id,))
return r2d(cur.fetchone()) if cur.rowcount > 0 else {}
def get_latest_weight(profile_id: str) -> Optional[str]:
"""Get latest weight entry."""
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(
"SELECT weight FROM weight_log WHERE profile_id=%s ORDER BY date DESC LIMIT 1",
(profile_id,)
)
row = cur.fetchone()
return f"{row['weight']:.1f} kg" if row else "nicht verfügbar"
def get_weight_trend(profile_id: str, days: int = 28) -> str:
"""Calculate weight trend description."""
with get_db() as conn:
cur = get_cursor(conn)
cutoff = (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d')
cur.execute(
"""SELECT weight, date FROM weight_log
WHERE profile_id=%s AND date >= %s
ORDER BY date""",
(profile_id, cutoff)
)
rows = [r2d(r) for r in cur.fetchall()]
if len(rows) < 2:
return "nicht genug Daten"
first = rows[0]['weight']
last = rows[-1]['weight']
delta = last - first
if abs(delta) < 0.3:
return "stabil"
elif delta > 0:
return f"steigend (+{delta:.1f} kg in {days} Tagen)"
else:
return f"sinkend ({delta:.1f} kg in {days} Tagen)"
def get_latest_bf(profile_id: str) -> Optional[str]:
"""Get latest body fat percentage from caliper."""
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(
"""SELECT bf_jpl FROM caliper_log
WHERE profile_id=%s AND bf_jpl IS NOT NULL
ORDER BY date DESC LIMIT 1""",
(profile_id,)
)
row = cur.fetchone()
return f"{row['bf_jpl']:.1f}%" if row else "nicht verfügbar"
def get_nutrition_avg(profile_id: str, field: str, days: int = 30) -> str:
"""Calculate average nutrition value."""
with get_db() as conn:
cur = get_cursor(conn)
cutoff = (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d')
cur.execute(
f"""SELECT AVG({field}) as avg FROM nutrition_log
WHERE profile_id=%s AND date >= %s AND {field} IS NOT NULL""",
(profile_id, cutoff)
)
row = cur.fetchone()
if row and row['avg']:
if field == 'kcal':
return f"{int(row['avg'])} kcal/Tag (Ø {days} Tage)"
else:
return f"{int(row['avg'])}g/Tag (Ø {days} Tage)"
return "nicht verfügbar"
def get_activity_summary(profile_id: str, days: int = 14) -> str:
"""Get activity summary for recent period."""
with get_db() as conn:
cur = get_cursor(conn)
cutoff = (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d')
cur.execute(
"""SELECT COUNT(*) as count,
SUM(duration_min) as total_min,
SUM(kcal_active) as total_kcal
FROM activity_log
WHERE profile_id=%s AND date >= %s""",
(profile_id, cutoff)
)
row = r2d(cur.fetchone())
if row['count'] == 0:
return f"Keine Aktivitäten in den letzten {days} Tagen"
avg_min = int(row['total_min'] / row['count']) if row['total_min'] else 0
return f"{row['count']} Einheiten in {days} Tagen (Ø {avg_min} min/Einheit, {int(row['total_kcal'] or 0)} kcal gesamt)"
def calculate_age(dob: Optional[str]) -> str:
"""Calculate age from date of birth."""
if not dob:
return "unbekannt"
try:
birth = datetime.strptime(dob, '%Y-%m-%d')
today = datetime.now()
age = today.year - birth.year - ((today.month, today.day) < (birth.month, birth.day))
return str(age)
except:
return "unbekannt"
def get_trainingstyp_verteilung(profile_id: str, days: int = 14) -> str:
"""Get training type distribution."""
with get_db() as conn:
cur = get_cursor(conn)
cutoff = (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d')
cur.execute(
"""SELECT training_category, COUNT(*) as count
FROM activity_log
WHERE profile_id=%s AND date >= %s AND training_category IS NOT NULL
GROUP BY training_category
ORDER BY count DESC""",
(profile_id, cutoff)
)
rows = [r2d(r) for r in cur.fetchall()]
if not rows:
return "Keine kategorisierten Trainings"
total = sum(r['count'] for r in rows)
parts = [f"{r['training_category']}: {int(r['count']/total*100)}%" for r in rows[:3]]
return ", ".join(parts)
# ── Placeholder Registry ──────────────────────────────────────────────────────
PLACEHOLDER_MAP: Dict[str, Callable[[str], str]] = {
# Profil
'{{name}}': lambda pid: get_profile_data(pid).get('name', 'Nutzer'),
'{{age}}': lambda pid: calculate_age(get_profile_data(pid).get('dob')),
'{{height}}': lambda pid: str(get_profile_data(pid).get('height', 'unbekannt')),
'{{geschlecht}}': lambda pid: 'männlich' if get_profile_data(pid).get('sex') == 'm' else 'weiblich',
# Körper
'{{weight_aktuell}}': get_latest_weight,
'{{weight_trend}}': get_weight_trend,
'{{kf_aktuell}}': get_latest_bf,
'{{bmi}}': lambda pid: calculate_bmi(pid),
# Ernährung
'{{kcal_avg}}': lambda pid: get_nutrition_avg(pid, 'kcal', 30),
'{{protein_avg}}': lambda pid: get_nutrition_avg(pid, 'protein', 30),
'{{carb_avg}}': lambda pid: get_nutrition_avg(pid, 'carb', 30),
'{{fat_avg}}': lambda pid: get_nutrition_avg(pid, 'fat', 30),
# Training
'{{activity_summary}}': get_activity_summary,
'{{trainingstyp_verteilung}}': get_trainingstyp_verteilung,
# Zeitraum
'{{datum_heute}}': lambda pid: datetime.now().strftime('%d.%m.%Y'),
'{{zeitraum_7d}}': lambda pid: 'letzte 7 Tage',
'{{zeitraum_30d}}': lambda pid: 'letzte 30 Tage',
'{{zeitraum_90d}}': lambda pid: 'letzte 90 Tage',
}
def calculate_bmi(profile_id: str) -> str:
"""Calculate BMI from latest weight and profile height."""
profile = get_profile_data(profile_id)
if not profile.get('height'):
return "nicht verfügbar"
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(
"SELECT weight FROM weight_log WHERE profile_id=%s ORDER BY date DESC LIMIT 1",
(profile_id,)
)
row = cur.fetchone()
if not row:
return "nicht verfügbar"
height_m = profile['height'] / 100
bmi = row['weight'] / (height_m ** 2)
return f"{bmi:.1f}"
# ── Public API ────────────────────────────────────────────────────────────────
def resolve_placeholders(template: str, profile_id: str) -> str:
"""
Replace all {{placeholders}} in template with actual user data.
Args:
template: Prompt template with placeholders
profile_id: User profile ID
Returns:
Resolved template with placeholders replaced by values
"""
result = template
for placeholder, resolver in PLACEHOLDER_MAP.items():
if placeholder in result:
try:
value = resolver(profile_id)
result = result.replace(placeholder, str(value))
except Exception as e:
# On error, replace with error message
result = result.replace(placeholder, f"[Fehler: {placeholder}]")
return result
def get_unknown_placeholders(template: str) -> List[str]:
"""
Find all placeholders in template that are not in PLACEHOLDER_MAP.
Args:
template: Prompt template
Returns:
List of unknown placeholder names (without {{}})
"""
# Find all {{...}} patterns
found = re.findall(r'\{\{(\w+)\}\}', template)
# Filter to only unknown ones
known_names = {p.strip('{}') for p in PLACEHOLDER_MAP.keys()}
unknown = [p for p in found if p not in known_names]
return list(set(unknown)) # Remove duplicates
def get_available_placeholders(categories: Optional[List[str]] = None) -> Dict[str, List[str]]:
"""
Get available placeholders, optionally filtered by categories.
Args:
categories: Optional list of categories to filter (körper, ernährung, training, etc.)
Returns:
Dict mapping category to list of placeholders
"""
placeholder_categories = {
'profil': [
'{{name}}', '{{age}}', '{{height}}', '{{geschlecht}}'
],
'körper': [
'{{weight_aktuell}}', '{{weight_trend}}', '{{kf_aktuell}}', '{{bmi}}'
],
'ernährung': [
'{{kcal_avg}}', '{{protein_avg}}', '{{carb_avg}}', '{{fat_avg}}'
],
'training': [
'{{activity_summary}}', '{{trainingstyp_verteilung}}'
],
'zeitraum': [
'{{datum_heute}}', '{{zeitraum_7d}}', '{{zeitraum_30d}}', '{{zeitraum_90d}}'
]
}
if not categories:
return placeholder_categories
# Filter to requested categories
return {k: v for k, v in placeholder_categories.items() if k in categories}
def get_placeholder_example_values(profile_id: str) -> Dict[str, str]:
"""
Get example values for all placeholders using real user data.
Args:
profile_id: User profile ID
Returns:
Dict mapping placeholder to example value
"""
examples = {}
for placeholder, resolver in PLACEHOLDER_MAP.items():
try:
examples[placeholder] = resolver(profile_id)
except Exception as e:
examples[placeholder] = f"[Fehler: {str(e)}]"
return examples

View File

@ -3,10 +3,26 @@ AI Prompts Management Endpoints for Mitai Jinkendo
Handles prompt template configuration (admin-editable). Handles prompt template configuration (admin-editable).
""" """
from fastapi import APIRouter, Depends import os
import json
import uuid
import httpx
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException
from db import get_db, get_cursor, r2d from db import get_db, get_cursor, r2d
from auth import require_auth, require_admin from auth import require_auth, require_admin
from models import PromptCreate, PromptUpdate, PromptGenerateRequest
from placeholder_resolver import (
resolve_placeholders,
get_unknown_placeholders,
get_placeholder_example_values,
get_available_placeholders
)
# Environment variables
OPENROUTER_KEY = os.getenv("OPENROUTER_API_KEY")
OPENROUTER_MODEL = os.getenv("OPENROUTER_MODEL", "anthropic/claude-sonnet-4")
router = APIRouter(prefix="/api/prompts", tags=["prompts"]) router = APIRouter(prefix="/api/prompts", tags=["prompts"])
@ -32,29 +48,434 @@ def list_prompts(session: dict=Depends(require_auth)):
return [r2d(r) for r in cur.fetchall()] return [r2d(r) for r in cur.fetchall()]
@router.post("")
def create_prompt(p: PromptCreate, session: dict=Depends(require_admin)):
"""Create new AI prompt (admin only)."""
with get_db() as conn:
cur = get_cursor(conn)
# Check if slug already exists
cur.execute("SELECT id FROM ai_prompts WHERE slug=%s", (p.slug,))
if cur.fetchone():
raise HTTPException(status_code=400, detail=f"Prompt with slug '{p.slug}' already exists")
prompt_id = str(uuid.uuid4())
cur.execute(
"""INSERT INTO ai_prompts (id, name, slug, description, template, category, active, sort_order, created, updated)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)""",
(prompt_id, p.name, p.slug, p.description, p.template, p.category, p.active, p.sort_order)
)
return {"id": prompt_id, "slug": p.slug}
@router.put("/{prompt_id}") @router.put("/{prompt_id}")
def update_prompt(prompt_id: str, data: dict, session: dict=Depends(require_admin)): def update_prompt(prompt_id: str, p: PromptUpdate, session: dict=Depends(require_admin)):
"""Update AI prompt template (admin only).""" """Update AI prompt template (admin only)."""
with get_db() as conn: with get_db() as conn:
cur = get_cursor(conn) cur = get_cursor(conn)
# Build dynamic UPDATE query
updates = [] updates = []
values = [] values = []
if 'name' in data:
updates.append('name=%s')
values.append(data['name'])
if 'description' in data:
updates.append('description=%s')
values.append(data['description'])
if 'template' in data:
updates.append('template=%s')
values.append(data['template'])
if 'active' in data:
updates.append('active=%s')
# Convert to boolean (accepts true/false, 1/0)
values.append(bool(data['active']))
if updates: if p.name is not None:
cur.execute(f"UPDATE ai_prompts SET {', '.join(updates)}, updated=CURRENT_TIMESTAMP WHERE id=%s", updates.append('name=%s')
values + [prompt_id]) values.append(p.name)
if p.description is not None:
updates.append('description=%s')
values.append(p.description)
if p.template is not None:
updates.append('template=%s')
values.append(p.template)
if p.category is not None:
updates.append('category=%s')
values.append(p.category)
if p.active is not None:
updates.append('active=%s')
values.append(p.active)
if p.sort_order is not None:
updates.append('sort_order=%s')
values.append(p.sort_order)
if not updates:
return {"ok": True}
cur.execute(
f"UPDATE ai_prompts SET {', '.join(updates)}, updated=CURRENT_TIMESTAMP WHERE id=%s",
values + [prompt_id]
)
return {"ok": True} return {"ok": True}
@router.delete("/{prompt_id}")
def delete_prompt(prompt_id: str, session: dict=Depends(require_admin)):
"""Delete AI prompt (admin only)."""
with get_db() as conn:
cur = get_cursor(conn)
cur.execute("DELETE FROM ai_prompts WHERE id=%s", (prompt_id,))
if cur.rowcount == 0:
raise HTTPException(status_code=404, detail="Prompt not found")
return {"ok": True}
@router.post("/{prompt_id}/duplicate")
def duplicate_prompt(prompt_id: str, session: dict=Depends(require_admin)):
"""Duplicate an existing prompt (admin only)."""
with get_db() as conn:
cur = get_cursor(conn)
# Load original prompt
cur.execute("SELECT * FROM ai_prompts WHERE id=%s", (prompt_id,))
original = r2d(cur.fetchone())
if not original:
raise HTTPException(status_code=404, detail="Prompt not found")
# Create duplicate with new ID and modified name/slug
new_id = str(uuid.uuid4())
new_name = f"{original['name']} (Kopie)"
new_slug = f"{original['slug']}_copy_{uuid.uuid4().hex[:6]}"
cur.execute(
"""INSERT INTO ai_prompts (id, name, slug, description, template, category, active, sort_order, created, updated)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)""",
(new_id, new_name, new_slug, original['description'], original['template'],
original.get('category', 'ganzheitlich'), original['active'], original['sort_order'])
)
return {"id": new_id, "slug": new_slug, "name": new_name}
@router.put("/reorder")
def reorder_prompts(order: list[str], session: dict=Depends(require_admin)):
"""
Reorder prompts by providing list of IDs in desired order.
Args:
order: List of prompt IDs in new order
"""
with get_db() as conn:
cur = get_cursor(conn)
for idx, prompt_id in enumerate(order):
cur.execute(
"UPDATE ai_prompts SET sort_order=%s WHERE id=%s",
(idx, prompt_id)
)
return {"ok": True}
@router.post("/preview")
def preview_prompt(data: dict, session: dict=Depends(require_auth)):
"""
Preview a prompt template with real user data (without calling AI).
Args:
data: {"template": "Your template with {{placeholders}}"}
Returns:
{
"resolved": "Template with replaced placeholders",
"unknown_placeholders": ["list", "of", "unknown"]
}
"""
template = data.get('template', '')
profile_id = session['profile_id']
resolved = resolve_placeholders(template, profile_id)
unknown = get_unknown_placeholders(template)
return {
"resolved": resolved,
"unknown_placeholders": unknown
}
@router.get("/placeholders")
def list_placeholders(session: dict=Depends(require_auth)):
"""
Get list of available placeholders with example values.
Returns:
Dict mapping placeholder to example value using current user's data
"""
profile_id = session['profile_id']
return get_placeholder_example_values(profile_id)
# ── KI-Assisted Prompt Engineering ───────────────────────────────────────────
async def call_openrouter(prompt: str, max_tokens: int = 1500) -> str:
"""Call OpenRouter API to get AI response."""
if not OPENROUTER_KEY:
raise HTTPException(status_code=500, detail="OpenRouter API key not configured")
async with httpx.AsyncClient() as client:
resp = await client.post(
"https://openrouter.ai/api/v1/chat/completions",
headers={"Authorization": f"Bearer {OPENROUTER_KEY}"},
json={
"model": OPENROUTER_MODEL,
"messages": [{"role": "user", "content": prompt}],
"max_tokens": max_tokens
},
timeout=60.0
)
if resp.status_code != 200:
raise HTTPException(status_code=resp.status_code, detail=f"OpenRouter API error: {resp.text}")
return resp.json()['choices'][0]['message']['content'].strip()
def collect_example_data(profile_id: str, data_categories: list[str]) -> dict:
"""Collect example data from user's profile for specified categories."""
example_data = {}
with get_db() as conn:
cur = get_cursor(conn)
# Profil
cur.execute("SELECT * FROM profiles WHERE id=%s", (profile_id,))
profile = r2d(cur.fetchone())
example_data['profil'] = {
'name': profile.get('name', 'Nutzer'),
'age': profile.get('dob', 'unbekannt'),
'height': profile.get('height', 'unbekannt'),
'sex': profile.get('sex', 'unbekannt')
}
# Körper
if 'körper' in data_categories:
cur.execute(
"SELECT weight, date FROM weight_log WHERE profile_id=%s ORDER BY date DESC LIMIT 3",
(profile_id,)
)
weights = [r2d(r) for r in cur.fetchall()]
example_data['körper'] = {
'weight_entries': weights,
'latest_weight': f"{weights[0]['weight']:.1f} kg" if weights else "nicht verfügbar"
}
# Ernährung
if 'ernährung' in data_categories:
cur.execute(
"""SELECT kcal, protein, carb, fat, date FROM nutrition_log
WHERE profile_id=%s ORDER BY date DESC LIMIT 3""",
(profile_id,)
)
nutrition = [r2d(r) for r in cur.fetchall()]
example_data['ernährung'] = {
'recent_entries': nutrition
}
# Training
if 'training' in data_categories:
cur.execute(
"""SELECT activity_type, duration_min, kcal_active, date FROM activity_log
WHERE profile_id=%s ORDER BY date DESC LIMIT 5""",
(profile_id,)
)
activities = [r2d(r) for r in cur.fetchall()]
example_data['training'] = {
'recent_activities': activities
}
return example_data
@router.post("/generate")
async def generate_prompt(req: PromptGenerateRequest, session: dict=Depends(require_admin)):
"""
Generate AI prompt using KI based on user's goal description.
This is a meta-feature: KI helps create better prompts for KI analysis.
"""
profile_id = session['profile_id']
# Collect example data
example_data = collect_example_data(profile_id, req.data_categories)
# Get available placeholders for selected categories
available_placeholders = get_available_placeholders(req.data_categories)
placeholders_list = []
for cat, phs in available_placeholders.items():
placeholders_list.extend(phs)
# Build meta-prompt for prompt generation
meta_prompt = f"""Du bist ein Experte für Prompt-Engineering im Bereich Fitness & Gesundheit.
**Aufgabe:**
Erstelle einen optimalen KI-Prompt für folgendes Analyseziel:
"{req.goal}"
**Verfügbare Datenbereiche:**
{', '.join(req.data_categories)}
**Beispieldaten (aktuelle Werte des Nutzers):**
```json
{json.dumps(example_data, indent=2, ensure_ascii=False)}
```
**Verfügbare Platzhalter:**
{', '.join(placeholders_list)}
**Anforderungen an den Prompt:**
1. Nutze relevante Platzhalter ({{{{platzhalter_name}}}}) - diese werden durch echte Daten ersetzt
2. Sei spezifisch und klar in den Anweisungen
3. Fordere strukturierte Antworten (z.B. Abschnitte, Bullet Points)
4. Gib der KI Kontext über ihre Rolle/Expertise (z.B. "Du bist ein Sportwissenschaftler")
5. Fordere konkrete, umsetzbare Handlungsempfehlungen
6. Sprache: Deutsch
7. Der Prompt sollte 150-300 Wörter lang sein
{f'**Gewünschtes Antwort-Format:**\\n{req.example_output}' if req.example_output else ''}
**Generiere jetzt NUR den Prompt-Text (keine Erklärung, keine Metakommentare):**
"""
# Call AI to generate prompt
generated_prompt = await call_openrouter(meta_prompt, max_tokens=1000)
# Extract placeholders used
import re
placeholders_used = list(set(re.findall(r'\{\{(\w+)\}\}', generated_prompt)))
# Generate title from goal
title = generate_title_from_goal(req.goal)
# Infer category
category = infer_category(req.data_categories)
return {
"template": generated_prompt,
"placeholders_used": placeholders_used,
"example_data": example_data,
"suggested_title": title,
"suggested_category": category
}
def generate_title_from_goal(goal: str) -> str:
"""Generate a title from the goal description."""
goal_lower = goal.lower()
# Simple keyword matching
if 'protein' in goal_lower:
return 'Protein-Analyse'
elif 'gewicht' in goal_lower or 'abnehmen' in goal_lower:
return 'Gewichtstrend-Analyse'
elif 'training' in goal_lower or 'aktivität' in goal_lower:
return 'Trainingsanalyse'
elif 'schlaf' in goal_lower:
return 'Schlaf-Analyse'
elif 'regeneration' in goal_lower or 'erholung' in goal_lower:
return 'Regenerations-Analyse'
elif 'kraft' in goal_lower or 'muskel' in goal_lower:
return 'Kraftentwicklung'
elif 'ausdauer' in goal_lower or 'cardio' in goal_lower:
return 'Ausdauer-Analyse'
else:
return 'Neue Analyse'
def infer_category(data_categories: list[str]) -> str:
"""Infer prompt category from selected data categories."""
if len(data_categories) == 1:
return data_categories[0]
elif len(data_categories) > 2:
return 'ganzheitlich'
else:
# 2 categories: prefer the first one
return data_categories[0] if data_categories else 'ganzheitlich'
@router.post("/{prompt_id}/optimize")
async def optimize_prompt(prompt_id: str, session: dict=Depends(require_admin)):
"""
Analyze and optimize an existing prompt using KI.
Returns suggestions for improvement with score, strengths, weaknesses,
and an optimized version of the prompt.
"""
with get_db() as conn:
cur = get_cursor(conn)
cur.execute("SELECT * FROM ai_prompts WHERE id=%s", (prompt_id,))
prompt = r2d(cur.fetchone())
if not prompt:
raise HTTPException(status_code=404, detail="Prompt not found")
# Build meta-prompt for optimization
meta_prompt = f"""Du bist ein Experte für Prompt-Engineering.
**Analysiere folgenden KI-Prompt und schlage Verbesserungen vor:**
```
{prompt['template']}
```
**Analysiere folgende Aspekte:**
1. **Klarheit & Präzision:** Ist die Anweisung klar und eindeutig?
2. **Struktur & Lesbarkeit:** Ist der Prompt gut strukturiert?
3. **Platzhalter-Nutzung:** Werden relevante Platzhalter genutzt? Fehlen wichtige Daten?
4. **Antwort-Format:** Wird eine strukturierte Ausgabe gefordert?
5. **Kontext:** Hat die KI genug Rollenkontext (z.B. "Du bist ein Ernährungsexperte")?
6. **Handlungsempfehlungen:** Werden konkrete, umsetzbare Schritte gefordert?
**Gib deine Analyse als JSON zurück (NUR das JSON, keine zusätzlichen Kommentare):**
```json
{{
"score": 0-100,
"strengths": ["Stärke 1", "Stärke 2", "Stärke 3"],
"weaknesses": ["Schwäche 1", "Schwäche 2"],
"optimized_prompt": "Vollständig optimierte Version des Prompts",
"changes_summary": "Kurze Zusammenfassung was verbessert wurde (2-3 Sätze)"
}}
```
**Wichtig:**
- Die optimierte Version sollte alle Platzhalter beibehalten und ggf. ergänzen
- Sprache: Deutsch
- Der optimierte Prompt sollte 150-400 Wörter lang sein
"""
# Call AI for optimization
response = await call_openrouter(meta_prompt, max_tokens=1500)
# Parse JSON response
try:
# Extract JSON from markdown code blocks if present
if '```json' in response:
json_start = response.find('```json') + 7
json_end = response.find('```', json_start)
json_str = response[json_start:json_end].strip()
elif '```' in response:
json_start = response.find('```') + 3
json_end = response.find('```', json_start)
json_str = response[json_start:json_end].strip()
else:
json_str = response
analysis = json.loads(json_str)
except json.JSONDecodeError as e:
raise HTTPException(
status_code=500,
detail=f"Failed to parse AI response as JSON: {str(e)}. Response: {response[:200]}"
)
# Ensure required fields
if not all(k in analysis for k in ['score', 'strengths', 'weaknesses', 'optimized_prompt', 'changes_summary']):
raise HTTPException(
status_code=500,
detail=f"AI response missing required fields. Got: {list(analysis.keys())}"
)
return analysis

View File

@ -0,0 +1,293 @@
import { useState, useEffect } from 'react'
import { api } from '../utils/api'
import PromptEditModal from '../components/PromptEditModal'
export default function AdminPromptsPage() {
const [prompts, setPrompts] = useState([])
const [filteredPrompts, setFilteredPrompts] = useState([])
const [category, setCategory] = useState('all')
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
const [editingPrompt, setEditingPrompt] = useState(null)
const [showNewPrompt, setShowNewPrompt] = useState(false)
const categories = [
{ id: 'all', label: 'Alle Kategorien' },
{ id: 'körper', label: 'Körper' },
{ id: 'ernährung', label: 'Ernährung' },
{ id: 'training', label: 'Training' },
{ id: 'schlaf', label: 'Schlaf' },
{ id: 'vitalwerte', label: 'Vitalwerte' },
{ id: 'ziele', label: 'Ziele' },
{ id: 'ganzheitlich', label: 'Ganzheitlich' }
]
useEffect(() => {
loadPrompts()
}, [])
useEffect(() => {
if (category === 'all') {
setFilteredPrompts(prompts)
} else {
setFilteredPrompts(prompts.filter(p => p.category === category))
}
}, [category, prompts])
const loadPrompts = async () => {
try {
setLoading(true)
const data = await api.listAdminPrompts()
setPrompts(data)
setError(null)
} catch (e) {
setError(e.message)
} finally {
setLoading(false)
}
}
const handleToggleActive = async (prompt) => {
try {
await api.updatePrompt(prompt.id, { active: !prompt.active })
await loadPrompts()
} catch (e) {
alert('Fehler: ' + e.message)
}
}
const handleDelete = async (prompt) => {
if (!confirm(`Prompt "${prompt.name}" wirklich löschen?`)) return
try {
await api.deletePrompt(prompt.id)
await loadPrompts()
} catch (e) {
alert('Fehler: ' + e.message)
}
}
const handleDuplicate = async (prompt) => {
try {
await api.duplicatePrompt(prompt.id)
await loadPrompts()
} catch (e) {
alert('Fehler: ' + e.message)
}
}
const handleMoveUp = async (prompt) => {
const idx = filteredPrompts.findIndex(p => p.id === prompt.id)
if (idx === 0) return // Already at top
const above = filteredPrompts[idx - 1]
const newOrder = filteredPrompts.map(p => p.id)
newOrder[idx] = above.id
newOrder[idx - 1] = prompt.id
try {
await api.reorderPrompts(newOrder)
await loadPrompts()
} catch (e) {
alert('Fehler: ' + e.message)
}
}
const handleMoveDown = async (prompt) => {
const idx = filteredPrompts.findIndex(p => p.id === prompt.id)
if (idx === filteredPrompts.length - 1) return // Already at bottom
const below = filteredPrompts[idx + 1]
const newOrder = filteredPrompts.map(p => p.id)
newOrder[idx] = below.id
newOrder[idx + 1] = prompt.id
try {
await api.reorderPrompts(newOrder)
await loadPrompts()
} catch (e) {
alert('Fehler: ' + e.message)
}
}
const handleSavePrompt = async () => {
await loadPrompts()
setEditingPrompt(null)
setShowNewPrompt(false)
}
if (loading) {
return (
<div className="page">
<div style={{textAlign:'center', padding:40}}>
<div className="spinner"/>
</div>
</div>
)
}
return (
<div className="page">
<div style={{display:'flex', justifyContent:'space-between', alignItems:'center', marginBottom:24}}>
<h1 className="page-title">KI-Prompts Verwaltung</h1>
<button
className="btn btn-primary"
onClick={() => setShowNewPrompt(true)}
>
+ Neuer Prompt
</button>
</div>
{error && (
<div style={{padding:12, background:'#fee', color:'#c00', borderRadius:8, marginBottom:16}}>
{error}
</div>
)}
{/* Category Filter */}
<div style={{marginBottom:24}}>
<label style={{fontSize:13, fontWeight:600, marginBottom:8, display:'block'}}>
Kategorie filtern:
</label>
<select
className="form-select"
value={category}
onChange={e => setCategory(e.target.value)}
style={{maxWidth:300}}
>
{categories.map(cat => (
<option key={cat.id} value={cat.id}>{cat.label}</option>
))}
</select>
</div>
{/* Prompts Table */}
<div className="card">
<table style={{width:'100%', borderCollapse:'collapse'}}>
<thead>
<tr style={{borderBottom:'2px solid var(--border)', textAlign:'left'}}>
<th style={{padding:'12px 8px', fontSize:12, fontWeight:600, color:'var(--text3)'}}>
Titel
</th>
<th style={{padding:'12px 8px', fontSize:12, fontWeight:600, color:'var(--text3)'}}>
Kategorie
</th>
<th style={{padding:'12px 8px', fontSize:12, fontWeight:600, color:'var(--text3)'}}>
Aktiv
</th>
<th style={{padding:'12px 8px', fontSize:12, fontWeight:600, color:'var(--text3)'}}>
Reihenfolge
</th>
<th style={{padding:'12px 8px', fontSize:12, fontWeight:600, color:'var(--text3)'}}>
Aktionen
</th>
</tr>
</thead>
<tbody>
{filteredPrompts.map((prompt, idx) => (
<tr key={prompt.id} style={{borderBottom:'1px solid var(--border)'}}>
<td style={{padding:'12px 8px'}}>
<div style={{fontWeight:500, fontSize:14}}>{prompt.name}</div>
{prompt.description && (
<div style={{fontSize:11, color:'var(--text3)', marginTop:2}}>
{prompt.description}
</div>
)}
<div style={{fontSize:10, color:'var(--text3)', marginTop:4}}>
{prompt.slug}
</div>
</td>
<td style={{padding:'12px 8px'}}>
<span style={{
padding:'4px 8px', borderRadius:4, fontSize:11, fontWeight:500,
background:'var(--surface2)', color:'var(--text2)'
}}>
{prompt.category || 'ganzheitlich'}
</span>
</td>
<td style={{padding:'12px 8px'}}>
<input
type="checkbox"
checked={prompt.active}
onChange={() => handleToggleActive(prompt)}
style={{cursor:'pointer'}}
/>
</td>
<td style={{padding:'12px 8px'}}>
<div style={{display:'flex', gap:4}}>
<button
onClick={() => handleMoveUp(prompt)}
disabled={idx === 0}
style={{
padding:'4px 8px', fontSize:12, cursor: idx === 0 ? 'not-allowed' : 'pointer',
opacity: idx === 0 ? 0.5 : 1
}}
title="Nach oben"
>
</button>
<button
onClick={() => handleMoveDown(prompt)}
disabled={idx === filteredPrompts.length - 1}
style={{
padding:'4px 8px', fontSize:12,
cursor: idx === filteredPrompts.length - 1 ? 'not-allowed' : 'pointer',
opacity: idx === filteredPrompts.length - 1 ? 0.5 : 1
}}
title="Nach unten"
>
</button>
</div>
</td>
<td style={{padding:'12px 8px'}}>
<div style={{display:'flex', gap:6}}>
<button
className="btn"
onClick={() => setEditingPrompt(prompt)}
style={{fontSize:12, padding:'6px 12px'}}
>
Bearbeiten
</button>
<button
className="btn"
onClick={() => handleDuplicate(prompt)}
style={{fontSize:12, padding:'6px 12px'}}
>
Duplizieren
</button>
<button
className="btn"
onClick={() => handleDelete(prompt)}
style={{fontSize:12, padding:'6px 12px', color:'#D85A30'}}
>
Löschen
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
{filteredPrompts.length === 0 && (
<div style={{padding:40, textAlign:'center', color:'var(--text3)'}}>
Keine Prompts in dieser Kategorie
</div>
)}
</div>
{/* Edit Modal */}
{(editingPrompt || showNewPrompt) && (
<PromptEditModal
prompt={editingPrompt}
onSave={handleSavePrompt}
onClose={() => {
setEditingPrompt(null)
setShowNewPrompt(false)
}}
/>
)}
</div>
)
}