feat: Enhance placeholder system with AI context support
All checks were successful
Deploy Development / deploy (push) Successful in 48s
Build Test / pytest-backend (push) Successful in 4s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 16s

- 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.
This commit is contained in:
Lars 2026-04-11 21:36:29 +02:00
parent 41bf593d4c
commit baeddd7c13
5 changed files with 146 additions and 27 deletions

View File

@ -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 0100-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 0100: 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", "0100", "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()

View File

@ -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

View File

@ -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:

View File

@ -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

View File

@ -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 "0100" 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"}