diff --git a/.claude/docs/PROJECT_STATUS.md b/.claude/docs/PROJECT_STATUS.md index 209c8fd..e1c2886 100644 --- a/.claude/docs/PROJECT_STATUS.md +++ b/.claude/docs/PROJECT_STATUS.md @@ -15,7 +15,7 @@ **Plattform-Rechtstexte (P-01, 0.8.95–0.8.96):** Admin-Editor mit **Abschnitts- und Vollvorschau** (Markdown); fortlaufende Abschnittsnummerierung in der Anzeige/PDF (Darstellung, nicht DB-persistent). -**Parallel weiter relevant:** **Trainingsplan Phasen & Streams** (Migration **063**, Coach + Planung **0.8.137–0.8.140**; Handover **`docs/HANDOVER.md`** §3); **Trainingsrahmenprogramm** (036–037), **Progressionsgraph** (032–034) — siehe **`TRAINING_FRAMEWORK_SPEC.md`**. **Planungs-KI Progressionsgraph** (Roadmap-first, Auto-Optimierung, Katalog-Kontext **0.8.233**): Ist-Doku **`docs/architecture/PLANNING_PROGRESSION_GRAPH_KI.md`**, Handover **`docs/HANDOVER.md`** §2.8. +**Parallel weiter relevant:** **Trainingsplan Phasen & Streams** (Migration **063**, Coach + Planung **0.8.137–0.8.140**; Handover **`docs/HANDOVER.md`** §3); **Trainingsrahmenprogramm** (036–037), **Progressionsgraph** (032–034) — siehe **`TRAINING_FRAMEWORK_SPEC.md`**. **Planungs-KI Progressionsgraph** (Roadmap-first, Auto-Optimierung, Katalog-Kontext **0.8.233**, **F15** Match-Dialog + getrennte Pfad-QS lokal): Ist-Doku **`docs/architecture/PLANNING_PROGRESSION_GRAPH_KI.md`**, Handover **`docs/HANDOVER.md`** §2.8. **Referenz:** [`library/FEATURES_DELIVERED_2026-Q2.md`](library/FEATURES_DELIVERED_2026-Q2.md) Abschnitt 12 · Medien-Norm: [`technical/MEDIA_ASSETS_AND_ARCHIVE_SPEC.md`](technical/MEDIA_ASSETS_AND_ARCHIVE_SPEC.md) (inkl. **Abschnitt 11 Inline-Medien**, umgesetzt) · **Fachlicher Nutzerüberblick:** [`../../docs/FACHLICHE_NUTZERFUNKTIONEN.md`](../../docs/FACHLICHE_NUTZERFUNKTIONEN.md) diff --git a/.claude/docs/working/PROGRESSION_GRAPH_SLOT_EDITOR_SPEC.md b/.claude/docs/working/PROGRESSION_GRAPH_SLOT_EDITOR_SPEC.md index 3f5d0cf..571f5f8 100644 --- a/.claude/docs/working/PROGRESSION_GRAPH_SLOT_EDITOR_SPEC.md +++ b/.claude/docs/working/PROGRESSION_GRAPH_SLOT_EDITOR_SPEC.md @@ -1,6 +1,6 @@ -# Progressionsgraph — Slot-Editor (Phase B) +# Progressionsgraph — Slot-Editor (Phase B + F15) -**Stand:** 2026-06-10 · **Status:** In Umsetzung +**Stand:** 2026-05-22 · **Status:** Umgesetzt (F14 + F15 lokal nach 0.8.233) ## Ziel @@ -35,35 +35,52 @@ Leere Slots in der Roadmap sind erlaubt; Kanten nur zwischen aufeinanderfolgende slots: Slot[], // index = major_step_index pathSkillExpectations?, lastFindings?, // path_qa-Snapshot + findingsStale?: boolean, // Bewertung veraltet (↔ Artefakt findings_stale) dirty: boolean, } ``` **Hydration:** `planning_roadmap` + Kanten → Slots; `slot_contents[]` für Entwürfe; Primärkette aus `next_exercise`. -**Speichern:** Batch-Delete bestehender Pfad-/Schwester-Kanten → `edges/sequence` (Primärkette) → einzelne `sibling`-Kanten → `PUT`/`sequence` mit Artefakt inkl. `slot_contents`, optional `last_findings`. +**Speichern:** Batch-Delete bestehender Pfad-/Schwester-Kanten → `edges/sequence` (Primärkette) → einzelne `sibling`-Kanten → `PUT`/`sequence` mit Artefakt inkl. `slot_contents`, `last_findings`, **`findings_stale`**. ## Findings-Panel -Nutzt `path_qa` (`overall_ok`, `quality_score`, `issues`, `recommendations`, `gap_fill_offers`, …). +Nutzt `path_qa`: + +| Feld | Bedeutung | +|------|-----------| +| `quality_score` | Gesamt = **min(`roadmap_qa`, `assignment_qa`)** | +| `roadmap_qa` | Stufen/Roadmap (LLM `topic_coverage`, …) | +| `assignment_qa` | Slot-Befüllung (`empty_slot_count`, …) | +| `overall_ok`, `issues`, `recommendations`, `gap_fill_offers`, … | wie bisher | **API:** `POST /api/planning/progression-path-suggest` mit `evaluate_only: true` und `evaluate_steps[]` — QA ohne Re-Match. -Persistenz: `planning_roadmap.last_findings`. +**Bewertung veraltet:** Jede Graph-Änderung setzt `findingsStale: true` → Banner im Panel. Nach „Graph bewerten“ → `false`. Persistenz: `planning_roadmap.findings_stale`. + +## Match-Flow („Übungen matchen“) + +1. **Schritt 1:** `evaluate_only` + volle Pfad-QS (wie „Graph bewerten“) +2. **Schritt 2:** `unified_slot_review: true` → **`ProgressionOptimizeCompareModal`** +3. Pro Slot: aktuell vs. beste Bibliothek vs. optional KI-Vorschlag +4. **Vorauswahl:** Bibliothek nur wenn Stufen-Fit ≥ 50 % und besser als Baseline; sonst KI (bei leerem/schwachem Slot) +5. **Übernahme:** nur gewählte Slots speichern — **keine** automatische Nach-Bewertung ## Artefakt-Erweiterung (`GraphPlanningRoadmapArtifact`) -Zusätzlich optional: +Optional: - `slot_contents[]` — `{ major_step_index, primary, siblings[] }` - `last_findings` — letzter `path_qa`-Snapshot +- **`findings_stale`** — bool, Bewertung bezieht sich nicht mehr auf aktuellen Graph-Stand ## UI (konsolidiert) - **Eine Oberfläche:** `ExerciseProgressionGraphPanel` embeddet `ProgressionGraphEditor` (Slots + Findings) - Kein separater Slot-Editor, kein 4-Schritt-KI-Wizard, kein `ProgressionChainEditor` im Panel - Route `/progression-graphs/:id` → Redirect nach `/exercises` (Deep-Link wählt Graph) -- **Phase C:** Übersicht mit Kacheln (Name, Start, Ziel) +- **Slot-Keys:** stabil `slot-{index}` (nicht Lernziel-Text) — sonst Fokusverlust beim Tippen ## Ersetzt (Legacy, nicht mehr im Panel) @@ -71,11 +88,14 @@ Zusätzlich optional: ## Implementierungsreihenfolge -| ID | Inhalt | -|----|--------| -| B.0 | Draft + Laden/Speichern Slots ↔ Kanten | -| B.1 | Slot-Karten, Bibliothek + Entwurf | -| B.2 | Findings-Panel + `evaluate_only` | -| B.3 | Entwürfe im Artefakt + „Übung anlegen“ | -| B.4 | Route + Panel vereinfachen | -| B.5 | `last_findings` + Phase-C-Vorbereitung | +| ID | Inhalt | Status | +|----|--------|--------| +| B.0 | Draft + Laden/Speichern Slots ↔ Kanten | ✅ | +| B.1 | Slot-Karten, Bibliothek + Entwurf | ✅ | +| B.2 | Findings-Panel + `evaluate_only` | ✅ | +| B.3 | Entwürfe im Artefakt + „Übung anlegen“ | ✅ | +| B.4 | Route + Panel vereinfachen | ✅ | +| B.5 | `last_findings` + Phase-C-Vorbereitung | ✅ | +| F15 | Unified Slot-Review, getrennte QS, `findings_stale` | ✅ | + +**Ist-Doku:** `docs/architecture/PLANNING_PROGRESSION_GRAPH_KI.md` §8.1 · `docs/HANDOVER.md` §2.8 F15 diff --git a/backend/ai_prompt_planning_preview.py b/backend/ai_prompt_planning_preview.py new file mode 100644 index 0000000..fe123fa --- /dev/null +++ b/backend/ai_prompt_planning_preview.py @@ -0,0 +1,317 @@ +""" +Admin-Vorschau: Platzhalter für Planungs-Prompts (Progressionsgraph, Pfad-QS, Suggest). + +Nutzt repräsentative Beispieldaten + echte Katalog-Auszüge aus der DB. +""" +from __future__ import annotations + +import json +from typing import Any, Dict, List, Mapping, Optional + +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( + { + "planning_progression_start_target", + "planning_progression_goal_analysis", + "planning_progression_roadmap", + "planning_progression_stage_spec", + "planning_exercise_query_semantics", + "planning_exercise_path_qa", + "planning_exercise_search_intent", + "planning_exercise_search_rank", + "planning_exercise_expectation_profile", + } +) + + +class PlanningPromptPreviewInput(BaseModel): + goal_query: str = Field( + default="Mae Geri vom Grundschritt bis zur kontrollierten Kumite-Nähe", + max_length=2000, + ) + 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: + return (slug or "").strip().lower() in PLANNING_PROMPT_SLUGS + + +def _compact_json(obj: Any) -> str: + return json.dumps(obj, ensure_ascii=False, separators=(",", ":")) + + +def _sample_goal_analysis() -> Dict[str, Any]: + return { + "primary_topic": "Mae Geri", + "start_assumption": "Grundstellung und einfache Frontkick-Bewegung bekannt", + "target_state": "Kontrollierter Mae Geri in Kumite-Nähe mit Hüftöffnung", + "success_criteria": [ + "Hüfte öffnet vor dem Kick", + "Ballen trifft Zielzone", + "Rückzug ohne Balanceverlust", + ], + "constraints": { + "partner_required": False, + "excluded_themes": ["reine Kraft ohne Technikbezug"], + "trainer_notes": "Breitensport, kein Wettkampf", + }, + } + + +def _sample_major_steps(max_steps: int) -> List[Dict[str, Any]]: + phases = ["einstieg", "grundlage", "vertiefung", "anwendung", "perfektion"] + titles = [ + "Grundstellung und Mae Geri Einstieg", + "Hüftöffnung und Ballen-Fokus", + "Koordination und Rückzug", + "Anwendung in Partnerübung", + "Qualität unter leichtem Druck", + ] + out: List[Dict[str, Any]] = [] + for i in range(max_steps): + out.append( + { + "index": i, + "phase": phases[min(i, len(phases) - 1)], + "title": titles[min(i, len(titles) - 1)], + "learning_goal": titles[min(i, len(titles) - 1)], + } + ) + return out + + +def _sample_path_steps() -> List[Dict[str, Any]]: + return [ + { + "index": 1, + "exercise_id": 101, + "title": "Mae Geri — Stand und Hüftöffnung", + "goal": "Frontkick mit geöffneter Hüfte aus Grundstellung", + "is_bridge": False, + "is_ai_proposal": False, + "reasons": ["Stufen-Gate: Grundlagen"], + }, + { + "index": 2, + "exercise_id": 102, + "title": "Mae Geri — Ballen und Rückzug", + "goal": "Präziser Ballentreffer mit kontrolliertem Rückzug", + "is_bridge": False, + "is_ai_proposal": False, + "reasons": ["Nachfolger im Graph"], + }, + ] + + +def _sample_planning_context() -> Dict[str, Any]: + return { + "scope": "progression_path", + "goal_query": "Mae Geri vom Grundschritt bis zur Kumite-Nähe", + "stage_index": 1, + "learning_goal": "Hüftöffnung und Ballen-Fokus", + } + + +def _sample_target_profile() -> Dict[str, Any]: + return { + "primary_focus": "Kihon", + "training_type": "Breitensport", + "skill_expectations": ["Geri Waza", "Koordination"], + } + + +def _sample_candidates() -> List[Dict[str, Any]]: + return [ + { + "exercise_id": 101, + "title": "Mae Geri — Stand und Hüftöffnung", + "summary": "Frontkick mit Hüftöffnung", + "skill_names": ["Geri Waza"], + "score_hint": 0.82, + }, + { + "exercise_id": 102, + "title": "Mae Geri — Ballen und Rückzug", + "summary": "Ballentreffer mit Rückzug", + "skill_names": ["Geri Waza", "Koordination"], + "score_hint": 0.76, + }, + ] + + +def _load_catalog_variables(cur) -> Dict[str, str]: + from planning_exercise_intent import ( + _load_compact_catalog, + _load_skills_catalog_compact, + ) + + return { + "skills_catalog_json": _compact_json(_load_skills_catalog_compact(cur)), + "focus_areas_catalog_json": _compact_json(_load_compact_catalog(cur, "focus_areas", "id")), + "training_types_catalog_json": _compact_json(_load_compact_catalog(cur, "training_types", "id")), + "style_directions_catalog_json": _compact_json(_load_compact_catalog(cur, "style_directions", "id")), + "target_groups_catalog_json": _compact_json(_load_compact_catalog(cur, "target_groups", "id")), + } + + +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, + body: PlanningPromptPreviewInput, +) -> Dict[str, str]: + """Mustache-Variablen für Planungs-Prompt-Vorschau im Admin.""" + s = (slug or "").strip().lower() + if s not in PLANNING_PROMPT_SLUGS: + raise ValueError(f"Kein Planungs-Prompt-Slug: {slug!r}") + + goal_query = (body.goal_query or "").strip() or "Mae Geri Progression" + search_query = (body.search_query or "").strip() or goal_query + max_steps = int(body.max_steps) + brief = build_semantic_brief(goal_query) + brief_json = _compact_json(brief_to_summary_dict(brief)) + goal_analysis = _sample_goal_analysis() + major_steps = _sample_major_steps(max_steps) + intent_ctx = build_planning_intent_context( + goal_query=goal_query, + goal_analysis=goal_analysis, + semantic_brief=brief, + extra_context=(body.user_notes or "").strip() or None, + ) + intent_ctx_json = _compact_json(intent_ctx.to_api_dict()) + ctx = _sample_planning_context() + target = _sample_target_profile() + catalogs = _load_catalog_variables(cur) + + if s == "planning_progression_start_target": + 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 _merge_catalog_preview( + cur, + s, + { + "goal_query": goal_query, + "semantic_brief_json": brief_json, + }, + body, + ) + + if s == "planning_progression_roadmap": + 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 _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 { + "search_query": search_query, + "semantic_brief_json": brief_json, + } + + if s == "planning_exercise_path_qa": + 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 { + "search_query": search_query, + "heuristic_intent": "progression_next", + "scenario_hint": "preset_next", + "planning_context_json": _compact_json(ctx), + "target_profile_json": _compact_json(target), + **catalogs, + } + + if s == "planning_exercise_search_rank": + return { + "search_query": search_query, + "intent": "progression_next", + "planning_context_json": _compact_json(ctx), + "target_profile_json": _compact_json(target), + "candidates_json": _compact_json(_sample_candidates()), + "result_limit": "5", + } + + if s == "planning_exercise_expectation_profile": + return { + "heuristic_intent": "suggest_next", + "planning_context_json": _compact_json(ctx), + "target_profile_json": _compact_json(target), + **{k: v for k, v in catalogs.items() if k != "style_directions_catalog_json"}, + } + + raise ValueError(f"Planungs-Prompt-Slug nicht implementiert: {slug!r}") + + +__all__ = [ + "PLANNING_PROMPT_SLUGS", + "PlanningPromptPreviewInput", + "is_planning_prompt_slug", + "resolve_planning_prompt_preview_variables", +] diff --git a/backend/catalog_prompt_slots.py b/backend/catalog_prompt_slots.py new file mode 100644 index 0000000..de7e993 --- /dev/null +++ b/backend/catalog_prompt_slots.py @@ -0,0 +1,432 @@ +""" +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, +) +from catalog_slot_fallbacks import merge_stored_slots_with_fallbacks + +# --------------------------------------------------------------------------- +# 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 _resolve_entry_slot_values( + stored: Mapping[str, str], + row: Mapping[str, Any], + catalog_kind: str, +) -> Dict[str, str]: + """DB → Namens-Fallback → Stammdaten-Beschreibung (nur description).""" + return merge_stored_slots_with_fallbacks( + stored, + catalog_kind=catalog_kind, + name=str(row.get("name") or ""), + stammdaten_description=str(row.get("description") or ""), + ) + + +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 = _resolve_entry_slot_values(stored, row, cfg.kind) + return { + "catalog_kind": cfg.kind, + "catalog_id": int(catalog_id), + "name": row["name"], + "slots": merged, + "stored_slots": {k: stored.get(k, "") for k in SLOT_KEYS}, + } + + +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 = _resolve_entry_slot_values(stored, row, cfg.kind) + for sk in SLOT_KEYS: + pk = placeholder_key(cfg.kind, sk) + text = slot_values.get(sk, "") + variables[pk] = text + if text.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()], + "stored_slots": [k for k in SLOT_KEYS if (stored.get(k) or "").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) + row = _load_catalog_row(cur, cfg.table, active.id) + if not row: + continue + slot_values = _resolve_entry_slot_values(stored, row, cfg.kind) + guard = (slot_values.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/catalog_slot_fallbacks.py b/backend/catalog_slot_fallbacks.py new file mode 100644 index 0000000..ecc9323 --- /dev/null +++ b/backend/catalog_slot_fallbacks.py @@ -0,0 +1,284 @@ +""" +Namensbasierte Fallback-Slots — bis Admin/DB befüllt sind (H1-Registry-Inhalt). + +DB-Werte in catalog_prompt_slots haben immer Vorrang. Fallbacks füllen nur leere Slot-Keys. +""" +from __future__ import annotations + +import re +import unicodedata +from typing import Dict, Mapping, Optional, Sequence, Tuple + +_UMLAUT_MAP = str.maketrans({"ä": "ae", "ö": "oe", "ü": "ue", "ß": "ss", "Ä": "ae", "Ö": "oe", "Ü": "ue"}) + +SlotPack = Dict[str, str] + +# (catalog_kind, name_pattern_lower) — erste passende Regel gewinnt; * = Default pro Kind +_FALLBACK_RULES: Tuple[Tuple[str, str, SlotPack], ...] = ( + # --- focus_area --- + ( + "focus_area", + "gewaltschutz", + { + "description": ( + "Planung zielt auf Prävention, Deeskalation, Grenzen und sichere Übungsformen — " + "nicht auf Wettkampf-Perfektion oder Technik-Show." + ), + "hints_on_progression": ( + "Phasen: Wahrnehmung → Grenzen → Deeskalation → sichere Übungsformen; " + "keine Kumite-Perfektionsstufen erzwingen." + ), + "hints_on_exercise": ( + "Übungen mit Rollen, Kommunikation, Ausweichen; keine rein technischen Kick-Fokus-Inseln ohne Bezug." + ), + "hints_on_path_qa": ( + "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.", + }, + ), + ( + "focus_area", + "selbstverteidigung", + { + "description": ( + "Praktische Selbstverteidigung: realistische Szenarien, Sicherheit und " + "anwendungsnahe Progression — nicht Show-Technik oder Wettkampf-Kata." + ), + "hints_on_progression": ( + "Von Wahrnehmung und Distanz zu einfachen Abwehrmustern und kontrollierter Anwendung." + ), + "hints_on_exercise": "Partnerübungen mit klaren Sicherheitsregeln; Szenario-Bezug wichtiger als Stil-Show.", + "hints_on_path_qa": ( + "Lücken bei Szenario- oder Sicherheitsstufen sind relevant; " + "fehlende Kick-Varianten oder Wettkampftiefe sind kein Mangel." + ), + "anti_patterns": "Keine Wettkampf- oder Kata-Perfektion als QS-Maßstab.", + }, + ), + ( + "focus_area", + "fitness", + { + "description": ( + "Fitness- und Konditionsorientierung mit sicherer Belastungssteuerung; " + "Technikbezug nur wo fachlich sinnvoll." + ), + "hints_on_progression": "Progression von niedriger zu moderater Belastung; klare Pausen und Technikhygiene.", + "hints_on_path_qa": ( + "Keine Wettkampf-Spezialisierung als Pflicht-Kriterium; " + "Belastungssteigerung ohne Technikbezug abwerten." + ), + "anti_patterns": "Keine Kumite-Perfektion oder Wettkampf-Kombinationen als QS-Maßstab verlangen.", + }, + ), + ( + "focus_area", + "karate", + { + "description": ( + "Technik-Curriculum im Karate-Kontext: aufeinander aufbauende Kihon-Progression " + "mit klaren Qualitätsankern (Stand, Hüfte, Kime)." + ), + "hints_on_progression": ( + "Typische Phasen: Einstieg → Grundlagen → Koordination/Kraft → Anwendung → optional Vertiefung; " + "Grundlagen vor Perfektion." + ), + "hints_on_exercise": ( + "Kihon und Partnerübungen mit Technikbezug; reine Kraft-/Ausdauer-Inseln nur mit klarer Begründung." + ), + "hints_on_path_qa": ( + "Kohärente Progression Grundlagen → Anwendung → Vertiefung; " + "Übergänge ohne Sprünge; themenfremde Kraft-/Ausdauer-Inseln abwerten." + ), + "anti_patterns": "Keine pauschale Perfektions-Stufe verlangen, wenn Kontext Breitensport ist.", + }, + ), + ( + "focus_area", + "*", + { + "description": "Technik- oder Themen-Curriculum mit didaktisch aufeinander aufbauenden Stufen.", + "hints_on_progression": "Grundlagen vor Anwendung; moderate Sprünge zwischen Stufen vermeiden.", + "hints_on_path_qa": ( + "Kohärente Progression zum Anfrage-Thema; " + "Lücken sind fehlende Zwischenstufen im Lernpfad, nicht fehlende Nebenthemen." + ), + "hints_on_exercise": "Übungen mit klarem Bezug zum Pfad-Thema und zur Stufe.", + }, + ), + # --- training_type --- + ( + "training_type", + "breitensport", + { + "description": ( + "Partizipation, Verständlichkeit, Freude am Bewegen; weniger maximale Spezialisierung." + ), + "hints_on_progression": "Moderater Schwierigkeitsanstieg; Perfektionsphasen optional.", + "hints_on_path_qa": ( + "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.", + }, + ), + ( + "training_type", + "leistungssport", + { + "description": "Leistungsorientiertes Training mit höherer Anspruchskurve und Spezialisierung.", + "hints_on_progression": "Belastungs- und Kombinationsprogressionen sind erwünscht.", + "hints_on_path_qa": ( + "Höhere Anspruchskurven sind passend; Lücken in Spezialisierung können echte Hinweise sein." + ), + }, + ), + ( + "training_type", + "wettkampf", + { + "description": ( + "Wettkampforientiertes Training mit höherer Anspruchskurve und belastungsnahen Phasen." + ), + "hints_on_progression": "Anwendungs- und Druckphasen zeitig einplanen.", + "hints_on_path_qa": ( + "Spezialisierung, Kombination und Belastung unter Druck sind relevant; " + "Lücken in Anwendungs- oder Perfektionsphasen können echte Hinweise sein." + ), + }, + ), + ( + "training_type", + "*", + { + "hints_on_path_qa": "Didaktische Kohärenz wichtiger als maximale Spezialisierung — Kontext beachten.", + }, + ), + # --- target_group --- + ( + "target_group", + "kinder", + { + "description": ( + "Kinder: kurze Einheiten, spielerische Einstiege, Sicherheit und altersgerechte Komplexität." + ), + "hints_on_progression": "Spielerische Einstiege; kurze Abschnitte; Sicherheit vor Perfektion.", + "hints_on_path_qa": ( + "Didaktik ohne Überforderung; klare Regeln und Sicherheit vor Perfektion; " + "Lücken bei Spiel-/Rollenelementen wichtiger als Wettkampftiefe." + ), + "anti_patterns": "Keine Erwachsenen-Wettkampf-Perfektion als QS-Maßstab.", + }, + ), + ( + "target_group", + "leistungssportler", + { + "description": "Leistungsgruppe: höhere Anspruchskurven und Spezialisierung sind fachlich passend.", + "hints_on_progression": "Anspruchskurve und Spezialisierung dürfen steiler sein.", + "hints_on_path_qa": ( + "Höhere Anspruchskurven, Belastungs- und Kombinationsprogressionen sind relevant; " + "Lücken in Spezialisierung können echte Hinweise sein." + ), + }, + ), + ( + "target_group", + "breitensportler", + { + "description": "Breitensport: Partizipation und Verständlichkeit vor maximaler Spezialisierung.", + "hints_on_path_qa": ( + "Moderate Progression; Perfektions-Lücken sind selten echte Mängel." + ), + "anti_patterns": "Keine Leistungssport-Perfektion als Pflicht-Kriterium.", + }, + ), + ( + "target_group", + "*", + { + "hints_on_path_qa": "Zielgruppe im Tempo und in der Komplexität berücksichtigen.", + }, + ), + # --- style_direction --- + ( + "style_direction", + "shotokan", + { + "description": ( + "Shotokan-Linie: klare Kihon-Struktur, Hüft- und Standarbeit als wiederkehrende Qualitätsanker." + ), + "hints_on_progression": "Nuancen in Stellung und Hüfttechnik; kein neuer Planungstyp.", + "hints_on_path_qa": "Konsistenz von Stand, Hüfte und Kime entlang des Pfads bewerten.", + }, + ), + ( + "style_direction", + "*", + { + "hints_on_progression": ( + "Stil-spezifische Nuancen (Stand, Hüfte, Rhythmus) einbeziehen — ohne Stilwechsel zu erzwingen." + ), + }, + ), +) + + +def normalize_catalog_name_key(name: str) -> str: + s = unicodedata.normalize("NFKD", (name or "").translate(_UMLAUT_MAP)) + s = s.encode("ascii", "ignore").decode("ascii").lower() + s = re.sub(r"[^a-z0-9]+", "_", s).strip("_") + return s or "unknown" + + +def get_fallback_slots_for_entry(catalog_kind: str, name: str) -> SlotPack: + kind = (catalog_kind or "").strip().lower() + norm = normalize_catalog_name_key(name) + default: SlotPack = {} + for rule_kind, pattern, pack in _FALLBACK_RULES: + if rule_kind != kind: + continue + if pattern == "*": + default = dict(pack) + continue + if pattern in norm or norm.startswith(pattern) or pattern in (name or "").lower(): + return dict(pack) + return default + + +def merge_stored_slots_with_fallbacks( + stored: Mapping[str, str], + *, + catalog_kind: str, + name: str, + stammdaten_description: str = "", +) -> Dict[str, str]: + """DB + Stammdaten-Beschreibung + Namens-Fallback.""" + fallbacks = get_fallback_slots_for_entry(catalog_kind, name) + out: Dict[str, str] = {} + for key in ( + "description", + "hints_on_progression", + "hints_on_exercise", + "hints_on_path_qa", + "anti_patterns", + "rematch_guard", + ): + if key == "description": + out[key] = ( + (stored.get(key) or "").strip() + or (fallbacks.get(key) or "").strip() + or (stammdaten_description or "").strip() + ) + else: + out[key] = (stored.get(key) or "").strip() or (fallbacks.get(key) or "").strip() + return out + + +__all__ = [ + "get_fallback_slots_for_entry", + "merge_stored_slots_with_fallbacks", + "normalize_catalog_name_key", +] 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..5178054 --- /dev/null +++ b/backend/migrations/092_catalog_prompt_slots.sql @@ -0,0 +1,86 @@ +-- Migration 092: Katalog-Prompt-Slots (H2) — Slot-Typ-Vokabular + leere Wertetabelle +-- Keine Inhalts-Seeds: Stammdaten (Primärfokus etc.) sind mandantenspezifisch. +-- Slot-Inhalte: Admin-UI oder catalog_slot_fallbacks.py (Namens-Match zur Laufzeit). + +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; 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/migrations/094_catalog_prompt_slots_full_seed.sql b/backend/migrations/094_catalog_prompt_slots_full_seed.sql new file mode 100644 index 0000000..e9d0189 --- /dev/null +++ b/backend/migrations/094_catalog_prompt_slots_full_seed.sql @@ -0,0 +1,11 @@ +-- Migration 094: bewusst ohne Daten-Seed (ersetzt frühere Voll-Befüllung) +-- +-- Katalog-Stammdaten (Primärfokus, Trainingsstile, …) sind pro Umgebung unterschiedlich. +-- Migrationen dürfen keine Dev-Standardtexte an Prod-Kataloge hängen. +-- +-- Slot-Inhalte stattdessen: +-- • Admin-UI (catalog_prompt_slots pro Eintrag) +-- • Laufzeit-Fallback catalog_slot_fallbacks.py (Namens-Match, kein DB-Schreiben) +-- • optional: backend/scripts/seed_catalog_prompt_slots_dev.py (nur lokale Dev-DB) + +SELECT 1; diff --git a/backend/migrations/095_catalog_prompt_slots_clear_migration_seeds.sql b/backend/migrations/095_catalog_prompt_slots_clear_migration_seeds.sql new file mode 100644 index 0000000..3e84e26 --- /dev/null +++ b/backend/migrations/095_catalog_prompt_slots_clear_migration_seeds.sql @@ -0,0 +1,7 @@ +-- Migration 095: Entfernt per 092/094 (alte Version) eingespielte Standard-Slot-Texte +-- +-- Betrifft Dev-Umgebungen, die die frühen Seed-Migrationen bereits erhalten haben. +-- Prod mit eigener Katalogstruktur: Tabelle bleibt leer bis Admin-Inhalte gepflegt werden. +-- Manuell in der Admin-UI gesetzte Texte nach 095 bitte erneut prüfen (waren ggf. identisch mit Seeds). + +DELETE FROM catalog_prompt_slots; diff --git a/backend/openrouter_chat.py b/backend/openrouter_chat.py index 8a7dd88..516b2dc 100644 --- a/backend/openrouter_chat.py +++ b/backend/openrouter_chat.py @@ -196,6 +196,13 @@ def openrouter_chat_completion( cc, ) + try: + from planning_llm_usage import record_planning_llm_call + + record_planning_llm_call(1) + except Exception: + pass + return joined 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 618a467..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( @@ -2208,6 +2213,7 @@ def _run_evaluate_only_path_qa( reorder_notes=[], roadmap_qa_mode=roadmap_qa_mode, multistage_qa=multistage_qa, + steps=steps, ) return { "path_qa": path_qa, @@ -2500,6 +2506,7 @@ def _quick_evaluate_steps_qa( llm_applied=False, roadmap_qa_mode="roadmap_first_lite" if roadmap_first else None, multistage_qa=multistage_qa, + steps=steps_list, ) if path_qa.get("quality_score") is None: path_qa["quality_score"] = compute_deterministic_path_quality_score( @@ -3072,6 +3079,7 @@ def _suggestion_as_slot_diff(entry: Mapping[str, Any]) -> Dict[str, Any]: _SLOT_FIT_POOR_THRESHOLD = 0.30 +_SLOT_FIT_GOOD_THRESHOLD = 0.50 def _off_topic_semantic_scores_by_slot( @@ -3152,9 +3160,18 @@ def _slot_auto_select_library( return False if proposed_slot_score is None: return False - if baseline_slot_score is None: - return True - return float(proposed_slot_score) > float(baseline_slot_score) + 0.001 + effective_baseline = float(baseline_slot_score) if baseline_slot_score is not None else 0.0 + if float(proposed_slot_score) <= effective_baseline + 0.001: + return False + # Leerer Slot: Bibliothek nur vorauswählen, wenn Stufen-Fit klar ausreicht. + if baseline_exercise_id is None: + return float(proposed_slot_score) >= _SLOT_FIT_GOOD_THRESHOLD + return True + + +def _slot_auto_select_ai(*, library_auto_select: bool, has_ai: bool) -> bool: + """KI-Vorschlag vorauswählen, wenn angeboten und Bibliothek nicht klar besser.""" + return bool(has_ai and not library_auto_select) def _build_unified_slot_review_entry( @@ -3391,10 +3408,14 @@ def _build_unified_slot_review_entry( ) gap_fill_offers.append(slot_offer) if slot_offer: + ai_auto = _slot_auto_select_ai( + library_auto_select=bool(library_alt and library_alt.get("auto_select")), + has_ai=True, + ) ai_alt = { "title_hint": slot_offer.get("title_hint") or f"Slot {major_idx + 1}", "gap_offer": slot_offer, - "auto_select": False, + "auto_select": ai_auto, } return { @@ -3812,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: @@ -3836,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: @@ -3847,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) @@ -3907,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, @@ -3937,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: @@ -4136,6 +4161,7 @@ def suggest_progression_path( steps=steps, gaps=gaps, bridge_inserts=bridge_inserts, + catalog=catalog_context, ) if ( @@ -4224,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( @@ -4312,6 +4339,7 @@ def suggest_progression_path( reorder_notes=reorder_notes, roadmap_qa_mode=roadmap_qa_mode, multistage_qa=multistage_qa, + steps=steps, ) if rematch_log: path_qa["rematch_applied"] = True diff --git a/backend/planning_exercise_path_qa.py b/backend/planning_exercise_path_qa.py index 48770a1..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) @@ -688,6 +696,160 @@ def find_step_pair_index( return None +def count_step_assignment_stats(steps: Optional[Sequence[Mapping[str, Any]]]) -> Dict[str, int]: + stats = {"total": 0, "empty": 0, "library_filled": 0, "ai_proposal": 0} + for raw in steps or []: + if not isinstance(raw, dict): + continue + stats["total"] += 1 + if raw.get("exercise_id") is not None and not raw.get("is_ai_proposal"): + stats["library_filled"] += 1 + elif raw.get("is_ai_proposal"): + stats["ai_proposal"] += 1 + else: + stats["empty"] += 1 + return stats + + +def compute_assignment_quality_score( + *, + steps: Optional[Sequence[Mapping[str, Any]]] = None, + off_topic_steps: Optional[Sequence[Mapping[str, Any]]] = None, + gaps: Optional[Sequence[Mapping[str, Any]]] = None, +) -> float: + """QS der Übungsbesetzung — leere Slots stark abwerten.""" + stats = count_step_assignment_stats(steps) + total = stats["total"] + if total <= 0: + return 0.45 + empty = stats["empty"] + library = stats["library_filled"] + ai = stats["ai_proposal"] + fill_credit = (library + 0.55 * ai) / total + score = 0.1 + 0.84 * fill_credit + if empty > 0: + score -= 0.22 * (empty / total) + score -= 0.08 * len(off_topic_steps or []) + score -= 0.03 * len(gaps or []) + return max(0.08, min(0.98, round(score, 4))) + + +def compute_roadmap_quality_score( + *, + llm_qa: Optional[Mapping[str, Any]] = None, + llm_applied: bool = False, + gaps: Optional[Sequence[Mapping[str, Any]]] = None, + multistage_qa: Optional[Mapping[str, Any]] = None, +) -> float: + """QS der Roadmap-/Stufenlogik — unabhängig von Slot-Befüllung.""" + if llm_applied and llm_qa and llm_qa.get("quality_score") is not None: + try: + return max(0.08, min(0.98, round(float(llm_qa["quality_score"]), 4))) + except (TypeError, ValueError): + pass + score = 0.9 + score -= 0.05 * len(gaps or []) + hint_count = int((multistage_qa or {}).get("optimization_hint_count") or 0) + score -= min(0.12, 0.015 * hint_count) + return max(0.35, min(0.98, round(score, 4))) + + +def build_assignment_qa_snapshot( + *, + steps: Optional[Sequence[Mapping[str, Any]]] = None, + off_topic_steps: Optional[Sequence[Mapping[str, Any]]] = None, + gaps: Optional[Sequence[Mapping[str, Any]]] = None, +) -> Dict[str, Any]: + off_topic = list(off_topic_steps or []) + stats = count_step_assignment_stats(steps) + score = compute_assignment_quality_score( + steps=steps, + off_topic_steps=off_topic, + gaps=gaps, + ) + issues: List[str] = [] + if stats["empty"] > 0: + issues.append( + f"{stats['empty']} von {stats['total']} Slot(s) ohne Übung — bitte Bibliothek oder KI-Vorschlag zuweisen", + ) + if stats["ai_proposal"] > 0 and stats["library_filled"] == 0 and stats["empty"] > 0: + issues.append( + f"{stats['ai_proposal']} KI-Entwurf(e), aber noch {stats['empty']} leere Slot(s)", + ) + for item in off_topic[:5]: + title = (item.get("title") or "Schritt").strip() + issues.append(f"„{title}“ passt nicht zum Stufen-Ziel") + overall_ok = stats["empty"] == 0 and len(off_topic) == 0 + return { + "overall_ok": overall_ok, + "quality_score": score, + "slot_count": stats["total"], + "empty_slot_count": stats["empty"], + "library_filled_count": stats["library_filled"], + "ai_proposal_count": stats["ai_proposal"], + "issues": issues, + } + + +def build_roadmap_qa_snapshot( + *, + llm_qa: Optional[Mapping[str, Any]] = None, + llm_applied: bool = False, + gaps: Optional[Sequence[Mapping[str, Any]]] = None, + multistage_qa: Optional[Mapping[str, Any]] = None, + roadmap_qa_mode: Optional[str] = None, +) -> Dict[str, Any]: + score = compute_roadmap_quality_score( + llm_qa=llm_qa, + llm_applied=llm_applied, + gaps=gaps, + multistage_qa=multistage_qa, + ) + issues: List[str] = [] + if not llm_applied: + for gap in gaps or []: + issues.append( + f"Übergang „{gap.get('from_title')}“ → „{gap.get('to_title')}“ schwach (Score {gap.get('gap_score')})", + ) + if llm_applied and llm_qa: + issues.extend(str(x).strip() for x in (llm_qa.get("issues") or []) if str(x).strip()) + overall_ok = bool(llm_qa.get("overall_ok", True)) if llm_applied and llm_qa else len(gaps or []) == 0 + snapshot: Dict[str, Any] = { + "overall_ok": overall_ok, + "quality_score": score, + "issues": issues[:8], + "llm_applied": bool(llm_applied), + "roadmap_qa_mode": roadmap_qa_mode, + } + if llm_applied and llm_qa: + snapshot["topic_coverage"] = llm_qa.get("topic_coverage") + snapshot["recommendations"] = list(llm_qa.get("recommendations") or []) + snapshot["sequence_notes"] = list(llm_qa.get("sequence_notes") or []) + return snapshot + + +def merge_path_quality_scores( + roadmap_qa: Mapping[str, Any], + assignment_qa: Mapping[str, Any], +) -> float: + """Gesamt-QS: schwächere Dimension begrenzt — leere Slots senken den Pfad deutlich.""" + try: + roadmap_score = float(roadmap_qa.get("quality_score")) + except (TypeError, ValueError): + roadmap_score = None + try: + assignment_score = float(assignment_qa.get("quality_score")) + except (TypeError, ValueError): + assignment_score = None + if roadmap_score is not None and assignment_score is not None: + return round(min(roadmap_score, assignment_score), 4) + if assignment_score is not None: + return assignment_score + if roadmap_score is not None: + return roadmap_score + return 0.5 + + def build_path_qa_summary( *, gaps: Sequence[Mapping[str, Any]], @@ -702,6 +864,7 @@ def build_path_qa_summary( reorder_notes: Optional[Sequence[str]] = None, roadmap_qa_mode: Optional[str] = None, multistage_qa: Optional[Mapping[str, Any]] = None, + steps: Optional[Sequence[Mapping[str, Any]]] = None, ) -> Dict[str, Any]: offers = list(gap_fill_offers or []) off_topic = list(off_topic_steps or []) @@ -726,31 +889,32 @@ def build_path_qa_summary( summary["qa_tiers"] = list(multistage_qa.get("qa_tiers") or []) summary["optimization_hints"] = list(multistage_qa.get("optimization_hints") or []) summary["optimization_hint_count"] = int(multistage_qa.get("optimization_hint_count") or 0) + + assignment_qa = build_assignment_qa_snapshot( + steps=steps, + off_topic_steps=off_topic, + gaps=gaps, + ) + roadmap_qa = build_roadmap_qa_snapshot( + llm_qa=llm_qa, + llm_applied=llm_applied, + gaps=gaps, + multistage_qa=multistage_qa, + roadmap_qa_mode=roadmap_qa_mode, + ) + summary["assignment_qa"] = assignment_qa + summary["roadmap_qa"] = roadmap_qa + summary["quality_score"] = merge_path_quality_scores(roadmap_qa, assignment_qa) + summary["overall_ok"] = bool( + assignment_qa.get("overall_ok") + and roadmap_qa.get("overall_ok", True), + ) + summary["topic_coverage"] = roadmap_qa.get("topic_coverage") + summary["recommendations"] = list(roadmap_qa.get("recommendations") or []) + summary["sequence_notes"] = list(roadmap_qa.get("sequence_notes") or []) + summary["issues"] = list(assignment_qa.get("issues") or []) + list(roadmap_qa.get("issues") or [])[:6] if llm_qa: - summary["overall_ok"] = bool(llm_qa.get("overall_ok", True)) - summary["quality_score"] = llm_qa.get("quality_score") - summary["issues"] = list(llm_qa.get("issues") or []) - summary["sequence_notes"] = list(llm_qa.get("sequence_notes") or []) - summary["topic_coverage"] = llm_qa.get("topic_coverage") - summary["recommendations"] = list(llm_qa.get("recommendations") or []) summary["suggested_new_exercises"] = list(llm_qa.get("suggested_new_exercises") or []) - else: - summary["overall_ok"] = len(gaps) == 0 and len(off_topic) == 0 - summary["issues"] = [ - f"Lücke zwischen „{g.get('from_title')}“ und „{g.get('to_title')}“ (Score {g.get('gap_score')})" - for g in gaps - ] if gaps else [] - if off_topic: - summary["issues"] = list(summary["issues"]) + [ - f"Schritt „{o.get('title')}“ passt nicht zum Pfad-Thema" - for o in off_topic - ] - summary["quality_score"] = compute_deterministic_path_quality_score( - gaps=gaps, - off_topic_steps=off_topic, - steps=steps, - multistage_qa=multistage_qa, - ) return summary @@ -761,31 +925,34 @@ def compute_deterministic_path_quality_score( steps: Optional[Sequence[Mapping[str, Any]]] = None, multistage_qa: Optional[Mapping[str, Any]] = None, ) -> float: - """Heuristische Pfad-QS ohne LLM — Basis für Slot-Vergleiche.""" - score = 0.92 - score -= 0.08 * len(off_topic_steps or []) - score -= 0.05 * len(gaps or []) - if steps: - empty = sum( - 1 - for s in steps - if isinstance(s, dict) - and s.get("exercise_id") is None - and not s.get("is_ai_proposal") - ) - score -= 0.06 * empty - hint_count = int((multistage_qa or {}).get("optimization_hint_count") or 0) - score -= min(0.14, 0.02 * hint_count) - return max(0.35, min(0.98, round(score, 4))) + """Heuristische Pfad-QS ohne LLM — Roadmap + Besetzung kombiniert.""" + roadmap_qa = build_roadmap_qa_snapshot( + llm_qa=None, + llm_applied=False, + gaps=gaps, + multistage_qa=multistage_qa, + ) + assignment_qa = build_assignment_qa_snapshot( + steps=steps, + off_topic_steps=off_topic_steps, + gaps=gaps, + ) + return merge_path_quality_scores(roadmap_qa, assignment_qa) __all__ = [ "apply_llm_path_reorder", + "build_assignment_qa_snapshot", "build_path_qa_summary", + "build_roadmap_qa_snapshot", + "compute_assignment_quality_score", "compute_deterministic_path_quality_score", + "compute_roadmap_quality_score", + "count_step_assignment_stats", "detect_off_topic_steps", "detect_path_gaps", "is_roadmap_planned_neighbor_pair", + "merge_path_quality_scores", "strip_off_topic_steps_from_path", "find_step_pair_index", "insert_bridge_exercises", diff --git a/backend/planning_llm_usage.py b/backend/planning_llm_usage.py new file mode 100644 index 0000000..567c3ba --- /dev/null +++ b/backend/planning_llm_usage.py @@ -0,0 +1,62 @@ +""" +Zähler für produktive OpenRouter-Aufrufe innerhalb einer Planungs-API-Anfrage. + +Wird per ContextVar gesetzt (Router: ``planning_llm_call_meter``); ``openrouter_chat_completion`` +erhöht den Zähler nach erfolgreicher Antwort — nur wenn ein Meter aktiv ist. +""" +from __future__ import annotations + +from contextlib import contextmanager +from contextvars import ContextVar +from typing import Iterator, Optional + +_llm_call_counter: ContextVar[Optional["PlanningLlmCallCounter"]] = ContextVar( + "planning_llm_call_counter", + default=None, +) + + +class PlanningLlmCallCounter: + """Anzahl erfolgreicher OpenRouter-Chat-Completions in einem Request-Kontext.""" + + __slots__ = ("count",) + + def __init__(self) -> None: + self.count = 0 + + def record(self, amount: int = 1) -> None: + try: + n = int(amount) + except (TypeError, ValueError): + n = 1 + if n > 0: + self.count += n + + +def current_planning_llm_call_counter() -> Optional[PlanningLlmCallCounter]: + return _llm_call_counter.get() + + +def record_planning_llm_call(amount: int = 1) -> None: + counter = _llm_call_counter.get() + if counter is not None: + counter.record(amount) + + +@contextmanager +def planning_llm_call_meter() -> Iterator[PlanningLlmCallCounter]: + """Aktiviert LLM-Zählung für den umschlossenen Block (inkl. verschachtelter Aufrufe).""" + counter = PlanningLlmCallCounter() + token = _llm_call_counter.set(counter) + try: + yield counter + finally: + _llm_call_counter.reset(token) + + +__all__ = [ + "PlanningLlmCallCounter", + "current_planning_llm_call_counter", + "planning_llm_call_meter", + "record_planning_llm_call", +] 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 2a4e106..44861f2 100644 --- a/backend/routers/ai_prompts_admin.py +++ b/backend/routers/ai_prompts_admin.py @@ -14,9 +14,15 @@ from auth import require_auth from club_tenancy import is_superadmin from ai_prompt_context import ExerciseFormAiPromptContext from ai_prompt_job import resolve_exercise_form_variables +from ai_prompt_planning_preview import ( + PlanningPromptPreviewInput, + is_planning_prompt_slug, + resolve_planning_prompt_preview_variables, +) 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"]) @@ -62,12 +68,22 @@ class AiPromptUpdateBody(BaseModel): class AiPromptPreviewBody(ExerciseFormAiPromptContext): - """Preview-POST: gleiche Felder wie ExerciseFormAiPromptContext (focus_hint, nicht focus_area_hint).""" + """Preview-POST: Übungs-KI und Planungs-Prompts.""" + + goal_query: Optional[str] = Field(default=None, max_length=2000) + user_notes: Optional[str] = Field(default=None, max_length=2000) + max_steps: Optional[int] = Field(default=None, ge=2, le=10) + search_query: Optional[str] = Field(default=None, max_length=2000) @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") @@ -223,6 +239,17 @@ def preview_ai_prompt(prompt_id: int, body: AiPromptPreviewBody, session: dict = vars_map = resolve_exercise_form_variables(cur, slug, body) except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) from e + elif is_planning_prompt_slug(slug): + planning_in = PlanningPromptPreviewInput( + goal_query=(body.goal_query or "Mae Geri vom Grundschritt bis zur Kumite-Nähe").strip(), + user_notes=(body.user_notes or "").strip(), + max_steps=body.max_steps if body.max_steps is not None else 5, + search_query=(body.search_query or body.goal_query or "").strip() or None, + ) + try: + vars_map = resolve_planning_prompt_preview_variables(cur, slug, planning_in) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) from e elif slug == "pipeline": vars_map = {} warn = "Pipeline-Slug: keine Kontextsubstitution fuer Vorschau." diff --git a/backend/routers/catalog_prompt_slots.py b/backend/routers/catalog_prompt_slots.py new file mode 100644 index 0000000..b843a70 --- /dev/null +++ b/backend/routers/catalog_prompt_slots.py @@ -0,0 +1,97 @@ +""" +API: Katalog-Prompt-Slots (Stammdaten × Slot-Typ). + +Globaler Admin-Katalog (wie catalogs.py) — require_auth + Admin-Rolle, kein TenantContext. +Eingetragen in backend/scripts/check_access_layer_hints.py EXEMPT_ROUTERS. +""" +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/routers/exercise_progression_graphs.py b/backend/routers/exercise_progression_graphs.py index 9e4edaa..b2d3419 100644 --- a/backend/routers/exercise_progression_graphs.py +++ b/backend/routers/exercise_progression_graphs.py @@ -4,7 +4,7 @@ Optional Übungsvarianten als Knoten-Endpunkte; Sequenz-Bulk-Anlage. AuthZ analog training_plan_templates: Bearbeiten/Löschen wie Übungen; Governance-Übergänge zentral. """ import json -from typing import Any, Dict, List, Optional +from typing import Any, Dict, List, Mapping, Optional from fastapi import APIRouter, Depends, HTTPException, Query from pydantic import BaseModel, Field, model_validator @@ -19,6 +19,7 @@ from club_tenancy import ( assert_library_content_editable, assert_library_content_governance_transition, assert_valid_governance_visibility, + is_platform_admin, library_content_visible_to_profile, ) @@ -176,6 +177,87 @@ def _assert_variant_for_exercise(cur, exercise_id: int, variant_id: Optional[int raise HTTPException(status_code=400, detail="Variante gehört nicht zur gewählten Übung") +def _exercise_allowed_in_progression_graph( + exercise_row: Mapping[str, Any], + *, + graph_visibility: str, + graph_club_id: Optional[int], + profile_id: int, + role: str, +) -> bool: + """Prüft, ob eine Übung zur Sichtbarkeit des Progressionsgraphen passt.""" + ex_vis = (exercise_row.get("visibility") or "private").strip().lower() + gvis = (graph_visibility or "private").strip().lower() + if gvis == "private": + if ex_vis == "official": + return True + if ex_vis == "club": + return True + if ex_vis == "private": + if is_platform_admin(role): + return True + try: + return int(exercise_row.get("created_by") or 0) == int(profile_id) + except (TypeError, ValueError): + return False + return False + if gvis == "club": + if ex_vis == "official": + return True + if ex_vis != "club": + return False + ex_club = exercise_row.get("club_id") + if ex_club is None: + return False + if graph_club_id is None: + return True + return int(ex_club) == int(graph_club_id) + return ex_vis == "official" + + +def _assert_exercises_allowed_in_graph( + cur, + graph_id: int, + profile_id: int, + role: str, + *exercise_ids: int, +) -> None: + """400 wenn eine Übung nicht zur Graph-Sichtbarkeit passt.""" + row = _graph_row(cur, graph_id) + gvis = (row.get("visibility") or "private").strip().lower() + gclub_raw = row.get("club_id") + gclub = int(gclub_raw) if gclub_raw is not None else None + unique = list(dict.fromkeys(exercise_ids)) + if not unique: + return + ph = ",".join(["%s"] * len(unique)) + cur.execute( + f"SELECT id, title, visibility, club_id, created_by FROM exercises WHERE id IN ({ph})", + tuple(unique), + ) + by_id = {int(r2d(r)["id"]): r2d(r) for r in cur.fetchall()} + for eid in unique: + ex = by_id.get(int(eid)) + if not ex: + continue + if _exercise_allowed_in_progression_graph( + ex, + graph_visibility=gvis, + graph_club_id=gclub, + profile_id=profile_id, + role=role, + ): + continue + title = (ex.get("title") or "").strip() or f"#{eid}" + raise HTTPException( + status_code=400, + detail=( + f"Übung „{title}“ (Sichtbarkeit: {ex.get('visibility') or 'private'}) " + f"passt nicht zum Progressionsgraphen ({gvis})." + ), + ) + + def _insert_edge_row( cur, graph_id: int, @@ -312,6 +394,22 @@ def _collect_graph_referenced_exercise_ids(cur, graph_id: int) -> set[int]: return ids +def _graph_promotion_transition(graph_visibility: str, target_visibility: str) -> Optional[tuple[str, ...]]: + """ + Erlaubte Graph-Promotions und welche Übungs-Sichtbarkeiten mit angehoben werden müssen. + + Returns None wenn kein Übungs-Promotion-Hinweis nötig. + """ + gvis = (graph_visibility or "private").strip().lower() + tvis = (target_visibility or "").strip().lower() + transitions: Dict[tuple[str, str], tuple[str, ...]] = { + ("private", "club"): ("private",), + ("private", "official"): ("private", "club"), + ("club", "official"): ("private", "club"), + } + return transitions.get((gvis, tvis)) + + @router.get("/exercise-progression-graphs/{graph_id}/visibility-promotion-candidates") def list_visibility_promotion_candidates( graph_id: int, @@ -319,7 +417,9 @@ def list_visibility_promotion_candidates( tenant: TenantContext = Depends(get_tenant_context), ): """ - Private Übungen im Graph, die bei Promotion des Graphen mit angehoben werden müssten. + Übungen im Graph, die bei Promotion des Graphen mit angehoben werden müssten. + + Unterstützt: private→club, private→official, club→official. """ profile_id = tenant.profile_id role = tenant.global_role @@ -327,11 +427,13 @@ def list_visibility_promotion_candidates( cur = get_cursor(conn) row = _require_graph_read(cur, graph_id, profile_id, role) graph_vis = (row.get("visibility") or "private").strip().lower() - if graph_vis != "private" or target_visibility != "club": + target_vis = (target_visibility or "club").strip().lower() + need_vis = _graph_promotion_transition(graph_vis, target_vis) + if not need_vis: return { "graph_id": graph_id, "graph_visibility": graph_vis, - "target_visibility": target_visibility, + "target_visibility": target_vis, "exercises": [], } ref_ids = _collect_graph_referenced_exercise_ids(cur, graph_id) @@ -339,19 +441,20 @@ def list_visibility_promotion_candidates( return { "graph_id": graph_id, "graph_visibility": graph_vis, - "target_visibility": target_visibility, + "target_visibility": target_vis, "exercises": [], } + vis_placeholders = ",".join(["%s"] * len(need_vis)) ph = ",".join(["%s"] * len(ref_ids)) cur.execute( f""" SELECT id, title, visibility, club_id, created_by FROM exercises WHERE id IN ({ph}) - AND LOWER(TRIM(COALESCE(visibility, ''))) = 'private' + AND LOWER(TRIM(COALESCE(visibility, ''))) IN ({vis_placeholders}) ORDER BY title """, - list(ref_ids), + list(ref_ids) + list(need_vis), ) exercises = [] for ex in cur.fetchall(): @@ -359,8 +462,10 @@ def list_visibility_promotion_candidates( if not library_content_visible_to_profile( cur, profile_id, + (exd.get("visibility") or "private").strip().lower(), + exd.get("club_id"), + exd.get("created_by"), role, - exd, ): continue exercises.append( @@ -373,7 +478,7 @@ def list_visibility_promotion_candidates( return { "graph_id": graph_id, "graph_visibility": graph_vis, - "target_visibility": target_visibility, + "target_visibility": target_vis, "exercises": exercises, } @@ -565,6 +670,9 @@ def create_progression_edge( cur = get_cursor(conn) _require_graph_write(cur, graph_id, profile_id, role) _assert_exercises_exist(cur, body.from_exercise_id, body.to_exercise_id) + _assert_exercises_allowed_in_graph( + cur, graph_id, profile_id, role, body.from_exercise_id, body.to_exercise_id + ) fv = body.from_exercise_variant_id tv = body.to_exercise_variant_id _assert_variant_for_exercise(cur, body.from_exercise_id, fv) @@ -613,6 +721,7 @@ def create_progression_sequence( ex_ids = [s.exercise_id for s in steps] _assert_exercises_exist(cur, *ex_ids) + _assert_exercises_allowed_in_graph(cur, graph_id, profile_id, role, *ex_ids) try: for i in range(n_seg): diff --git a/backend/routers/planning_exercise_suggest.py b/backend/routers/planning_exercise_suggest.py index beb0934..1545b00 100644 --- a/backend/routers/planning_exercise_suggest.py +++ b/backend/routers/planning_exercise_suggest.py @@ -7,6 +7,7 @@ from db import get_db, get_cursor from tenant_context import TenantContext, get_tenant_context from planning_exercise_suggest import PlanningExerciseSuggestRequest, suggest_planning_exercises from planning_exercise_path_builder import ProgressionPathSuggestRequest, suggest_progression_path +from planning_llm_usage import planning_llm_call_meter from account_lifecycle import assert_min_account_state from capabilities import probe_capability from club_features import ( @@ -46,19 +47,25 @@ def post_planning_exercise_suggest( ) with get_db() as conn: cur = get_cursor(conn) - result = suggest_planning_exercises(cur, tenant=tenant, body=body) - if uses_ai: + with planning_llm_call_meter() as llm_meter: + result = suggest_planning_exercises(cur, tenant=tenant, body=body) + if uses_ai and llm_meter.count > 0: usage = consume_club_feature_with_usage( feature_id="ai_calls", club_id=club_id, profile_id=tenant.profile_id, portal_role=tenant.global_role, action="planning_suggest", + amount=llm_meter.count, cur=cur, tenant=tenant, conn=conn, ) result = merge_feature_usage_into_response(result, usage) + if isinstance(result, dict): + result["llm_call_count"] = llm_meter.count + elif uses_ai and isinstance(result, dict): + result["llm_call_count"] = 0 return result @@ -70,7 +77,6 @@ def post_progression_path_suggest( uses_ai = ( body.include_llm_intent or body.include_llm_path_qa - or body.include_ai_gap_fill or body.include_llm_roadmap or body.include_llm_start_target or (body.start_target_only and body.include_llm_start_target) @@ -98,17 +104,23 @@ def post_progression_path_suggest( ) with get_db() as conn: cur = get_cursor(conn) - result = suggest_progression_path(cur, tenant=tenant, body=body) - if uses_ai: + with planning_llm_call_meter() as llm_meter: + result = suggest_progression_path(cur, tenant=tenant, body=body) + if uses_ai and llm_meter.count > 0: usage = consume_club_feature_with_usage( feature_id="ai_calls", club_id=club_id, profile_id=tenant.profile_id, portal_role=tenant.global_role, action="progression_path_suggest", + amount=llm_meter.count, cur=cur, tenant=tenant, conn=conn, ) result = merge_feature_usage_into_response(result, usage) + if isinstance(result, dict): + result["llm_call_count"] = llm_meter.count + elif uses_ai and isinstance(result, dict): + result["llm_call_count"] = 0 return result diff --git a/backend/scripts/check_access_layer_hints.py b/backend/scripts/check_access_layer_hints.py index 30ad5ff..8ade257 100644 --- a/backend/scripts/check_access_layer_hints.py +++ b/backend/scripts/check_access_layer_hints.py @@ -29,6 +29,7 @@ EXEMPT_ROUTERS: frozenset[str] = frozenset( "admin_user_content.py", # Superadmin Moderation nutzerangelegter Inhalte; require_auth + is_superadmin — kein Vereinsmandant "admin_rights.py", # Superadmin Rollen/Rechte (Capabilities, Kontingent-Bypass, Pläne); require_auth + is_superadmin — kein Vereinsmandant "catalogs.py", + "catalog_prompt_slots.py", # Admin Stammdaten KI-Prompt-Slots; require_auth + admin/superadmin — globaler Katalog, kein Vereinsmandant "skills.py", "maturity_models.py", "matrix_stack_bundle.py", diff --git a/backend/tests/test_ai_prompt_planning_preview.py b/backend/tests/test_ai_prompt_planning_preview.py new file mode 100644 index 0000000..d5c31c1 --- /dev/null +++ b/backend/tests/test_ai_prompt_planning_preview.py @@ -0,0 +1,89 @@ +"""Admin-Vorschau für Planungs-Prompt-Slugs.""" +from unittest.mock import MagicMock, patch + +import pytest + +from ai_prompt_planning_preview import ( + PLANNING_PROMPT_SLUGS, + PlanningPromptPreviewInput, + is_planning_prompt_slug, + resolve_planning_prompt_preview_variables, +) + + +def test_is_planning_prompt_slug(): + assert is_planning_prompt_slug("planning_progression_roadmap") + assert is_planning_prompt_slug("PLANNING_EXERCISE_PATH_QA") + assert not is_planning_prompt_slug("exercise_summary") + assert not is_planning_prompt_slug("") + + +def test_resolve_roadmap_preview_variables(): + body = PlanningPromptPreviewInput(goal_query="Mae Geri Basics", max_steps=4) + vars_map = resolve_planning_prompt_preview_variables( + MagicMock(), + "planning_progression_roadmap", + body, + ) + assert vars_map["goal_query"] == "Mae Geri Basics" + assert vars_map["max_steps"] == "4" + assert "goal_analysis_json" in vars_map + assert "semantic_brief_json" in vars_map + + +def test_resolve_stage_spec_includes_intent_context(): + body = PlanningPromptPreviewInput(user_notes="Breitensport") + vars_map = resolve_planning_prompt_preview_variables( + MagicMock(), + "planning_progression_stage_spec", + body, + ) + assert "intent_context_json" in vars_map + assert "major_steps_json" in vars_map + + +@patch("ai_prompt_planning_preview._load_catalog_variables") +def test_resolve_search_intent_includes_catalogs(mock_catalog): + mock_catalog.return_value = { + "skills_catalog_json": "[]", + "focus_areas_catalog_json": "[]", + "training_types_catalog_json": "[]", + "style_directions_catalog_json": "[]", + "target_groups_catalog_json": "[]", + } + body = PlanningPromptPreviewInput(search_query="Mae Geri nächster Schritt") + vars_map = resolve_planning_prompt_preview_variables( + MagicMock(), + "planning_exercise_search_intent", + body, + ) + assert vars_map["search_query"] == "Mae Geri nächster Schritt" + assert vars_map["skills_catalog_json"] == "[]" + + +def test_non_planning_slug_raises(): + with pytest.raises(ValueError, match="Kein Planungs-Prompt-Slug"): + resolve_planning_prompt_preview_variables( + MagicMock(), + "exercise_summary", + PlanningPromptPreviewInput(), + ) + + +def test_all_registered_slugs_resolve(): + for slug in PLANNING_PROMPT_SLUGS: + with patch("ai_prompt_planning_preview._load_catalog_variables") as mock_catalog: + mock_catalog.return_value = { + "skills_catalog_json": "[]", + "focus_areas_catalog_json": "[]", + "training_types_catalog_json": "[]", + "style_directions_catalog_json": "[]", + "target_groups_catalog_json": "[]", + } + vars_map = resolve_planning_prompt_preview_variables( + MagicMock(), + slug, + PlanningPromptPreviewInput(), + ) + assert isinstance(vars_map, dict) + assert len(vars_map) >= 1 diff --git a/backend/tests/test_catalog_slot_fallbacks.py b/backend/tests/test_catalog_slot_fallbacks.py new file mode 100644 index 0000000..89bb78d --- /dev/null +++ b/backend/tests/test_catalog_slot_fallbacks.py @@ -0,0 +1,38 @@ +"""Tests Namens-Fallback für Katalog-Prompt-Slots.""" +from catalog_slot_fallbacks import get_fallback_slots_for_entry, merge_stored_slots_with_fallbacks +from catalog_prompt_slots import _resolve_entry_slot_values + + +def test_karate_fallback_has_path_qa(): + pack = get_fallback_slots_for_entry("focus_area", "Karate") + assert "Kohärente Progression" in pack.get("hints_on_path_qa", "") + + +def test_db_value_overrides_fallback(): + merged = merge_stored_slots_with_fallbacks( + {"hints_on_path_qa": "Eigener QS-Text."}, + catalog_kind="focus_area", + name="Karate", + stammdaten_description="Traditionelles Karate", + ) + assert merged["hints_on_path_qa"] == "Eigener QS-Text." + + +def test_empty_db_uses_karate_fallback(): + merged = _resolve_entry_slot_values( + {}, + {"name": "Karate", "description": "Traditionelles Karate"}, + "focus_area", + ) + assert "Kihon-Progression" in merged["description"] or "Technik-Curriculum" in merged["description"] + assert "Kohärente Progression" in merged["hints_on_path_qa"] + + +def test_gewaltschutz_fallback_no_kumite(): + merged = _resolve_entry_slot_values( + {}, + {"name": "Gewaltschutz", "description": "Gewaltprävention"}, + "focus_area", + ) + assert "Deeskalation" in merged["hints_on_path_qa"] + assert "Kumite-Tiefe" in merged["anti_patterns"] diff --git a/backend/tests/test_exercise_progression_graph_visibility.py b/backend/tests/test_exercise_progression_graph_visibility.py new file mode 100644 index 0000000..5a01668 --- /dev/null +++ b/backend/tests/test_exercise_progression_graph_visibility.py @@ -0,0 +1,80 @@ +"""Sichtbarkeit: Progressionsgraph ↔ Übungen (Promotion, Kanten, Match).""" +from routers.exercise_progression_graphs import ( + _exercise_allowed_in_progression_graph, + _graph_promotion_transition, +) + + +def test_graph_promotion_transition_private_to_club(): + assert _graph_promotion_transition("private", "club") == ("private",) + + +def test_graph_promotion_transition_private_to_official(): + assert _graph_promotion_transition("private", "official") == ("private", "club") + + +def test_graph_promotion_transition_club_to_official(): + assert _graph_promotion_transition("club", "official") == ("private", "club") + + +def test_graph_promotion_transition_noop(): + assert _graph_promotion_transition("club", "club") is None + assert _graph_promotion_transition("official", "club") is None + assert _graph_promotion_transition("private", "private") is None + + +def test_club_graph_rejects_private_exercise(): + assert not _exercise_allowed_in_progression_graph( + {"visibility": "private", "club_id": None, "created_by": 1}, + graph_visibility="club", + graph_club_id=5, + profile_id=1, + role="trainer", + ) + + +def test_club_graph_accepts_matching_club_exercise(): + assert _exercise_allowed_in_progression_graph( + {"visibility": "club", "club_id": 5, "created_by": 2}, + graph_visibility="club", + graph_club_id=5, + profile_id=1, + role="trainer", + ) + + +def test_club_graph_accepts_official_exercise(): + assert _exercise_allowed_in_progression_graph( + {"visibility": "official", "club_id": None, "created_by": 99}, + graph_visibility="club", + graph_club_id=5, + profile_id=1, + role="trainer", + ) + + +def test_private_graph_accepts_own_private_exercise(): + assert _exercise_allowed_in_progression_graph( + {"visibility": "private", "club_id": None, "created_by": 7}, + graph_visibility="private", + graph_club_id=None, + profile_id=7, + role="trainer", + ) + + +def test_official_graph_requires_official_exercise(): + assert not _exercise_allowed_in_progression_graph( + {"visibility": "club", "club_id": 5, "created_by": 2}, + graph_visibility="official", + graph_club_id=None, + profile_id=1, + role="trainer", + ) + assert _exercise_allowed_in_progression_graph( + {"visibility": "official", "club_id": None, "created_by": 2}, + graph_visibility="official", + graph_club_id=None, + profile_id=1, + role="trainer", + ) 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..ad2f323 --- /dev/null +++ b/backend/tests/test_planning_catalog_prompt_snippets.py @@ -0,0 +1,168 @@ +"""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_unknown_focus_uses_default_description_pack(): + cur = _mock_cur( + rows_by_table={ + "focus_areas": { + 4: { + "name": "Sonderfokus Alpha", + "description": "Kurze Stammdaten-Beschreibung", + } + } + }, + slots_by_kind_id={("focus_area", 4): {}}, + ) + catalog = ProgressionPlanningCatalogContext( + focus_areas=[PlanningCatalogContextItem(id=4, is_primary=True)], + ) + resolved = resolve_catalog_prompt_variables(cur, catalog) + desc = resolved[placeholder_key("focus_area", "description")] + assert "Technik- oder Themen-Curriculum" in desc + assert resolved[placeholder_key("focus_area", "hints_on_path_qa")] + + +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_gets_default_technique_fallback(): + 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 True + assert "Unbekannter Fokus XYZ" in out["catalog_context_json"] + assert "Zwischenstufen" in out["catalog_guidance_block"] or "Progression" in out["catalog_guidance_block"] + + +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/tests/test_planning_deterministic_quality_score.py b/backend/tests/test_planning_deterministic_quality_score.py index f5a0975..ed7e0dd 100644 --- a/backend/tests/test_planning_deterministic_quality_score.py +++ b/backend/tests/test_planning_deterministic_quality_score.py @@ -3,19 +3,28 @@ from planning_exercise_path_qa import compute_deterministic_path_quality_score def test_deterministic_quality_score_penalizes_off_topic(): - base = compute_deterministic_path_quality_score(gaps=[], off_topic_steps=[]) + steps = [{"roadmap_major_step_index": 0, "exercise_id": 1}] + base = compute_deterministic_path_quality_score(gaps=[], off_topic_steps=[], steps=steps) with_off = compute_deterministic_path_quality_score( gaps=[], off_topic_steps=[{"roadmap_major_step_index": 1}], + steps=steps, ) assert with_off < base def test_deterministic_quality_score_penalizes_empty_slots(): - base = compute_deterministic_path_quality_score(gaps=[], off_topic_steps=[], steps=[]) + filled = [{"roadmap_major_step_index": 0, "exercise_id": 1}] + base = compute_deterministic_path_quality_score(gaps=[], off_topic_steps=[], steps=filled) with_empty = compute_deterministic_path_quality_score( gaps=[], off_topic_steps=[], - steps=[{"exercise_id": None}, {"exercise_id": 1}], + steps=[{"roadmap_major_step_index": 0, "exercise_id": None}, {"roadmap_major_step_index": 1, "exercise_id": 2}], + ) + all_empty = compute_deterministic_path_quality_score( + gaps=[], + off_topic_steps=[], + steps=[{"roadmap_major_step_index": 0, "exercise_id": None}] * 4, ) assert with_empty < base + assert all_empty <= 0.15 diff --git a/backend/tests/test_planning_llm_usage.py b/backend/tests/test_planning_llm_usage.py new file mode 100644 index 0000000..03946ca --- /dev/null +++ b/backend/tests/test_planning_llm_usage.py @@ -0,0 +1,94 @@ +"""LLM-Zählung für Planungs-APIs (P1-C2).""" +from unittest.mock import MagicMock, patch + +import pytest + +from planning_llm_usage import ( + current_planning_llm_call_counter, + planning_llm_call_meter, + record_planning_llm_call, +) + + +def test_meter_inactive_by_default(): + assert current_planning_llm_call_counter() is None + record_planning_llm_call(3) + assert current_planning_llm_call_counter() is None + + +def test_meter_counts_within_scope(): + with planning_llm_call_meter() as meter: + record_planning_llm_call(1) + record_planning_llm_call(2) + assert meter.count == 3 + + +def test_openrouter_increments_active_meter(): + from openrouter_chat import openrouter_chat_completion + + fake_resp = MagicMock() + fake_resp.status_code = 200 + fake_resp.json.return_value = { + "choices": [{"message": {"content": "ok"}, "finish_reason": "stop"}], + } + + with planning_llm_call_meter() as meter: + with patch("openrouter_chat.httpx.Client") as client_cls: + client = MagicMock() + client.__enter__.return_value = client + client.post.return_value = fake_resp + client_cls.return_value = client + out = openrouter_chat_completion( + api_key="test-key", + model="test/model", + user_content="hello", + ) + assert out == "ok" + assert meter.count == 1 + + +def test_openrouter_skips_meter_on_http_error(): + from openrouter_chat import OpenRouterError, openrouter_chat_completion + + fake_resp = MagicMock() + fake_resp.status_code = 500 + fake_resp.json.return_value = {"error": {"message": "fail"}} + fake_resp.text = "fail" + + with planning_llm_call_meter() as meter: + with patch("openrouter_chat.httpx.Client") as client_cls: + client = MagicMock() + client.__enter__.return_value = client + client.post.return_value = fake_resp + client_cls.return_value = client + with pytest.raises(OpenRouterError): + openrouter_chat_completion( + api_key="test-key", + model="test/model", + user_content="hello", + ) + assert meter.count == 0 + + +def test_uses_ai_gap_fill_not_counted_without_openrouter(): + """Regression: Gap-Fill-Flag allein löst keinen OpenRouter-Aufruf aus.""" + from planning_exercise_path_builder import ProgressionPathSuggestRequest + + body = ProgressionPathSuggestRequest( + query="Mae Geri Progression", + include_llm_intent=False, + include_llm_path_qa=False, + include_llm_roadmap=False, + include_llm_start_target=False, + include_ai_gap_fill=True, + evaluate_only=True, + evaluate_steps=[], + ) + uses_ai = ( + body.include_llm_intent + or body.include_llm_path_qa + or body.include_llm_roadmap + or body.include_llm_start_target + or (body.start_target_only and body.include_llm_start_target) + ) + assert uses_ai is False diff --git a/backend/tests/test_planning_path_qa_split.py b/backend/tests/test_planning_path_qa_split.py new file mode 100644 index 0000000..77bb0f9 --- /dev/null +++ b/backend/tests/test_planning_path_qa_split.py @@ -0,0 +1,62 @@ +"""Getrennte Roadmap- vs. Besetzungs-QS.""" +from planning_exercise_path_qa import ( + build_assignment_qa_snapshot, + build_path_qa_summary, + compute_assignment_quality_score, + merge_path_quality_scores, +) + + +def _empty_steps(n: int): + return [{"roadmap_major_step_index": i, "exercise_id": None} for i in range(n)] + + +def test_assignment_quality_all_empty_slots_is_low(): + steps = _empty_steps(5) + score = compute_assignment_quality_score(steps=steps, off_topic_steps=[], gaps=[]) + assert score <= 0.15 + + +def test_assignment_quality_all_filled_is_high(): + steps = [{"roadmap_major_step_index": i, "exercise_id": i + 1} for i in range(5)] + score = compute_assignment_quality_score(steps=steps, off_topic_steps=[], gaps=[]) + assert score >= 0.9 + + +def test_build_path_qa_summary_caps_llm_score_when_slots_empty(): + steps = _empty_steps(4) + summary = build_path_qa_summary( + gaps=[], + bridge_inserts=[], + ai_proposals=[], + off_topic_steps=[], + stripped_off_topic=[], + llm_qa={ + "overall_ok": True, + "quality_score": 0.88, + "topic_coverage": "Roadmap deckt Ziel gut ab", + "issues": [], + "recommendations": ["Feinschliff Stufe 3"], + }, + llm_applied=True, + steps=steps, + ) + assert summary["roadmap_qa"]["quality_score"] == 0.88 + assert summary["assignment_qa"]["empty_slot_count"] == 4 + assert summary["assignment_qa"]["quality_score"] <= 0.15 + assert summary["quality_score"] <= 0.15 + assert summary["overall_ok"] is False + + +def test_merge_path_quality_uses_minimum(): + assert merge_path_quality_scores( + {"quality_score": 0.88}, + {"quality_score": 0.12}, + ) == 0.12 + + +def test_assignment_snapshot_reports_empty_slots(): + snap = build_assignment_qa_snapshot(steps=_empty_steps(3), off_topic_steps=[], gaps=[]) + assert snap["empty_slot_count"] == 3 + assert snap["overall_ok"] is False + assert any("ohne Übung" in issue for issue in snap["issues"]) diff --git a/backend/tests/test_planning_problematic_slots.py b/backend/tests/test_planning_problematic_slots.py index 8276d70..bae6fd1 100644 --- a/backend/tests/test_planning_problematic_slots.py +++ b/backend/tests/test_planning_problematic_slots.py @@ -2,6 +2,7 @@ from planning_exercise_path_builder import ( _parse_slot_refs_from_text, _problematic_slots_from_path_qa, + _slot_auto_select_ai, _slot_auto_select_library, _slot_suggestion_accepted, ) @@ -113,6 +114,27 @@ def test_slot_auto_select_requires_higher_score(): ) +def test_slot_auto_select_empty_slot_requires_good_fit(): + assert not _slot_auto_select_library( + baseline_slot_score=None, + proposed_slot_score=0.35, + baseline_exercise_id=None, + proposed_exercise_id=2, + ) + assert _slot_auto_select_library( + baseline_slot_score=None, + proposed_slot_score=0.55, + baseline_exercise_id=None, + proposed_exercise_id=2, + ) + + +def test_slot_auto_select_ai_when_library_not_selected(): + assert _slot_auto_select_ai(library_auto_select=False, has_ai=True) + assert not _slot_auto_select_ai(library_auto_select=True, has_ai=True) + assert not _slot_auto_select_ai(library_auto_select=False, has_ai=False) + + def test_off_topic_slot_gap_spec_for_filled_slot(): from planning_exercise_path_builder import _build_off_topic_slot_gap_spec diff --git a/backend/version.py b/backend/version.py index 7f1310a..b81f759 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.237" BUILD_DATE = "2026-05-22" -DB_SCHEMA_VERSION = "20260607090" +DB_SCHEMA_VERSION = "20260607094" 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,40 @@ MODULE_VERSIONS = { } CHANGELOG = [ + { + "version": "0.8.237", + "date": "2026-05-22", + "changes": [ + "Migration 094: catalog_prompt_slots vollständig befüllt (Karate, SV, alle Trainingsstile/Zielgruppen).", + "catalog_slot_fallbacks: Namens-Fallback bis Admin-Override — gleiche Qualität wie H1-Registry.", + ], + }, + { + "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/HANDOVER.md b/docs/HANDOVER.md index a9d5820..6159fe6 100644 --- a/docs/HANDOVER.md +++ b/docs/HANDOVER.md @@ -1,7 +1,7 @@ # Shinkan Jinkendo – Entwicklungsstand & Handover -**Stand:** 2026-05-22 -**App-Version / DB-Schema:** App **`0.8.233`** (Planungs-KI F11–F14, Katalog-Kontext); DB siehe **`backend/version.py`** (`DB_SCHEMA_VERSION`, Migration **088**). +**Stand:** 2026-05-22 (F15 Graph-Match & getrennte Pfad-QS, lokal nach **0.8.233**) +**App-Version / DB-Schema:** App **`0.8.233`** (Planungs-KI F11–F14, Katalog-Kontext); **F15** siehe §2.8 — DB unverändert (`DB_SCHEMA_VERSION`, Migration **088**). Diese Datei ist die **Einstiegs-Doku für neue Chat-Sessions**: Anforderungen im Detail stehen in `.claude/docs/` (siehe unten); hier der **implementierte Stand**, **Medien-Meilenstein** und **sinnvolle nächste Schritte**. @@ -114,11 +114,25 @@ Das Schema ist gegenüber dem Code zurück: Migration **`022_skills_schema_compl | **F12** | Post-Match-Gate, LLM-QA nach Rematch, Gap-Timing, `roadmap_unfilled`-Sync | ✅ **0.8.231–0.8.232** | | **F13** | **`planning_catalog_context`** (Fokus/Stil/TT/ZG) im Match + Graph-Artefakt | ✅ **0.8.233** | | **F14** | **`ProgressionGraphEditor`** — Slot-UI + Planungskontext-Dropdowns | ✅ **0.8.233** | +| **F15** | Unified Slot-Review (Match-Dialog), getrennte Pfad-QS, `findings_stale` | ✅ lokal (nach 0.8.233) | | **H1** | Katalog-Prompt-Snippets (modulare LLM-Anweisungen) | 🔲 Spec **`docs/architecture/PLANNING_CATALOG_PROMPT_SNIPPETS.md`** | **Architektur (verbindlich):** Drei Schichten — (1) **Katalog-Dimensionen** (DB, jetzt im Match verdrahtet; **H1:** zusätzlich Prompt-Snippets), (2) **Technik-Disambiguierung** (Code, nur bei `topic_type=technique`), (3) **Didaktik** (Roadmap + LLM-QS, nicht im Vokabular). Progressionsgraph = **Roadmap-first**, **keine Gruppenanalyse**. Bestehender Graph = **leichter Nachfolger-Bias** ab Schritt 2. Trainingsplanung = **eigene Pipeline** (Phase G) — Wiederverwendung der Bausteine, siehe Ist-Doku §16. -**Validierung (Mae Geri, Härtetest):** Pfad-QS vor Optimierung ~65 % → nach Trainer-Roadmap + KI-Gap-Fill **~88 % OK**. Workbench ist **universell** gedacht; Mae Geri war Referenzfall, kein Sonder-Patch. +**Validierung (Mae Geri, Härtetest):** Roadmap-QS nach Trainer-Roadmap oft **~85–88 %** — gilt **Stufenlogik**, nicht leere Slots. **Gesamt-Pfad-QS** = Minimum aus **`roadmap_qa`** + **`assignment_qa`** (leere Slots → Besetzung ~8–15 %). Workbench universell; Mae Geri Referenzfall. + +#### F15 — Match-Dialog, Bewertung, Pfad-QS (Stand 2026-05-22) + +| Thema | Ist | +|--------|-----| +| **„Übungen matchen“** | Schritt 1: `evaluate_only` (wie „Graph bewerten“) · Schritt 2: `unified_slot_review: true` → Dialog **pro Slot** (Bewertung, Bibliotheks-Alternative, optional KI) | +| **Vorauswahl Dialog** | Bibliothek nur bei Stufen-Fit **≥ 50 %** und besser als aktuell; bei leerem Slot + schwacher Bibliothek → **KI-Vorschlag** vorausgewählt | +| **Übernahme** | Nur gewählte Slots speichern — **keine** automatische teure Nach-Bewertung | +| **Bewertung veraltet** | Nach Graph-Änderungen Hinweis im Findings-Panel; persistiert als **`findings_stale`** im `planning_roadmap`-Artefakt (mit Speichern) | +| **Getrennte QS** | `path_qa.roadmap_qa` (Stufen/Roadmap/LLM) + `path_qa.assignment_qa` (Slot-Befüllung); **`quality_score`** = Minimum beider | +| **UX-Fix** | Slot-Karten: stabiler React-Key (`slot-{index}`) — Lernziel editierbar ohne Fokusverlust | + +**Code:** `ProgressionOptimizeCompareModal.jsx`, `planning_exercise_path_builder.py` (`_build_unified_slot_review_entry`, `_slot_auto_select_*`), `planning_exercise_path_qa.py` (`build_*_qa_snapshot`), `progression_graph_planning_artifact.py` (`findings_stale`), `progressionGraphDraft.js` **Backend-Kern:** `planning_progression_roadmap.py`, `planning_exercise_path_builder.py`, `planning_catalog_context.py`, `planning_path_rematch.py`, `planning_path_refine_stage.py`, `planning_path_qa_pipeline.py`, `planning_skill_expectations.py`, `planning_exercise_form_context.py`, `planning_exercise_path_ai_fill.py`, `progression_graph_planning_artifact.py` @@ -129,12 +143,12 @@ Das Schema ist gegenüber dem Code zurück: Migration **`022_skills_schema_compl **Offen (priorisiert):** 1. Dev-Regression: Gewaltschutz / Breitensport / Kinder (nicht nur Mae Geri) 2. **PathBuilder-Parität** — gleiche Katalog-Dropdowns wie GraphEditor -3. QS-UI — positive LLM-Hinweise als Highlights -4. UI-Wizard (4 Schritte: Ziel → Roadmap → Match → Lücken) -5. Graph-Erweiterungsmodus (Start ab Knoten) -6. Phase D′ — Auto KI-Gap-Fill bei persistent leeren Slots -7. **Trainingsplanung Phase G** — Gruppenkontext-Pack, Scopes `training_section` / `framework_slot` (Ist-Doku §16) -8. Technik-Katalog konfigurierbar (Backlog) +3. UI-Wizard (4 Schritte: Ziel → Roadmap → Match → Lücken) +4. Graph-Erweiterungsmodus (Start ab Knoten) +5. Phase D′ — Auto KI-Gap-Fill bei persistent leeren Slots +6. **Trainingsplanung Phase G** — Gruppenkontext-Pack, Scopes `training_section` / `framework_slot` (Ist-Doku §16) +7. Technik-Katalog konfigurierbar (Backlog) +8. **H1** — Katalog-Prompt-Snippets (modulare LLM-Anweisungen) #### Übungs-KI Formular / Schnellanlage (Stand **0.8.171**) @@ -271,8 +285,7 @@ Das Schema ist gegenüber dem Code zurück: Migration **`022_skills_schema_compl 1. **H1 Katalog-Prompt-Snippets:** modulare LLM-Anweisungen — **`docs/architecture/PLANNING_CATALOG_PROMPT_SNIPPETS.md`** (Priorität: Primärfokus → Trainingsstil → Zielgruppe → Stilrichtung). 2. **Dev-Regression:** Katalog-Match für Gewaltschutz, Breitensport, Kinder — nicht nur Mae-Geri-Härtetest. -2. **PathBuilder-Parität:** `planning_catalog_context`-Dropdowns auch in `ExerciseProgressionPathBuilder`. -3. **QS-UI:** positive LLM-Empfehlungen als Highlights statt nur Optimierungspotenziale. +3. **PathBuilder-Parität:** `planning_catalog_context`-Dropdowns auch in `ExerciseProgressionPathBuilder`. 4. **UI-Wizard:** 4 Schritte (Ziel & Katalog → Roadmap → Match → Lücken); Backend-Pipeline unverändert. 5. **Phase D′:** automatisches KI-Gap-Fill bei persistent `roadmap_unfilled`. 6. **Trainingsplanung G0–G4:** Katalog in Einheits-Editor, Scopes `training_section`/`framework_slot`, Abschnitts-QS, Gruppenkontext-Pack — Details **`PLANNING_PROGRESSION_GRAPH_KI.md`** §16, **`PLANNING_KI_ROADMAP.md`**. diff --git a/docs/architecture/PLANNING_CATALOG_PROMPT_SNIPPETS.md b/docs/architecture/PLANNING_CATALOG_PROMPT_SNIPPETS.md index 61dacdd..19e29f6 100644 --- a/docs/architecture/PLANNING_CATALOG_PROMPT_SNIPPETS.md +++ b/docs/architecture/PLANNING_CATALOG_PROMPT_SNIPPETS.md @@ -1,229 +1,218 @@ -# 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:** +Neuer Katalog-Eintrag im Admin → **keine** Code-Änderung; Slots optional befüllen. -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. +**Keine Migrations-Seeds:** Primärfokus und andere Stammdaten sind pro Umgebung unterschiedlich. Leere `catalog_prompt_slots` sind normal; zur Laufzeit greifen Namens-Fallbacks (`catalog_slot_fallbacks.py`) und die `description`-Spalte der Stammdaten. --- -## 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) — **entfernt (095)**: keine Migrations-Seeds; Inhalte Admin oder `catalog_slot_fallbacks.py` +- [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/docs/architecture/PLANNING_KI_ROADMAP.md b/docs/architecture/PLANNING_KI_ROADMAP.md index 98b9cfb..56ec945 100644 --- a/docs/architecture/PLANNING_KI_ROADMAP.md +++ b/docs/architecture/PLANNING_KI_ROADMAP.md @@ -89,14 +89,24 @@ Details und Module: **`PLANNING_PROGRESSION_GRAPH_KI.md`**. - [x] Vier Planungskontext-Dropdowns im Editor - [x] `progressionGraphDraft.js` — Artefakt + API-Payload +### F15 — Match-Dialog & getrennte Pfad-QS (2026-05-22, lokal) + +- [x] **`unified_slot_review`** — Dialog pro Slot (Bibliothek + KI, Stufen-Fit-Vergleich) +- [x] Vorauswahl: Bibliothek nur bei Stufen-Fit ≥ 50 %; sonst KI bei leerem/schwachem Slot +- [x] Übernahme ohne teure Auto-Nach-Bewertung; manuell „Graph bewerten“ +- [x] **`path_qa.roadmap_qa`** + **`path_qa.assignment_qa`**; Gesamt = Minimum +- [x] **`findings_stale`** im Graph-Artefakt — Hinweis „Bewertung veraltet“ (persistiert) +- [x] Slot-Key-Fix — Lernziel editierbar ohne Fokusverlust + ### Validierung (Referenz Mae Geri, 2026-05) -| Phase | Pfad-QS | Ergebnis | -|-------|---------|----------| -| Vor Roadmap/KI | ~65 % | Lücken, falsche Reihenfolge, Off-Topic | -| Nach Trainer-Roadmap + KI-Gap-Fill | **~88 % OK** | Vollständige Abdeckung; positive LLM-Hinweise | +| Phase | Roadmap-QS | Besetzung | Gesamt | Ergebnis | +|-------|------------|-----------|--------|----------| +| Vor Roadmap/KI | — | — | ~65 % | Lücken, Off-Topic | +| Roadmap ok, Slots leer | ~88 % | ~8–15 % | **~8–15 %** | Besetzung fehlt | +| Nach Match + Fill | ~88 % | hoch | **~85 %+** | Vollständige Abdeckung | -**Fazit:** Workbench + Katalog + Roadmap sind universell; Technik-Hardcoding allein reicht für Didaktik nicht. +**Fazit:** Roadmap-QS und Besetzungs-QS getrennt betrachten; Workbench + Katalog + Roadmap universell. --- diff --git a/docs/architecture/PLANNING_PROGRESSION_GRAPH_KI.md b/docs/architecture/PLANNING_PROGRESSION_GRAPH_KI.md index 647b4e2..7015c99 100644 --- a/docs/architecture/PLANNING_PROGRESSION_GRAPH_KI.md +++ b/docs/architecture/PLANNING_PROGRESSION_GRAPH_KI.md @@ -157,6 +157,10 @@ flowchart TB | `auto_refine_stage_spec` | bool | Stufen-Spec bei `stage_mismatch` schärfen (Default **true**) | | `max_rematch_rounds` | int | Rematch-Runden 0–4 (Default **3**) | | `include_path_qa`, `include_llm_path_qa`, `include_ai_gap_fill` | bool | QS, LLM-Ganzpfad, Lücken-Angebote | +| `evaluate_only` | bool | Nur QS auf `evaluate_steps[]` — kein Match | +| `unified_slot_review` | bool | Pro-Slot-Review (Bibliothek + optional KI) für Match-Dialog; erfordert `baseline_evaluate_steps` + Roadmap | +| `baseline_evaluate_steps` | array? | Slot-Stand für Schritt 1 / Review-Baseline | +| `baseline_path_qa_snapshot` | object? | `path_qa` aus evaluate_only (Schritt 1 des Match-Flows) | ### 4.2 Wichtige Response-Felder @@ -166,7 +170,9 @@ flowchart TB | `steps[]` | Gematchte Übungen; pro Schritt u. a. `roadmap_*`, `skill_expectations` | | `path_skill_expectations` | Pfadweite Skill-Erwartungen | | `gap_fill_offers[]` | Lücken mit `context_preview`, `goal_for_ai` | -| `path_qa` | QS inkl. `qa_tiers`, `optimization_hints`, `rematch_log`, `refine_log` | +| `path_qa` | QS inkl. `qa_tiers`, `optimization_hints`, `rematch_log`, `refine_log`; **F15:** auch `roadmap_qa`, `assignment_qa` (siehe §8.1) | +| `slot_reviews[]` | Bei `unified_slot_review`: je Slot `library_alternative`, `ai_alternative`, `auto_select`-Flags | +| `findings_stale` | Im Graph-Artefakt (nicht API-Response): Bewertung veraltet seit letztem „Graph bewerten“ | | `target_profile_summary` | Erwartungsprofil inkl. Katalog-Dimensionen (nach Match) | | `match_summary` | `library_matches`, `gap_fill_offer_count`, `roadmap_unfilled_count` | @@ -221,12 +227,13 @@ Tests: `test_planning_roadmap_stage_match.py`, `test_planning_path_rematch.py`, ### Referenz-Validierung (Mae Geri, 2026-05) -| Phase | Pfad-QS | Ergebnis | -|-------|---------|----------| -| Vor Roadmap/KI-Anpassung | ~65 % | Strukturelle Lücken (Grundlagen, Reihenfolge, Zielgenauigkeit) | -| Nach Trainer-Roadmap + KI-Angebote in leeren Slots | **~88 % OK** | Vollständige Curriculum-Abdeckung; positive LLM-Empfehlungen | +| Phase | Roadmap-QS | Besetzung | Gesamt (min) | Ergebnis | +|-------|------------|-----------|--------------|----------| +| Vor Roadmap/KI-Anpassung | — | — | ~65 % | Strukturelle Lücken | +| Nach Trainer-Roadmap, **Slots leer** | ~85–88 % | ~8–15 % | **~8–15 %** | Roadmap ok, Besetzung fehlt | +| Nach Match + befüllte Slots | ~85–88 % | hoch | **~85 %+** | Vollständige Curriculum-Abdeckung | -**Lesson:** Workbench + Katalog-Kontext + Roadmap sind der Hebel; Technik-Hardcoding allein reicht nicht für Didaktik. +**Lesson:** **`roadmap_qa`** und **`assignment_qa`** getrennt interpretieren; Gesamt-QS allein bei leerer Roadmap irreführend (historisch nur LLM-Roadmap-Score). --- @@ -330,6 +337,21 @@ API: `path_qa.qa_tiers`, `path_qa.optimization_hints` — **kein** anfrage-spezi Phase G: gleiche Tiers für Abschnitts-QS nach Einheits-Match. +### 8.1 Getrennte Pfad-QS — Roadmap vs. Übungsbesetzung (F15) + +`build_path_qa_summary()` in `planning_exercise_path_qa.py` liefert drei Ebenen: + +| Feld | Inhalt | Score-Logik | +|------|--------|-------------| +| **`roadmap_qa`** | Stufenlogik, LLM `topic_coverage`, Roadmap-Hinweise | LLM-`quality_score` oder heuristisch (Lücken, Hints) | +| **`assignment_qa`** | Leere Slots, Off-Topic auf belegten Slots, Fill-Statistik | Stark abwertend bei leeren Slots (~8–15 % bei 100 % leer) | +| **`quality_score`** (gesamt) | Anzeige „Pfad-QS gesamt“ | **`min(roadmap_qa, assignment_qa)`** | +| **`overall_ok`** | Gesamt-OK | Beide Dimensionen müssen OK sein | + +UI: **`ProgressionFindingsPanel`** — zwei Unterblöcke; Match-Dialog zeigt Roadmap- vs. Besetzungs-Prozent. Nach Graph-Änderung: **`findings_stale: true`** im Artefakt → Hinweis „Bewertung veraltet“ (bis erneut „Graph bewerten“ + Speichern). + +Tests: `test_planning_path_qa_split.py`, `test_planning_deterministic_quality_score.py` + ## 9. Fähigkeiten-Scoring-Anbindung Modul: `planning_skill_expectations.py` @@ -379,6 +401,7 @@ Kontext-Helfer: `frontend/src/utils/planningContextForExerciseAi.js` | **F12** | Post-Match-Gate, LLM-QA nach Rematch, Gap-Timing, `roadmap_unfilled`-Sync | ✅ | 0.8.231–0.8.232 | | **F13** | **Katalog-Kontext** (`planning_catalog_context`) im Match + Graph-Artefakt | ✅ | **0.8.233** | | **F14** | `ProgressionGraphEditor` Slot-UI + Planungskontext-Dropdowns | ✅ | 0.8.233 | +| **F15** | Unified Slot-Review, getrennte Pfad-QS, `findings_stale`, Match-Vorauswahl | ✅ | lokal (2026-05-22) | | **H1** | **Katalog-Prompt-Snippets** — modulare LLM-Anweisungen pro Dimension | 🔲 | Spec **`PLANNING_CATALOG_PROMPT_SNIPPETS.md`** | | **G** | Trainingsplanung: eigene Pipeline + Wiederverwendung Bausteine (§16) | 🔲 | — | | **UX** | Wizard/Stepper; PathBuilder-Parität Katalog | 🔲 | — | @@ -391,8 +414,7 @@ Kontext-Helfer: `frontend/src/utils/planningContextForExerciseAi.js` 1. **H1 Katalog-Prompt-Snippets** — modulare LLM-Anweisungen (Priorität: Primärfokus → Trainingsstil → Zielgruppe → Stilrichtung) — **`PLANNING_CATALOG_PROMPT_SNIPPETS.md`** 2. **Dev-Regression:** Gewaltschutz + Breitensport + Kinder (ohne Mae Geri) — Katalog-Match verifizieren -2. **PathBuilder-Parität** — gleiche `planning_catalog_context`-Dropdowns in `ExerciseProgressionPathBuilder` -3. **QS-UI** — positive LLM-Hinweise als „Highlights“, nicht als „Optimierungspotenziale“ +3. **PathBuilder-Parität** — gleiche `planning_catalog_context`-Dropdowns in `ExerciseProgressionPathBuilder` 4. **UI-Wizard** — 4 Schritte (Ziel → Roadmap → Match → Lücken); Backend unverändert 5. **Graph-Erweiterungsmodus** — Start ab gewähltem Knoten / letzter Sequenz 6. **Phase D′** — automatisches KI-Gap-Fill bei persistent leeren Slots diff --git a/frontend/src/components/ExerciseProgressionGraphPanel.jsx b/frontend/src/components/ExerciseProgressionGraphPanel.jsx index 2d02149..8beac1e 100644 --- a/frontend/src/components/ExerciseProgressionGraphPanel.jsx +++ b/frontend/src/components/ExerciseProgressionGraphPanel.jsx @@ -13,7 +13,7 @@ import { Link, useLocation } from 'react-router-dom' import api from '../utils/api' import SkillProfilePanel from './skills/SkillProfilePanel' import { useAuth } from '../context/AuthContext' -import { activeClubMemberships, getTenantClubDependencyKey } from '../utils/activeClub' +import { activeClubMemberships, getDefaultClubIdForGovernanceForms, getTenantClubDependencyKey } from '../utils/activeClub' import ProgressionGraphEditor from './ProgressionGraphEditor' import ProgressionGraphListCard from './ProgressionGraphListCard' import { EXERCISE_VISIBILITY_FIELD_LABEL } from '../constants/exerciseGovernanceLabels' @@ -24,6 +24,21 @@ const VIS_OPTIONS = [ { value: 'official', label: 'Offiziell' }, ] +const GRAPH_VISIBILITY_PROMOTION_LABEL = { + club: 'Vereins-Sichtbarkeit', + official: 'offizielle Sichtbarkeit', +} + +/** Graph-Promotion mit optionalem Übungs-Anheben (private→club/official, club→official). */ +function shouldPromptGraphExercisePromotion(prevVis, nextVis) { + const p = (prevVis || 'private').trim().toLowerCase() + const n = (nextVis || 'private').trim().toLowerCase() + return ( + (p === 'private' && (n === 'club' || n === 'official')) || + (p === 'club' && n === 'official') + ) +} + function edgeTypeLabel(type) { if (type === 'next_exercise') return 'Nachfolger' if (type === 'sibling') return 'Schwester' @@ -41,7 +56,9 @@ function ExerciseProgressionGraphPanel( const { user } = useAuth() const location = useLocation() const isSuperadmin = user?.role === 'superadmin' + const memberClubs = useMemo(() => activeClubMemberships(user?.clubs), [user?.clubs]) const tenantClubDepKey = useMemo(() => getTenantClubDependencyKey(user), [user]) + const [clubsForGovernanceForms, setClubsForGovernanceForms] = useState([]) const filteredGraphVisOptions = useMemo( () => VIS_OPTIONS.filter((o) => o.value !== 'official' || isSuperadmin), @@ -61,6 +78,37 @@ function ExerciseProgressionGraphPanel( const [metaName, setMetaName] = useState('') const [metaDescription, setMetaDescription] = useState('') const [metaVisibility, setMetaVisibility] = useState('private') + const [metaClubSelect, setMetaClubSelect] = useState('') + + const memberClubIdSet = useMemo( + () => new Set(memberClubs.map((c) => Number(c.id))), + [memberClubs], + ) + + const sortedMemberClubs = useMemo( + () => + [...memberClubs].sort((a, b) => + String(a.name || '').localeCompare(String(b.name || ''), 'de'), + ), + [memberClubs], + ) + + const sortedOtherGovernanceClubs = useMemo(() => { + if (!isSuperadmin || clubsForGovernanceForms.length === 0) return [] + return clubsForGovernanceForms + .filter((c) => !memberClubIdSet.has(Number(c.id))) + .sort((a, b) => String(a.name || '').localeCompare(String(b.name || ''), 'de')) + }, [isSuperadmin, clubsForGovernanceForms, memberClubIdSet]) + + const showGovernanceClubOptgroups = + isSuperadmin && sortedMemberClubs.length > 0 && sortedOtherGovernanceClubs.length > 0 + + const governanceClubSelectOptions = useMemo(() => { + if (isSuperadmin && clubsForGovernanceForms.length > 0) { + return [...sortedMemberClubs, ...sortedOtherGovernanceClubs] + } + return sortedMemberClubs + }, [isSuperadmin, clubsForGovernanceForms.length, sortedMemberClubs, sortedOtherGovernanceClubs]) const [filterAnchorOnly, setFilterAnchorOnly] = useState(!!anchorExerciseId) const [editingEdgeNotes, setEditingEdgeNotes] = useState(null) @@ -125,6 +173,25 @@ function ExerciseProgressionGraphPanel( } }, [refreshGraphs, tenantClubDepKey]) + useEffect(() => { + if (!isSuperadmin) { + setClubsForGovernanceForms([]) + return undefined + } + let cancelled = false + ;(async () => { + try { + const list = await api.listClubs() + if (!cancelled) setClubsForGovernanceForms(Array.isArray(list) ? list : []) + } catch { + if (!cancelled) setClubsForGovernanceForms([]) + } + })() + return () => { + cancelled = true + } + }, [isSuperadmin, tenantClubDepKey]) + useEffect(() => { if (!selectedGraphId) { setSkillProfileData(null) @@ -157,6 +224,7 @@ function ExerciseProgressionGraphPanel( setMetaName('') setMetaDescription('') setMetaVisibility('private') + setMetaClubSelect('') return } const g = graphs.find((x) => x.id === selectedGraphId) @@ -164,6 +232,12 @@ function ExerciseProgressionGraphPanel( setMetaName(g.name || '') setMetaDescription(g.description || '') setMetaVisibility(g.visibility || 'private') + if (g.club_id != null) { + setMetaClubSelect(String(g.club_id)) + } else { + const fallback = getDefaultClubIdForGovernanceForms(user) + setMetaClubSelect(fallback != null ? String(fallback) : '') + } } let cancelled = false ;(async () => { @@ -176,7 +250,17 @@ function ExerciseProgressionGraphPanel( return () => { cancelled = true } - }, [selectedGraphId, graphs, refreshEdges]) + }, [selectedGraphId, graphs, refreshEdges, user]) + + const resolveGovernanceClubId = useCallback(() => { + const g = graphs.find((x) => x.id === selectedGraphId) + if (g?.club_id != null) return Number(g.club_id) + + const sel = String(metaClubSelect || '').trim() + if (sel && /^\d+$/.test(sel)) return Number(sel) + + return getDefaultClubIdForGovernanceForms(user) + }, [graphs, selectedGraphId, metaClubSelect, user]) const filteredEdges = useMemo(() => { if (!filterAnchorOnly || anchorExerciseId == null) return edges @@ -226,13 +310,7 @@ function ExerciseProgressionGraphPanel( } } - const resolvePromoteClubId = () => { - const g = graphs.find((x) => x.id === selectedGraphId) - if (g?.club_id != null) return Number(g.club_id) - const memberships = activeClubMemberships(user?.clubs) - const active = memberships.find((c) => c.is_active) || memberships[0] - return active?.club_id != null ? Number(active.club_id) : null - } + const resolvePromoteClubId = resolveGovernanceClubId const handleSaveMeta = async () => { if (!selectedGraphId) return @@ -247,48 +325,58 @@ function ExerciseProgressionGraphPanel( setBusy(true) try { - if (prevVis === 'private' && nextVis === 'club') { + if (shouldPromptGraphExercisePromotion(prevVis, nextVis)) { const preview = await api.getProgressionGraphVisibilityPromotionCandidates( selectedGraphId, - { targetVisibility: 'club' }, + { targetVisibility: nextVis }, ) - const privateExercises = Array.isArray(preview?.exercises) ? preview.exercises : [] - if (privateExercises.length > 0) { - const titles = privateExercises + const promotionExercises = Array.isArray(preview?.exercises) ? preview.exercises : [] + if (promotionExercises.length > 0) { + const visLabel = GRAPH_VISIBILITY_PROMOTION_LABEL[nextVis] || nextVis + const titles = promotionExercises .slice(0, 8) .map((ex) => `• ${ex.title || `Übung #${ex.id}`}`) .join('\n') const more = - privateExercises.length > 8 - ? `\n… und ${privateExercises.length - 8} weitere` + promotionExercises.length > 8 + ? `\n… und ${promotionExercises.length - 8} weitere` : '' const promote = window.confirm( - `Der Graph wird auf „Verein“ gestellt. Im Graph sind noch ${privateExercises.length} private Übung(en):\n\n${titles}${more}\n\nDiese Übungen ebenfalls auf Vereins-Sichtbarkeit anheben?`, + `Der Graph wird auf „${visLabel}“ gestellt. Im Graph sind noch ${promotionExercises.length} Übung(en) mit niedrigerer Sichtbarkeit:\n\n${titles}${more}\n\nDiese Übungen ebenfalls auf ${visLabel} anheben?`, ) if (promote) { - const clubId = resolvePromoteClubId() - if (!clubId) { - alert('Kein aktiver Verein — Übungen können nicht auf Verein promoted werden.') - } else { - const ids = privateExercises.map((ex) => ex.id).filter((id) => id != null) - const res = await api.bulkPatchExercisesMetadata({ - exercise_ids: ids, - visibility: 'club', - club_id: clubId, - }) - if ((res?.failed || []).length) { - const f = res.failed[0] - throw new Error(f?.detail || 'Übungs-Promotion fehlgeschlagen') + let clubId = null + if (nextVis === 'club') { + clubId = resolvePromoteClubId() + if (!clubId) { + throw new Error( + 'Kein Verein gewählt — bitte unter „Verein zuordnen“ einen Verein auswählen oder den Vereins-Umschalter nutzen.', + ) } } + const ids = promotionExercises.map((ex) => ex.id).filter((id) => id != null) + const bulkPayload = { exercise_ids: ids, visibility: nextVis } + if (nextVis === 'club' && clubId != null) bulkPayload.club_id = clubId + const res = await api.bulkPatchExercisesMetadata(bulkPayload) + if ((res?.failed || []).length) { + const f = res.failed[0] + throw new Error(f?.detail || 'Übungs-Promotion fehlgeschlagen') + } } } } + const promoteClubId = nextVis === 'club' ? resolvePromoteClubId() : null + if (nextVis === 'club' && !promoteClubId) { + throw new Error( + 'Vereins-Sichtbarkeit: Bitte einen Verein unter „Verein zuordnen“ wählen oder den Vereins-Umschalter setzen.', + ) + } await api.updateExerciseProgressionGraph(selectedGraphId, { name, description: metaDescription.trim() || null, visibility: metaVisibility, + ...(promoteClubId != null ? { club_id: promoteClubId } : {}), }) await refreshGraphs() alert('Graph-Metadaten gespeichert.') @@ -539,7 +627,14 @@ function ExerciseProgressionGraphPanel( + {metaVisibility === 'club' ? ( +
+ + +
+ ) : null}