diff --git a/CLAUDE.md b/CLAUDE.md index 164a5a8..04004e4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -7,6 +7,26 @@ > | Coding-Regeln | `.claude/rules/CODING_RULES.md` | > | Lessons Learned | `.claude/rules/LESSONS_LEARNED.md` | +## Claude Code Verantwortlichkeiten + +**Issue-Management (Gitea):** +- ✅ Neue Issues/Feature Requests in Gitea anlegen +- ✅ Issue-Dokumentation in `docs/issues/` pflegen +- ✅ Issues mit Labels, Priority, Aufwandsschätzung versehen +- ✅ Bestehende Issues aktualisieren (Status, Beschreibung) +- ✅ Issues bei Fertigstellung schließen +- 🎯 Gitea: http://192.168.2.144:3000/Lars/mitai-jinkendo/issues + +**Dokumentation:** +- Code-Änderungen in CLAUDE.md dokumentieren +- Versions-Updates bei jedem Feature/Fix +- Library-Dateien bei größeren Änderungen aktualisieren + +**Entwicklung:** +- Alle Änderungen auf `develop` Branch +- Production Deploy nur nach expliziter Freigabe +- Migration 001-999 Pattern einhalten + ## Projekt-Übersicht **Mitai Jinkendo** (身体 Jinkendo) – selbst-gehostete PWA für Körper-Tracking mit KI-Auswertung. Teil der **Jinkendo**-App-Familie (人拳道). Domains: jinkendo.de / .com / .life @@ -56,7 +76,7 @@ frontend/src/ └── technical/ # MEMBERSHIP_SYSTEM.md ``` -## Aktuelle Version: v9c (komplett) 🚀 Production seit 21.03.2026 +## Aktuelle Version: v9e (Issue #28, #47 Complete) 🚀 Ready for Production 26.03.2026 ### Implementiert ✅ - Login (E-Mail + bcrypt), Auth-Middleware alle Endpoints, Rate Limiting @@ -188,6 +208,169 @@ frontend/src/ 📚 Details: `.claude/docs/technical/MEMBERSHIP_SYSTEM.md` · `.claude/docs/architecture/FEATURE_ENFORCEMENT.md` +### Issue #28: Unified Prompt System ✅ (Completed 26.03.2026) + +**AI-Prompts Flexibilisierung - Komplett überarbeitet:** + +- ✅ **Unified Prompt System (4 Phasen):** + - **Phase 1:** DB-Migration - Schema erweitert + - `ai_prompts` um `type`, `stages`, `output_format`, `output_schema` erweitert + - Alle Prompts zu 1-stage Pipelines migriert + - Pipeline-Configs in `ai_prompts` konsolidiert + - **Phase 2:** Backend Executor + - `prompt_executor.py` - universeller Executor für base + pipeline + - Dynamische Placeholder-Auflösung (`{{stage_N_key}}`) + - JSON-Output-Validierung + - Multi-stage parallele Ausführung + - Reference (Basis-Prompts) + Inline (Templates) Support + - **Phase 3:** Frontend UI Consolidation + - `UnifiedPromptModal` - ein Editor für beide Typen + - `AdminPromptsPage` - Tab-Switcher entfernt, Type-Filter hinzugefügt + - Stage-Editor mit Add/Remove/Reorder + - Mobile-ready Design + - **Phase 4:** Cleanup & Docs + - Deprecated Komponenten entfernt (PipelineConfigModal, PromptEditModal) + - Old endpoints behalten für Backward-Compatibility + +**Features:** +- Unbegrenzte dynamische Stages (keine 3-Stage Limitierung mehr) +- Mehrere Prompts pro Stage (parallel) +- Zwei Prompt-Typen: `base` (wiederverwendbar) + `pipeline` (Workflows) +- Inline-Templates oder Referenzen zu Basis-Prompts +- JSON-Output erzwingbar pro Prompt +- Cross-Module Korrelationen möglich + +**Debug & Development Tools (26.03.2026):** +- ✅ **Comprehensive Debug System:** + - Test-Button in Prompt-Editor mit Debug-Modus + - Shows resolved/unresolved placeholders + - Displays final prompts sent to AI + - Per-stage debug info for pipelines + - Export debug data as JSON +- ✅ **Placeholder Export (per Test):** + - Button in Debug-Viewer + - Exports all placeholders with values per execution +- ✅ **Global Placeholder Export:** + - Settings → "📊 Platzhalter exportieren" + - All 32 placeholders with current values + - Organized by category + - Includes metadata (description, example) +- ✅ **Batch Import/Export:** + - Admin → "📦 Alle exportieren" (all prompts as JSON) + - Admin → "📥 Importieren" (upload JSON, create/update) + - Dev→Prod sync in 2 clicks + +**Placeholder System Enhancements:** +- ✅ **6 New Placeholder Functions:** + - `{{sleep_avg_duration}}` - Average sleep duration (7d) + - `{{sleep_avg_quality}}` - Deep+REM percentage (7d) + - `{{rest_days_count}}` - Rest days count (30d) + - `{{vitals_avg_hr}}` - Average resting heart rate (7d) + - `{{vitals_avg_hrv}}` - Average HRV (7d) + - `{{vitals_vo2_max}}` - Latest VO2 Max value +- ✅ **7 Reconstructed Placeholders:** + - `{{caliper_summary}}`, `{{circ_summary}}` + - `{{goal_weight}}`, `{{goal_bf_pct}}` + - `{{nutrition_days}}` + - `{{protein_ziel_low}}`, `{{protein_ziel_high}}` + - `{{activity_detail}}` +- **Total: 32 active placeholders** across 6 categories + +**Bug Fixes (26.03.2026):** +- ✅ **PIPELINE_MASTER Response:** Analysis page now uses unified executor + - Fixed: Old `/insights/run` endpoint sent raw template to AI + - Now: `/prompts/execute` with proper stage processing +- ✅ **Age Calculation:** Handle PostgreSQL DATE objects + - Fixed: `calculate_age()` expected string, got date object + - Now: Handles both strings and date objects +- ✅ **Sleep Quality 0%:** Lowercase stage names + - Fixed: Checked ['Deep', 'REM'], but stored as ['deep', 'rem'] + - Now: Correct case-sensitive matching +- ✅ **SQL Column Name Errors:** + - Fixed: `bf_jpl` → `body_fat_pct` + - Fixed: `brust` → `c_chest`, etc. + - Fixed: `protein` → `protein_g` +- ✅ **Decimal × Float Type Error:** + - Fixed: `protein_ziel_low/high` calculations + - Now: Convert Decimal to float before multiplication + +**Migrations:** +- Migration 020: Unified Prompt System Schema + +**Backend Endpoints:** +- `POST /api/prompts/execute` - Universeller Executor (with save=true param) +- `POST /api/prompts/unified` - Create unified prompt +- `PUT /api/prompts/unified/{id}` - Update unified prompt +- `GET /api/prompts/export-all` - Export all prompts as JSON +- `POST /api/prompts/import` - Import prompts from JSON (with overwrite option) +- `GET /api/prompts/placeholders/export-values` - Export all placeholder values + +**UI:** +- Admin → KI-Prompts: Type-Filter (Alle/Basis/Pipeline) +- Neuer Prompt-Editor mit dynamischem Stage-Builder +- Inline editing von Stages + Prompts +- Test-Button mit Debug-Viewer (always visible) +- Export/Import Buttons (📦 Alle exportieren, 📥 Importieren) +- Settings → 📊 Platzhalter exportieren + +📚 Details: `.claude/docs/functional/AI_PROMPTS.md` + +**Related Gitea Issues:** +- #28: Unified Prompt System - ✅ CLOSED (26.03.2026) +- #43: Enhanced Debug UI - 🔲 OPEN (Future enhancement) +- #44: BUG - Analysen löschen - 🔲 OPEN (High priority) +- #45: KI Prompt-Optimierer - 🔲 OPEN (Future feature) +- #46: KI Prompt-Ersteller - 🔲 OPEN (Future feature) +- #47: Value Table - ✅ CLOSED (26.03.2026) + +### Issue #47: Comprehensive Value Table ✅ (Completed 26.03.2026) + +**AI-Analyse Transparenz - Vollständige Platzhalter-Anzeige:** + +- ✅ **Metadata Collection System:** + - Alle genutzten Platzhalter mit Werten während Ausführung gesammelt + - Vollständige (nicht gekürzte) Werte aus placeholder_resolver + - Kategorisierung nach Modul (Profil, Körper, Ernährung, Training, etc.) + - Speicherung in ai_insights.metadata (JSONB) + +- ✅ **Expert Mode:** + - Toggle-Button "🔬 Experten-Modus" in Analysis-Seite + - Normal-Modus: Zeigt nur relevante, gefüllte Werte + - Experten-Modus: Zeigt alle Werte inkl. Rohdaten und Stage-Outputs + +- ✅ **Stage Output Extraction:** + - Basis-Prompts mit JSON-Output: Einzelwerte extrahiert + - Jedes Feld aus Stage-JSON als eigene Zeile + - Visuelle Kennzeichnung: ↳ für extrahierte Werte + - Source-Tracking: Welche Stage, welcher Output + +- ✅ **Category Grouping:** + - Gruppierung nach Kategorien (PROFIL, KÖRPER, ERNÄHRUNG, etc.) + - Stage-Outputs als eigene Kategorien ("Stage 1 - Body") + - Rohdaten-Sektion (nur im Experten-Modus) + - Sortierung: Reguläre → Stage-Outputs → Rohdaten + +- ✅ **Value Table Features:** + - Drei Spalten: Platzhalter | Wert | Beschreibung + - Keine Kürzung langer Werte + - Kategorie-Header mit grauem Hintergrund + - Empty/nicht verfügbar Werte ausgeblendet (Normal-Modus) + +**Migrations:** +- Migration 021: ai_insights.metadata JSONB column + +**Backend Endpoints:** +- `POST /api/prompts/execute` - Erweitert um Metadata-Collection +- `GET /api/insights/placeholders/catalog` - Placeholder-Kategorien + +**UI:** +- Analysis Page: Value Table mit Category-Grouping +- Expert-Mode Toggle (🔬 Symbol) +- Collapsible JSON für Rohdaten +- Delete-Button für Insights (🗑️) + +📚 Details: `.claude/docs/functional/AI_PROMPTS.md` + ## Feature-Roadmap > 📋 **Detaillierte Roadmap:** `.claude/docs/ROADMAP.md` (Phasen 0-3, Timeline, Abhängigkeiten) 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/migrations/018_prompt_display_name.sql b/backend/migrations/018_prompt_display_name.sql new file mode 100644 index 0000000..045ccc0 --- /dev/null +++ b/backend/migrations/018_prompt_display_name.sql @@ -0,0 +1,20 @@ +-- Migration 018: Add display_name to ai_prompts for user-facing labels + +ALTER TABLE ai_prompts ADD COLUMN IF NOT EXISTS display_name VARCHAR(100); + +-- Migrate existing prompts from hardcoded SLUG_LABELS +UPDATE ai_prompts SET display_name = '🔍 Gesamtanalyse' WHERE slug = 'gesamt' AND display_name IS NULL; +UPDATE ai_prompts SET display_name = '🫧 Körperkomposition' WHERE slug = 'koerper' AND display_name IS NULL; +UPDATE ai_prompts SET display_name = '🍽️ Ernährung' WHERE slug = 'ernaehrung' AND display_name IS NULL; +UPDATE ai_prompts SET display_name = '🏋️ Aktivität' WHERE slug = 'aktivitaet' AND display_name IS NULL; +UPDATE ai_prompts SET display_name = '❤️ Gesundheitsindikatoren' WHERE slug = 'gesundheit' AND display_name IS NULL; +UPDATE ai_prompts SET display_name = '🎯 Zielfortschritt' WHERE slug = 'ziele' AND display_name IS NULL; +UPDATE ai_prompts SET display_name = '🔬 Mehrstufige Gesamtanalyse' WHERE slug = 'pipeline' AND display_name IS NULL; +UPDATE ai_prompts SET display_name = '🔬 Pipeline: Körper-Analyse (JSON)' WHERE slug = 'pipeline_body' AND display_name IS NULL; +UPDATE ai_prompts SET display_name = '🔬 Pipeline: Ernährungs-Analyse (JSON)' WHERE slug = 'pipeline_nutrition' AND display_name IS NULL; +UPDATE ai_prompts SET display_name = '🔬 Pipeline: Aktivitäts-Analyse (JSON)' WHERE slug = 'pipeline_activity' AND display_name IS NULL; +UPDATE ai_prompts SET display_name = '🔬 Pipeline: Synthese' WHERE slug = 'pipeline_synthesis' AND display_name IS NULL; +UPDATE ai_prompts SET display_name = '🔬 Pipeline: Zielabgleich' WHERE slug = 'pipeline_goals' AND display_name IS NULL; + +-- Fallback: use name as display_name if still NULL +UPDATE ai_prompts SET display_name = name WHERE display_name IS NULL; diff --git a/backend/migrations/019_pipeline_system.sql b/backend/migrations/019_pipeline_system.sql new file mode 100644 index 0000000..aa009c2 --- /dev/null +++ b/backend/migrations/019_pipeline_system.sql @@ -0,0 +1,157 @@ +-- Migration 019: Pipeline-System - Konfigurierbare mehrstufige Analysen +-- Ermöglicht Admin-Verwaltung von Pipeline-Konfigurationen (Issue #28) +-- Created: 2026-03-25 + +-- ======================================== +-- 1. Erweitere ai_prompts für Reset-Feature +-- ======================================== +ALTER TABLE ai_prompts +ADD COLUMN IF NOT EXISTS is_system_default BOOLEAN DEFAULT FALSE, +ADD COLUMN IF NOT EXISTS default_template TEXT; + +COMMENT ON COLUMN ai_prompts.is_system_default IS 'true = System-Prompt mit Reset-Funktion'; +COMMENT ON COLUMN ai_prompts.default_template IS 'Original-Template für Reset-to-Default'; + +-- Markiere bestehende Pipeline-Prompts als System-Defaults +UPDATE ai_prompts +SET + is_system_default = true, + default_template = template +WHERE slug LIKE 'pipeline_%'; + +-- ======================================== +-- 2. Create pipeline_configs table +-- ======================================== +CREATE TABLE IF NOT EXISTS pipeline_configs ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + name VARCHAR(255) NOT NULL UNIQUE, + description TEXT, + is_default BOOLEAN DEFAULT FALSE, + active BOOLEAN DEFAULT TRUE, + + -- Module configuration: which data sources to include + modules JSONB NOT NULL DEFAULT '{}'::jsonb, + -- Example: {"körper": true, "ernährung": true, "training": true, "schlaf": false} + + -- Timeframes per module (days) + timeframes JSONB NOT NULL DEFAULT '{}'::jsonb, + -- Example: {"körper": 30, "ernährung": 30, "training": 14} + + -- Stage 1 prompts (parallel execution) + stage1_prompts TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[], + -- Example: ARRAY['pipeline_body', 'pipeline_nutrition', 'pipeline_activity'] + + -- Stage 2 prompt (synthesis) + stage2_prompt VARCHAR(100) NOT NULL, + -- Example: 'pipeline_synthesis' + + -- Stage 3 prompt (optional, e.g., goals) + stage3_prompt VARCHAR(100), + -- Example: 'pipeline_goals' + + created TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + updated TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP +); + +-- ======================================== +-- 3. Create indexes +-- ======================================== +CREATE INDEX IF NOT EXISTS idx_pipeline_configs_default ON pipeline_configs(is_default) WHERE is_default = true; +CREATE INDEX IF NOT EXISTS idx_pipeline_configs_active ON pipeline_configs(active); + +-- ======================================== +-- 4. Seed: Standard-Pipeline "Alltags-Check" +-- ======================================== +INSERT INTO pipeline_configs ( + name, + description, + is_default, + modules, + timeframes, + stage1_prompts, + stage2_prompt, + stage3_prompt +) VALUES ( + 'Alltags-Check', + 'Standard-Analyse: Körper, Ernährung, Training über die letzten 2-4 Wochen', + true, + '{"körper": true, "ernährung": true, "training": true, "schlaf": false, "vitalwerte": false, "mentales": false, "ziele": false}'::jsonb, + '{"körper": 30, "ernährung": 30, "training": 14}'::jsonb, + ARRAY['pipeline_body', 'pipeline_nutrition', 'pipeline_activity'], + 'pipeline_synthesis', + 'pipeline_goals' +) ON CONFLICT (name) DO NOTHING; + +-- ======================================== +-- 5. Seed: Erweiterte Pipelines (optional) +-- ======================================== + +-- Schlaf-Fokus Pipeline (wenn Schlaf-Prompts existieren) +INSERT INTO pipeline_configs ( + name, + description, + is_default, + modules, + timeframes, + stage1_prompts, + stage2_prompt, + stage3_prompt +) VALUES ( + 'Schlaf & Erholung', + 'Analyse von Schlaf, Vitalwerten und Erholungsstatus', + false, + '{"schlaf": true, "vitalwerte": true, "training": true, "körper": false, "ernährung": false, "mentales": false, "ziele": false}'::jsonb, + '{"schlaf": 14, "vitalwerte": 7, "training": 14}'::jsonb, + ARRAY['pipeline_sleep', 'pipeline_vitals', 'pipeline_activity'], + 'pipeline_synthesis', + NULL +) ON CONFLICT (name) DO NOTHING; + +-- Wettkampf-Analyse (langfristiger Trend) +INSERT INTO pipeline_configs ( + name, + description, + is_default, + modules, + timeframes, + stage1_prompts, + stage2_prompt, + stage3_prompt +) VALUES ( + 'Wettkampf-Analyse', + 'Langfristige Analyse für Wettkampfvorbereitung (90 Tage)', + false, + '{"körper": true, "training": true, "vitalwerte": true, "ernährung": true, "schlaf": false, "mentales": false, "ziele": true}'::jsonb, + '{"körper": 90, "training": 90, "vitalwerte": 30, "ernährung": 60}'::jsonb, + ARRAY['pipeline_body', 'pipeline_activity', 'pipeline_vitals', 'pipeline_nutrition'], + 'pipeline_synthesis', + 'pipeline_goals' +) ON CONFLICT (name) DO NOTHING; + +-- ======================================== +-- 6. Trigger für updated timestamp +-- ======================================== +DROP TRIGGER IF EXISTS trigger_pipeline_configs_updated ON pipeline_configs; +CREATE TRIGGER trigger_pipeline_configs_updated + BEFORE UPDATE ON pipeline_configs + FOR EACH ROW + EXECUTE FUNCTION update_updated_timestamp(); + +-- ======================================== +-- 7. Constraints & Validation +-- ======================================== + +-- Only one default config allowed (enforced via partial unique index) +CREATE UNIQUE INDEX IF NOT EXISTS idx_pipeline_configs_single_default +ON pipeline_configs(is_default) +WHERE is_default = true; + +-- ======================================== +-- 8. Comments (Documentation) +-- ======================================== +COMMENT ON TABLE pipeline_configs IS 'v9f Issue #28: Konfigurierbare Pipeline-Analysen. Admins können mehrere Pipeline-Configs erstellen mit unterschiedlichen Modulen und Zeiträumen.'; +COMMENT ON COLUMN pipeline_configs.modules IS 'JSONB: Welche Module aktiv sind (boolean flags)'; +COMMENT ON COLUMN pipeline_configs.timeframes IS 'JSONB: Zeiträume pro Modul in Tagen'; +COMMENT ON COLUMN pipeline_configs.stage1_prompts IS 'Array von slug-Werten für parallele Stage-1-Prompts'; +COMMENT ON COLUMN pipeline_configs.stage2_prompt IS 'Slug des Synthese-Prompts (kombiniert Stage-1-Ergebnisse)'; +COMMENT ON COLUMN pipeline_configs.stage3_prompt IS 'Optionaler Slug für Stage-3-Prompt (z.B. Zielabgleich)'; diff --git a/backend/migrations/020_unified_prompt_system.sql b/backend/migrations/020_unified_prompt_system.sql new file mode 100644 index 0000000..7d69df2 --- /dev/null +++ b/backend/migrations/020_unified_prompt_system.sql @@ -0,0 +1,128 @@ +-- Migration 020: Unified Prompt System (Issue #28) +-- Consolidate ai_prompts and pipeline_configs into single system +-- Type: 'base' (reusable building blocks) or 'pipeline' (workflows) + +-- Step 1: Add new columns to ai_prompts and make template nullable +ALTER TABLE ai_prompts +ADD COLUMN IF NOT EXISTS type VARCHAR(20) DEFAULT 'pipeline', +ADD COLUMN IF NOT EXISTS stages JSONB, +ADD COLUMN IF NOT EXISTS output_format VARCHAR(10) DEFAULT 'text', +ADD COLUMN IF NOT EXISTS output_schema JSONB; + +-- Make template nullable (pipeline-type prompts use stages instead) +ALTER TABLE ai_prompts +ALTER COLUMN template DROP NOT NULL; + +-- Step 2: Migrate existing single-prompts to 1-stage pipeline format +-- All existing prompts become single-stage pipelines with inline source +UPDATE ai_prompts +SET + type = 'pipeline', + stages = jsonb_build_array( + jsonb_build_object( + 'stage', 1, + 'prompts', jsonb_build_array( + jsonb_build_object( + 'source', 'inline', + 'template', template, + 'output_key', REPLACE(slug, 'pipeline_', ''), + 'output_format', 'text' + ) + ) + ) + ), + output_format = 'text' +WHERE stages IS NULL; + +-- Step 3: Migrate pipeline_configs into ai_prompts as multi-stage pipelines +-- Each pipeline_config becomes a pipeline-type prompt with multiple stages +INSERT INTO ai_prompts ( + slug, + name, + description, + type, + stages, + output_format, + active, + is_system_default, + category +) +SELECT + 'pipeline_config_' || LOWER(REPLACE(pc.name, ' ', '_')) || '_' || SUBSTRING(pc.id::TEXT FROM 1 FOR 8) as slug, + pc.name, + pc.description, + 'pipeline' as type, + -- Build stages JSONB: combine stage1_prompts, stage2_prompt, stage3_prompt + ( + -- Stage 1: Convert array to prompts + SELECT jsonb_agg(stage_obj ORDER BY stage_num) + FROM ( + SELECT 1 as stage_num, + jsonb_build_object( + 'stage', 1, + 'prompts', ( + SELECT jsonb_agg( + jsonb_build_object( + 'source', 'reference', + 'slug', s1.slug_val, + 'output_key', REPLACE(s1.slug_val, 'pipeline_', 'stage1_'), + 'output_format', 'json' + ) + ) + FROM UNNEST(pc.stage1_prompts) AS s1(slug_val) + ) + ) as stage_obj + WHERE array_length(pc.stage1_prompts, 1) > 0 + + UNION ALL + + SELECT 2 as stage_num, + jsonb_build_object( + 'stage', 2, + 'prompts', jsonb_build_array( + jsonb_build_object( + 'source', 'reference', + 'slug', pc.stage2_prompt, + 'output_key', 'synthesis', + 'output_format', 'text' + ) + ) + ) as stage_obj + WHERE pc.stage2_prompt IS NOT NULL + + UNION ALL + + SELECT 3 as stage_num, + jsonb_build_object( + 'stage', 3, + 'prompts', jsonb_build_array( + jsonb_build_object( + 'source', 'reference', + 'slug', pc.stage3_prompt, + 'output_key', 'goals', + 'output_format', 'text' + ) + ) + ) as stage_obj + WHERE pc.stage3_prompt IS NOT NULL + ) all_stages + ) as stages, + 'text' as output_format, + pc.active, + pc.is_default as is_system_default, + 'pipeline' as category +FROM pipeline_configs pc; + +-- Step 4: Add indices for performance +CREATE INDEX IF NOT EXISTS idx_ai_prompts_type ON ai_prompts(type); +CREATE INDEX IF NOT EXISTS idx_ai_prompts_stages ON ai_prompts USING GIN (stages); + +-- Step 5: Add comment explaining stages structure +COMMENT ON COLUMN ai_prompts.stages IS 'JSONB array of stages, each with prompts array. Structure: [{"stage":1,"prompts":[{"source":"reference|inline","slug":"...","template":"...","output_key":"key","output_format":"text|json"}]}]'; + +-- Step 6: Backup pipeline_configs before eventual deletion +CREATE TABLE IF NOT EXISTS pipeline_configs_backup_pre_020 AS +SELECT * FROM pipeline_configs; + +-- Note: We keep pipeline_configs table for now during transition period +-- It can be dropped in a later migration once all code is migrated diff --git a/backend/migrations/021_ai_insights_metadata.sql b/backend/migrations/021_ai_insights_metadata.sql new file mode 100644 index 0000000..80bf0c9 --- /dev/null +++ b/backend/migrations/021_ai_insights_metadata.sql @@ -0,0 +1,7 @@ +-- Migration 021: Add metadata column to ai_insights for storing debug info +-- Date: 2026-03-26 +-- Purpose: Store resolved placeholder values with descriptions for transparency + +ALTER TABLE ai_insights ADD COLUMN IF NOT EXISTS metadata JSONB DEFAULT NULL; + +COMMENT ON COLUMN ai_insights.metadata IS 'Debug info: resolved placeholders, descriptions, etc.'; diff --git a/backend/models.py b/backend/models.py index 8025ca3..4c0c39b 100644 --- a/backend/models.py +++ b/backend/models.py @@ -127,3 +127,116 @@ 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 + display_name: Optional[str] = None + description: Optional[str] = None + template: str + category: str = 'ganzheitlich' + active: bool = True + sort_order: int = 0 + + +class PromptUpdate(BaseModel): + name: Optional[str] = None + display_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 + + +# ── Unified Prompt System Models (Issue #28 Phase 2) ─────────────────────── + +class StagePromptCreate(BaseModel): + """Single prompt within a stage""" + source: str # 'inline' or 'reference' + slug: Optional[str] = None # Required if source='reference' + template: Optional[str] = None # Required if source='inline' + output_key: str # Key for storing result (e.g., 'nutrition', 'stage1_body') + output_format: str = 'text' # 'text' or 'json' + output_schema: Optional[dict] = None # JSON schema if output_format='json' + + +class StageCreate(BaseModel): + """Single stage with multiple prompts""" + stage: int # Stage number (1, 2, 3, ...) + prompts: list[StagePromptCreate] + + +class UnifiedPromptCreate(BaseModel): + """Create a new unified prompt (base or pipeline type)""" + name: str + slug: str + display_name: Optional[str] = None + description: Optional[str] = None + type: str # 'base' or 'pipeline' + category: str = 'ganzheitlich' + active: bool = True + sort_order: int = 0 + + # For base prompts (single reusable template) + template: Optional[str] = None # Required if type='base' + output_format: str = 'text' + output_schema: Optional[dict] = None + + # For pipeline prompts (multi-stage workflow) + stages: Optional[list[StageCreate]] = None # Required if type='pipeline' + + +class UnifiedPromptUpdate(BaseModel): + """Update an existing unified prompt""" + name: Optional[str] = None + display_name: Optional[str] = None + description: Optional[str] = None + type: Optional[str] = None + category: Optional[str] = None + active: Optional[bool] = None + sort_order: Optional[int] = None + template: Optional[str] = None + output_format: Optional[str] = None + output_schema: Optional[dict] = None + stages: Optional[list[StageCreate]] = None + + +# ── Pipeline Config Models (Issue #28) ───────────────────────────────────── +# NOTE: These will be deprecated in favor of UnifiedPrompt models above + +class PipelineConfigCreate(BaseModel): + name: str + description: Optional[str] = None + is_default: bool = False + active: bool = True + modules: dict # {"körper": true, "ernährung": true, ...} + timeframes: dict # {"körper": 30, "ernährung": 30, ...} + stage1_prompts: list[str] # Array of slugs + stage2_prompt: str # slug + stage3_prompt: Optional[str] = None # slug + + +class PipelineConfigUpdate(BaseModel): + name: Optional[str] = None + description: Optional[str] = None + is_default: Optional[bool] = None + active: Optional[bool] = None + modules: Optional[dict] = None + timeframes: Optional[dict] = None + stage1_prompts: Optional[list[str]] = None + stage2_prompt: Optional[str] = None + stage3_prompt: Optional[str] = None + + +class PipelineExecuteRequest(BaseModel): + config_id: Optional[str] = None # None = use default config diff --git a/backend/placeholder_resolver.py b/backend/placeholder_resolver.py new file mode 100644 index 0000000..73e0030 --- /dev/null +++ b/backend/placeholder_resolver.py @@ -0,0 +1,715 @@ +""" +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 body_fat_pct FROM caliper_log + WHERE profile_id=%s AND body_fat_pct IS NOT NULL + ORDER BY date DESC LIMIT 1""", + (profile_id,) + ) + row = cur.fetchone() + return f"{row['body_fat_pct']:.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') + + # Map field names to actual column names + field_map = { + 'protein': 'protein_g', + 'fat': 'fat_g', + 'carb': 'carbs_g', + 'kcal': 'kcal' + } + db_field = field_map.get(field, field) + + cur.execute( + f"""SELECT AVG({db_field}) as avg FROM nutrition_log + WHERE profile_id=%s AND date >= %s AND {db_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_caliper_summary(profile_id: str) -> str: + """Get latest caliper measurements summary.""" + with get_db() as conn: + cur = get_cursor(conn) + cur.execute( + """SELECT body_fat_pct, sf_method, date FROM caliper_log + WHERE profile_id=%s AND body_fat_pct IS NOT NULL + ORDER BY date DESC LIMIT 1""", + (profile_id,) + ) + row = r2d(cur.fetchone()) if cur.rowcount > 0 else None + + if not row: + return "keine Caliper-Messungen" + + method = row.get('sf_method', 'unbekannt') + return f"{row['body_fat_pct']:.1f}% ({method} am {row['date']})" + + +def get_circ_summary(profile_id: str) -> str: + """Get latest circumference measurements summary with age annotations. + + For each measurement point, fetches the most recent value (even if from different dates). + Annotates each value with measurement age for AI context. + """ + with get_db() as conn: + cur = get_cursor(conn) + + # Define all circumference points with their labels + fields = [ + ('c_neck', 'Nacken'), + ('c_chest', 'Brust'), + ('c_waist', 'Taille'), + ('c_belly', 'Bauch'), + ('c_hip', 'Hüfte'), + ('c_thigh', 'Oberschenkel'), + ('c_calf', 'Wade'), + ('c_arm', 'Arm') + ] + + parts = [] + today = datetime.now().date() + + # Get latest value for each field individually + for field_name, label in fields: + cur.execute( + f"""SELECT {field_name}, date, + CURRENT_DATE - date AS age_days + FROM circumference_log + WHERE profile_id=%s AND {field_name} IS NOT NULL + ORDER BY date DESC LIMIT 1""", + (profile_id,) + ) + row = r2d(cur.fetchone()) if cur.rowcount > 0 else None + + if row: + value = row[field_name] + age_days = row['age_days'] + + # Format age annotation + if age_days == 0: + age_str = "heute" + elif age_days == 1: + age_str = "gestern" + elif age_days <= 7: + age_str = f"vor {age_days} Tagen" + elif age_days <= 30: + weeks = age_days // 7 + age_str = f"vor {weeks} Woche{'n' if weeks > 1 else ''}" + else: + months = age_days // 30 + age_str = f"vor {months} Monat{'en' if months > 1 else ''}" + + parts.append(f"{label} {value:.1f}cm ({age_str})") + + return ', '.join(parts) if parts else "keine Umfangsmessungen" + + +def get_goal_weight(profile_id: str) -> str: + """Get goal weight from profile.""" + profile = get_profile_data(profile_id) + goal = profile.get('goal_weight') + return f"{goal:.1f}" if goal else "nicht gesetzt" + + +def get_goal_bf_pct(profile_id: str) -> str: + """Get goal body fat percentage from profile.""" + profile = get_profile_data(profile_id) + goal = profile.get('goal_bf_pct') + return f"{goal:.1f}" if goal else "nicht gesetzt" + + +def get_nutrition_days(profile_id: str, days: int = 30) -> str: + """Get number of days with nutrition data.""" + with get_db() as conn: + cur = get_cursor(conn) + cutoff = (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d') + cur.execute( + """SELECT COUNT(DISTINCT date) as days FROM nutrition_log + WHERE profile_id=%s AND date >= %s""", + (profile_id, cutoff) + ) + row = cur.fetchone() + return str(row['days']) if row else "0" + + +def get_protein_ziel_low(profile_id: str) -> str: + """Calculate lower protein target based on current weight (1.6g/kg).""" + 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 row: + return f"{int(float(row['weight']) * 1.6)}" + return "nicht verfügbar" + + +def get_protein_ziel_high(profile_id: str) -> str: + """Calculate upper protein target based on current weight (2.2g/kg).""" + 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 row: + return f"{int(float(row['weight']) * 2.2)}" + 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) -> str: + """Calculate age from date of birth (accepts date object or string).""" + if not dob: + return "unbekannt" + try: + # Handle both datetime.date objects and strings + if isinstance(dob, str): + birth = datetime.strptime(dob, '%Y-%m-%d').date() + else: + birth = dob # Already a date object from PostgreSQL + + today = datetime.now().date() + age = today.year - birth.year - ((today.month, today.day) < (birth.month, birth.day)) + return str(age) + except Exception as e: + return "unbekannt" + + +def get_activity_detail(profile_id: str, days: int = 14) -> str: + """Get detailed activity log for analysis.""" + with get_db() as conn: + cur = get_cursor(conn) + cutoff = (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d') + cur.execute( + """SELECT date, activity_type, duration_min, kcal_active, hr_avg + FROM activity_log + WHERE profile_id=%s AND date >= %s + ORDER BY date DESC + LIMIT 50""", + (profile_id, cutoff) + ) + rows = [r2d(r) for r in cur.fetchall()] + + if not rows: + return f"Keine Aktivitäten in den letzten {days} Tagen" + + # Format as readable list + lines = [] + for r in rows: + hr_str = f" HF={r['hr_avg']}" if r.get('hr_avg') else "" + lines.append( + f"{r['date']}: {r['activity_type']} ({r['duration_min']}min, {r.get('kcal_active', 0)}kcal{hr_str})" + ) + + return '\n'.join(lines[:20]) # Max 20 entries to avoid token bloat + + +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) + + +def get_sleep_avg_duration(profile_id: str, days: int = 7) -> str: + """Calculate average sleep duration in hours.""" + with get_db() as conn: + cur = get_cursor(conn) + cutoff = (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d') + cur.execute( + """SELECT sleep_segments FROM sleep_log + WHERE profile_id=%s AND date >= %s + ORDER BY date DESC""", + (profile_id, cutoff) + ) + rows = cur.fetchall() + + if not rows: + return "nicht verfügbar" + + total_minutes = 0 + for row in rows: + segments = row['sleep_segments'] + if segments: + # Sum duration_min from all segments + for seg in segments: + total_minutes += seg.get('duration_min', 0) + + if total_minutes == 0: + return "nicht verfügbar" + + avg_hours = total_minutes / len(rows) / 60 + return f"{avg_hours:.1f}h" + + +def get_sleep_avg_quality(profile_id: str, days: int = 7) -> str: + """Calculate average sleep quality (Deep+REM %).""" + with get_db() as conn: + cur = get_cursor(conn) + cutoff = (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d') + cur.execute( + """SELECT sleep_segments FROM sleep_log + WHERE profile_id=%s AND date >= %s + ORDER BY date DESC""", + (profile_id, cutoff) + ) + rows = cur.fetchall() + + if not rows: + return "nicht verfügbar" + + total_quality = 0 + count = 0 + for row in rows: + segments = row['sleep_segments'] + if segments: + # Note: segments use 'phase' key (not 'stage'), stored lowercase (deep, rem, light, awake) + deep_rem_min = sum(s.get('duration_min', 0) for s in segments if s.get('phase') in ['deep', 'rem']) + total_min = sum(s.get('duration_min', 0) for s in segments) + if total_min > 0: + quality_pct = (deep_rem_min / total_min) * 100 + total_quality += quality_pct + count += 1 + + if count == 0: + return "nicht verfügbar" + + avg_quality = total_quality / count + return f"{avg_quality:.0f}% (Deep+REM)" + + +def get_rest_days_count(profile_id: str, days: int = 30) -> str: + """Count rest days in the given period.""" + with get_db() as conn: + cur = get_cursor(conn) + cutoff = (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d') + cur.execute( + """SELECT COUNT(DISTINCT date) as count FROM rest_days + WHERE profile_id=%s AND date >= %s""", + (profile_id, cutoff) + ) + row = cur.fetchone() + count = row['count'] if row else 0 + return f"{count} Ruhetage" + + +def get_vitals_avg_hr(profile_id: str, days: int = 7) -> str: + """Calculate average resting heart rate.""" + with get_db() as conn: + cur = get_cursor(conn) + cutoff = (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d') + cur.execute( + """SELECT AVG(resting_hr) as avg FROM vitals_baseline + WHERE profile_id=%s AND date >= %s AND resting_hr IS NOT NULL""", + (profile_id, cutoff) + ) + row = cur.fetchone() + + if row and row['avg']: + return f"{int(row['avg'])} bpm" + return "nicht verfügbar" + + +def get_vitals_avg_hrv(profile_id: str, days: int = 7) -> str: + """Calculate average heart rate variability.""" + with get_db() as conn: + cur = get_cursor(conn) + cutoff = (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d') + cur.execute( + """SELECT AVG(hrv) as avg FROM vitals_baseline + WHERE profile_id=%s AND date >= %s AND hrv IS NOT NULL""", + (profile_id, cutoff) + ) + row = cur.fetchone() + + if row and row['avg']: + return f"{int(row['avg'])} ms" + return "nicht verfügbar" + + +def get_vitals_vo2_max(profile_id: str) -> str: + """Get latest VO2 Max value.""" + with get_db() as conn: + cur = get_cursor(conn) + cur.execute( + """SELECT vo2_max FROM vitals_baseline + WHERE profile_id=%s AND vo2_max IS NOT NULL + ORDER BY date DESC LIMIT 1""", + (profile_id,) + ) + row = cur.fetchone() + + if row and row['vo2_max']: + return f"{row['vo2_max']:.1f} ml/kg/min" + return "nicht verfügbar" + + +# ── 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), + '{{caliper_summary}}': get_caliper_summary, + '{{circ_summary}}': get_circ_summary, + '{{goal_weight}}': get_goal_weight, + '{{goal_bf_pct}}': get_goal_bf_pct, + + # 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), + '{{nutrition_days}}': lambda pid: get_nutrition_days(pid, 30), + '{{protein_ziel_low}}': get_protein_ziel_low, + '{{protein_ziel_high}}': get_protein_ziel_high, + + # Training + '{{activity_summary}}': get_activity_summary, + '{{activity_detail}}': get_activity_detail, + '{{trainingstyp_verteilung}}': get_trainingstyp_verteilung, + + # Schlaf & Erholung + '{{sleep_avg_duration}}': lambda pid: get_sleep_avg_duration(pid, 7), + '{{sleep_avg_quality}}': lambda pid: get_sleep_avg_quality(pid, 7), + '{{rest_days_count}}': lambda pid: get_rest_days_count(pid, 30), + + # Vitalwerte + '{{vitals_avg_hr}}': lambda pid: get_vitals_avg_hr(pid, 7), + '{{vitals_avg_hrv}}': lambda pid: get_vitals_avg_hrv(pid, 7), + '{{vitals_vo2_max}}': get_vitals_vo2_max, + + # 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 + + +def get_placeholder_catalog(profile_id: str) -> Dict[str, List[Dict[str, str]]]: + """ + Get grouped placeholder catalog with descriptions and example values. + + Args: + profile_id: User profile ID + + Returns: + Dict mapping category to list of {key, description, example} + """ + # Placeholder definitions with descriptions + placeholders = { + 'Profil': [ + ('name', 'Name des Nutzers'), + ('age', 'Alter in Jahren'), + ('height', 'Körpergröße in cm'), + ('geschlecht', 'Geschlecht'), + ], + 'Körper': [ + ('weight_aktuell', 'Aktuelles Gewicht in kg'), + ('weight_trend', 'Gewichtstrend (7d/30d)'), + ('kf_aktuell', 'Aktueller Körperfettanteil in %'), + ('bmi', 'Body Mass Index'), + ], + 'Ernährung': [ + ('kcal_avg', 'Durchschn. Kalorien (30d)'), + ('protein_avg', 'Durchschn. Protein in g (30d)'), + ('carb_avg', 'Durchschn. Kohlenhydrate in g (30d)'), + ('fat_avg', 'Durchschn. Fett in g (30d)'), + ], + 'Training': [ + ('activity_summary', 'Aktivitäts-Zusammenfassung (7d)'), + ('trainingstyp_verteilung', 'Verteilung nach Trainingstypen'), + ], + 'Schlaf & Erholung': [ + ('sleep_avg_duration', 'Durchschn. Schlafdauer (7d)'), + ('sleep_avg_quality', 'Durchschn. Schlafqualität (7d)'), + ('rest_days_count', 'Anzahl Ruhetage (30d)'), + ], + 'Vitalwerte': [ + ('vitals_avg_hr', 'Durchschn. Ruhepuls (7d)'), + ('vitals_avg_hrv', 'Durchschn. HRV (7d)'), + ('vitals_vo2_max', 'Aktueller VO2 Max'), + ], + 'Zeitraum': [ + ('datum_heute', 'Heutiges Datum'), + ('zeitraum_7d', '7-Tage-Zeitraum'), + ('zeitraum_30d', '30-Tage-Zeitraum'), + ], + } + + catalog = {} + + for category, items in placeholders.items(): + catalog[category] = [] + for key, description in items: + placeholder = f'{{{{{key}}}}}' + # Get example value if resolver exists + resolver = PLACEHOLDER_MAP.get(placeholder) + if resolver: + try: + example = resolver(profile_id) + except Exception: + example = '[Nicht verfügbar]' + else: + example = '[Nicht implementiert]' + + catalog[category].append({ + 'key': key, + 'description': description, + 'example': str(example) + }) + + return catalog diff --git a/backend/prompt_executor.py b/backend/prompt_executor.py new file mode 100644 index 0000000..656868a --- /dev/null +++ b/backend/prompt_executor.py @@ -0,0 +1,526 @@ +""" +Unified Prompt Executor (Issue #28 Phase 2) + +Executes both base and pipeline-type prompts with: +- Dynamic placeholder resolution +- JSON output validation +- Multi-stage parallel execution +- Reference and inline prompt support +""" +import json +import re +from typing import Dict, Any, Optional +from db import get_db, get_cursor, r2d +from fastapi import HTTPException + + +def resolve_placeholders(template: str, variables: Dict[str, Any], debug_info: Optional[Dict] = None, catalog: Optional[Dict] = None) -> str: + """ + Replace {{placeholder}} with values from variables dict. + + Supports modifiers: + - {{key|d}} - Include description in parentheses (requires catalog) + + Args: + template: String with {{key}} or {{key|modifiers}} placeholders + variables: Dict of key -> value mappings + debug_info: Optional dict to collect debug information + catalog: Optional placeholder catalog for descriptions (from get_placeholder_catalog) + + Returns: + Template with placeholders replaced + """ + resolved = {} + unresolved = [] + + def replacer(match): + full_placeholder = match.group(1).strip() + + # Parse key and modifiers (e.g., "weight_aktuell|d" -> key="weight_aktuell", modifiers="d") + parts = full_placeholder.split('|') + key = parts[0].strip() + modifiers = parts[1].strip() if len(parts) > 1 else '' + + if key in variables: + value = variables[key] + # Convert dict/list to JSON string + if isinstance(value, (dict, list)): + resolved_value = json.dumps(value, ensure_ascii=False) + else: + resolved_value = str(value) + + # Apply modifiers + if 'd' in modifiers: + if catalog: + # Add description from catalog + description = None + for cat_items in catalog.values(): + matching = [item for item in cat_items if item['key'] == key] + if matching: + description = matching[0].get('description', '') + break + + if description: + resolved_value = f"{resolved_value} ({description})" + else: + # Catalog not available - log warning in debug + if debug_info is not None: + if 'warnings' not in debug_info: + debug_info['warnings'] = [] + debug_info['warnings'].append(f"Modifier |d used but catalog not available for {key}") + + # Track resolution for debug + if debug_info is not None: + resolved[key] = resolved_value[:100] + ('...' if len(resolved_value) > 100 else '') + + return resolved_value + else: + # Keep placeholder if no value found + if debug_info is not None: + unresolved.append(key) + return match.group(0) + + result = re.sub(r'\{\{([^}]+)\}\}', replacer, template) + + # Store debug info + if debug_info is not None: + debug_info['resolved_placeholders'] = resolved + debug_info['unresolved_placeholders'] = unresolved + + return result + + +def validate_json_output(output: str, schema: Optional[Dict] = None, debug_info: Optional[Dict] = None) -> Dict: + """ + Validate that output is valid JSON. + + Unwraps Markdown-wrapped JSON (```json ... ```) if present. + + Args: + output: String to validate + schema: Optional JSON schema to validate against (TODO: jsonschema library) + debug_info: Optional dict to attach to error for debugging + + Returns: + Parsed JSON dict + + Raises: + HTTPException: If output is not valid JSON (with debug info attached) + """ + # Try to unwrap Markdown code blocks (common AI pattern) + unwrapped = output.strip() + if unwrapped.startswith('```json'): + # Extract content between ```json and ``` + lines = unwrapped.split('\n') + if len(lines) > 2 and lines[-1].strip() == '```': + unwrapped = '\n'.join(lines[1:-1]) + elif unwrapped.startswith('```'): + # Generic code block + lines = unwrapped.split('\n') + if len(lines) > 2 and lines[-1].strip() == '```': + unwrapped = '\n'.join(lines[1:-1]) + + try: + parsed = json.loads(unwrapped) + # TODO: Add jsonschema validation if schema provided + return parsed + except json.JSONDecodeError as e: + error_detail = { + "error": f"AI returned invalid JSON: {str(e)}", + "raw_output": output[:500] + ('...' if len(output) > 500 else ''), + "unwrapped": unwrapped[:500] if unwrapped != output else None, + "output_length": len(output) + } + if debug_info: + error_detail["debug"] = debug_info + + raise HTTPException( + status_code=500, + detail=error_detail + ) + + +async def execute_prompt( + prompt_slug: str, + variables: Dict[str, Any], + openrouter_call_func, + enable_debug: bool = False +) -> Dict[str, Any]: + """ + Execute a single prompt (base or pipeline type). + + Args: + prompt_slug: Slug of prompt to execute + variables: Dict of variables for placeholder replacement + openrouter_call_func: Async function(prompt_text) -> response_text + enable_debug: If True, include debug information in response + + Returns: + Dict with execution results: + { + "type": "base" | "pipeline", + "slug": "...", + "output": "..." | {...}, # String or parsed JSON + "stages": [...] # Only for pipeline type + "debug": {...} # Only if enable_debug=True + } + """ + # Load prompt from database + with get_db() as conn: + cur = get_cursor(conn) + cur.execute( + """SELECT * FROM ai_prompts + WHERE slug = %s AND active = true""", + (prompt_slug,) + ) + row = cur.fetchone() + if not row: + raise HTTPException(404, f"Prompt nicht gefunden: {prompt_slug}") + + prompt = r2d(row) + + prompt_type = prompt.get('type', 'pipeline') + + # Get catalog from variables if available (passed from execute_prompt_with_data) + catalog = variables.pop('_catalog', None) if '_catalog' in variables else None + + if prompt_type == 'base': + # Base prompt: single execution with template + return await execute_base_prompt(prompt, variables, openrouter_call_func, enable_debug, catalog) + + elif prompt_type == 'pipeline': + # Pipeline prompt: multi-stage execution + return await execute_pipeline_prompt(prompt, variables, openrouter_call_func, enable_debug, catalog) + + else: + raise HTTPException(400, f"Unknown prompt type: {prompt_type}") + + +async def execute_base_prompt( + prompt: Dict, + variables: Dict[str, Any], + openrouter_call_func, + enable_debug: bool = False, + catalog: Optional[Dict] = None +) -> Dict[str, Any]: + """Execute a base-type prompt (single template).""" + template = prompt.get('template') + if not template: + raise HTTPException(400, f"Base prompt missing template: {prompt['slug']}") + + debug_info = {} if enable_debug else None + + # Resolve placeholders (with optional catalog for |d modifier) + prompt_text = resolve_placeholders(template, variables, debug_info, catalog) + + if enable_debug: + debug_info['template'] = template + debug_info['final_prompt'] = prompt_text[:500] + ('...' if len(prompt_text) > 500 else '') + debug_info['available_variables'] = list(variables.keys()) + + # Call AI + response = await openrouter_call_func(prompt_text) + + if enable_debug: + debug_info['ai_response_length'] = len(response) + debug_info['ai_response_preview'] = response[:200] + ('...' if len(response) > 200 else '') + + # Validate JSON if required + output_format = prompt.get('output_format', 'text') + if output_format == 'json': + output = validate_json_output(response, prompt.get('output_schema'), debug_info if enable_debug else None) + else: + output = response + + result = { + "type": "base", + "slug": prompt['slug'], + "output": output, + "output_format": output_format + } + + if enable_debug: + result['debug'] = debug_info + + return result + + +async def execute_pipeline_prompt( + prompt: Dict, + variables: Dict[str, Any], + openrouter_call_func, + enable_debug: bool = False, + catalog: Optional[Dict] = None +) -> Dict[str, Any]: + """ + Execute a pipeline-type prompt (multi-stage). + + Each stage's results are added to variables for next stage. + """ + stages = prompt.get('stages') + if not stages: + raise HTTPException(400, f"Pipeline prompt missing stages: {prompt['slug']}") + + # Parse stages if stored as JSON string + if isinstance(stages, str): + stages = json.loads(stages) + + stage_results = [] + context_vars = variables.copy() + pipeline_debug = [] if enable_debug else None + + # Execute stages in order + for stage_def in sorted(stages, key=lambda s: s['stage']): + stage_num = stage_def['stage'] + stage_prompts = stage_def.get('prompts', []) + + if not stage_prompts: + continue + + stage_debug = {} if enable_debug else None + if enable_debug: + stage_debug['stage'] = stage_num + stage_debug['available_variables'] = list(context_vars.keys()) + stage_debug['prompts'] = [] + + # Execute all prompts in this stage (parallel concept, sequential impl for now) + stage_outputs = {} + + for prompt_def in stage_prompts: + source = prompt_def.get('source') + output_key = prompt_def.get('output_key', f'stage{stage_num}') + output_format = prompt_def.get('output_format', 'text') + + prompt_debug = {} if enable_debug else None + + if source == 'reference': + # Reference to another prompt + ref_slug = prompt_def.get('slug') + if not ref_slug: + raise HTTPException(400, f"Reference prompt missing slug in stage {stage_num}") + + if enable_debug: + prompt_debug['source'] = 'reference' + prompt_debug['ref_slug'] = ref_slug + + # Load referenced prompt + result = await execute_prompt(ref_slug, context_vars, openrouter_call_func, enable_debug) + output = result['output'] + + if enable_debug and 'debug' in result: + prompt_debug['ref_debug'] = result['debug'] + + elif source == 'inline': + # Inline template + template = prompt_def.get('template') + if not template: + raise HTTPException(400, f"Inline prompt missing template in stage {stage_num}") + + placeholder_debug = {} if enable_debug else None + prompt_text = resolve_placeholders(template, context_vars, placeholder_debug, catalog) + + if enable_debug: + prompt_debug['source'] = 'inline' + prompt_debug['template'] = template + prompt_debug['final_prompt'] = prompt_text[:500] + ('...' if len(prompt_text) > 500 else '') + prompt_debug.update(placeholder_debug) + + response = await openrouter_call_func(prompt_text) + + if enable_debug: + prompt_debug['ai_response_length'] = len(response) + prompt_debug['ai_response_preview'] = response[:200] + ('...' if len(response) > 200 else '') + + # Validate JSON if required + if output_format == 'json': + output = validate_json_output(response, prompt_def.get('output_schema'), prompt_debug if enable_debug else None) + else: + output = response + + else: + raise HTTPException(400, f"Unknown prompt source: {source}") + + # Store output with key + stage_outputs[output_key] = output + + # Add to context for next stage + context_var_key = f'stage_{stage_num}_{output_key}' + context_vars[context_var_key] = output + + if enable_debug: + prompt_debug['output_key'] = output_key + prompt_debug['context_var_key'] = context_var_key + stage_debug['prompts'].append(prompt_debug) + + stage_results.append({ + "stage": stage_num, + "outputs": stage_outputs + }) + + if enable_debug: + stage_debug['output'] = stage_outputs # Add outputs to debug info for value table + pipeline_debug.append(stage_debug) + + # Final output is last stage's first output + final_output = stage_results[-1]['outputs'] if stage_results else {} + + result = { + "type": "pipeline", + "slug": prompt['slug'], + "stages": stage_results, + "output": final_output, + "output_format": prompt.get('output_format', 'text') + } + + if enable_debug: + result['debug'] = { + 'initial_variables': list(variables.keys()), + 'stages': pipeline_debug + } + + return result + + +async def execute_prompt_with_data( + prompt_slug: str, + profile_id: str, + modules: Optional[Dict[str, bool]] = None, + timeframes: Optional[Dict[str, int]] = None, + openrouter_call_func = None, + enable_debug: bool = False +) -> Dict[str, Any]: + """ + Execute prompt with data loaded from database. + + Args: + prompt_slug: Slug of prompt to execute + profile_id: User profile ID + modules: Dict of module -> enabled (e.g., {"körper": true}) + timeframes: Dict of module -> days (e.g., {"körper": 30}) + openrouter_call_func: Async function for AI calls + enable_debug: If True, include debug information in response + + Returns: + Execution result dict + """ + from datetime import datetime, timedelta + from placeholder_resolver import get_placeholder_example_values, get_placeholder_catalog + + # Build variables from data modules + variables = { + 'profile_id': profile_id, + 'today': datetime.now().strftime('%Y-%m-%d') + } + + # Load placeholder catalog for |d modifier support + try: + catalog = get_placeholder_catalog(profile_id) + except Exception as e: + catalog = None + print(f"Warning: Could not load placeholder catalog: {e}") + + variables['_catalog'] = catalog # Will be popped in execute_prompt (can be None) + + # Add PROCESSED placeholders (name, weight_trend, caliper_summary, etc.) + # This makes old-style prompts work with the new executor + try: + processed_placeholders = get_placeholder_example_values(profile_id) + # Remove {{ }} from keys (placeholder_resolver returns them with wrappers) + cleaned_placeholders = { + key.replace('{{', '').replace('}}', ''): value + for key, value in processed_placeholders.items() + } + variables.update(cleaned_placeholders) + except Exception as e: + # Continue even if placeholder resolution fails + if enable_debug: + variables['_placeholder_error'] = str(e) + + # Load data for enabled modules + if modules: + with get_db() as conn: + cur = get_cursor(conn) + + # Weight data + if modules.get('körper'): + days = timeframes.get('körper', 30) + since = (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d') + cur.execute( + """SELECT date, weight FROM weight_log + WHERE profile_id = %s AND date >= %s + ORDER BY date DESC""", + (profile_id, since) + ) + variables['weight_data'] = [r2d(r) for r in cur.fetchall()] + + # Nutrition data + if modules.get('ernährung'): + days = timeframes.get('ernährung', 30) + since = (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d') + cur.execute( + """SELECT date, kcal, protein_g, fat_g, carbs_g + FROM nutrition_log + WHERE profile_id = %s AND date >= %s + ORDER BY date DESC""", + (profile_id, since) + ) + variables['nutrition_data'] = [r2d(r) for r in cur.fetchall()] + + # Activity data + if modules.get('training'): + days = timeframes.get('training', 14) + since = (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d') + cur.execute( + """SELECT date, activity_type, duration_min, kcal_active, hr_avg + FROM activity_log + WHERE profile_id = %s AND date >= %s + ORDER BY date DESC""", + (profile_id, since) + ) + variables['activity_data'] = [r2d(r) for r in cur.fetchall()] + + # Sleep data + if modules.get('schlaf'): + days = timeframes.get('schlaf', 14) + since = (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d') + cur.execute( + """SELECT date, sleep_segments, source + FROM sleep_log + WHERE profile_id = %s AND date >= %s + ORDER BY date DESC""", + (profile_id, since) + ) + variables['sleep_data'] = [r2d(r) for r in cur.fetchall()] + + # Vitals data + if modules.get('vitalwerte'): + days = timeframes.get('vitalwerte', 7) + since = (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d') + + # Baseline vitals + cur.execute( + """SELECT date, resting_hr, hrv, vo2_max, spo2, respiratory_rate + FROM vitals_baseline + WHERE profile_id = %s AND date >= %s + ORDER BY date DESC""", + (profile_id, since) + ) + variables['vitals_baseline'] = [r2d(r) for r in cur.fetchall()] + + # Blood pressure + cur.execute( + """SELECT measured_at, systolic, diastolic, pulse + FROM blood_pressure_log + WHERE profile_id = %s AND measured_at >= %s + ORDER BY measured_at DESC""", + (profile_id, since + ' 00:00:00') + ) + variables['blood_pressure'] = [r2d(r) for r in cur.fetchall()] + + # Mental/Goals (no timeframe, just current state) + if modules.get('mentales') or modules.get('ziele'): + # TODO: Add mental state / goals data when implemented + variables['goals_data'] = [] + + # Execute prompt + return await execute_prompt(prompt_slug, variables, openrouter_call_func, enable_debug) diff --git a/backend/routers/insights.py b/backend/routers/insights.py index 0127241..a9dd7fe 100644 --- a/backend/routers/insights.py +++ b/backend/routers/insights.py @@ -433,8 +433,17 @@ async def analyze_with_prompt(slug: str, x_profile_id: Optional[str]=Header(defa @router.post("/insights/pipeline") -async def analyze_pipeline(x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)): - """Run 3-stage pipeline analysis.""" +async def analyze_pipeline( + config_id: Optional[str] = None, + x_profile_id: Optional[str] = Header(default=None), + session: dict = Depends(require_auth) +): + """ + Run configurable multi-stage pipeline analysis. + + Args: + config_id: Pipeline config ID (optional, uses default if not specified) + """ pid = get_pid(x_profile_id) # Phase 4: Check pipeline feature access (boolean - enabled/disabled) @@ -466,14 +475,34 @@ async def analyze_pipeline(x_profile_id: Optional[str]=Header(default=None), ses f"Bitte kontaktiere den Admin oder warte bis zum nächsten Reset." ) + # Load pipeline config + with get_db() as conn: + cur = get_cursor(conn) + if config_id: + cur.execute("SELECT * FROM pipeline_configs WHERE id=%s AND active=true", (config_id,)) + else: + cur.execute("SELECT * FROM pipeline_configs WHERE is_default=true AND active=true") + + config = r2d(cur.fetchone()) + if not config: + raise HTTPException(404, "Pipeline-Konfiguration nicht gefunden") + + logger.info(f"[PIPELINE] Using config '{config['name']}' (id={config['id']})") + data = _get_profile_data(pid) vars = _prepare_template_vars(data) - # Stage 1: Parallel JSON analyses + # Stage 1: Load and execute prompts from config + stage1_prompts = [] with get_db() as conn: cur = get_cursor(conn) - cur.execute("SELECT slug, template FROM ai_prompts WHERE slug LIKE 'pipeline_%' AND slug NOT IN ('pipeline_synthesis','pipeline_goals') AND active=true") - stage1_prompts = [r2d(r) for r in cur.fetchall()] + for slug in config['stage1_prompts']: + cur.execute("SELECT slug, template FROM ai_prompts WHERE slug=%s AND active=true", (slug,)) + prompt = r2d(cur.fetchone()) + if prompt: + stage1_prompts.append(prompt) + else: + logger.warning(f"[PIPELINE] Stage 1 prompt '{slug}' not found or inactive") stage1_results = {} for p in stage1_prompts: @@ -510,17 +539,20 @@ async def analyze_pipeline(x_profile_id: Optional[str]=Header(default=None), ses except: stage1_results[slug] = content - # Stage 2: Synthesis - vars['stage1_body'] = json.dumps(stage1_results.get('pipeline_body', {}), ensure_ascii=False) - vars['stage1_nutrition'] = json.dumps(stage1_results.get('pipeline_nutrition', {}), ensure_ascii=False) - vars['stage1_activity'] = json.dumps(stage1_results.get('pipeline_activity', {}), ensure_ascii=False) + # Stage 2: Synthesis with dynamic placeholders + # Inject all stage1 results as {{stage1_}} placeholders + for slug, result in stage1_results.items(): + # Convert slug like "pipeline_body" to placeholder name "stage1_body" + placeholder_name = slug.replace('pipeline_', 'stage1_') + vars[placeholder_name] = json.dumps(result, ensure_ascii=False) if isinstance(result, dict) else str(result) + # Load stage 2 prompt from config with get_db() as conn: cur = get_cursor(conn) - cur.execute("SELECT template FROM ai_prompts WHERE slug='pipeline_synthesis' AND active=true") + cur.execute("SELECT template FROM ai_prompts WHERE slug=%s AND active=true", (config['stage2_prompt'],)) synth_row = cur.fetchone() if not synth_row: - raise HTTPException(500, "Pipeline synthesis prompt not found") + raise HTTPException(500, f"Pipeline synthesis prompt '{config['stage2_prompt']}' not found") synth_prompt = _render_template(synth_row['template'], vars) @@ -548,16 +580,24 @@ async def analyze_pipeline(x_profile_id: Optional[str]=Header(default=None), ses else: raise HTTPException(500, "Keine KI-API konfiguriert") - # Stage 3: Goals (only if goals are set) + # Stage 3: Optional (e.g., Goals) goals_text = None - prof = data['profile'] - if prof.get('goal_weight') or prof.get('goal_bf_pct'): - with get_db() as conn: - cur = get_cursor(conn) - cur.execute("SELECT template FROM ai_prompts WHERE slug='pipeline_goals' AND active=true") - goals_row = cur.fetchone() - if goals_row: - goals_prompt = _render_template(goals_row['template'], vars) + if config.get('stage3_prompt'): + # Check if conditions are met (for backwards compatibility with goals check) + prof = data['profile'] + should_run_stage3 = True + + # Special case: goals prompt only runs if goals are set + if config['stage3_prompt'] == 'pipeline_goals': + should_run_stage3 = bool(prof.get('goal_weight') or prof.get('goal_bf_pct')) + + if should_run_stage3: + with get_db() as conn: + cur = get_cursor(conn) + cur.execute("SELECT template FROM ai_prompts WHERE slug=%s AND active=true", (config['stage3_prompt'],)) + goals_row = cur.fetchone() + if goals_row: + goals_prompt = _render_template(goals_row['template'], vars) if ANTHROPIC_KEY: import anthropic @@ -586,11 +626,14 @@ async def analyze_pipeline(x_profile_id: Optional[str]=Header(default=None), ses if goals_text: final_content += "\n\n" + goals_text - # Save as 'pipeline' scope (with history - no DELETE) + # Save with config-specific scope (with history - no DELETE) + scope = f"pipeline_{config['name'].lower().replace(' ', '_')}" with get_db() as conn: cur = get_cursor(conn) - cur.execute("INSERT INTO ai_insights (id, profile_id, scope, content, created) VALUES (%s,%s,'pipeline',%s,CURRENT_TIMESTAMP)", - (str(uuid.uuid4()), pid, final_content)) + cur.execute("INSERT INTO ai_insights (id, profile_id, scope, content, created) VALUES (%s,%s,%s,%s,CURRENT_TIMESTAMP)", + (str(uuid.uuid4()), pid, scope, final_content)) + + logger.info(f"[PIPELINE] Completed '{config['name']}' - saved as scope='{scope}'") # Phase 2: Increment ai_calls usage (pipeline uses multiple API calls) # Note: We increment once per pipeline run, not per individual call @@ -599,7 +642,15 @@ async def analyze_pipeline(x_profile_id: Optional[str]=Header(default=None), ses # Old usage tracking (keep for now) inc_ai_usage(pid) - return {"scope": "pipeline", "content": final_content, "stage1": stage1_results} + return { + "scope": scope, + "content": final_content, + "stage1": stage1_results, + "config": { + "id": config['id'], + "name": config['name'] + } + } @router.get("/ai/usage") diff --git a/backend/routers/prompts.py b/backend/routers/prompts.py index a0999ad..65b3ae7 100644 --- a/backend/routers/prompts.py +++ b/backend/routers/prompts.py @@ -3,10 +3,30 @@ 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, + PipelineConfigCreate, PipelineConfigUpdate +) +from placeholder_resolver import ( + resolve_placeholders, + get_unknown_placeholders, + get_placeholder_example_values, + get_available_placeholders, + get_placeholder_catalog +) + +# 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 +52,1184 @@ 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, display_name, description, template, category, active, sort_order, created, updated) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)""", + (prompt_id, p.name, p.slug, p.display_name or p.name, 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.display_name is not None: + updates.append('display_name=%s') + values.append(p.display_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]}" + + new_display_name = f"{original.get('display_name') or original['name']} (Kopie)" + + cur.execute( + """INSERT INTO ai_prompts (id, name, slug, display_name, description, template, category, active, sort_order, created, updated) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)""", + (new_id, new_name, new_slug, new_display_name, 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 grouped catalog of available placeholders with descriptions and examples. + + Returns: + Dict mapping category to list of {key, description, example} + """ + profile_id = session['profile_id'] + return get_placeholder_catalog(profile_id) + + +@router.get("/placeholders/export-values") +def export_placeholder_values(session: dict = Depends(require_auth)): + """ + Export all available placeholders with their current resolved values. + + Returns JSON export suitable for download with all placeholders + resolved for the current user's profile. + """ + from datetime import datetime + profile_id = session['profile_id'] + + # Get all resolved placeholder values + resolved_values = get_placeholder_example_values(profile_id) + + # Clean up keys (remove {{ }}) + cleaned_values = { + key.replace('{{', '').replace('}}', ''): value + for key, value in resolved_values.items() + } + + # Get catalog for metadata + catalog = get_placeholder_catalog(profile_id) + + # Organize by category with metadata + export_data = { + 'export_date': datetime.now().isoformat(), + 'profile_id': profile_id, + 'placeholders_by_category': {} + } + + for category, items in catalog.items(): + export_data['placeholders_by_category'][category] = [] + for item in items: + key = item['key'].replace('{{', '').replace('}}', '') + export_data['placeholders_by_category'][category].append({ + 'key': item['key'], + 'description': item['description'], + 'value': cleaned_values.get(key, 'nicht verfügbar'), + 'example': item.get('example') + }) + + # Also include flat list for easy access + export_data['all_placeholders'] = cleaned_values + export_data['count'] = len(cleaned_values) + + return export_data + + +# ── 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 + + +# ── Pipeline Config Management (Issue #28) ──────────────────────────────────── + +@router.get("/pipeline-configs") +def list_pipeline_configs(session: dict=Depends(require_auth)): + """ + List pipeline configurations. + - Admins: see ALL configs + - Users: see only active configs + """ + with get_db() as conn: + cur = get_cursor(conn) + is_admin = session.get('role') == 'admin' + + if is_admin: + cur.execute("SELECT * FROM pipeline_configs ORDER BY is_default DESC, name") + else: + cur.execute("SELECT * FROM pipeline_configs WHERE active=true ORDER BY is_default DESC, name") + + return [r2d(r) for r in cur.fetchall()] + + +@router.post("/pipeline-configs") +def create_pipeline_config(p: PipelineConfigCreate, session: dict=Depends(require_admin)): + """Create new pipeline configuration (admin only).""" + with get_db() as conn: + cur = get_cursor(conn) + + # Check if name already exists + cur.execute("SELECT id FROM pipeline_configs WHERE name=%s", (p.name,)) + if cur.fetchone(): + raise HTTPException(status_code=400, detail=f"Pipeline config with name '{p.name}' already exists") + + # Validate: stage prompts must exist + all_slugs = p.stage1_prompts + [p.stage2_prompt] + if p.stage3_prompt: + all_slugs.append(p.stage3_prompt) + + for slug in all_slugs: + cur.execute("SELECT id FROM ai_prompts WHERE slug=%s", (slug,)) + if not cur.fetchone(): + raise HTTPException(status_code=400, detail=f"Prompt '{slug}' does not exist") + + # If is_default=true, unset other defaults + if p.is_default: + cur.execute("UPDATE pipeline_configs SET is_default=false WHERE is_default=true") + + config_id = str(uuid.uuid4()) + cur.execute( + """INSERT INTO pipeline_configs ( + id, name, description, is_default, active, + modules, timeframes, stage1_prompts, stage2_prompt, stage3_prompt, + created, updated + ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)""", + ( + config_id, p.name, p.description, p.is_default, p.active, + json.dumps(p.modules), json.dumps(p.timeframes), + p.stage1_prompts, p.stage2_prompt, p.stage3_prompt + ) + ) + + return {"id": config_id, "name": p.name} + + +@router.put("/pipeline-configs/{config_id}") +def update_pipeline_config(config_id: str, p: PipelineConfigUpdate, session: dict=Depends(require_admin)): + """Update pipeline configuration (admin only).""" + with get_db() as conn: + cur = get_cursor(conn) + + # Check if config exists + cur.execute("SELECT id FROM pipeline_configs WHERE id=%s", (config_id,)) + if not cur.fetchone(): + raise HTTPException(status_code=404, detail="Pipeline config not found") + + # Build dynamic UPDATE query + updates = [] + values = [] + + 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.is_default is not None: + # If setting to default, unset others + if p.is_default: + cur.execute("UPDATE pipeline_configs SET is_default=false WHERE is_default=true AND id!=%s", (config_id,)) + updates.append('is_default=%s') + values.append(p.is_default) + if p.active is not None: + updates.append('active=%s') + values.append(p.active) + if p.modules is not None: + updates.append('modules=%s') + values.append(json.dumps(p.modules)) + if p.timeframes is not None: + updates.append('timeframes=%s') + values.append(json.dumps(p.timeframes)) + if p.stage1_prompts is not None: + updates.append('stage1_prompts=%s') + values.append(p.stage1_prompts) + if p.stage2_prompt is not None: + updates.append('stage2_prompt=%s') + values.append(p.stage2_prompt) + if p.stage3_prompt is not None: + updates.append('stage3_prompt=%s') + values.append(p.stage3_prompt) + + if not updates: + return {"ok": True} + + cur.execute( + f"UPDATE pipeline_configs SET {', '.join(updates)}, updated=CURRENT_TIMESTAMP WHERE id=%s", + values + [config_id] + ) + + return {"ok": True} + + +@router.delete("/pipeline-configs/{config_id}") +def delete_pipeline_config(config_id: str, session: dict=Depends(require_admin)): + """Delete pipeline configuration (admin only).""" + with get_db() as conn: + cur = get_cursor(conn) + + # Check if it's the only default + cur.execute("SELECT is_default FROM pipeline_configs WHERE id=%s", (config_id,)) + row = cur.fetchone() + if not row: + raise HTTPException(status_code=404, detail="Pipeline config not found") + + if row['is_default']: + # Check if there are other configs + cur.execute("SELECT COUNT(*) as count FROM pipeline_configs WHERE id!=%s", (config_id,)) + if cur.fetchone()['count'] > 0: + raise HTTPException( + status_code=400, + detail="Cannot delete the default config. Please set another config as default first." + ) + + cur.execute("DELETE FROM pipeline_configs WHERE id=%s", (config_id,)) + + return {"ok": True} + + +@router.post("/pipeline-configs/{config_id}/set-default") +def set_default_pipeline_config(config_id: str, session: dict=Depends(require_admin)): + """Set a pipeline config as default (admin only).""" + with get_db() as conn: + cur = get_cursor(conn) + + # Check if config exists + cur.execute("SELECT id FROM pipeline_configs WHERE id=%s", (config_id,)) + if not cur.fetchone(): + raise HTTPException(status_code=404, detail="Pipeline config not found") + + # Unset all other defaults + cur.execute("UPDATE pipeline_configs SET is_default=false WHERE is_default=true") + + # Set this one as default + cur.execute("UPDATE pipeline_configs SET is_default=true, updated=CURRENT_TIMESTAMP WHERE id=%s", (config_id,)) + + return {"ok": True} + + +@router.post("/{prompt_id}/reset-to-default") +def reset_prompt_to_default(prompt_id: str, session: dict=Depends(require_admin)): + """ + Reset a system prompt to its default template (admin only). + Only works for prompts with is_system_default=true. + """ + with get_db() as conn: + cur = get_cursor(conn) + + cur.execute("SELECT is_system_default, default_template FROM ai_prompts WHERE id=%s", (prompt_id,)) + row = cur.fetchone() + + if not row: + raise HTTPException(status_code=404, detail="Prompt not found") + + if not row['is_system_default']: + raise HTTPException(status_code=400, detail="Only system prompts can be reset to default") + + if not row['default_template']: + raise HTTPException(status_code=400, detail="No default template available for this prompt") + + # Reset template to default + cur.execute( + "UPDATE ai_prompts SET template=%s, updated=CURRENT_TIMESTAMP WHERE id=%s", + (row['default_template'], prompt_id) + ) + + return {"ok": True} + + +# ══════════════════════════════════════════════════════════════════════════════ +# UNIFIED PROMPT SYSTEM (Issue #28 Phase 2) +# ══════════════════════════════════════════════════════════════════════════════ + +from prompt_executor import execute_prompt_with_data +from models import UnifiedPromptCreate, UnifiedPromptUpdate + + +@router.post("/execute") +async def execute_unified_prompt( + prompt_slug: str, + modules: Optional[dict] = None, + timeframes: Optional[dict] = None, + debug: bool = False, + save: bool = False, + session: dict = Depends(require_auth) +): + """ + Execute a unified prompt (base or pipeline type). + + Args: + prompt_slug: Slug of prompt to execute + modules: Dict of enabled modules (e.g., {"körper": true}) + timeframes: Dict of timeframes per module (e.g., {"körper": 30}) + debug: If true, include debug information (placeholders, final prompts, etc.) + save: If true, save result to ai_insights table + + Returns: + Execution result with outputs (and debug info if debug=true) + """ + profile_id = session['profile_id'] + + # Use default modules/timeframes if not provided + if not modules: + modules = { + 'körper': True, + 'ernährung': True, + 'training': True, + 'schlaf': True, + 'vitalwerte': True + } + + if not timeframes: + timeframes = { + 'körper': 30, + 'ernährung': 30, + 'training': 14, + 'schlaf': 14, + 'vitalwerte': 7 + } + + # Execute with prompt_executor + # Always enable debug when saving to collect metadata for value table + result = await execute_prompt_with_data( + prompt_slug=prompt_slug, + profile_id=profile_id, + modules=modules, + timeframes=timeframes, + openrouter_call_func=call_openrouter, + enable_debug=debug or save # Enable debug if saving for metadata collection + ) + + # Save to ai_insights if requested + if save: + # Extract final output text/markdown + if result['type'] == 'pipeline': + # For pipeline, get the last stage's output + final_output = result.get('output', {}) + # If output is dict with single key, use that value + if isinstance(final_output, dict) and len(final_output) == 1: + content = list(final_output.values())[0] + else: + content = json.dumps(final_output, ensure_ascii=False) + else: + # For base prompts, use output directly + content = result.get('output', '') + if isinstance(content, dict): + content = json.dumps(content, ensure_ascii=False) + + # Prepare metadata with resolved placeholders and descriptions + from placeholder_resolver import get_placeholder_catalog, get_placeholder_example_values + + metadata = { + 'prompt_type': result['type'], + 'placeholders': {} + } + + # Collect all resolved placeholders from debug info + if result.get('debug'): + catalog = get_placeholder_catalog(profile_id) + + # Get full untruncated values from placeholder resolver + full_values = get_placeholder_example_values(profile_id) + # Remove {{ }} wrappers + cleaned_values = { + key.replace('{{', '').replace('}}', ''): value + for key, value in full_values.items() + } + + if result['type'] == 'base': + # Base prompt: single set of placeholders + resolved_keys = result['debug'].get('resolved_placeholders', {}).keys() + for key in resolved_keys: + # Get full untruncated value + value = cleaned_values.get(key, result['debug']['resolved_placeholders'].get(key, '')) + + # Find description and category in catalog + desc = None + category = 'Sonstiges' + for cat_name, cat_items in catalog.items(): + matching = [item for item in cat_items if item['key'] == key] + if matching: + desc = matching[0].get('description', '') + category = cat_name + break + + metadata['placeholders'][key] = { + 'value': value, + 'description': desc or '', + 'category': category + } + + elif result['type'] == 'pipeline': + # Pipeline: collect from all stages + stages_debug = result['debug'].get('stages', []) + + # First, collect stage outputs (outputs from base prompts in each stage) + stage_outputs = {} # Raw stage outputs (for expert mode) + extracted_values = {} # Individual values extracted from JSON outputs (for normal mode) + + for stage_debug in stages_debug: + stage_num = stage_debug.get('stage', 0) + stage_output = stage_debug.get('output', {}) + if isinstance(stage_output, dict): + for output_key, output_value in stage_output.items(): + # Store raw stage output (for expert mode) + placeholder_key = f"stage_{stage_num}_{output_key}" + stage_outputs[placeholder_key] = output_value + + # If output is a dict/object, extract individual fields + if isinstance(output_value, dict): + for field_key, field_value in output_value.items(): + # Store individual field (for normal mode) + # Use just the field name as key (e.g., "bmi" instead of "stage_1_body.bmi") + # This allows deduplication if multiple stages have the same field + if field_key not in extracted_values: + extracted_values[field_key] = { + 'value': field_value if isinstance(field_value, str) else json.dumps(field_value, ensure_ascii=False), + 'source_stage': stage_num, + 'source_output': output_key + } + + # Add extracted values from stage outputs (individual fields) + for field_key, field_data in extracted_values.items(): + if field_key not in metadata['placeholders']: + # Determine category for extracted values + output_name = field_data['source_output'].replace('stage1_', '').replace('_', ' ').title() + category = f"Stage {field_data['source_stage']} - {output_name}" + + metadata['placeholders'][field_key] = { + 'value': field_data['value'], + 'description': f"Aus Stage {field_data['source_stage']} ({field_data['source_output']})", + 'is_extracted': True, # Mark as extracted for filtering + 'category': category + } + + # Add all stage outputs (raw JSON) for expert mode - regardless of whether referenced + for stage_key, stage_value in stage_outputs.items(): + if stage_key not in metadata['placeholders']: + stage_parts = stage_key.split('_') + stage_num = stage_parts[1] if len(stage_parts) > 1 else '?' + output_name = '_'.join(stage_parts[2:]) if len(stage_parts) > 2 else 'output' + + metadata['placeholders'][stage_key] = { + 'value': json.dumps(stage_value, ensure_ascii=False, indent=2) if isinstance(stage_value, dict) else str(stage_value), + 'description': f"Zwischenergebnis aus Stage {stage_num} ({output_name})", + 'is_stage_raw': True, + 'category': f"Stage {stage_num} - Rohdaten" + } + + # Collect all resolved placeholders from prompts (input placeholders) + for stage_debug in stages_debug: + for prompt_debug in stage_debug.get('prompts', []): + resolved_keys = [] + # Check both direct and ref_debug + if 'resolved_placeholders' in prompt_debug: + resolved_keys = prompt_debug['resolved_placeholders'].keys() + elif 'ref_debug' in prompt_debug and 'resolved_placeholders' in prompt_debug['ref_debug']: + resolved_keys = prompt_debug['ref_debug']['resolved_placeholders'].keys() + + for key in resolved_keys: + if key not in metadata['placeholders']: # Avoid duplicates + # Get value from cleaned_values + value = cleaned_values.get(key, '') + + # Find description and category in catalog + desc = None + category = 'Sonstiges' + for cat_name, cat_items in catalog.items(): + matching = [item for item in cat_items if item['key'] == key] + if matching: + desc = matching[0].get('description', '') + category = cat_name + break + desc = desc or '' + + metadata['placeholders'][key] = { + 'value': value if isinstance(value, str) else json.dumps(value, ensure_ascii=False), + 'description': desc, + 'category': category + } + + # Save to database with metadata + with get_db() as conn: + cur = get_cursor(conn) + cur.execute( + """INSERT INTO ai_insights (id, profile_id, scope, content, metadata, created) + VALUES (%s, %s, %s, %s, %s, CURRENT_TIMESTAMP)""", + (str(uuid.uuid4()), profile_id, prompt_slug, content, json.dumps(metadata)) + ) + conn.commit() + + return result + + +@router.post("/unified") +def create_unified_prompt(p: UnifiedPromptCreate, session: dict = Depends(require_admin)): + """ + Create a new unified prompt (base or pipeline type). + Admin only. + """ + with get_db() as conn: + cur = get_cursor(conn) + + # Check for duplicate slug + cur.execute("SELECT id FROM ai_prompts WHERE slug=%s", (p.slug,)) + if cur.fetchone(): + raise HTTPException(status_code=400, detail="Slug already exists") + + # Validate type + if p.type not in ['base', 'pipeline']: + raise HTTPException(status_code=400, detail="Type must be 'base' or 'pipeline'") + + # Validate base type has template + if p.type == 'base' and not p.template: + raise HTTPException(status_code=400, detail="Base prompts require a template") + + # Validate pipeline type has stages + if p.type == 'pipeline' and not p.stages: + raise HTTPException(status_code=400, detail="Pipeline prompts require stages") + + # Convert stages to JSONB + stages_json = None + if p.stages: + stages_json = json.dumps([ + { + 'stage': s.stage, + 'prompts': [ + { + 'source': pr.source, + 'slug': pr.slug, + 'template': pr.template, + 'output_key': pr.output_key, + 'output_format': pr.output_format, + 'output_schema': pr.output_schema + } + for pr in s.prompts + ] + } + for s in p.stages + ]) + + prompt_id = str(uuid.uuid4()) + + cur.execute( + """INSERT INTO ai_prompts + (id, slug, name, display_name, description, template, category, active, sort_order, + type, stages, output_format, output_schema) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)""", + ( + prompt_id, p.slug, p.name, p.display_name, p.description, + p.template, p.category, p.active, p.sort_order, + p.type, stages_json, p.output_format, + json.dumps(p.output_schema) if p.output_schema else None + ) + ) + + return {"id": prompt_id, "slug": p.slug} + + +@router.put("/unified/{prompt_id}") +def update_unified_prompt(prompt_id: str, p: UnifiedPromptUpdate, session: dict = Depends(require_admin)): + """ + Update a unified prompt. + Admin only. + """ + with get_db() as conn: + cur = get_cursor(conn) + + # Check if exists + cur.execute("SELECT id FROM ai_prompts WHERE id=%s", (prompt_id,)) + if not cur.fetchone(): + raise HTTPException(status_code=404, detail="Prompt not found") + + # Build update query + updates = [] + values = [] + + if p.name is not None: + updates.append('name=%s') + values.append(p.name) + if p.display_name is not None: + updates.append('display_name=%s') + values.append(p.display_name) + if p.description is not None: + updates.append('description=%s') + values.append(p.description) + if p.type is not None: + if p.type not in ['base', 'pipeline']: + raise HTTPException(status_code=400, detail="Type must be 'base' or 'pipeline'") + updates.append('type=%s') + values.append(p.type) + 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 p.template is not None: + updates.append('template=%s') + values.append(p.template) + if p.output_format is not None: + updates.append('output_format=%s') + values.append(p.output_format) + if p.output_schema is not None: + updates.append('output_schema=%s') + values.append(json.dumps(p.output_schema)) + if p.stages is not None: + stages_json = json.dumps([ + { + 'stage': s.stage, + 'prompts': [ + { + 'source': pr.source, + 'slug': pr.slug, + 'template': pr.template, + 'output_key': pr.output_key, + 'output_format': pr.output_format, + 'output_schema': pr.output_schema + } + for pr in s.prompts + ] + } + for s in p.stages + ]) + updates.append('stages=%s') + values.append(stages_json) + + 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.get("/export-all") +def export_all_prompts(session: dict = Depends(require_admin)): + """ + Export all prompts as JSON array. + Admin only. Used for backup and dev→prod sync. + """ + from datetime import datetime + with get_db() as conn: + cur = get_cursor(conn) + cur.execute("SELECT * FROM ai_prompts ORDER BY sort_order, slug") + prompts = [r2d(row) for row in cur.fetchall()] + + # Convert to export format (clean up DB-specific fields) + export_data = [] + for p in prompts: + export_item = { + 'slug': p['slug'], + 'name': p['name'], + 'display_name': p.get('display_name'), + 'description': p.get('description'), + 'type': p.get('type', 'pipeline'), + 'category': p.get('category', 'ganzheitlich'), + 'template': p.get('template'), + 'stages': p.get('stages'), + 'output_format': p.get('output_format', 'text'), + 'output_schema': p.get('output_schema'), + 'active': p.get('active', True), + 'sort_order': p.get('sort_order', 0) + } + export_data.append(export_item) + + return { + 'export_date': datetime.now().isoformat(), + 'count': len(export_data), + 'prompts': export_data + } + + +@router.post("/import") +def import_prompts( + data: dict, + overwrite: bool = False, + session: dict = Depends(require_admin) +): + """ + Import prompts from JSON export. + + Args: + data: Export data from /export-all endpoint + overwrite: If true, update existing prompts. If false, skip duplicates. + + Returns: + Summary of import results (created, updated, skipped) + """ + if 'prompts' not in data: + raise HTTPException(400, "Invalid import data: missing 'prompts' key") + + prompts = data['prompts'] + created = 0 + updated = 0 + skipped = 0 + errors = [] + + with get_db() as conn: + cur = get_cursor(conn) + + for p in prompts: + slug = p.get('slug') + if not slug: + errors.append('Prompt without slug skipped') + continue + + # Check if exists + cur.execute("SELECT id FROM ai_prompts WHERE slug=%s", (slug,)) + existing = cur.fetchone() + + if existing and not overwrite: + skipped += 1 + continue + + # Prepare stages JSON if present + stages_json = None + if p.get('stages'): + stages_json = json.dumps(p['stages']) if isinstance(p['stages'], list) else p['stages'] + + if existing: + # Update existing + cur.execute(""" + UPDATE ai_prompts SET + name=%s, display_name=%s, description=%s, type=%s, + category=%s, template=%s, stages=%s, output_format=%s, + output_schema=%s, active=%s, sort_order=%s, + updated=CURRENT_TIMESTAMP + WHERE slug=%s + """, ( + p.get('name'), p.get('display_name'), p.get('description'), + p.get('type', 'pipeline'), p.get('category', 'ganzheitlich'), + p.get('template'), stages_json, p.get('output_format', 'text'), + p.get('output_schema'), p.get('active', True), + p.get('sort_order', 0), slug + )) + updated += 1 + else: + # Create new + cur.execute(""" + INSERT INTO ai_prompts ( + slug, name, display_name, description, type, category, + template, stages, output_format, output_schema, + active, sort_order, created, updated + ) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,CURRENT_TIMESTAMP,CURRENT_TIMESTAMP) + """, ( + slug, p.get('name'), p.get('display_name'), p.get('description'), + p.get('type', 'pipeline'), p.get('category', 'ganzheitlich'), + p.get('template'), stages_json, p.get('output_format', 'text'), + p.get('output_schema'), p.get('active', True), p.get('sort_order', 0) + )) + created += 1 + + conn.commit() + + return { + 'success': True, + 'created': created, + 'updated': updated, + 'skipped': skipped, + 'errors': errors if errors else None + } diff --git a/docs/issues/issue-50-value-table-refinement.md b/docs/issues/issue-50-value-table-refinement.md new file mode 100644 index 0000000..284f941 --- /dev/null +++ b/docs/issues/issue-50-value-table-refinement.md @@ -0,0 +1,181 @@ +# Enhancement: Wertetabelle Optimierung + +**Labels:** enhancement, ux +**Priority:** Medium (Phase 1) +**Related:** Issue #47 (Value Table - Complete) + +## Beschreibung +Wertetabelle übersichtlicher gestalten durch intelligente Filterung und Beschreibungs-Vervollständigung. + +## Problem (aktueller Stand) + +Nach Implementierung von Issue #47 haben wir eine sehr umfangreiche Wertetabelle mit: +- Reguläre Platzhalter (PROFIL, KÖRPER, etc.) +- Extrahierte Einzelwerte aus Stages (↳ Symbol) +- Rohdaten der Stage-Outputs (🔬 JSON) + +**Probleme:** +1. Zu viele Werte im Normal-Modus (unübersichtlich) +2. Stage-Rohdaten sollten nur im Experten-Modus sichtbar sein +3. Einige Platzhalter haben keine/unvollständige Beschreibungen + +## Gewünschtes Verhalten + +### Normal-Modus (Standard) +``` +📊 Verwendete Werte (24) [🔬 Experten-Modus] + +PROFIL +├─ name: Lars Stommer +├─ age: 35 Jahre (Geburtsdatum 1990-05-15) +├─ height: 178cm (Körpergröße) + +KÖRPER +├─ weight_aktuell: 85.2kg (Aktuelles Gewicht) +├─ bmi: 26.9 (Body-Mass-Index) +├─ bmi_interpretation: Leicht übergewichtig (BMI-Bewertung) +├─ kf_aktuell: 18.5% (Körperfettanteil) + +ERNÄHRUNG +├─ kcal_avg: 2450 kcal (Durchschnitt 7 Tage) +... + +Stage 1 - Body (Extrahierte Werte) +├─ ↳ trend: leicht sinkend (Gewichtstrend) +├─ ↳ ziel_erreichbar: ja, in 8 Wochen (Zielerreichbarkeit) +``` + +**Ausgeblendet:** +- 🔬 stage_1_stage1_body (komplettes JSON) +- Leere/nicht verfügbare Werte + +### Experten-Modus +``` +📊 Verwendete Werte (32) [🔬 Experten-Modus ✓] + +[... wie Normal-Modus ...] + +Stage 1 - Rohdaten +├─ 🔬 stage_1_stage1_body + └─ [JSON anzeigen ▼] + { + "bmi": 26.9, + "trend": "leicht sinkend", + "ziel_erreichbar": "ja, in 8 Wochen", + "interpretation": "Dein BMI liegt..." + } + +Stage 2 - Rohdaten +├─ 🔬 stage_2_stage2_nutrition + └─ [JSON anzeigen ▼] + { ... } +``` + +**Zusätzlich sichtbar:** +- Alle Stage-Rohdaten (🔬 JSON) +- Leere/nicht verfügbare Werte +- Debug-Informationen + +## Technische Umsetzung + +### 1. Filterlogik anpassen (Analysis.jsx) + +**Aktuell:** +```javascript +const placeholders = expertMode + ? allPlaceholders + : Object.fromEntries( + Object.entries(allPlaceholders).filter(([key, data]) => { + if (data.is_stage_raw) return false // Versteckt Rohdaten + const val = data.value || '' + return val.trim() !== '' && val !== 'nicht verfügbar' + }) + ) +``` + +**Problem:** `is_stage_raw` wird nur für Keys wie "stage_1_stage1_body" gesetzt, nicht für extrahierte Werte. + +**Lösung:** Neue Flag `is_extracted` (bereits vorhanden) wird beibehalten, `is_stage_raw` nur für komplette JSON-Outputs. + +### 2. Beschreibungen vervollständigen (placeholder_resolver.py) + +**Fehlende Beschreibungen prüfen:** +```python +# In get_placeholder_catalog() +PLACEHOLDER_CATALOG = { + 'PROFIL': [ + {'key': 'name', 'description': 'Name des Nutzers', 'example': '...'}, + {'key': 'age', 'description': 'Alter in Jahren', 'example': '35'}, + # ... alle prüfen + ], + 'KÖRPER': [ + {'key': 'weight_aktuell', 'description': 'Aktuelles Gewicht', 'example': '85.2kg'}, + {'key': 'bmi', 'description': 'Body-Mass-Index (berechnet)', 'example': '26.9'}, + # ... alle prüfen + ], + # ... alle Kategorien durchgehen +} +``` + +**Aktionen:** +- Alle 32 Platzhalter durchgehen +- Fehlende Beschreibungen ergänzen +- Beschreibungen aus Kontext ableiten (z.B. bei extrahierten Werten aus Stage-Output) + +### 3. Extrahierte Werte beschreiben (prompts.py) + +**Aktuell (Line 896-901):** +```python +metadata['placeholders'][field_key] = { + 'value': field_data['value'], + 'description': f"Aus Stage {field_data['source_stage']} ({field_data['source_output']})", + 'is_extracted': True, + 'category': category +} +``` + +**Verbesserung:** +- Wenn Base-Prompt ein JSON-Schema hat, Feld-Beschreibungen aus Schema extrahieren +- Fallback: Generische Beschreibung aus Kontext + +**Beispiel:** +```python +# Wenn output_schema verfügbar: +schema = base_prompt.get('output_schema', {}) +properties = schema.get('properties', {}) +field_description = properties.get(field_key, {}).get('description', '') + +metadata['placeholders'][field_key] = { + 'value': field_data['value'], + 'description': field_description or f"Aus Stage {stage_num} ({output_name})", + 'is_extracted': True, + 'category': category +} +``` + +## Akzeptanzkriterien + +- [ ] Normal-Modus zeigt nur Einzelwerte (regulär + extrahiert ↳) +- [ ] Experten-Modus zeigt zusätzlich Stage-Rohdaten (🔬 JSON) +- [ ] Alle 32 Platzhalter haben sinnvolle Beschreibungen +- [ ] Extrahierte Werte nutzen Schema-Beschreibungen (wenn vorhanden) +- [ ] Toggle "Experten-Modus" funktioniert korrekt +- [ ] Kategorien bleiben sauber getrennt +- [ ] Leere Werte werden im Normal-Modus ausgeblendet + +## Abschätzung + +**Aufwand:** 4-6 Stunden +- 1h: Filterlogik testen/anpassen +- 2-3h: Beschreibungen vervollständigen (32 Platzhalter) +- 1h: Schema-basierte Beschreibungen für extrahierte Werte +- 1h: Testing + Feintuning + +**Priorität:** Medium (verbessert UX erheblich, aber keine kritische Funktionalität) + +## Notizen + +- Issue #47 hat die Grundlage geschaffen (Kategorien, Expert-Mode, Stage-Outputs) +- Diese Optimierung macht die Wertetabelle produktionsreif +- Beschreibungen sind wichtig für KI-Kontext UND User-Verständnis +- ggf. später: Beschreibungen editierbar machen (Admin-UI) diff --git a/find-container.sh b/find-container.sh new file mode 100644 index 0000000..a773b1e --- /dev/null +++ b/find-container.sh @@ -0,0 +1,13 @@ +#!/bin/bash +# Find correct container name + +echo "Suche Backend-Container..." +echo "" + +# List all running containers +echo "Alle laufenden Container:" +docker ps --format "{{.Names}}" | grep -i backend + +echo "" +echo "Oder alle Container (auch gestoppte):" +docker ps -a --format "{{.Names}}" | grep -i backend 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/PlaceholderPicker.jsx b/frontend/src/components/PlaceholderPicker.jsx new file mode 100644 index 0000000..837f3fd --- /dev/null +++ b/frontend/src/components/PlaceholderPicker.jsx @@ -0,0 +1,255 @@ +import { useState, useEffect } from 'react' +import { api } from '../utils/api' +import { Search, X } from 'lucide-react' + +/** + * Placeholder Picker with grouped categories and search + * + * Loads placeholders dynamically from backend catalog. + * Grouped by category (Profil, Körper, Ernährung, Training, etc.) + */ +export default function PlaceholderPicker({ onSelect, onClose }) { + const [catalog, setCatalog] = useState({}) + const [search, setSearch] = useState('') + const [loading, setLoading] = useState(true) + const [expandedCategories, setExpandedCategories] = useState(new Set()) + + useEffect(() => { + loadCatalog() + }, []) + + const loadCatalog = async () => { + try { + setLoading(true) + const data = await api.listPlaceholders() + setCatalog(data) + // Expand all categories by default + setExpandedCategories(new Set(Object.keys(data))) + } catch (e) { + console.error('Failed to load placeholders:', e) + } finally { + setLoading(false) + } + } + + const toggleCategory = (category) => { + const newExpanded = new Set(expandedCategories) + if (newExpanded.has(category)) { + newExpanded.delete(category) + } else { + newExpanded.add(category) + } + setExpandedCategories(newExpanded) + } + + const handleSelect = (key) => { + onSelect(`{{${key}}}`) + onClose() + } + + // Filter placeholders by search + const filteredCatalog = {} + const searchLower = search.toLowerCase() + + Object.entries(catalog).forEach(([category, items]) => { + const filtered = items.filter(item => + item.key.toLowerCase().includes(searchLower) || + item.description.toLowerCase().includes(searchLower) + ) + if (filtered.length > 0) { + filteredCatalog[category] = filtered + } + }) + + return ( +
+
e.stopPropagation()} + style={{ + background: 'var(--bg)', + borderRadius: 12, + maxWidth: 800, + width: '100%', + maxHeight: '80vh', + display: 'flex', + flexDirection: 'column', + overflow: 'hidden' + }} + > + {/* Header */} +
+

