feat: Refine placeholder resolution with enhanced modifiers support
All checks were successful
Deploy Development / deploy (push) Successful in 55s
Build Test / pytest-backend (push) Successful in 4s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 17s

- Updated `resolve_placeholders` in `prompt_executor.py` to support combined modifiers for placeholders, allowing for more flexible output formats.
- Enhanced `build_ai_placeholder_caption` in `placeholder_registry.py` to clarify the generation of AI context captions, focusing on descriptions and explanations.
- Introduced new helper functions in `placeholder_resolver.py` to streamline the retrieval of descriptions and explanations for placeholders.
- Modified tests to cover new functionality, ensuring accurate behavior for combined modifiers and improved placeholder resolution.

These changes enhance the usability and clarity of placeholder outputs, providing users with richer contextual information.
This commit is contained in:
Lars 2026-04-11 21:58:29 +02:00
parent a9a414b956
commit 4868e44882
5 changed files with 151 additions and 91 deletions

View File

@ -260,27 +260,24 @@ class PlaceholderRegistry:
def build_ai_placeholder_caption(metadata: PlaceholderMetadata, max_len: int = 400) -> str:
"""
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.
Kurzerklärung / Einordnung für {{key|x}} und Exportfeld ``ai_caption`` (ohne Wert, ohne Einheit).
Inhalt: business_meaning oder gekürzter semantic_contract; bei SCORE-Zeilen die 0100-Skala.
Nicht enthalten: description (die nur bei {{key|d}} angehängt wird) und keine Technischer Bezug: -Zeile.
"""
desc = (metadata.description or "").strip()
bm = (metadata.business_meaning or "").strip()
sc = (metadata.semantic_contract or "").strip()
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 (
redundant = bool(
desc
and len(desc) >= 10
and desc.lower() in il
@ -291,27 +288,10 @@ def build_ai_placeholder_caption(metadata: PlaceholderMetadata, max_len: int = 4
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()
# 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", "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
return out
# Global registry instance

View File

@ -49,25 +49,46 @@ from data_layer.health_metrics import (
from placeholder_registry import build_ai_placeholder_caption, get_registry
# {{key}} oder {{key|d}} — Modifier d hängt KI-Kontext (ai_caption) an
# {{key|d}} — nur description anhängen; {{key|x}} — nur Erklärung (ai_caption / Registry)
_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)
def get_catalog_row_for_key(
catalog: Optional[Dict[str, List[Dict[str, Any]]]], key: str
) -> Optional[Dict[str, Any]]:
"""Katalogzeile zu Platzhalter-Key (key ohne {{}})."""
if not catalog:
return None
for items in catalog.values():
for item in items:
raw = item.get("key") or ""
ik = str(raw).replace("{{", "").replace("}}", "").strip()
if ik == key:
return item
return None
def _description_for_registry_key(key: str) -> str:
meta = get_registry().get(key)
if not meta:
return ""
return (meta.description or "").strip()
def _explain_for_registry_key(key: str) -> str:
meta = get_registry().get(key)
if not meta:
return ""
return build_ai_placeholder_caption(meta).strip()
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).
Vorschau für Export wie {{key|d}}: Wert description (kein ai_caption).
"""
cap = (catalog_row.get("ai_caption") or catalog_row.get("description") or "").strip()
cap = (catalog_row.get("description") or "").strip()
if cap:
return f"{value}{cap}"
return str(value)
@ -1615,7 +1636,9 @@ def resolve_placeholders(template: str, profile_id: str) -> str:
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)
- {{fat_avg|d}} Wert description (kurz, token-sparend)
- {{fat_avg|x}} nur Erklärung (business_meaning / semantic_contract, ggf. Score-Skala), ohne Wert
- {{fat_avg|d,x}} Wert description Erklärung
Args:
template: Prompt template with placeholders
@ -1637,12 +1660,28 @@ def resolve_placeholders(template: str, profile_id: str) -> str:
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}"
want_d = "d" in mods
want_x = "x" in mods
if want_x and not want_d:
expl = _explain_for_registry_key(key)
return expl if expl else ""
if not want_d and not want_x:
return value
parts: List[str] = [value]
if want_d:
desc = _description_for_registry_key(key)
if desc:
parts.append(desc)
if want_x:
expl = _explain_for_registry_key(key)
if expl:
parts.append(expl)
return "".join(parts)
return _PLACEHOLDER_TOKEN_RE.sub(_repl, template)
@ -1846,7 +1885,7 @@ def get_placeholder_catalog(profile_id: str) -> Dict[str, List[Dict[str, str]]]:
'key': key,
'description': description,
'example': str(example),
'ai_caption': description,
'ai_caption': '',
})
# Add ALL remaining placeholders from PLACEHOLDER_MAP that aren't categorized yet

View File

@ -12,14 +12,17 @@ import re
from typing import Dict, Any, Optional
from db import get_db, get_cursor, r2d
from fastapi import HTTPException
from placeholder_resolver import get_catalog_row_for_key
def resolve_placeholders(template: str, variables: Dict[str, Any], debug_info: Optional[Dict] = None, catalog: Optional[Dict] = None) -> str:
"""
Replace {{placeholder}} with values from variables dict.
Supports modifiers:
- {{key|d}} angehängter KI-Kontext (ai_caption aus Katalog, sonst description; Katalog nötig)
Modifiers (Katalog aus get_placeholder_catalog empfohlen):
- {{key|d}} Wert description (kurz)
- {{key|x}} nur Erklärung (Katalogfeld ai_caption), ohne Zahlenwert
- {{key|d,x}} Wert description Erklärung
Args:
template: String with {{key}} or {{key|modifiers}} placeholders
@ -40,45 +43,65 @@ def resolve_placeholders(template: str, variables: Dict[str, Any], debug_info: O
parts = full_placeholder.split('|')
key = parts[0].strip()
modifiers = parts[1].strip() if len(parts) > 1 else ''
mods = {x.strip().lower() for x in modifiers.split(",") if x.strip()}
want_d = "d" in mods
want_x = "x" in mods
def _warn(msg: str):
if debug_info is not None:
debug_info.setdefault("warnings", []).append(msg)
row = get_catalog_row_for_key(catalog, key) if catalog else None
if want_x and not want_d:
if key not in variables:
if debug_info is not None:
unresolved.append(key)
return match.group(0)
expl = (row.get("ai_caption") or "").strip() if row else ""
if not expl and catalog is None:
_warn(f"Modifier |x für {key}: Katalog fehlt (ai_caption).")
out = expl
if debug_info is not None:
resolved[key] = out[:100] + ("..." if len(out) > 100 else "")
return out
if key not in variables:
if debug_info is not None:
unresolved.append(key)
return match.group(0)
if key in variables:
value = variables[key]
# Convert dict/list to JSON string
if isinstance(value, (dict, list)):
resolved_value = json.dumps(value, ensure_ascii=False)
else:
resolved_value = str(value)
# Apply modifiers
if 'd' in modifiers:
if catalog:
caption = None
for cat_items in catalog.values():
matching = [item for item in cat_items if item['key'] == key]
if matching:
row = matching[0]
caption = (row.get('ai_caption') or row.get('description') or '').strip()
break
if not want_d and not want_x:
out = resolved_value
if debug_info is not None:
resolved[key] = out[:100] + ("..." if len(out) > 100 else "")
return out
if caption:
resolved_value = f"{resolved_value}{caption}"
parts = [resolved_value]
if want_d:
if row:
desc = (row.get("description") or "").strip()
if desc:
parts.append(desc)
else:
# Catalog not available - log warning in debug
if debug_info is not None:
if 'warnings' not in debug_info:
debug_info['warnings'] = []
debug_info['warnings'].append(f"Modifier |d used but catalog not available for {key}")
_warn(f"Modifier |d für {key}: Katalog fehlt (description).")
if want_x:
expl = (row.get("ai_caption") or "").strip() if row else ""
if expl:
parts.append(expl)
elif catalog is not None:
_warn(f"Modifier |x (mit |d) für {key}: ai_caption leer.")
# Track resolution for debug
out = "".join(parts)
if debug_info is not None:
resolved[key] = resolved_value[:100] + ('...' if len(resolved_value) > 100 else '')
return resolved_value
else:
# Keep placeholder if no value found
if debug_info is not None:
unresolved.append(key)
return match.group(0)
resolved[key] = out[:100] + ("..." if len(out) > 100 else "")
return out
result = re.sub(r'\{\{([^}]+)\}\}', replacer, template)
@ -464,7 +487,7 @@ async def execute_prompt_with_data(
'today': datetime.now().strftime('%Y-%m-%d')
}
# Load placeholder catalog for |d modifier support
# Load placeholder catalog for |d / |x Modifier
try:
catalog = get_placeholder_catalog(profile_id)
except Exception as e:

View File

@ -17,8 +17,8 @@ from models import (
PromptCreate, PromptUpdate, PromptGenerateRequest,
PipelineConfigCreate, PipelineConfigUpdate
)
from prompt_executor import resolve_placeholders as resolve_prompt_placeholders
from placeholder_resolver import (
resolve_placeholders,
get_unknown_placeholders,
get_placeholder_example_values,
format_value_with_d_modifier,
@ -432,7 +432,13 @@ def preview_prompt(data: dict, session: dict=Depends(require_auth)):
template = data.get('template', '')
profile_id = session['profile_id']
resolved = resolve_placeholders(template, profile_id)
catalog = get_placeholder_catalog(profile_id)
processed = get_placeholder_example_values(profile_id)
variables = {
k.replace('{{', '').replace('}}', ''): v
for k, v in processed.items()
}
resolved = resolve_prompt_placeholders(template, variables, None, catalog)
unknown = get_unknown_placeholders(template)
return {
@ -458,8 +464,8 @@ def export_placeholder_values(session: dict = Depends(require_auth)):
"""
Export all available placeholders with their current resolved values.
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.
Pro Zeile: value = {{key}}, example = Vorschau {{key|d}} (Wert description),
ai_caption = Text für {{key|x}} (Erklärung ohne Wert). JSON für das aktive Profil.
"""
from datetime import datetime
profile_id = session['profile_id']

View File

@ -1,4 +1,4 @@
"""Tests für {{key|d}}, ai_caption und Unbekannt-Erkennung."""
"""Tests für {{key|d}}, {{key|x}}, ai_caption und Unbekannt-Erkennung."""
from placeholder_registry import (
PlaceholderMetadata,
PlaceholderType,
@ -7,9 +7,10 @@ from placeholder_registry import (
)
import placeholder_resolver as pr
from placeholder_resolver import format_value_with_d_modifier
from prompt_executor import resolve_placeholders
def test_build_ai_caption_prefers_business_meaning():
def test_build_ai_caption_is_explanation_only():
m = PlaceholderMetadata(
key="test_x",
category="Test",
@ -23,11 +24,11 @@ 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
assert "Kurzbeschreibung" not in cap
def test_build_ai_caption_description_then_meaning_like_protein_avg():
def test_build_ai_caption_protein_avg_no_description_prefix():
m = PlaceholderMetadata(
key="protein_avg",
category="Ernährung",
@ -40,8 +41,8 @@ def test_build_ai_caption_description_then_meaning_like_protein_avg():
output_type=OutputType.NUMERIC,
)
cap = build_ai_placeholder_caption(m)
assert cap.startswith("Durchschn. Protein in g (30d)")
assert "Muskelerhalt" in cap
assert cap.startswith("Zentraler Placeholder")
assert "Durchschn. Protein" not in cap
assert "Technischer Bezug" not in cap
@ -69,6 +70,8 @@ def test_placeholder_token_regex_optional_modifier():
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"
m3 = pr._PLACEHOLDER_TOKEN_RE.search("{{k|d,x}}")
assert m3 and m3.group(1) == "k" and m3.group(2).strip() == "d,x"
def test_get_unknown_placeholders_strips_modifier():
@ -76,17 +79,26 @@ def test_get_unknown_placeholders_strips_modifier():
assert set(unk) == {"not_a_real_key"}
def test_format_value_with_d_modifier_matches_prompt_executor():
def test_format_value_with_d_modifier_uses_description_only():
row = {
"key": "protein_avg",
"description": "Durchschn. Protein in g (30d)",
"example": "119g/Tag",
"ai_caption": "Durchschn. Protein in g (30d). Zentral für Muskelerhalt.",
"ai_caption": "Nur für |x",
}
out = format_value_with_d_modifier("119g/Tag", row)
assert out == "119g/Tag — Durchschn. Protein in g (30d). Zentral für Muskelerhalt."
assert out == "119g/Tag — Durchschn. Protein in g (30d)"
assert "Nur für |x" not in out
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"
def test_prompt_executor_modifiers_d_x_combined():
catalog = {"E": [{"key": "p", "description": "Desc", "ai_caption": "Expl"}]}
v = {"p": "99"}
assert resolve_placeholders("{{p|d}}", v, None, catalog) == "99 — Desc"
assert resolve_placeholders("{{p|x}}", v, None, catalog) == "Expl"
assert resolve_placeholders("{{p|d,x}}", v, None, catalog) == "99 — Desc — Expl"
assert resolve_placeholders("{{p}}", v, None, catalog) == "99"