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
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:
parent
9cee862c32
commit
53f2b027cc
|
|
@ -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 {
|
||||
"goal_query": goal_query,
|
||||
"semantic_brief_json": brief_json,
|
||||
"user_notes": (body.user_notes or "").strip(),
|
||||
}
|
||||
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 {
|
||||
"goal_query": goal_query,
|
||||
"semantic_brief_json": brief_json,
|
||||
}
|
||||
return _merge_catalog_preview(
|
||||
cur,
|
||||
s,
|
||||
{
|
||||
"goal_query": goal_query,
|
||||
"semantic_brief_json": brief_json,
|
||||
},
|
||||
body,
|
||||
)
|
||||
|
||||
if s == "planning_progression_roadmap":
|
||||
return {
|
||||
"goal_query": goal_query,
|
||||
"goal_analysis_json": _compact_json(goal_analysis),
|
||||
"semantic_brief_json": brief_json,
|
||||
"max_steps": str(max_steps),
|
||||
}
|
||||
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 {
|
||||
"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,
|
||||
}
|
||||
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 {
|
||||
"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([]),
|
||||
}
|
||||
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 {
|
||||
|
|
|
|||
419
backend/catalog_prompt_slots.py
Normal file
419
backend/catalog_prompt_slots.py
Normal 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",
|
||||
]
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
172
backend/migrations/091_ai_prompt_catalog_guidance.sql
Normal file
172
backend/migrations/091_ai_prompt_catalog_guidance.sql
Normal 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 8–12 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';
|
||||
176
backend/migrations/092_catalog_prompt_slots.sql
Normal file
176
backend/migrations/092_catalog_prompt_slots.sql
Normal 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();
|
||||
|
|
@ -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 8–12 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';
|
||||
16
backend/planning_catalog_prompt_snippets.py
Normal file
16
backend/planning_catalog_prompt_snippets.py
Normal 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",
|
||||
]
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
"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),
|
||||
}
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
118
backend/planning_prompt_variables.py
Normal file
118
backend/planning_prompt_variables.py
Normal 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",
|
||||
]
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
94
backend/routers/catalog_prompt_slots.py
Normal file
94
backend/routers/catalog_prompt_slots.py
Normal 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
|
||||
166
backend/tests/test_planning_catalog_prompt_snippets.py
Normal file
166
backend/tests/test_planning_catalog_prompt_snippets.py
Normal 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")
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 | 2–4 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 (5–8 Snippets: 2–3 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; H1–H3 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 |
|
||||
|
|
|
|||
155
frontend/src/components/admin/CatalogPromptSlotsEditor.jsx
Normal file
155
frontend/src/components/admin/CatalogPromptSlotsEditor.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user