Platzhalter finalisiert - Option |d und Option |x implementiert #77
|
|
@ -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,11 +1660,27 @@ 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}"
|
||||
return value
|
||||
|
||||
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,46 +43,66 @@ 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
|
||||
|
||||
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 caption:
|
||||
resolved_value = f"{resolved_value} — {caption}"
|
||||
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}")
|
||||
|
||||
# Track resolution for debug
|
||||
def _warn(msg: str):
|
||||
if debug_info is not None:
|
||||
resolved[key] = resolved_value[:100] + ('...' if len(resolved_value) > 100 else '')
|
||||
debug_info.setdefault("warnings", []).append(msg)
|
||||
|
||||
return resolved_value
|
||||
else:
|
||||
# Keep placeholder if no value found
|
||||
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)
|
||||
|
||||
value = variables[key]
|
||||
if isinstance(value, (dict, list)):
|
||||
resolved_value = json.dumps(value, ensure_ascii=False)
|
||||
else:
|
||||
resolved_value = str(value)
|
||||
|
||||
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
|
||||
|
||||
parts = [resolved_value]
|
||||
if want_d:
|
||||
if row:
|
||||
desc = (row.get("description") or "").strip()
|
||||
if desc:
|
||||
parts.append(desc)
|
||||
else:
|
||||
_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.")
|
||||
|
||||
out = " — ".join(parts)
|
||||
if debug_info is not None:
|
||||
resolved[key] = out[:100] + ("..." if len(out) > 100 else "")
|
||||
return out
|
||||
|
||||
result = re.sub(r'\{\{([^}]+)\}\}', replacer, template)
|
||||
|
||||
# Store debug info
|
||||
|
|
@ -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