feat: Refine placeholder resolution with enhanced modifiers support
- 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:
parent
a9a414b956
commit
4868e44882
|
|
@ -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 0–100-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 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()
|
||||
# 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", "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
|
||||
return out
|
||||
|
||||
|
||||
# Global registry instance
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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']
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user