diff --git a/backend/placeholder_registry.py b/backend/placeholder_registry.py index 749071a..0571abc 100644 --- a/backend/placeholder_registry.py +++ b/backend/placeholder_registry.py @@ -258,6 +258,40 @@ class PlaceholderRegistry: return metadata._resolver_func(profile_id) +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. + """ + chunks: List[str] = [] + 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.append(desc) + + 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() + 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}.") + + 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 + + # Global registry instance _global_registry = PlaceholderRegistry() diff --git a/backend/placeholder_resolver.py b/backend/placeholder_resolver.py index c227bbb..db4f0b5 100644 --- a/backend/placeholder_resolver.py +++ b/backend/placeholder_resolver.py @@ -47,6 +47,20 @@ from data_layer.health_metrics import ( get_vo2_max_data ) +from placeholder_registry import build_ai_placeholder_caption, get_registry + +# {{key}} oder {{key|d}} — Modifier d hängt KI-Kontext (ai_caption) an +_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) + return None + # ── Helper Functions ────────────────────────────────────────────────────────── @@ -1588,6 +1602,10 @@ def resolve_placeholders(template: str, profile_id: str) -> str: """ Replace all {{placeholders}} in template with actual user data. + 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) + Args: template: Prompt template with placeholders profile_id: User profile ID @@ -1595,18 +1613,26 @@ def resolve_placeholders(template: str, profile_id: str) -> str: 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}]") + def _repl(match: re.Match) -> str: + key = match.group(1) + modifiers_raw = (match.group(2) or "").strip() + mods = {x.strip().lower() for x in modifiers_raw.split(",") if x.strip()} + ph = f"{{{{{key}}}}}" + resolver = PLACEHOLDER_MAP.get(ph) + if not resolver: + return match.group(0) + try: + 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 - return result + return _PLACEHOLDER_TOKEN_RE.sub(_repl, template) def get_unknown_placeholders(template: str) -> List[str]: @@ -1619,12 +1645,9 @@ def get_unknown_placeholders(template: str) -> List[str]: Returns: List of unknown placeholder names (without {{}}) """ - # Find all {{...}} patterns - found = re.findall(r'\{\{(\w+)\}\}', template) - - # Filter to only unknown ones + found = _PLACEHOLDER_TOKEN_RE.findall(template) known_names = {p.strip('{}') for p in PLACEHOLDER_MAP.keys()} - unknown = [p for p in found if p not in known_names] + unknown = [key for key, _ in found if key not in known_names] return list(set(unknown)) # Remove duplicates @@ -1781,7 +1804,8 @@ def get_placeholder_catalog(profile_id: str) -> Dict[str, List[Dict[str, str]]]: catalog[category].append({ 'key': key, 'description': metadata.description, - 'example': str(example) + 'example': str(example), + 'ai_caption': build_ai_placeholder_caption(metadata), }) # Legacy placeholders (not in registry yet) @@ -1810,7 +1834,8 @@ def get_placeholder_catalog(profile_id: str) -> Dict[str, List[Dict[str, str]]]: catalog[category].append({ 'key': key, 'description': description, - 'example': str(example) + 'example': str(example), + 'ai_caption': description, }) # Add ALL remaining placeholders from PLACEHOLDER_MAP that aren't categorized yet @@ -1839,7 +1864,8 @@ def get_placeholder_catalog(profile_id: str) -> Dict[str, List[Dict[str, str]]]: catalog[sonstige_category].append({ 'key': key, 'description': f'Platzhalter: {key}', # Generic description - 'example': str(example) + 'example': str(example), + 'ai_caption': f'Platzhalter {key} (noch ohne erweiterte Registry-Beschreibung).', }) return catalog diff --git a/backend/prompt_executor.py b/backend/prompt_executor.py index 769a8d9..4eb0f33 100644 --- a/backend/prompt_executor.py +++ b/backend/prompt_executor.py @@ -19,7 +19,7 @@ def resolve_placeholders(template: str, variables: Dict[str, Any], debug_info: O Replace {{placeholder}} with values from variables dict. Supports modifiers: - - {{key|d}} - Include description in parentheses (requires catalog) + - {{key|d}} — angehängter KI-Kontext (ai_caption aus Katalog, sonst description; Katalog nötig) Args: template: String with {{key}} or {{key|modifiers}} placeholders @@ -52,16 +52,16 @@ def resolve_placeholders(template: str, variables: Dict[str, Any], debug_info: O # Apply modifiers if 'd' in modifiers: if catalog: - # Add description from catalog - description = None + caption = 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', '') + row = matching[0] + caption = (row.get('ai_caption') or row.get('description') or '').strip() break - if description: - resolved_value = f"{resolved_value} ({description})" + if caption: + resolved_value = f"{resolved_value} — {caption}" else: # Catalog not available - log warning in debug if debug_info is not None: diff --git a/backend/routers/prompts.py b/backend/routers/prompts.py index 3e6288c..41f5172 100644 --- a/backend/routers/prompts.py +++ b/backend/routers/prompts.py @@ -486,12 +486,15 @@ def export_placeholder_values(session: dict = Depends(require_auth)): export_data['placeholders_by_category'][category] = [] for item in items: key = item['key'].replace('{{', '').replace('}}', '') - export_data['placeholders_by_category'][category].append({ + row = { 'key': item['key'], 'description': item['description'], 'value': cleaned_values.get(key, 'nicht verfügbar'), - 'example': item.get('example') - }) + 'example': item.get('example'), + } + if item.get('ai_caption'): + row['ai_caption'] = item['ai_caption'] + export_data['placeholders_by_category'][category].append(row) # Also include flat list for easy access export_data['all_placeholders'] = cleaned_values diff --git a/backend/tests/test_placeholder_modifier_d.py b/backend/tests/test_placeholder_modifier_d.py new file mode 100644 index 0000000..b3149bf --- /dev/null +++ b/backend/tests/test_placeholder_modifier_d.py @@ -0,0 +1,56 @@ +"""Tests für {{key|d}}, ai_caption und Unbekannt-Erkennung.""" +from placeholder_registry import ( + PlaceholderMetadata, + PlaceholderType, + OutputType, + build_ai_placeholder_caption, +) +import placeholder_resolver as pr + + +def test_build_ai_caption_prefers_business_meaning(): + m = PlaceholderMetadata( + key="test_x", + category="Test", + description="Kurzbeschreibung", + resolver_module="m", + resolver_function="f", + semantic_contract="Lang Vertrag " * 50, + business_meaning="Kernbedeutung für die KI.", + unit="g/day", + placeholder_type=PlaceholderType.INTERPRETED, + output_type=OutputType.NUMERIC, + ) + cap = build_ai_placeholder_caption(m) + assert "Kernbedeutung" in cap + + +def test_build_ai_caption_score_adds_scale(): + m = PlaceholderMetadata( + key="test_score", + category="Test", + description="Score", + resolver_module="m", + resolver_function="f", + business_meaning="Gewichteter Gesamtscore.", + unit="Score (0-100)", + placeholder_type=PlaceholderType.SCORE, + output_type=OutputType.NUMERIC, + ) + cap = build_ai_placeholder_caption(m) + assert "0–100" in cap or "0-100" in cap + assert "Gewichteter" in cap + + +def test_placeholder_token_regex_optional_modifier(): + m0 = pr._PLACEHOLDER_TOKEN_RE.search("{{fat_avg}}") + assert m0 and m0.group(1) == "fat_avg" and m0.group(2) is None + m1 = pr._PLACEHOLDER_TOKEN_RE.search("{{fat_avg|d}}") + 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" + + +def test_get_unknown_placeholders_strips_modifier(): + unk = pr.get_unknown_placeholders("{{not_a_real_key|d}}") + assert set(unk) == {"not_a_real_key"}