feat: value table + {{placeholder|d}} modifier (Issue #47)
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
This commit is contained in:
parent
c56d2b2201
commit
c0a50dedcd
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.';
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<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'}}
|
||||
|
|
@ -38,7 +44,61 @@ function InsightCard({ ins, onDelete, defaultOpen=false, prompts=[] }) {
|
|||
</button>
|
||||
{open ? <ChevronUp size={16} color="var(--text3)"/> : <ChevronDown size={16} color="var(--text3)"/>}
|
||||
</div>
|
||||
{open && <Markdown text={ins.content}/>}
|
||||
{open && (
|
||||
<>
|
||||
<Markdown text={ins.content}/>
|
||||
|
||||
{/* Value Table */}
|
||||
{placeholderCount > 0 && (
|
||||
<div style={{ marginTop: 16, borderTop: '1px solid var(--border)', paddingTop: 12 }}>
|
||||
<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})
|
||||
</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>
|
||||
{Object.entries(placeholders).map(([key, data]) => (
|
||||
<tr key={key} style={{ borderBottom: '1px solid var(--border)' }}>
|
||||
<td style={{ padding: '6px 8px', fontFamily: 'monospace', color: 'var(--accent)' }}>
|
||||
{key}
|
||||
</td>
|
||||
<td style={{ padding: '6px 8px', fontFamily: 'monospace' }}>
|
||||
{data.value}
|
||||
</td>
|
||||
<td style={{ padding: '6px 8px', color: 'var(--text3)', fontSize: 10 }}>
|
||||
{data.description || '—'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user