diff --git a/backend/ai_prompt_planning_preview.py b/backend/ai_prompt_planning_preview.py index a7188fb..fe123fa 100644 --- a/backend/ai_prompt_planning_preview.py +++ b/backend/ai_prompt_planning_preview.py @@ -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 { diff --git a/backend/catalog_prompt_slots.py b/backend/catalog_prompt_slots.py new file mode 100644 index 0000000..5a25d36 --- /dev/null +++ b/backend/catalog_prompt_slots.py @@ -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", +] diff --git a/backend/main.py b/backend/main.py index a1a6cad..1c7c31f 100644 --- a/backend/main.py +++ b/backend/main.py @@ -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) diff --git a/backend/migrations/091_ai_prompt_catalog_guidance.sql b/backend/migrations/091_ai_prompt_catalog_guidance.sql new file mode 100644 index 0000000..db47993 --- /dev/null +++ b/backend/migrations/091_ai_prompt_catalog_guidance.sql @@ -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'; diff --git a/backend/migrations/092_catalog_prompt_slots.sql b/backend/migrations/092_catalog_prompt_slots.sql new file mode 100644 index 0000000..db787fe --- /dev/null +++ b/backend/migrations/092_catalog_prompt_slots.sql @@ -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(); diff --git a/backend/migrations/093_ai_prompt_catalog_granular_placeholders.sql b/backend/migrations/093_ai_prompt_catalog_granular_placeholders.sql new file mode 100644 index 0000000..93d848f --- /dev/null +++ b/backend/migrations/093_ai_prompt_catalog_granular_placeholders.sql @@ -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'; diff --git a/backend/planning_catalog_prompt_snippets.py b/backend/planning_catalog_prompt_snippets.py new file mode 100644 index 0000000..7bd116f --- /dev/null +++ b/backend/planning_catalog_prompt_snippets.py @@ -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", +] diff --git a/backend/planning_exercise_path_builder.py b/backend/planning_exercise_path_builder.py index 2cfcea6..ccdb97f 100644 --- a/backend/planning_exercise_path_builder.py +++ b/backend/planning_exercise_path_builder.py @@ -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( diff --git a/backend/planning_exercise_path_qa.py b/backend/planning_exercise_path_qa.py index 6b8032a..80b727a 100644 --- a/backend/planning_exercise_path_qa.py +++ b/backend/planning_exercise_path_qa.py @@ -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) diff --git a/backend/planning_progression_roadmap.py b/backend/planning_progression_roadmap.py index dfab0c2..3bf3dcb 100644 --- a/backend/planning_progression_roadmap.py +++ b/backend/planning_progression_roadmap.py @@ -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) diff --git a/backend/planning_prompt_variables.py b/backend/planning_prompt_variables.py new file mode 100644 index 0000000..fcec48e --- /dev/null +++ b/backend/planning_prompt_variables.py @@ -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", +] diff --git a/backend/routers/ai_prompts_admin.py b/backend/routers/ai_prompts_admin.py index a051435..44861f2 100644 --- a/backend/routers/ai_prompts_admin.py +++ b/backend/routers/ai_prompts_admin.py @@ -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") diff --git a/backend/routers/catalog_prompt_slots.py b/backend/routers/catalog_prompt_slots.py new file mode 100644 index 0000000..4367282 --- /dev/null +++ b/backend/routers/catalog_prompt_slots.py @@ -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 diff --git a/backend/tests/test_planning_catalog_prompt_snippets.py b/backend/tests/test_planning_catalog_prompt_snippets.py new file mode 100644 index 0000000..1a18bde --- /dev/null +++ b/backend/tests/test_planning_catalog_prompt_snippets.py @@ -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") diff --git a/backend/version.py b/backend/version.py index 7f1310a..355d0d3 100644 --- a/backend/version.py +++ b/backend/version.py @@ -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", diff --git a/docs/architecture/PLANNING_CATALOG_PROMPT_SNIPPETS.md b/docs/architecture/PLANNING_CATALOG_PROMPT_SNIPPETS.md index 61dacdd..183dfe4 100644 --- a/docs/architecture/PLANNING_CATALOG_PROMPT_SNIPPETS.md +++ b/docs/architecture/PLANNING_CATALOG_PROMPT_SNIPPETS.md @@ -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 | diff --git a/frontend/src/components/admin/CatalogPromptSlotsEditor.jsx b/frontend/src/components/admin/CatalogPromptSlotsEditor.jsx new file mode 100644 index 0000000..26c5cf8 --- /dev/null +++ b/frontend/src/components/admin/CatalogPromptSlotsEditor.jsx @@ -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 ( +
+

Planungs-KI — Prompt-Texte

+

+ Texte für KI-Prompts (Progressionsgraph, Pfad-QS). Platzhalter:{' '} + {'{{' + catalogKind + '_}}'} + {entryName ? ( + <> + {' '} + — Eintrag: {entryName} + + ) : null} +

+ + {error ? ( +
+ {error} +
+ ) : null} + + {loading && !loaded ? ( +
+ ) : ( + <> + {applicableTypes.map((st) => { + const key = st.slot_key + const ph = `{{${catalogKind}_${key}}}` + const isCodeOnly = st.for_code && !st.for_llm + return ( +
+ + {st.description ? ( +

{st.description}

+ ) : null} +