Compare commits

...

10 Commits

Author SHA1 Message Date
90e8f51566 Update Catalog Prompt Slots Router and Access Layer Exemptions
All checks were successful
Deploy Development / deploy (push) Successful in 44s
Test Suite / pytest-backend (push) Successful in 44s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 13s
Test Suite / k6 /health Baseline (push) Successful in 34s
Test Suite / playwright-tests (push) Successful in 1m13s
- Added documentation to the `catalog_prompt_slots.py` file to clarify its role as a global admin catalog requiring authentication and admin role, without tenant context.
- Updated the `check_access_layer_hints.py` script to include `catalog_prompt_slots.py` in the list of exempt routers, ensuring proper access control for admin functionalities.
2026-06-15 15:27:03 +02:00
7e5ef4561a Refactor Catalog Prompt Slot Management and Enhance Fallback Logic
Some checks failed
Deploy Development / deploy (push) Successful in 40s
Test Suite / pytest-backend (push) Failing after 2s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 13s
Test Suite / k6 /health Baseline (push) Successful in 34s
Test Suite / playwright-tests (push) Successful in 1m22s
- Introduced a new function `_resolve_entry_slot_values` to streamline the merging of stored slot values with fallbacks, improving code clarity and maintainability.
- Updated `get_catalog_entry_slots` and `resolve_catalog_prompt_variables` functions to utilize the new fallback logic, enhancing the handling of catalog entries.
- Enhanced the `CatalogPromptSlotsEditor` component to display fallback information for slots, improving user experience in managing catalog prompts.
- Incremented version numbers and updated changelog to reflect the new features and improvements.
2026-06-15 15:18:00 +02:00
53f2b027cc Enhance Planning Catalog Context and Prompt Slot Management
Some checks failed
Deploy Development / deploy (push) Successful in 43s
Test Suite / pytest-backend (push) Failing after 2s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 13s
Test Suite / k6 /health Baseline (push) Successful in 34s
Test Suite / playwright-tests (push) Successful in 1m20s
- Introduced new catalog context handling in planning prompt functions, allowing for improved integration of planning variables.
- Added optional catalog context parameters in various functions to streamline the merging of planning prompt variables.
- Updated frontend components to include CatalogPromptSlotsEditor for managing prompt slots across different catalog types.
- Enhanced API utilities to support fetching and updating catalog prompt slots, improving backend functionality for catalog management.
- Incremented version numbers and updated changelog to reflect the new features and improvements.
2026-06-15 12:13:15 +02:00
9cee862c32 Implement Planning Prompt Enhancements and LLM Usage Tracking
All checks were successful
Deploy Development / deploy (push) Successful in 47s
Test Suite / pytest-backend (push) Successful in 49s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 15s
Test Suite / k6 /health Baseline (push) Successful in 34s
Test Suite / playwright-tests (push) Successful in 1m26s
- Added new fields for goal query, user notes, max steps, and search query in the AiPromptPreviewBody to support planning prompts.
- Integrated planning prompt handling in the preview_ai_prompt function, allowing for distinct processing of planning and exercise prompts.
- Introduced LLM usage tracking in openrouter_chat_completion and planning_exercise_suggest functions to monitor AI call metrics.
- Updated frontend components to accommodate new input fields for planning prompts, enhancing user experience and functionality.
2026-06-15 07:50:49 +02:00
0b203489f7 Implement Graph Visibility Promotion Logic and Update UI Components
All checks were successful
Deploy Development / deploy (push) Successful in 43s
Test Suite / pytest-backend (push) Successful in 44s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 13s
Test Suite / k6 /health Baseline (push) Successful in 41s
Test Suite / playwright-tests (push) Successful in 1m21s
- Added a new function `_graph_promotion_transition` to determine the necessary exercise visibility changes during graph promotions.
- Updated the `list_visibility_promotion_candidates` endpoint to utilize the new promotion logic, ensuring accurate exercise visibility handling.
- Enhanced the frontend components to prompt users for exercise visibility adjustments based on graph visibility changes, improving user experience.
- Introduced tests for the new promotion logic to ensure correctness and reliability in visibility transitions.
2026-06-14 07:30:26 +02:00
1c67a50ce4 Enhance Exercise Progression Graph Panel with Governance Club Management
All checks were successful
Deploy Development / deploy (push) Successful in 42s
Test Suite / pytest-backend (push) Successful in 44s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 14s
Test Suite / k6 /health Baseline (push) Successful in 38s
Test Suite / playwright-tests (push) Successful in 1m12s
- Introduced functionality to manage governance clubs for superadmins, allowing for better club selection and organization within the Exercise Progression Graph Panel.
- Implemented state management for clubs, including sorting and filtering options, to improve user experience and accessibility.
- Enhanced the useEffect hook to fetch governance clubs dynamically, ensuring up-to-date club information is available for selection.
- Updated the club selection dropdown to categorize clubs into "My Clubs" and "Other Clubs," improving clarity and usability for users.
2026-06-14 07:19:35 +02:00
87d9fa9b65 Enhance Exercise Progression Graph Panel with Club Management Features
All checks were successful
Deploy Development / deploy (push) Successful in 43s
Test Suite / pytest-backend (push) Successful in 44s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 14s
Test Suite / k6 /health Baseline (push) Successful in 43s
Test Suite / playwright-tests (push) Successful in 1m13s
- Added functionality to select and manage clubs within the Exercise Progression Graph Panel, allowing users to assign clubs to exercises.
- Introduced state management for club selection and manual entry, improving user experience for platform admins.
- Updated visibility handling to ensure proper governance and club association during exercise promotion.
- Enhanced error handling to provide clearer feedback when no club is selected, ensuring users are guided to make necessary selections.
2026-06-14 07:10:11 +02:00
4b9374765b Enhance Progression Graph Management with F15 Features and Evaluation Improvements
All checks were successful
Deploy Development / deploy (push) Successful in 41s
Test Suite / pytest-backend (push) Successful in 43s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 13s
Test Suite / k6 /health Baseline (push) Successful in 34s
Test Suite / playwright-tests (push) Successful in 1m21s
- Updated `PROJECT_STATUS.md` to reflect the implementation of F15 features, including the unified slot review and handling of `findings_stale`.
- Enhanced `PROGRESSION_GRAPH_SLOT_EDITOR_SPEC.md` with detailed descriptions of new functionalities related to the match dialog and path quality assessments.
- Introduced new functions in `exercise_progression_graphs.py` to validate exercise visibility against progression graph settings, ensuring proper governance.
- Improved frontend components to support new governance parameters (visibility and club_id) in exercise creation workflows.
- Updated documentation in `HANDOVER.md` and `PLANNING_KI_ROADMAP.md` to outline the latest developments and validation results for the F15 features.
- Enhanced utility functions for exercise creation to incorporate governance settings, improving the overall user experience in the path builder and editor.
2026-06-14 06:44:12 +02:00
b629f192ac Refactor ProgressionSlotCard Key Prop for Improved Stability
All checks were successful
Deploy Development / deploy (push) Successful in 44s
Test Suite / pytest-backend (push) Successful in 44s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 14s
Test Suite / k6 /health Baseline (push) Successful in 33s
Test Suite / playwright-tests (push) Successful in 1m39s
- Updated the key prop in the ProgressionSlotCard component to use a simpler index-based key, enhancing the stability of component rendering during updates.
- This change aims to prevent potential issues with component re-renders and improve overall performance in the ProgressionGraphEditor.
2026-06-13 17:33:36 +02:00
313d613b7c Enhance Path Quality Assessment and Slot Management Features
All checks were successful
Deploy Development / deploy (push) Successful in 42s
Test Suite / pytest-backend (push) Successful in 44s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 13s
Test Suite / k6 /health Baseline (push) Successful in 34s
Test Suite / playwright-tests (push) Successful in 1m13s
- Added new functions for computing assignment quality scores and counting step assignment statistics, improving the evaluation of steps in path quality assessments.
- Updated existing methods to incorporate the new scoring logic, enhancing the robustness of path evaluations.
- Introduced UI components in the frontend to display detailed quality assessment results, including handling of split dimensions in path evaluations.
- Enhanced tests to cover new functionalities and ensure accuracy in quality scoring and slot management processes.
2026-06-13 17:13:35 +02:00
47 changed files with 4037 additions and 386 deletions

View File

@ -15,7 +15,7 @@
**Plattform-Rechtstexte (P-01, 0.8.950.8.96):** Admin-Editor mit **Abschnitts- und Vollvorschau** (Markdown); fortlaufende Abschnittsnummerierung in der Anzeige/PDF (Darstellung, nicht DB-persistent). **Plattform-Rechtstexte (P-01, 0.8.950.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.1370.8.140**; Handover **`docs/HANDOVER.md`** §3); **Trainingsrahmenprogramm** (036037), **Progressionsgraph** (032034) — 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.1370.8.140**; Handover **`docs/HANDOVER.md`** §3); **Trainingsrahmenprogramm** (036037), **Progressionsgraph** (032034) — 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) **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)

View File

@ -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 ## Ziel
@ -35,35 +35,52 @@ Leere Slots in der Roadmap sind erlaubt; Kanten nur zwischen aufeinanderfolgende
slots: Slot[], // index = major_step_index slots: Slot[], // index = major_step_index
pathSkillExpectations?, pathSkillExpectations?,
lastFindings?, // path_qa-Snapshot lastFindings?, // path_qa-Snapshot
findingsStale?: boolean, // Bewertung veraltet (↔ Artefakt findings_stale)
dirty: boolean, dirty: boolean,
} }
``` ```
**Hydration:** `planning_roadmap` + Kanten → Slots; `slot_contents[]` für Entwürfe; Primärkette aus `next_exercise`. **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 ## 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. **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`) ## Artefakt-Erweiterung (`GraphPlanningRoadmapArtifact`)
Zusätzlich optional: Optional:
- `slot_contents[]``{ major_step_index, primary, siblings[] }` - `slot_contents[]``{ major_step_index, primary, siblings[] }`
- `last_findings` — letzter `path_qa`-Snapshot - `last_findings` — letzter `path_qa`-Snapshot
- **`findings_stale`** — bool, Bewertung bezieht sich nicht mehr auf aktuellen Graph-Stand
## UI (konsolidiert) ## UI (konsolidiert)
- **Eine Oberfläche:** `ExerciseProgressionGraphPanel` embeddet `ProgressionGraphEditor` (Slots + Findings) - **Eine Oberfläche:** `ExerciseProgressionGraphPanel` embeddet `ProgressionGraphEditor` (Slots + Findings)
- Kein separater Slot-Editor, kein 4-Schritt-KI-Wizard, kein `ProgressionChainEditor` im Panel - 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) - 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) ## Ersetzt (Legacy, nicht mehr im Panel)
@ -71,11 +88,14 @@ Zusätzlich optional:
## Implementierungsreihenfolge ## Implementierungsreihenfolge
| ID | Inhalt | | ID | Inhalt | Status |
|----|--------| |----|--------|--------|
| B.0 | Draft + Laden/Speichern Slots ↔ Kanten | | B.0 | Draft + Laden/Speichern Slots ↔ Kanten | ✅ |
| B.1 | Slot-Karten, Bibliothek + Entwurf | | B.1 | Slot-Karten, Bibliothek + Entwurf | ✅ |
| B.2 | Findings-Panel + `evaluate_only` | | B.2 | Findings-Panel + `evaluate_only` | ✅ |
| B.3 | Entwürfe im Artefakt + „Übung anlegen“ | | B.3 | Entwürfe im Artefakt + „Übung anlegen“ | ✅ |
| B.4 | Route + Panel vereinfachen | | B.4 | Route + Panel vereinfachen | ✅ |
| B.5 | `last_findings` + Phase-C-Vorbereitung | | 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

View File

@ -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",
]

View File

@ -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",
]

View File

@ -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",
]

View File

@ -243,7 +243,7 @@ def read_root():
return out return out
# Register routers # 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(auth.router)
app.include_router(profiles.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_modules.router)
app.include_router(training_framework_programs.router) app.include_router(training_framework_programs.router)
app.include_router(catalogs.router) app.include_router(catalogs.router)
app.include_router(catalog_prompt_slots.router)
app.include_router(maturity_models.router) app.include_router(maturity_models.router)
app.include_router(matrix_stack_bundle.router) app.include_router(matrix_stack_bundle.router)
app.include_router(matrix_editor.router) app.include_router(matrix_editor.router)

View File

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

View File

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

View File

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

View File

