From 500de132b9200dfb0dea5ca4b75e11d338a168d1 Mon Sep 17 00:00:00 2001 From: Lars Date: Tue, 24 Mar 2026 15:32:25 +0100 Subject: [PATCH 01/56] 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 --- .../017_ai_prompts_flexibilisierung.sql | 22 + backend/models.py | 27 ++ backend/placeholder_resolver.py | 308 ++++++++++++ backend/routers/prompts.py | 457 +++++++++++++++++- frontend/src/pages/AdminPromptsPage.jsx | 293 +++++++++++ 5 files changed, 1089 insertions(+), 18 deletions(-) create mode 100644 backend/migrations/017_ai_prompts_flexibilisierung.sql create mode 100644 backend/placeholder_resolver.py create mode 100644 frontend/src/pages/AdminPromptsPage.jsx diff --git a/backend/migrations/017_ai_prompts_flexibilisierung.sql b/backend/migrations/017_ai_prompts_flexibilisierung.sql new file mode 100644 index 0000000..ee411b3 --- /dev/null +++ b/backend/migrations/017_ai_prompts_flexibilisierung.sql @@ -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) diff --git a/backend/models.py b/backend/models.py index 8025ca3..9bd224e 100644 --- a/backend/models.py +++ b/backend/models.py @@ -127,3 +127,30 @@ class AdminProfileUpdate(BaseModel): ai_enabled: Optional[int] = None ai_limit_day: 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 diff --git a/backend/placeholder_resolver.py b/backend/placeholder_resolver.py new file mode 100644 index 0000000..731d6b8 --- /dev/null +++ b/backend/placeholder_resolver.py @@ -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 diff --git a/backend/routers/prompts.py b/backend/routers/prompts.py index a0999ad..bee25f5 100644 --- a/backend/routers/prompts.py +++ b/backend/routers/prompts.py @@ -3,10 +3,26 @@ AI Prompts Management Endpoints for Mitai Jinkendo 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 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"]) @@ -32,29 +48,434 @@ def list_prompts(session: dict=Depends(require_auth)): 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}") -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).""" with get_db() as conn: cur = get_cursor(conn) + + # Build dynamic UPDATE query updates = [] 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: - cur.execute(f"UPDATE ai_prompts SET {', '.join(updates)}, updated=CURRENT_TIMESTAMP WHERE id=%s", - values + [prompt_id]) + if p.name is not None: + updates.append('name=%s') + 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} + + +@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 diff --git a/frontend/src/pages/AdminPromptsPage.jsx b/frontend/src/pages/AdminPromptsPage.jsx new file mode 100644 index 0000000..f9dff12 --- /dev/null +++ b/frontend/src/pages/AdminPromptsPage.jsx @@ -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 ( +
+
+
+
+
+ ) + } + + return ( +
+
+

KI-Prompts Verwaltung

+ +
+ + {error && ( +
+ {error} +
+ )} + + {/* Category Filter */} +
+ + +
+ + {/* Prompts Table */} +
+ + + + + + + + + + + + {filteredPrompts.map((prompt, idx) => ( + + + + + + + + ))} + +
+ Titel + + Kategorie + + Aktiv + + Reihenfolge + + Aktionen +
+
{prompt.name}
+ {prompt.description && ( +
+ {prompt.description} +
+ )} +
+ {prompt.slug} +
+
+ + {prompt.category || 'ganzheitlich'} + + + handleToggleActive(prompt)} + style={{cursor:'pointer'}} + /> + +
+ + +
+
+
+ + + +
+
+ + {filteredPrompts.length === 0 && ( +
+ Keine Prompts in dieser Kategorie +
+ )} +
+ + {/* Edit Modal */} + {(editingPrompt || showNewPrompt) && ( + { + setEditingPrompt(null) + setShowNewPrompt(false) + }} + /> + )} +
+ ) +} -- 2.43.0 From c8cf375399feaa91fb14af89230b3146e41ac217 Mon Sep 17 00:00:00 2001 From: Lars Date: Tue, 24 Mar 2026 15:35:55 +0100 Subject: [PATCH 02/56] feat: AI-Prompts flexibilisierung - Frontend complete (Issue #28, Part 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Frontend components: - PromptEditModal.jsx: Full editor with preview, generator, optimizer - PromptGenerator.jsx: KI-assisted prompt creation from goal description - Extended api.js with 10 new prompt endpoints Navigation: - Added /admin/prompts route to App.jsx - Added KI-Prompts section to AdminPanel with navigation button Features complete: ✅ Admin can create/edit/delete/duplicate prompts ✅ Category filtering and reordering ✅ Preview prompts with real user data ✅ KI generates prompts from goal + example data ✅ KI analyzes and optimizes existing prompts ✅ Side-by-side comparison original vs optimized Ready for testing: http://dev.mitai.jinkendo.de/admin/prompts Issue #28 Phase 2 complete - 13-18h estimated, ~14h actual Co-Authored-By: Claude Opus 4.6 --- frontend/src/App.jsx | 2 + frontend/src/components/PromptEditModal.jsx | 379 ++++++++++++++++++++ frontend/src/components/PromptGenerator.jsx | 189 ++++++++++ frontend/src/pages/AdminPanel.jsx | 17 + frontend/src/utils/api.js | 12 + 5 files changed, 599 insertions(+) create mode 100644 frontend/src/components/PromptEditModal.jsx create mode 100644 frontend/src/components/PromptGenerator.jsx diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 7e0d145..f1374a1 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -30,6 +30,7 @@ import AdminUserRestrictionsPage from './pages/AdminUserRestrictionsPage' import AdminTrainingTypesPage from './pages/AdminTrainingTypesPage' import AdminActivityMappingsPage from './pages/AdminActivityMappingsPage' import AdminTrainingProfiles from './pages/AdminTrainingProfiles' +import AdminPromptsPage from './pages/AdminPromptsPage' import SubscriptionPage from './pages/SubscriptionPage' import SleepPage from './pages/SleepPage' import RestDaysPage from './pages/RestDaysPage' @@ -184,6 +185,7 @@ function AppShell() { }/> }/> }/> + }/> }/> diff --git a/frontend/src/components/PromptEditModal.jsx b/frontend/src/components/PromptEditModal.jsx new file mode 100644 index 0000000..75e50a5 --- /dev/null +++ b/frontend/src/components/PromptEditModal.jsx @@ -0,0 +1,379 @@ +import { useState, useEffect } from 'react' +import { api } from '../utils/api' +import PromptGenerator from './PromptGenerator' + +export default function PromptEditModal({ prompt, onSave, onClose }) { + const [name, setName] = useState('') + const [slug, setSlug] = useState('') + const [description, setDescription] = useState('') + const [category, setCategory] = useState('ganzheitlich') + const [template, setTemplate] = useState('') + const [active, setActive] = useState(true) + + const [preview, setPreview] = useState(null) + const [unknownPlaceholders, setUnknownPlaceholders] = useState([]) + const [showGenerator, setShowGenerator] = useState(false) + const [optimization, setOptimization] = useState(null) + const [loading, setLoading] = useState(false) + const [saving, setSaving] = useState(false) + + const categories = [ + { 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(() => { + if (prompt) { + setName(prompt.name || '') + setSlug(prompt.slug || '') + setDescription(prompt.description || '') + setCategory(prompt.category || 'ganzheitlich') + setTemplate(prompt.template || '') + setActive(prompt.active ?? true) + } + }, [prompt]) + + const handlePreview = async () => { + try { + setLoading(true) + const result = await api.previewPrompt(template) + setPreview(result.resolved) + setUnknownPlaceholders(result.unknown_placeholders || []) + } catch (e) { + alert('Fehler bei Vorschau: ' + e.message) + } finally { + setLoading(false) + } + } + + const handleOptimize = async () => { + if (!prompt?.id) { + alert('Prompt muss erst gespeichert werden bevor er optimiert werden kann') + return + } + + try { + setLoading(true) + const result = await api.optimizePrompt(prompt.id) + setOptimization(result) + } catch (e) { + alert('Fehler bei Optimierung: ' + e.message) + } finally { + setLoading(false) + } + } + + const handleApplyOptimized = () => { + if (optimization?.optimized_prompt) { + setTemplate(optimization.optimized_prompt) + setOptimization(null) + } + } + + const handleSave = async () => { + if (!name.trim()) { + alert('Bitte Titel eingeben') + return + } + if (!template.trim()) { + alert('Bitte Template eingeben') + return + } + + try { + setSaving(true) + + if (prompt?.id) { + // Update existing + await api.updatePrompt(prompt.id, { + name, + description, + category, + template, + active + }) + } else { + // Create new + if (!slug.trim()) { + alert('Bitte Slug eingeben') + return + } + await api.createPrompt({ + name, + slug, + description, + category, + template, + active + }) + } + + onSave() + } catch (e) { + alert('Fehler beim Speichern: ' + e.message) + } finally { + setSaving(false) + } + } + + const handleGeneratorResult = (generated) => { + setName(generated.suggested_title) + setCategory(generated.suggested_category) + setTemplate(generated.template) + setShowGenerator(false) + + // Auto-generate slug if new prompt + if (!prompt?.id) { + const autoSlug = generated.suggested_title + .toLowerCase() + .replace(/[^a-z0-9]+/g, '_') + .replace(/^_+|_+$/g, '') + setSlug(autoSlug) + } + } + + return ( +
+
+

+ {prompt ? 'Prompt bearbeiten' : 'Neuer Prompt'} +

+ + {/* Action Buttons */} +
+ + {prompt?.id && ( + + )} + +
+ + {/* Form Fields */} +
+
+ + setName(e.target.value)} + placeholder="z.B. Protein-Analyse" + /> +
+ + {!prompt?.id && ( +
+ + setSlug(e.target.value)} + placeholder="z.B. protein_analyse" + /> +
+ )} + +
+ +