Flexibles KI Prompt System #48
185
CLAUDE.md
185
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)
|
||||
|
|
|
|||
22
backend/migrations/017_ai_prompts_flexibilisierung.sql
Normal file
22
backend/migrations/017_ai_prompts_flexibilisierung.sql
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
-- Migration 017: AI Prompts Flexibilisierung (Issue #28)
|
||||
-- Add category column to ai_prompts for better organization and filtering
|
||||
|
||||
-- Add category column
|
||||
ALTER TABLE ai_prompts ADD COLUMN IF NOT EXISTS category VARCHAR(20) DEFAULT 'ganzheitlich';
|
||||
|
||||
-- Create index for category filtering
|
||||
CREATE INDEX IF NOT EXISTS idx_ai_prompts_category ON ai_prompts(category);
|
||||
|
||||
-- Add comment
|
||||
COMMENT ON COLUMN ai_prompts.category IS 'Prompt category: körper, ernährung, training, schlaf, vitalwerte, ziele, ganzheitlich';
|
||||
|
||||
-- Update existing prompts with appropriate categories
|
||||
-- Based on slug patterns and content
|
||||
UPDATE ai_prompts SET category = 'körper' WHERE slug IN ('koerperkomposition', 'gewichtstrend', 'umfaenge', 'caliper');
|
||||
UPDATE ai_prompts SET category = 'ernährung' WHERE slug IN ('ernaehrung', 'kalorienbilanz', 'protein', 'makros');
|
||||
UPDATE ai_prompts SET category = 'training' WHERE slug IN ('aktivitaet', 'trainingsanalyse', 'erholung', 'leistung');
|
||||
UPDATE ai_prompts SET category = 'schlaf' WHERE slug LIKE '%schlaf%';
|
||||
UPDATE ai_prompts SET category = 'vitalwerte' WHERE slug IN ('vitalwerte', 'herzfrequenz', 'ruhepuls', 'hrv');
|
||||
UPDATE ai_prompts SET category = 'ziele' WHERE slug LIKE '%ziel%' OR slug LIKE '%goal%';
|
||||
|
||||
-- Pipeline prompts remain 'ganzheitlich' (default)
|
||||
20
backend/migrations/018_prompt_display_name.sql
Normal file
20
backend/migrations/018_prompt_display_name.sql
Normal file
|
|
@ -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;
|
||||
157
backend/migrations/019_pipeline_system.sql
Normal file
157
backend/migrations/019_pipeline_system.sql
Normal file
|
|
@ -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)';
|
||||
128
backend/migrations/020_unified_prompt_system.sql
Normal file
128
backend/migrations/020_unified_prompt_system.sql
Normal file
|
|
@ -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
|
||||
7
backend/migrations/021_ai_insights_metadata.sql
Normal file
7
backend/migrations/021_ai_insights_metadata.sql
Normal file
|
|
@ -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.';
|
||||
|
|
@ -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
|
||||
|
|
|
|||
715
backend/placeholder_resolver.py
Normal file
715
backend/placeholder_resolver.py
Normal file
|
|
@ -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
|
||||
526
backend/prompt_executor.py
Normal file
526
backend/prompt_executor.py
Normal file
|
|
@ -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)
|
||||
|
|
@ -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_<slug>}} 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")
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
181
docs/issues/issue-50-value-table-refinement.md
Normal file
181
docs/issues/issue-50-value-table-refinement.md
Normal file
|
|
@ -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)
|
||||
13
find-container.sh
Normal file
13
find-container.sh
Normal file
|
|
@ -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
|
||||
|
|
@ -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() {
|
|||
<Route path="/admin/training-types" element={<AdminTrainingTypesPage/>}/>
|
||||
<Route path="/admin/activity-mappings" element={<AdminActivityMappingsPage/>}/>
|
||||
<Route path="/admin/training-profiles" element={<AdminTrainingProfiles/>}/>
|
||||
<Route path="/admin/prompts" element={<AdminPromptsPage/>}/>
|
||||
<Route path="/subscription" element={<SubscriptionPage/>}/>
|
||||
</Routes>
|
||||
</main>
|
||||
|
|
|
|||
255
frontend/src/components/PlaceholderPicker.jsx
Normal file
255
frontend/src/components/PlaceholderPicker.jsx
Normal file
|
|
@ -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 (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
background: 'rgba(0,0,0,0.5)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 2000,
|
||||
padding: 20
|
||||
}}
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
onClick={e => e.stopPropagation()}
|
||||
style={{
|
||||
background: 'var(--bg)',
|
||||
borderRadius: 12,
|
||||
maxWidth: 800,
|
||||
width: '100%',
|
||||
maxHeight: '80vh',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
overflow: 'hidden'
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<div style={{
|
||||
padding: 20,
|
||||
borderBottom: '1px solid var(--border)',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center'
|
||||
}}>
|
||||
<h3 style={{ margin: 0, fontSize: 18, fontWeight: 600 }}>
|
||||
Platzhalter auswählen
|
||||
</h3>
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 4 }}
|
||||
>
|
||||
<X size={24} color="var(--text3)" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div style={{ padding: '12px 20px', borderBottom: '1px solid var(--border)' }}>
|
||||
<div style={{ position: 'relative' }}>
|
||||
<Search
|
||||
size={16}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: 12,
|
||||
top: '50%',
|
||||
transform: 'translateY(-50%)',
|
||||
color: 'var(--text3)'
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
className="form-input"
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
placeholder="Platzhalter suchen..."
|
||||
style={{
|
||||
width: '100%',
|
||||
paddingLeft: 40,
|
||||
textAlign: 'left'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Categories */}
|
||||
<div style={{
|
||||
flex: 1,
|
||||
overflow: 'auto',
|
||||
padding: 20
|
||||
}}>
|
||||
{loading ? (
|
||||
<div style={{ textAlign: 'center', padding: 40, color: 'var(--text3)' }}>
|
||||
Lädt Platzhalter...
|
||||
</div>
|
||||
) : Object.keys(filteredCatalog).length === 0 ? (
|
||||
<div style={{ textAlign: 'center', padding: 40, color: 'var(--text3)' }}>
|
||||
Keine Platzhalter gefunden
|
||||
</div>
|
||||
) : (
|
||||
Object.entries(filteredCatalog).map(([category, items]) => (
|
||||
<div key={category} style={{ marginBottom: 16 }}>
|
||||
<div
|
||||
onClick={() => toggleCategory(category)}
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
background: 'var(--surface)',
|
||||
borderRadius: 8,
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 8
|
||||
}}
|
||||
>
|
||||
<h4 style={{ margin: 0, fontSize: 14, fontWeight: 600 }}>
|
||||
{category} ({items.length})
|
||||
</h4>
|
||||
<span style={{ fontSize: 12, color: 'var(--text3)' }}>
|
||||
{expandedCategories.has(category) ? '▼' : '▶'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{expandedCategories.has(category) && (
|
||||
<div style={{ display: 'grid', gap: 6, paddingLeft: 12 }}>
|
||||
{items.map(item => (
|
||||
<div
|
||||
key={item.key}
|
||||
onClick={() => 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)'
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div>
|
||||
<code style={{
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
color: 'var(--accent)',
|
||||
fontFamily: 'monospace'
|
||||
}}>
|
||||
{`{{${item.key}}}`}
|
||||
</code>
|
||||
<div style={{
|
||||
fontSize: 11,
|
||||
color: 'var(--text2)',
|
||||
marginTop: 2
|
||||
}}>
|
||||
{item.description}
|
||||
</div>
|
||||
</div>
|
||||
{item.example && (
|
||||
<div style={{
|
||||
fontSize: 11,
|
||||
color: 'var(--text3)',
|
||||
fontFamily: 'monospace',
|
||||
padding: '4px 8px',
|
||||
background: 'var(--bg)',
|
||||
borderRadius: 4,
|
||||
marginTop: 6,
|
||||
wordBreak: 'break-word'
|
||||
}}>
|
||||
<span style={{ fontSize: 9, opacity: 0.7, marginRight: 4 }}>Beispiel:</span>
|
||||
{item.example}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div style={{
|
||||
padding: 16,
|
||||
borderTop: '1px solid var(--border)',
|
||||
textAlign: 'center',
|
||||
fontSize: 11,
|
||||
color: 'var(--text3)'
|
||||
}}>
|
||||
Klicke auf einen Platzhalter zum Einfügen
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
190
frontend/src/components/PromptGenerator.jsx
Normal file
190
frontend/src/components/PromptGenerator.jsx
Normal file
|
|
@ -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 (
|
||||
<div style={{
|
||||
position:'fixed', inset:0, background:'rgba(0,0,0,0.6)',
|
||||
display:'flex', alignItems:'center', justifyContent:'center',
|
||||
zIndex:1001, padding:20
|
||||
}}>
|
||||
<div style={{
|
||||
background:'var(--bg)', borderRadius:12, maxWidth:700, width:'100%',
|
||||
maxHeight:'90vh', overflow:'auto', padding:24
|
||||
}}>
|
||||
<h2 style={{margin:'0 0 24px 0', fontSize:18, fontWeight:600}}>
|
||||
🤖 KI-Prompt generieren
|
||||
</h2>
|
||||
|
||||
{/* Step 1: Goal */}
|
||||
<div style={{marginBottom:24}}>
|
||||
<label className="form-label" style={{display:'block', marginBottom:6}}>
|
||||
1️⃣ Was möchtest du analysieren?
|
||||
</label>
|
||||
<textarea
|
||||
className="form-input"
|
||||
value={goal}
|
||||
onChange={e => setGoal(e.target.value)}
|
||||
rows={3}
|
||||
placeholder="Beispiel: Ich möchte wissen ob meine Proteinzufuhr ausreichend ist für Muskelaufbau und wie ich sie optimieren kann."
|
||||
style={{width:'100%', textAlign:'left', resize:'vertical'}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Step 2: Data Categories */}
|
||||
<div style={{marginBottom:24}}>
|
||||
<label className="form-label">
|
||||
2️⃣ Welche Daten sollen analysiert werden?
|
||||
</label>
|
||||
<div style={{display:'grid', gridTemplateColumns:'1fr 1fr', gap:8}}>
|
||||
{categories.map(cat => (
|
||||
<label
|
||||
key={cat.id}
|
||||
style={{
|
||||
display:'flex', alignItems:'center', gap:8,
|
||||
padding:8, background:'var(--surface)', borderRadius:6,
|
||||
cursor:'pointer', fontSize:13
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={dataCategories.includes(cat.id)}
|
||||
onChange={() => handleToggleCategory(cat.id)}
|
||||
/>
|
||||
{cat.label}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleShowExampleData}
|
||||
disabled={loadingExample}
|
||||
style={{
|
||||
marginTop:12, fontSize:12, padding:'6px 12px',
|
||||
background:'var(--surface2)', border:'1px solid var(--border)',
|
||||
borderRadius:6, cursor:'pointer'
|
||||
}}
|
||||
>
|
||||
{loadingExample ? 'Lädt...' : '📊 Beispieldaten anzeigen'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Example Data */}
|
||||
{exampleData && (
|
||||
<div style={{
|
||||
marginBottom:24, padding:12, background:'var(--surface2)',
|
||||
borderRadius:8, fontSize:11, fontFamily:'monospace',
|
||||
maxHeight:200, overflow:'auto'
|
||||
}}>
|
||||
<strong style={{fontFamily:'var(--font)'}}>Deine aktuellen Daten:</strong>
|
||||
<pre style={{marginTop:8, whiteSpace:'pre-wrap'}}>
|
||||
{JSON.stringify(exampleData, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 3: Desired Format */}
|
||||
<div style={{marginBottom:24}}>
|
||||
<label className="form-label" style={{display:'block', marginBottom:6}}>
|
||||
3️⃣ Gewünschtes Antwort-Format (optional)
|
||||
</label>
|
||||
<textarea
|
||||
className="form-input"
|
||||
value={exampleOutput}
|
||||
onChange={e => setExampleOutput(e.target.value)}
|
||||
rows={4}
|
||||
placeholder={'Beispiel:\n## Analyse\n[Bewertung]\n\n## Empfehlungen\n- Punkt 1\n- Punkt 2'}
|
||||
style={{width:'100%', textAlign:'left', fontFamily:'monospace', fontSize:12, resize:'vertical'}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Info Box */}
|
||||
<div style={{
|
||||
marginBottom:24, padding:12, background:'var(--surface)',
|
||||
border:'1px solid var(--border)', borderRadius:8, fontSize:12,
|
||||
color:'var(--text3)'
|
||||
}}>
|
||||
<strong style={{color:'var(--text2)'}}>💡 Tipp:</strong> Je präziser deine Zielbeschreibung,
|
||||
desto besser der generierte Prompt. Die KI wählt automatisch passende Platzhalter
|
||||
und strukturiert die Analyse optimal.
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div style={{display:'flex', gap:12}}>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={handleGenerate}
|
||||
disabled={generating || !goal.trim() || dataCategories.length === 0}
|
||||
style={{flex:1}}
|
||||
>
|
||||
{generating ? '⏳ Generiere...' : '🚀 Prompt generieren'}
|
||||
</button>
|
||||
<button className="btn" onClick={onClose}>
|
||||
Abbrechen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
872
frontend/src/components/UnifiedPromptModal.jsx
Normal file
872
frontend/src/components/UnifiedPromptModal.jsx
Normal file
|
|
@ -0,0 +1,872 @@
|
|||
import { useState, useEffect, useRef } from 'react'
|
||||
import { api } from '../utils/api'
|
||||
import { X, Plus, Trash2, MoveUp, MoveDown, Code } from 'lucide-react'
|
||||
import PlaceholderPicker from './PlaceholderPicker'
|
||||
|
||||
/**
|
||||
* Unified Prompt Editor Modal (Issue #28 Phase 3)
|
||||
*
|
||||
* Supports both prompt types:
|
||||
* - Base: Single reusable template
|
||||
* - Pipeline: Multi-stage workflow with dynamic stages
|
||||
*/
|
||||
export default function UnifiedPromptModal({ prompt, onSave, onClose }) {
|
||||
const [name, setName] = useState('')
|
||||
const [slug, setSlug] = useState('')
|
||||
const [displayName, setDisplayName] = useState('')
|
||||
const [description, setDescription] = useState('')
|
||||
const [type, setType] = useState('pipeline') // 'base' or 'pipeline'
|
||||
const [category, setCategory] = useState('ganzheitlich')
|
||||
const [active, setActive] = useState(true)
|
||||
const [sortOrder, setSortOrder] = useState(0)
|
||||
|
||||
// Base prompt fields
|
||||
const [template, setTemplate] = useState('')
|
||||
const [outputFormat, setOutputFormat] = useState('text')
|
||||
|
||||
// Pipeline prompt fields
|
||||
const [stages, setStages] = useState([
|
||||
{
|
||||
stage: 1,
|
||||
prompts: []
|
||||
}
|
||||
])
|
||||
|
||||
// Available prompts for reference selection
|
||||
const [availablePrompts, setAvailablePrompts] = useState([])
|
||||
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState(null)
|
||||
const [showPlaceholderPicker, setShowPlaceholderPicker] = useState(false)
|
||||
const [pickerTarget, setPickerTarget] = useState(null) // 'base' or {stage, promptIdx}
|
||||
const [cursorPosition, setCursorPosition] = useState(null) // Track cursor position for insertion
|
||||
const baseTemplateRef = useRef(null)
|
||||
const stageTemplateRefs = useRef({}) // Map of stage_promptIdx -> ref
|
||||
|
||||
// Test functionality
|
||||
const [testing, setTesting] = useState(false)
|
||||
const [testResult, setTestResult] = useState(null)
|
||||
const [showDebug, setShowDebug] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
loadAvailablePrompts()
|
||||
|
||||
if (prompt) {
|
||||
// Edit mode
|
||||
setName(prompt.name || '')
|
||||
setSlug(prompt.slug || '')
|
||||
setDisplayName(prompt.display_name || '')
|
||||
setDescription(prompt.description || '')
|
||||
setType(prompt.type || 'pipeline')
|
||||
setCategory(prompt.category || 'ganzheitlich')
|
||||
setActive(prompt.active ?? true)
|
||||
setSortOrder(prompt.sort_order || 0)
|
||||
setTemplate(prompt.template || '')
|
||||
setOutputFormat(prompt.output_format || 'text')
|
||||
|
||||
// Parse stages if editing pipeline
|
||||
if (prompt.type === 'pipeline' && prompt.stages) {
|
||||
try {
|
||||
const parsedStages = typeof prompt.stages === 'string'
|
||||
? JSON.parse(prompt.stages)
|
||||
: prompt.stages
|
||||
setStages(parsedStages.length > 0 ? parsedStages : [{ stage: 1, prompts: [] }])
|
||||
} catch (e) {
|
||||
console.error('Failed to parse stages:', e)
|
||||
setStages([{ stage: 1, prompts: [] }])
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [prompt])
|
||||
|
||||
const loadAvailablePrompts = async () => {
|
||||
try {
|
||||
const prompts = await api.listAdminPrompts()
|
||||
setAvailablePrompts(prompts)
|
||||
} catch (e) {
|
||||
setError('Fehler beim Laden der Prompts: ' + e.message)
|
||||
}
|
||||
}
|
||||
|
||||
const addStage = () => {
|
||||
const nextStageNum = Math.max(...stages.map(s => s.stage), 0) + 1
|
||||
setStages([...stages, { stage: nextStageNum, prompts: [] }])
|
||||
}
|
||||
|
||||
const removeStage = (stageNum) => {
|
||||
if (stages.length === 1) {
|
||||
setError('Mindestens eine Stage erforderlich')
|
||||
return
|
||||
}
|
||||
setStages(stages.filter(s => s.stage !== stageNum))
|
||||
}
|
||||
|
||||
const moveStage = (stageNum, direction) => {
|
||||
const idx = stages.findIndex(s => s.stage === stageNum)
|
||||
if (idx === -1) return
|
||||
|
||||
const newStages = [...stages]
|
||||
if (direction === 'up' && idx > 0) {
|
||||
[newStages[idx], newStages[idx - 1]] = [newStages[idx - 1], newStages[idx]]
|
||||
} else if (direction === 'down' && idx < newStages.length - 1) {
|
||||
[newStages[idx], newStages[idx + 1]] = [newStages[idx + 1], newStages[idx]]
|
||||
}
|
||||
|
||||
// Renumber stages
|
||||
newStages.forEach((s, i) => s.stage = i + 1)
|
||||
setStages(newStages)
|
||||
}
|
||||
|
||||
const addPromptToStage = (stageNum) => {
|
||||
setStages(stages.map(s => {
|
||||
if (s.stage === stageNum) {
|
||||
return {
|
||||
...s,
|
||||
prompts: [...s.prompts, {
|
||||
source: 'inline',
|
||||
template: '',
|
||||
output_key: `output_${Date.now()}`,
|
||||
output_format: 'text'
|
||||
}]
|
||||
}
|
||||
}
|
||||
return s
|
||||
}))
|
||||
}
|
||||
|
||||
const removePromptFromStage = (stageNum, promptIdx) => {
|
||||
setStages(stages.map(s => {
|
||||
if (s.stage === stageNum) {
|
||||
return {
|
||||
...s,
|
||||
prompts: s.prompts.filter((_, i) => i !== promptIdx)
|
||||
}
|
||||
}
|
||||
return s
|
||||
}))
|
||||
}
|
||||
|
||||
const updateStagePrompt = (stageNum, promptIdx, field, value) => {
|
||||
setStages(stages.map(s => {
|
||||
if (s.stage === stageNum) {
|
||||
const newPrompts = [...s.prompts]
|
||||
newPrompts[promptIdx] = { ...newPrompts[promptIdx], [field]: value }
|
||||
return { ...s, prompts: newPrompts }
|
||||
}
|
||||
return s
|
||||
}))
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
// Validation
|
||||
if (!name.trim() || !slug.trim()) {
|
||||
setError('Name und Slug sind Pflichtfelder')
|
||||
return
|
||||
}
|
||||
|
||||
if (type === 'base' && !template.trim()) {
|
||||
setError('Basis-Prompts benötigen ein Template')
|
||||
return
|
||||
}
|
||||
|
||||
if (type === 'pipeline' && stages.length === 0) {
|
||||
setError('Pipeline-Prompts benötigen mindestens eine Stage')
|
||||
return
|
||||
}
|
||||
|
||||
if (type === 'pipeline') {
|
||||
// Validate all stages have at least one prompt
|
||||
const emptyStages = stages.filter(s => s.prompts.length === 0)
|
||||
if (emptyStages.length > 0) {
|
||||
setError(`Stage ${emptyStages[0].stage} hat keine Prompts`)
|
||||
return
|
||||
}
|
||||
|
||||
// Validate all prompts have required fields
|
||||
for (const stage of stages) {
|
||||
for (const p of stage.prompts) {
|
||||
if (!p.output_key) {
|
||||
setError(`Stage ${stage.stage}: Output-Key fehlt`)
|
||||
return
|
||||
}
|
||||
if (p.source === 'inline' && !p.template) {
|
||||
setError(`Stage ${stage.stage}: Inline-Prompt ohne Template`)
|
||||
return
|
||||
}
|
||||
if (p.source === 'reference' && !p.slug) {
|
||||
setError(`Stage ${stage.stage}: Referenz-Prompt ohne Slug`)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const data = {
|
||||
name,
|
||||
slug,
|
||||
display_name: displayName,
|
||||
description,
|
||||
type,
|
||||
category,
|
||||
active,
|
||||
sort_order: sortOrder,
|
||||
output_format: outputFormat
|
||||
}
|
||||
|
||||
if (type === 'base') {
|
||||
data.template = template
|
||||
data.stages = null
|
||||
} else {
|
||||
data.template = null
|
||||
data.stages = stages
|
||||
}
|
||||
|
||||
if (prompt?.id) {
|
||||
await api.updateUnifiedPrompt(prompt.id, data)
|
||||
} else {
|
||||
await api.createUnifiedPrompt(data)
|
||||
}
|
||||
|
||||
onSave()
|
||||
} catch (e) {
|
||||
setError(e.message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleExport = () => {
|
||||
// Export complete prompt configuration as JSON
|
||||
const exportData = {
|
||||
name,
|
||||
slug,
|
||||
display_name: displayName,
|
||||
description,
|
||||
type,
|
||||
category,
|
||||
active,
|
||||
sort_order: sortOrder,
|
||||
output_format: outputFormat,
|
||||
template: type === 'base' ? template : null,
|
||||
stages: type === 'pipeline' ? stages : null
|
||||
}
|
||||
|
||||
const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `prompt-${slug || 'new'}-${new Date().toISOString().split('T')[0]}.json`
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
const handleTest = async () => {
|
||||
// Can only test existing prompts (need slug in database)
|
||||
if (!prompt?.slug) {
|
||||
setError('Bitte erst speichern, dann testen')
|
||||
return
|
||||
}
|
||||
|
||||
setTesting(true)
|
||||
setError(null)
|
||||
setTestResult(null)
|
||||
|
||||
try {
|
||||
const result = await api.executeUnifiedPrompt(prompt.slug, null, null, true)
|
||||
setTestResult(result)
|
||||
setShowDebug(true)
|
||||
} catch (e) {
|
||||
// Show error AND try to extract debug info from error
|
||||
const errorMsg = e.message
|
||||
let debugData = null
|
||||
|
||||
// Try to parse error message for embedded debug info
|
||||
try {
|
||||
const parsed = JSON.parse(errorMsg)
|
||||
if (parsed.detail) {
|
||||
setError('Test-Fehler: ' + parsed.detail)
|
||||
debugData = parsed
|
||||
} else {
|
||||
setError('Test-Fehler: ' + errorMsg)
|
||||
}
|
||||
} catch {
|
||||
setError('Test-Fehler: ' + errorMsg)
|
||||
}
|
||||
|
||||
// Set result with error info so debug viewer shows it
|
||||
setTestResult({
|
||||
error: true,
|
||||
error_message: errorMsg,
|
||||
debug: debugData || { error: errorMsg }
|
||||
})
|
||||
setShowDebug(true) // ALWAYS show debug on test, even on error
|
||||
} finally {
|
||||
setTesting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleExportPlaceholders = () => {
|
||||
if (!testResult) return
|
||||
|
||||
// Extract all placeholder data from test result
|
||||
const debug = testResult.debug || testResult
|
||||
const exportData = {
|
||||
export_date: new Date().toISOString(),
|
||||
prompt_slug: prompt?.slug || 'unknown',
|
||||
prompt_name: name || 'Unnamed Prompt',
|
||||
placeholders: {}
|
||||
}
|
||||
|
||||
// For pipeline prompts, collect from all stages
|
||||
if (debug.stages && Array.isArray(debug.stages)) {
|
||||
debug.stages.forEach(stage => {
|
||||
exportData.placeholders[`stage_${stage.stage}`] = {
|
||||
available_variables: stage.available_variables || [],
|
||||
prompts: stage.prompts?.map(p => ({
|
||||
source: p.source,
|
||||
resolved: p.resolved_placeholders || p.ref_debug?.resolved_placeholders || {},
|
||||
unresolved: p.unresolved_placeholders || p.ref_debug?.unresolved_placeholders || []
|
||||
})) || []
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// For base prompts or direct execution
|
||||
if (debug.resolved_placeholders) {
|
||||
exportData.placeholders.resolved = debug.resolved_placeholders
|
||||
}
|
||||
if (debug.unresolved_placeholders) {
|
||||
exportData.placeholders.unresolved = debug.unresolved_placeholders
|
||||
}
|
||||
if (debug.available_variables) {
|
||||
exportData.available_variables = debug.available_variables
|
||||
}
|
||||
if (debug.initial_variables) {
|
||||
exportData.initial_variables = debug.initial_variables
|
||||
}
|
||||
|
||||
// Download as JSON
|
||||
const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `placeholders-${prompt?.slug || 'test'}-${new Date().toISOString().split('T')[0]}.json`
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.5)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
zIndex: 1000, padding: 20, overflow: 'auto'
|
||||
}}>
|
||||
<div style={{
|
||||
background: 'var(--bg)', borderRadius: 12, maxWidth: 1000, width: '100%',
|
||||
maxHeight: '90vh', overflow: 'auto', padding: 24
|
||||
}}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 24 }}>
|
||||
<h2 style={{ margin: 0, fontSize: 20, fontWeight: 600 }}>
|
||||
{prompt ? 'Prompt bearbeiten' : 'Neuer Prompt'}
|
||||
</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 4 }}
|
||||
>
|
||||
<X size={24} color="var(--text3)" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div style={{
|
||||
padding: 12, background: '#fee', color: '#c00', borderRadius: 8, marginBottom: 16, fontSize: 13
|
||||
}}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Basic Info */}
|
||||
<div style={{ display: 'grid', gap: 16, marginBottom: 24 }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
|
||||
<div>
|
||||
<label className="form-label">Name *</label>
|
||||
<input
|
||||
className="form-input"
|
||||
value={name}
|
||||
onChange={e => setName(e.target.value)}
|
||||
placeholder="Interner Name"
|
||||
style={{ width: '100%', textAlign: 'left' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="form-label">Slug *</label>
|
||||
<input
|
||||
className="form-input"
|
||||
value={slug}
|
||||
onChange={e => setSlug(e.target.value)}
|
||||
placeholder="technischer_name"
|
||||
style={{ width: '100%', textAlign: 'left' }}
|
||||
disabled={!!prompt}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="form-label">Anzeigename</label>
|
||||
<input
|
||||
className="form-input"
|
||||
value={displayName}
|
||||
onChange={e => setDisplayName(e.target.value)}
|
||||
placeholder="Name für Benutzer (optional)"
|
||||
style={{ width: '100%', textAlign: 'left' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="form-label">Beschreibung</label>
|
||||
<textarea
|
||||
className="form-input"
|
||||
value={description}
|
||||
onChange={e => setDescription(e.target.value)}
|
||||
rows={2}
|
||||
placeholder="Kurze Beschreibung des Prompts"
|
||||
style={{ width: '100%', textAlign: 'left', resize: 'vertical' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 12 }}>
|
||||
<div>
|
||||
<label className="form-label">Typ *</label>
|
||||
<select
|
||||
className="form-select"
|
||||
value={type}
|
||||
onChange={e => setType(e.target.value)}
|
||||
style={{ width: '100%' }}
|
||||
disabled={!!prompt}
|
||||
>
|
||||
<option value="base">Basis-Prompt</option>
|
||||
<option value="pipeline">Pipeline</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="form-label">Kategorie</label>
|
||||
<select
|
||||
className="form-select"
|
||||
value={category}
|
||||
onChange={e => setCategory(e.target.value)}
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
<option value="ganzheitlich">Ganzheitlich</option>
|
||||
<option value="körper">Körper</option>
|
||||
<option value="ernährung">Ernährung</option>
|
||||
<option value="training">Training</option>
|
||||
<option value="schlaf">Schlaf</option>
|
||||
<option value="pipeline">Pipeline</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="form-label">Output-Format</label>
|
||||
<select
|
||||
className="form-select"
|
||||
value={outputFormat}
|
||||
onChange={e => setOutputFormat(e.target.value)}
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
<option value="text">Text</option>
|
||||
<option value="json">JSON</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 16, alignItems: 'center' }}>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 6, cursor: 'pointer' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={active}
|
||||
onChange={e => setActive(e.target.checked)}
|
||||
/>
|
||||
<span style={{ fontSize: 13 }}>Aktiv</span>
|
||||
</label>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<label className="form-label" style={{ margin: 0 }}>Sortierung:</label>
|
||||
<input
|
||||
type="number"
|
||||
className="form-input"
|
||||
value={sortOrder}
|
||||
onChange={e => setSortOrder(parseInt(e.target.value) || 0)}
|
||||
style={{ width: 80, padding: '4px 8px' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Type-specific editor */}
|
||||
{type === 'base' && (
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<h3 style={{ fontSize: 16, fontWeight: 600, marginBottom: 12 }}>Template</h3>
|
||||
<textarea
|
||||
ref={baseTemplateRef}
|
||||
className="form-input"
|
||||
value={template}
|
||||
onChange={e => setTemplate(e.target.value)}
|
||||
onClick={e => setCursorPosition(e.target.selectionStart)}
|
||||
onKeyUp={e => setCursorPosition(e.target.selectionStart)}
|
||||
rows={12}
|
||||
placeholder="Prompt-Template mit {{placeholders}}..."
|
||||
style={{ width: '100%', textAlign: 'left', resize: 'vertical', fontFamily: 'monospace', fontSize: 12 }}
|
||||
/>
|
||||
<div style={{ fontSize: 11, color: 'var(--text3)', marginTop: 4 }}>
|
||||
<button
|
||||
className="btn"
|
||||
onClick={() => {
|
||||
setPickerTarget('base')
|
||||
setShowPlaceholderPicker(true)
|
||||
}}
|
||||
style={{
|
||||
fontSize: 12,
|
||||
padding: '6px 12px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 6
|
||||
}}
|
||||
>
|
||||
<Code size={14} />
|
||||
Platzhalter einfügen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{type === 'pipeline' && (
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
|
||||
<h3 style={{ fontSize: 16, fontWeight: 600, margin: 0 }}>Stages ({stages.length})</h3>
|
||||
<button className="btn" onClick={addStage} style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<Plus size={16} /> Stage hinzufügen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{stages.map((stage, sIdx) => (
|
||||
<div key={stage.stage} style={{
|
||||
background: 'var(--surface)', padding: 16, borderRadius: 8,
|
||||
border: '1px solid var(--border)', marginBottom: 12
|
||||
}}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
|
||||
<h4 style={{ margin: 0, fontSize: 14, fontWeight: 600 }}>Stage {stage.stage}</h4>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
{sIdx > 0 && (
|
||||
<button
|
||||
className="btn"
|
||||
onClick={() => moveStage(stage.stage, 'up')}
|
||||
style={{ padding: '4px 8px' }}
|
||||
>
|
||||
<MoveUp size={14} />
|
||||
</button>
|
||||
)}
|
||||
{sIdx < stages.length - 1 && (
|
||||
<button
|
||||
className="btn"
|
||||
onClick={() => moveStage(stage.stage, 'down')}
|
||||
style={{ padding: '4px 8px' }}
|
||||
>
|
||||
<MoveDown size={14} />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="btn"
|
||||
onClick={() => removeStage(stage.stage)}
|
||||
style={{ padding: '4px 8px', color: 'var(--danger)' }}
|
||||
disabled={stages.length === 1}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Prompts in this stage */}
|
||||
{stage.prompts.map((p, pIdx) => (
|
||||
<div key={pIdx} style={{
|
||||
background: 'var(--bg)', padding: 12, borderRadius: 6, marginBottom: 8,
|
||||
border: '1px solid var(--border)'
|
||||
}}>
|
||||
<div style={{ display: 'grid', gap: 8 }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '120px 1fr 120px auto', gap: 8, alignItems: 'center' }}>
|
||||
<select
|
||||
className="form-select"
|
||||
value={p.source || 'inline'}
|
||||
onChange={e => updateStagePrompt(stage.stage, pIdx, 'source', e.target.value)}
|
||||
style={{ fontSize: 12 }}
|
||||
>
|
||||
<option value="inline">Inline</option>
|
||||
<option value="reference">Referenz</option>
|
||||
</select>
|
||||
|
||||
<input
|
||||
className="form-input"
|
||||
value={p.output_key || ''}
|
||||
onChange={e => updateStagePrompt(stage.stage, pIdx, 'output_key', e.target.value)}
|
||||
placeholder="output_key"
|
||||
style={{ fontSize: 12, textAlign: 'left' }}
|
||||
/>
|
||||
|
||||
<select
|
||||
className="form-select"
|
||||
value={p.output_format || 'text'}
|
||||
onChange={e => updateStagePrompt(stage.stage, pIdx, 'output_format', e.target.value)}
|
||||
style={{ fontSize: 12 }}
|
||||
>
|
||||
<option value="text">Text</option>
|
||||
<option value="json">JSON</option>
|
||||
</select>
|
||||
|
||||
<button
|
||||
onClick={() => removePromptFromStage(stage.stage, pIdx)}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 4 }}
|
||||
>
|
||||
<Trash2 size={16} color="var(--danger)" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{p.source === 'reference' ? (
|
||||
<select
|
||||
className="form-select"
|
||||
value={p.slug || ''}
|
||||
onChange={e => updateStagePrompt(stage.stage, pIdx, 'slug', e.target.value)}
|
||||
style={{ fontSize: 12 }}
|
||||
>
|
||||
<option value="">-- Prompt wählen --</option>
|
||||
{availablePrompts
|
||||
.filter(ap => ap.type === 'base' || !ap.type)
|
||||
.map(ap => (
|
||||
<option key={ap.slug} value={ap.slug}>
|
||||
{ap.display_name || ap.name} ({ap.slug})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<div>
|
||||
<textarea
|
||||
ref={el => stageTemplateRefs.current[`${stage.stage}_${pIdx}`] = el}
|
||||
className="form-input"
|
||||
value={p.template || ''}
|
||||
onChange={e => updateStagePrompt(stage.stage, pIdx, 'template', e.target.value)}
|
||||
onClick={e => {
|
||||
setCursorPosition(e.target.selectionStart)
|
||||
setPickerTarget({ stage: stage.stage, promptIdx: pIdx })
|
||||
}}
|
||||
onKeyUp={e => {
|
||||
setCursorPosition(e.target.selectionStart)
|
||||
setPickerTarget({ stage: stage.stage, promptIdx: pIdx })
|
||||
}}
|
||||
rows={3}
|
||||
placeholder="Inline-Template mit {{placeholders}}..."
|
||||
style={{ width: '100%', fontSize: 12, textAlign: 'left', resize: 'vertical', fontFamily: 'monospace' }}
|
||||
/>
|
||||
<div style={{ fontSize: 10, color: 'var(--text3)', marginTop: 4 }}>
|
||||
<button
|
||||
className="btn"
|
||||
onClick={() => {
|
||||
setPickerTarget({ stage: stage.stage, promptIdx: pIdx })
|
||||
setShowPlaceholderPicker(true)
|
||||
}}
|
||||
style={{
|
||||
fontSize: 11,
|
||||
padding: '4px 8px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 4
|
||||
}}
|
||||
>
|
||||
<Code size={12} />
|
||||
Platzhalter
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<button
|
||||
className="btn"
|
||||
onClick={() => addPromptToStage(stage.stage)}
|
||||
style={{ fontSize: 12, display: 'flex', alignItems: 'center', gap: 4 }}
|
||||
>
|
||||
<Plus size={14} /> Prompt hinzufügen
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Debug Output */}
|
||||
{showDebug && testResult && (
|
||||
<div style={{
|
||||
marginTop: 16,
|
||||
padding: 16,
|
||||
background: 'var(--surface2)',
|
||||
borderRadius: 8,
|
||||
border: '1px solid var(--border)'
|
||||
}}>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 12
|
||||
}}>
|
||||
<h3 style={{ margin: 0, fontSize: 14, fontWeight: 600 }}>
|
||||
🔬 Debug-Info
|
||||
</h3>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||
<button
|
||||
className="btn"
|
||||
onClick={handleExportPlaceholders}
|
||||
style={{
|
||||
padding: '4px 8px',
|
||||
fontSize: 11,
|
||||
background: 'var(--accent)',
|
||||
color: 'white'
|
||||
}}
|
||||
title="Exportiere alle Platzhalter mit Werten als JSON"
|
||||
>
|
||||
📋 Platzhalter exportieren
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowDebug(false)}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 4 }}
|
||||
>
|
||||
<X size={16} color="var(--text3)" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<pre style={{
|
||||
fontSize: 11,
|
||||
fontFamily: 'monospace',
|
||||
background: 'var(--bg)',
|
||||
padding: 12,
|
||||
borderRadius: 6,
|
||||
overflow: 'auto',
|
||||
maxHeight: 400,
|
||||
lineHeight: 1.5,
|
||||
color: 'var(--text2)'
|
||||
}}>
|
||||
{JSON.stringify(testResult.debug || testResult, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div style={{
|
||||
display: 'flex', gap: 12, justifyContent: 'space-between',
|
||||
paddingTop: 16, borderTop: '1px solid var(--border)'
|
||||
}}>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button
|
||||
className="btn"
|
||||
onClick={handleTest}
|
||||
disabled={testing || loading}
|
||||
style={{
|
||||
background: testing ? 'var(--surface)' : 'var(--accent)',
|
||||
color: testing ? 'var(--text3)' : 'white'
|
||||
}}
|
||||
title={!prompt?.slug ? 'Bitte erst speichern, dann testen' : 'Test mit Debug-Modus ausführen'}
|
||||
>
|
||||
{testing ? '🔬 Teste...' : '🔬 Test ausführen'}
|
||||
</button>
|
||||
<button
|
||||
className="btn"
|
||||
onClick={handleExport}
|
||||
disabled={loading}
|
||||
title="Exportiere Prompt-Konfiguration als JSON"
|
||||
>
|
||||
📥 Export
|
||||
</button>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 12 }}>
|
||||
<button className="btn" onClick={onClose}>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={handleSave}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? 'Speichern...' : 'Speichern'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Placeholder Picker */}
|
||||
{showPlaceholderPicker && (
|
||||
<PlaceholderPicker
|
||||
onSelect={(placeholder) => {
|
||||
if (pickerTarget === 'base') {
|
||||
// Insert into base template at cursor position
|
||||
const pos = cursorPosition ?? template.length
|
||||
const newTemplate = template.slice(0, pos) + placeholder + template.slice(pos)
|
||||
setTemplate(newTemplate)
|
||||
|
||||
// Restore focus and cursor position after insertion
|
||||
setTimeout(() => {
|
||||
if (baseTemplateRef.current) {
|
||||
baseTemplateRef.current.focus()
|
||||
const newPos = pos + placeholder.length
|
||||
baseTemplateRef.current.setSelectionRange(newPos, newPos)
|
||||
setCursorPosition(newPos)
|
||||
}
|
||||
}, 0)
|
||||
} else if (pickerTarget && typeof pickerTarget === 'object') {
|
||||
// Insert into pipeline stage template at cursor position
|
||||
const { stage: stageNum, promptIdx } = pickerTarget
|
||||
setStages(stages.map(s => {
|
||||
if (s.stage === stageNum) {
|
||||
const newPrompts = [...s.prompts]
|
||||
const currentTemplate = newPrompts[promptIdx].template || ''
|
||||
const pos = cursorPosition ?? currentTemplate.length
|
||||
const newTemplate = currentTemplate.slice(0, pos) + placeholder + currentTemplate.slice(pos)
|
||||
|
||||
newPrompts[promptIdx] = {
|
||||
...newPrompts[promptIdx],
|
||||
template: newTemplate
|
||||
}
|
||||
|
||||
// Restore focus and cursor position
|
||||
setTimeout(() => {
|
||||
const refKey = `${stageNum}_${promptIdx}`
|
||||
const textarea = stageTemplateRefs.current[refKey]
|
||||
if (textarea) {
|
||||
textarea.focus()
|
||||
const newPos = pos + placeholder.length
|
||||
textarea.setSelectionRange(newPos, newPos)
|
||||
setCursorPosition(newPos)
|
||||
}
|
||||
}, 0)
|
||||
|
||||
return { ...s, prompts: newPrompts }
|
||||
}
|
||||
return s
|
||||
}))
|
||||
}
|
||||
}}
|
||||
onClose={() => {
|
||||
setShowPlaceholderPicker(false)
|
||||
setPickerTarget(null)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -451,6 +451,23 @@ export default function AdminPanel() {
|
|||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* KI-Prompts Section */}
|
||||
<div className="card">
|
||||
<div style={{fontWeight:700,fontSize:14,marginBottom:12,display:'flex',alignItems:'center',gap:6}}>
|
||||
<Settings size={16} color="var(--accent)"/> KI-Prompts (v9f)
|
||||
</div>
|
||||
<div style={{fontSize:12,color:'var(--text3)',marginBottom:12,lineHeight:1.5}}>
|
||||
Verwalte AI-Prompts mit KI-Unterstützung: Generiere, optimiere und organisiere Prompts.
|
||||
</div>
|
||||
<div style={{display:'grid',gap:8}}>
|
||||
<Link to="/admin/prompts">
|
||||
<button className="btn btn-secondary btn-full">
|
||||
🤖 KI-Prompts verwalten
|
||||
</button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
588
frontend/src/pages/AdminPromptsPage.jsx
Normal file
588
frontend/src/pages/AdminPromptsPage.jsx
Normal file
|
|
@ -0,0 +1,588 @@
|
|||
import { useState, useEffect } from 'react'
|
||||
import { api } from '../utils/api'
|
||||
import UnifiedPromptModal from '../components/UnifiedPromptModal'
|
||||
import { Star, Trash2, Edit, Copy, Filter, ArrowDownToLine } from 'lucide-react'
|
||||
|
||||
/**
|
||||
* Admin Prompts Page - Unified System (Issue #28 Phase 3)
|
||||
*
|
||||
* Manages both base and pipeline-type prompts in one interface.
|
||||
*/
|
||||
export default function AdminPromptsPage() {
|
||||
const [prompts, setPrompts] = useState([])
|
||||
const [filteredPrompts, setFilteredPrompts] = useState([])
|
||||
const [typeFilter, setTypeFilter] = useState('all') // 'all' | 'base' | 'pipeline'
|
||||
const [category, setCategory] = useState('all')
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState(null)
|
||||
const [editingPrompt, setEditingPrompt] = useState(null)
|
||||
const [showNewPrompt, setShowNewPrompt] = useState(false)
|
||||
const [importing, setImporting] = useState(false)
|
||||
const [importResult, setImportResult] = useState(null)
|
||||
|
||||
const categories = [
|
||||
{ id: 'all', label: 'Alle Kategorien' },
|
||||
{ id: 'körper', label: 'Körper' },
|
||||
{ id: 'ernährung', label: 'Ernährung' },
|
||||
{ id: 'training', label: 'Training' },
|
||||
{ id: 'schlaf', label: 'Schlaf' },
|
||||
{ id: 'vitalwerte', label: 'Vitalwerte' },
|
||||
{ id: 'ziele', label: 'Ziele' },
|
||||
{ id: 'ganzheitlich', label: 'Ganzheitlich' },
|
||||
{ id: 'pipeline', label: 'Pipeline' }
|
||||
]
|
||||
|
||||
useEffect(() => {
|
||||
loadPrompts()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
let filtered = prompts
|
||||
|
||||
// Filter by type
|
||||
if (typeFilter === 'base') {
|
||||
filtered = filtered.filter(p => p.type === 'base')
|
||||
} else if (typeFilter === 'pipeline') {
|
||||
filtered = filtered.filter(p => p.type === 'pipeline')
|
||||
}
|
||||
|
||||
// Filter by category
|
||||
if (category !== 'all') {
|
||||
filtered = filtered.filter(p => p.category === category)
|
||||
}
|
||||
|
||||
setFilteredPrompts(filtered)
|
||||
}, [typeFilter, category, prompts])
|
||||
|
||||
const loadPrompts = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const data = await api.listAdminPrompts()
|
||||
setPrompts(data)
|
||||
setError(null)
|
||||
} catch (e) {
|
||||
setError(e.message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleToggleActive = async (prompt) => {
|
||||
try {
|
||||
await api.updateUnifiedPrompt(prompt.id, { active: !prompt.active })
|
||||
await loadPrompts()
|
||||
} catch (e) {
|
||||
alert('Fehler: ' + e.message)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (prompt) => {
|
||||
if (!confirm(`Prompt "${prompt.name}" wirklich löschen?`)) return
|
||||
|
||||
try {
|
||||
await api.deletePrompt(prompt.id)
|
||||
await loadPrompts()
|
||||
} catch (e) {
|
||||
alert('Fehler: ' + e.message)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDuplicate = async (prompt) => {
|
||||
try {
|
||||
await api.duplicatePrompt(prompt.id)
|
||||
await loadPrompts()
|
||||
} catch (e) {
|
||||
alert('Fehler: ' + e.message)
|
||||
}
|
||||
}
|
||||
|
||||
const handleConvertToBase = async (prompt) => {
|
||||
// Convert a 1-stage pipeline to a base prompt
|
||||
if (prompt.type !== 'pipeline') {
|
||||
alert('Nur Pipeline-Prompts können konvertiert werden')
|
||||
return
|
||||
}
|
||||
|
||||
const stages = typeof prompt.stages === 'string'
|
||||
? JSON.parse(prompt.stages)
|
||||
: prompt.stages
|
||||
|
||||
if (!stages || stages.length !== 1) {
|
||||
alert('Nur 1-stage Pipeline-Prompts können zu Basis-Prompts konvertiert werden')
|
||||
return
|
||||
}
|
||||
|
||||
const stage1 = stages[0]
|
||||
if (!stage1.prompts || stage1.prompts.length !== 1) {
|
||||
alert('Stage muss genau einen Prompt haben')
|
||||
return
|
||||
}
|
||||
|
||||
const firstPrompt = stage1.prompts[0]
|
||||
if (firstPrompt.source !== 'inline' || !firstPrompt.template) {
|
||||
alert('Nur inline Templates können konvertiert werden')
|
||||
return
|
||||
}
|
||||
|
||||
if (!confirm(`"${prompt.name}" zu Basis-Prompt konvertieren?`)) return
|
||||
|
||||
try {
|
||||
await api.updateUnifiedPrompt(prompt.id, {
|
||||
type: 'base',
|
||||
template: firstPrompt.template,
|
||||
output_format: firstPrompt.output_format || 'text',
|
||||
stages: null
|
||||
})
|
||||
await loadPrompts()
|
||||
} catch (e) {
|
||||
alert('Fehler: ' + e.message)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
setEditingPrompt(null)
|
||||
setShowNewPrompt(false)
|
||||
await loadPrompts()
|
||||
}
|
||||
|
||||
const getStageCount = (prompt) => {
|
||||
if (prompt.type !== 'pipeline' || !prompt.stages) return 0
|
||||
try {
|
||||
const stages = typeof prompt.stages === 'string'
|
||||
? JSON.parse(prompt.stages)
|
||||
: prompt.stages
|
||||
return stages.length
|
||||
} catch (e) {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
const getTypeLabel = (type) => {
|
||||
if (type === 'base') return 'Basis'
|
||||
if (type === 'pipeline') return 'Pipeline'
|
||||
return type || 'Pipeline' // Default for old prompts
|
||||
}
|
||||
|
||||
const getTypeColor = (type) => {
|
||||
if (type === 'base') return 'var(--accent)'
|
||||
if (type === 'pipeline') return '#6366f1'
|
||||
return 'var(--text3)'
|
||||
}
|
||||
|
||||
const handleExportAll = async () => {
|
||||
try {
|
||||
const data = await api.exportAllPrompts()
|
||||
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `all-prompts-${new Date().toISOString().split('T')[0]}.json`
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
URL.revokeObjectURL(url)
|
||||
} catch (e) {
|
||||
setError('Export-Fehler: ' + e.message)
|
||||
}
|
||||
}
|
||||
|
||||
const handleImport = async (event) => {
|
||||
const file = event.target.files[0]
|
||||
if (!file) return
|
||||
|
||||
setImporting(true)
|
||||
setError(null)
|
||||
setImportResult(null)
|
||||
|
||||
try {
|
||||
const text = await file.text()
|
||||
const data = JSON.parse(text)
|
||||
|
||||
// Ask user about overwrite
|
||||
const overwrite = confirm(
|
||||
'Bestehende Prompts überschreiben?\n\n' +
|
||||
'JA = Existierende Prompts aktualisieren\n' +
|
||||
'NEIN = Nur neue Prompts erstellen, Duplikate überspringen'
|
||||
)
|
||||
|
||||
const result = await api.importPrompts(data, overwrite)
|
||||
setImportResult(result)
|
||||
await loadPrompts()
|
||||
} catch (e) {
|
||||
setError('Import-Fehler: ' + e.message)
|
||||
} finally {
|
||||
setImporting(false)
|
||||
event.target.value = '' // Reset file input
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
padding: 20,
|
||||
maxWidth: 1400,
|
||||
margin: '0 auto',
|
||||
paddingBottom: 80
|
||||
}}>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 24
|
||||
}}>
|
||||
<h1 style={{ margin: 0, fontSize: 24, fontWeight: 600 }}>
|
||||
KI-Prompts ({filteredPrompts.length})
|
||||
</h1>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button
|
||||
className="btn"
|
||||
onClick={handleExportAll}
|
||||
title="Alle Prompts als JSON exportieren (Backup / Dev→Prod Sync)"
|
||||
>
|
||||
📦 Alle exportieren
|
||||
</button>
|
||||
<label className="btn" style={{ margin: 0, cursor: 'pointer' }}>
|
||||
📥 Importieren
|
||||
<input
|
||||
type="file"
|
||||
accept=".json"
|
||||
onChange={handleImport}
|
||||
disabled={importing}
|
||||
style={{ display: 'none' }}
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={() => setShowNewPrompt(true)}
|
||||
>
|
||||
+ Neuer Prompt
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div style={{
|
||||
padding: 16,
|
||||
background: '#fee',
|
||||
color: '#c00',
|
||||
borderRadius: 8,
|
||||
marginBottom: 16
|
||||
}}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{importResult && (
|
||||
<div style={{
|
||||
padding: 16,
|
||||
background: '#efe',
|
||||
color: '#060',
|
||||
borderRadius: 8,
|
||||
marginBottom: 16
|
||||
}}>
|
||||
<div style={{ fontWeight: 600, marginBottom: 4 }}>
|
||||
✅ Import erfolgreich
|
||||
</div>
|
||||
<div style={{ fontSize: 13 }}>
|
||||
{importResult.created} erstellt · {importResult.updated} aktualisiert · {importResult.skipped} übersprungen
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setImportResult(null)}
|
||||
style={{ marginTop: 8, fontSize: 12, padding: '4px 8px' }}
|
||||
className="btn"
|
||||
>
|
||||
OK
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filters */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
gap: 12,
|
||||
marginBottom: 24,
|
||||
flexWrap: 'wrap',
|
||||
alignItems: 'center'
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<Filter size={16} color="var(--text3)" />
|
||||
<span style={{ fontSize: 13, color: 'var(--text3)' }}>Typ:</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 6 }}>
|
||||
<button
|
||||
className={typeFilter === 'all' ? 'btn btn-primary' : 'btn'}
|
||||
onClick={() => setTypeFilter('all')}
|
||||
style={{ fontSize: 13, padding: '6px 12px' }}
|
||||
>
|
||||
Alle ({prompts.length})
|
||||
</button>
|
||||
<button
|
||||
className={typeFilter === 'base' ? 'btn btn-primary' : 'btn'}
|
||||
onClick={() => setTypeFilter('base')}
|
||||
style={{ fontSize: 13, padding: '6px 12px' }}
|
||||
>
|
||||
Basis-Prompts ({prompts.filter(p => p.type === 'base').length})
|
||||
</button>
|
||||
<button
|
||||
className={typeFilter === 'pipeline' ? 'btn btn-primary' : 'btn'}
|
||||
onClick={() => setTypeFilter('pipeline')}
|
||||
style={{ fontSize: 13, padding: '6px 12px' }}
|
||||
>
|
||||
Pipelines ({prompts.filter(p => p.type === 'pipeline' || !p.type).length})
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
width: 1,
|
||||
height: 24,
|
||||
background: 'var(--border)',
|
||||
margin: '0 8px'
|
||||
}} />
|
||||
|
||||
<select
|
||||
className="form-select"
|
||||
value={category}
|
||||
onChange={e => setCategory(e.target.value)}
|
||||
style={{ fontSize: 13, padding: '6px 12px' }}
|
||||
>
|
||||
{categories.map(cat => (
|
||||
<option key={cat.id} value={cat.id}>
|
||||
{cat.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Prompts Table */}
|
||||
{loading ? (
|
||||
<div style={{ textAlign: 'center', padding: 40, color: 'var(--text3)' }}>
|
||||
Lädt...
|
||||
</div>
|
||||
) : (
|
||||
<div style={{
|
||||
background: 'var(--surface)',
|
||||
borderRadius: 12,
|
||||
border: '1px solid var(--border)',
|
||||
overflowX: 'auto'
|
||||
}}>
|
||||
<table style={{ width: '100%', minWidth: 900, borderCollapse: 'collapse' }}>
|
||||
<thead>
|
||||
<tr style={{
|
||||
background: 'var(--surface2)',
|
||||
borderBottom: '1px solid var(--border)'
|
||||
}}>
|
||||
<th style={{
|
||||
padding: 12,
|
||||
textAlign: 'left',
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
color: 'var(--text3)',
|
||||
width: 80
|
||||
}}>
|
||||
Typ
|
||||
</th>
|
||||
<th style={{
|
||||
padding: 12,
|
||||
textAlign: 'left',
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
color: 'var(--text3)'
|
||||
}}>
|
||||
Name
|
||||
</th>
|
||||
<th style={{
|
||||
padding: 12,
|
||||
textAlign: 'left',
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
color: 'var(--text3)',
|
||||
width: 120
|
||||
}}>
|
||||
Kategorie
|
||||
</th>
|
||||
<th style={{
|
||||
padding: 12,
|
||||
textAlign: 'center',
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
color: 'var(--text3)',
|
||||
width: 100
|
||||
}}>
|
||||
Stages
|
||||
</th>
|
||||
<th style={{
|
||||
padding: 12,
|
||||
textAlign: 'center',
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
color: 'var(--text3)',
|
||||
width: 80
|
||||
}}>
|
||||
Status
|
||||
</th>
|
||||
<th style={{
|
||||
padding: 12,
|
||||
textAlign: 'right',
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
color: 'var(--text3)',
|
||||
width: 120
|
||||
}}>
|
||||
Aktionen
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredPrompts.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan="6" style={{
|
||||
padding: 40,
|
||||
textAlign: 'center',
|
||||
color: 'var(--text3)'
|
||||
}}>
|
||||
Keine Prompts gefunden
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
filteredPrompts.map(prompt => (
|
||||
<tr
|
||||
key={prompt.id}
|
||||
style={{
|
||||
borderBottom: '1px solid var(--border)',
|
||||
opacity: prompt.active ? 1 : 0.5
|
||||
}}
|
||||
>
|
||||
<td style={{ padding: 12 }}>
|
||||
<div style={{
|
||||
display: 'inline-block',
|
||||
padding: '2px 8px',
|
||||
background: getTypeColor(prompt.type) + '20',
|
||||
color: getTypeColor(prompt.type),
|
||||
borderRadius: 6,
|
||||
fontSize: 11,
|
||||
fontWeight: 600
|
||||
}}>
|
||||
{getTypeLabel(prompt.type)}
|
||||
</div>
|
||||
</td>
|
||||
<td style={{ padding: 12 }}>
|
||||
<div>
|
||||
<div style={{ fontSize: 14, fontWeight: 500 }}>
|
||||
{prompt.display_name || prompt.name}
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--text3)', marginTop: 2 }}>
|
||||
{prompt.slug}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td style={{ padding: 12, fontSize: 13 }}>
|
||||
{prompt.category || 'ganzheitlich'}
|
||||
</td>
|
||||
<td style={{ padding: 12, textAlign: 'center', fontSize: 13 }}>
|
||||
{prompt.type === 'pipeline' ? (
|
||||
<span style={{
|
||||
background: 'var(--surface2)',
|
||||
padding: '2px 6px',
|
||||
borderRadius: 4,
|
||||
fontSize: 11
|
||||
}}>
|
||||
{getStageCount(prompt)} Stages
|
||||
</span>
|
||||
) : (
|
||||
<span style={{ color: 'var(--text3)', fontSize: 11 }}>—</span>
|
||||
)}
|
||||
</td>
|
||||
<td style={{ padding: 12, textAlign: 'center' }}>
|
||||
<label style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
cursor: 'pointer'
|
||||
}}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={prompt.active}
|
||||
onChange={() => handleToggleActive(prompt)}
|
||||
style={{ margin: 0 }}
|
||||
/>
|
||||
</label>
|
||||
</td>
|
||||
<td style={{ padding: 12 }}>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
gap: 6,
|
||||
justifyContent: 'flex-end'
|
||||
}}>
|
||||
<button
|
||||
onClick={() => setEditingPrompt(prompt)}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
padding: 4
|
||||
}}
|
||||
title="Bearbeiten"
|
||||
>
|
||||
<Edit size={16} color="var(--accent)" />
|
||||
</button>
|
||||
{/* Show convert button for 1-stage pipelines */}
|
||||
{prompt.type === 'pipeline' && getStageCount(prompt) === 1 && (
|
||||
<button
|
||||
onClick={() => handleConvertToBase(prompt)}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
padding: 4
|
||||
}}
|
||||
title="Zu Basis-Prompt konvertieren"
|
||||
>
|
||||
<ArrowDownToLine size={16} color="#6366f1" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => handleDuplicate(prompt)}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
padding: 4
|
||||
}}
|
||||
title="Duplizieren"
|
||||
>
|
||||
<Copy size={16} color="var(--text3)" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(prompt)}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
padding: 4
|
||||
}}
|
||||
title="Löschen"
|
||||
>
|
||||
<Trash2 size={16} color="var(--danger)" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Unified Prompt Modal */}
|
||||
{(editingPrompt || showNewPrompt) && (
|
||||
<UnifiedPromptModal
|
||||
prompt={editingPrompt}
|
||||
onSave={handleSave}
|
||||
onClose={() => {
|
||||
setEditingPrompt(null)
|
||||
setShowNewPrompt(false)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import { useState, useEffect } from 'react'
|
||||
import { Brain, Pencil, Trash2, ChevronDown, ChevronUp, Check, X } from 'lucide-react'
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { Brain, Trash2, ChevronDown, ChevronUp } from 'lucide-react'
|
||||
import { api } from '../utils/api'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import Markdown from '../utils/Markdown'
|
||||
|
|
@ -8,30 +8,83 @@ import dayjs from 'dayjs'
|
|||
import 'dayjs/locale/de'
|
||||
dayjs.locale('de')
|
||||
|
||||
// Legacy fallback labels (display_name takes precedence)
|
||||
const SLUG_LABELS = {
|
||||
gesamt: '🔍 Gesamtanalyse',
|
||||
koerper: '🫧 Körperkomposition',
|
||||
ernaehrung: '🍽️ Ernährung',
|
||||
aktivitaet: '🏋️ Aktivität',
|
||||
gesundheit: '❤️ Gesundheitsindikatoren',
|
||||
ziele: '🎯 Zielfortschritt',
|
||||
pipeline: '🔬 Mehrstufige Gesamtanalyse',
|
||||
pipeline_body: '🔬 Pipeline: Körper-Analyse (JSON)',
|
||||
pipeline_nutrition: '🔬 Pipeline: Ernährungs-Analyse (JSON)',
|
||||
pipeline_activity: '🔬 Pipeline: Aktivitäts-Analyse (JSON)',
|
||||
pipeline_synthesis: '🔬 Pipeline: Synthese',
|
||||
pipeline_goals: '🔬 Pipeline: Zielabgleich',
|
||||
pipeline: '🔬 Mehrstufige Gesamtanalyse'
|
||||
}
|
||||
|
||||
function InsightCard({ ins, onDelete, defaultOpen=false }) {
|
||||
function InsightCard({ ins, onDelete, defaultOpen=false, prompts=[] }) {
|
||||
const [open, setOpen] = useState(defaultOpen)
|
||||
|
||||
// Parse metadata early to determine showOnlyValues
|
||||
const metadataRaw = ins.metadata ? (typeof ins.metadata === 'string' ? JSON.parse(ins.metadata) : ins.metadata) : null
|
||||
const isBasePrompt = metadataRaw?.prompt_type === 'base'
|
||||
const isJsonOutput = ins.content && (ins.content.trim().startsWith('{') || ins.content.trim().startsWith('['))
|
||||
const placeholdersRaw = metadataRaw?.placeholders || {}
|
||||
const showOnlyValues = isBasePrompt && isJsonOutput && Object.keys(placeholdersRaw).length > 0
|
||||
|
||||
const [showValues, setShowValues] = useState(showOnlyValues) // Auto-expand for base prompts with JSON
|
||||
const [expertMode, setExpertMode] = useState(false) // Show empty/technical placeholders
|
||||
|
||||
// Find matching prompt to get display_name
|
||||
const prompt = prompts.find(p => p.slug === ins.scope)
|
||||
const displayName = prompt?.display_name || SLUG_LABELS[ins.scope] || ins.scope
|
||||
|
||||
// Use already-parsed metadata
|
||||
const metadata = metadataRaw
|
||||
const allPlaceholders = placeholdersRaw
|
||||
|
||||
// Filter placeholders: In normal mode, hide empty values and raw stage outputs
|
||||
const placeholders = expertMode
|
||||
? allPlaceholders
|
||||
: Object.fromEntries(
|
||||
Object.entries(allPlaceholders).filter(([key, data]) => {
|
||||
// Hide raw stage outputs (JSON) in normal mode
|
||||
if (data.is_stage_raw) return false
|
||||
|
||||
// Hide empty values
|
||||
const val = data.value || ''
|
||||
return val.trim() !== '' && val !== 'nicht verfügbar' && val !== '[Nicht verfügbar]'
|
||||
})
|
||||
)
|
||||
|
||||
const placeholderCount = Object.keys(placeholders).length
|
||||
const hiddenCount = Object.keys(allPlaceholders).length - placeholderCount
|
||||
|
||||
// Group placeholders by category
|
||||
const groupedPlaceholders = Object.entries(placeholders).reduce((acc, [key, data]) => {
|
||||
const category = data.category || 'Sonstiges'
|
||||
if (!acc[category]) acc[category] = []
|
||||
acc[category].push([key, data])
|
||||
return acc
|
||||
}, {})
|
||||
|
||||
// Sort categories: Regular categories first, then Stage outputs, then Rohdaten
|
||||
const sortedCategories = Object.keys(groupedPlaceholders).sort((a, b) => {
|
||||
const aIsStage = a.startsWith('Stage')
|
||||
const bIsStage = b.startsWith('Stage')
|
||||
const aIsRohdaten = a.includes('Rohdaten')
|
||||
const bIsRohdaten = b.includes('Rohdaten')
|
||||
|
||||
// Rohdaten last
|
||||
if (aIsRohdaten && !bIsRohdaten) return 1
|
||||
if (!aIsRohdaten && bIsRohdaten) return -1
|
||||
|
||||
// Stage outputs after regular categories
|
||||
if (!aIsStage && bIsStage) return -1
|
||||
if (aIsStage && !bIsStage) return 1
|
||||
|
||||
// Otherwise alphabetical
|
||||
return a.localeCompare(b)
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="card section-gap" style={{borderLeft:`3px solid var(--accent)`}}>
|
||||
<div style={{display:'flex',alignItems:'center',gap:8,marginBottom:open?12:0,cursor:'pointer'}}
|
||||
onClick={()=>setOpen(o=>!o)}>
|
||||
<div style={{flex:1}}>
|
||||
<div style={{fontSize:13,fontWeight:600}}>
|
||||
{SLUG_LABELS[ins.scope] || ins.scope}
|
||||
{displayName}
|
||||
</div>
|
||||
<div style={{fontSize:11,color:'var(--text3)'}}>
|
||||
{dayjs(ins.created).format('DD. MMMM YYYY, HH:mm')}
|
||||
|
|
@ -43,83 +96,194 @@ function InsightCard({ ins, onDelete, defaultOpen=false }) {
|
|||
</button>
|
||||
{open ? <ChevronUp size={16} color="var(--text3)"/> : <ChevronDown size={16} color="var(--text3)"/>}
|
||||
</div>
|
||||
{open && <Markdown text={ins.content}/>}
|
||||
{open && (
|
||||
<>
|
||||
{/* For base prompts with JSON: Only show value table */}
|
||||
{showOnlyValues && (
|
||||
<div style={{ padding: '12px 16px', background: 'var(--surface)', borderRadius: 8, marginBottom: 12 }}>
|
||||
<div style={{ fontSize: 11, color: 'var(--text3)', marginBottom: 8 }}>
|
||||
ℹ️ Basis-Prompt Rohdaten (JSON-Struktur für technische Nutzung)
|
||||
</div>
|
||||
<details style={{ fontSize: 11, color: 'var(--text3)' }}>
|
||||
<summary style={{ cursor: 'pointer' }}>Technische Daten anzeigen</summary>
|
||||
<pre style={{
|
||||
marginTop: 8,
|
||||
padding: 8,
|
||||
background: 'var(--bg)',
|
||||
borderRadius: 4,
|
||||
overflow: 'auto',
|
||||
fontSize: 10,
|
||||
fontFamily: 'monospace'
|
||||
}}>
|
||||
{ins.content}
|
||||
</pre>
|
||||
</details>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* For other prompts: Show full content */}
|
||||
{!showOnlyValues && <Markdown text={ins.content}/>}
|
||||
|
||||
{/* Value Table */}
|
||||
{placeholderCount > 0 && (
|
||||
<div style={{ marginTop: 16, borderTop: '1px solid var(--border)', paddingTop: 12 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<div
|
||||
onClick={() => setShowValues(!showValues)}
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
fontSize: 12,
|
||||
color: 'var(--text2)',
|
||||
fontWeight: 600,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 6
|
||||
}}
|
||||
>
|
||||
{showValues ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
|
||||
📊 Verwendete Werte ({placeholderCount})
|
||||
{hiddenCount > 0 && !expertMode && (
|
||||
<span style={{ fontSize: 10, color: 'var(--text3)', fontWeight: 400 }}>
|
||||
(+{hiddenCount} ausgeblendet)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showValues && Object.keys(allPlaceholders).length > 0 && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setExpertMode(!expertMode)
|
||||
}}
|
||||
className="btn"
|
||||
style={{
|
||||
fontSize: 10,
|
||||
padding: '4px 8px',
|
||||
background: expertMode ? 'var(--accent)' : 'var(--surface)',
|
||||
color: expertMode ? 'white' : 'var(--text2)'
|
||||
}}
|
||||
>
|
||||
🔬 Experten-Modus
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showValues && (
|
||||
<div style={{ marginTop: 12, fontSize: 11 }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<thead>
|
||||
<tr style={{ borderBottom: '1px solid var(--border)', textAlign: 'left' }}>
|
||||
<th style={{ padding: '6px 8px', color: 'var(--text3)', fontWeight: 600 }}>Platzhalter</th>
|
||||
<th style={{ padding: '6px 8px', color: 'var(--text3)', fontWeight: 600 }}>Wert</th>
|
||||
<th style={{ padding: '6px 8px', color: 'var(--text3)', fontWeight: 600 }}>Beschreibung</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sortedCategories.map(category => (
|
||||
<React.Fragment key={category}>
|
||||
{/* Category Header */}
|
||||
<tr style={{ background: 'var(--surface2)', borderTop: '2px solid var(--border)' }}>
|
||||
<td colSpan="3" style={{
|
||||
padding: '8px',
|
||||
fontWeight: 600,
|
||||
fontSize: 11,
|
||||
color: 'var(--text2)',
|
||||
letterSpacing: '0.5px'
|
||||
}}>
|
||||
{category}
|
||||
</td>
|
||||
</tr>
|
||||
{/* Category Values */}
|
||||
{groupedPlaceholders[category].map(([key, data]) => {
|
||||
const isExtracted = data.is_extracted
|
||||
const isStageRaw = data.is_stage_raw
|
||||
|
||||
return (
|
||||
<tr key={key} style={{
|
||||
borderBottom: '1px solid var(--border)',
|
||||
background: isStageRaw && expertMode ? 'var(--surface)' : 'transparent'
|
||||
}}>
|
||||
<td style={{
|
||||
padding: '6px 8px',
|
||||
fontFamily: 'monospace',
|
||||
color: isStageRaw ? 'var(--text3)' : (isExtracted ? '#6B8E23' : 'var(--accent)'),
|
||||
whiteSpace: 'nowrap',
|
||||
verticalAlign: 'top',
|
||||
fontSize: isStageRaw ? 10 : 11
|
||||
}}>
|
||||
{isExtracted && '↳ '}
|
||||
{isStageRaw && '🔬 '}
|
||||
{key}
|
||||
</td>
|
||||
<td style={{
|
||||
padding: '6px 8px',
|
||||
fontFamily: 'monospace',
|
||||
wordBreak: 'break-word',
|
||||
maxWidth: '400px',
|
||||
verticalAlign: 'top',
|
||||
fontSize: isStageRaw ? 9 : 11,
|
||||
color: isStageRaw ? 'var(--text3)' : 'var(--text1)'
|
||||
}}>
|
||||
{isStageRaw ? (
|
||||
<details style={{ cursor: 'pointer' }}>
|
||||
<summary style={{
|
||||
fontWeight: 600,
|
||||
color: 'var(--accent)',
|
||||
fontSize: 10,
|
||||
marginBottom: '4px'
|
||||
}}>
|
||||
JSON anzeigen ▼
|
||||
</summary>
|
||||
<pre style={{
|
||||
background: 'var(--surface2)',
|
||||
padding: '8px',
|
||||
borderRadius: '4px',
|
||||
fontSize: 9,
|
||||
overflow: 'auto',
|
||||
maxHeight: '300px',
|
||||
margin: 0
|
||||
}}>
|
||||
{data.value}
|
||||
</pre>
|
||||
</details>
|
||||
) : data.value}
|
||||
</td>
|
||||
<td style={{
|
||||
padding: '6px 8px',
|
||||
color: 'var(--text3)',
|
||||
fontSize: 10,
|
||||
verticalAlign: 'top',
|
||||
fontStyle: isExtracted ? 'italic' : 'normal'
|
||||
}}>
|
||||
{data.description || '—'}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PromptEditor({ prompt, onSave, onCancel }) {
|
||||
const [template, setTemplate] = useState(prompt.template)
|
||||
const [name, setName] = useState(prompt.name)
|
||||
const [desc, setDesc] = useState(prompt.description||'')
|
||||
|
||||
const VARS = ['{{name}}','{{geschlecht}}','{{height}}','{{goal_weight}}','{{goal_bf_pct}}',
|
||||
'{{weight_trend}}','{{weight_aktuell}}','{{kf_aktuell}}','{{caliper_summary}}',
|
||||
'{{circ_summary}}','{{nutrition_summary}}','{{nutrition_detail}}',
|
||||
'{{protein_ziel_low}}','{{protein_ziel_high}}','{{activity_summary}}',
|
||||
'{{activity_kcal_summary}}','{{activity_detail}}',
|
||||
'{{sleep_summary}}','{{sleep_detail}}','{{sleep_avg_duration}}','{{sleep_avg_quality}}',
|
||||
'{{rest_days_summary}}','{{rest_days_count}}','{{rest_days_types}}',
|
||||
'{{vitals_summary}}','{{vitals_detail}}','{{vitals_avg_hr}}','{{vitals_avg_hrv}}',
|
||||
'{{vitals_avg_bp}}','{{vitals_vo2_max}}','{{bp_summary}}']
|
||||
|
||||
return (
|
||||
<div className="card section-gap">
|
||||
<div style={{display:'flex',alignItems:'center',justifyContent:'space-between',marginBottom:12}}>
|
||||
<div className="card-title" style={{margin:0}}>Prompt bearbeiten</div>
|
||||
<button style={{background:'none',border:'none',cursor:'pointer',color:'var(--text3)'}}
|
||||
onClick={onCancel}><X size={16}/></button>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label className="form-label">Name</label>
|
||||
<input type="text" className="form-input" value={name} onChange={e=>setName(e.target.value)}/>
|
||||
<span className="form-unit"/>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label className="form-label">Beschreibung</label>
|
||||
<input type="text" className="form-input" value={desc} onChange={e=>setDesc(e.target.value)}/>
|
||||
<span className="form-unit"/>
|
||||
</div>
|
||||
<div style={{marginBottom:8}}>
|
||||
<div style={{fontSize:12,fontWeight:600,color:'var(--text3)',marginBottom:6}}>
|
||||
Variablen (antippen zum Einfügen):
|
||||
</div>
|
||||
<div style={{display:'flex',flexWrap:'wrap',gap:4}}>
|
||||
{VARS.map(v=>(
|
||||
<button key={v} onClick={()=>setTemplate(t=>t+v)}
|
||||
style={{fontSize:10,padding:'2px 7px',borderRadius:4,border:'1px solid var(--border2)',
|
||||
background:'var(--surface2)',cursor:'pointer',fontFamily:'monospace',color:'var(--accent)'}}>
|
||||
{v}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<textarea value={template} onChange={e=>setTemplate(e.target.value)}
|
||||
style={{width:'100%',minHeight:280,padding:10,fontFamily:'monospace',fontSize:12,
|
||||
background:'var(--surface2)',border:'1.5px solid var(--border2)',borderRadius:8,
|
||||
color:'var(--text1)',resize:'vertical',lineHeight:1.5,boxSizing:'border-box'}}/>
|
||||
<div style={{display:'flex',gap:8,marginTop:8}}>
|
||||
<button className="btn btn-primary" style={{flex:1}}
|
||||
onClick={()=>onSave({name,description:desc,template})}>
|
||||
<Check size={14}/> Speichern
|
||||
</button>
|
||||
<button className="btn btn-secondary" style={{flex:1}} onClick={onCancel}>Abbrechen</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Analysis() {
|
||||
const { canUseAI, isAdmin } = useAuth()
|
||||
const { canUseAI } = useAuth()
|
||||
const [prompts, setPrompts] = useState([])
|
||||
const [allInsights, setAllInsights] = useState([])
|
||||
const [loading, setLoading] = useState(null)
|
||||
const [error, setError] = useState(null)
|
||||
const [editing, setEditing] = useState(null)
|
||||
const [tab, setTab] = useState('run')
|
||||
const [newResult, setNewResult] = useState(null)
|
||||
const [pipelineLoading, setPipelineLoading] = useState(false)
|
||||
const [aiUsage, setAiUsage] = useState(null) // Phase 3: Usage badge
|
||||
const [newResult, setNewResult] = useState(null)
|
||||
const [aiUsage, setAiUsage] = useState(null)
|
||||
|
||||
const loadAll = async () => {
|
||||
const [p, i] = await Promise.all([
|
||||
|
|
@ -139,48 +303,74 @@ export default function Analysis() {
|
|||
}).catch(err => console.error('Failed to load usage:', err))
|
||||
},[])
|
||||
|
||||
const runPipeline = async () => {
|
||||
setPipelineLoading(true); setError(null); setNewResult(null)
|
||||
try {
|
||||
const result = await api.insightPipeline()
|
||||
setNewResult(result)
|
||||
await loadAll()
|
||||
setTab('run')
|
||||
} catch(e) {
|
||||
setError('Pipeline-Fehler: ' + e.message)
|
||||
} finally { setPipelineLoading(false) }
|
||||
}
|
||||
|
||||
const runPrompt = async (slug) => {
|
||||
setLoading(slug); setError(null); setNewResult(null)
|
||||
try {
|
||||
const result = await api.runInsight(slug)
|
||||
setNewResult(result) // show immediately
|
||||
await loadAll() // refresh lists
|
||||
setTab('run') // stay on run tab to see result
|
||||
// Use new unified executor with save=true
|
||||
const result = await api.executeUnifiedPrompt(slug, null, null, false, true)
|
||||
|
||||
// Transform result to match old format for InsightCard
|
||||
let content = ''
|
||||
if (result.type === 'pipeline') {
|
||||
// For pipeline, extract final output
|
||||
const finalOutput = result.output || {}
|
||||
if (typeof finalOutput === 'object' && Object.keys(finalOutput).length === 1) {
|
||||
content = Object.values(finalOutput)[0]
|
||||
} else {
|
||||
content = JSON.stringify(finalOutput, null, 2)
|
||||
}
|
||||
} else {
|
||||
// For base prompts, use output directly
|
||||
content = typeof result.output === 'string' ? result.output : JSON.stringify(result.output, null, 2)
|
||||
}
|
||||
|
||||
// Build metadata from debug info (same logic as backend)
|
||||
let metadata = null
|
||||
if (result.debug && result.debug.resolved_placeholders) {
|
||||
const placeholders = {}
|
||||
const resolved = result.debug.resolved_placeholders
|
||||
|
||||
// For pipeline, collect from all stages
|
||||
if (result.type === 'pipeline' && result.debug.stages) {
|
||||
for (const stage of result.debug.stages) {
|
||||
for (const promptDebug of (stage.prompts || [])) {
|
||||
const stageResolved = promptDebug.resolved_placeholders || promptDebug.ref_debug?.resolved_placeholders || {}
|
||||
for (const [key, value] of Object.entries(stageResolved)) {
|
||||
if (!placeholders[key]) {
|
||||
placeholders[key] = { value, description: '' }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// For base prompts
|
||||
for (const [key, value] of Object.entries(resolved)) {
|
||||
placeholders[key] = { value, description: '' }
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(placeholders).length > 0) {
|
||||
metadata = { prompt_type: result.type, placeholders }
|
||||
}
|
||||
}
|
||||
|
||||
setNewResult({ scope: slug, content, metadata })
|
||||
await loadAll()
|
||||
setTab('run')
|
||||
} catch(e) {
|
||||
setError('Fehler: ' + e.message)
|
||||
} finally { setLoading(null) }
|
||||
}
|
||||
|
||||
const savePrompt = async (promptId, data) => {
|
||||
const token = localStorage.getItem('bodytrack_token')||''
|
||||
await fetch(`/api/prompts/${promptId}`, {
|
||||
method:'PUT',
|
||||
headers:{'Content-Type':'application/json', 'X-Auth-Token': token},
|
||||
body:JSON.stringify(data)
|
||||
})
|
||||
setEditing(null); await loadAll()
|
||||
}
|
||||
|
||||
const deleteInsight = async (id) => {
|
||||
if (!confirm('Analyse löschen?')) return
|
||||
const pid = localStorage.getItem('bodytrack_active_profile')||''
|
||||
await fetch(`/api/insights/${id}`, {
|
||||
method:'DELETE', headers: pid ? {'X-Profile-Id':pid} : {}
|
||||
})
|
||||
if (newResult?.id === id) setNewResult(null)
|
||||
await loadAll()
|
||||
try {
|
||||
await api.deleteInsight(id)
|
||||
if (newResult?.id === id) setNewResult(null)
|
||||
await loadAll()
|
||||
} catch (e) {
|
||||
setError('Löschen fehlgeschlagen: ' + e.message)
|
||||
}
|
||||
}
|
||||
|
||||
// Group insights by scope for history view
|
||||
|
|
@ -191,11 +381,8 @@ export default function Analysis() {
|
|||
grouped[key].push(ins)
|
||||
})
|
||||
|
||||
const activePrompts = prompts.filter(p=>p.active && !p.slug.startsWith('pipeline_') && p.slug !== 'pipeline')
|
||||
|
||||
// Pipeline is available if the "pipeline" prompt is active
|
||||
const pipelinePrompt = prompts.find(p=>p.slug==='pipeline')
|
||||
const pipelineAvailable = pipelinePrompt?.active ?? true // Default to true if not found (backwards compatibility)
|
||||
// Show only active pipeline-type prompts
|
||||
const pipelinePrompts = prompts.filter(p => p.active && p.type === 'pipeline')
|
||||
|
||||
return (
|
||||
<div>
|
||||
|
|
@ -208,7 +395,6 @@ export default function Analysis() {
|
|||
{allInsights.length>0 && <span style={{marginLeft:4,fontSize:10,background:'var(--accent)',
|
||||
color:'white',padding:'1px 5px',borderRadius:8}}>{allInsights.length}</span>}
|
||||
</button>
|
||||
{isAdmin && <button className={'tab'+(tab==='prompts'?' active':'')} onClick={()=>setTab('prompts')}>Prompts</button>}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
|
|
@ -235,56 +421,11 @@ export default function Analysis() {
|
|||
ins={{...newResult, created: new Date().toISOString()}}
|
||||
onDelete={deleteInsight}
|
||||
defaultOpen={true}
|
||||
prompts={prompts}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pipeline button - only if all sub-prompts are active */}
|
||||
{pipelineAvailable && (
|
||||
<div className="card" style={{marginBottom:16,borderColor:'var(--accent)',borderWidth:2}}>
|
||||
<div style={{display:'flex',alignItems:'flex-start',gap:12}}>
|
||||
<div style={{flex:1}}>
|
||||
<div className="badge-container-right" style={{fontWeight:700,fontSize:15,color:'var(--accent)'}}>
|
||||
<span>🔬 Mehrstufige Gesamtanalyse</span>
|
||||
{aiUsage && <UsageBadge {...aiUsage} />}
|
||||
</div>
|
||||
<div style={{fontSize:12,color:'var(--text2)',marginTop:3,lineHeight:1.5}}>
|
||||
3 spezialisierte KI-Calls parallel (Körper + Ernährung + Aktivität),
|
||||
dann Synthese + Zielabgleich. Detaillierteste Auswertung.
|
||||
</div>
|
||||
{allInsights.find(i=>i.scope==='pipeline') && (
|
||||
<div style={{fontSize:11,color:'var(--text3)',marginTop:3}}>
|
||||
Letzte Analyse: {dayjs(allInsights.find(i=>i.scope==='pipeline').created).format('DD.MM.YYYY, HH:mm')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
title={aiUsage && !aiUsage.allowed ? `Limit erreicht (${aiUsage.used}/${aiUsage.limit}). Kontaktiere den Admin oder warte bis zum nächsten Reset.` : ''}
|
||||
style={{display:'inline-block'}}
|
||||
>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
style={{flexShrink:0,minWidth:100, cursor: (aiUsage && !aiUsage.allowed) ? 'not-allowed' : 'pointer'}}
|
||||
onClick={runPipeline}
|
||||
disabled={!!loading||pipelineLoading||(aiUsage && !aiUsage.allowed)}
|
||||
>
|
||||
{pipelineLoading
|
||||
? <><div className="spinner" style={{width:13,height:13}}/> Läuft…</>
|
||||
: (aiUsage && !aiUsage.allowed) ? '🔒 Limit'
|
||||
: <><Brain size={13}/> Starten</>}
|
||||
</button>
|
||||
</div>
|
||||
{!canUseAI && <div style={{fontSize:11,color:'#D85A30',marginTop:4}}>🔒 KI nicht freigeschaltet</div>}
|
||||
</div>
|
||||
{pipelineLoading && (
|
||||
<div style={{marginTop:10,padding:'8px 12px',background:'var(--accent-light)',
|
||||
borderRadius:8,fontSize:12,color:'var(--accent-dark)'}}>
|
||||
⚡ Stufe 1: 3 parallele Analyse-Calls… dann Synthese… dann Zielabgleich
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!canUseAI && (
|
||||
<div style={{padding:'14px 16px',background:'#FCEBEB',borderRadius:10,
|
||||
border:'1px solid #D85A3033',marginBottom:16}}>
|
||||
|
|
@ -298,25 +439,31 @@ export default function Analysis() {
|
|||
</div>
|
||||
</div>
|
||||
)}
|
||||
{canUseAI && <p style={{fontSize:13,color:'var(--text2)',marginBottom:14,lineHeight:1.6}}>
|
||||
Oder wähle eine Einzelanalyse:
|
||||
</p>}
|
||||
|
||||
{activePrompts.map(p => {
|
||||
// Show latest existing insight for this prompt
|
||||
{canUseAI && pipelinePrompts.length > 0 && (
|
||||
<p style={{fontSize:13,color:'var(--text2)',marginBottom:14,lineHeight:1.6}}>
|
||||
Wähle eine mehrstufige KI-Analyse:
|
||||
</p>
|
||||
)}
|
||||
|
||||
{pipelinePrompts.map(p => {
|
||||
const existing = allInsights.find(i=>i.scope===p.slug)
|
||||
return (
|
||||
<div key={p.id} className="card section-gap">
|
||||
<div key={p.id} className="card" style={{marginBottom:16,borderColor:'var(--accent)',borderWidth:2}}>
|
||||
<div style={{display:'flex',alignItems:'flex-start',gap:12}}>
|
||||
<div style={{flex:1}}>
|
||||
<div className="badge-container-right" style={{fontWeight:600,fontSize:15}}>
|
||||
<span>{SLUG_LABELS[p.slug]||p.name}</span>
|
||||
<div className="badge-container-right" style={{fontWeight:700,fontSize:15,color:'var(--accent)'}}>
|
||||
<span>{p.display_name || SLUG_LABELS[p.slug] || p.name}</span>
|
||||
{aiUsage && <UsageBadge {...aiUsage} />}
|
||||
</div>
|
||||
{p.description && <div style={{fontSize:12,color:'var(--text3)',marginTop:2}}>{p.description}</div>}
|
||||
{p.description && (
|
||||
<div style={{fontSize:12,color:'var(--text2)',marginTop:3,lineHeight:1.5}}>
|
||||
{p.description}
|
||||
</div>
|
||||
)}
|
||||
{existing && (
|
||||
<div style={{fontSize:11,color:'var(--text3)',marginTop:3}}>
|
||||
Letzte Auswertung: {dayjs(existing.created).format('DD.MM.YYYY, HH:mm')}
|
||||
Letzte Analyse: {dayjs(existing.created).format('DD.MM.YYYY, HH:mm')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -326,28 +473,34 @@ export default function Analysis() {
|
|||
>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
style={{flexShrink:0,minWidth:90, cursor: (aiUsage && !aiUsage.allowed) ? 'not-allowed' : 'pointer'}}
|
||||
style={{flexShrink:0,minWidth:100, cursor: (aiUsage && !aiUsage.allowed) ? 'not-allowed' : 'pointer'}}
|
||||
onClick={()=>runPrompt(p.slug)}
|
||||
disabled={!!loading||!canUseAI||(aiUsage && !aiUsage.allowed)}
|
||||
>
|
||||
{loading===p.slug
|
||||
? <><div className="spinner" style={{width:13,height:13}}/> Läuft…</>
|
||||
: (aiUsage && !aiUsage.allowed) ? '🔒 Limit'
|
||||
: <><Brain size={13}/> {existing?'Neu erstellen':'Starten'}</>}
|
||||
: <><Brain size={13}/> Starten</>}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/* Show existing result collapsed */}
|
||||
{existing && newResult?.id !== existing.id && (
|
||||
<div style={{marginTop:8,borderTop:'1px solid var(--border)',paddingTop:8}}>
|
||||
<InsightCard ins={existing} onDelete={deleteInsight} defaultOpen={false}/>
|
||||
<InsightCard ins={existing} onDelete={deleteInsight} defaultOpen={false} prompts={prompts}/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{activePrompts.length===0 && (
|
||||
<div className="empty-state"><p>Keine aktiven Prompts. Aktiviere im Tab "Prompts".</p></div>
|
||||
|
||||
{canUseAI && pipelinePrompts.length === 0 && (
|
||||
<div className="empty-state">
|
||||
<p>Keine aktiven Pipeline-Prompts verfügbar.</p>
|
||||
<p style={{fontSize:12,color:'var(--text3)',marginTop:8}}>
|
||||
Erstelle Pipeline-Prompts im Admin-Bereich (Einstellungen → Admin → KI-Prompts).
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -361,143 +514,14 @@ export default function Analysis() {
|
|||
<div key={scope} style={{marginBottom:20}}>
|
||||
<div style={{fontSize:13,fontWeight:700,color:'var(--text3)',
|
||||
textTransform:'uppercase',letterSpacing:'0.05em',marginBottom:8}}>
|
||||
{SLUG_LABELS[scope]||scope} ({ins.length})
|
||||
{prompts.find(p => p.slug === scope)?.display_name || SLUG_LABELS[scope] || scope} ({ins.length})
|
||||
</div>
|
||||
{ins.map(i => <InsightCard key={i.id} ins={i} onDelete={deleteInsight}/>)}
|
||||
{ins.map(i => <InsightCard key={i.id} ins={i} onDelete={deleteInsight} prompts={prompts}/>)}
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Prompts ── */}
|
||||
{tab==='prompts' && (
|
||||
<div>
|
||||
<p style={{fontSize:13,color:'var(--text2)',marginBottom:14,lineHeight:1.6}}>
|
||||
Passe Prompts an. Variablen wie{' '}
|
||||
<code style={{fontSize:11,background:'var(--surface2)',padding:'1px 4px',borderRadius:3}}>{'{{name}}'}</code>{' '}
|
||||
werden automatisch mit deinen Daten befüllt.
|
||||
</p>
|
||||
{editing ? (
|
||||
<PromptEditor prompt={editing}
|
||||
onSave={(data)=>savePrompt(editing.id,data)}
|
||||
onCancel={()=>setEditing(null)}/>
|
||||
) : (() => {
|
||||
const singlePrompts = prompts.filter(p=>!p.slug.startsWith('pipeline_'))
|
||||
const pipelinePrompts = prompts.filter(p=>p.slug.startsWith('pipeline_'))
|
||||
const jsonSlugs = ['pipeline_body','pipeline_nutrition','pipeline_activity']
|
||||
return (
|
||||
<>
|
||||
{/* Single prompts */}
|
||||
<div style={{fontSize:12,fontWeight:700,color:'var(--text3)',
|
||||
textTransform:'uppercase',letterSpacing:'0.05em',marginBottom:8}}>
|
||||
Einzelanalysen
|
||||
</div>
|
||||
{singlePrompts.map(p=>(
|
||||
<div key={p.id} className="card section-gap" style={{opacity:p.active?1:0.6}}>
|
||||
<div style={{display:'flex',alignItems:'center',gap:10}}>
|
||||
<div style={{flex:1}}>
|
||||
<div style={{fontWeight:600,fontSize:14,display:'flex',alignItems:'center',gap:8}}>
|
||||
{SLUG_LABELS[p.slug]||p.name}
|
||||
{!p.active && <span style={{fontSize:10,color:'#D85A30',
|
||||
background:'#FCEBEB',padding:'2px 8px',borderRadius:4,fontWeight:600}}>⏸ Deaktiviert</span>}
|
||||
</div>
|
||||
{p.description && <div style={{fontSize:12,color:'var(--text3)',marginTop:1}}>{p.description}</div>}
|
||||
</div>
|
||||
<button className="btn btn-secondary" style={{padding:'5px 8px',fontSize:12}}
|
||||
onClick={()=>{
|
||||
const token = localStorage.getItem('bodytrack_token')||''
|
||||
fetch(`/api/prompts/${p.id}`,{
|
||||
method:'PUT',
|
||||
headers:{'Content-Type':'application/json','X-Auth-Token':token},
|
||||
body:JSON.stringify({active:!p.active})
|
||||
}).then(loadAll)
|
||||
}}>
|
||||
{p.active?'Deaktivieren':'Aktivieren'}
|
||||
</button>
|
||||
<button className="btn btn-secondary" style={{padding:'5px 8px'}}
|
||||
onClick={()=>setEditing(p)}><Pencil size={13}/></button>
|
||||
</div>
|
||||
<div style={{marginTop:8,padding:'8px 10px',background:'var(--surface2)',borderRadius:6,
|
||||
fontSize:11,fontFamily:'monospace',color:'var(--text3)',maxHeight:60,overflow:'hidden',lineHeight:1.4}}>
|
||||
{p.template.slice(0,200)}…
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Pipeline prompts */}
|
||||
<div style={{display:'flex',alignItems:'center',justifyContent:'space-between',margin:'20px 0 8px'}}>
|
||||
<div style={{fontSize:12,fontWeight:700,color:'var(--text3)',
|
||||
textTransform:'uppercase',letterSpacing:'0.05em'}}>
|
||||
Mehrstufige Pipeline
|
||||
</div>
|
||||
{(() => {
|
||||
const pipelinePrompt = prompts.find(p=>p.slug==='pipeline')
|
||||
return pipelinePrompt && (
|
||||
<button className="btn btn-secondary" style={{padding:'5px 12px',fontSize:12}}
|
||||
onClick={()=>{
|
||||
const token = localStorage.getItem('bodytrack_token')||''
|
||||
fetch(`/api/prompts/${pipelinePrompt.id}`,{
|
||||
method:'PUT',
|
||||
headers:{'Content-Type':'application/json','X-Auth-Token':token},
|
||||
body:JSON.stringify({active:!pipelinePrompt.active})
|
||||
}).then(loadAll)
|
||||
}}>
|
||||
{pipelinePrompt.active ? 'Gesamte Pipeline deaktivieren' : 'Gesamte Pipeline aktivieren'}
|
||||
</button>
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
{(() => {
|
||||
const pipelinePrompt = prompts.find(p=>p.slug==='pipeline')
|
||||
const isPipelineActive = pipelinePrompt?.active ?? true
|
||||
return (
|
||||
<div style={{padding:'10px 12px',
|
||||
background: isPipelineActive ? 'var(--warn-bg)' : '#FCEBEB',
|
||||
borderRadius:8,fontSize:12,
|
||||
color: isPipelineActive ? 'var(--warn-text)' : '#D85A30',
|
||||
marginBottom:12,lineHeight:1.6}}>
|
||||
{isPipelineActive ? (
|
||||
<>⚠️ <strong>Hinweis:</strong> Pipeline-Stage-1-Prompts müssen valides JSON zurückgeben.
|
||||
Halte das JSON-Format im Prompt erhalten. Stage 2 + 3 können frei angepasst werden.</>
|
||||
) : (
|
||||
<>⏸ <strong>Pipeline deaktiviert:</strong> Die mehrstufige Gesamtanalyse ist aktuell nicht verfügbar.
|
||||
Aktiviere sie mit dem Schalter oben, um sie auf der Analyse-Seite zu nutzen.</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
{pipelinePrompts.map(p=>{
|
||||
const isJson = jsonSlugs.includes(p.slug)
|
||||
return (
|
||||
<div key={p.id} className="card section-gap"
|
||||
style={{borderLeft:`3px solid ${isJson?'var(--warn)':'var(--accent)'}`,opacity:p.active?1:0.6}}>
|
||||
<div style={{display:'flex',alignItems:'center',gap:10}}>
|
||||
<div style={{flex:1}}>
|
||||
<div style={{fontWeight:600,fontSize:14,display:'flex',alignItems:'center',gap:8}}>
|
||||
{p.name}
|
||||
{isJson && <span style={{fontSize:10,background:'var(--warn-bg)',
|
||||
color:'var(--warn-text)',padding:'1px 6px',borderRadius:4}}>JSON-Output</span>}
|
||||
{!p.active && <span style={{fontSize:10,color:'#D85A30',
|
||||
background:'#FCEBEB',padding:'2px 8px',borderRadius:4,fontWeight:600}}>⏸ Deaktiviert</span>}
|
||||
</div>
|
||||
{p.description && <div style={{fontSize:12,color:'var(--text3)',marginTop:1}}>{p.description}</div>}
|
||||
</div>
|
||||
<button className="btn btn-secondary" style={{padding:'5px 8px'}}
|
||||
onClick={()=>setEditing(p)}><Pencil size={13}/></button>
|
||||
</div>
|
||||
<div style={{marginTop:8,padding:'8px 10px',background:'var(--surface2)',borderRadius:6,
|
||||
fontSize:11,fontFamily:'monospace',color:'var(--text3)',maxHeight:80,overflow:'hidden',lineHeight:1.4}}>
|
||||
{p.template.slice(0,300)}…
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -251,6 +251,23 @@ export default function SettingsPage() {
|
|||
setEditingId(null)
|
||||
}
|
||||
|
||||
const handleExportPlaceholders = async () => {
|
||||
try {
|
||||
const data = await api.exportPlaceholderValues()
|
||||
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `placeholders-${activeProfile?.name || 'profile'}-${new Date().toISOString().split('T')[0]}.json`
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
URL.revokeObjectURL(url)
|
||||
} catch (e) {
|
||||
alert('Fehler beim Export: ' + e.message)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="page-title">Einstellungen</h1>
|
||||
|
|
@ -409,6 +426,16 @@ export default function SettingsPage() {
|
|||
<span className="badge-button-description">maschinenlesbar, alles in einer Datei</span>
|
||||
</div>
|
||||
</button>
|
||||
<button className="btn btn-full"
|
||||
onClick={handleExportPlaceholders}
|
||||
style={{ background: 'var(--surface2)', border: '1px solid var(--border)' }}>
|
||||
<div className="badge-button-layout">
|
||||
<div className="badge-button-header">
|
||||
<span><BarChart3 size={14}/> Platzhalter exportieren</span>
|
||||
</div>
|
||||
<span className="badge-button-description">alle verfügbaren Platzhalter mit aktuellen Werten</span>
|
||||
</div>
|
||||
</button>
|
||||
</>}
|
||||
</div>
|
||||
<p style={{fontSize:11,color:'var(--text3)',marginTop:8}}>
|
||||
|
|
|
|||
|
|
@ -107,6 +107,7 @@ export const api = {
|
|||
insightPipeline: () => req('/insights/pipeline',{method:'POST'}),
|
||||
listInsights: () => req('/insights'),
|
||||
latestInsights: () => req('/insights/latest'),
|
||||
deleteInsight: (id) => req(`/insights/${id}`, {method:'DELETE'}),
|
||||
exportZip: async () => {
|
||||
const res = await fetch(`${BASE}/export/zip`, {headers: hdrs()})
|
||||
if (!res.ok) throw new Error('Export failed')
|
||||
|
|
@ -282,4 +283,51 @@ export const api = {
|
|||
fd.append('file', file)
|
||||
return req('/blood-pressure/import/omron', {method:'POST', body:fd})
|
||||
},
|
||||
|
||||
// AI Prompts Management (Issue #28)
|
||||
listAdminPrompts: () => req('/prompts'),
|
||||
createPrompt: (d) => req('/prompts', json(d)),
|
||||
updatePrompt: (id,d) => req(`/prompts/${id}`, jput(d)),
|
||||
deletePrompt: (id) => req(`/prompts/${id}`, {method:'DELETE'}),
|
||||
duplicatePrompt: (id) => req(`/prompts/${id}/duplicate`, json({})),
|
||||
reorderPrompts: (order) => req('/prompts/reorder', jput(order)),
|
||||
previewPrompt: (tpl) => req('/prompts/preview', json({template:tpl})),
|
||||
generatePrompt: (d) => req('/prompts/generate', json(d)),
|
||||
optimizePrompt: (id) => req(`/prompts/${id}/optimize`, json({})),
|
||||
listPlaceholders: () => req('/prompts/placeholders'),
|
||||
resetPromptToDefault: (id) => req(`/prompts/${id}/reset-to-default`, json({})),
|
||||
|
||||
// Pipeline Configs Management (Issue #28 Phase 2)
|
||||
listPipelineConfigs: () => req('/prompts/pipeline-configs'),
|
||||
createPipelineConfig: (d) => req('/prompts/pipeline-configs', json(d)),
|
||||
updatePipelineConfig: (id,d) => req(`/prompts/pipeline-configs/${id}`, jput(d)),
|
||||
deletePipelineConfig: (id) => req(`/prompts/pipeline-configs/${id}`, {method:'DELETE'}),
|
||||
setDefaultPipelineConfig: (id) => req(`/prompts/pipeline-configs/${id}/set-default`, json({})),
|
||||
|
||||
// Pipeline Execution (Issue #28 Phase 2)
|
||||
executePipeline: (configId=null) => req('/insights/pipeline' + (configId ? `?config_id=${configId}` : ''), json({})),
|
||||
|
||||
// Unified Prompt System (Issue #28 Phase 2)
|
||||
executeUnifiedPrompt: (slug, modules=null, timeframes=null, debug=false, save=false) => {
|
||||
const params = new URLSearchParams({ prompt_slug: slug })
|
||||
if (debug) params.append('debug', 'true')
|
||||
if (save) params.append('save', 'true')
|
||||
const body = {}
|
||||
if (modules) body.modules = modules
|
||||
if (timeframes) body.timeframes = timeframes
|
||||
return req('/prompts/execute?' + params, json(body))
|
||||
},
|
||||
createUnifiedPrompt: (d) => req('/prompts/unified', json(d)),
|
||||
updateUnifiedPrompt: (id,d) => req(`/prompts/unified/${id}`, jput(d)),
|
||||
|
||||
// Batch Import/Export
|
||||
exportAllPrompts: () => req('/prompts/export-all'),
|
||||
importPrompts: (data, overwrite=false) => {
|
||||
const params = new URLSearchParams()
|
||||
if (overwrite) params.append('overwrite', 'true')
|
||||
return req(`/prompts/import?${params}`, json(data))
|
||||
},
|
||||
|
||||
// Placeholder Export
|
||||
exportPlaceholderValues: () => req('/prompts/placeholders/export-values'),
|
||||
}
|
||||
|
|
|
|||
68
test-pipeline-api.sh
Normal file
68
test-pipeline-api.sh
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
#!/bin/bash
|
||||
# API-Tests für Pipeline-System (Issue #28)
|
||||
# Ausführen auf Server oder lokal: bash test-pipeline-api.sh
|
||||
|
||||
echo "═══════════════════════════════════════════════════════════"
|
||||
echo "Pipeline-System API Tests"
|
||||
echo "═══════════════════════════════════════════════════════════"
|
||||
echo ""
|
||||
|
||||
# Configuration
|
||||
API_URL="https://dev.mitai.jinkendo.de"
|
||||
TOKEN="" # <-- Füge deinen Admin-Token hier ein
|
||||
|
||||
if [ -z "$TOKEN" ]; then
|
||||
echo "❌ Bitte TOKEN in Zeile 11 eintragen (Admin-Token von dev.mitai.jinkendo.de)"
|
||||
echo ""
|
||||
echo "1. In Browser einloggen: https://dev.mitai.jinkendo.de"
|
||||
echo "2. Developer Tools öffnen (F12)"
|
||||
echo "3. Application/Storage → localStorage → auth_token kopieren"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Test 1: GET /api/prompts/pipeline-configs"
|
||||
echo "─────────────────────────────────────────────────────────"
|
||||
curl -s "$API_URL/api/prompts/pipeline-configs" \
|
||||
-H "X-Auth-Token: $TOKEN" | jq -r '.[] | "\(.name) (default: \(.is_default), active: \(.active))"'
|
||||
echo ""
|
||||
|
||||
echo "Test 2: GET /api/prompts/pipeline-configs - Full JSON"
|
||||
echo "─────────────────────────────────────────────────────────"
|
||||
curl -s "$API_URL/api/prompts/pipeline-configs" \
|
||||
-H "X-Auth-Token: $TOKEN" | jq '.[0] | {name, is_default, modules, stage1_prompts, stage2_prompt}'
|
||||
echo ""
|
||||
|
||||
echo "Test 3: POST /api/insights/pipeline (default config)"
|
||||
echo "─────────────────────────────────────────────────────────"
|
||||
echo "Hinweis: Dies startet eine echte KI-Analyse (kann 30-60s dauern)"
|
||||
read -p "Fortfahren? (y/n) " -n 1 -r
|
||||
echo
|
||||
if [[ $REPLY =~ ^[Yy]$ ]]; then
|
||||
RESULT=$(curl -s -X POST "$API_URL/api/insights/pipeline" \
|
||||
-H "X-Auth-Token: $TOKEN" \
|
||||
-H "Content-Type: application/json")
|
||||
|
||||
echo "$RESULT" | jq '{
|
||||
scope,
|
||||
config: .config,
|
||||
stage1_results: (.stage1 | keys),
|
||||
content_length: (.content | length)
|
||||
}'
|
||||
echo ""
|
||||
echo "Full content (first 500 chars):"
|
||||
echo "$RESULT" | jq -r '.content' | head -c 500
|
||||
echo "..."
|
||||
else
|
||||
echo "Übersprungen."
|
||||
fi
|
||||
echo ""
|
||||
|
||||
echo "Test 4: GET /api/prompts - Prüfe auf is_system_default"
|
||||
echo "─────────────────────────────────────────────────────────"
|
||||
curl -s "$API_URL/api/prompts" \
|
||||
-H "X-Auth-Token: $TOKEN" | jq -r '.[] | select(.slug | startswith("pipeline_")) | "\(.slug): is_system_default=\(.is_system_default // false)"' | head -6
|
||||
echo ""
|
||||
|
||||
echo "═══════════════════════════════════════════════════════════"
|
||||
echo "API-Tests abgeschlossen!"
|
||||
echo "═══════════════════════════════════════════════════════════"
|
||||
53
test-pipeline-backend.sh
Normal file
53
test-pipeline-backend.sh
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
#!/bin/bash
|
||||
# Test-Script für Pipeline-System Backend (Issue #28)
|
||||
# Ausführen auf Server: bash test-pipeline-backend.sh
|
||||
|
||||
echo "═══════════════════════════════════════════════════════════"
|
||||
echo "Pipeline-System Backend Tests"
|
||||
echo "═══════════════════════════════════════════════════════════"
|
||||
echo ""
|
||||
|
||||
# Container Names (from docker-compose.dev-env.yml)
|
||||
POSTGRES_CONTAINER="dev-mitai-postgres"
|
||||
DB_USER="mitai_dev"
|
||||
DB_NAME="mitai_dev"
|
||||
|
||||
echo "Test 1: Migration 019 ausgeführt"
|
||||
echo "─────────────────────────────────────────────────────────"
|
||||
docker exec $POSTGRES_CONTAINER psql -U $DB_USER -d $DB_NAME -c \
|
||||
"SELECT version, applied_at FROM schema_migrations WHERE version = '019';"
|
||||
echo ""
|
||||
|
||||
echo "Test 2: Tabelle pipeline_configs existiert"
|
||||
echo "─────────────────────────────────────────────────────────"
|
||||
docker exec $POSTGRES_CONTAINER psql -U $DB_USER -d $DB_NAME -c \
|
||||
"\d pipeline_configs" | head -40
|
||||
echo ""
|
||||
|
||||
echo "Test 3: Seed-Daten (3 Configs erwartet)"
|
||||
echo "─────────────────────────────────────────────────────────"
|
||||
docker exec $POSTGRES_CONTAINER psql -U $DB_USER -d $DB_NAME -c \
|
||||
"SELECT name, is_default, active, stage2_prompt, stage3_prompt FROM pipeline_configs ORDER BY is_default DESC, name;"
|
||||
echo ""
|
||||
|
||||
echo "Test 4: ai_prompts erweitert (is_system_default)"
|
||||
echo "─────────────────────────────────────────────────────────"
|
||||
docker exec $POSTGRES_CONTAINER psql -U $DB_USER -d $DB_NAME -c \
|
||||
"SELECT slug, is_system_default, (default_template IS NOT NULL) as has_default FROM ai_prompts WHERE slug LIKE 'pipeline_%' ORDER BY slug;"
|
||||
echo ""
|
||||
|
||||
echo "Test 5: stage1_prompts Array-Inhalte"
|
||||
echo "─────────────────────────────────────────────────────────"
|
||||
docker exec $POSTGRES_CONTAINER psql -U $DB_USER -d $DB_NAME -c \
|
||||
"SELECT name, stage1_prompts FROM pipeline_configs WHERE name = 'Alltags-Check';"
|
||||
echo ""
|
||||
|
||||
echo "Test 6: modules JSONB"
|
||||
echo "─────────────────────────────────────────────────────────"
|
||||
docker exec $POSTGRES_CONTAINER psql -U $DB_USER -d $DB_NAME -c \
|
||||
"SELECT name, jsonb_pretty(modules) as modules FROM pipeline_configs WHERE name = 'Alltags-Check';"
|
||||
echo ""
|
||||
|
||||
echo "═══════════════════════════════════════════════════════════"
|
||||
echo "Alle DB-Tests abgeschlossen!"
|
||||
echo "═══════════════════════════════════════════════════════════"
|
||||
74
test-unified-migration.sh
Normal file
74
test-unified-migration.sh
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
#!/bin/bash
|
||||
# Test Migration 020: Unified Prompt System (Issue #28)
|
||||
|
||||
echo "═══════════════════════════════════════════════════════════"
|
||||
echo "Migration 020: Unified Prompt System - Verification Tests"
|
||||
echo "═══════════════════════════════════════════════════════════"
|
||||
echo ""
|
||||
|
||||
POSTGRES_CONTAINER="dev-mitai-postgres"
|
||||
DB_USER="mitai_dev"
|
||||
DB_NAME="mitai_dev"
|
||||
|
||||
echo "Test 1: Migration 020 applied"
|
||||
echo "─────────────────────────────────────────────────────────"
|
||||
docker exec $POSTGRES_CONTAINER psql -U $DB_USER -d $DB_NAME -c \
|
||||
"SELECT version, applied_at FROM schema_migrations WHERE version = '020';"
|
||||
echo ""
|
||||
|
||||
echo "Test 2: New columns exist in ai_prompts"
|
||||
echo "─────────────────────────────────────────────────────────"
|
||||
docker exec $POSTGRES_CONTAINER psql -U $DB_USER -d $DB_NAME -c \
|
||||
"SELECT column_name, data_type
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = 'ai_prompts'
|
||||
AND column_name IN ('type', 'stages', 'output_format', 'output_schema')
|
||||
ORDER BY column_name;"
|
||||
echo ""
|
||||
|
||||
echo "Test 3: Existing prompts migrated to pipeline type"
|
||||
echo "─────────────────────────────────────────────────────────"
|
||||
docker exec $POSTGRES_CONTAINER psql -U $DB_USER -d $DB_NAME -c \
|
||||
"SELECT slug, type,
|
||||
CASE WHEN stages IS NOT NULL THEN 'Has stages' ELSE 'No stages' END as stages_status,
|
||||
output_format
|
||||
FROM ai_prompts
|
||||
WHERE slug LIKE 'pipeline_%'
|
||||
LIMIT 5;"
|
||||
echo ""
|
||||
|
||||
echo "Test 4: Pipeline configs migrated to ai_prompts"
|
||||
echo "─────────────────────────────────────────────────────────"
|
||||
docker exec $POSTGRES_CONTAINER psql -U $DB_USER -d $DB_NAME -c \
|
||||
"SELECT slug, name, type,
|
||||
jsonb_array_length(stages) as num_stages
|
||||
FROM ai_prompts
|
||||
WHERE slug LIKE 'pipeline_config_%';"
|
||||
echo ""
|
||||
|
||||
echo "Test 5: Stages JSONB structure (sample)"
|
||||
echo "─────────────────────────────────────────────────────────"
|
||||
docker exec $POSTGRES_CONTAINER psql -U $DB_USER -d $DB_NAME -c \
|
||||
"SELECT slug, jsonb_pretty(stages) as stages_structure
|
||||
FROM ai_prompts
|
||||
WHERE slug LIKE 'pipeline_config_%'
|
||||
LIMIT 1;"
|
||||
echo ""
|
||||
|
||||
echo "Test 6: Backup table created"
|
||||
echo "─────────────────────────────────────────────────────────"
|
||||
docker exec $POSTGRES_CONTAINER psql -U $DB_USER -d $DB_NAME -c \
|
||||
"SELECT COUNT(*) as backup_count FROM pipeline_configs_backup_pre_020;"
|
||||
echo ""
|
||||
|
||||
echo "Test 7: Indices created"
|
||||
echo "─────────────────────────────────────────────────────────"
|
||||
docker exec $POSTGRES_CONTAINER psql -U $DB_USER -d $DB_NAME -c \
|
||||
"SELECT indexname FROM pg_indexes
|
||||
WHERE tablename = 'ai_prompts'
|
||||
AND indexname LIKE 'idx_ai_prompts_%';"
|
||||
echo ""
|
||||
|
||||
echo "═══════════════════════════════════════════════════════════"
|
||||
echo "Migration 020 Tests Complete!"
|
||||
echo "═══════════════════════════════════════════════════════════"
|
||||
Loading…
Reference in New Issue
Block a user