@ -0,0 +1,167 @@
-- Migration 094: Vollständige Befüllung catalog_prompt_slots (H1-Inhalte + Defaults für alle Stammdaten)
CREATE TEMP TABLE IF NOT EXISTS _catalog_slot_seed (
catalog_kind VARCHAR(32) NOT NULL,
name_pattern TEXT NOT NULL,
slot_key VARCHAR(64) NOT NULL,
content TEXT NOT NULL
);
TRUNCATE _catalog_slot_seed;
-- Primärfokus Karate (häufigster Technik-Pfad)
INSERT INTO _catalog_slot_seed (catalog_kind, name_pattern, slot_key, content) VALUES
('focus_area', 'Karate', 'description',
'Technik-Curriculum im Karate-Kontext: aufeinander aufbauende Kihon-Progression mit klaren Qualitätsankern (Stand, Hüfte, Kime).'),
('focus_area', 'Karate', 'hints_on_progression',
'Typische Phasen: Einstieg → Grundlagen → Koordination/Kraft → Anwendung → optional Vertiefung; Grundlagen vor Perfektion.'),
('focus_area', 'Karate', 'hints_on_exercise',
'Kihon und Partnerübungen mit Technikbezug; reine Kraft-/Ausdauer-Inseln nur mit klarer Begründung.'),
('focus_area', 'Karate', 'hints_on_path_qa',
'Kohärente Progression Grundlagen → Anwendung → Vertiefung; Übergänge ohne Sprünge; themenfremde Kraft-/Ausdauer-Inseln abwerten.'),
('focus_area', 'Karate', 'anti_patterns',
'Keine pauschale Perfektions-Stufe verlangen, wenn der Trainingsstil Breitensport ist.');
-- Selbstverteidigung
INSERT INTO _catalog_slot_seed VALUES
('focus_area', 'Selbstverteidigung', 'description',
'Praktische Selbstverteidigung: realistische Szenarien, Sicherheit und anwendungsnahe Progression — nicht Show-Technik oder Wettkampf-Kata.'),
('focus_area', 'Selbstverteidigung', 'hints_on_progression',
'Von Wahrnehmung und Distanz zu einfachen Abwehrmustern und kontrollierter Anwendung.'),
('focus_area', 'Selbstverteidigung', 'hints_on_exercise',
'Partnerübungen mit klaren Sicherheitsregeln; Szenario-Bezug wichtiger als Stil-Show.'),
('focus_area', 'Selbstverteidigung', 'hints_on_path_qa',
'Lücken bei Szenario- oder Sicherheitsstufen sind relevant; fehlende Kick-Varianten oder Wettkampftiefe sind kein Mangel.'),
('focus_area', 'Selbstverteidigung', 'anti_patterns',
'Keine Wettkampf- oder Kata-Perfektion als QS-Maßstab.');
-- Gewaltschutz (ergänzt 092)
INSERT INTO _catalog_slot_seed VALUES
('focus_area', 'Gewaltschutz', 'hints_on_progression',
'Phasen: Wahrnehmung → Grenzen → Deeskalation → sichere Übungsformen; keine Kumite-Perfektionsstufen erzwingen.'),
('focus_area', 'Gewaltschutz', 'hints_on_exercise',
'Übungen mit Rollen, Kommunikation, Ausweichen; keine rein technischen Kick-Fokus-Inseln ohne Bezug.');
-- Fitness (falls vorhanden)
INSERT INTO _catalog_slot_seed VALUES
('focus_area', 'Fitness', 'description',
'Fitness- und Konditionsorientierung mit sicherer Belastungssteuerung; Technikbezug nur wo fachlich sinnvoll.'),
('focus_area', 'Fitness', 'hints_on_progression',
'Progression von niedriger zu moderater Belastung; klare Pausen und Technikhygiene.'),
('focus_area', 'Fitness', 'hints_on_path_qa',
'Keine Wettkampf-Spezialisierung als Pflicht-Kriterium; Belastungssteigerung ohne Technikbezug abwerten.'),
('focus_area', 'Fitness', 'anti_patterns',
'Keine Kumite-Perfektion oder Wettkampf-Kombinationen als QS-Maßstab verlangen.');
-- Trainingsstile (global)
INSERT INTO _catalog_slot_seed VALUES
('training_type', 'Breitensport', 'hints_on_progression',
'Moderater Schwierigkeitsanstieg; Perfektionsphasen optional.'),
('training_type', 'Breitensport', 'anti_patterns',
'Keine Leistungssport-Perfektion als Pflicht-Lücke.'),
('training_type', 'Leistungssport', 'description',
'Leistungsorientiertes Training mit höherer Anspruchskurve und Spezialisierung.'),
('training_type', 'Leistungssport', 'hints_on_progression',
'Belastungs- und Kombinationsprogressionen sind erwünscht.'),
('training_type', 'Leistungssport', 'hints_on_path_qa',
'Höhere Anspruchskurven sind passend; Lücken in Spezialisierung können echte Hinweise sein.'),
('training_type', 'Wettkampf', 'hints_on_progression',
'Anwendungs- und Druckphasen zeitig einplanen.');
-- Zielgruppen
INSERT INTO _catalog_slot_seed VALUES
('target_group', 'Breitensportler', 'description',
'Breitensport: Partizipation und Verständlichkeit vor maximaler Spezialisierung.'),
('target_group', 'Breitensportler', 'hints_on_path_qa',
'Moderate Progression; Perfektions-Lücken sind selten echte Mängel.'),
('target_group', 'Breitensportler', 'anti_patterns',
'Keine Leistungssport-Perfektion als Pflicht-Kriterium.'),
('target_group', 'Kinder', 'hints_on_progression',
'Spielerische Einstiege; kurze Abschnitte; Sicherheit vor Perfektion.'),
('target_group', 'Leistungssportler', 'hints_on_progression',
'Anspruchskurve und Spezialisierung dürfen steiler sein.');
-- Stilrichtungen (generisch + Shotokan-Details via 092)
INSERT INTO _catalog_slot_seed VALUES
('style_direction', 'Goju-Ryu', 'hints_on_progression',
'Stil-Nuancen (Stand, Atem, Kime) einbeziehen — kein Stilwechsel erzwingen.'),
('style_direction', 'Wado-Ryu', 'hints_on_progression',
'Stil-Nuancen (Stand, Atem, Kime) einbeziehen — kein Stilwechsel erzwingen.'),
('style_direction', 'Shito-Ryu', 'hints_on_progression',
'Stil-Nuancen (Stand, Atem, Kime) einbeziehen — kein Stilwechsel erzwingen.'),
('style_direction', 'Kyokushin', 'hints_on_progression',
'Stil-Nuancen (Stand, Belastung, Kime) einbeziehen — kein Stilwechsel erzwingen.');
-- Fokusbereiche: aus Seed-Tabelle
INSERT INTO catalog_prompt_slots (catalog_kind, catalog_id, slot_key, content)
SELECT s.catalog_kind, fa.id, s.slot_key, s.content
FROM _catalog_slot_seed s
JOIN focus_areas fa ON fa.name ILIKE s.name_pattern
WHERE s.catalog_kind = 'focus_area'
ON CONFLICT (catalog_kind, catalog_id, slot_key)
DO UPDATE SET content = EXCLUDED.content, updated_at = NOW();
INSERT INTO catalog_prompt_slots (catalog_kind, catalog_id, slot_key, content)
SELECT s.catalog_kind, tt.id, s.slot_key, s.content
FROM _catalog_slot_seed s
JOIN training_types tt ON tt.name ILIKE s.name_pattern
WHERE s.catalog_kind = 'training_type'
ON CONFLICT (catalog_kind, catalog_id, slot_key)
DO UPDATE SET content = EXCLUDED.content, updated_at = NOW();
INSERT INTO catalog_prompt_slots (catalog_kind, catalog_id, slot_key, content)
SELECT s.catalog_kind, tg.id, s.slot_key, s.content
FROM _catalog_slot_seed s
JOIN target_groups tg ON tg.name ILIKE s.name_pattern
WHERE s.catalog_kind = 'target_group'
ON CONFLICT (catalog_kind, catalog_id, slot_key)
DO UPDATE SET content = EXCLUDED.content, updated_at = NOW();
INSERT INTO catalog_prompt_slots (catalog_kind, catalog_id, slot_key, content)
SELECT s.catalog_kind, sd.id, s.slot_key, s.content
FROM _catalog_slot_seed s
JOIN style_directions sd ON sd.name ILIKE s.name_pattern
WHERE s.catalog_kind = 'style_direction'
ON CONFLICT (catalog_kind, catalog_id, slot_key)
DO UPDATE SET content = EXCLUDED.content, updated_at = NOW();
-- Default-Technik-Pack für Fokusbereiche ohne hints_on_path_qa (außer Gewaltschutz/Fitness)
INSERT INTO catalog_prompt_slots (catalog_kind, catalog_id, slot_key, content)
SELECT 'focus_area', fa.id, 'hints_on_path_qa',
'Kohärente Progression zum Anfrage-Thema; Lücken sind fehlende Zwischenstufen im Lernpfad, nicht fehlende Nebenthemen.'
FROM focus_areas fa
WHERE fa.name NOT ILIKE 'Gewaltschutz'
AND fa.name NOT ILIKE 'Fitness'
AND NOT EXISTS (
SELECT 1 FROM catalog_prompt_slots cps
WHERE cps.catalog_kind = 'focus_area' AND cps.catalog_id = fa.id AND cps.slot_key = 'hints_on_path_qa'
AND TRIM(cps.content) <> ''
)
ON CONFLICT (catalog_kind, catalog_id, slot_key) DO NOTHING;
INSERT INTO catalog_prompt_slots (catalog_kind, catalog_id, slot_key, content)
SELECT 'focus_area', fa.id, 'hints_on_progression',
'Grundlagen vor Anwendung; moderate Sprünge zwischen Stufen vermeiden.'
FROM focus_areas fa
WHERE fa.name NOT ILIKE 'Gewaltschutz'
AND fa.name NOT ILIKE 'Fitness'
AND NOT EXISTS (
SELECT 1 FROM catalog_prompt_slots cps
WHERE cps.catalog_kind = 'focus_area' AND cps.catalog_id = fa.id AND cps.slot_key = 'hints_on_progression'
AND TRIM(cps.content) <> ''
)
ON CONFLICT (catalog_kind, catalog_id, slot_key) DO NOTHING;
-- Stilrichtungen ohne Eintrag: generischer Progressions-Hinweis
INSERT INTO catalog_prompt_slots (catalog_kind, catalog_id, slot_key, content)
SELECT 'style_direction', sd.id, 'hints_on_progression',
'Stil-spezifische Nuancen (Stand, Hüfte, Rhythmus) einbeziehen — ohne Stilwechsel zu erzwingen.'
FROM style_directions sd
WHERE NOT EXISTS (
SELECT 1 FROM catalog_prompt_slots cps
WHERE cps.catalog_kind = 'style_direction' AND cps.catalog_id = sd.id AND cps.slot_key = 'hints_on_progression'
AND TRIM(cps.content) <> ''
)
ON CONFLICT (catalog_kind, catalog_id, slot_key) DO NOTHING;
DROP TABLE IF EXISTS _catalog_slot_seed;

View File

@ -196,6 +196,13 @@ def openrouter_chat_completion(
cc, cc,
) )
try:
from planning_llm_usage import record_planning_llm_call
record_planning_llm_call(1)
except Exception:
pass
return joined return joined

View File

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

View File