+ Platzhalter auswählen +

+ +
+ + {/* Search */} +
+
+ + setSearch(e.target.value)} + placeholder="Platzhalter suchen..." + style={{ + width: '100%', + paddingLeft: 40, + textAlign: 'left' + }} + /> +
+
+ + {/* Categories */} +
+ {loading ? ( +
+ Lädt Platzhalter... +
+ ) : Object.keys(filteredCatalog).length === 0 ? ( +
+ Keine Platzhalter gefunden +
+ ) : ( + Object.entries(filteredCatalog).map(([category, items]) => ( +
+
toggleCategory(category)} + style={{ + padding: '8px 12px', + background: 'var(--surface)', + borderRadius: 8, + cursor: 'pointer', + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: 8 + }} + > +

+ {category} ({items.length}) +

+ + {expandedCategories.has(category) ? '▼' : '▶'} + +
+ + {expandedCategories.has(category) && ( +
+ {items.map(item => ( +
handleSelect(item.key)} + style={{ + padding: '8px 12px', + background: 'var(--surface2)', + borderRadius: 6, + cursor: 'pointer', + border: '1px solid transparent', + transition: 'all 0.2s' + }} + onMouseEnter={e => { + e.currentTarget.style.borderColor = 'var(--accent)' + e.currentTarget.style.background = 'var(--surface)' + }} + onMouseLeave={e => { + e.currentTarget.style.borderColor = 'transparent' + e.currentTarget.style.background = 'var(--surface2)' + }} + > +
+
+ + {`{{${item.key}}}`} + +
+ {item.description} +
+
+ {item.example && ( +
+ Beispiel: + {item.example} +
+ )} +
+
+ ))} +
+ )} +
+ )) + )} +
+ + {/* Footer */} +
+ Klicke auf einen Platzhalter zum Einfügen +
+
+
+ ) +} diff --git a/frontend/src/components/PromptGenerator.jsx b/frontend/src/components/PromptGenerator.jsx new file mode 100644 index 0000000..bd7dd84 --- /dev/null +++ b/frontend/src/components/PromptGenerator.jsx @@ -0,0 +1,190 @@ +import { useState } from 'react' +import { api } from '../utils/api' + +export default function PromptGenerator({ onGenerated, onClose }) { + const [goal, setGoal] = useState('') + const [dataCategories, setDataCategories] = useState(['körper', 'ernährung']) + const [exampleOutput, setExampleOutput] = useState('') + const [exampleData, setExampleData] = useState(null) + const [generating, setGenerating] = useState(false) + const [loadingExample, setLoadingExample] = useState(false) + + const categories = [ + { id: 'körper', label: 'Körper (Gewicht, KF, Umfänge)' }, + { id: 'ernährung', label: 'Ernährung (Kalorien, Makros)' }, + { id: 'training', label: 'Training (Volumen, Typen)' }, + { id: 'schlaf', label: 'Schlaf (Dauer, Qualität)' }, + { id: 'vitalwerte', label: 'Vitalwerte (RHR, HRV, VO2max)' }, + { id: 'ziele', label: 'Ziele (Fortschritt, Prognose)' } + ] + + const handleToggleCategory = (catId) => { + if (dataCategories.includes(catId)) { + setDataCategories(dataCategories.filter(c => c !== catId)) + } else { + setDataCategories([...dataCategories, catId]) + } + } + + const handleShowExampleData = async () => { + try { + setLoadingExample(true) + const placeholders = await api.listPlaceholders() + setExampleData(placeholders) + } catch (e) { + alert('Fehler: ' + e.message) + } finally { + setLoadingExample(false) + } + } + + const handleGenerate = async () => { + if (!goal.trim()) { + alert('Bitte Ziel beschreiben') + return + } + if (dataCategories.length === 0) { + alert('Bitte mindestens einen Datenbereich wählen') + return + } + + try { + setGenerating(true) + const result = await api.generatePrompt({ + goal, + data_categories: dataCategories, + example_output: exampleOutput || null + }) + onGenerated(result) + } catch (e) { + alert('Fehler beim Generieren: ' + e.message) + } finally { + setGenerating(false) + } + } + + return ( +
+
+

+ 🤖 KI-Prompt generieren +

+ + {/* Step 1: Goal */} +
+ +