From baeddd7c135e7d47db2b39d786cb93633388cdc9 Mon Sep 17 00:00:00 2001 From: Lars Date: Sat, 11 Apr 2026 21:36:29 +0200 Subject: [PATCH] feat: Enhance placeholder system with AI context support - Introduced `build_ai_placeholder_caption` function in `placeholder_registry.py` to generate AI context captions based on placeholder metadata. - Updated `resolve_placeholders` in `placeholder_resolver.py` to support modifiers for AI context, allowing for enhanced descriptions when placeholders are resolved. - Modified `get_placeholder_catalog` to include AI captions in the output, improving the metadata available for placeholders. - Adjusted `export_placeholder_values` to include AI captions in the exported data, enhancing the information provided to users. These changes improve the flexibility and functionality of the placeholder system, enabling richer context generation for dynamic content. --- backend/placeholder_registry.py | 34 +++++++++++ backend/placeholder_resolver.py | 62 ++++++++++++++------ backend/prompt_executor.py | 12 ++-- backend/routers/prompts.py | 9 ++- backend/tests/test_placeholder_modifier_d.py | 56 ++++++++++++++++++ 5 files changed, 146 insertions(+), 27 deletions(-) create mode 100644 backend/tests/test_placeholder_modifier_d.py 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"}