@ -2082,6 +2082,7 @@ def _run_evaluate_only_path_qa(
semantic_brief: PlanningSemanticBrief, semantic_brief: PlanningSemanticBrief,
steps: List[Dict[str, Any]], steps: List[Dict[str, Any]],
roadmap_ctx: Optional[ProgressionRoadmapContext], roadmap_ctx: Optional[ProgressionRoadmapContext],
catalog_context: Optional[ProgressionPlanningCatalogContext] = None,
) -> Dict[str, Any]: ) -> Dict[str, Any]:
roadmap_first = roadmap_ctx is not None roadmap_first = roadmap_ctx is not None
gaps: List[Dict[str, Any]] = [] gaps: List[Dict[str, Any]] = []
@ -2095,6 +2096,9 @@ def _run_evaluate_only_path_qa(
gap_fill_offers: List[Dict[str, Any]] = [] gap_fill_offers: List[Dict[str, Any]] = []
roadmap_qa_mode: Optional[str] = None 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 body.include_path_qa:
if roadmap_first: if roadmap_first:
roadmap_qa_mode = "roadmap_first_lite" roadmap_qa_mode = "roadmap_first_lite"
@ -2115,6 +2119,7 @@ def _run_evaluate_only_path_qa(
steps=steps, steps=steps,
gaps=gaps, gaps=gaps,
bridge_inserts=bridge_inserts, bridge_inserts=bridge_inserts,
catalog=catalog_context,
) )
off_topic_steps = detect_off_topic_steps( off_topic_steps = detect_off_topic_steps(
@ -2208,6 +2213,7 @@ def _run_evaluate_only_path_qa(
reorder_notes=[], reorder_notes=[],
roadmap_qa_mode=roadmap_qa_mode, roadmap_qa_mode=roadmap_qa_mode,
multistage_qa=multistage_qa, multistage_qa=multistage_qa,
steps=steps,
) )
return { return {
"path_qa": path_qa, "path_qa": path_qa,
@ -2500,6 +2506,7 @@ def _quick_evaluate_steps_qa(
llm_applied=False, llm_applied=False,
roadmap_qa_mode="roadmap_first_lite" if roadmap_first else None, roadmap_qa_mode="roadmap_first_lite" if roadmap_first else None,
multistage_qa=multistage_qa, multistage_qa=multistage_qa,
steps=steps_list,
) )
if path_qa.get("quality_score") is None: if path_qa.get("quality_score") is None:
path_qa["quality_score"] = compute_deterministic_path_quality_score( 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_POOR_THRESHOLD = 0.30
_SLOT_FIT_GOOD_THRESHOLD = 0.50
def _off_topic_semantic_scores_by_slot( def _off_topic_semantic_scores_by_slot(
@ -3152,9 +3160,18 @@ def _slot_auto_select_library(
return False return False
if proposed_slot_score is None: if proposed_slot_score is None:
return False return False
if baseline_slot_score is None: 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 return True
return float(proposed_slot_score) > float(baseline_slot_score) + 0.001
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( def _build_unified_slot_review_entry(
@ -3391,10 +3408,14 @@ def _build_unified_slot_review_entry(
) )
gap_fill_offers.append(slot_offer) gap_fill_offers.append(slot_offer)
if 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 = { ai_alt = {
"title_hint": slot_offer.get("title_hint") or f"Slot {major_idx + 1}", "title_hint": slot_offer.get("title_hint") or f"Slot {major_idx + 1}",
"gap_offer": slot_offer, "gap_offer": slot_offer,
"auto_select": False, "auto_select": ai_auto,
} }
return { return {
@ -3812,6 +3833,7 @@ def suggest_progression_path(
roadmap_ctx: Optional[ProgressionRoadmapContext] = None roadmap_ctx: Optional[ProgressionRoadmapContext] = None
roadmap_edited = False roadmap_edited = False
roadmap_structured = _roadmap_structured_from_body(body) roadmap_structured = _roadmap_structured_from_body(body)
catalog_context = _resolve_planning_catalog_context(cur, body)
if body.roadmap_override is not None: if body.roadmap_override is not None:
try: try:
@ -3836,6 +3858,7 @@ def suggest_progression_path(
cur=cur, cur=cur,
include_llm_start_target=body.include_llm_start_target, include_llm_start_target=body.include_llm_start_target,
structured=roadmap_structured, structured=roadmap_structured,
catalog=catalog_context,
) )
progression_roadmap = progression_roadmap_to_api_dict(roadmap_ctx) progression_roadmap = progression_roadmap_to_api_dict(roadmap_ctx)
elif include_roadmap: elif include_roadmap:
@ -3847,6 +3870,7 @@ def suggest_progression_path(
include_llm_roadmap=body.include_llm_roadmap, include_llm_roadmap=body.include_llm_roadmap,
include_llm_start_target=body.include_llm_start_target, include_llm_start_target=body.include_llm_start_target,
structured=roadmap_structured, structured=roadmap_structured,
catalog=catalog_context,
) )
progression_roadmap = progression_roadmap_to_api_dict(roadmap_ctx) progression_roadmap = progression_roadmap_to_api_dict(roadmap_ctx)
@ -3907,6 +3931,7 @@ def suggest_progression_path(
semantic_brief=semantic_brief, semantic_brief=semantic_brief,
steps=eval_steps, steps=eval_steps,
roadmap_ctx=roadmap_ctx, roadmap_ctx=roadmap_ctx,
catalog_context=catalog_context,
) )
return { return {
"goal_query": goal_query, "goal_query": goal_query,
@ -3937,7 +3962,7 @@ def suggest_progression_path(
start_situation=body.start_situation, start_situation=body.start_situation,
target_state=body.target_state, target_state=body.target_state,
roadmap_notes=body.roadmap_notes, 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 path_skill_expectations: Optional[Dict[str, Any]] = None
if roadmap_ctx and roadmap_ctx.goal_analysis: if roadmap_ctx and roadmap_ctx.goal_analysis:
@ -4136,6 +4161,7 @@ def suggest_progression_path(
steps=steps, steps=steps,
gaps=gaps, gaps=gaps,
bridge_inserts=bridge_inserts, bridge_inserts=bridge_inserts,
catalog=catalog_context,
) )
if ( if (
@ -4224,6 +4250,7 @@ def suggest_progression_path(
steps=steps, steps=steps,
gaps=gaps, gaps=gaps,
bridge_inserts=bridge_inserts, bridge_inserts=bridge_inserts,
catalog=catalog_context,
) )
llm_gap_specs = parse_llm_suggested_new_exercises( llm_gap_specs = parse_llm_suggested_new_exercises(
@ -4312,6 +4339,7 @@ def suggest_progression_path(
reorder_notes=reorder_notes, reorder_notes=reorder_notes,
roadmap_qa_mode=roadmap_qa_mode, roadmap_qa_mode=roadmap_qa_mode,
multistage_qa=multistage_qa, multistage_qa=multistage_qa,
steps=steps,
) )
if rematch_log: if rematch_log:
path_qa["rematch_applied"] = True path_qa["rematch_applied"] = True

View File

@ -9,6 +9,8 @@ import re
from typing import Any, Callable, Dict, List, Mapping, Optional, Sequence, Set, Tuple from typing import Any, Callable, Dict, List, Mapping, Optional, Sequence, Set, Tuple
from ai_prompt_runtime import AiPromptUnavailableError, load_and_render_ai_prompt 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 exercise_ai import strip_html_to_plain
from openrouter_chat import ( from openrouter_chat import (
effective_openrouter_model_for_prompt_row, effective_openrouter_model_for_prompt_row,
@ -320,6 +322,7 @@ def try_llm_qa_progression_path(
steps: Sequence[Mapping[str, Any]], steps: Sequence[Mapping[str, Any]],
gaps: Sequence[Mapping[str, Any]], gaps: Sequence[Mapping[str, Any]],
bridge_inserts: Sequence[Mapping[str, Any]], bridge_inserts: Sequence[Mapping[str, Any]],
catalog: Optional[ProgressionPlanningCatalogContext] = None,
) -> Tuple[Optional[Dict[str, Any]], bool]: ) -> Tuple[Optional[Dict[str, Any]], bool]:
api_key, _ = normalize_openrouter_env() api_key, _ = normalize_openrouter_env()
if not api_key or len(steps) < 2: if not api_key or len(steps) < 2:
@ -354,13 +357,18 @@ def try_llm_qa_progression_path(
} }
) )
variables = { variables = merge_planning_prompt_variables(
cur,
{
"goal_query": goal_query or "", "goal_query": goal_query or "",
"semantic_brief_json": json.dumps(brief_to_summary_dict(brief), ensure_ascii=False), "semantic_brief_json": json.dumps(brief_to_summary_dict(brief), ensure_ascii=False),
"steps_json": json.dumps(step_payload, ensure_ascii=False), "steps_json": json.dumps(step_payload, ensure_ascii=False),
"gaps_json": json.dumps(list(gaps), ensure_ascii=False), "gaps_json": json.dumps(list(gaps), ensure_ascii=False),
"bridge_inserts_json": json.dumps(list(bridge_inserts), ensure_ascii=False), "bridge_inserts_json": json.dumps(list(bridge_inserts), ensure_ascii=False),
} },
catalog=catalog,
slug="planning_exercise_path_qa",
)
try: try:
prow, rendered = load_and_render_ai_prompt(cur, "planning_exercise_path_qa", variables) prow, rendered = load_and_render_ai_prompt(cur, "planning_exercise_path_qa", variables)
@ -688,6 +696,160 @@ def find_step_pair_index(
return None 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( def build_path_qa_summary(
*, *,
gaps: Sequence[Mapping[str, Any]], gaps: Sequence[Mapping[str, Any]],
@ -702,6 +864,7 @@ def build_path_qa_summary(
reorder_notes: Optional[Sequence[str]] = None, reorder_notes: Optional[Sequence[str]] = None,
roadmap_qa_mode: Optional[str] = None, roadmap_qa_mode: Optional[str] = None,
multistage_qa: Optional[Mapping[str, Any]] = None, multistage_qa: Optional[Mapping[str, Any]] = None,
steps: Optional[Sequence[Mapping[str, Any]]] = None,
) -> Dict[str, Any]: ) -> Dict[str, Any]:
offers = list(gap_fill_offers or []) offers = list(gap_fill_offers or [])
off_topic = list(off_topic_steps 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["qa_tiers"] = list(multistage_qa.get("qa_tiers") or [])
summary["optimization_hints"] = list(multistage_qa.get("optimization_hints") or []) summary["optimization_hints"] = list(multistage_qa.get("optimization_hints") or [])
summary["optimization_hint_count"] = int(multistage_qa.get("optimization_hint_count") or 0) summary["optimization_hint_count"] = int(multistage_qa.get("optimization_hint_count") or 0)
if llm_qa:
summary["overall_ok"] = bool(llm_qa.get("overall_ok", True)) assignment_qa = build_assignment_qa_snapshot(
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, steps=steps,
multistage_qa=multistage_qa, 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["suggested_new_exercises"] = list(llm_qa.get("suggested_new_exercises") or [])
return summary return summary
@ -761,31 +925,34 @@ def compute_deterministic_path_quality_score(
steps: Optional[Sequence[Mapping[str, Any]]] = None, steps: Optional[Sequence[Mapping[str, Any]]] = None,
multistage_qa: Optional[Mapping[str, Any]] = None, multistage_qa: Optional[Mapping[str, Any]] = None,
) -> float: ) -> float:
"""Heuristische Pfad-QS ohne LLM — Basis für Slot-Vergleiche.""" """Heuristische Pfad-QS ohne LLM — Roadmap + Besetzung kombiniert."""
score = 0.92 roadmap_qa = build_roadmap_qa_snapshot(
score -= 0.08 * len(off_topic_steps or []) llm_qa=None,
score -= 0.05 * len(gaps or []) llm_applied=False,
if steps: gaps=gaps,
empty = sum( multistage_qa=multistage_qa,
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 assignment_qa = build_assignment_qa_snapshot(
hint_count = int((multistage_qa or {}).get("optimization_hint_count") or 0) steps=steps,
score -= min(0.14, 0.02 * hint_count) off_topic_steps=off_topic_steps,
return max(0.35, min(0.98, round(score, 4))) gaps=gaps,
)
return merge_path_quality_scores(roadmap_qa, assignment_qa)
__all__ = [ __all__ = [
"apply_llm_path_reorder", "apply_llm_path_reorder",
"build_assignment_qa_snapshot",
"build_path_qa_summary", "build_path_qa_summary",
"build_roadmap_qa_snapshot",
"compute_assignment_quality_score",
"compute_deterministic_path_quality_score", "compute_deterministic_path_quality_score",
"compute_roadmap_quality_score",
"count_step_assignment_stats",
"detect_off_topic_steps", "detect_off_topic_steps",
"detect_path_gaps", "detect_path_gaps",
"is_roadmap_planned_neighbor_pair", "is_roadmap_planned_neighbor_pair",
"merge_path_quality_scores",
"strip_off_topic_steps_from_path", "strip_off_topic_steps_from_path",
"find_step_pair_index", "find_step_pair_index",
"insert_bridge_exercises", "insert_bridge_exercises",

View File

@ -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",
]

View File

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

View File

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

View File

@ -14,9 +14,15 @@ from auth import require_auth
from club_tenancy import is_superadmin from club_tenancy import is_superadmin
from ai_prompt_context import ExerciseFormAiPromptContext from ai_prompt_context import ExerciseFormAiPromptContext
from ai_prompt_job import resolve_exercise_form_variables 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 ai_prompt_runtime import render_ai_prompt_template_for_row
from db import get_cursor, get_db, r2d from db import get_cursor, get_db, r2d
from prompt_resolver import exercise_placeholder_catalog from prompt_resolver import exercise_placeholder_catalog
from planning_prompt_variables import planning_prompt_placeholder_catalog
router = APIRouter(tags=["admin_ai_prompts"]) router = APIRouter(tags=["admin_ai_prompts"])
@ -62,12 +68,22 @@ class AiPromptUpdateBody(BaseModel):
class AiPromptPreviewBody(ExerciseFormAiPromptContext): 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") @router.get("/api/admin/ai-prompts/catalog/placeholders")
def get_ai_prompt_placeholders_catalog(session: dict = Depends(_require_superadmin)): 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") @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) vars_map = resolve_exercise_form_variables(cur, slug, body)
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) from 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": elif slug == "pipeline":
vars_map = {} vars_map = {}
warn = "Pipeline-Slug: keine Kontextsubstitution fuer Vorschau." warn = "Pipeline-Slug: keine Kontextsubstitution fuer Vorschau."

View File

@ -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

View File

@ -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. AuthZ analog training_plan_templates: Bearbeiten/Löschen wie Übungen; Governance-Übergänge zentral.
""" """
import json 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 fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel, Field, model_validator from pydantic import BaseModel, Field, model_validator
@ -19,6 +19,7 @@ from club_tenancy import (
assert_library_content_editable, assert_library_content_editable,
assert_library_content_governance_transition, assert_library_content_governance_transition,
assert_valid_governance_visibility, assert_valid_governance_visibility,
is_platform_admin,
library_content_visible_to_profile, 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") 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( def _insert_edge_row(
cur, cur,
graph_id: int, graph_id: int,
@ -312,6 +394,22 @@ def _collect_graph_referenced_exercise_ids(cur, graph_id: int) -> set[int]:
return ids 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") @router.get("/exercise-progression-graphs/{graph_id}/visibility-promotion-candidates")
def list_visibility_promotion_candidates( def list_visibility_promotion_candidates(
graph_id: int, graph_id: int,
@ -319,7 +417,9 @@ def list_visibility_promotion_candidates(
tenant: TenantContext = Depends(get_tenant_context), 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: privateclub, privateofficial, clubofficial.
""" """
profile_id = tenant.profile_id profile_id = tenant.profile_id
role = tenant.global_role role = tenant.global_role
@ -327,11 +427,13 @@ def list_visibility_promotion_candidates(
cur = get_cursor(conn) cur = get_cursor(conn)
row = _require_graph_read(cur, graph_id, profile_id, role) row = _require_graph_read(cur, graph_id, profile_id, role)
graph_vis = (row.get("visibility") or "private").strip().lower() 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 { return {
"graph_id": graph_id, "graph_id": graph_id,
"graph_visibility": graph_vis, "graph_visibility": graph_vis,
"target_visibility": target_visibility, "target_visibility": target_vis,
"exercises": [], "exercises": [],
} }
ref_ids = _collect_graph_referenced_exercise_ids(cur, graph_id) ref_ids = _collect_graph_referenced_exercise_ids(cur, graph_id)
@ -339,19 +441,20 @@ def list_visibility_promotion_candidates(
return { return {
"graph_id": graph_id, "graph_id": graph_id,
"graph_visibility": graph_vis, "graph_visibility": graph_vis,
"target_visibility": target_visibility, "target_visibility": target_vis,
"exercises": [], "exercises": [],
} }
vis_placeholders = ",".join(["%s"] * len(need_vis))
ph = ",".join(["%s"] * len(ref_ids)) ph = ",".join(["%s"] * len(ref_ids))
cur.execute( cur.execute(
f""" f"""
SELECT id, title, visibility, club_id, created_by SELECT id, title, visibility, club_id, created_by
FROM exercises FROM exercises
WHERE id IN ({ph}) WHERE id IN ({ph})
AND LOWER(TRIM(COALESCE(visibility, ''))) = 'private' AND LOWER(TRIM(COALESCE(visibility, ''))) IN ({vis_placeholders})
ORDER BY title ORDER BY title
""", """,
list(ref_ids), list(ref_ids) + list(need_vis),
) )
exercises = [] exercises = []
for ex in cur.fetchall(): for ex in cur.fetchall():
@ -359,8 +462,10 @@ def list_visibility_promotion_candidates(
if not library_content_visible_to_profile( if not library_content_visible_to_profile(
cur, cur,
profile_id, profile_id,
(exd.get("visibility") or "private").strip().lower(),
exd.get("club_id"),
exd.get("created_by"),
role, role,
exd,
): ):
continue continue
exercises.append( exercises.append(
@ -373,7 +478,7 @@ def list_visibility_promotion_candidates(
return { return {
"graph_id": graph_id, "graph_id": graph_id,
"graph_visibility": graph_vis, "graph_visibility": graph_vis,
"target_visibility": target_visibility, "target_visibility": target_vis,
"exercises": exercises, "exercises": exercises,
} }
@ -565,6 +670,9 @@ def create_progression_edge(
cur = get_cursor(conn) cur = get_cursor(conn)
_require_graph_write(cur, graph_id, profile_id, role) _require_graph_write(cur, graph_id, profile_id, role)
_assert_exercises_exist(cur, body.from_exercise_id, body.to_exercise_id) _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 fv = body.from_exercise_variant_id
tv = body.to_exercise_variant_id tv = body.to_exercise_variant_id
_assert_variant_for_exercise(cur, body.from_exercise_id, fv) _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] ex_ids = [s.exercise_id for s in steps]
_assert_exercises_exist(cur, *ex_ids) _assert_exercises_exist(cur, *ex_ids)
_assert_exercises_allowed_in_graph(cur, graph_id, profile_id, role, *ex_ids)
try: try:
for i in range(n_seg): for i in range(n_seg):

View File

@ -7,6 +7,7 @@ from db import get_db, get_cursor
from tenant_context import TenantContext, get_tenant_context from tenant_context import TenantContext, get_tenant_context
from planning_exercise_suggest import PlanningExerciseSuggestRequest, suggest_planning_exercises from planning_exercise_suggest import PlanningExerciseSuggestRequest, suggest_planning_exercises
from planning_exercise_path_builder import ProgressionPathSuggestRequest, suggest_progression_path 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 account_lifecycle import assert_min_account_state
from capabilities import probe_capability from capabilities import probe_capability
from club_features import ( from club_features import (
@ -46,19 +47,25 @@ def post_planning_exercise_suggest(
) )
with get_db() as conn: with get_db() as conn:
cur = get_cursor(conn) cur = get_cursor(conn)
with planning_llm_call_meter() as llm_meter:
result = suggest_planning_exercises(cur, tenant=tenant, body=body) result = suggest_planning_exercises(cur, tenant=tenant, body=body)
if uses_ai: if uses_ai and llm_meter.count > 0:
usage = consume_club_feature_with_usage( usage = consume_club_feature_with_usage(
feature_id="ai_calls", feature_id="ai_calls",
club_id=club_id, club_id=club_id,
profile_id=tenant.profile_id, profile_id=tenant.profile_id,
portal_role=tenant.global_role, portal_role=tenant.global_role,
action="planning_suggest", action="planning_suggest",
amount=llm_meter.count,
cur=cur, cur=cur,
tenant=tenant, tenant=tenant,
conn=conn, conn=conn,
) )
result = merge_feature_usage_into_response(result, usage) 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 return result
@ -70,7 +77,6 @@ def post_progression_path_suggest(
uses_ai = ( uses_ai = (
body.include_llm_intent body.include_llm_intent
or body.include_llm_path_qa or body.include_llm_path_qa
or body.include_ai_gap_fill
or body.include_llm_roadmap or body.include_llm_roadmap
or body.include_llm_start_target or body.include_llm_start_target
or (body.start_target_only and 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: with get_db() as conn:
cur = get_cursor(conn) cur = get_cursor(conn)
with planning_llm_call_meter() as llm_meter:
result = suggest_progression_path(cur, tenant=tenant, body=body) result = suggest_progression_path(cur, tenant=tenant, body=body)
if uses_ai: if uses_ai and llm_meter.count > 0:
usage = consume_club_feature_with_usage( usage = consume_club_feature_with_usage(
feature_id="ai_calls", feature_id="ai_calls",
club_id=club_id, club_id=club_id,
profile_id=tenant.profile_id, profile_id=tenant.profile_id,
portal_role=tenant.global_role, portal_role=tenant.global_role,
action="progression_path_suggest", action="progression_path_suggest",
amount=llm_meter.count,
cur=cur, cur=cur,
tenant=tenant, tenant=tenant,
conn=conn, conn=conn,
) )
result = merge_feature_usage_into_response(result, usage) 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 return result

View File

@ -29,6 +29,7 @@ EXEMPT_ROUTERS: frozenset[str] = frozenset(
"admin_user_content.py", # Superadmin Moderation nutzerangelegter Inhalte; require_auth + is_superadmin — kein Vereinsmandant "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 "admin_rights.py", # Superadmin Rollen/Rechte (Capabilities, Kontingent-Bypass, Pläne); require_auth + is_superadmin — kein Vereinsmandant
"catalogs.py", "catalogs.py",
"catalog_prompt_slots.py", # Admin Stammdaten KI-Prompt-Slots; require_auth + admin/superadmin — globaler Katalog, kein Vereinsmandant
"skills.py", "skills.py",
"maturity_models.py", "maturity_models.py",
"matrix_stack_bundle.py", "matrix_stack_bundle.py",

View File

@ -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

View File

@ -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"]

View File

@ -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",
)

View File

@ -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")

View File

@ -3,19 +3,28 @@ from planning_exercise_path_qa import compute_deterministic_path_quality_score
def test_deterministic_quality_score_penalizes_off_topic(): 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( with_off = compute_deterministic_path_quality_score(
gaps=[], gaps=[],
off_topic_steps=[{"roadmap_major_step_index": 1}], off_topic_steps=[{"roadmap_major_step_index": 1}],
steps=steps,
) )
assert with_off < base assert with_off < base
def test_deterministic_quality_score_penalizes_empty_slots(): 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( with_empty = compute_deterministic_path_quality_score(
gaps=[], gaps=[],
off_topic_steps=[], 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 with_empty < base
assert all_empty <= 0.15

View File

@ -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

View File

@ -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"])

View File

@ -2,6 +2,7 @@
from planning_exercise_path_builder import ( from planning_exercise_path_builder import (
_parse_slot_refs_from_text, _parse_slot_refs_from_text,
_problematic_slots_from_path_qa, _problematic_slots_from_path_qa,
_slot_auto_select_ai,
_slot_auto_select_library, _slot_auto_select_library,
_slot_suggestion_accepted, _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(): def test_off_topic_slot_gap_spec_for_filled_slot():
from planning_exercise_path_builder import _build_off_topic_slot_gap_spec from planning_exercise_path_builder import _build_off_topic_slot_gap_spec

View File

@ -1,8 +1,8 @@
# Shinkan Jinkendo Version Information # Shinkan Jinkendo Version Information
APP_VERSION = "0.8.233" APP_VERSION = "0.8.237"
BUILD_DATE = "2026-05-22" BUILD_DATE = "2026-05-22"
DB_SCHEMA_VERSION = "20260607090" DB_SCHEMA_VERSION = "20260607094"
MODULE_VERSIONS = { MODULE_VERSIONS = {
"legal_documents": "1.4.0", # Admin: Live-Vorschau pro Abschnitt + modale Vollvorschau (Editor + Dokumentenliste) "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 "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) "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) "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_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_context": "0.2.0", # preparation/trainer_notes; has_instruction_source_text
"ai_prompt_runtime": "0.2.2", # Slug planning_exercise_expectation_profile "ai_prompt_runtime": "0.2.2", # Slug planning_exercise_expectation_profile
@ -53,6 +53,40 @@ MODULE_VERSIONS = {
} }
CHANGELOG = [ 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", "version": "0.8.226",
"date": "2026-05-22", "date": "2026-05-22",

View File

@ -1,7 +1,7 @@
# Shinkan Jinkendo Entwicklungsstand & Handover # Shinkan Jinkendo Entwicklungsstand & Handover
**Stand:** 2026-05-22 **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 F11F14, Katalog-Kontext); DB siehe **`backend/version.py`** (`DB_SCHEMA_VERSION`, Migration **088**). **App-Version / DB-Schema:** App **`0.8.233`** (Planungs-KI F11F14, 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**. 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.2310.8.232** | | **F12** | Post-Match-Gate, LLM-QA nach Rematch, Gap-Timing, `roadmap_unfilled`-Sync | ✅ **0.8.2310.8.232** |
| **F13** | **`planning_catalog_context`** (Fokus/Stil/TT/ZG) im Match + Graph-Artefakt | ✅ **0.8.233** | | **F13** | **`planning_catalog_context`** (Fokus/Stil/TT/ZG) im Match + Graph-Artefakt | ✅ **0.8.233** |
| **F14** | **`ProgressionGraphEditor`** — Slot-UI + Planungskontext-Dropdowns | ✅ **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`** | | **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. **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 **~8588 %** — gilt **Stufenlogik**, nicht leere Slots. **Gesamt-Pfad-QS** = Minimum aus **`roadmap_qa`** + **`assignment_qa`** (leere Slots → Besetzung ~815 %). 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` **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):** **Offen (priorisiert):**
1. Dev-Regression: Gewaltschutz / Breitensport / Kinder (nicht nur Mae Geri) 1. Dev-Regression: Gewaltschutz / Breitensport / Kinder (nicht nur Mae Geri)
2. **PathBuilder-Parität** — gleiche Katalog-Dropdowns wie GraphEditor 2. **PathBuilder-Parität** — gleiche Katalog-Dropdowns wie GraphEditor
3. QS-UI — positive LLM-Hinweise als Highlights 3. UI-Wizard (4 Schritte: Ziel → Roadmap → Match → Lücken)
4. UI-Wizard (4 Schritte: Ziel → Roadmap → Match → Lücken) 4. Graph-Erweiterungsmodus (Start ab Knoten)
5. Graph-Erweiterungsmodus (Start ab Knoten) 5. Phase D — Auto KI-Gap-Fill bei persistent leeren Slots
6. 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. **Trainingsplanung Phase G** — Gruppenkontext-Pack, Scopes `training_section` / `framework_slot` (Ist-Doku §16) 7. Technik-Katalog konfigurierbar (Backlog)
8. Technik-Katalog konfigurierbar (Backlog) 8. **H1** — Katalog-Prompt-Snippets (modulare LLM-Anweisungen)
#### Übungs-KI Formular / Schnellanlage (Stand **0.8.171**) #### Ü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). 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. **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. **PathBuilder-Parität:** `planning_catalog_context`-Dropdowns auch in `ExerciseProgressionPathBuilder`.
3. **QS-UI:** positive LLM-Empfehlungen als Highlights statt nur Optimierungspotenziale.
4. **UI-Wizard:** 4 Schritte (Ziel & Katalog → Roadmap → Match → Lücken); Backend-Pipeline unverändert. 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`. 5. **Phase D:** automatisches KI-Gap-Fill bei persistent `roadmap_unfilled`.
6. **Trainingsplanung G0G4:** Katalog in Einheits-Editor, Scopes `training_section`/`framework_slot`, Abschnitts-QS, Gruppenkontext-Pack — Details **`PLANNING_PROGRESSION_GRAPH_KI.md`** §16, **`PLANNING_KI_ROADMAP.md`**. 6. **Trainingsplanung G0G4:** Katalog in Einheits-Editor, Scopes `training_section`/`framework_slot`, Abschnitts-QS, Gruppenkontext-Pack — Details **`PLANNING_PROGRESSION_GRAPH_KI.md`** §16, **`PLANNING_KI_ROADMAP.md`**.

View File

@ -1,229 +1,216 @@
# Planungs-KI — Katalog-Snippets für modulare Prompts # Planungs-KI — Katalog-Prompt-Slots (Snippets)
**Stand:** 2026-05-22 **Stand:** 2026-05-22
**Status:** Spezifikation (Phase **H1** — Umsetzung offen) **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` **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 ## 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:** Prompts in `ai_prompts` referenzieren **Slot-Typen** (Vokabular). Inhalte hängen an **Katalog-Zeilen** (Stammdaten). Der Resolver füllt Platzhalter zur Laufzeit.
**Ziel:** Gleiche **Prompt-Basis**, aber **kaskadierte Snippet-Blöcke** pro Katalog-Ausprägung — keine Matrix aus Voll-Prompt-Kopien.
--- ---
## 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 | **Nicht:** feste Attribute pro Eintrag im Code (`planning_lens`, `qa_criteria` …).
|------|-----------|------------|----------------| **Sondern:** beliebig viele Katalog-Einträge × definierte Slot-Typen.
| **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.
--- ---
## 3. Architektur — drei Schichten (Erinnerung) ## 3. Dimensionen & Priorität
| Schicht | Heute | Mit H1 | | Rang | Dimension | `catalog_kind` | DB-Tabelle |
|---------|-------|--------| |------|-----------|----------------|------------|
| **Retrieval** | Katalog-Gewichte in `merge_catalog_context_into_target` | unverändert | | **1** | Primärfokus | `focus_area` | `focus_areas` |
| **Technik-Gates** | `planning_exercise_semantics.py`, nur `topic_type=technique` | unverändert | | **2** | Trainingsstil | `training_type` | `training_types` |
| **LLM-Prompts** | kaum Katalog-spezifisch | **`catalog_guidance_block`** pro Aufruf | | **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) |
``` **Erweiterung:** neuer Slot-Typ = eine Zeile in `catalog_prompt_slot_types` + Resolver/Admin-Katalog — **kein** neues Python-Feld pro Eintrag.
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
```
**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): Mustache-Keys: **`{catalog_kind}_{slot_key}`** (nur `[a-z0-9_]`).
| Feld | Pflicht | Inhalt |
|------|---------|--------|
| `planning_lens` | ja | 24 Sätze: Was ist das Planungsziel in dieser Dimension? |
| `qa_criteria` | ja | Bullet-artige Kriterien für Pfad-QS (was ist „gut“, was ist kein Mangel) |
| `roadmap_hints` | empfohlen | Stufenlogik, typische Phasen, was vermeiden |
| `anti_patterns` | optional | Explizite Fehlbewertungen vermeiden (z. B. „keine Wettkampf-Tiefe verlangen“) |
| `rematch_guard` | optional | Wann **kein** Auto-Rematch sinnvoll (Breitensport: keine Perfektions-Slots erzwingen) |
Phase **H1:** flache Markdown-Strings im Code-Modul.
Phase **H2 (optional):** Tabelle `planning_catalog_prompt_snippets` oder JSONB an Katalog-Zeilen, Admin-editierbar.
### 4.3 Platzhalter in `ai_prompts`
Neue **gemeinsame** Platzhalter (Mustache), in alle betroffenen Prompts einfügen:
| Platzhalter | Bedeutung | | Platzhalter | Bedeutung |
|-------------|-----------| |-------------|-----------|
| `{{catalog_guidance_block}}` | Gerenderter Gesamttext (alle aktiven Snippets, kaskadiert) | | `{{focus_area_description}}` | Aktiver Primärfokus — Beschreibung |
| `{{catalog_context_json}}` | Kompakte JSON-Zusammenfassung der gewählten IDs/Namen (Audit) | | `{{focus_area_hints_on_progression}}` | … — Progressions-Hinweise |
| `{{#has_catalog_guidance}}``{{/has_catalog_guidance}}` | Block nur wenn mindestens ein Snippet aktiv | | `{{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 | | Prompt-Slug | Aggregat enthält primär |
|-----------|------|-----------|---------| |-------------|-------------------------|
| 1 | `planning_exercise_path_qa` | bestehend | Pfad-QS, `quality_score`, Empfehlungen | | `planning_exercise_path_qa` | `*_hints_on_path_qa`, `*_anti_patterns`, `*_description` |
| 2 | `planning_progression_roadmap` | 078 | Major Steps, Didaktik | | `planning_progression_roadmap` | `*_description`, `*_hints_on_progression`, `*_anti_patterns` |
| 3 | `planning_progression_stage_spec` | 079 | Stufen-Gates, Erfolgskriterien | | `planning_progression_stage_spec` | `*_hints_on_progression`, `*_anti_patterns` |
| 4 | `planning_progression_start_target` | 087 | Start/Ziel-Extraktion | | `planning_progression_goal_analysis` | `*_description` |
| 5 | `planning_progression_goal_analysis` | 078 | Zielanalyse |
| 6 | Gap-Fill / Übungs-KI | 085+ | `planning_context` ergänzen |
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 Metadaten je Slot-Typ (`slot_key`, `display_name`, `description`, `applicable_kinds[]`, `sort_order`, `for_llm`, `for_code`).
def build_catalog_guidance_for_prompt(
cur, ### 6.2 `catalog_prompt_slots`
catalog: Optional[ProgressionPlanningCatalogContext],
) -> Dict[str, str]: ```text
""" catalog_kind — focus_area | training_type | target_group | style_direction
Returns: catalog_id — FK auf jeweilige Stammtabelle (logisch, kein polymorpher FK)
catalog_guidance_block: str slot_key — FK → catalog_prompt_slot_types
catalog_context_json: str content — TEXT (Markdown/Plain für LLM)
has_catalog_guidance: bool UNIQUE (catalog_kind, catalog_id, slot_key)
snippet_keys: list[str] # Metadaten für Logs/Tests
"""
``` ```
**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.
--- ---
## 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. | Modul | Rolle |
|-------|--------|
**anti_patterns:** Nicht nach Kumite-Tiefe, Explosivität oder Wettkampf-Belastung bewerten. | `catalog_prompt_slots.py` | Slot-Typen, DB-Lese/Schreib, Resolver, Aggregat-Block |
| `planning_prompt_variables.py` | Zentrale Provider-Liste (erweiterbar) |
### 6.2 Primärfokus — Technik / Kumite-Beinarbeit (`focus:kumite` o. ä.) | `planning_catalog_prompt_snippets.py` | Deprecated Re-Exports (Tests/Kompatibilität) |
**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.)*
--- ---
## 7. Rollout-Phasen ## 8. Admin-API
### H1 — Minimal viable (Progressionsgraph) | Methode | Pfad | Beschreibung |
|---------|------|--------------|
| GET | `/api/catalog-prompt-slot-types` | Slot-Typ-Register |
| GET | `/api/catalog-prompt-slots/{kind}/{catalog_id}` | Alle Slots eines Eintrags |
| PUT | `/api/catalog-prompt-slots/{kind}/{catalog_id}` | Slots upserten (Admin) |
- [ ] Modul `planning_catalog_prompt_snippets.py` + Registry (58 Snippets: 23 Foki, 2 Trainingsstile, 2 Zielgruppen) `kind`: `focus_area` · `training_type` · `target_group` · `style_direction`
- [ ] Einbindung in **`planning_exercise_path_qa`** + **`planning_progression_roadmap`** + **`planning_progression_stage_spec`**
- [ ] Migration Prompt-Templates: Abschnitt `{{#has_catalog_guidance}}…{{/has_catalog_guidance}}` **Später:** UI-Tabs am Katalog-Editor; Versionierung/Audit wie `ai_prompts`.
- [ ] 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) ---
## 9. Rollout-Phasen
### H1 — Bootstrap (0.8.234) ✓
Hardcodierte `SNIPPET_REGISTRY` — Proof of Concept für `catalog_guidance_block`.
### H2 — Slot-Modell (0.8.235) ✓
- [x] Tabellen `catalog_prompt_slot_types`, `catalog_prompt_slots`
- [x] Seed aus H1-Texten (Name-Match auf Stammdaten)
- [x] Resolver mit granularen Platzhaltern + Aggregat
- [x] Admin-API GET/PUT
- [x] `SNIPPET_REGISTRY` aus Laufzeit-Pfad entfernt
### H2.1 — Admin-UI
- [x] Slot-Editor an Fokusbereich / Trainingsstil / Zielgruppe / Stilrichtung (`CatalogPromptSlotsEditor`, Stammdaten-Katalog)
- [x] Prompt-Templates mit granularen Platzhaltern (Migration 093)
- [ ] Platzhalter-Hilfe im KI-Prompt-Editor (erweitert)
### H1.5 ### H1.5
- [ ] Rematch/Refine: `rematch_guard` aus Snippets respektieren (weniger Ping-Pong bei Breitensport) - [ ] `rematch_guard` im Rematch-Loop
- [ ] Intent-Prompts + Gap-Fill-Kontext - [ ] Intent-Prompts + Gap-Fill: `hints_on_exercise`
### H2 — Betrieb ### H3 — Trainingsplanung (Phase G)
- [ ] Snippets in DB, Admin-UI oder Markdown-Import - [ ] Gleicher Resolver, andere Orchestratoren
- [ ] Versionierung / Audit wie `ai_prompts`
### H3 — Phase G (Trainingsplanung)
- [ ] Gleicher Builder, anderer Orchestrator (Abschnitts-QS, Slot-Suggest)
--- ---
## 8. Tests & Akzeptanz ## 10. Tests & Akzeptanz
| Test | Erwartung | | Test | Erwartung |
|------|-----------| |------|-----------|
| `test_catalog_prompt_snippets_priority` | Bei Konflikt gewinnt Fokus-Snippet-Text in `_block`-Reihenfolge | | Slot aus DB | Gewaltschutz + `hints_on_path_qa` → Platzhalter enthält Deeskalation |
| `test_path_qa_variables_include_guidance` | Mit Gewaltschutz-Kontext enthält gerendeter Prompt „Deeskalation“ o. ä., nicht „Kumite-Perfektion“ | | Ohne Katalog | Alle `{{focus_area_*}}` leer; `has_catalog_guidance` leer |
| `test_path_qa_no_snippet_without_catalog` | Ohne Katalog: `has_catalog_guidance=false`, Prompt unverändert wie heute | | Neuer Eintrag | Leere Slots, kein Crash; `description`-Fallback aus Stammdaten |
| Manuell | Mae-Geri-Pfad + Breitensport-Kontext: QS-Highlights ohne Leistungs-Belastungs-Forderungen | | Priorität | Aggregat: Primärfokus vor Trainingsstil |
**Nicht Ziel von H1:** Retrieval-Gewichte neu kalibrieren; Technik-Tuples externalisieren (separates Backlog **H** in Roadmap).
--- ---
## 9. Abgrenzung zu anderen Fixes ## 11. Abgrenzung
| Thema | Dokument / Fix | | Thema | Hinweis |
|-------|----------------| |-------|---------|
| 88% vs. 65% falsche Pipeline | Evaluate-only für Pfad-QS; fairer Compare — Code-Stand 2026-05-22 | | Retrieval-Gewichte | `merge_catalog_context_into_target` — unverändert |
| Triviale ID-Tausche im Dialog | `_filter_trivial_slot_diffs` | | Technik-Gates | `planning_exercise_semantics` — unverändert |
| Katalog nur im Retrieval | F13 — bleibt, Snippets ergänzen LLM-Schicht | | Prompt-Text | `ai_prompts` — editierbar, referenziert Slot-Platzhalter |
Snippets lösen **fachliche Fehlbewertung** — nicht Pipeline-Inkonsistenz allein.
--- ---
## 10. Changelog ## 12. Changelog
| Datum | Änderung | | Datum | Änderung |
|-------|----------| |-------|----------|
| 2026-05-22 | Erstfassung — Priorität Primärfokus → Trainingsstil → Zielgruppe → Stilrichtung; H1H3 Rollout | | 2026-05-22 | **H2.1** (0.8.236): Admin-UI `CatalogPromptSlotsEditor`; Migration 093 granulare Prompt-Templates |
| 2026-05-22 | **H2** (0.8.235): Slot-Typ-Register + `catalog_prompt_slots` DB, granulare Platzhalter, Admin-API |
| 2026-05-22 | Konzept §4§8: zwei Ebenen Slot-Typ vs. Slot-Wert; Platzhalter `{kind}_{slot_key}` |
| 2026-05-22 | H1 (0.8.234): Bootstrap-Registry — durch H2 ersetzt |
| 2026-05-22 | Erstfassung |

View File

@ -89,14 +89,24 @@ Details und Module: **`PLANNING_PROGRESSION_GRAPH_KI.md`**.
- [x] Vier Planungskontext-Dropdowns im Editor - [x] Vier Planungskontext-Dropdowns im Editor
- [x] `progressionGraphDraft.js` — Artefakt + API-Payload - [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) ### Validierung (Referenz Mae Geri, 2026-05)
| Phase | Pfad-QS | Ergebnis | | Phase | Roadmap-QS | Besetzung | Gesamt | Ergebnis |
|-------|---------|----------| |-------|------------|-----------|--------|----------|
| Vor Roadmap/KI | ~65 % | Lücken, falsche Reihenfolge, Off-Topic | | Vor Roadmap/KI | — | — | ~65 % | Lücken, Off-Topic |
| Nach Trainer-Roadmap + KI-Gap-Fill | **~88 % OK** | Vollständige Abdeckung; positive LLM-Hinweise | | Roadmap ok, Slots leer | ~88 % | ~815 % | **~815 %** | 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.
--- ---

View File

@ -157,6 +157,10 @@ flowchart TB
| `auto_refine_stage_spec` | bool | Stufen-Spec bei `stage_mismatch` schärfen (Default **true**) | | `auto_refine_stage_spec` | bool | Stufen-Spec bei `stage_mismatch` schärfen (Default **true**) |
| `max_rematch_rounds` | int | Rematch-Runden 04 (Default **3**) | | `max_rematch_rounds` | int | Rematch-Runden 04 (Default **3**) |
| `include_path_qa`, `include_llm_path_qa`, `include_ai_gap_fill` | bool | QS, LLM-Ganzpfad, Lücken-Angebote | | `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 ### 4.2 Wichtige Response-Felder
@ -166,7 +170,9 @@ flowchart TB
| `steps[]` | Gematchte Übungen; pro Schritt u. a. `roadmap_*`, `skill_expectations` | | `steps[]` | Gematchte Übungen; pro Schritt u. a. `roadmap_*`, `skill_expectations` |
| `path_skill_expectations` | Pfadweite Skill-Erwartungen | | `path_skill_expectations` | Pfadweite Skill-Erwartungen |
| `gap_fill_offers[]` | Lücken mit `context_preview`, `goal_for_ai` | | `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) | | `target_profile_summary` | Erwartungsprofil inkl. Katalog-Dimensionen (nach Match) |
| `match_summary` | `library_matches`, `gap_fill_offer_count`, `roadmap_unfilled_count` | | `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) ### Referenz-Validierung (Mae Geri, 2026-05)
| Phase | Pfad-QS | Ergebnis | | Phase | Roadmap-QS | Besetzung | Gesamt (min) | Ergebnis |
|-------|---------|----------| |-------|------------|-----------|--------------|----------|
| Vor Roadmap/KI-Anpassung | ~65 % | Strukturelle Lücken (Grundlagen, Reihenfolge, Zielgenauigkeit) | | Vor Roadmap/KI-Anpassung | — | — | ~65 % | Strukturelle Lücken |
| Nach Trainer-Roadmap + KI-Angebote in leeren Slots | **~88 % OK** | Vollständige Curriculum-Abdeckung; positive LLM-Empfehlungen | | Nach Trainer-Roadmap, **Slots leer** | ~8588 % | ~815 % | **~815 %** | Roadmap ok, Besetzung fehlt |
| Nach Match + befüllte Slots | ~8588 % | 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. 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 (~815 % 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 ## 9. Fähigkeiten-Scoring-Anbindung
Modul: `planning_skill_expectations.py` 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.2310.8.232 | | **F12** | Post-Match-Gate, LLM-QA nach Rematch, Gap-Timing, `roadmap_unfilled`-Sync | ✅ | 0.8.2310.8.232 |
| **F13** | **Katalog-Kontext** (`planning_catalog_context`) im Match + Graph-Artefakt | ✅ | **0.8.233** | | **F13** | **Katalog-Kontext** (`planning_catalog_context`) im Match + Graph-Artefakt | ✅ | **0.8.233** |
| **F14** | `ProgressionGraphEditor` Slot-UI + Planungskontext-Dropdowns | ✅ | 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`** | | **H1** | **Katalog-Prompt-Snippets** — modulare LLM-Anweisungen pro Dimension | 🔲 | Spec **`PLANNING_CATALOG_PROMPT_SNIPPETS.md`** |
| **G** | Trainingsplanung: eigene Pipeline + Wiederverwendung Bausteine (§16) | 🔲 | — | | **G** | Trainingsplanung: eigene Pipeline + Wiederverwendung Bausteine (§16) | 🔲 | — |
| **UX** | Wizard/Stepper; PathBuilder-Parität Katalog | 🔲 | — | | **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`** 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. **Dev-Regression:** Gewaltschutz + Breitensport + Kinder (ohne Mae Geri) — Katalog-Match verifizieren
2. **PathBuilder-Parität** — gleiche `planning_catalog_context`-Dropdowns in `ExerciseProgressionPathBuilder` 3. **PathBuilder-Parität** — gleiche `planning_catalog_context`-Dropdowns in `ExerciseProgressionPathBuilder`
3. **QS-UI** — positive LLM-Hinweise als „Highlights“, nicht als „Optimierungspotenziale“
4. **UI-Wizard** — 4 Schritte (Ziel → Roadmap → Match → Lücken); Backend unverändert 4. **UI-Wizard** — 4 Schritte (Ziel → Roadmap → Match → Lücken); Backend unverändert
5. **Graph-Erweiterungsmodus** — Start ab gewähltem Knoten / letzter Sequenz 5. **Graph-Erweiterungsmodus** — Start ab gewähltem Knoten / letzter Sequenz
6. **Phase D** — automatisches KI-Gap-Fill bei persistent leeren Slots 6. **Phase D** — automatisches KI-Gap-Fill bei persistent leeren Slots

View File

@ -13,7 +13,7 @@ import { Link, useLocation } from 'react-router-dom'
import api from '../utils/api' import api from '../utils/api'
import SkillProfilePanel from './skills/SkillProfilePanel' import SkillProfilePanel from './skills/SkillProfilePanel'
import { useAuth } from '../context/AuthContext' import { useAuth } from '../context/AuthContext'
import { activeClubMemberships, getTenantClubDependencyKey } from '../utils/activeClub' import { activeClubMemberships, getDefaultClubIdForGovernanceForms, getTenantClubDependencyKey } from '../utils/activeClub'
import ProgressionGraphEditor from './ProgressionGraphEditor' import ProgressionGraphEditor from './ProgressionGraphEditor'
import ProgressionGraphListCard from './ProgressionGraphListCard' import ProgressionGraphListCard from './ProgressionGraphListCard'
import { EXERCISE_VISIBILITY_FIELD_LABEL } from '../constants/exerciseGovernanceLabels' import { EXERCISE_VISIBILITY_FIELD_LABEL } from '../constants/exerciseGovernanceLabels'
@ -24,6 +24,21 @@ const VIS_OPTIONS = [
{ value: 'official', label: 'Offiziell' }, { 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) { function edgeTypeLabel(type) {
if (type === 'next_exercise') return 'Nachfolger' if (type === 'next_exercise') return 'Nachfolger'
if (type === 'sibling') return 'Schwester' if (type === 'sibling') return 'Schwester'
@ -41,7 +56,9 @@ function ExerciseProgressionGraphPanel(
const { user } = useAuth() const { user } = useAuth()
const location = useLocation() const location = useLocation()
const isSuperadmin = user?.role === 'superadmin' const isSuperadmin = user?.role === 'superadmin'
const memberClubs = useMemo(() => activeClubMemberships(user?.clubs), [user?.clubs])
const tenantClubDepKey = useMemo(() => getTenantClubDependencyKey(user), [user]) const tenantClubDepKey = useMemo(() => getTenantClubDependencyKey(user), [user])
const [clubsForGovernanceForms, setClubsForGovernanceForms] = useState([])
const filteredGraphVisOptions = useMemo( const filteredGraphVisOptions = useMemo(
() => VIS_OPTIONS.filter((o) => o.value !== 'official' || isSuperadmin), () => VIS_OPTIONS.filter((o) => o.value !== 'official' || isSuperadmin),
@ -61,6 +78,37 @@ function ExerciseProgressionGraphPanel(
const [metaName, setMetaName] = useState('') const [metaName, setMetaName] = useState('')
const [metaDescription, setMetaDescription] = useState('') const [metaDescription, setMetaDescription] = useState('')
const [metaVisibility, setMetaVisibility] = useState('private') 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 [filterAnchorOnly, setFilterAnchorOnly] = useState(!!anchorExerciseId)
const [editingEdgeNotes, setEditingEdgeNotes] = useState(null) const [editingEdgeNotes, setEditingEdgeNotes] = useState(null)
@ -125,6 +173,25 @@ function ExerciseProgressionGraphPanel(
} }
}, [refreshGraphs, tenantClubDepKey]) }, [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(() => { useEffect(() => {
if (!selectedGraphId) { if (!selectedGraphId) {
setSkillProfileData(null) setSkillProfileData(null)
@ -157,6 +224,7 @@ function ExerciseProgressionGraphPanel(
setMetaName('') setMetaName('')
setMetaDescription('') setMetaDescription('')
setMetaVisibility('private') setMetaVisibility('private')
setMetaClubSelect('')
return return
} }
const g = graphs.find((x) => x.id === selectedGraphId) const g = graphs.find((x) => x.id === selectedGraphId)
@ -164,6 +232,12 @@ function ExerciseProgressionGraphPanel(
setMetaName(g.name || '') setMetaName(g.name || '')
setMetaDescription(g.description || '') setMetaDescription(g.description || '')
setMetaVisibility(g.visibility || 'private') 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 let cancelled = false
;(async () => { ;(async () => {
@ -176,7 +250,17 @@ function ExerciseProgressionGraphPanel(
return () => { return () => {
cancelled = true 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(() => { const filteredEdges = useMemo(() => {
if (!filterAnchorOnly || anchorExerciseId == null) return edges if (!filterAnchorOnly || anchorExerciseId == null) return edges
@ -226,13 +310,7 @@ function ExerciseProgressionGraphPanel(
} }
} }
const resolvePromoteClubId = () => { const resolvePromoteClubId = resolveGovernanceClubId
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 handleSaveMeta = async () => { const handleSaveMeta = async () => {
if (!selectedGraphId) return if (!selectedGraphId) return
@ -247,35 +325,39 @@ function ExerciseProgressionGraphPanel(
setBusy(true) setBusy(true)
try { try {
if (prevVis === 'private' && nextVis === 'club') { if (shouldPromptGraphExercisePromotion(prevVis, nextVis)) {
const preview = await api.getProgressionGraphVisibilityPromotionCandidates( const preview = await api.getProgressionGraphVisibilityPromotionCandidates(
selectedGraphId, selectedGraphId,
{ targetVisibility: 'club' }, { targetVisibility: nextVis },
) )
const privateExercises = Array.isArray(preview?.exercises) ? preview.exercises : [] const promotionExercises = Array.isArray(preview?.exercises) ? preview.exercises : []
if (privateExercises.length > 0) { if (promotionExercises.length > 0) {
const titles = privateExercises const visLabel = GRAPH_VISIBILITY_PROMOTION_LABEL[nextVis] || nextVis
const titles = promotionExercises
.slice(0, 8) .slice(0, 8)
.map((ex) => `${ex.title || `Übung #${ex.id}`}`) .map((ex) => `${ex.title || `Übung #${ex.id}`}`)
.join('\n') .join('\n')
const more = const more =
privateExercises.length > 8 promotionExercises.length > 8
? `\n… und ${privateExercises.length - 8} weitere` ? `\n… und ${promotionExercises.length - 8} weitere`
: '' : ''
const promote = window.confirm( 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) { if (promote) {
const clubId = resolvePromoteClubId() let clubId = null
if (nextVis === 'club') {
clubId = resolvePromoteClubId()
if (!clubId) { if (!clubId) {
alert('Kein aktiver Verein — Übungen können nicht auf Verein promoted werden.') throw new Error(
} else { 'Kein Verein gewählt — bitte unter „Verein zuordnen“ einen Verein auswählen oder den Vereins-Umschalter nutzen.',
const ids = privateExercises.map((ex) => ex.id).filter((id) => id != null) )
const res = await api.bulkPatchExercisesMetadata({ }
exercise_ids: ids, }
visibility: 'club', const ids = promotionExercises.map((ex) => ex.id).filter((id) => id != null)
club_id: clubId, 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) { if ((res?.failed || []).length) {
const f = res.failed[0] const f = res.failed[0]
throw new Error(f?.detail || 'Übungs-Promotion fehlgeschlagen') throw new Error(f?.detail || 'Übungs-Promotion fehlgeschlagen')
@ -283,12 +365,18 @@ function ExerciseProgressionGraphPanel(
} }
} }
} }
}
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, { await api.updateExerciseProgressionGraph(selectedGraphId, {
name, name,
description: metaDescription.trim() || null, description: metaDescription.trim() || null,
visibility: metaVisibility, visibility: metaVisibility,
...(promoteClubId != null ? { club_id: promoteClubId } : {}),
}) })
await refreshGraphs() await refreshGraphs()
alert('Graph-Metadaten gespeichert.') alert('Graph-Metadaten gespeichert.')
@ -539,7 +627,14 @@ function ExerciseProgressionGraphPanel(
<select <select
className="form-input" className="form-input"
value={metaVisibility} value={metaVisibility}
onChange={(e) => setMetaVisibility(e.target.value)} onChange={(e) => {
const v = e.target.value
setMetaVisibility(v)
if (v === 'club' && !metaClubSelect) {
const fb = getDefaultClubIdForGovernanceForms(user)
if (fb != null) setMetaClubSelect(String(fb))
}
}}
> >
{filteredGraphVisOptions.map((o) => ( {filteredGraphVisOptions.map((o) => (
<option key={o.value} value={o.value}> <option key={o.value} value={o.value}>
@ -548,6 +643,42 @@ function ExerciseProgressionGraphPanel(
))} ))}
</select> </select>
</div> </div>
{metaVisibility === 'club' ? (
<div className="form-row">
<label className="form-label">Verein zuordnen</label>
<select
className="form-input"
value={metaClubSelect}
onChange={(e) => setMetaClubSelect(e.target.value)}
>
<option value="">Aktiver Verein (Vereins-Umschalter / Header)</option>
{showGovernanceClubOptgroups ? (
<>
<optgroup label="Meine Vereine">
{sortedMemberClubs.map((c) => (
<option key={c.id} value={String(c.id)}>
{c.name || `Verein #${c.id}`}
</option>
))}
</optgroup>
<optgroup label="Weitere Vereine">
{sortedOtherGovernanceClubs.map((c) => (
<option key={c.id} value={String(c.id)}>
{c.name || `Verein #${c.id}`}
</option>
))}
</optgroup>
</>
) : (
governanceClubSelectOptions.map((c) => (
<option key={c.id} value={String(c.id)}>
{c.name || `Verein #${c.id}`}
</option>
))
)}
</select>
</div>
) : null}
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}> <div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
<button type="button" className="btn btn-secondary" disabled={busy} onClick={handleSaveMeta}> <button type="button" className="btn btn-secondary" disabled={busy} onClick={handleSaveMeta}>
Metadaten speichern Metadaten speichern

View File

@ -589,6 +589,7 @@ export default function ExerciseProgressionPathBuilder({
const [gapPrepFocusAreaId, setGapPrepFocusAreaId] = useState('') const [gapPrepFocusAreaId, setGapPrepFocusAreaId] = useState('')
const [gapPrepError, setGapPrepError] = useState('') const [gapPrepError, setGapPrepError] = useState('')
const [loadedPlanningHint, setLoadedPlanningHint] = useState(false) const [loadedPlanningHint, setLoadedPlanningHint] = useState(false)
const [graphGovernance, setGraphGovernance] = useState({ visibility: 'private', clubId: null })
const [wizardStep, setWizardStep] = useState(1) const [wizardStep, setWizardStep] = useState(1)
const [pathInsertNotice, setPathInsertNotice] = useState('') const [pathInsertNotice, setPathInsertNotice] = useState('')
@ -670,6 +671,10 @@ export default function ExerciseProgressionPathBuilder({
.getExerciseProgressionGraph(Number(graphId)) .getExerciseProgressionGraph(Number(graphId))
.then((g) => { .then((g) => {
if (cancelled) return if (cancelled) return
setGraphGovernance({
visibility: g?.visibility || 'private',
clubId: g?.club_id ?? null,
})
const art = g?.planning_roadmap const art = g?.planning_roadmap
if (!art) return if (!art) return
if (art.goal_query) setGoalQuery(String(art.goal_query)) if (art.goal_query) setGoalQuery(String(art.goal_query))
@ -1056,7 +1061,7 @@ export default function ExerciseProgressionPathBuilder({
setQuickSaving(true) setQuickSaving(true)
setQuickAiError('') setQuickAiError('')
try { try {
const payload = buildQuickCreateExercisePayloadFromDraft(quickCreateDraft) const payload = buildQuickCreateExercisePayloadFromDraft(quickCreateDraft, graphGovernance)
const created = await api.createExercise(payload) const created = await api.createExercise(payload)
if (!created?.id) throw new Error('Anlegen fehlgeschlagen') if (!created?.id) throw new Error('Anlegen fehlgeschlagen')
insertExerciseFromOffer(created, activeOffer) insertExerciseFromOffer(created, activeOffer)

View File

@ -11,6 +11,8 @@ import {
formatRefineLogEntry, formatRefineLogEntry,
hasRematchSlotHints, hasRematchSlotHints,
pathQaQualityPercent, pathQaQualityPercent,
pathQaHasSplitDimensions,
pathQaSubsectionPercent,
pathQaShowsStrongResult, pathQaShowsStrongResult,
resolveHintSlotIndex, resolveHintSlotIndex,
resolveOfferSlotIndex, resolveOfferSlotIndex,
@ -26,6 +28,46 @@ function severityStyle(pathQa) {
} }
} }
function subsectionSeverityStyle(subsection) {
if (!subsection) return {}
return {
background: subsection.overall_ok
? 'color-mix(in srgb, var(--accent) 6%, var(--surface))'
: 'color-mix(in srgb, var(--danger) 10%, var(--surface))',
border: `1px solid ${subsection.overall_ok ? 'var(--border)' : 'color-mix(in srgb, var(--danger) 35%, var(--border))'}`,
}
}
function PathQaDimensionBlock({ title, subsection, children = null }) {
if (!subsection) return null
const pct = pathQaSubsectionPercent(subsection)
return (
<div
style={{
marginTop: '10px',
padding: '8px 10px',
borderRadius: '8px',
fontSize: '11px',
lineHeight: 1.45,
...subsectionSeverityStyle(subsection),
}}
>
<strong>
{title}: {subsection.overall_ok ? 'OK' : 'Hinweise'}
{pct != null ? ` (${pct} %)` : ''}
</strong>
{Array.isArray(subsection.issues) && subsection.issues.length > 0 ? (
<ul style={{ margin: '6px 0 0', paddingLeft: '16px', color: 'var(--text2)' }}>
{subsection.issues.slice(0, 5).map((issue) => (
<li key={`${title}-${issue}`}>{issue}</li>
))}
</ul>
) : null}
{children}
</div>
)
}
function PathQaPipelineDetails({ pathQa, fairQa = null, draft, title, compact = false }) { function PathQaPipelineDetails({ pathQa, fairQa = null, draft, title, compact = false }) {
const { fixHints: optimizationHints } = useMemo( const { fixHints: optimizationHints } = useMemo(
() => splitPathQaHints(pathQa), () => splitPathQaHints(pathQa),
@ -310,6 +352,9 @@ export default function ProgressionFindingsPanel({
&& (canOptimizeCompare || pathQa?.assignments_preserved || showRematchAction) && (canOptimizeCompare || pathQa?.assignments_preserved || showRematchAction)
const qualityPct = pathQaQualityPercent(pathQa) const qualityPct = pathQaQualityPercent(pathQa)
const strongResult = pathQaShowsStrongResult(pathQa) const strongResult = pathQaShowsStrongResult(pathQa)
const hasSplitQa = pathQaHasSplitDimensions(pathQa)
const roadmapQa = pathQa?.roadmap_qa || null
const assignmentQa = pathQa?.assignment_qa || null
return ( return (
<div className="card" style={{ position: 'sticky', top: '12px' }}> <div className="card" style={{ position: 'sticky', top: '12px' }}>
@ -364,9 +409,14 @@ export default function ProgressionFindingsPanel({
}} }}
> >
<strong> <strong>
Pfad-QS: {pathQa.overall_ok ? 'OK' : 'Hinweise'} Pfad-QS gesamt: {pathQa.overall_ok ? 'OK' : 'Hinweise'}
{qualityPct != null ? ` (${qualityPct} %)` : ''} {qualityPct != null ? ` (${qualityPct} %)` : ''}
</strong> </strong>
{hasSplitQa ? (
<p style={{ margin: '6px 0 0', color: 'var(--text2)', fontSize: '11px' }}>
Gesamt = schwächere Dimension (Roadmap vs. Übungsbesetzung).
</p>
) : null}
{pathQa.assignments_preserved ? ( {pathQa.assignments_preserved ? (
<p style={{ margin: '6px 0 0', color: 'var(--text2)', fontSize: '11px' }}> <p style={{ margin: '6px 0 0', color: 'var(--text2)', fontSize: '11px' }}>
Bewertung des aktuellen Pfads. Übungen matchen öffnet einen Dialog mit Vorschlägen für Bewertung des aktuellen Pfads. Übungen matchen öffnet einen Dialog mit Vorschlägen für
@ -378,7 +428,23 @@ export default function ProgressionFindingsPanel({
Starker Pfad KI-Empfehlungen unten können Feinschliff oder optionale Vertiefung sein. Starker Pfad KI-Empfehlungen unten können Feinschliff oder optionale Vertiefung sein.
</p> </p>
) : null} ) : null}
{pathQa.topic_coverage ? ( {hasSplitQa ? (
<>
<PathQaDimensionBlock title="Roadmap & Stufen" subsection={roadmapQa}>
{roadmapQa?.topic_coverage ? (
<p style={{ margin: '6px 0 0', color: 'var(--text2)' }}>{roadmapQa.topic_coverage}</p>
) : null}
</PathQaDimensionBlock>
<PathQaDimensionBlock title="Übungsbesetzung" subsection={assignmentQa}>
{assignmentQa?.empty_slot_count > 0 ? (
<p style={{ margin: '6px 0 0', color: 'var(--danger)' }}>
{assignmentQa.empty_slot_count} leere Slot(s) Übungen matchen oder manuell befüllen.
</p>
) : null}
</PathQaDimensionBlock>
</>
) : null}
{!hasSplitQa && pathQa.topic_coverage ? (
<p style={{ margin: '6px 0 0', color: 'var(--text2)' }}>{pathQa.topic_coverage}</p> <p style={{ margin: '6px 0 0', color: 'var(--text2)' }}>{pathQa.topic_coverage}</p>
) : null} ) : null}
{highlightTexts.length > 0 ? ( {highlightTexts.length > 0 ? (
@ -415,7 +481,7 @@ export default function ProgressionFindingsPanel({
</ul> </ul>
</> </>
) : null} ) : null}
{Array.isArray(pathQa.issues) && pathQa.issues.length > 0 ? ( {Array.isArray(pathQa.issues) && pathQa.issues.length > 0 && !hasSplitQa ? (
<ul style={{ margin: '6px 0 0', paddingLeft: '16px', color: 'var(--text2)' }}> <ul style={{ margin: '6px 0 0', paddingLeft: '16px', color: 'var(--text2)' }}>
{pathQa.issues.map((issue) => ( {pathQa.issues.map((issue) => (
<li key={issue}>{issue}</li> <li key={issue}>{issue}</li>

View File

@ -4,6 +4,8 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react' import React, { useCallback, useEffect, useMemo, useState } from 'react'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import api from '../utils/api' import api from '../utils/api'
import { useAuth } from '../context/AuthContext'
import { getDefaultClubIdForGovernanceForms } from '../utils/activeClub'
import ExercisePickerModal from './ExercisePickerModal' import ExercisePickerModal from './ExercisePickerModal'
import ExerciseGapFillPrepModal from './exercises/ExerciseGapFillPrepModal' import ExerciseGapFillPrepModal from './exercises/ExerciseGapFillPrepModal'
import ProgressionSlotCard from './ProgressionSlotCard' import ProgressionSlotCard from './ProgressionSlotCard'
@ -37,7 +39,6 @@ import {
compareDiffsForDialog, compareDiffsForDialog,
dedupeGapOffersBySlot, dedupeGapOffersBySlot,
draftHasLibrarySlotAssignments, draftHasLibrarySlotAssignments,
draftRetrievalBoostExerciseIds,
EMPTY_PLANNING_CATALOG_CONTEXT, EMPTY_PLANNING_CATALOG_CONTEXT,
filterGapOffersForUnfilledSlots, filterGapOffersForUnfilledSlots,
hydrateProgressionGraphDraft, hydrateProgressionGraphDraft,
@ -85,6 +86,7 @@ function resolveDefaultFocusAreaId(targetSummary, focusAreas) {
} }
export default function ProgressionGraphEditor({ graphId, embedded = false, onSaved }) { export default function ProgressionGraphEditor({ graphId, embedded = false, onSaved }) {
const { user } = useAuth()
const [graphMeta, setGraphMeta] = useState(null) const [graphMeta, setGraphMeta] = useState(null)
const [draft, setDraft] = useState(null) const [draft, setDraft] = useState(null)
const [busy, setBusy] = useState(false) const [busy, setBusy] = useState(false)
@ -475,28 +477,6 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa
} }
} }
const buildMatchRequestBase = (synced) => {
const override = majorStepsToOverridePayload(synced.slots)
return {
query: (synced.goalQuery || '').trim(),
max_steps: synced.slots.length,
include_llm_intent: true,
include_path_qa: true,
include_llm_path_qa: true,
include_path_reorder: false,
include_ai_gap_fill: true,
include_roadmap_preview: true,
include_llm_roadmap: false,
roadmap_first: true,
roadmap_override: override,
slot_assignments: slotsToSlotAssignments(synced),
retrieval_boost_exercise_ids: draftRetrievalBoostExerciseIds(synced),
progression_graph_id: Number(graphId),
...roadmapStructuredPayload(synced.startSituation, synced.targetState, synced.roadmapNotes),
...catalogApiPayload,
}
}
const runMatchCompareFlow = async (synced, { source = 'match' } = {}) => { const runMatchCompareFlow = async (synced, { source = 'match' } = {}) => {
setMatchNotice('Schritt 1/2: Pfad bewerten (wie „Graph bewerten“)…') setMatchNotice('Schritt 1/2: Pfad bewerten (wie „Graph bewerten“)…')
const baselineRes = await fetchPathEvaluate(synced) const baselineRes = await fetchPathEvaluate(synced)
@ -880,7 +860,17 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa
setSlotQuickSaving(true) setSlotQuickSaving(true)
setSlotQuickError('') setSlotQuickError('')
try { try {
const payload = buildQuickCreateExercisePayloadFromDraft(slotQuickCreateDraft) const graphVis = (graphMeta?.visibility || 'private').trim().toLowerCase()
const graphClubId =
graphMeta?.club_id != null
? graphMeta.club_id
: graphVis === 'club'
? getDefaultClubIdForGovernanceForms(user)
: null
const payload = buildQuickCreateExercisePayloadFromDraft(slotQuickCreateDraft, {
visibility: graphVis,
clubId: graphClubId,
})
const created = await api.createExercise(payload) const created = await api.createExercise(payload)
if (!created?.id) throw new Error('Anlegen fehlgeschlagen') if (!created?.id) throw new Error('Anlegen fehlgeschlagen')
setDraft((prev) => ({ setDraft((prev) => ({
@ -1149,7 +1139,7 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa
{draft.slots.map((slot, idx) => ( {draft.slots.map((slot, idx) => (
<ProgressionSlotCard <ProgressionSlotCard
key={`slot-${idx}-${slot.learning_goal?.slice(0, 12) || 'x'}`} key={`slot-${idx}`}
slot={slot} slot={slot}
slotIndex={idx} slotIndex={idx}
slotCount={draft.slots.length} slotCount={draft.slots.length}

View File

@ -6,7 +6,9 @@ import FormModalOverlay from './FormModalOverlay'
import { import {
compareSlotReviews, compareSlotReviews,
defaultSelectedCompareDiffs, defaultSelectedCompareDiffs,
pathQaHasSplitDimensions,
pathQaQualityPercent, pathQaQualityPercent,
pathQaSubsectionPercent,
qualityDeltaPercent, qualityDeltaPercent,
rejectedCompareDiffs, rejectedCompareDiffs,
slotFitScorePercent, slotFitScorePercent,
@ -189,6 +191,9 @@ function SlotReviewRow({ review, selected, onToggle, applying }) {
<strong>KI-Vorschlag nutzen</strong> <strong>KI-Vorschlag nutzen</strong>
<span style={{ display: 'block', fontSize: '11px', color: 'var(--text3)', marginTop: '2px' }}> <span style={{ display: 'block', fontSize: '11px', color: 'var(--text3)', marginTop: '2px' }}>
{ai.title_hint || 'Neue Übung per KI entwerfen'} {ai.title_hint || 'Neue Übung per KI entwerfen'}
{ai.auto_select
? ' — empfohlen, Bibliothek passt nicht ausreichend zum Stufen-Ziel'
: ''}
</span> </span>
</span> </span>
</label> </label>
@ -223,6 +228,9 @@ export default function ProgressionOptimizeCompareModal({
const baselineQa = comparison.baseline_path_qa const baselineQa = comparison.baseline_path_qa
const baselinePct = pathQaQualityPercent(baselineQa) const baselinePct = pathQaQualityPercent(baselineQa)
const hasSplitQa = pathQaHasSplitDimensions(baselineQa)
const roadmapPct = pathQaSubsectionPercent(baselineQa?.roadmap_qa)
const assignmentPct = pathQaSubsectionPercent(baselineQa?.assignment_qa)
const rejectedCount = rejected.length const rejectedCount = rejected.length
const reviewError = comparison.review_error || null const reviewError = comparison.review_error || null
@ -258,8 +266,9 @@ export default function ProgressionOptimizeCompareModal({
{title} {title}
</h3> </h3>
<p style={{ fontSize: '12px', color: 'var(--text3)', marginTop: 0, lineHeight: 1.45 }}> <p style={{ fontSize: '12px', color: 'var(--text3)', marginTop: 0, lineHeight: 1.45 }}>
Je Slot: aktuelle Bewertung, beste Bibliotheks-Alternative und optional KI. Haken nur Je Slot: aktuelle Bewertung, beste Bibliotheks-Alternative und optional KI. Vorauswahl:
vorausgewählt, wenn die Alternative einen höheren Stufen-Fit hat. Bibliothek nur bei klar besserem Stufen-Fit; bei leeren oder schwach passenden Slots eher
KI-Vorschlag.
</p> </p>
{reviewError ? ( {reviewError ? (
@ -289,9 +298,19 @@ export default function ProgressionOptimizeCompareModal({
> >
<strong>Dein Pfad</strong> <strong>Dein Pfad</strong>
<div style={{ marginTop: '6px' }}>{qaLabel(baselineQa)}</div> <div style={{ marginTop: '6px' }}>{qaLabel(baselineQa)}</div>
{baselineQa?.topic_coverage ? ( {hasSplitQa ? (
<div style={{ marginTop: '6px', fontSize: '11px', color: 'var(--text2)', lineHeight: 1.45 }}>
Roadmap {roadmapPct != null ? `${roadmapPct} %` : '—'}
{' · '}
Besetzung {assignmentPct != null ? `${assignmentPct} %` : '—'}
</div>
) : null}
{!hasSplitQa && baselineQa?.topic_coverage ? (
<p style={{ margin: '6px 0 0', color: 'var(--text2)' }}>{baselineQa.topic_coverage}</p> <p style={{ margin: '6px 0 0', color: 'var(--text2)' }}>{baselineQa.topic_coverage}</p>
) : null} ) : null}
{hasSplitQa && baselineQa?.roadmap_qa?.topic_coverage ? (
<p style={{ margin: '6px 0 0', color: 'var(--text2)' }}>{baselineQa.roadmap_qa.topic_coverage}</p>
) : null}
</div> </div>
{rejectedCount > 0 ? ( {rejectedCount > 0 ? (

View File

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

View File

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

View File

@ -31,8 +31,22 @@ export default function AdminAiPromptsPage() {
const [pvExec, setPvExec] = useState('<p>Ablauf hier</p>') const [pvExec, setPvExec] = useState('<p>Ablauf hier</p>')
const [pvHint, setPvHint] = useState('') const [pvHint, setPvHint] = useState('')
const [pvFocusId, setPvFocusId] = useState('') const [pvFocusId, setPvFocusId] = useState('')
const [pvGoalQuery, setPvGoalQuery] = useState(
'Mae Geri vom Grundschritt bis zur kontrollierten Kumite-Nähe'
)
const [pvUserNotes, setPvUserNotes] = useState('Fokus Breitensport, ohne Wettkampfdruck.')
const [pvMaxSteps, setPvMaxSteps] = useState('5')
const [pvSearchQuery, setPvSearchQuery] = useState('')
const [pvPreview, setPvPreview] = useState(null) const [pvPreview, setPvPreview] = useState(null)
const selectedSlug = (detail?.slug || '').trim().toLowerCase()
const isExercisePreviewSlug = [
'exercise_summary',
'exercise_skill_suggestions',
'exercise_instruction_rewrite',
].includes(selectedSlug)
const isPlanningPreviewSlug = selectedSlug.startsWith('planning_')
const loadList = useCallback(async () => { const loadList = useCallback(async () => {
const [pList, cat] = await Promise.all([ const [pList, cat] = await Promise.all([
api.listAdminAiPrompts(), api.listAdminAiPrompts(),
@ -133,16 +147,24 @@ export default function AdminAiPromptsPage() {
if (!detail?.id) return if (!detail?.id) return
setError('') setError('')
try { try {
const body = { const body = {}
title: pvTitle, if (isPlanningPreviewSlug) {
goal: pvGoal, body.goal_query = pvGoalQuery.trim() || undefined
execution: pvExec, body.user_notes = pvUserNotes.trim() || undefined
focus_hint: pvHint || undefined, const ms = parseInt(String(pvMaxSteps).trim(), 10)
} if (Number.isFinite(ms) && ms >= 2 && ms <= 10) body.max_steps = ms
const sq = pvSearchQuery.trim()
if (sq) body.search_query = sq
} else if (isExercisePreviewSlug) {
body.title = pvTitle
body.goal = pvGoal
body.execution = pvExec
body.focus_hint = pvHint || undefined
const fid = parseInt(String(pvFocusId).trim(), 10) const fid = parseInt(String(pvFocusId).trim(), 10)
if (Number.isFinite(fid) && fid >= 1) { if (Number.isFinite(fid) && fid >= 1) {
body.focus_areas_context = [{ focus_area_id: fid, is_primary: true }] body.focus_areas_context = [{ focus_area_id: fid, is_primary: true }]
} }
}
const r = await api.previewAdminAiPrompt(detail.id, body) const r = await api.previewAdminAiPrompt(detail.id, body)
setPvPreview(r) setPvPreview(r)
} catch (e) { } catch (e) {
@ -171,8 +193,8 @@ export default function AdminAiPromptsPage() {
<h1 style={{ margin: 0, fontSize: '1.25rem' }}>KI Prompts</h1> <h1 style={{ margin: 0, fontSize: '1.25rem' }}>KI Prompts</h1>
</div> </div>
<p style={{ fontSize: '13px', color: 'var(--text3)', marginTop: 0 }}> <p style={{ fontSize: '13px', color: 'var(--text3)', marginTop: 0 }}>
Datenbankvorlagen (<code>ai_prompts</code>) für Übungs-KI. Platzhalter im Mustache-Stil werden serverseitig Datenbankvorlagen (<code>ai_prompts</code>) für Übungs- und Planungs-KI. Platzhalter im Mustache-Stil werden
aufgelöst die Vorschau unten ruft kein externes Modell auf. serverseitig aufgelöst die Vorschau unten ruft kein externes Modell auf.
</p> </p>
{error ? <p style={{ color: 'var(--danger)' }}>{error}</p> : null} {error ? <p style={{ color: 'var(--danger)' }}>{error}</p> : null}
@ -301,6 +323,55 @@ export default function AdminAiPromptsPage() {
<section style={{ marginTop: 20, paddingTop: 16, borderTop: '1px solid var(--border)' }}> <section style={{ marginTop: 20, paddingTop: 16, borderTop: '1px solid var(--border)' }}>
<h4 style={{ margin: '0 0 12px', fontSize: '15px' }}>Vorschau (ohne OpenRouter)</h4> <h4 style={{ margin: '0 0 12px', fontSize: '15px' }}>Vorschau (ohne OpenRouter)</h4>
{isPlanningPreviewSlug ? (
<>
<p style={{ fontSize: 13, color: 'var(--text3)', marginTop: 0 }}>
Beispielkontext für Planungs-Prompts echte Katalog-Auszüge aus der Datenbank, übrige Felder
sind repräsentative Demo-Daten.
</p>
<div className="form-row">
<label className="form-label">Zielanfrage (goal_query)</label>
<textarea
className="form-input"
rows={3}
value={pvGoalQuery}
onChange={(e) => setPvGoalQuery(e.target.value)}
/>
</div>
<div className="form-row">
<label className="form-label">Trainer-Notizen (user_notes)</label>
<textarea
className="form-input"
rows={2}
value={pvUserNotes}
onChange={(e) => setPvUserNotes(e.target.value)}
/>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
<div className="form-row">
<label className="form-label">max_steps (Roadmap)</label>
<input
className="form-input"
type="number"
min={2}
max={10}
value={pvMaxSteps}
onChange={(e) => setPvMaxSteps(e.target.value)}
/>
</div>
<div className="form-row">
<label className="form-label">Suchanfrage (optional)</label>
<input
className="form-input"
placeholder="Leer = goal_query"
value={pvSearchQuery}
onChange={(e) => setPvSearchQuery(e.target.value)}
/>
</div>
</div>
</>
) : isExercisePreviewSlug ? (
<>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}> <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
<div className="form-row"> <div className="form-row">
<label className="form-label">Titel</label> <label className="form-label">Titel</label>
@ -328,6 +399,13 @@ export default function AdminAiPromptsPage() {
<label className="form-label">Durchführung (HTML möglich)</label> <label className="form-label">Durchführung (HTML möglich)</label>
<textarea className="form-input" rows={4} value={pvExec} onChange={(e) => setPvExec(e.target.value)} /> <textarea className="form-input" rows={4} value={pvExec} onChange={(e) => setPvExec(e.target.value)} />
</div> </div>
</>
) : (
<p style={{ fontSize: 13, color: 'var(--text3)' }}>
Für diesen Slug ist noch kein Beispielkontext hinterlegt es wird nur das Roh-Template ohne
Ersetzung angezeigt.
</p>
)}
<button type="button" className="btn btn-secondary" onClick={() => runPreview()}> <button type="button" className="btn btn-secondary" onClick={() => runPreview()}>
Platzhalter auflösen Platzhalter auflösen
</button> </button>

View File

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

View File

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

View File

@ -208,11 +208,27 @@ export function aiPreviewToQuickCreateDraft(preview, { title, focusAreaId, sketc
} }
} }
/**
* Sichtbarkeit/club_id für Schnellanlage (z. B. aus Progressionsgraph).
* @param {{ visibility?: string, clubId?: number|null }} [governance]
*/
function resolveQuickCreateGovernance(governance) {
const rawVis = (governance?.visibility || 'private').trim().toLowerCase()
const vis = rawVis === 'club' || rawVis === 'official' ? rawVis : 'private'
let clubId = null
if (vis === 'club' && governance?.clubId != null && governance.clubId !== '') {
const n = Number(governance.clubId)
if (Number.isFinite(n) && n > 0) clubId = n
}
return { visibility: vis, club_id: clubId }
}
/** /**
* createExercise-Payload aus bearbeitetem Entwurf. * createExercise-Payload aus bearbeitetem Entwurf.
* @param {{ visibility?: string, clubId?: number|null }} [governance]
* @throws {Error} * @throws {Error}
*/ */
export function buildQuickCreateExercisePayloadFromDraft(draft) { export function buildQuickCreateExercisePayloadFromDraft(draft, governance) {
const title = (draft?.title || '').trim() const title = (draft?.title || '').trim()
if (title.length < 3) { if (title.length < 3) {
throw new Error('Titel: mindestens 3 Zeichen.') throw new Error('Titel: mindestens 3 Zeichen.')
@ -239,6 +255,7 @@ export function buildQuickCreateExercisePayloadFromDraft(draft) {
if (summary && !stripHtmlToText(summary).trim()) summary = null if (summary && !stripHtmlToText(summary).trim()) summary = null
const skills = (draft?.skillChoices || []).filter((c) => c.include).map((c) => c.after) const skills = (draft?.skillChoices || []).filter((c) => c.include).map((c) => c.after)
const { visibility, club_id: clubId } = resolveQuickCreateGovernance(governance)
return { return {
title, title,
@ -247,7 +264,7 @@ export function buildQuickCreateExercisePayloadFromDraft(draft) {
execution, execution,
preparation: prep, preparation: prep,
trainer_notes: trainerNotes, trainer_notes: trainerNotes,
visibility: 'private', visibility,
status: 'draft', status: 'draft',
equipment: [], equipment: [],
focus_areas_multi: [{ focus_area_id: fid, is_primary: true }], focus_areas_multi: [{ focus_area_id: fid, is_primary: true }],
@ -256,15 +273,16 @@ export function buildQuickCreateExercisePayloadFromDraft(draft) {
target_groups_multi: [], target_groups_multi: [],
age_groups: [], age_groups: [],
skills, skills,
club_id: null, club_id: clubId,
} }
} }
/** /**
* createExercise-Payload aus bestätigter Vorschau (Checkbox-Modus). * createExercise-Payload aus bestätigter Vorschau (Checkbox-Modus).
* @param {{ visibility?: string, clubId?: number|null }} [governance]
* @throws {Error} * @throws {Error}
*/ */
export function buildQuickCreateExercisePayloadFromPreview(preview, { title, focusAreaId, sketchPlain }) { export function buildQuickCreateExercisePayloadFromPreview(preview, { title, focusAreaId, sketchPlain, ...governance } = {}) {
const sketchHtml = aiPlainTextToMinimalHtml(sketchPlain) const sketchHtml = aiPlainTextToMinimalHtml(sketchPlain)
const fieldMap = {} const fieldMap = {}
for (const c of preview?.instructionChoices || []) { for (const c of preview?.instructionChoices || []) {
@ -288,6 +306,7 @@ export function buildQuickCreateExercisePayloadFromPreview(preview, { title, foc
} }
const skills = (preview?.skillChoices || []).filter((c) => c.include).map((c) => c.after) const skills = (preview?.skillChoices || []).filter((c) => c.include).map((c) => c.after)
const { visibility, club_id: clubId } = resolveQuickCreateGovernance(governance)
const fid = Number(focusAreaId) const fid = Number(focusAreaId)
if (!Number.isFinite(fid) || fid < 1) { if (!Number.isFinite(fid) || fid < 1) {
@ -301,7 +320,7 @@ export function buildQuickCreateExercisePayloadFromPreview(preview, { title, foc
execution, execution,
preparation: prep, preparation: prep,
trainer_notes: trainerNotes, trainer_notes: trainerNotes,
visibility: 'private', visibility,
status: 'draft', status: 'draft',
equipment: [], equipment: [],
focus_areas_multi: [{ focus_area_id: fid, is_primary: true }], focus_areas_multi: [{ focus_area_id: fid, is_primary: true }],
@ -310,7 +329,7 @@ export function buildQuickCreateExercisePayloadFromPreview(preview, { title, foc
target_groups_multi: [], target_groups_multi: [],
age_groups: [], age_groups: [],
skills, skills,
club_id: null, club_id: clubId,
} }
} }

View File

@ -173,8 +173,19 @@ export function pathQaQualityPercent(pathQa) {
return Math.round(Number(pathQa.quality_score) * 100) return Math.round(Number(pathQa.quality_score) * 100)
} }
export function pathQaSubsectionPercent(subsection) {
if (subsection?.quality_score == null || !Number.isFinite(Number(subsection.quality_score))) return null
return Math.round(Number(subsection.quality_score) * 100)
}
export function pathQaHasSplitDimensions(pathQa) {
return Boolean(pathQa?.roadmap_qa || pathQa?.assignment_qa)
}
export function pathQaShowsStrongResult(pathQa) { export function pathQaShowsStrongResult(pathQa) {
const pct = pathQaQualityPercent(pathQa) const pct = pathQaQualityPercent(pathQa)
const assignmentOk = pathQa?.assignment_qa ? pathQa.assignment_qa.overall_ok !== false : true
if (!assignmentOk) return false
if (pathQa?.overall_ok && pct != null && pct >= 85) return true if (pathQa?.overall_ok && pct != null && pct >= 85) return true
return Boolean(pathQa?.overall_ok && pct != null && pct >= 80 && !(pathQa?.issues || []).length) return Boolean(pathQa?.overall_ok && pct != null && pct >= 80 && !(pathQa?.issues || []).length)
} }
@ -1073,9 +1084,16 @@ export function compareDiffsForDialog(comparison) {
export function defaultSelectedCompareDiffs(comparison) { export function defaultSelectedCompareDiffs(comparison) {
const reviews = compareSlotReviews(comparison) const reviews = compareSlotReviews(comparison)
if (reviews.length > 0) { if (reviews.length > 0) {
return reviews const keys = []
.filter((review) => review?.library_alternative?.auto_select) for (const review of reviews) {
.map((review) => slotReviewSelectionKey(review.roadmap_major_step_index, 'library')) const midx = review.roadmap_major_step_index
if (review?.ai_alternative?.auto_select) {
keys.push(slotReviewSelectionKey(midx, 'ai'))
} else if (review?.library_alternative?.auto_select) {
keys.push(slotReviewSelectionKey(midx, 'library'))
}
}
return keys
} }
return compareDiffsForDialog(comparison).map((d) => Number(d.roadmap_major_step_index)) return compareDiffsForDialog(comparison).map((d) => Number(d.roadmap_major_step_index))
} }