diff --git a/backend/placeholder_registry.py b/backend/placeholder_registry.py index b02fe96..7dd1c1c 100644 --- a/backend/placeholder_registry.py +++ b/backend/placeholder_registry.py @@ -260,27 +260,24 @@ class PlaceholderRegistry: def build_ai_placeholder_caption(metadata: PlaceholderMetadata, max_len: int = 400) -> str: """ - Text für |d und Exportfeld ai_caption: zuerst **was** der Platzhalter misst (description), - dann **Einordnung** (business_meaning oder gekürzter semantic_contract). - So ist klar, worauf sich der konkrete Wert bezieht — nicht nur eine „Meta-Bedeutung“. + Kurzerklärung / Einordnung für {{key|x}} und Exportfeld ``ai_caption`` (ohne Wert, ohne Einheit). + + Inhalt: business_meaning oder gekürzter semantic_contract; bei SCORE-Zeilen die 0–100-Skala. + Nicht enthalten: description (die nur bei {{key|d}} angehängt wird) und keine „Technischer Bezug: …“-Zeile. """ desc = (metadata.description or "").strip() bm = (metadata.business_meaning or "").strip() sc = (metadata.semantic_contract or "").strip() chunks: List[str] = [] - if desc: - chunks.append(desc) interpret = bm if not interpret and sc: interpret = sc if len(sc) <= max_len else sc[: max_len - 1] + "…" if interpret: - blob = " ".join(chunks).lower() il = interpret.lower() - # Keine Dublette: gleicher Text oder lange Description bereits in der Interpretation - redundant = il in blob or ( + redundant = bool( desc and len(desc) >= 10 and desc.lower() in il @@ -291,27 +288,10 @@ def build_ai_placeholder_caption(metadata: PlaceholderMetadata, max_len: int = 4 if metadata.placeholder_type == PlaceholderType.SCORE: chunks.append("Skala 0–100: höher = im Modell günstiger / besser abgestimmt.") - unit = (metadata.unit or "").strip() - if unit and metadata.placeholder_type != PlaceholderType.SCORE: - blob = " ".join(chunks).lower() - u_low = unit.lower() - # Einheit oft schon in description („… in g (30d)“, „Kalorien“) — nicht doppeln - compact_blob = blob.replace(" ", "").replace("/", "") - compact_u = u_low.replace(" ", "").replace("/", "") - unit_redundant = compact_u in compact_blob or ( - "g/day" in u_low and ("g/" in blob or "gramm" in blob or " protein" in blob or " fett" in blob or " kh" in blob) - ) or ("kcal" in u_low and ("kcal" in blob or "kalorien" in blob)) - - if ( - not unit_redundant - and u_low not in ("score (0-100)", "0-100", "0–100", "dimensionless") - ): - chunks.append(f"Technischer Bezug: {unit}.") - out = " ".join(c for c in chunks if c).strip() if len(out) > max_len + 120: out = out[: max_len + 60] + "…" - return out or desc or metadata.key + return out # Global registry instance diff --git a/backend/placeholder_resolver.py b/backend/placeholder_resolver.py index 40aae40..61a5d43 100644 --- a/backend/placeholder_resolver.py +++ b/backend/placeholder_resolver.py @@ -49,25 +49,46 @@ from data_layer.health_metrics import ( from placeholder_registry import build_ai_placeholder_caption, get_registry -# {{key}} oder {{key|d}} — Modifier d hängt KI-Kontext (ai_caption) an +# {{key|d}} — nur description anhängen; {{key|x}} — nur Erklärung (ai_caption / Registry) _PLACEHOLDER_TOKEN_RE = re.compile( r"\{\{\s*([a-zA-Z0-9_]+)(?:\s*\|\s*([a-zA-Z0-9_,\s]+))?\s*\}\}" ) -def _ai_caption_for_placeholder_key(key: str) -> Optional[str]: - meta = get_registry().get(key) - if meta: - return build_ai_placeholder_caption(meta) +def get_catalog_row_for_key( + catalog: Optional[Dict[str, List[Dict[str, Any]]]], key: str +) -> Optional[Dict[str, Any]]: + """Katalogzeile zu Platzhalter-Key (key ohne {{}}).""" + if not catalog: + return None + for items in catalog.values(): + for item in items: + raw = item.get("key") or "" + ik = str(raw).replace("{{", "").replace("}}", "").strip() + if ik == key: + return item return None +def _description_for_registry_key(key: str) -> str: + meta = get_registry().get(key) + if not meta: + return "" + return (meta.description or "").strip() + + +def _explain_for_registry_key(key: str) -> str: + meta = get_registry().get(key) + if not meta: + return "" + return build_ai_placeholder_caption(meta).strip() + + def format_value_with_d_modifier(value: str, catalog_row: Dict[str, Any]) -> str: """ - Entspricht der Prompt-Ersetzung bei {{key|d}}: „Wert — Kontext“. - Kontext: ai_caption aus dem Katalog, sonst description (wie prompt_executor). + Vorschau für Export wie {{key|d}}: „Wert — description“ (kein ai_caption). """ - cap = (catalog_row.get("ai_caption") or catalog_row.get("description") or "").strip() + cap = (catalog_row.get("description") or "").strip() if cap: return f"{value} — {cap}" return str(value) @@ -1615,7 +1636,9 @@ def resolve_placeholders(template: str, profile_id: str) -> str: Unterstützt Modifier wie bei der Prompt-Pipeline: - {{fat_avg}} — nur Wert - - {{fat_avg|d}} — Wert plus KI-Kontext (business_meaning / semantic_contract aus Registry) + - {{fat_avg|d}} — Wert — description (kurz, token-sparend) + - {{fat_avg|x}} — nur Erklärung (business_meaning / semantic_contract, ggf. Score-Skala), ohne Wert + - {{fat_avg|d,x}} — Wert — description — Erklärung Args: template: Prompt template with placeholders @@ -1637,11 +1660,27 @@ def resolve_placeholders(template: str, profile_id: str) -> str: value = str(resolver(profile_id)) except Exception: return f"[Fehler: {ph}]" - if "d" in mods: - cap = _ai_caption_for_placeholder_key(key) - if cap: - value = f"{value} — {cap}" - return value + + want_d = "d" in mods + want_x = "x" in mods + + if want_x and not want_d: + expl = _explain_for_registry_key(key) + return expl if expl else "" + + if not want_d and not want_x: + return value + + parts: List[str] = [value] + if want_d: + desc = _description_for_registry_key(key) + if desc: + parts.append(desc) + if want_x: + expl = _explain_for_registry_key(key) + if expl: + parts.append(expl) + return " — ".join(parts) return _PLACEHOLDER_TOKEN_RE.sub(_repl, template) @@ -1846,7 +1885,7 @@ def get_placeholder_catalog(profile_id: str) -> Dict[str, List[Dict[str, str]]]: 'key': key, 'description': description, 'example': str(example), - 'ai_caption': description, + 'ai_caption': '', }) # Add ALL remaining placeholders from PLACEHOLDER_MAP that aren't categorized yet diff --git a/backend/prompt_executor.py b/backend/prompt_executor.py index 4eb0f33..0970892 100644 --- a/backend/prompt_executor.py +++ b/backend/prompt_executor.py @@ -12,14 +12,17 @@ import re from typing import Dict, Any, Optional from db import get_db, get_cursor, r2d from fastapi import HTTPException +from placeholder_resolver import get_catalog_row_for_key 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}} — angehängter KI-Kontext (ai_caption aus Katalog, sonst description; Katalog nötig) + Modifiers (Katalog aus get_placeholder_catalog empfohlen): + - {{key|d}} — Wert — description (kurz) + - {{key|x}} — nur Erklärung (Katalogfeld ai_caption), ohne Zahlenwert + - {{key|d,x}} — Wert — description — Erklärung Args: template: String with {{key}} or {{key|modifiers}} placeholders @@ -40,46 +43,66 @@ def resolve_placeholders(template: str, variables: Dict[str, Any], debug_info: O parts = full_placeholder.split('|') key = parts[0].strip() modifiers = parts[1].strip() if len(parts) > 1 else '' + mods = {x.strip().lower() for x in modifiers.split(",") if x.strip()} + want_d = "d" in mods + want_x = "x" in mods - 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: - caption = None - for cat_items in catalog.values(): - matching = [item for item in cat_items if item['key'] == key] - if matching: - row = matching[0] - caption = (row.get('ai_caption') or row.get('description') or '').strip() - break - - if caption: - resolved_value = f"{resolved_value} — {caption}" - 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 + def _warn(msg: str): if debug_info is not None: - resolved[key] = resolved_value[:100] + ('...' if len(resolved_value) > 100 else '') + debug_info.setdefault("warnings", []).append(msg) - return resolved_value - else: - # Keep placeholder if no value found + row = get_catalog_row_for_key(catalog, key) if catalog else None + + if want_x and not want_d: + if key not in variables: + if debug_info is not None: + unresolved.append(key) + return match.group(0) + expl = (row.get("ai_caption") or "").strip() if row else "" + if not expl and catalog is None: + _warn(f"Modifier |x für {key}: Katalog fehlt (ai_caption).") + out = expl + if debug_info is not None: + resolved[key] = out[:100] + ("..." if len(out) > 100 else "") + return out + + if key not in variables: if debug_info is not None: unresolved.append(key) return match.group(0) + value = variables[key] + if isinstance(value, (dict, list)): + resolved_value = json.dumps(value, ensure_ascii=False) + else: + resolved_value = str(value) + + if not want_d and not want_x: + out = resolved_value + if debug_info is not None: + resolved[key] = out[:100] + ("..." if len(out) > 100 else "") + return out + + parts = [resolved_value] + if want_d: + if row: + desc = (row.get("description") or "").strip() + if desc: + parts.append(desc) + else: + _warn(f"Modifier |d für {key}: Katalog fehlt (description).") + if want_x: + expl = (row.get("ai_caption") or "").strip() if row else "" + if expl: + parts.append(expl) + elif catalog is not None: + _warn(f"Modifier |x (mit |d) für {key}: ai_caption leer.") + + out = " — ".join(parts) + if debug_info is not None: + resolved[key] = out[:100] + ("..." if len(out) > 100 else "") + return out + result = re.sub(r'\{\{([^}]+)\}\}', replacer, template) # Store debug info @@ -464,7 +487,7 @@ async def execute_prompt_with_data( 'today': datetime.now().strftime('%Y-%m-%d') } - # Load placeholder catalog for |d modifier support + # Load placeholder catalog for |d / |x Modifier try: catalog = get_placeholder_catalog(profile_id) except Exception as e: diff --git a/backend/routers/prompts.py b/backend/routers/prompts.py index 43fc9ba..5153ce5 100644 --- a/backend/routers/prompts.py +++ b/backend/routers/prompts.py @@ -17,8 +17,8 @@ from models import ( PromptCreate, PromptUpdate, PromptGenerateRequest, PipelineConfigCreate, PipelineConfigUpdate ) +from prompt_executor import resolve_placeholders as resolve_prompt_placeholders from placeholder_resolver import ( - resolve_placeholders, get_unknown_placeholders, get_placeholder_example_values, format_value_with_d_modifier, @@ -432,7 +432,13 @@ def preview_prompt(data: dict, session: dict=Depends(require_auth)): template = data.get('template', '') profile_id = session['profile_id'] - resolved = resolve_placeholders(template, profile_id) + catalog = get_placeholder_catalog(profile_id) + processed = get_placeholder_example_values(profile_id) + variables = { + k.replace('{{', '').replace('}}', ''): v + for k, v in processed.items() + } + resolved = resolve_prompt_placeholders(template, variables, None, catalog) unknown = get_unknown_placeholders(template) return { @@ -458,8 +464,8 @@ def export_placeholder_values(session: dict = Depends(require_auth)): """ Export all available placeholders with their current resolved values. - Pro Zeile: value = Rohwert wie bei {{key}}, example = Vorschau wie bei {{key|d}} - (Wert — ai_caption bzw. description). JSON-Download für das aktive Profil. + Pro Zeile: value = {{key}}, example = Vorschau {{key|d}} (Wert — description), + ai_caption = Text für {{key|x}} (Erklärung ohne Wert). JSON für das aktive Profil. """ from datetime import datetime profile_id = session['profile_id'] diff --git a/backend/tests/test_placeholder_modifier_d.py b/backend/tests/test_placeholder_modifier_d.py index bf55111..4387937 100644 --- a/backend/tests/test_placeholder_modifier_d.py +++ b/backend/tests/test_placeholder_modifier_d.py @@ -1,4 +1,4 @@ -"""Tests für {{key|d}}, ai_caption und Unbekannt-Erkennung.""" +"""Tests für {{key|d}}, {{key|x}}, ai_caption und Unbekannt-Erkennung.""" from placeholder_registry import ( PlaceholderMetadata, PlaceholderType, @@ -7,9 +7,10 @@ from placeholder_registry import ( ) import placeholder_resolver as pr from placeholder_resolver import format_value_with_d_modifier +from prompt_executor import resolve_placeholders -def test_build_ai_caption_prefers_business_meaning(): +def test_build_ai_caption_is_explanation_only(): m = PlaceholderMetadata( key="test_x", category="Test", @@ -23,11 +24,11 @@ def test_build_ai_caption_prefers_business_meaning(): output_type=OutputType.NUMERIC, ) cap = build_ai_placeholder_caption(m) - assert cap.startswith("Kurzbeschreibung") assert "Kernbedeutung" in cap + assert "Kurzbeschreibung" not in cap -def test_build_ai_caption_description_then_meaning_like_protein_avg(): +def test_build_ai_caption_protein_avg_no_description_prefix(): m = PlaceholderMetadata( key="protein_avg", category="Ernährung", @@ -40,8 +41,8 @@ def test_build_ai_caption_description_then_meaning_like_protein_avg(): output_type=OutputType.NUMERIC, ) cap = build_ai_placeholder_caption(m) - assert cap.startswith("Durchschn. Protein in g (30d)") - assert "Muskelerhalt" in cap + assert cap.startswith("Zentraler Placeholder") + assert "Durchschn. Protein" not in cap assert "Technischer Bezug" not in cap @@ -69,6 +70,8 @@ def test_placeholder_token_regex_optional_modifier(): assert m1 and m1.group(1) == "fat_avg" and m1.group(2).strip() == "d" m2 = pr._PLACEHOLDER_TOKEN_RE.search("{{ protein_avg | d }}") assert m2 and m2.group(1) == "protein_avg" and m2.group(2).strip() == "d" + m3 = pr._PLACEHOLDER_TOKEN_RE.search("{{k|d,x}}") + assert m3 and m3.group(1) == "k" and m3.group(2).strip() == "d,x" def test_get_unknown_placeholders_strips_modifier(): @@ -76,17 +79,26 @@ def test_get_unknown_placeholders_strips_modifier(): assert set(unk) == {"not_a_real_key"} -def test_format_value_with_d_modifier_matches_prompt_executor(): +def test_format_value_with_d_modifier_uses_description_only(): row = { "key": "protein_avg", "description": "Durchschn. Protein in g (30d)", - "example": "119g/Tag", - "ai_caption": "Durchschn. Protein in g (30d). Zentral für Muskelerhalt.", + "ai_caption": "Nur für |x", } out = format_value_with_d_modifier("119g/Tag", row) - assert out == "119g/Tag — Durchschn. Protein in g (30d). Zentral für Muskelerhalt." + assert out == "119g/Tag — Durchschn. Protein in g (30d)" + assert "Nur für |x" not in out def test_format_value_with_d_modifier_falls_back_to_description(): row = {"description": "Nur Beschreibung", "key": "x"} assert format_value_with_d_modifier("42", row) == "42 — Nur Beschreibung" + + +def test_prompt_executor_modifiers_d_x_combined(): + catalog = {"E": [{"key": "p", "description": "Desc", "ai_caption": "Expl"}]} + v = {"p": "99"} + assert resolve_placeholders("{{p|d}}", v, None, catalog) == "99 — Desc" + assert resolve_placeholders("{{p|x}}", v, None, catalog) == "Expl" + assert resolve_placeholders("{{p|d,x}}", v, None, catalog) == "99 — Desc — Expl" + assert resolve_placeholders("{{p}}", v, None, catalog) == "99"