From c0a50dedcdc149c33c42038f3fd24f6c297919a4 Mon Sep 17 00:00:00 2001 From: Lars Date: Thu, 26 Mar 2026 11:52:26 +0100 Subject: [PATCH] feat: value table + {{placeholder|d}} modifier (Issue #47) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FEATURE #47: Wertetabelle nach KI-Analysen - Migration 021: metadata JSONB column in ai_insights - Backend sammelt resolved placeholders mit descriptions beim Speichern - Frontend: Collapsible value table in InsightCard - Zeigt: Platzhalter | Wert | Beschreibung - Sortiert tabellarisch - Funktioniert für base + pipeline prompts FEATURE #48: {{placeholder|d}} Modifier - Syntax: {{weight_aktuell|d}} → "85.2 kg (Aktuelles Gewicht in kg)" - resolve_placeholders() erkennt |d modifier - Hängt description aus catalog an Wert - Fein-granulare Kontrolle pro Platzhalter (nicht global) - Optional: nur wo sinnvoll einsetzen TECHNICAL: - prompt_executor.py: catalog parameter durchgereicht - execute_prompt_with_data() lädt catalog via get_placeholder_catalog() - Catalog als _catalog in variables übergeben, in execute_prompt() extrahiert - Base + Pipeline Prompts unterstützen |d modifier EXAMPLE: Template: "Gewicht: {{weight_aktuell|d}}, Alter: {{age}}" Output: "Gewicht: 85.2 kg (Aktuelles Gewicht in kg), Alter: 55" version: 9.6.0 (feature) module: prompts 2.1.0, insights 1.4.0 --- .../migrations/021_ai_insights_metadata.sql | 7 ++ backend/prompt_executor.py | 57 +++++++++++++---- backend/routers/prompts.py | 64 +++++++++++++++++-- frontend/src/pages/Analysis.jsx | 62 +++++++++++++++++- 4 files changed, 174 insertions(+), 16 deletions(-) create mode 100644 backend/migrations/021_ai_insights_metadata.sql diff --git a/backend/migrations/021_ai_insights_metadata.sql b/backend/migrations/021_ai_insights_metadata.sql new file mode 100644 index 0000000..80bf0c9 --- /dev/null +++ b/backend/migrations/021_ai_insights_metadata.sql @@ -0,0 +1,7 @@ +-- Migration 021: Add metadata column to ai_insights for storing debug info +-- Date: 2026-03-26 +-- Purpose: Store resolved placeholder values with descriptions for transparency + +ALTER TABLE ai_insights ADD COLUMN IF NOT EXISTS metadata JSONB DEFAULT NULL; + +COMMENT ON COLUMN ai_insights.metadata IS 'Debug info: resolved placeholders, descriptions, etc.'; diff --git a/backend/prompt_executor.py b/backend/prompt_executor.py index 07f4558..3b13100 100644 --- a/backend/prompt_executor.py +++ b/backend/prompt_executor.py @@ -14,14 +14,18 @@ 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) -> str: +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}} placeholders + 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 @@ -30,7 +34,13 @@ def resolve_placeholders(template: str, variables: Dict[str, Any], debug_info: O unresolved = [] def replacer(match): - key = match.group(1).strip() + 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 @@ -39,6 +49,19 @@ def resolve_placeholders(template: str, variables: Dict[str, Any], debug_info: O else: resolved_value = str(value) + # Apply modifiers + if 'd' in modifiers and 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})" + # Track resolution for debug if debug_info is not None: resolved[key] = resolved_value[:100] + ('...' if len(resolved_value) > 100 else '') @@ -151,13 +174,16 @@ async def execute_prompt( 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) + 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) + return await execute_pipeline_prompt(prompt, variables, openrouter_call_func, enable_debug, catalog) else: raise HTTPException(400, f"Unknown prompt type: {prompt_type}") @@ -167,7 +193,8 @@ async def execute_base_prompt( prompt: Dict, variables: Dict[str, Any], openrouter_call_func, - enable_debug: bool = False + enable_debug: bool = False, + catalog: Optional[Dict] = None ) -> Dict[str, Any]: """Execute a base-type prompt (single template).""" template = prompt.get('template') @@ -176,8 +203,8 @@ async def execute_base_prompt( debug_info = {} if enable_debug else None - # Resolve placeholders - prompt_text = resolve_placeholders(template, variables, debug_info) + # Resolve placeholders (with optional catalog for |d modifier) + prompt_text = resolve_placeholders(template, variables, debug_info, catalog) if enable_debug: debug_info['template'] = template @@ -215,7 +242,8 @@ async def execute_pipeline_prompt( prompt: Dict, variables: Dict[str, Any], openrouter_call_func, - enable_debug: bool = False + enable_debug: bool = False, + catalog: Optional[Dict] = None ) -> Dict[str, Any]: """ Execute a pipeline-type prompt (multi-stage). @@ -282,7 +310,7 @@ async def execute_pipeline_prompt( 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) + prompt_text = resolve_placeholders(template, context_vars, placeholder_debug, catalog) if enable_debug: prompt_debug['source'] = 'inline' @@ -368,7 +396,7 @@ async def execute_prompt_with_data( Execution result dict """ from datetime import datetime, timedelta - from placeholder_resolver import get_placeholder_example_values + from placeholder_resolver import get_placeholder_example_values, get_placeholder_catalog # Build variables from data modules variables = { @@ -376,6 +404,13 @@ async def execute_prompt_with_data( 'today': datetime.now().strftime('%Y-%m-%d') } + # Load placeholder catalog for |d modifier support + try: + catalog = get_placeholder_catalog(profile_id) + variables['_catalog'] = catalog # Will be popped in execute_prompt + except Exception: + catalog = None + # Add PROCESSED placeholders (name, weight_trend, caliper_summary, etc.) # This makes old-style prompts work with the new executor try: diff --git a/backend/routers/prompts.py b/backend/routers/prompts.py index 327d7e7..139a7a2 100644 --- a/backend/routers/prompts.py +++ b/backend/routers/prompts.py @@ -812,13 +812,69 @@ async def execute_unified_prompt( if isinstance(content, dict): content = json.dumps(content, ensure_ascii=False) - # Save to database + # Prepare metadata with resolved placeholders and descriptions + from placeholder_resolver import get_placeholder_catalog + + metadata = { + 'prompt_type': result['type'], + 'placeholders': {} + } + + # Collect all resolved placeholders from debug info + if result.get('debug'): + catalog = get_placeholder_catalog(profile_id) + + if result['type'] == 'base': + # Base prompt: single set of placeholders + resolved = result['debug'].get('resolved_placeholders', {}) + for key, value in resolved.items(): + # Find description in catalog + desc = None + for cat_items in catalog.values(): + matching = [item for item in cat_items if item['key'] == key] + if matching: + desc = matching[0].get('description', '') + break + + metadata['placeholders'][key] = { + 'value': value, + 'description': desc or '' + } + + elif result['type'] == 'pipeline': + # Pipeline: collect from all stages + stages_debug = result['debug'].get('stages', []) + for stage_debug in stages_debug: + for prompt_debug in stage_debug.get('prompts', []): + resolved = {} + # Check both direct and ref_debug + if 'resolved_placeholders' in prompt_debug: + resolved = prompt_debug['resolved_placeholders'] + elif 'ref_debug' in prompt_debug and 'resolved_placeholders' in prompt_debug['ref_debug']: + resolved = prompt_debug['ref_debug']['resolved_placeholders'] + + for key, value in resolved.items(): + if key not in metadata['placeholders']: # Avoid duplicates + # Find description in catalog + desc = None + for cat_items in catalog.values(): + matching = [item for item in cat_items if item['key'] == key] + if matching: + desc = matching[0].get('description', '') + break + + metadata['placeholders'][key] = { + 'value': value, + 'description': desc or '' + } + + # Save to database with metadata with get_db() as conn: cur = get_cursor(conn) cur.execute( - """INSERT INTO ai_insights (id, profile_id, scope, content, created) - VALUES (%s, %s, %s, %s, CURRENT_TIMESTAMP)""", - (str(uuid.uuid4()), profile_id, prompt_slug, content) + """INSERT INTO ai_insights (id, profile_id, scope, content, metadata, created) + VALUES (%s, %s, %s, %s, %s, CURRENT_TIMESTAMP)""", + (str(uuid.uuid4()), profile_id, prompt_slug, content, json.dumps(metadata)) ) conn.commit() diff --git a/frontend/src/pages/Analysis.jsx b/frontend/src/pages/Analysis.jsx index 6b9b2f6..10c0f07 100644 --- a/frontend/src/pages/Analysis.jsx +++ b/frontend/src/pages/Analysis.jsx @@ -15,11 +15,17 @@ const SLUG_LABELS = { function InsightCard({ ins, onDelete, defaultOpen=false, prompts=[] }) { const [open, setOpen] = useState(defaultOpen) + const [showValues, setShowValues] = useState(false) // 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 + // Parse metadata if available + const metadata = ins.metadata ? (typeof ins.metadata === 'string' ? JSON.parse(ins.metadata) : ins.metadata) : null + const placeholders = metadata?.placeholders || {} + const placeholderCount = Object.keys(placeholders).length + return (
{open ? : }
- {open && } + {open && ( + <> + + + {/* Value Table */} + {placeholderCount > 0 && ( +
+
setShowValues(!showValues)} + style={{ + cursor: 'pointer', + fontSize: 12, + color: 'var(--text2)', + fontWeight: 600, + display: 'flex', + alignItems: 'center', + gap: 6 + }} + > + {showValues ? : } + 📊 Verwendete Werte ({placeholderCount}) +
+ + {showValues && ( +
+ + + + + + + + + + {Object.entries(placeholders).map(([key, data]) => ( + + + + + + ))} + +
PlatzhalterWertBeschreibung
+ {key} + + {data.value} + + {data.description || '—'} +
+
+ )} +
+ )} + + )}
) }