Platzhalter finalisiert - Option |d und Option |x implementiert #77

Merged
Lars merged 9 commits from develop into main 2026-04-11 22:10:10 +02:00
5 changed files with 146 additions and 27 deletions
Showing only changes of commit baeddd7c13 - Show all commits

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