diff --git a/backend/placeholder_registry.py b/backend/placeholder_registry.py index 0571abc..b02fe96 100644 --- a/backend/placeholder_registry.py +++ b/backend/placeholder_registry.py @@ -260,21 +260,34 @@ class PlaceholderRegistry: def build_ai_placeholder_caption(metadata: PlaceholderMetadata, max_len: int = 400) -> str: """ - Kurztext für KI-Kontext (z. B. Modifier |d): Bedeutung/Skala, ohne die Rohausgabe zu ersetzen. - Nutzt business_meaning / semantic_contract; bei Scores explizite 0–100-Erläuterung. + 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“. """ - chunks: List[str] = [] + desc = (metadata.description or "").strip() bm = (metadata.business_meaning or "").strip() sc = (metadata.semantic_contract or "").strip() - desc = (metadata.description or "").strip() - if bm: - chunks.append(bm) - elif sc: - chunks.append(sc if len(sc) <= max_len else sc[: max_len - 1] + "…") - elif desc: + 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 ( + desc + and len(desc) >= 10 + and desc.lower() in il + ) + if not redundant: + chunks.append(interpret) + if metadata.placeholder_type == PlaceholderType.SCORE: chunks.append("Skala 0–100: höher = im Modell günstiger / besser abgestimmt.") @@ -282,9 +295,18 @@ def build_ai_placeholder_caption(metadata: PlaceholderMetadata, max_len: int = 4 if unit and metadata.placeholder_type != PlaceholderType.SCORE: blob = " ".join(chunks).lower() u_low = unit.lower() - if u_low not in blob and u_low.replace(" ", "") not in blob.replace(" ", ""): - if u_low not in ("score (0-100)", "0-100", "0–100", "dimensionless"): - chunks.append(f"Technischer Bezug: {unit}.") + # 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: diff --git a/backend/placeholder_resolver.py b/backend/placeholder_resolver.py index db4f0b5..40aae40 100644 --- a/backend/placeholder_resolver.py +++ b/backend/placeholder_resolver.py @@ -10,7 +10,7 @@ This module now focuses on FORMATTING for AI consumption. import json import re from datetime import datetime, timedelta -from typing import Dict, List, Optional, Callable, Tuple +from typing import Any, Dict, List, Optional, Callable, Tuple from db import get_db, get_cursor, r2d # Phase 0c: Import data layer @@ -62,6 +62,17 @@ def _ai_caption_for_placeholder_key(key: str) -> Optional[str]: return None +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). + """ + cap = (catalog_row.get("ai_caption") or catalog_row.get("description") or "").strip() + if cap: + return f"{value} — {cap}" + return str(value) + + # ── Helper Functions ────────────────────────────────────────────────────────── def get_profile_data(profile_id: str) -> Dict: diff --git a/backend/routers/prompts.py b/backend/routers/prompts.py index 41f5172..43fc9ba 100644 --- a/backend/routers/prompts.py +++ b/backend/routers/prompts.py @@ -21,6 +21,7 @@ from placeholder_resolver import ( resolve_placeholders, get_unknown_placeholders, get_placeholder_example_values, + format_value_with_d_modifier, get_available_placeholders, get_placeholder_catalog ) @@ -457,8 +458,8 @@ def export_placeholder_values(session: dict = Depends(require_auth)): """ Export all available placeholders with their current resolved values. - Returns JSON export suitable for download with all placeholders - resolved for the current user's profile. + 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. """ from datetime import datetime profile_id = session['profile_id'] @@ -486,11 +487,12 @@ def export_placeholder_values(session: dict = Depends(require_auth)): export_data['placeholders_by_category'][category] = [] for item in items: key = item['key'].replace('{{', '').replace('}}', '') + raw_val = cleaned_values.get(key, 'nicht verfügbar') row = { 'key': item['key'], 'description': item['description'], - 'value': cleaned_values.get(key, 'nicht verfügbar'), - 'example': item.get('example'), + 'value': raw_val, + 'example': format_value_with_d_modifier(str(raw_val), item), } if item.get('ai_caption'): row['ai_caption'] = item['ai_caption'] @@ -662,11 +664,12 @@ def export_placeholder_values_extended( export_data['legacy']['placeholders_by_category'][category] = [] for item in items: key = item['key'].replace('{{', '').replace('}}', '') + raw_val = cleaned_values.get(key, 'nicht verfügbar') export_data['legacy']['placeholders_by_category'][category].append({ 'key': item['key'], 'description': item['description'], - 'value': cleaned_values.get(key, 'nicht verfügbar'), - 'example': item.get('example') + 'value': raw_val, + 'example': format_value_with_d_modifier(str(raw_val), item), }) # Fill metadata flat diff --git a/backend/tests/test_placeholder_modifier_d.py b/backend/tests/test_placeholder_modifier_d.py index b3149bf..bf55111 100644 --- a/backend/tests/test_placeholder_modifier_d.py +++ b/backend/tests/test_placeholder_modifier_d.py @@ -6,6 +6,7 @@ from placeholder_registry import ( build_ai_placeholder_caption, ) import placeholder_resolver as pr +from placeholder_resolver import format_value_with_d_modifier def test_build_ai_caption_prefers_business_meaning(): @@ -22,9 +23,28 @@ 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 +def test_build_ai_caption_description_then_meaning_like_protein_avg(): + m = PlaceholderMetadata( + key="protein_avg", + category="Ernährung", + description="Durchschn. Protein in g (30d)", + resolver_module="m", + resolver_function="f", + business_meaning="Zentraler Placeholder für Muskelerhalt.", + unit="g/day", + placeholder_type=PlaceholderType.INTERPRETED, + output_type=OutputType.NUMERIC, + ) + cap = build_ai_placeholder_caption(m) + assert cap.startswith("Durchschn. Protein in g (30d)") + assert "Muskelerhalt" in cap + assert "Technischer Bezug" not in cap + + def test_build_ai_caption_score_adds_scale(): m = PlaceholderMetadata( key="test_score", @@ -54,3 +74,19 @@ def test_placeholder_token_regex_optional_modifier(): def test_get_unknown_placeholders_strips_modifier(): unk = pr.get_unknown_placeholders("{{not_a_real_key|d}}") assert set(unk) == {"not_a_real_key"} + + +def test_format_value_with_d_modifier_matches_prompt_executor(): + row = { + "key": "protein_avg", + "description": "Durchschn. Protein in g (30d)", + "example": "119g/Tag", + "ai_caption": "Durchschn. Protein in g (30d). Zentral für Muskelerhalt.", + } + out = format_value_with_d_modifier("119g/Tag", row) + assert out == "119g/Tag — Durchschn. Protein in g (30d). Zentral für Muskelerhalt." + + +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"