Enhance Planning Catalog Context and Prompt Slot Management
Some checks failed
Deploy Development / deploy (push) Successful in 43s
Test Suite / pytest-backend (push) Failing after 2s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 13s
Test Suite / k6 /health Baseline (push) Successful in 34s
Test Suite / playwright-tests (push) Successful in 1m20s

- Introduced new catalog context handling in planning prompt functions, allowing for improved integration of planning variables.
- Added optional catalog context parameters in various functions to streamline the merging of planning prompt variables.
- Updated frontend components to include CatalogPromptSlotsEditor for managing prompt slots across different catalog types.
- Enhanced API utilities to support fetching and updating catalog prompt slots, improving backend functionality for catalog management.
- Incremented version numbers and updated changelog to reflect the new features and improvements.
This commit is contained in:
Lars 2026-06-15 12:13:15 +02:00
parent 9cee862c32
commit 53f2b027cc
20 changed files with 1922 additions and 204 deletions

View File

@ -12,6 +12,7 @@ from pydantic import BaseModel, Field
from planning_exercise_semantics import brief_to_summary_dict, build_semantic_brief
from planning_intent_context import build_planning_intent_context
from planning_prompt_variables import merge_planning_prompt_variables
PLANNING_PROMPT_SLUGS = frozenset(
{
@ -36,6 +37,7 @@ class PlanningPromptPreviewInput(BaseModel):
user_notes: str = Field(default="Fokus Breitensport, ohne Wettkampfdruck.", max_length=2000)
max_steps: int = Field(default=5, ge=2, le=10)
search_query: Optional[str] = Field(default=None, max_length=2000)
planning_catalog_context: Optional[Dict[str, Any]] = Field(default=None)
def is_planning_prompt_slug(slug: str) -> bool:
@ -160,6 +162,24 @@ def _load_catalog_variables(cur) -> Dict[str, str]:
}
def _preview_catalog_context(body: PlanningPromptPreviewInput):
from planning_catalog_context import catalog_context_from_mapping
raw = body.planning_catalog_context
if raw:
return catalog_context_from_mapping(raw)
return None
def _merge_catalog_preview(cur, slug: str, base: Dict[str, str], body: PlanningPromptPreviewInput) -> Dict[str, str]:
return merge_planning_prompt_variables(
cur,
base,
catalog=_preview_catalog_context(body),
slug=slug,
)
def resolve_planning_prompt_preview_variables(
cur,
slug: str,
@ -189,34 +209,54 @@ def resolve_planning_prompt_preview_variables(
catalogs = _load_catalog_variables(cur)
if s == "planning_progression_start_target":
return {
return _merge_catalog_preview(
cur,
s,
{
"goal_query": goal_query,
"semantic_brief_json": brief_json,
"user_notes": (body.user_notes or "").strip(),
}
},
body,
)
if s == "planning_progression_goal_analysis":
return {
return _merge_catalog_preview(
cur,
s,
{
"goal_query": goal_query,
"semantic_brief_json": brief_json,
}
},
body,
)
if s == "planning_progression_roadmap":
return {
return _merge_catalog_preview(
cur,
s,
{
"goal_query": goal_query,
"goal_analysis_json": _compact_json(goal_analysis),
"semantic_brief_json": brief_json,
"max_steps": str(max_steps),
}
},
body,
)
if s == "planning_progression_stage_spec":
return {
return _merge_catalog_preview(
cur,
s,
{
"goal_query": goal_query,
"goal_analysis_json": _compact_json(goal_analysis),
"major_steps_json": _compact_json(major_steps),
"intent_context_json": intent_ctx_json,
"semantic_brief_json": brief_json,
}
},
body,
)
if s == "planning_exercise_query_semantics":
return {
@ -225,13 +265,18 @@ def resolve_planning_prompt_preview_variables(
}
if s == "planning_exercise_path_qa":
return {
return _merge_catalog_preview(
cur,
s,
{
"goal_query": goal_query,
"semantic_brief_json": brief_json,
"steps_json": _compact_json(_sample_path_steps()),
"gaps_json": _compact_json([]),
"bridge_inserts_json": _compact_json([]),
}
},
body,
)
if s == "planning_exercise_search_intent":
return {

View File

@ -0,0 +1,419 @@
"""
Katalog-Prompt-Slots Slot-Typ-Vokabular + Werte pro Stammdaten-Zeile (H2).
Prompts in ai_prompts referenzieren Platzhalter wie {{focus_area_hints_on_progression}}.
Inhalte liegen in catalog_prompt_slots (Admin-editierbar), nicht im Code pro Eintrag.
"""
from __future__ import annotations
import json
from dataclasses import dataclass
from typing import Any, Dict, List, Mapping, Optional, Sequence, Tuple
from planning_catalog_context import (
ProgressionPlanningCatalogContext,
PlanningCatalogContextItem,
catalog_context_has_items,
)
# ---------------------------------------------------------------------------
# Dimensionen (Prioritätsreihenfolge)
# ---------------------------------------------------------------------------
@dataclass(frozen=True)
class CatalogKindConfig:
kind: str
table: str
context_attr: str
label_de: str
CATALOG_KINDS: Tuple[CatalogKindConfig, ...] = (
CatalogKindConfig("focus_area", "focus_areas", "focus_areas", "Primärfokus"),
CatalogKindConfig("training_type", "training_types", "training_types", "Trainingsstil"),
CatalogKindConfig("target_group", "target_groups", "target_groups", "Zielgruppe"),
CatalogKindConfig("style_direction", "style_directions", "style_directions", "Stilrichtung"),
)
_KIND_BY_NAME = {c.kind: c for c in CATALOG_KINDS}
# ---------------------------------------------------------------------------
# Slot-Typen (Vokabular — erweiterbar via catalog_prompt_slot_types)
# ---------------------------------------------------------------------------
SLOT_KEYS: Tuple[str, ...] = (
"description",
"hints_on_progression",
"hints_on_exercise",
"hints_on_path_qa",
"anti_patterns",
"rematch_guard",
)
LLM_SLOT_KEYS: Tuple[str, ...] = tuple(k for k in SLOT_KEYS if k != "rematch_guard")
GUIDANCE_BLOCK_SLOTS: Tuple[str, ...] = (
"description",
"hints_on_progression",
"hints_on_path_qa",
"anti_patterns",
)
GUIDANCE_PROFILE_BY_SLUG: Dict[str, Tuple[str, ...]] = {
"planning_exercise_path_qa": ("description", "hints_on_path_qa", "anti_patterns"),
"planning_progression_roadmap": ("description", "hints_on_progression", "anti_patterns"),
"planning_progression_stage_spec": ("hints_on_progression", "anti_patterns", "description"),
"planning_progression_goal_analysis": ("description", "hints_on_progression"),
"planning_progression_start_target": ("description",),
}
def placeholder_key(catalog_kind: str, slot_key: str) -> str:
return f"{catalog_kind}_{slot_key}"
def all_placeholder_keys() -> List[str]:
keys: List[str] = []
for cfg in CATALOG_KINDS:
for slot in SLOT_KEYS:
keys.append(placeholder_key(cfg.kind, slot))
keys.extend(["catalog_guidance_block", "catalog_context_json", "has_catalog_guidance"])
return keys
def empty_catalog_variables() -> Dict[str, str]:
out = {k: "" for k in all_placeholder_keys()}
return out
# ---------------------------------------------------------------------------
# Katalog-Kontext → aktiver Eintrag
# ---------------------------------------------------------------------------
def pick_active_catalog_item(
items: Sequence[PlanningCatalogContextItem],
) -> Optional[PlanningCatalogContextItem]:
if not items:
return None
primaries = [i for i in items if i.is_primary]
if primaries:
return primaries[0]
if len(items) == 1:
return items[0]
return max(items, key=lambda i: (float(i.weight), -int(i.id)))
def _load_catalog_row(cur, table: str, item_id: int) -> Optional[Dict[str, Any]]:
cur.execute(
f"""
SELECT id, name, description
FROM {table}
WHERE id = %s
""",
(int(item_id),),
)
row = cur.fetchone()
if not row:
return None
return {
"id": int(row["id"]),
"name": str(row.get("name") or "").strip(),
"description": str(row.get("description") or "").strip(),
}
def _load_slots_for_entry(cur, catalog_kind: str, catalog_id: int) -> Dict[str, str]:
cur.execute(
"""
SELECT slot_key, content
FROM catalog_prompt_slots
WHERE catalog_kind = %s AND catalog_id = %s
""",
(catalog_kind, int(catalog_id)),
)
out: Dict[str, str] = {}
for row in cur.fetchall():
key = str(row.get("slot_key") or "").strip()
if key:
out[key] = str(row.get("content") or "").strip()
return out
def _slot_types_table_ready(cur) -> bool:
cur.execute("SELECT to_regclass(%s)::text AS t", ("public.catalog_prompt_slot_types",))
row = cur.fetchone()
if not row:
return False
val = row.get("t") if isinstance(row, dict) else row[0]
return val is not None and str(val).strip() != ""
def list_slot_type_definitions(cur) -> List[Dict[str, Any]]:
if not _slot_types_table_ready(cur):
return _fallback_slot_type_rows()
cur.execute(
"""
SELECT slot_key, display_name, description, applicable_kinds, sort_order, for_llm, for_code
FROM catalog_prompt_slot_types
ORDER BY sort_order ASC NULLS LAST, slot_key ASC
"""
)
rows = []
for row in cur.fetchall():
d = dict(row)
kinds = d.get("applicable_kinds")
if isinstance(kinds, str):
kinds = [k.strip() for k in kinds.strip("{}").split(",") if k.strip()]
d["applicable_kinds"] = list(kinds or [])
rows.append(d)
return rows
def _fallback_slot_type_rows() -> List[Dict[str, Any]]:
labels = {
"description": "Allgemeine Beschreibung",
"hints_on_progression": "Hinweise Progressionsgraph",
"hints_on_exercise": "Hinweise Übungsanlage",
"hints_on_path_qa": "Hinweise Pfad-QS",
"anti_patterns": "Anti-Patterns (Fehlbewertung vermeiden)",
"rematch_guard": "Rematch-Guard (Code)",
}
kinds = [c.kind for c in CATALOG_KINDS]
rows = []
for i, key in enumerate(SLOT_KEYS):
rows.append(
{
"slot_key": key,
"display_name": labels.get(key, key),
"description": "",
"applicable_kinds": kinds,
"sort_order": (i + 1) * 10,
"for_llm": key != "rematch_guard",
"for_code": key == "rematch_guard",
}
)
return rows
def get_catalog_entry_slots(cur, catalog_kind: str, catalog_id: int) -> Dict[str, Any]:
cfg = _KIND_BY_NAME.get((catalog_kind or "").strip())
if not cfg:
raise ValueError(f"Unbekannter catalog_kind: {catalog_kind!r}")
row = _load_catalog_row(cur, cfg.table, catalog_id)
if not row:
raise LookupError("Katalog-Eintrag nicht gefunden")
stored = _load_slots_for_entry(cur, cfg.kind, catalog_id)
merged: Dict[str, str] = {}
for slot in SLOT_KEYS:
if slot == "description":
merged[slot] = stored.get("description") or row.get("description") or ""
else:
merged[slot] = stored.get(slot, "")
return {
"catalog_kind": cfg.kind,
"catalog_id": int(catalog_id),
"name": row["name"],
"slots": merged,
}
def upsert_catalog_entry_slots(
cur,
catalog_kind: str,
catalog_id: int,
slots: Mapping[str, Any],
) -> Dict[str, Any]:
cfg = _KIND_BY_NAME.get((catalog_kind or "").strip())
if not cfg:
raise ValueError(f"Unbekannter catalog_kind: {catalog_kind!r}")
row = _load_catalog_row(cur, cfg.table, catalog_id)
if not row:
raise LookupError("Katalog-Eintrag nicht gefunden")
for slot_key, raw in (slots or {}).items():
sk = str(slot_key or "").strip()
if sk not in SLOT_KEYS:
continue
content = str(raw or "").strip()
if not content:
cur.execute(
"""
DELETE FROM catalog_prompt_slots
WHERE catalog_kind = %s AND catalog_id = %s AND slot_key = %s
""",
(cfg.kind, int(catalog_id), sk),
)
continue
cur.execute(
"""
INSERT INTO catalog_prompt_slots (catalog_kind, catalog_id, slot_key, content, updated_at)
VALUES (%s, %s, %s, %s, NOW())
ON CONFLICT (catalog_kind, catalog_id, slot_key)
DO UPDATE SET content = EXCLUDED.content, updated_at = NOW()
""",
(cfg.kind, int(catalog_id), sk, content),
)
return get_catalog_entry_slots(cur, cfg.kind, catalog_id)
def _render_dimension_section(
label_de: str,
name: str,
slot_values: Mapping[str, str],
*,
slot_keys: Sequence[str],
) -> Optional[str]:
parts: List[str] = [f"### {label_de}{name}"]
labels = {
"description": "Beschreibung",
"hints_on_progression": "Progressions-Hinweise",
"hints_on_path_qa": "QS-Hinweise",
"hints_on_exercise": "Übungsanlage",
"anti_patterns": "Vermeiden",
}
added = False
for sk in slot_keys:
text = str(slot_values.get(sk) or "").strip()
if not text:
continue
added = True
if sk == "description":
parts.append(text)
else:
parts.append(f"{labels.get(sk, sk)}: {text}")
if not added:
return None
return "\n".join(parts)
def _compose_guidance_block(
sections: List[str],
) -> str:
if not sections:
return ""
return "## Katalog-Kontext (Didaktik & Bewertung)\n\n" + "\n\n".join(sections)
def resolve_catalog_prompt_variables(
cur,
catalog: Optional[ProgressionPlanningCatalogContext],
*,
slug: Optional[str] = None,
) -> Dict[str, Any]:
"""
Liefert Mustache-Strings + Metadaten.
Returns dict mit allen {{kind_slot}} Keys, catalog_guidance_block, catalog_context_json,
has_catalog_guidance (bool), active_slots (list).
"""
variables = empty_catalog_variables()
meta: Dict[str, Any] = {
"active_slots": [],
"audit": {},
}
if cur is None or not catalog_context_has_items(catalog):
variables["catalog_context_json"] = ""
return {**variables, **meta}
profile = GUIDANCE_PROFILE_BY_SLUG.get((slug or "").strip().lower(), GUIDANCE_BLOCK_SLOTS)
sections: List[str] = []
audit: Dict[str, Any] = {}
has_any = False
active_slots: List[str] = []
for cfg in CATALOG_KINDS:
items = getattr(catalog, cfg.context_attr, None) or []
active = pick_active_catalog_item(items)
if not active:
continue
row = _load_catalog_row(cur, cfg.table, active.id)
if not row:
continue
stored = _load_slots_for_entry(cur, cfg.kind, row["id"]) if _slot_types_table_ready(cur) else {}
slot_values: Dict[str, str] = {}
for sk in SLOT_KEYS:
if sk == "description":
slot_values[sk] = stored.get("description") or row.get("description") or ""
else:
slot_values[sk] = stored.get(sk, "")
pk = placeholder_key(cfg.kind, sk)
variables[pk] = slot_values[sk]
if slot_values[sk].strip() and sk in LLM_SLOT_KEYS:
has_any = True
active_slots.append(pk)
audit[cfg.context_attr] = {
"catalog_kind": cfg.kind,
"id": row["id"],
"name": row["name"],
"is_primary": bool(active.is_primary),
"weight": float(active.weight),
"filled_slots": [k for k in LLM_SLOT_KEYS if slot_values.get(k, "").strip()],
}
section = _render_dimension_section(cfg.label_de, row["name"], slot_values, slot_keys=profile)
if section:
sections.append(section)
variables["catalog_guidance_block"] = _compose_guidance_block(sections)
ctx_json = json.dumps(audit, ensure_ascii=False, separators=(",", ":"))
variables["catalog_context_json"] = f"Katalog-Audit: {ctx_json}" if audit else ""
variables["has_catalog_guidance"] = "true" if has_any else ""
return {
**variables,
"active_slots": active_slots,
"audit": audit,
}
def get_rematch_guard_for_catalog(
cur,
catalog: Optional[ProgressionPlanningCatalogContext],
) -> Optional[str]:
"""Erste passende rematch_guard entlang der Dimensions-Priorität."""
if cur is None or not catalog_context_has_items(catalog):
return None
for cfg in CATALOG_KINDS:
items = getattr(catalog, cfg.context_attr, None) or []
active = pick_active_catalog_item(items)
if not active:
continue
stored = _load_slots_for_entry(cur, cfg.kind, active.id)
guard = (stored.get("rematch_guard") or "").strip()
if guard:
return guard
return None
# Abwärtskompatibilität H1-API
def build_catalog_guidance_for_prompt(
cur,
catalog: Optional[ProgressionPlanningCatalogContext],
*,
slug: Optional[str] = None,
) -> Dict[str, Any]:
resolved = resolve_catalog_prompt_variables(cur, catalog, slug=slug)
return {
"catalog_guidance_block": resolved.get("catalog_guidance_block", ""),
"catalog_context_json": resolved.get("catalog_context_json", ""),
"has_catalog_guidance": resolved.get("has_catalog_guidance") == "true",
"snippet_keys": list(resolved.get("active_slots") or []),
"variables": {k: str(resolved.get(k) or "") for k in all_placeholder_keys()},
}
__all__ = [
"CATALOG_KINDS",
"GUIDANCE_PROFILE_BY_SLUG",
"SLOT_KEYS",
"build_catalog_guidance_for_prompt",
"empty_catalog_variables",
"get_catalog_entry_slots",
"get_rematch_guard_for_catalog",
"list_slot_type_definitions",
"pick_active_catalog_item",
"placeholder_key",
"all_placeholder_keys",
"resolve_catalog_prompt_variables",
"upsert_catalog_entry_slots",
]

View File

@ -243,7 +243,7 @@ def read_root():
return out
# Register routers
from routers import auth, profiles, exercises, exercise_progression_graphs, clubs, club_memberships, club_join_requests, club_creation_requests, admin_users, admin_user_content, admin_rights, me_entitlements, platform_media_storage, media_assets, skills, skill_profiles, training_planning, planning_exercise_suggest, dashboard, training_modules, training_framework_programs, catalogs, maturity_models, matrix_stack_bundle, matrix_editor, import_wiki, import_wiki_admin, legal_documents, content_reports, ai_prompts_admin, ai_skill_retrieval_admin, exercise_enrichment_admin
from routers import auth, profiles, exercises, exercise_progression_graphs, clubs, club_memberships, club_join_requests, club_creation_requests, admin_users, admin_user_content, admin_rights, me_entitlements, platform_media_storage, media_assets, skills, skill_profiles, training_planning, planning_exercise_suggest, dashboard, training_modules, training_framework_programs, catalogs, catalog_prompt_slots, maturity_models, matrix_stack_bundle, matrix_editor, import_wiki, import_wiki_admin, legal_documents, content_reports, ai_prompts_admin, ai_skill_retrieval_admin, exercise_enrichment_admin
app.include_router(auth.router)
app.include_router(profiles.router)
@ -269,6 +269,7 @@ app.include_router(dashboard.router)
app.include_router(training_modules.router)
app.include_router(training_framework_programs.router)
app.include_router(catalogs.router)
app.include_router(catalog_prompt_slots.router)
app.include_router(maturity_models.router)
app.include_router(matrix_stack_bundle.router)
app.include_router(matrix_editor.router)

View File

@ -0,0 +1,172 @@
-- Migration 091: Planungs-KI H1 — Katalog-Guidance-Platzhalter in Progressions-Prompts
UPDATE ai_prompts
SET template = $t$Du bist Assistent für Kampfsport-Trainer und prüfst einen vorgeschlagenen Übungspfad.
Ziel-Anfrage: {{goal_query}}
Semantic Brief: {{semantic_brief_json}}
Schritte (JSON): {{steps_json}}
Erkannte Lücken: {{gaps_json}}
Eingefügte Brücken: {{bridge_inserts_json}}
{{catalog_guidance_block}}
{{catalog_context_json}}
Wichtig: Wenn Katalog-Kontext gesetzt ist, haben dessen QS-Kriterien Vorrang vor allgemeinen Technik-/Wettkampf-Maßstäben.
Prüfe:
1. Deckt der Pfad das Hauptthema der Anfrage ab (nicht nur Oberbegriffe)?
2. Ist die Reihenfolge didaktisch sinnvoll (Einstieg Vertiefung Ziel)?
3. Sind Sprünge zwischen benachbarten Schritten zu groß?
4. Sind Brücken-Übungen sinnvoll oder überflüssig?
5. Fehlen wichtige Zwischenschritte (Kraft, Geschwindigkeit, Anwendung, Perfektion)?
6. Gibt es Schritte ohne Bezug zum Hauptthema (z. B. reine Kraftübungen bei einer Technik)?
Wenn die Reihenfolge verbessert werden sollte: ordered_step_indices = Permutation der aktuellen 0-basierten Schritt-Indizes (beste didaktische Reihenfolge).
Nur Indizes aus dem steps_json verwenden Länge muss exakt der Schrittzahl entsprechen.
Wenn wichtige Zwischenschritte fehlen oder Schritte themenfremd sind: suggested_new_exercises mit konkreten Übungs-Ideen (Titel + Kurzskizze), jeweils mit insert_after_step_index (0-basiert: nach welchem Schritt einfügen).
Antworte NUR mit JSON:
{
"overall_ok": true,
"quality_score": 0.85,
"topic_coverage": "Kurz: wie gut das Hauptthema abgedeckt ist",
"ordered_step_indices": [0, 1, 2, 3],
"issues": [""],
"sequence_notes": [""],
"recommendations": [""],
"suggested_new_exercises": [
{
"title_hint": "Mae Geri Kraftentwicklung am Sandsack",
"sketch": "Gezielte Kraft- und Schnelligkeitsentwicklung für Mae Geri …",
"phase": "vertiefung",
"insert_after_step_index": 2,
"rationale": "Schließt Lücke zwischen Grundlagen und Gleichgewichtstritt"
}
]
}$t$,
default_template = $t$Du bist Assistent für Kampfsport-Trainer und prüfst einen vorgeschlagenen Übungspfad.
Ziel-Anfrage: {{goal_query}}
Semantic Brief: {{semantic_brief_json}}
Schritte (JSON): {{steps_json}}
Erkannte Lücken: {{gaps_json}}
Eingefügte Brücken: {{bridge_inserts_json}}
{{catalog_guidance_block}}
{{catalog_context_json}}
Wichtig: Wenn Katalog-Kontext gesetzt ist, haben dessen QS-Kriterien Vorrang vor allgemeinen Technik-/Wettkampf-Maßstäben.
Prüfe:
1. Deckt der Pfad das Hauptthema der Anfrage ab (nicht nur Oberbegriffe)?
2. Ist die Reihenfolge didaktisch sinnvoll (Einstieg Vertiefung Ziel)?
3. Sind Sprünge zwischen benachbarten Schritten zu groß?
4. Sind Brücken-Übungen sinnvoll oder überflüssig?
5. Fehlen wichtige Zwischenschritte (Kraft, Geschwindigkeit, Anwendung, Perfektion)?
6. Gibt es Schritte ohne Bezug zum Hauptthema (z. B. reine Kraftübungen bei einer Technik)?
Wenn die Reihenfolge verbessert werden sollte: ordered_step_indices = Permutation der aktuellen 0-basierten Schritt-Indizes (beste didaktische Reihenfolge).
Nur Indizes aus dem steps_json verwenden Länge muss exakt der Schrittzahl entsprechen.
Wenn wichtige Zwischenschritte fehlen oder Schritte themenfremd sind: suggested_new_exercises mit konkreten Übungs-Ideen (Titel + Kurzskizze), jeweils mit insert_after_step_index (0-basiert: nach welchem Schritt einfügen).
Antworte NUR mit JSON:
{
"overall_ok": true,
"quality_score": 0.85,
"topic_coverage": "Kurz: wie gut das Hauptthema abgedeckt ist",
"ordered_step_indices": [0, 1, 2, 3],
"issues": [""],
"sequence_notes": [""],
"recommendations": [""],
"suggested_new_exercises": [
{
"title_hint": "Mae Geri Kraftentwicklung am Sandsack",
"sketch": "Gezielte Kraft- und Schnelligkeitsentwicklung für Mae Geri …",
"phase": "vertiefung",
"insert_after_step_index": 2,
"rationale": "Schließt Lücke zwischen Grundlagen und Gleichgewichtstritt"
}
]
}$t$
WHERE slug = 'planning_exercise_path_qa';
UPDATE ai_prompts
SET template = $t$Du bist Assistent für Kampfsport-Trainer und analysierst eine Anfrage für einen Progressionsgraphen.
Anfrage: {{goal_query}}
Semantic Brief: {{semantic_brief_json}}
{{catalog_guidance_block}}
Wichtig: Keine Gruppenanalyse nur didaktischer Pfad für die Technik/das Thema.
Antworte NUR mit JSON:
{
"primary_topic": "Mae Geri",
"start_assumption": "Welche Voraussetzungen werden für den Einstieg angenommen",
"target_state": "Konkreter Zielzustand der Progression",
"success_criteria": ["messbare Kriterien"],
"constraints": { "partner_required": false }
}$t$,
default_template = template
WHERE slug = 'planning_progression_goal_analysis';
UPDATE ai_prompts
SET template = $t$Du bist Assistent für Kampfsport-Trainer und erstellst eine didaktische Roadmap für einen Progressionsgraphen.
Anfrage: {{goal_query}}
Zielanalyse: {{goal_analysis_json}}
Semantic Brief: {{semantic_brief_json}}
Anzahl Major Steps (N): {{max_steps}}
{{catalog_guidance_block}}
{{catalog_context_json}}
Erzeuge zuerst 812 micro_objectives (phase, title, weight, depends_on), dann konsolidiere auf genau N major_steps.
Phasen: einstieg, grundlage, vertiefung, anwendung, perfektion in sinnvoller Reihenfolge (Grundlagen vor Perfektion).
Beachte Katalog-Roadmap-Hinweise, falls gesetzt.
Antworte NUR mit JSON:
{
"micro_objectives": [
{ "id": "m1", "phase": "grundlage", "title": "", "weight": 0.9, "depends_on": [] }
],
"major_steps": [
{ "index": 0, "phase": "grundlage", "learning_goal": "", "consolidates": ["m1","m2"], "rationale": "" }
],
"consolidation_notes": [""]
}$t$,
default_template = template
WHERE slug = 'planning_progression_roadmap';
UPDATE ai_prompts
SET template = $t$Du bist Assistent für Kampfsport-Trainer und spezifizierst didaktische Stufen eines Progressionsgraphen.
Anfrage: {{goal_query}}
Zielanalyse: {{goal_analysis_json}}
Major Steps: {{major_steps_json}}
{{catalog_guidance_block}}
{{catalog_context_json}}
Für jeden Major Step: messbares Lernziel, load_profile (z. B. koordination, präzision, kraft), exercise_type (kihon_einzel, partner_drill, kombination, kraft_auxiliary), success_criteria, anti_patterns (z. B. reine Kraft ohne Technikbezug).
Beachte Katalog-QS-Kriterien und Anti-Patterns, falls gesetzt.
Antworte NUR mit JSON:
{
"stage_specs": [
{
"major_step_index": 0,
"learning_goal": "",
"load_profile": ["koordination", "gleichgewicht"],
"exercise_type": "kihon_einzel",
"success_criteria": [""],
"anti_patterns": [""]
}
]
}$t$,
default_template = template
WHERE slug = 'planning_progression_stage_spec';

View File

@ -0,0 +1,176 @@
-- Migration 092: Katalog-Prompt-Slots (H2) — Slot-Typ-Vokabular + Werte pro Stammdaten-Zeile
CREATE TABLE IF NOT EXISTS catalog_prompt_slot_types (
slot_key VARCHAR(64) PRIMARY KEY,
display_name VARCHAR(200) NOT NULL,
description TEXT,
applicable_kinds TEXT[] NOT NULL DEFAULT '{}',
sort_order INT DEFAULT 99,
for_llm BOOLEAN NOT NULL DEFAULT true,
for_code BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS catalog_prompt_slots (
id SERIAL PRIMARY KEY,
catalog_kind VARCHAR(32) NOT NULL,
catalog_id INT NOT NULL,
slot_key VARCHAR(64) NOT NULL REFERENCES catalog_prompt_slot_types(slot_key) ON DELETE CASCADE,
content TEXT NOT NULL DEFAULT '',
updated_at TIMESTAMP DEFAULT NOW(),
UNIQUE (catalog_kind, catalog_id, slot_key)
);
CREATE INDEX IF NOT EXISTS idx_catalog_prompt_slots_kind_id
ON catalog_prompt_slots (catalog_kind, catalog_id);
INSERT INTO catalog_prompt_slot_types (slot_key, display_name, description, applicable_kinds, sort_order, for_llm, for_code)
VALUES
(
'description',
'Allgemeine Beschreibung',
'Fachliche Einordnung des Katalog-Eintrags für Planungs-KI.',
ARRAY['focus_area', 'training_type', 'target_group', 'style_direction'],
10,
true,
false
),
(
'hints_on_progression',
'Hinweise Progressionsgraph',
'Didaktik für Roadmap, Major Steps und Stufenspezifikation.',
ARRAY['focus_area', 'training_type', 'target_group', 'style_direction'],
20,
true,
false
),
(
'hints_on_exercise',
'Hinweise Übungsanlage',
'Kontext für Gap-Fill, Übungs-KI und Schnellanlage.',
ARRAY['focus_area', 'training_type', 'target_group', 'style_direction'],
30,
true,
false
),
(
'hints_on_path_qa',
'Hinweise Pfad-QS',
'Bewertungsmaßstäbe für Pfad-Qualitätssicherung.',
ARRAY['focus_area', 'training_type', 'target_group', 'style_direction'],
40,
true,
false
),
(
'anti_patterns',
'Anti-Patterns',
'Explizite Fehlbewertungen vermeiden.',
ARRAY['focus_area', 'training_type', 'target_group', 'style_direction'],
50,
true,
false
),
(
'rematch_guard',
'Rematch-Guard',
'Wann kein Auto-Rematch sinnvoll ist (primär Code-Logik).',
ARRAY['focus_area', 'training_type', 'target_group', 'style_direction'],
60,
false,
true
)
ON CONFLICT (slot_key) DO NOTHING;
-- Seed aus H1-Registry (Name-Match auf Stammdaten)
INSERT INTO catalog_prompt_slots (catalog_kind, catalog_id, slot_key, content)
SELECT 'focus_area', fa.id, 'description',
'Planung zielt auf Prävention, Deeskalation, Grenzen und sichere Übungsformen — nicht auf Wettkampf-Perfektion oder Technik-Show.'
FROM focus_areas fa WHERE fa.name ILIKE 'Gewaltschutz'
ON CONFLICT (catalog_kind, catalog_id, slot_key) DO UPDATE SET content = EXCLUDED.content, updated_at = NOW();
INSERT INTO catalog_prompt_slots (catalog_kind, catalog_id, slot_key, content)
SELECT 'focus_area', fa.id, 'hints_on_path_qa',
'Gute Pfade bauen Sicherheit, Kommunikation und Alternativen auf; „Lücken“ sind fehlende Deeskalations- oder Rollenspiel-Stufen, nicht fehlende Kick-Varianten.'
FROM focus_areas fa WHERE fa.name ILIKE 'Gewaltschutz'
ON CONFLICT (catalog_kind, catalog_id, slot_key) DO UPDATE SET content = EXCLUDED.content, updated_at = NOW();
INSERT INTO catalog_prompt_slots (catalog_kind, catalog_id, slot_key, content)
SELECT 'focus_area', fa.id, 'anti_patterns',
'Nicht nach Kumite-Tiefe, Explosivität oder Wettkampf-Belastung bewerten.'
FROM focus_areas fa WHERE fa.name ILIKE 'Gewaltschutz'
ON CONFLICT (catalog_kind, catalog_id, slot_key) DO UPDATE SET content = EXCLUDED.content, updated_at = NOW();
INSERT INTO catalog_prompt_slots (catalog_kind, catalog_id, slot_key, content)
SELECT 'training_type', tt.id, 'description',
'Partizipation, Verständlichkeit, Freude am Bewegen; weniger maximale Spezialisierung.'
FROM training_types tt WHERE tt.name ILIKE 'Breitensport'
ON CONFLICT (catalog_kind, catalog_id, slot_key) DO UPDATE SET content = EXCLUDED.content, updated_at = NOW();
INSERT INTO catalog_prompt_slots (catalog_kind, catalog_id, slot_key, content)
SELECT 'training_type', tt.id, 'hints_on_path_qa',
'Hohe OK-Rate bei moderatem Schwierigkeitsanstieg; „Perfektion“-Stufen nur optional, nicht als Pflicht-Lücke.'
FROM training_types tt WHERE tt.name ILIKE 'Breitensport'
ON CONFLICT (catalog_kind, catalog_id, slot_key) DO UPDATE SET content = EXCLUDED.content, updated_at = NOW();
INSERT INTO catalog_prompt_slots (catalog_kind, catalog_id, slot_key, content)
SELECT 'training_type', tt.id, 'rematch_guard',
'Keine leeren Slots erzwingen, nur um eine Leistungs-Perfektionsstufe zu füllen.'
FROM training_types tt WHERE tt.name ILIKE 'Breitensport'
ON CONFLICT (catalog_kind, catalog_id, slot_key) DO UPDATE SET content = EXCLUDED.content, updated_at = NOW();
INSERT INTO catalog_prompt_slots (catalog_kind, catalog_id, slot_key, content)
SELECT 'target_group', tg.id, 'description',
'Kinder: kurze Einheiten, spielerische Einstiege, Sicherheit und altersgerechte Komplexität.'
FROM target_groups tg WHERE tg.name ILIKE 'Kinder'
ON CONFLICT (catalog_kind, catalog_id, slot_key) DO UPDATE SET content = EXCLUDED.content, updated_at = NOW();
INSERT INTO catalog_prompt_slots (catalog_kind, catalog_id, slot_key, content)
SELECT 'target_group', tg.id, 'hints_on_path_qa',
'Didaktik ohne Überforderung; klare Regeln und Sicherheit vor Perfektion; Lücken bei Spiel-/Rollenelementen wichtiger als Wettkampftiefe.'
FROM target_groups tg WHERE tg.name ILIKE 'Kinder'
ON CONFLICT (catalog_kind, catalog_id, slot_key) DO UPDATE SET content = EXCLUDED.content, updated_at = NOW();
INSERT INTO catalog_prompt_slots (catalog_kind, catalog_id, slot_key, content)
SELECT 'target_group', tg.id, 'anti_patterns',
'Keine Erwachsenen-Wettkampf-Perfektion als QS-Maßstab.'
FROM target_groups tg WHERE tg.name ILIKE 'Kinder'
ON CONFLICT (catalog_kind, catalog_id, slot_key) DO UPDATE SET content = EXCLUDED.content, updated_at = NOW();
INSERT INTO catalog_prompt_slots (catalog_kind, catalog_id, slot_key, content)
SELECT 'target_group', tg.id, 'description',
'Leistungsgruppe: höhere Anspruchskurven und Spezialisierung sind fachlich passend.'
FROM target_groups tg WHERE tg.name ILIKE 'Leistungssportler'
ON CONFLICT (catalog_kind, catalog_id, slot_key) DO UPDATE SET content = EXCLUDED.content, updated_at = NOW();
INSERT INTO catalog_prompt_slots (catalog_kind, catalog_id, slot_key, content)
SELECT 'target_group', tg.id, 'hints_on_path_qa',
'Höhere Anspruchskurven, Belastungs- und Kombinationsprogressionen sind relevant; Lücken in Spezialisierung können echte Hinweise sein.'
FROM target_groups tg WHERE tg.name ILIKE 'Leistungssportler'
ON CONFLICT (catalog_kind, catalog_id, slot_key) DO UPDATE SET content = EXCLUDED.content, updated_at = NOW();
INSERT INTO catalog_prompt_slots (catalog_kind, catalog_id, slot_key, content)
SELECT 'style_direction', sd.id, 'description',
'Shotokan-Linie: klare Kihon-Struktur, Hüft- und Standarbeit als wiederkehrende Qualitätsanker.'
FROM style_directions sd WHERE sd.name ILIKE 'Shotokan'
ON CONFLICT (catalog_kind, catalog_id, slot_key) DO UPDATE SET content = EXCLUDED.content, updated_at = NOW();
INSERT INTO catalog_prompt_slots (catalog_kind, catalog_id, slot_key, content)
SELECT 'style_direction', sd.id, 'hints_on_progression',
'Nuancen in Stellung und Hüfttechnik, kein neuer Planungstyp.'
FROM style_directions sd WHERE sd.name ILIKE 'Shotokan'
ON CONFLICT (catalog_kind, catalog_id, slot_key) DO UPDATE SET content = EXCLUDED.content, updated_at = NOW();
INSERT INTO catalog_prompt_slots (catalog_kind, catalog_id, slot_key, content)
SELECT 'training_type', tt.id, 'description',
'Wettkampforientiertes Training mit höherer Anspruchskurve und belastungsnahen Phasen.'
FROM training_types tt WHERE tt.name ILIKE 'Wettkampf'
ON CONFLICT (catalog_kind, catalog_id, slot_key) DO UPDATE SET content = EXCLUDED.content, updated_at = NOW();
INSERT INTO catalog_prompt_slots (catalog_kind, catalog_id, slot_key, content)
SELECT 'training_type', tt.id, 'hints_on_path_qa',
'Spezialisierung, Kombination und Belastung unter Druck sind relevant; Lücken in Anwendungs- oder Perfektionsphasen können echte Hinweise sein.'
FROM training_types tt WHERE tt.name ILIKE 'Wettkampf'
ON CONFLICT (catalog_kind, catalog_id, slot_key) DO UPDATE SET content = EXCLUDED.content, updated_at = NOW();

View File

@ -0,0 +1,199 @@
-- Migration 093: Planungs-KI — granulare Katalog-Slot-Platzhalter in Prompt-Templates
UPDATE ai_prompts
SET template = $t$Du bist Assistent für Kampfsport-Trainer und prüfst einen vorgeschlagenen Übungspfad.
Ziel-Anfrage: {{goal_query}}
Semantic Brief: {{semantic_brief_json}}
Schritte (JSON): {{steps_json}}
Erkannte Lücken: {{gaps_json}}
Eingefügte Brücken: {{bridge_inserts_json}}
Katalog-Kontext für Bewertung (Trainer-Auswahl leere Zeilen ignorieren):
Primärfokus:
{{focus_area_description}}
QS: {{focus_area_hints_on_path_qa}}
Vermeiden: {{focus_area_anti_patterns}}
Trainingsstil:
{{training_type_description}}
QS: {{training_type_hints_on_path_qa}}
Zielgruppe:
{{target_group_description}}
QS: {{target_group_hints_on_path_qa}}
Stilrichtung:
{{style_direction_description}}
QS: {{style_direction_hints_on_path_qa}}
{{catalog_context_json}}
Wichtig: Wenn Katalog-Slots gesetzt sind, haben diese Vorrang vor allgemeinen Technik-/Wettkampf-Maßstäben.
Prüfe:
1. Deckt der Pfad das Hauptthema der Anfrage ab (nicht nur Oberbegriffe)?
2. Ist die Reihenfolge didaktisch sinnvoll (Einstieg Vertiefung Ziel)?
3. Sind Sprünge zwischen benachbarten Schritten zu groß?
4. Sind Brücken-Übungen sinnvoll oder überflüssig?
5. Fehlen wichtige Zwischenschritte gemäß Katalog-QS-Hinweisen, nicht pauschal Perfektion?
6. Gibt es Schritte ohne Bezug zum Hauptthema?
Wenn die Reihenfolge verbessert werden sollte: ordered_step_indices = Permutation der aktuellen 0-basierten Schritt-Indizes.
Wenn wichtige Zwischenschritte fehlen: suggested_new_exercises mit Titel + Kurzskizze und insert_after_step_index.
Antworte NUR mit JSON:
{
"overall_ok": true,
"quality_score": 0.85,
"topic_coverage": "Kurz: wie gut das Hauptthema abgedeckt ist",
"ordered_step_indices": [0, 1, 2, 3],
"issues": [""],
"sequence_notes": [""],
"recommendations": [""],
"suggested_new_exercises": []
}$t$,
default_template = template
WHERE slug = 'planning_exercise_path_qa';
UPDATE ai_prompts
SET template = $t$Du bist Assistent für Kampfsport-Trainer und analysierst eine Anfrage für einen Progressionsgraphen.
Anfrage: {{goal_query}}
Semantic Brief: {{semantic_brief_json}}
Katalog-Kontext (Primärfokus / Trainingsstil / Zielgruppe / Stil leere Zeilen ignorieren):
Primärfokus: {{focus_area_description}}
Progression: {{focus_area_hints_on_progression}}
Trainingsstil: {{training_type_description}}
Progression: {{training_type_hints_on_progression}}
Zielgruppe: {{target_group_description}}
Stilrichtung: {{style_direction_description}}
Wichtig: Keine Gruppenanalyse nur didaktischer Pfad. Katalog-Hinweise beachten.
Antworte NUR mit JSON:
{
"primary_topic": "Mae Geri",
"start_assumption": "Welche Voraussetzungen werden für den Einstieg angenommen",
"target_state": "Konkreter Zielzustand der Progression",
"success_criteria": ["messbare Kriterien"],
"constraints": { "partner_required": false }
}$t$,
default_template = template
WHERE slug = 'planning_progression_goal_analysis';
UPDATE ai_prompts
SET template = $t$Du bist Assistent für Kampfsport-Trainer und erstellst eine didaktische Roadmap für einen Progressionsgraphen.
Anfrage: {{goal_query}}
Zielanalyse: {{goal_analysis_json}}
Semantic Brief: {{semantic_brief_json}}
Anzahl Major Steps (N): {{max_steps}}
Katalog-Kontext für Stufenlogik:
Primärfokus:
{{focus_area_description}}
Roadmap: {{focus_area_hints_on_progression}}
Vermeiden: {{focus_area_anti_patterns}}
Trainingsstil:
{{training_type_description}}
Roadmap: {{training_type_hints_on_progression}}
Zielgruppe:
{{target_group_description}}
Roadmap: {{target_group_hints_on_progression}}
Stilrichtung:
{{style_direction_description}}
Roadmap: {{style_direction_hints_on_progression}}
{{catalog_context_json}}
Erzeuge zuerst 812 micro_objectives, dann konsolidiere auf genau N major_steps.
Phasen: einstieg, grundlage, vertiefung, anwendung, perfektion Katalog-Roadmap-Hinweise beachten.
Antworte NUR mit JSON:
{
"micro_objectives": [
{ "id": "m1", "phase": "grundlage", "title": "", "weight": 0.9, "depends_on": [] }
],
"major_steps": [
{ "index": 0, "phase": "grundlage", "learning_goal": "", "consolidates": ["m1","m2"], "rationale": "" }
],
"consolidation_notes": [""]
}$t$,
default_template = template
WHERE slug = 'planning_progression_roadmap';
UPDATE ai_prompts
SET template = $t$Du bist Assistent für Kampfsport-Trainer und spezifizierst didaktische Stufen eines Progressionsgraphen.
Anfrage: {{goal_query}}
Zielanalyse: {{goal_analysis_json}}
Major Steps: {{major_steps_json}}
Intent-Kontext: {{intent_context_json}}
Semantic Brief: {{semantic_brief_json}}
Katalog-Kontext je Stufe:
Primärfokus Progression: {{focus_area_hints_on_progression}}
Primärfokus Vermeiden: {{focus_area_anti_patterns}}
Trainingsstil Progression: {{training_type_hints_on_progression}}
Trainingsstil Vermeiden: {{training_type_anti_patterns}}
Zielgruppe Progression: {{target_group_hints_on_progression}}
Zielgruppe Vermeiden: {{target_group_anti_patterns}}
Stilrichtung Progression: {{style_direction_hints_on_progression}}
{{catalog_context_json}}
Für jeden Major Step: messbares Lernziel, load_profile, exercise_type, success_criteria, anti_patterns Katalog-Slots beachten.
Antworte NUR mit JSON:
{
"stage_specs": [
{
"major_step_index": 0,
"learning_goal": "",
"load_profile": ["koordination", "gleichgewicht"],
"exercise_type": "kihon_einzel",
"success_criteria": [""],
"anti_patterns": [""]
}
]
}$t$,
default_template = template
WHERE slug = 'planning_progression_stage_spec';
UPDATE ai_prompts
SET template = $t$Du bist Assistent für Kampfsport-Trainer und extrahierst Start, Ziel und Ergänzungen für einen Progressionsgraphen.
Anfrage: {{goal_query}}
Semantic Brief: {{semantic_brief_json}}
Trainer-Notizen: {{user_notes}}
Katalog-Einordnung:
Primärfokus: {{focus_area_description}}
Trainingsstil: {{training_type_description}}
Zielgruppe: {{target_group_description}}
Antworte NUR mit JSON:
{
"primary_topic": "",
"start_situation": "",
"target_state": "",
"roadmap_notes": "",
"extraction_notes": ""
}$t$,
default_template = template
WHERE slug = 'planning_progression_start_target';

View File

@ -0,0 +1,16 @@
"""
Katalog-Prompt-Snippets Abwärtskompatibilität (H1-Importpfade).
Implementierung: catalog_prompt_slots.py (H2).
"""
from catalog_prompt_slots import (
build_catalog_guidance_for_prompt,
get_rematch_guard_for_catalog,
pick_active_catalog_item,
)
__all__ = [
"build_catalog_guidance_for_prompt",
"get_rematch_guard_for_catalog",
"pick_active_catalog_item",
]

View File

@ -2082,6 +2082,7 @@ def _run_evaluate_only_path_qa(
semantic_brief: PlanningSemanticBrief,
steps: List[Dict[str, Any]],
roadmap_ctx: Optional[ProgressionRoadmapContext],
catalog_context: Optional[ProgressionPlanningCatalogContext] = None,
) -> Dict[str, Any]:
roadmap_first = roadmap_ctx is not None
gaps: List[Dict[str, Any]] = []
@ -2095,6 +2096,9 @@ def _run_evaluate_only_path_qa(
gap_fill_offers: List[Dict[str, Any]] = []
roadmap_qa_mode: Optional[str] = None
if catalog_context is None:
catalog_context = _resolve_planning_catalog_context(cur, body)
if body.include_path_qa:
if roadmap_first:
roadmap_qa_mode = "roadmap_first_lite"
@ -2115,6 +2119,7 @@ def _run_evaluate_only_path_qa(
steps=steps,
gaps=gaps,
bridge_inserts=bridge_inserts,
catalog=catalog_context,
)
off_topic_steps = detect_off_topic_steps(
@ -3828,6 +3833,7 @@ def suggest_progression_path(
roadmap_ctx: Optional[ProgressionRoadmapContext] = None
roadmap_edited = False
roadmap_structured = _roadmap_structured_from_body(body)
catalog_context = _resolve_planning_catalog_context(cur, body)
if body.roadmap_override is not None:
try:
@ -3852,6 +3858,7 @@ def suggest_progression_path(
cur=cur,
include_llm_start_target=body.include_llm_start_target,
structured=roadmap_structured,
catalog=catalog_context,
)
progression_roadmap = progression_roadmap_to_api_dict(roadmap_ctx)
elif include_roadmap:
@ -3863,6 +3870,7 @@ def suggest_progression_path(
include_llm_roadmap=body.include_llm_roadmap,
include_llm_start_target=body.include_llm_start_target,
structured=roadmap_structured,
catalog=catalog_context,
)
progression_roadmap = progression_roadmap_to_api_dict(roadmap_ctx)
@ -3923,6 +3931,7 @@ def suggest_progression_path(
semantic_brief=semantic_brief,
steps=eval_steps,
roadmap_ctx=roadmap_ctx,
catalog_context=catalog_context,
)
return {
"goal_query": goal_query,
@ -3953,7 +3962,7 @@ def suggest_progression_path(
start_situation=body.start_situation,
target_state=body.target_state,
roadmap_notes=body.roadmap_notes,
catalog_context=_resolve_planning_catalog_context(cur, body),
catalog_context=catalog_context,
)
path_skill_expectations: Optional[Dict[str, Any]] = None
if roadmap_ctx and roadmap_ctx.goal_analysis:
@ -4152,6 +4161,7 @@ def suggest_progression_path(
steps=steps,
gaps=gaps,
bridge_inserts=bridge_inserts,
catalog=catalog_context,
)
if (
@ -4240,6 +4250,7 @@ def suggest_progression_path(
steps=steps,
gaps=gaps,
bridge_inserts=bridge_inserts,
catalog=catalog_context,
)
llm_gap_specs = parse_llm_suggested_new_exercises(

View File

@ -9,6 +9,8 @@ import re
from typing import Any, Callable, Dict, List, Mapping, Optional, Sequence, Set, Tuple
from ai_prompt_runtime import AiPromptUnavailableError, load_and_render_ai_prompt
from planning_catalog_context import ProgressionPlanningCatalogContext
from planning_prompt_variables import merge_planning_prompt_variables
from exercise_ai import strip_html_to_plain
from openrouter_chat import (
effective_openrouter_model_for_prompt_row,
@ -320,6 +322,7 @@ def try_llm_qa_progression_path(
steps: Sequence[Mapping[str, Any]],
gaps: Sequence[Mapping[str, Any]],
bridge_inserts: Sequence[Mapping[str, Any]],
catalog: Optional[ProgressionPlanningCatalogContext] = None,
) -> Tuple[Optional[Dict[str, Any]], bool]:
api_key, _ = normalize_openrouter_env()
if not api_key or len(steps) < 2:
@ -354,13 +357,18 @@ def try_llm_qa_progression_path(
}
)
variables = {
variables = merge_planning_prompt_variables(
cur,
{
"goal_query": goal_query or "",
"semantic_brief_json": json.dumps(brief_to_summary_dict(brief), ensure_ascii=False),
"steps_json": json.dumps(step_payload, ensure_ascii=False),
"gaps_json": json.dumps(list(gaps), ensure_ascii=False),
"bridge_inserts_json": json.dumps(list(bridge_inserts), ensure_ascii=False),
}
},
catalog=catalog,
slug="planning_exercise_path_qa",
)
try:
prow, rendered = load_and_render_ai_prompt(cur, "planning_exercise_path_qa", variables)

View File

@ -18,6 +18,8 @@ from typing import Any, Dict, List, Mapping, Optional, Sequence, Tuple
from pydantic import BaseModel, Field, ValidationError
from ai_prompt_runtime import AiPromptUnavailableError, load_and_render_ai_prompt
from planning_catalog_context import ProgressionPlanningCatalogContext
from planning_prompt_variables import merge_planning_prompt_variables
from openrouter_chat import (
effective_openrouter_model_for_prompt_row,
normalize_openrouter_env,
@ -190,12 +192,20 @@ def _run_prompt_json(
cur,
slug: str,
variables: Dict[str, str],
*,
catalog: Optional[ProgressionPlanningCatalogContext] = None,
) -> Optional[Dict[str, Any]]:
api_key, _ = normalize_openrouter_env()
if not api_key or cur is None:
return None
merged = merge_planning_prompt_variables(
cur,
variables,
catalog=catalog,
slug=slug,
)
try:
prow, rendered = load_and_render_ai_prompt(cur, slug, variables)
prow, rendered = load_and_render_ai_prompt(cur, slug, merged)
model = effective_openrouter_model_for_prompt_row(prow)
raw = openrouter_chat_completion(api_key=api_key, model=model, user_content=rendered.text)
return _extract_json_object(raw)
@ -212,6 +222,7 @@ def try_llm_start_target_extract(
goal_query: str,
brief: PlanningSemanticBrief,
user_notes: str = "",
catalog: Optional[ProgressionPlanningCatalogContext] = None,
) -> Tuple[Optional[StartTargetExtractArtifact], bool]:
obj = _run_prompt_json(
cur,
@ -221,6 +232,7 @@ def try_llm_start_target_extract(
"semantic_brief_json": json.dumps(brief_to_summary_dict(brief), ensure_ascii=False),
"user_notes": (user_notes or "").strip(),
},
catalog=catalog,
)
if not obj:
return None, False
@ -236,6 +248,7 @@ def try_llm_goal_analysis(
*,
goal_query: str,
brief: PlanningSemanticBrief,
catalog: Optional[ProgressionPlanningCatalogContext] = None,
) -> Tuple[Optional[GoalAnalysisArtifact], bool]:
obj = _run_prompt_json(
cur,
@ -244,6 +257,7 @@ def try_llm_goal_analysis(
"goal_query": goal_query or "",
"semantic_brief_json": json.dumps(brief_to_summary_dict(brief), ensure_ascii=False),
},
catalog=catalog,
)
if not obj:
return None, False
@ -261,6 +275,7 @@ def try_llm_roadmap(
brief: PlanningSemanticBrief,
goal_analysis: GoalAnalysisArtifact,
max_steps: int,
catalog: Optional[ProgressionPlanningCatalogContext] = None,
) -> Tuple[Optional[RoadmapArtifact], bool]:
obj = _run_prompt_json(
cur,
@ -271,6 +286,7 @@ def try_llm_roadmap(
"goal_analysis_json": json.dumps(goal_analysis.model_dump(), ensure_ascii=False),
"max_steps": str(int(max_steps)),
},
catalog=catalog,
)
if not obj:
return None, False
@ -304,6 +320,7 @@ def try_llm_stage_specs(
major_steps: Sequence[MajorStep],
intent_context: Optional[Mapping[str, Any]] = None,
semantic_brief: Optional[PlanningSemanticBrief] = None,
catalog: Optional[ProgressionPlanningCatalogContext] = None,
) -> Tuple[Optional[List[StageSpecArtifact]], bool]:
obj = _run_prompt_json(
cur,
@ -318,6 +335,7 @@ def try_llm_stage_specs(
ensure_ascii=False,
),
},
catalog=catalog,
)
if not obj:
return None, False
@ -380,6 +398,7 @@ def resolve_roadmap_structured_input(
brief: PlanningSemanticBrief,
cur=None,
include_llm: bool = False,
catalog: Optional[ProgressionPlanningCatalogContext] = None,
) -> Tuple[RoadmapStructuredInput, StartTargetResolveMeta, Optional[StartTargetExtractArtifact]]:
"""Priorität je Feld: Trainer-Eingabe > LLM-Extraktion > Regex (von … bis …)."""
user = structured or RoadmapStructuredInput()
@ -395,6 +414,7 @@ def resolve_roadmap_structured_input(
goal_query=goal_query,
brief=brief,
user_notes=user_notes,
catalog=catalog,
)
parsed_start, parsed_target = parse_start_target_from_goal_query(goal_query)
@ -1068,6 +1088,7 @@ def run_start_target_resolve_only(
cur=None,
include_llm_start_target: bool = True,
structured: Optional[RoadmapStructuredInput] = None,
catalog: Optional[ProgressionPlanningCatalogContext] = None,
) -> ProgressionRoadmapContext:
"""Nur Start/Ziel/Ergänzungen auflösen — ohne Roadmap-Stufen (Review vor Major Steps)."""
brief = semantic_brief or build_semantic_brief(goal_query)
@ -1077,6 +1098,7 @@ def run_start_target_resolve_only(
brief=brief,
cur=cur,
include_llm=include_llm_start_target,
catalog=catalog,
)
topic_override = None
if llm_extract and (llm_extract.primary_topic or "").strip():
@ -1112,6 +1134,7 @@ def run_progression_roadmap_pipeline(
include_llm_roadmap: bool = False,
include_llm_start_target: bool = False,
structured: Optional[RoadmapStructuredInput] = None,
catalog: Optional[ProgressionPlanningCatalogContext] = None,
) -> ProgressionRoadmapContext:
"""Workflow-lite: Phase A → B → C. LLM über ai_prompts-Slugs; deterministischer Fallback."""
brief = semantic_brief or build_semantic_brief(goal_query)
@ -1121,6 +1144,7 @@ def run_progression_roadmap_pipeline(
brief=brief,
cur=cur,
include_llm=include_llm_start_target,
catalog=catalog,
)
parsed_start, parsed_target = parse_start_target_from_goal_query(goal_query)
llm_goal_query = _roadmap_llm_goal_block(
@ -1152,7 +1176,9 @@ def run_progression_roadmap_pipeline(
topic_override=topic_override,
)
if include_llm_roadmap and cur is not None:
llm_ga, ga_ok = try_llm_goal_analysis(cur, goal_query=llm_goal_query, brief=brief)
llm_ga, ga_ok = try_llm_goal_analysis(
cur, goal_query=llm_goal_query, brief=brief, catalog=catalog
)
if ga_ok and llm_ga:
goal_analysis = _merge_structured_into_goal_analysis(
llm_ga,
@ -1172,6 +1198,7 @@ def run_progression_roadmap_pipeline(
brief=brief,
goal_analysis=goal_analysis,
max_steps=max_steps,
catalog=catalog,
)
if rm_ok and llm_rm:
roadmap = llm_rm
@ -1234,6 +1261,7 @@ def run_progression_roadmap_pipeline(
major_steps=roadmap.major_steps,
intent_context=intent.to_api_dict(),
semantic_brief=brief,
catalog=catalog,
)
if spec_ok and llm_specs:
stage_specs = list(llm_specs)

View File

@ -0,0 +1,118 @@
"""
Zentrale Mustache-Variablen für Planungs-KI-Prompts.
Orchestratoren bauen domänenspezifische Basis-Variablen; dieses Modul merged
erweiterbare Provider (Katalog-Slots, später weitere Kontexte).
"""
from __future__ import annotations
from typing import Any, Callable, Dict, Mapping, Optional
from catalog_prompt_slots import all_placeholder_keys, empty_catalog_variables
from planning_catalog_context import ProgressionPlanningCatalogContext
PlanningPromptVariableProvider = Callable[..., Dict[str, str]]
def _catalog_slot_variables(
*,
cur,
catalog: Optional[ProgressionPlanningCatalogContext] = None,
slug: Optional[str] = None,
**_: Any,
) -> Dict[str, str]:
if cur is None or catalog is None:
return empty_catalog_variables()
from catalog_prompt_slots import resolve_catalog_prompt_variables
resolved = resolve_catalog_prompt_variables(cur, catalog, slug=slug)
return {k: str(resolved.get(k) or "") for k in all_placeholder_keys()}
_PLANNING_PROMPT_VARIABLE_PROVIDERS: tuple[PlanningPromptVariableProvider, ...] = (
_catalog_slot_variables,
)
def merge_planning_prompt_variables(
cur,
base_variables: Mapping[str, str],
*,
catalog: Optional[ProgressionPlanningCatalogContext] = None,
slug: Optional[str] = None,
) -> Dict[str, str]:
"""Merged Basis-Variablen mit allen registrierten Planungs-Providern."""
out = {str(k): "" if v is None else str(v) for k, v in base_variables.items()}
ctx: Dict[str, Any] = {"cur": cur, "catalog": catalog, "slug": slug}
for provider in _PLANNING_PROMPT_VARIABLE_PROVIDERS:
out.update(provider(**ctx))
return out
def planning_prompt_placeholder_catalog() -> dict:
"""Platzhalter-Katalog für Admin — Slot-Typ × Dimension + Aggregat."""
from catalog_prompt_slots import CATALOG_KINDS, SLOT_KEYS, placeholder_key
slot_labels = {
"description": "Allgemeine Beschreibung",
"hints_on_progression": "Hinweise Progressionsgraph / Stufen",
"hints_on_exercise": "Hinweise Übungsanlage / Gap-Fill",
"hints_on_path_qa": "Bewertungsmaßstäbe Pfad-QS",
"anti_patterns": "Anti-Patterns (Fehlbewertung vermeiden)",
"rematch_guard": "Rematch-Guard (primär Code, optional Prompt)",
}
kind_labels = {c.kind: c.label_de for c in CATALOG_KINDS}
slugs_common = [
"planning_exercise_path_qa",
"planning_progression_roadmap",
"planning_progression_stage_spec",
"planning_progression_goal_analysis",
"planning_progression_start_target",
]
defs = []
for cfg in CATALOG_KINDS:
for slot in SLOT_KEYS:
key = placeholder_key(cfg.kind, slot)
defs.append(
{
"key": key,
"placeholder": "{{" + key + "}}",
"description": (
f"{kind_labels.get(cfg.kind, cfg.kind)}"
f"{slot_labels.get(slot, slot)} (aktiver Eintrag aus planning_catalog_context)."
),
"used_by_slugs": slugs_common,
}
)
defs.extend(
[
{
"key": "catalog_guidance_block",
"placeholder": "{{catalog_guidance_block}}",
"description": "Aggregierter Markdown-Block aus aktiven Slots (slug-spezifisches Profil).",
"used_by_slugs": slugs_common,
},
{
"key": "catalog_context_json",
"placeholder": "{{catalog_context_json}}",
"description": "Audit-JSON der gewählten Katalog-Einträge und befüllten Slots.",
"used_by_slugs": slugs_common[:3],
},
{
"key": "has_catalog_guidance",
"placeholder": "{{has_catalog_guidance}}",
"description": "„true“ wenn mindestens ein LLM-Slot gesetzt; sonst leer.",
"used_by_slugs": slugs_common[:3],
},
]
)
return {"context": "planning", "placeholders": defs}
__all__ = [
"merge_planning_prompt_variables",
"planning_prompt_placeholder_catalog",
]

View File

@ -22,6 +22,7 @@ from ai_prompt_planning_preview import (
from ai_prompt_runtime import render_ai_prompt_template_for_row
from db import get_cursor, get_db, r2d
from prompt_resolver import exercise_placeholder_catalog
from planning_prompt_variables import planning_prompt_placeholder_catalog
router = APIRouter(tags=["admin_ai_prompts"])
@ -77,7 +78,12 @@ class AiPromptPreviewBody(ExerciseFormAiPromptContext):
@router.get("/api/admin/ai-prompts/catalog/placeholders")
def get_ai_prompt_placeholders_catalog(session: dict = Depends(_require_superadmin)):
return exercise_placeholder_catalog()
exercise = exercise_placeholder_catalog()
planning = planning_prompt_placeholder_catalog()
return {
"context": "all",
"placeholders": list(exercise.get("placeholders") or []) + list(planning.get("placeholders") or []),
}
@router.get("/api/admin/ai-prompts")

View File

@ -0,0 +1,94 @@
"""
API: Katalog-Prompt-Slots (Stammdaten × Slot-Typ).
"""
from __future__ import annotations
from typing import Any, Dict, Optional
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from auth import require_auth
from catalog_prompt_slots import (
CATALOG_KINDS,
get_catalog_entry_slots,
list_slot_type_definitions,
upsert_catalog_entry_slots,
)
from db import get_cursor, get_db
router = APIRouter(prefix="/api", tags=["catalog_prompt_slots"])
_VALID_KINDS = frozenset(c.kind for c in CATALOG_KINDS)
class CatalogPromptSlotsBody(BaseModel):
slots: Dict[str, Optional[str]] = Field(default_factory=dict)
def _require_admin(session: dict = Depends(require_auth)) -> dict:
role = (session.get("role") or "").strip().lower()
if role not in ("admin", "superadmin"):
raise HTTPException(status_code=403, detail="Nur Admins")
return session
def _slots_table_ready(cur) -> bool:
cur.execute("SELECT to_regclass(%s)::text AS t", ("public.catalog_prompt_slots",))
row = cur.fetchone()
if not row:
return False
val = row.get("t") if isinstance(row, dict) else row[0]
return val is not None and str(val).strip() != ""
@router.get("/catalog-prompt-slot-types")
def api_list_catalog_prompt_slot_types(session: dict = Depends(_require_admin)):
with get_db() as conn:
cur = get_cursor(conn)
if not _slots_table_ready(cur):
raise HTTPException(status_code=503, detail="Tabelle catalog_prompt_slots fehlt.")
return {"slot_types": list_slot_type_definitions(cur)}
@router.get("/catalog-prompt-slots/{catalog_kind}/{catalog_id}")
def api_get_catalog_prompt_slots(
catalog_kind: str,
catalog_id: int,
session: dict = Depends(_require_admin),
):
kind = (catalog_kind or "").strip().lower()
if kind not in _VALID_KINDS:
raise HTTPException(status_code=400, detail=f"Unbekannter catalog_kind: {catalog_kind!r}")
with get_db() as conn:
cur = get_cursor(conn)
if not _slots_table_ready(cur):
raise HTTPException(status_code=503, detail="Tabelle catalog_prompt_slots fehlt.")
try:
return get_catalog_entry_slots(cur, kind, catalog_id)
except LookupError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@router.put("/catalog-prompt-slots/{catalog_kind}/{catalog_id}")
def api_put_catalog_prompt_slots(
catalog_kind: str,
catalog_id: int,
body: CatalogPromptSlotsBody,
session: dict = Depends(_require_admin),
):
kind = (catalog_kind or "").strip().lower()
if kind not in _VALID_KINDS:
raise HTTPException(status_code=400, detail=f"Unbekannter catalog_kind: {catalog_kind!r}")
with get_db() as conn:
cur = get_cursor(conn)
if not _slots_table_ready(cur):
raise HTTPException(status_code=503, detail="Tabelle catalog_prompt_slots fehlt.")
try:
return upsert_catalog_entry_slots(cur, kind, catalog_id, body.slots or {})
except LookupError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc

View File

@ -0,0 +1,166 @@
"""Tests Katalog-Prompt-Slots (H2)."""
from unittest.mock import MagicMock
from catalog_prompt_slots import (
build_catalog_guidance_for_prompt,
pick_active_catalog_item,
placeholder_key,
resolve_catalog_prompt_variables,
)
from planning_catalog_context import PlanningCatalogContextItem, ProgressionPlanningCatalogContext
from planning_prompt_variables import merge_planning_prompt_variables
def _mock_cur(
rows_by_table=None,
slots_by_kind_id=None,
slot_types_ready=True,
):
rows_by_table = rows_by_table or {}
slots_by_kind_id = slots_by_kind_id or {}
cur = MagicMock()
def execute(sql, params=None):
sql_l = (sql or "").lower()
if "to_regclass" in sql_l:
cur.fetchone.return_value = {"t": "catalog_prompt_slot_types" if slot_types_ready else None}
return
if "from catalog_prompt_slot_types" in sql_l:
cur.fetchall.return_value = []
return
if "from catalog_prompt_slots" in sql_l:
kind, cid = params[0], int(params[1])
slot_map = slots_by_kind_id.get((kind, cid), {})
cur.fetchall.return_value = [
{"slot_key": k, "content": v} for k, v in slot_map.items()
]
return
for table, rows in rows_by_table.items():
if f"from {table}" in sql_l:
item_id = int(params[0])
raw = rows.get(item_id)
if raw is None:
cur.fetchone.return_value = None
elif isinstance(raw, dict):
cur.fetchone.return_value = {
"id": item_id,
"name": raw.get("name", ""),
"description": raw.get("description", ""),
}
else:
cur.fetchone.return_value = {
"id": item_id,
"name": str(raw),
"description": "",
}
return
cur.fetchone.return_value = None
cur.fetchall.return_value = []
cur.execute.side_effect = execute
return cur
def test_pick_active_catalog_item_primary_wins():
items = [
PlanningCatalogContextItem(id=1, is_primary=False, weight=0.9),
PlanningCatalogContextItem(id=2, is_primary=True, weight=0.5),
]
assert pick_active_catalog_item(items).id == 2
def test_granular_placeholder_focus_area_hints_on_path_qa():
cur = _mock_cur(
rows_by_table={"focus_areas": {4: {"name": "Gewaltschutz"}}},
slots_by_kind_id={
("focus_area", 4): {
"description": "Planung zielt auf Prävention und Deeskalation.",
"hints_on_path_qa": "Lücken sind fehlende Deeskalations-Stufen.",
"anti_patterns": "Nicht nach Kumite-Tiefe bewerten.",
}
},
)
catalog = ProgressionPlanningCatalogContext(
focus_areas=[PlanningCatalogContextItem(id=4, is_primary=True)],
)
resolved = resolve_catalog_prompt_variables(cur, catalog, slug="planning_exercise_path_qa")
assert "Deeskalation" in resolved[placeholder_key("focus_area", "hints_on_path_qa")]
assert "Deeskalation" in resolved["catalog_guidance_block"]
assert resolved["has_catalog_guidance"] == "true"
def test_description_fallback_from_stammdaten():
cur = _mock_cur(
rows_by_table={
"focus_areas": {
4: {
"name": "Gewaltschutz",
"description": "Gewaltprävention und Deeskalation",
}
}
},
slots_by_kind_id={("focus_area", 4): {}},
)
catalog = ProgressionPlanningCatalogContext(
focus_areas=[PlanningCatalogContextItem(id=4, is_primary=True)],
)
resolved = resolve_catalog_prompt_variables(cur, catalog)
assert resolved[placeholder_key("focus_area", "description")] == "Gewaltprävention und Deeskalation"
def test_empty_without_catalog():
cur = MagicMock()
out = build_catalog_guidance_for_prompt(cur, None)
assert out["has_catalog_guidance"] is False
assert out["catalog_guidance_block"] == ""
def test_unknown_entry_no_guidance_block():
cur = _mock_cur(rows_by_table={"focus_areas": {99: {"name": "Unbekannter Fokus XYZ"}}})
catalog = ProgressionPlanningCatalogContext(
focus_areas=[PlanningCatalogContextItem(id=99, is_primary=True)],
)
out = build_catalog_guidance_for_prompt(cur, catalog)
assert out["has_catalog_guidance"] is False
assert out["catalog_guidance_block"] == ""
assert "Unbekannter Fokus XYZ" in out["catalog_context_json"]
def test_merge_planning_prompt_variables_granular_keys():
cur = _mock_cur(
rows_by_table={"focus_areas": {4: {"name": "Gewaltschutz"}}},
slots_by_kind_id={
("focus_area", 4): {"hints_on_path_qa": "Deeskalation und Grenzen."}
},
)
catalog = ProgressionPlanningCatalogContext(
focus_areas=[PlanningCatalogContextItem(id=4, is_primary=True)],
)
merged = merge_planning_prompt_variables(
cur,
{"goal_query": "Deeskalation Kinder"},
catalog=catalog,
slug="planning_exercise_path_qa",
)
assert merged[placeholder_key("focus_area", "hints_on_path_qa")].startswith("Deeskalation")
assert merged["has_catalog_guidance"] == "true"
def test_priority_order_in_guidance_block():
cur = _mock_cur(
rows_by_table={
"focus_areas": {1: {"name": "Gewaltschutz"}},
"training_types": {2: {"name": "Breitensport"}},
},
slots_by_kind_id={
("focus_area", 1): {"description": "Fokus-Text"},
("training_type", 2): {"description": "Stil-Text"},
},
)
catalog = ProgressionPlanningCatalogContext(
focus_areas=[PlanningCatalogContextItem(id=1, is_primary=True)],
training_types=[PlanningCatalogContextItem(id=2, is_primary=True)],
)
block = build_catalog_guidance_for_prompt(cur, catalog)["catalog_guidance_block"]
assert block.index("Primärfokus") < block.index("Trainingsstil")

View File

@ -1,8 +1,8 @@
# Shinkan Jinkendo Version Information
APP_VERSION = "0.8.233"
APP_VERSION = "0.8.236"
BUILD_DATE = "2026-05-22"
DB_SCHEMA_VERSION = "20260607090"
DB_SCHEMA_VERSION = "20260607093"
MODULE_VERSIONS = {
"legal_documents": "1.4.0", # Admin: Live-Vorschau pro Abschnitt + modale Vollvorschau (Editor + Dokumentenliste)
@ -29,7 +29,7 @@ MODULE_VERSIONS = {
"media_lifecycle": "1.1.0", # P-11: Retention-Job ueberspringt Legal-Hold-Assets
"admin_ai_skill_retrieval": "1.0.0", # Superadmin CRUD /api/admin/ai-skill-retrieval-profiles (Migration 068)
"exercise_enrichment_admin": "1.1.1", # Preview max 3/Request + parallel LLM (Gateway-504 vermeiden)
"admin_ai_prompts": "1.0.3", # Migration 070: openrouter_model; PUT/Liste/Detail
"admin_ai_prompts": "1.0.5", # H2: granulare Katalog-Slot-Platzhalter im Katalog
"ai_prompt_job": "0.2.1", # want_instructions; run_exercise_form_ai_suggestion
"ai_prompt_context": "0.2.0", # preparation/trainer_notes; has_instruction_source_text
"ai_prompt_runtime": "0.2.2", # Slug planning_exercise_expectation_profile
@ -53,6 +53,32 @@ MODULE_VERSIONS = {
}
CHANGELOG = [
{
"version": "0.8.236",
"date": "2026-05-22",
"changes": [
"Stammdaten-Katalog: CatalogPromptSlotsEditor für Fokus, Trainingsstil, Zielgruppe, Stilrichtung.",
"Migration 093: ai_prompts mit granularen Slot-Platzhaltern (focus_area_hints_on_path_qa etc.).",
],
},
{
"version": "0.8.235",
"date": "2026-05-22",
"changes": [
"Planungs-KI H2: catalog_prompt_slot_types + catalog_prompt_slots — Slot-Werte pro Katalog-Eintrag.",
"Granulare Platzhalter focus_area_hints_on_progression etc.; Resolver catalog_prompt_slots.py.",
"Admin-API GET/PUT /api/catalog-prompt-slots/{kind}/{id}; H1-Registry entfernt.",
],
},
{
"version": "0.8.234",
"date": "2026-05-22",
"changes": [
"Planungs-KI H1: Katalog-Snippets (planning_catalog_prompt_snippets) + zentrale Platzhalter (planning_prompt_variables).",
"Pfad-QS, Roadmap, Stufenspec: {{catalog_guidance_block}} aus Trainer-Katalog; Migration 091.",
"Admin: Planungs-Platzhalter-Katalog; Preview mit optional planning_catalog_context.",
],
},
{
"version": "0.8.226",
"date": "2026-05-22",

View File

@ -1,229 +1,216 @@
# Planungs-KI — Katalog-Snippets für modulare Prompts
# Planungs-KI — Katalog-Prompt-Slots (Snippets)
**Stand:** 2026-05-22
**Status:** Spezifikation (Phase **H1** — Umsetzung offen)
**Bezüge:** `PLANNING_PROGRESSION_GRAPH_KI.md` §4.4 · `AI_PROMPT_TARGET_ARCHITECTURE.md` §2.4 · `planning_catalog_context.py`
**Status:** **H2** umgesetzt (0.8.235) · **H2.1** Admin-UI + granulare Prompts (0.8.236)
**Bezüge:** `PLANNING_PROGRESSION_GRAPH_KI.md` §4.4 · `AI_PROMPT_TARGET_ARCHITECTURE.md` §2.4 · `planning_catalog_context.py` · `catalog_prompt_slots.py`
---
## 1. Problem
Seit **F13 (0.8.233)** fließen Primärfokus, Trainingsstil, Zielgruppe und Stilrichtung als `planning_catalog_context` in **Retrieval und Scoring** (`PlanningTargetProfile`).
Seit **F13 (0.8.233)** fließen Primärfokus, Trainingsstil, Zielgruppe und Stilrichtung als `planning_catalog_context` in **Retrieval und Scoring**.
Die **LLM-Prompts** (Roadmap, Stufen-Spec, Pfad-QS, Gap-Fill) erhalten diese Dimensionen **nicht** als differenzierte Bewertungs- und Planungslogik — höchstens indirekt über Freitext oder JSON-Kataloglisten in Intent-Prompts.
Die **LLM-Prompts** brauchen zusätzlich **fachliche Textbausteine** pro gewähltem Katalog-Eintrag — editierbar, ohne Code-Deploy, unabhängig von fest codierten Katalog-Namen.
**Folge:** Ein Breitensport-Pfad kann mit Leistungsgruppen-Kriterien bewertet werden; Gewaltschutz wird wie Technik-Curriculum behandelt; QS-Hinweise und Rematch-Vorschläge passen fachlich nicht zum gewählten Kontext.
**Ziel:** Gleiche **Prompt-Basis**, aber **kaskadierte Snippet-Blöcke** pro Katalog-Ausprägung — keine Matrix aus Voll-Prompt-Kopien.
**Ziel:** Prompts in `ai_prompts` referenzieren **Slot-Typen** (Vokabular). Inhalte hängen an **Katalog-Zeilen** (Stammdaten). Der Resolver füllt Platzhalter zur Laufzeit.
---
## 2. Priorität der Dimensionen (absteigend)
## 2. Zwei Ebenen (Kern des Modells)
Verbindliche Reihenfolge bei Konflikten und beim Rollout:
| Ebene | Was | Wer pflegt | Beispiel |
|-------|-----|------------|----------|
| **Slot-Typ** | Art des Textes (festes, erweiterbares Vokabular) | Produkt / Architektur | `hints_on_progression`, `hints_on_path_qa` |
| **Slot-Wert** | Konkreter Text pro Katalog-Eintrag | Admin / Redakteur | Gewaltschutz → `hints_on_path_qa`: „Lücken = fehlende Deeskalation …“ |
| Rang | Dimension | DB-Tabelle | Snippet-Rolle |
|------|-----------|------------|----------------|
| **1** | **Primärfokus** | `focus_areas` | Definiert *worüber* geplant und bewertet wird (Technik-Curriculum vs. Gewaltschutz vs. Fitness …). **Dominant.** |
| **2** | **Trainingsstil** | `training_types` | Definiert *wie* trainiert wird (Methodik, Belastungsaufbau, Wettkampf vs. Breitenansatz im Training). |
| **3** | **Zielgruppe** | `target_groups` | Definiert *für wen* (Kinder, Breitensport, Leistungsgruppe) — Tempo, Komplexität, Sicherheit. |
| **4** | **Stilrichtung** | `style_directions` | Vereins-/Stil-Linie (Shotokan, WKF …) — **Nuancen**, kein neuer Planungstyp. |
**Kaskaden-Regel:** Bei widersprüchlichen Snippet-Aussagen gilt: Primärfokus → Trainingsstil → Zielgruppe → Stilrichtung.
**Nicht:** feste Attribute pro Eintrag im Code (`planning_lens`, `qa_criteria` …).
**Sondern:** beliebig viele Katalog-Einträge × definierte Slot-Typen.
---
## 3. Architektur — drei Schichten (Erinnerung)
## 3. Dimensionen & Priorität
| Schicht | Heute | Mit H1 |
|---------|-------|--------|
| **Retrieval** | Katalog-Gewichte in `merge_catalog_context_into_target` | unverändert |
| **Technik-Gates** | `planning_exercise_semantics.py`, nur `topic_type=technique` | unverändert |
| **LLM-Prompts** | kaum Katalog-spezifisch | **`catalog_guidance_block`** pro Aufruf |
| Rang | Dimension | `catalog_kind` | DB-Tabelle |
|------|-----------|----------------|------------|
| **1** | Primärfokus | `focus_area` | `focus_areas` |
| **2** | Trainingsstil | `training_type` | `training_types` |
| **3** | Zielgruppe | `target_group` | `target_groups` |
| **4** | Stilrichtung | `style_direction` | `style_directions` |
Snippets **ersetzen** keine Technik-Disambiguierung und **duplizieren** keine Retrieval-Gewichte — sie steuern **Didaktik, Bewertungsmaßstäbe und Formulierung** für das Modell.
**Kaskade:** Bei widersprüchlichen Hinweisen gilt Fokus → Trainingsstil → Zielgruppe → Stilrichtung.
Pro Dimension im Request: **ein aktiver Eintrag** (`is_primary`, sonst höchstes `weight`).
---
## 4. Snippet-Modell
## 4. Slot-Typ-Register (Vokabular)
### 4.1 Lookup-Schlüssel
Definiert in **`catalog_prompt_slot_types`** (DB) + Spiegel in `catalog_prompt_slots.py`.
Pro Katalog-Eintrag ein stabiler **`snippet_key`** (nicht nur numerische ID — IDs können sich in Dev/Import unterscheiden):
| `slot_key` | Anzeige (DE) | LLM-Prompt | Code-only |
|------------|--------------|------------|-----------|
| `description` | Allgemeine Beschreibung | Roadmap, Goal-Analyse, Start/Ziel | — |
| `hints_on_progression` | Hinweise Progressionsgraph / Stufen | Roadmap, Stage-Spec | — |
| `hints_on_path_qa` | Bewertungsmaßstäbe Pfad-QS | Path-QA | — |
| `hints_on_exercise` | Hinweise Übungsanlage / Gap-Fill | Exercise-AI, Suggest (später) | — |
| `anti_patterns` | Explizit vermeiden (Fehlbewertung) | Path-QA, Stage-Spec | — |
| `rematch_guard` | Kein Auto-Rematch erzwingen | — | Rematch-Loop (H1.5) |
```
focus:{slug} z. B. focus:gewaltschutz
training_type:{slug} z. B. training_type:kumite
target_group:{slug} z. B. target_group:breitensport
style:{slug} z. B. style:shotokan
```
**Erweiterung:** neuer Slot-Typ = eine Zeile in `catalog_prompt_slot_types` + Resolver/Admin-Katalog — **kein** neues Python-Feld pro Eintrag.
**Primär** aus `slug` der DB-Zeile; Fallback normalisierter `name` (ASCII, lower, `_`).
**Fallback `description`:** Wenn kein Slot-Wert gesetzt → `focus_areas.description` (bzw. `description`-Spalte der jeweiligen Katalog-Tabelle).
Mehrfachauswahl im UI: pro Dimension **höchstens ein Snippet** — die Zeile mit `is_primary: true`, sonst erste Zeile, sonst höchste `weight`.
---
### 4.2 Snippet-Inhalt (Struktur)
## 5. Platzhalter in `ai_prompts`
Jedes Snippet liefert strukturierte Textbausteine (Deutsch, für LLM):
| Feld | Pflicht | Inhalt |
|------|---------|--------|
| `planning_lens` | ja | 24 Sätze: Was ist das Planungsziel in dieser Dimension? |
| `qa_criteria` | ja | Bullet-artige Kriterien für Pfad-QS (was ist „gut“, was ist kein Mangel) |
| `roadmap_hints` | empfohlen | Stufenlogik, typische Phasen, was vermeiden |
| `anti_patterns` | optional | Explizite Fehlbewertungen vermeiden (z. B. „keine Wettkampf-Tiefe verlangen“) |
| `rematch_guard` | optional | Wann **kein** Auto-Rematch sinnvoll (Breitensport: keine Perfektions-Slots erzwingen) |
Phase **H1:** flache Markdown-Strings im Code-Modul.
Phase **H2 (optional):** Tabelle `planning_catalog_prompt_snippets` oder JSONB an Katalog-Zeilen, Admin-editierbar.
### 4.3 Platzhalter in `ai_prompts`
Neue **gemeinsame** Platzhalter (Mustache), in alle betroffenen Prompts einfügen:
Mustache-Keys: **`{catalog_kind}_{slot_key}`** (nur `[a-z0-9_]`).
| Platzhalter | Bedeutung |
|-------------|-----------|
| `{{catalog_guidance_block}}` | Gerenderter Gesamttext (alle aktiven Snippets, kaskadiert) |
| `{{catalog_context_json}}` | Kompakte JSON-Zusammenfassung der gewählten IDs/Namen (Audit) |
| `{{#has_catalog_guidance}}``{{/has_catalog_guidance}}` | Block nur wenn mindestens ein Snippet aktiv |
| `{{focus_area_description}}` | Aktiver Primärfokus — Beschreibung |
| `{{focus_area_hints_on_progression}}` | … — Progressions-Hinweise |
| `{{focus_area_hints_on_path_qa}}` | … — QS-Hinweise |
| `{{focus_area_hints_on_exercise}}` | … — Übungsanlage |
| `{{focus_area_anti_patterns}}` | … — Anti-Patterns |
| `{{training_type_description}}` | Aktiver Trainingsstil — … |
| `{{training_type_hints_on_progression}}` | … |
| `{{target_group_hints_on_path_qa}}` | Aktive Zielgruppe — … |
| `{{style_direction_hints_on_progression}}` | Aktive Stilrichtung — … |
| *(analog für alle Slot-Typen × Dimension)* | |
| `{{catalog_guidance_block}}` | **Aggregat** (Abwärtskompatibel): kaskadierter Markdown-Block aus aktiven Slots |
| `{{catalog_context_json}}` | Audit: IDs, Namen, gesetzte Slot-Keys |
| `{{has_catalog_guidance}}` | `"true"` oder leer |
**Optional fein (später):** `{{catalog_focus_snippet}}`, `{{catalog_training_type_snippet}}`, … — Phase H1 nur `_block` + JSON.
**Hinweis:** `prompt_resolver` unterstützt **keine** `{{#if}}`-Blöcke — leere Slots = leerer String.
### 4.4 Betroffene Prompt-Slugs (Reihenfolge Einbindung)
### 5.1 Prompt-Profile (welche Slots im Aggregat)
| Priorität | Slug | Migration | Wirkung |
|-----------|------|-----------|---------|
| 1 | `planning_exercise_path_qa` | bestehend | Pfad-QS, `quality_score`, Empfehlungen |
| 2 | `planning_progression_roadmap` | 078 | Major Steps, Didaktik |
| 3 | `planning_progression_stage_spec` | 079 | Stufen-Gates, Erfolgskriterien |
| 4 | `planning_progression_start_target` | 087 | Start/Ziel-Extraktion |
| 5 | `planning_progression_goal_analysis` | 078 | Zielanalyse |
| 6 | Gap-Fill / Übungs-KI | 085+ | `planning_context` ergänzen |
| Prompt-Slug | Aggregat enthält primär |
|-------------|-------------------------|
| `planning_exercise_path_qa` | `*_hints_on_path_qa`, `*_anti_patterns`, `*_description` |
| `planning_progression_roadmap` | `*_description`, `*_hints_on_progression`, `*_anti_patterns` |
| `planning_progression_stage_spec` | `*_hints_on_progression`, `*_anti_patterns` |
| `planning_progression_goal_analysis` | `*_description` |
Intent-Prompts (`planning_exercise_search_intent`, …) **optional** Phase H1.5 — dort bereits Katalog-JSON.
Feinsteuerung: im Admin-Template **granulare** Platzhalter nutzen; Aggregat optional.
---
## 5. Builder (Backend)
## 6. Speicherung (DB)
**Neues Modul:** `backend/planning_catalog_prompt_snippets.py`
### 6.1 `catalog_prompt_slot_types`
```python
def build_catalog_guidance_for_prompt(
cur,
catalog: Optional[ProgressionPlanningCatalogContext],
) -> Dict[str, str]:
"""
Returns:
catalog_guidance_block: str
catalog_context_json: str
has_catalog_guidance: bool
snippet_keys: list[str] # Metadaten für Logs/Tests
"""
Metadaten je Slot-Typ (`slot_key`, `display_name`, `description`, `applicable_kinds[]`, `sort_order`, `for_llm`, `for_code`).
### 6.2 `catalog_prompt_slots`
```text
catalog_kind — focus_area | training_type | target_group | style_direction
catalog_id — FK auf jeweilige Stammtabelle (logisch, kein polymorpher FK)
slot_key — FK → catalog_prompt_slot_types
content — TEXT (Markdown/Plain für LLM)
UNIQUE (catalog_kind, catalog_id, slot_key)
```
**Ablauf:**
1. `catalog` aus Request oder `planning_roadmap.planning_catalog_context` (wie F13).
2. Pro Dimension aktives Item auflösen → `snippet_key` → Text aus Registry.
3. Snippets in **Prioritätsreihenfolge** §2 zu `_block` zusammenfügen (Überschriften: „Primärfokus“, „Trainingsstil“, …).
4. Fehlende Snippets: Dimension **weglassen** (kein Default-Text) — besser kein Snippet als falscher.
**Einbindung:** Orchestratoren rufen Builder auf und mergen in `variables` vor `load_and_render_ai_prompt`:
- `planning_exercise_path_qa.py``try_llm_qa_progression_path`
- `planning_progression_roadmap.py` (Roadmap-/Stage-Pipeline)
- `planning_exercise_path_builder.py` (catalog an QA/Match durchreichen)
`ProgressionPathSuggestRequest` trägt `planning_catalog_context` bereits — kein neues API-Feld nötig.
Neuer Katalog-Eintrag im Admin → **keine** Code-Änderung; Slots optional befüllen.
---
## 6. Beispiel-Snippets (Review-Entwurf)
## 7. Laufzeit-Architektur
### 6.1 Primärfokus — Gewaltschutz (`focus:gewaltschutz`)
```text
planning_catalog_context (Request / Graph-Artefakt)
catalog_prompt_slots.resolve_catalog_prompt_variables(cur, catalog, slug?)
planning_prompt_variables.merge_planning_prompt_variables(...)
load_and_render_ai_prompt (ai_prompts Template)
```
**planning_lens:** Planung zielt auf Prävention, Deeskalation, Grenzen und sichere Übungsformen — nicht auf Wettkampf-Perfektion oder Technik-Show.
**Module:**
**qa_criteria:** Gute Pfade bauen Sicherheit, Kommunikation und Alternativen auf; „Lücken“ sind fehlende Deeskalations- oder Rollenspiel-Stufen, nicht fehlende Kick-Varianten.
**anti_patterns:** Nicht nach Kumite-Tiefe, Explosivität oder Wettkampf-Belastung bewerten.
### 6.2 Primärfokus — Technik / Kumite-Beinarbeit (`focus:kumite` o. ä.)
**planning_lens:** Curriculum für eine Technik oder Kumite-Teilaspekt; aufeinander aufbauende Belastung und Anwendungsnähe sind erwünscht.
**qa_criteria:** Kohärente Progression Grundlagen → Anwendung → Vertiefung; Übergänge ohne Sprünge; themenfremde Kraft-/Ausdauer-Inseln abwerten.
### 6.3 Trainingsstil — Breitensport (`training_type:breitensport` o. Name-Match)
**planning_lens:** Partizipation, Verständlichkeit, Freude am Bewegen; weniger maximale Spezialisierung.
**qa_criteria:** Hohe OK-Rate bei moderatem Schwierigkeitsanstieg; „Perfektion“-Stufen nur optional, nicht als Pflicht-Lücke.
**rematch_guard:** Keine leeren Slots erzwingen, nur um eine Leistungs-Perfektionsstufe zu füllen.
### 6.4 Zielgruppe — Leistungsgruppe (`target_group:leistungsgruppe`)
**qa_criteria:** Höhere Anspruchskurven, Belastungs- und Kombinationsprogressionen sind relevant; Lücken in Spezialisierung können echte Hinweise sein.
*(Weitere Snippets iterativ ergänzen — nicht alle Katalog-Zeilen sofort.)*
| Modul | Rolle |
|-------|--------|
| `catalog_prompt_slots.py` | Slot-Typen, DB-Lese/Schreib, Resolver, Aggregat-Block |
| `planning_prompt_variables.py` | Zentrale Provider-Liste (erweiterbar) |
| `planning_catalog_prompt_snippets.py` | Deprecated Re-Exports (Tests/Kompatibilität) |
---
## 7. Rollout-Phasen
## 8. Admin-API
### H1 — Minimal viable (Progressionsgraph)
| Methode | Pfad | Beschreibung |
|---------|------|--------------|
| GET | `/api/catalog-prompt-slot-types` | Slot-Typ-Register |
| GET | `/api/catalog-prompt-slots/{kind}/{catalog_id}` | Alle Slots eines Eintrags |
| PUT | `/api/catalog-prompt-slots/{kind}/{catalog_id}` | Slots upserten (Admin) |
- [ ] Modul `planning_catalog_prompt_snippets.py` + Registry (58 Snippets: 23 Foki, 2 Trainingsstile, 2 Zielgruppen)
- [ ] Einbindung in **`planning_exercise_path_qa`** + **`planning_progression_roadmap`** + **`planning_progression_stage_spec`**
- [ ] Migration Prompt-Templates: Abschnitt `{{#has_catalog_guidance}}…{{/has_catalog_guidance}}`
- [ ] Tests: gleicher Pfad + unterschiedlicher Katalog → unterschiedlicher `catalog_guidance_block`; Snapshot QA-Variablen
- [ ] Dev-Regression: Gewaltschutz, Breitensport, Kinder — **Hinweistexte** müssen zum Kontext passen (nicht Mae-Geri-Kriterien)
`kind`: `focus_area` · `training_type` · `target_group` · `style_direction`
**Später:** UI-Tabs am Katalog-Editor; Versionierung/Audit wie `ai_prompts`.
---
## 9. Rollout-Phasen
### H1 — Bootstrap (0.8.234) ✓
Hardcodierte `SNIPPET_REGISTRY` — Proof of Concept für `catalog_guidance_block`.
### H2 — Slot-Modell (0.8.235) ✓
- [x] Tabellen `catalog_prompt_slot_types`, `catalog_prompt_slots`
- [x] Seed aus H1-Texten (Name-Match auf Stammdaten)
- [x] Resolver mit granularen Platzhaltern + Aggregat
- [x] Admin-API GET/PUT
- [x] `SNIPPET_REGISTRY` aus Laufzeit-Pfad entfernt
### H2.1 — Admin-UI
- [x] Slot-Editor an Fokusbereich / Trainingsstil / Zielgruppe / Stilrichtung (`CatalogPromptSlotsEditor`, Stammdaten-Katalog)
- [x] Prompt-Templates mit granularen Platzhaltern (Migration 093)
- [ ] Platzhalter-Hilfe im KI-Prompt-Editor (erweitert)
### H1.5
- [ ] Rematch/Refine: `rematch_guard` aus Snippets respektieren (weniger Ping-Pong bei Breitensport)
- [ ] Intent-Prompts + Gap-Fill-Kontext
- [ ] `rematch_guard` im Rematch-Loop
- [ ] Intent-Prompts + Gap-Fill: `hints_on_exercise`
### H2 — Betrieb
### H3 — Trainingsplanung (Phase G)
- [ ] Snippets in DB, Admin-UI oder Markdown-Import
- [ ] Versionierung / Audit wie `ai_prompts`
### H3 — Phase G (Trainingsplanung)
- [ ] Gleicher Builder, anderer Orchestrator (Abschnitts-QS, Slot-Suggest)
- [ ] Gleicher Resolver, andere Orchestratoren
---
## 8. Tests & Akzeptanz
## 10. Tests & Akzeptanz
| Test | Erwartung |
|------|-----------|
| `test_catalog_prompt_snippets_priority` | Bei Konflikt gewinnt Fokus-Snippet-Text in `_block`-Reihenfolge |
| `test_path_qa_variables_include_guidance` | Mit Gewaltschutz-Kontext enthält gerendeter Prompt „Deeskalation“ o. ä., nicht „Kumite-Perfektion“ |
| `test_path_qa_no_snippet_without_catalog` | Ohne Katalog: `has_catalog_guidance=false`, Prompt unverändert wie heute |
| Manuell | Mae-Geri-Pfad + Breitensport-Kontext: QS-Highlights ohne Leistungs-Belastungs-Forderungen |
**Nicht Ziel von H1:** Retrieval-Gewichte neu kalibrieren; Technik-Tuples externalisieren (separates Backlog **H** in Roadmap).
| Slot aus DB | Gewaltschutz + `hints_on_path_qa` → Platzhalter enthält Deeskalation |
| Ohne Katalog | Alle `{{focus_area_*}}` leer; `has_catalog_guidance` leer |
| Neuer Eintrag | Leere Slots, kein Crash; `description`-Fallback aus Stammdaten |
| Priorität | Aggregat: Primärfokus vor Trainingsstil |
---
## 9. Abgrenzung zu anderen Fixes
## 11. Abgrenzung
| Thema | Dokument / Fix |
|-------|----------------|
| 88% vs. 65% falsche Pipeline | Evaluate-only für Pfad-QS; fairer Compare — Code-Stand 2026-05-22 |
| Triviale ID-Tausche im Dialog | `_filter_trivial_slot_diffs` |
| Katalog nur im Retrieval | F13 — bleibt, Snippets ergänzen LLM-Schicht |
Snippets lösen **fachliche Fehlbewertung** — nicht Pipeline-Inkonsistenz allein.
| Thema | Hinweis |
|-------|---------|
| Retrieval-Gewichte | `merge_catalog_context_into_target` — unverändert |
| Technik-Gates | `planning_exercise_semantics` — unverändert |
| Prompt-Text | `ai_prompts` — editierbar, referenziert Slot-Platzhalter |
---
## 10. Changelog
## 12. Changelog
| Datum | Änderung |
|-------|----------|
| 2026-05-22 | Erstfassung — Priorität Primärfokus → Trainingsstil → Zielgruppe → Stilrichtung; H1H3 Rollout |
| 2026-05-22 | **H2.1** (0.8.236): Admin-UI `CatalogPromptSlotsEditor`; Migration 093 granulare Prompt-Templates |
| 2026-05-22 | **H2** (0.8.235): Slot-Typ-Register + `catalog_prompt_slots` DB, granulare Platzhalter, Admin-API |
| 2026-05-22 | Konzept §4§8: zwei Ebenen Slot-Typ vs. Slot-Wert; Platzhalter `{kind}_{slot_key}` |
| 2026-05-22 | H1 (0.8.234): Bootstrap-Registry — durch H2 ersetzt |
| 2026-05-22 | Erstfassung |

View File

@ -0,0 +1,155 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { api } from '../../utils/api'
const KIND_LABELS = {
focus_area: 'Fokusbereich',
training_type: 'Trainingsstil',
target_group: 'Zielgruppe',
style_direction: 'Stilrichtung',
}
/**
* Pflege der Katalog-Prompt-Slots (Planungs-KI) an einem Stammdaten-Eintrag.
*/
export default function CatalogPromptSlotsEditor({ catalogKind, catalogId, entryName = '' }) {
const [slotTypes, setSlotTypes] = useState([])
const [slots, setSlots] = useState({})
const [loading, setLoading] = useState(false)
const [saving, setSaving] = useState(false)
const [error, setError] = useState('')
const [loaded, setLoaded] = useState(false)
const applicableTypes = useMemo(() => {
const kind = (catalogKind || '').trim()
return (slotTypes || []).filter((t) => {
const kinds = t.applicable_kinds || []
return kinds.length === 0 || kinds.includes(kind)
})
}, [slotTypes, catalogKind])
const load = useCallback(async () => {
if (!catalogId || !catalogKind) return
setLoading(true)
setError('')
try {
const [typesRes, slotsRes] = await Promise.all([
api.listCatalogPromptSlotTypes(),
api.getCatalogPromptSlots(catalogKind, catalogId),
])
setSlotTypes(Array.isArray(typesRes?.slot_types) ? typesRes.slot_types : [])
setSlots(slotsRes?.slots && typeof slotsRes.slots === 'object' ? { ...slotsRes.slots } : {})
setLoaded(true)
} catch (e) {
setError(e.message || String(e))
} finally {
setLoading(false)
}
}, [catalogKind, catalogId])
useEffect(() => {
setLoaded(false)
setSlots({})
if (catalogId && catalogKind) {
load()
}
}, [catalogId, catalogKind, load])
async function handleSave() {
if (!catalogId || !catalogKind) return
setSaving(true)
setError('')
try {
const res = await api.updateCatalogPromptSlots(catalogKind, catalogId, { slots })
setSlots(res?.slots && typeof res.slots === 'object' ? { ...res.slots } : {})
} catch (e) {
setError(e.message || String(e))
} finally {
setSaving(false)
}
}
if (!catalogId || !catalogKind) {
return null
}
const kindLabel = KIND_LABELS[catalogKind] || catalogKind
return (
<div
className="catalog-prompt-slots"
style={{
marginTop: '20px',
paddingTop: '16px',
borderTop: '1px solid var(--border)',
}}
>
<h4 style={{ margin: '0 0 8px' }}>Planungs-KI Prompt-Texte</h4>
<p style={{ margin: '0 0 12px', fontSize: '13px', color: 'var(--text2)' }}>
Texte für KI-Prompts (Progressionsgraph, Pfad-QS). Platzhalter:{' '}
<code>{'{{' + catalogKind + '_<slot_key>}}'}</code>
{entryName ? (
<>
{' '}
Eintrag: <strong>{entryName}</strong>
</>
) : null}
</p>
{error ? (
<div className="admin-matrix-alert" style={{ marginBottom: '12px' }}>
{error}
</div>
) : null}
{loading && !loaded ? (
<div className="spinner" style={{ minHeight: '48px' }} />
) : (
<>
{applicableTypes.map((st) => {
const key = st.slot_key
const ph = `{{${catalogKind}_${key}}}`
const isCodeOnly = st.for_code && !st.for_llm
return (
<div key={key} className="form-row">
<label className="form-label">
{st.display_name || key}
{isCodeOnly ? (
<span style={{ marginLeft: '6px', fontSize: '12px', color: 'var(--text3)' }}>
(primär Code)
</span>
) : null}
</label>
{st.description ? (
<p style={{ margin: '0 0 6px', fontSize: '12px', color: 'var(--text3)' }}>{st.description}</p>
) : null}
<textarea
className="form-input"
rows={key === 'description' ? 3 : 4}
value={slots[key] || ''}
onChange={(e) => setSlots((prev) => ({ ...prev, [key]: e.target.value }))}
placeholder={
key === 'description'
? 'Leer = Stammdaten-Beschreibung als Fallback'
: `Text für ${ph}`
}
/>
<p style={{ margin: '4px 0 0', fontSize: '11px', color: 'var(--text3)', fontFamily: 'monospace' }}>
{ph}
</p>
</div>
)
})}
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap', marginTop: '8px' }}>
<button type="button" className="btn btn-primary" onClick={handleSave} disabled={saving || loading}>
{saving ? 'Speichert…' : 'KI-Texte speichern'}
</button>
<button type="button" className="btn btn-secondary" onClick={load} disabled={loading || saving}>
Neu laden
</button>
</div>
</>
)}
</div>
)
}

View File

@ -1,5 +1,6 @@
import React, { useState } from 'react'
import { api } from '../../utils/api'
import CatalogPromptSlotsEditor from './CatalogPromptSlotsEditor'
function DetailPanel({ item, onUpdate, focusAreas }) {
const type = item._type
@ -87,6 +88,7 @@ function FocusAreaDetail({ item, onUpdate }) {
</button>
<button type="button" className="btn btn-danger" onClick={handleDelete}>Löschen</button>
</div>
<CatalogPromptSlotsEditor catalogKind="focus_area" catalogId={item.id} entryName={form.name} />
</div>
)
}
@ -169,6 +171,7 @@ function StyleDirectionDetail({ item, onUpdate, focusAreas }) {
</button>
<button type="button" className="btn btn-danger" onClick={handleDelete}>Löschen</button>
</div>
<CatalogPromptSlotsEditor catalogKind="style_direction" catalogId={item.id} entryName={form.name} />
</div>
)
}
@ -251,6 +254,7 @@ function TrainingTypeDetail({ item, onUpdate, focusAreas }) {
</button>
<button type="button" className="btn btn-danger" onClick={handleDelete}>Löschen</button>
</div>
<CatalogPromptSlotsEditor catalogKind="training_type" catalogId={item.id} entryName={form.name} />
</div>
)
}

View File

@ -2,6 +2,7 @@ import { useState, useEffect } from 'react'
import { api } from '../utils/api'
import AdminPageNav from '../components/AdminPageNav'
import PageSectionNav from '../components/PageSectionNav'
import CatalogPromptSlotsEditor from '../components/admin/CatalogPromptSlotsEditor'
const CATALOG_SUBTABS = [
{ id: 'focus-areas', label: 'Fokusbereiche' },
@ -62,6 +63,38 @@ export default function AdminCatalogsPage() {
// M:N Assignment Matrix
const [assignments, setAssignments] = useState([])
const [matrixLoading, setMatrixLoading] = useState(false)
const [openKiSlots, setOpenKiSlots] = useState(null)
function toggleKiSlots(kind, id) {
const key = `${kind}:${id}`
setOpenKiSlots((prev) => (prev === key ? null : key))
}
function renderKiSlotsToggle(kind, id, label = 'KI-Planungstexte') {
const key = `${kind}:${id}`
const open = openKiSlots === key
return (
<button
type="button"
className="btn btn-secondary"
onClick={() => toggleKiSlots(kind, id)}
>
{open ? 'KI-Texte ausblenden' : label}
</button>
)
}
function renderKiSlotsPanel(kind, id, entryName) {
const key = `${kind}:${id}`
if (openKiSlots !== key) return null
return (
<CatalogPromptSlotsEditor
catalogKind={kind}
catalogId={id}
entryName={entryName}
/>
)
}
useEffect(() => {
loadData()
@ -75,14 +108,22 @@ export default function AdminCatalogsPage() {
const data = await api.listFocusAreas()
setFocusAreas(data)
} else if (activeTab === 'training-styles') {
const data = await api.listStyleDirections()
const [data, areas] = await Promise.all([
api.listStyleDirections(),
api.listFocusAreas(),
])
setTrainingStyles(data)
setFocusAreas(areas)
} else if (activeTab === 'training-characters') {
const data = await api.listTrainingCharacters()
setTrainingCharacters(data)
} else if (activeTab === 'training-types') {
const data = await api.listTrainingTypes()
const [data, areas] = await Promise.all([
api.listTrainingTypes(),
api.listFocusAreas(),
])
setTrainingTypes(data)
setFocusAreas(areas)
} else if (activeTab === 'skill-categories') {
const data = await api.listSkillCategories()
setSkillCategories(data)
@ -431,6 +472,11 @@ export default function AdminCatalogsPage() {
<button className="btn btn-primary" onClick={() => updateFocusArea(fa.id, editingFA)}>Speichern</button>
<button className="btn" onClick={() => setEditingFA(null)}>Abbrechen</button>
</div>
<CatalogPromptSlotsEditor
catalogKind="focus_area"
catalogId={fa.id}
entryName={editingFA.name}
/>
</div>
) : (
<div>
@ -449,10 +495,12 @@ export default function AdminCatalogsPage() {
}}
/>
</div>
<div style={{ display: 'flex', gap: '8px' }}>
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
<button className="btn" onClick={() => setEditingFA(fa)}>Bearbeiten</button>
{renderKiSlotsToggle('focus_area', fa.id)}
<button className="btn" onClick={() => deleteFocusArea(fa.id)}>Löschen</button>
</div>
{renderKiSlotsPanel('focus_area', fa.id, fa.name)}
</div>
)}
</div>
@ -539,6 +587,11 @@ export default function AdminCatalogsPage() {
<button className="btn btn-primary" onClick={() => updateStyleDirection(ts.id, editingTS)}>Speichern</button>
<button className="btn" onClick={() => setEditingTS(null)}>Abbrechen</button>
</div>
<CatalogPromptSlotsEditor
catalogKind="style_direction"
catalogId={ts.id}
entryName={editingTS.name}
/>
</div>
) : (
<div>
@ -554,10 +607,12 @@ export default function AdminCatalogsPage() {
</p>
)}
<p style={{ margin: '8px 0', color: 'var(--text2)' }}>{ts.description}</p>
<div style={{ display: 'flex', gap: '8px' }}>
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
<button className="btn" onClick={() => setEditingTS(ts)}>Bearbeiten</button>
{renderKiSlotsToggle('style_direction', ts.id)}
<button className="btn" onClick={() => deleteStyleDirection(ts.id)}>Löschen</button>
</div>
{renderKiSlotsPanel('style_direction', ts.id, ts.name)}
</div>
)}
</div>
@ -730,6 +785,11 @@ export default function AdminCatalogsPage() {
<button className="btn btn-primary" onClick={() => updateTrainingType(tt.id, editingTT)}>Speichern</button>
<button className="btn" onClick={() => setEditingTT(null)}>Abbrechen</button>
</div>
<CatalogPromptSlotsEditor
catalogKind="training_type"
catalogId={tt.id}
entryName={editingTT.name}
/>
</div>
) : (
<div>
@ -744,10 +804,12 @@ export default function AdminCatalogsPage() {
<span style={{ color: 'var(--accent)' }}>{tt.focus_area_icon} {tt.focus_area_name}</span>
</p>
)}
<div style={{ display: 'flex', gap: '8px', marginTop: '12px' }}>
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap', marginTop: '12px' }}>
<button className="btn" onClick={() => setEditingTT(tt)}>Bearbeiten</button>
{renderKiSlotsToggle('training_type', tt.id)}
<button className="btn" onClick={() => deleteTrainingType(tt.id)}>Löschen</button>
</div>
{renderKiSlotsPanel('training_type', tt.id, tt.name)}
</div>
)}
</div>
@ -956,6 +1018,11 @@ export default function AdminCatalogsPage() {
<button className="btn btn-primary" onClick={() => updateTargetGroup(tg.id, tg)}>Speichern</button>
<button className="btn" onClick={() => setEditingTG(null)}>Abbrechen</button>
</div>
<CatalogPromptSlotsEditor
catalogKind="target_group"
catalogId={tg.id}
entryName={tg.name}
/>
</div>
) : (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
@ -970,10 +1037,12 @@ export default function AdminCatalogsPage() {
<p style={{ margin: '8px 0 0 0', fontSize: '14px' }}>{tg.description}</p>
)}
</div>
<div style={{ display: 'flex', gap: '8px' }}>
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
<button className="btn" onClick={() => setEditingTG(tg.id)}>Bearbeiten</button>
{renderKiSlotsToggle('target_group', tg.id)}
<button className="btn" style={{ background: 'var(--danger)', color: 'white' }} onClick={() => deleteTargetGroup(tg.id)}>Löschen</button>
</div>
{renderKiSlotsPanel('target_group', tg.id, tg.name)}
</div>
)}
</div>

View File

@ -626,6 +626,21 @@ export async function getAdminAiPromptPlaceholdersCatalog() {
return request('/api/admin/ai-prompts/catalog/placeholders')
}
export async function listCatalogPromptSlotTypes() {
return request('/api/catalog-prompt-slot-types')
}
export async function getCatalogPromptSlots(catalogKind, catalogId) {
return request(`/api/catalog-prompt-slots/${encodeURIComponent(catalogKind)}/${catalogId}`)
}
export async function updateCatalogPromptSlots(catalogKind, catalogId, data) {
return request(`/api/catalog-prompt-slots/${encodeURIComponent(catalogKind)}/${catalogId}`, {
method: 'PUT',
body: JSON.stringify(data || {}),
})
}
// ============================================================================
// Reifegradmodelle / Fähigkeitsmatrix
// ============================================================================
@ -1089,6 +1104,9 @@ export const api = {
previewAdminAiPrompt,
resetAdminAiPromptTemplate,
getAdminAiPromptPlaceholdersCatalog,
listCatalogPromptSlotTypes,
getCatalogPromptSlots,
updateCatalogPromptSlots,
listStyleDirections,
listTrainingStyles,
createStyleDirection,