Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 90e8f51566 | |||
| 7e5ef4561a | |||
| 53f2b027cc | |||
| 9cee862c32 | |||
| 0b203489f7 | |||
| 1c67a50ce4 | |||
| 87d9fa9b65 | |||
| 4b9374765b | |||
| b629f192ac | |||
| 313d613b7c |
|
|
@ -15,7 +15,7 @@
|
|||
|
||||
**Plattform-Rechtstexte (P-01, 0.8.95–0.8.96):** Admin-Editor mit **Abschnitts- und Vollvorschau** (Markdown); fortlaufende Abschnittsnummerierung in der Anzeige/PDF (Darstellung, nicht DB-persistent).
|
||||
|
||||
**Parallel weiter relevant:** **Trainingsplan Phasen & Streams** (Migration **063**, Coach + Planung **0.8.137–0.8.140**; Handover **`docs/HANDOVER.md`** §3); **Trainingsrahmenprogramm** (036–037), **Progressionsgraph** (032–034) — siehe **`TRAINING_FRAMEWORK_SPEC.md`**. **Planungs-KI Progressionsgraph** (Roadmap-first, Auto-Optimierung, Katalog-Kontext **0.8.233**): Ist-Doku **`docs/architecture/PLANNING_PROGRESSION_GRAPH_KI.md`**, Handover **`docs/HANDOVER.md`** §2.8.
|
||||
**Parallel weiter relevant:** **Trainingsplan Phasen & Streams** (Migration **063**, Coach + Planung **0.8.137–0.8.140**; Handover **`docs/HANDOVER.md`** §3); **Trainingsrahmenprogramm** (036–037), **Progressionsgraph** (032–034) — siehe **`TRAINING_FRAMEWORK_SPEC.md`**. **Planungs-KI Progressionsgraph** (Roadmap-first, Auto-Optimierung, Katalog-Kontext **0.8.233**, **F15** Match-Dialog + getrennte Pfad-QS lokal): Ist-Doku **`docs/architecture/PLANNING_PROGRESSION_GRAPH_KI.md`**, Handover **`docs/HANDOVER.md`** §2.8.
|
||||
|
||||
**Referenz:** [`library/FEATURES_DELIVERED_2026-Q2.md`](library/FEATURES_DELIVERED_2026-Q2.md) Abschnitt 12 · Medien-Norm: [`technical/MEDIA_ASSETS_AND_ARCHIVE_SPEC.md`](technical/MEDIA_ASSETS_AND_ARCHIVE_SPEC.md) (inkl. **Abschnitt 11 Inline-Medien**, umgesetzt) · **Fachlicher Nutzerüberblick:** [`../../docs/FACHLICHE_NUTZERFUNKTIONEN.md`](../../docs/FACHLICHE_NUTZERFUNKTIONEN.md)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# Progressionsgraph — Slot-Editor (Phase B)
|
||||
# Progressionsgraph — Slot-Editor (Phase B + F15)
|
||||
|
||||
**Stand:** 2026-06-10 · **Status:** In Umsetzung
|
||||
**Stand:** 2026-05-22 · **Status:** Umgesetzt (F14 + F15 lokal nach 0.8.233)
|
||||
|
||||
## Ziel
|
||||
|
||||
|
|
@ -35,35 +35,52 @@ Leere Slots in der Roadmap sind erlaubt; Kanten nur zwischen aufeinanderfolgende
|
|||
slots: Slot[], // index = major_step_index
|
||||
pathSkillExpectations?,
|
||||
lastFindings?, // path_qa-Snapshot
|
||||
findingsStale?: boolean, // Bewertung veraltet (↔ Artefakt findings_stale)
|
||||
dirty: boolean,
|
||||
}
|
||||
```
|
||||
|
||||
**Hydration:** `planning_roadmap` + Kanten → Slots; `slot_contents[]` für Entwürfe; Primärkette aus `next_exercise`.
|
||||
|
||||
**Speichern:** Batch-Delete bestehender Pfad-/Schwester-Kanten → `edges/sequence` (Primärkette) → einzelne `sibling`-Kanten → `PUT`/`sequence` mit Artefakt inkl. `slot_contents`, optional `last_findings`.
|
||||
**Speichern:** Batch-Delete bestehender Pfad-/Schwester-Kanten → `edges/sequence` (Primärkette) → einzelne `sibling`-Kanten → `PUT`/`sequence` mit Artefakt inkl. `slot_contents`, `last_findings`, **`findings_stale`**.
|
||||
|
||||
## Findings-Panel
|
||||
|
||||
Nutzt `path_qa` (`overall_ok`, `quality_score`, `issues`, `recommendations`, `gap_fill_offers`, …).
|
||||
Nutzt `path_qa`:
|
||||
|
||||
| Feld | Bedeutung |
|
||||
|------|-----------|
|
||||
| `quality_score` | Gesamt = **min(`roadmap_qa`, `assignment_qa`)** |
|
||||
| `roadmap_qa` | Stufen/Roadmap (LLM `topic_coverage`, …) |
|
||||
| `assignment_qa` | Slot-Befüllung (`empty_slot_count`, …) |
|
||||
| `overall_ok`, `issues`, `recommendations`, `gap_fill_offers`, … | wie bisher |
|
||||
|
||||
**API:** `POST /api/planning/progression-path-suggest` mit `evaluate_only: true` und `evaluate_steps[]` — QA ohne Re-Match.
|
||||
|
||||
Persistenz: `planning_roadmap.last_findings`.
|
||||
**Bewertung veraltet:** Jede Graph-Änderung setzt `findingsStale: true` → Banner im Panel. Nach „Graph bewerten“ → `false`. Persistenz: `planning_roadmap.findings_stale`.
|
||||
|
||||
## Match-Flow („Übungen matchen“)
|
||||
|
||||
1. **Schritt 1:** `evaluate_only` + volle Pfad-QS (wie „Graph bewerten“)
|
||||
2. **Schritt 2:** `unified_slot_review: true` → **`ProgressionOptimizeCompareModal`**
|
||||
3. Pro Slot: aktuell vs. beste Bibliothek vs. optional KI-Vorschlag
|
||||
4. **Vorauswahl:** Bibliothek nur wenn Stufen-Fit ≥ 50 % und besser als Baseline; sonst KI (bei leerem/schwachem Slot)
|
||||
5. **Übernahme:** nur gewählte Slots speichern — **keine** automatische Nach-Bewertung
|
||||
|
||||
## Artefakt-Erweiterung (`GraphPlanningRoadmapArtifact`)
|
||||
|
||||
Zusätzlich optional:
|
||||
Optional:
|
||||
|
||||
- `slot_contents[]` — `{ major_step_index, primary, siblings[] }`
|
||||
- `last_findings` — letzter `path_qa`-Snapshot
|
||||
- **`findings_stale`** — bool, Bewertung bezieht sich nicht mehr auf aktuellen Graph-Stand
|
||||
|
||||
## UI (konsolidiert)
|
||||
|
||||
- **Eine Oberfläche:** `ExerciseProgressionGraphPanel` embeddet `ProgressionGraphEditor` (Slots + Findings)
|
||||
- Kein separater Slot-Editor, kein 4-Schritt-KI-Wizard, kein `ProgressionChainEditor` im Panel
|
||||
- Route `/progression-graphs/:id` → Redirect nach `/exercises` (Deep-Link wählt Graph)
|
||||
- **Phase C:** Übersicht mit Kacheln (Name, Start, Ziel)
|
||||
- **Slot-Keys:** stabil `slot-{index}` (nicht Lernziel-Text) — sonst Fokusverlust beim Tippen
|
||||
|
||||
## Ersetzt (Legacy, nicht mehr im Panel)
|
||||
|
||||
|
|
@ -71,11 +88,14 @@ Zusätzlich optional:
|
|||
|
||||
## Implementierungsreihenfolge
|
||||
|
||||
| ID | Inhalt |
|
||||
|----|--------|
|
||||
| B.0 | Draft + Laden/Speichern Slots ↔ Kanten |
|
||||
| B.1 | Slot-Karten, Bibliothek + Entwurf |
|
||||
| B.2 | Findings-Panel + `evaluate_only` |
|
||||
| B.3 | Entwürfe im Artefakt + „Übung anlegen“ |
|
||||
| B.4 | Route + Panel vereinfachen |
|
||||
| B.5 | `last_findings` + Phase-C-Vorbereitung |
|
||||
| ID | Inhalt | Status |
|
||||
|----|--------|--------|
|
||||
| B.0 | Draft + Laden/Speichern Slots ↔ Kanten | ✅ |
|
||||
| B.1 | Slot-Karten, Bibliothek + Entwurf | ✅ |
|
||||
| B.2 | Findings-Panel + `evaluate_only` | ✅ |
|
||||
| B.3 | Entwürfe im Artefakt + „Übung anlegen“ | ✅ |
|
||||
| B.4 | Route + Panel vereinfachen | ✅ |
|
||||
| B.5 | `last_findings` + Phase-C-Vorbereitung | ✅ |
|
||||
| F15 | Unified Slot-Review, getrennte QS, `findings_stale` | ✅ |
|
||||
|
||||
**Ist-Doku:** `docs/architecture/PLANNING_PROGRESSION_GRAPH_KI.md` §8.1 · `docs/HANDOVER.md` §2.8 F15
|
||||
|
|
|
|||
317
backend/ai_prompt_planning_preview.py
Normal file
317
backend/ai_prompt_planning_preview.py
Normal 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",
|
||||
]
|
||||
432
backend/catalog_prompt_slots.py
Normal file
432
backend/catalog_prompt_slots.py
Normal 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",
|
||||
]
|
||||
284
backend/catalog_slot_fallbacks.py
Normal file
284
backend/catalog_slot_fallbacks.py
Normal 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",
|
||||
]
|
||||
|
|
@ -243,7 +243,7 @@ def read_root():
|
|||
return out
|
||||
|
||||
# Register routers
|
||||
from routers import auth, profiles, exercises, exercise_progression_graphs, clubs, club_memberships, club_join_requests, club_creation_requests, admin_users, admin_user_content, admin_rights, me_entitlements, platform_media_storage, media_assets, skills, skill_profiles, training_planning, planning_exercise_suggest, dashboard, training_modules, training_framework_programs, catalogs, maturity_models, matrix_stack_bundle, matrix_editor, import_wiki, import_wiki_admin, legal_documents, content_reports, ai_prompts_admin, ai_skill_retrieval_admin, exercise_enrichment_admin
|
||||
from routers import auth, profiles, exercises, exercise_progression_graphs, clubs, club_memberships, club_join_requests, club_creation_requests, admin_users, admin_user_content, admin_rights, me_entitlements, platform_media_storage, media_assets, skills, skill_profiles, training_planning, planning_exercise_suggest, dashboard, training_modules, training_framework_programs, catalogs, catalog_prompt_slots, maturity_models, matrix_stack_bundle, matrix_editor, import_wiki, import_wiki_admin, legal_documents, content_reports, ai_prompts_admin, ai_skill_retrieval_admin, exercise_enrichment_admin
|
||||
|
||||
app.include_router(auth.router)
|
||||
app.include_router(profiles.router)
|
||||
|
|
@ -269,6 +269,7 @@ app.include_router(dashboard.router)
|
|||
app.include_router(training_modules.router)
|
||||
app.include_router(training_framework_programs.router)
|
||||
app.include_router(catalogs.router)
|
||||
app.include_router(catalog_prompt_slots.router)
|
||||
app.include_router(maturity_models.router)
|
||||
app.include_router(matrix_stack_bundle.router)
|
||||
app.include_router(matrix_editor.router)
|
||||
|
|
|
|||
172
backend/migrations/091_ai_prompt_catalog_guidance.sql
Normal file
172
backend/migrations/091_ai_prompt_catalog_guidance.sql
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
-- Migration 091: Planungs-KI H1 — Katalog-Guidance-Platzhalter in Progressions-Prompts
|
||||
|
||||
UPDATE ai_prompts
|
||||
SET template = $t$Du bist Assistent für Kampfsport-Trainer und prüfst einen vorgeschlagenen Übungspfad.
|
||||
|
||||
Ziel-Anfrage: {{goal_query}}
|
||||
Semantic Brief: {{semantic_brief_json}}
|
||||
Schritte (JSON): {{steps_json}}
|
||||
Erkannte Lücken: {{gaps_json}}
|
||||
Eingefügte Brücken: {{bridge_inserts_json}}
|
||||
|
||||
{{catalog_guidance_block}}
|
||||
{{catalog_context_json}}
|
||||
|
||||
Wichtig: Wenn Katalog-Kontext gesetzt ist, haben dessen QS-Kriterien Vorrang vor allgemeinen Technik-/Wettkampf-Maßstäben.
|
||||
|
||||
Prüfe:
|
||||
1. Deckt der Pfad das Hauptthema der Anfrage ab (nicht nur Oberbegriffe)?
|
||||
2. Ist die Reihenfolge didaktisch sinnvoll (Einstieg → Vertiefung → Ziel)?
|
||||
3. Sind Sprünge zwischen benachbarten Schritten zu groß?
|
||||
4. Sind Brücken-Übungen sinnvoll oder überflüssig?
|
||||
5. Fehlen wichtige Zwischenschritte (Kraft, Geschwindigkeit, Anwendung, Perfektion)?
|
||||
6. Gibt es Schritte ohne Bezug zum Hauptthema (z. B. reine Kraftübungen bei einer Technik)?
|
||||
|
||||
Wenn die Reihenfolge verbessert werden sollte: ordered_step_indices = Permutation der aktuellen 0-basierten Schritt-Indizes (beste didaktische Reihenfolge).
|
||||
Nur Indizes aus dem steps_json verwenden — Länge muss exakt der Schrittzahl entsprechen.
|
||||
|
||||
Wenn wichtige Zwischenschritte fehlen oder Schritte themenfremd sind: suggested_new_exercises mit konkreten Übungs-Ideen (Titel + Kurzskizze), jeweils mit insert_after_step_index (0-basiert: nach welchem Schritt einfügen).
|
||||
|
||||
Antworte NUR mit JSON:
|
||||
{
|
||||
"overall_ok": true,
|
||||
"quality_score": 0.85,
|
||||
"topic_coverage": "Kurz: wie gut das Hauptthema abgedeckt ist",
|
||||
"ordered_step_indices": [0, 1, 2, 3],
|
||||
"issues": ["…"],
|
||||
"sequence_notes": ["…"],
|
||||
"recommendations": ["…"],
|
||||
"suggested_new_exercises": [
|
||||
{
|
||||
"title_hint": "Mae Geri Kraftentwicklung am Sandsack",
|
||||
"sketch": "Gezielte Kraft- und Schnelligkeitsentwicklung für Mae Geri …",
|
||||
"phase": "vertiefung",
|
||||
"insert_after_step_index": 2,
|
||||
"rationale": "Schließt Lücke zwischen Grundlagen und Gleichgewichtstritt"
|
||||
}
|
||||
]
|
||||
}$t$,
|
||||
default_template = $t$Du bist Assistent für Kampfsport-Trainer und prüfst einen vorgeschlagenen Übungspfad.
|
||||
|
||||
Ziel-Anfrage: {{goal_query}}
|
||||
Semantic Brief: {{semantic_brief_json}}
|
||||
Schritte (JSON): {{steps_json}}
|
||||
Erkannte Lücken: {{gaps_json}}
|
||||
Eingefügte Brücken: {{bridge_inserts_json}}
|
||||
|
||||
{{catalog_guidance_block}}
|
||||
{{catalog_context_json}}
|
||||
|
||||
Wichtig: Wenn Katalog-Kontext gesetzt ist, haben dessen QS-Kriterien Vorrang vor allgemeinen Technik-/Wettkampf-Maßstäben.
|
||||
|
||||
Prüfe:
|
||||
1. Deckt der Pfad das Hauptthema der Anfrage ab (nicht nur Oberbegriffe)?
|
||||
2. Ist die Reihenfolge didaktisch sinnvoll (Einstieg → Vertiefung → Ziel)?
|
||||
3. Sind Sprünge zwischen benachbarten Schritten zu groß?
|
||||
4. Sind Brücken-Übungen sinnvoll oder überflüssig?
|
||||
5. Fehlen wichtige Zwischenschritte (Kraft, Geschwindigkeit, Anwendung, Perfektion)?
|
||||
6. Gibt es Schritte ohne Bezug zum Hauptthema (z. B. reine Kraftübungen bei einer Technik)?
|
||||
|
||||
Wenn die Reihenfolge verbessert werden sollte: ordered_step_indices = Permutation der aktuellen 0-basierten Schritt-Indizes (beste didaktische Reihenfolge).
|
||||
Nur Indizes aus dem steps_json verwenden — Länge muss exakt der Schrittzahl entsprechen.
|
||||
|
||||
Wenn wichtige Zwischenschritte fehlen oder Schritte themenfremd sind: suggested_new_exercises mit konkreten Übungs-Ideen (Titel + Kurzskizze), jeweils mit insert_after_step_index (0-basiert: nach welchem Schritt einfügen).
|
||||
|
||||
Antworte NUR mit JSON:
|
||||
{
|
||||
"overall_ok": true,
|
||||
"quality_score": 0.85,
|
||||
"topic_coverage": "Kurz: wie gut das Hauptthema abgedeckt ist",
|
||||
"ordered_step_indices": [0, 1, 2, 3],
|
||||
"issues": ["…"],
|
||||
"sequence_notes": ["…"],
|
||||
"recommendations": ["…"],
|
||||
"suggested_new_exercises": [
|
||||
{
|
||||
"title_hint": "Mae Geri Kraftentwicklung am Sandsack",
|
||||
"sketch": "Gezielte Kraft- und Schnelligkeitsentwicklung für Mae Geri …",
|
||||
"phase": "vertiefung",
|
||||
"insert_after_step_index": 2,
|
||||
"rationale": "Schließt Lücke zwischen Grundlagen und Gleichgewichtstritt"
|
||||
}
|
||||
]
|
||||
}$t$
|
||||
WHERE slug = 'planning_exercise_path_qa';
|
||||
|
||||
UPDATE ai_prompts
|
||||
SET template = $t$Du bist Assistent für Kampfsport-Trainer und analysierst eine Anfrage für einen Progressionsgraphen.
|
||||
|
||||
Anfrage: {{goal_query}}
|
||||
Semantic Brief: {{semantic_brief_json}}
|
||||
|
||||
{{catalog_guidance_block}}
|
||||
|
||||
Wichtig: Keine Gruppenanalyse — nur didaktischer Pfad für die Technik/das Thema.
|
||||
|
||||
Antworte NUR mit JSON:
|
||||
{
|
||||
"primary_topic": "Mae Geri",
|
||||
"start_assumption": "Welche Voraussetzungen werden für den Einstieg angenommen",
|
||||
"target_state": "Konkreter Zielzustand der Progression",
|
||||
"success_criteria": ["messbare Kriterien"],
|
||||
"constraints": { "partner_required": false }
|
||||
}$t$,
|
||||
default_template = template
|
||||
WHERE slug = 'planning_progression_goal_analysis';
|
||||
|
||||
UPDATE ai_prompts
|
||||
SET template = $t$Du bist Assistent für Kampfsport-Trainer und erstellst eine didaktische Roadmap für einen Progressionsgraphen.
|
||||
|
||||
Anfrage: {{goal_query}}
|
||||
Zielanalyse: {{goal_analysis_json}}
|
||||
Semantic Brief: {{semantic_brief_json}}
|
||||
Anzahl Major Steps (N): {{max_steps}}
|
||||
|
||||
{{catalog_guidance_block}}
|
||||
{{catalog_context_json}}
|
||||
|
||||
Erzeuge zuerst 8–12 micro_objectives (phase, title, weight, depends_on), dann konsolidiere auf genau N major_steps.
|
||||
Phasen: einstieg, grundlage, vertiefung, anwendung, perfektion — in sinnvoller Reihenfolge (Grundlagen vor Perfektion).
|
||||
Beachte Katalog-Roadmap-Hinweise, falls gesetzt.
|
||||
|
||||
Antworte NUR mit JSON:
|
||||
{
|
||||
"micro_objectives": [
|
||||
{ "id": "m1", "phase": "grundlage", "title": "…", "weight": 0.9, "depends_on": [] }
|
||||
],
|
||||
"major_steps": [
|
||||
{ "index": 0, "phase": "grundlage", "learning_goal": "…", "consolidates": ["m1","m2"], "rationale": "…" }
|
||||
],
|
||||
"consolidation_notes": ["…"]
|
||||
}$t$,
|
||||
default_template = template
|
||||
WHERE slug = 'planning_progression_roadmap';
|
||||
|
||||
UPDATE ai_prompts
|
||||
SET template = $t$Du bist Assistent für Kampfsport-Trainer und spezifizierst didaktische Stufen eines Progressionsgraphen.
|
||||
|
||||
Anfrage: {{goal_query}}
|
||||
Zielanalyse: {{goal_analysis_json}}
|
||||
Major Steps: {{major_steps_json}}
|
||||
|
||||
{{catalog_guidance_block}}
|
||||
{{catalog_context_json}}
|
||||
|
||||
Für jeden Major Step: messbares Lernziel, load_profile (z. B. koordination, präzision, kraft), exercise_type (kihon_einzel, partner_drill, kombination, kraft_auxiliary), success_criteria, anti_patterns (z. B. reine Kraft ohne Technikbezug).
|
||||
Beachte Katalog-QS-Kriterien und Anti-Patterns, falls gesetzt.
|
||||
|
||||
Antworte NUR mit JSON:
|
||||
{
|
||||
"stage_specs": [
|
||||
{
|
||||
"major_step_index": 0,
|
||||
"learning_goal": "…",
|
||||
"load_profile": ["koordination", "gleichgewicht"],
|
||||
"exercise_type": "kihon_einzel",
|
||||
"success_criteria": ["…"],
|
||||
"anti_patterns": ["…"]
|
||||
}
|
||||
]
|
||||
}$t$,
|
||||
default_template = template
|
||||
WHERE slug = 'planning_progression_stage_spec';
|
||||
176
backend/migrations/092_catalog_prompt_slots.sql
Normal file
176
backend/migrations/092_catalog_prompt_slots.sql
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
-- Migration 092: Katalog-Prompt-Slots (H2) — Slot-Typ-Vokabular + Werte pro Stammdaten-Zeile
|
||||
|
||||
CREATE TABLE IF NOT EXISTS catalog_prompt_slot_types (
|
||||
slot_key VARCHAR(64) PRIMARY KEY,
|
||||
display_name VARCHAR(200) NOT NULL,
|
||||
description TEXT,
|
||||
applicable_kinds TEXT[] NOT NULL DEFAULT '{}',
|
||||
sort_order INT DEFAULT 99,
|
||||
for_llm BOOLEAN NOT NULL DEFAULT true,
|
||||
for_code BOOLEAN NOT NULL DEFAULT false,
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
updated_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS catalog_prompt_slots (
|
||||
id SERIAL PRIMARY KEY,
|
||||
catalog_kind VARCHAR(32) NOT NULL,
|
||||
catalog_id INT NOT NULL,
|
||||
slot_key VARCHAR(64) NOT NULL REFERENCES catalog_prompt_slot_types(slot_key) ON DELETE CASCADE,
|
||||
content TEXT NOT NULL DEFAULT '',
|
||||
updated_at TIMESTAMP DEFAULT NOW(),
|
||||
UNIQUE (catalog_kind, catalog_id, slot_key)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_catalog_prompt_slots_kind_id
|
||||
ON catalog_prompt_slots (catalog_kind, catalog_id);
|
||||
|
||||
INSERT INTO catalog_prompt_slot_types (slot_key, display_name, description, applicable_kinds, sort_order, for_llm, for_code)
|
||||
VALUES
|
||||
(
|
||||
'description',
|
||||
'Allgemeine Beschreibung',
|
||||
'Fachliche Einordnung des Katalog-Eintrags für Planungs-KI.',
|
||||
ARRAY['focus_area', 'training_type', 'target_group', 'style_direction'],
|
||||
10,
|
||||
true,
|
||||
false
|
||||
),
|
||||
(
|
||||
'hints_on_progression',
|
||||
'Hinweise Progressionsgraph',
|
||||
'Didaktik für Roadmap, Major Steps und Stufenspezifikation.',
|
||||
ARRAY['focus_area', 'training_type', 'target_group', 'style_direction'],
|
||||
20,
|
||||
true,
|
||||
false
|
||||
),
|
||||
(
|
||||
'hints_on_exercise',
|
||||
'Hinweise Übungsanlage',
|
||||
'Kontext für Gap-Fill, Übungs-KI und Schnellanlage.',
|
||||
ARRAY['focus_area', 'training_type', 'target_group', 'style_direction'],
|
||||
30,
|
||||
true,
|
||||
false
|
||||
),
|
||||
(
|
||||
'hints_on_path_qa',
|
||||
'Hinweise Pfad-QS',
|
||||
'Bewertungsmaßstäbe für Pfad-Qualitätssicherung.',
|
||||
ARRAY['focus_area', 'training_type', 'target_group', 'style_direction'],
|
||||
40,
|
||||
true,
|
||||
false
|
||||
),
|
||||
(
|
||||
'anti_patterns',
|
||||
'Anti-Patterns',
|
||||
'Explizite Fehlbewertungen vermeiden.',
|
||||
ARRAY['focus_area', 'training_type', 'target_group', 'style_direction'],
|
||||
50,
|
||||
true,
|
||||
false
|
||||
),
|
||||
(
|
||||
'rematch_guard',
|
||||
'Rematch-Guard',
|
||||
'Wann kein Auto-Rematch sinnvoll ist (primär Code-Logik).',
|
||||
ARRAY['focus_area', 'training_type', 'target_group', 'style_direction'],
|
||||
60,
|
||||
false,
|
||||
true
|
||||
)
|
||||
ON CONFLICT (slot_key) DO NOTHING;
|
||||
|
||||
-- Seed aus H1-Registry (Name-Match auf Stammdaten)
|
||||
|
||||
INSERT INTO catalog_prompt_slots (catalog_kind, catalog_id, slot_key, content)
|
||||
SELECT 'focus_area', fa.id, 'description',
|
||||
'Planung zielt auf Prävention, Deeskalation, Grenzen und sichere Übungsformen — nicht auf Wettkampf-Perfektion oder Technik-Show.'
|
||||
FROM focus_areas fa WHERE fa.name ILIKE 'Gewaltschutz'
|
||||
ON CONFLICT (catalog_kind, catalog_id, slot_key) DO UPDATE SET content = EXCLUDED.content, updated_at = NOW();
|
||||
|
||||
INSERT INTO catalog_prompt_slots (catalog_kind, catalog_id, slot_key, content)
|
||||
SELECT 'focus_area', fa.id, 'hints_on_path_qa',
|
||||
'Gute Pfade bauen Sicherheit, Kommunikation und Alternativen auf; „Lücken“ sind fehlende Deeskalations- oder Rollenspiel-Stufen, nicht fehlende Kick-Varianten.'
|
||||
FROM focus_areas fa WHERE fa.name ILIKE 'Gewaltschutz'
|
||||
ON CONFLICT (catalog_kind, catalog_id, slot_key) DO UPDATE SET content = EXCLUDED.content, updated_at = NOW();
|
||||
|
||||
INSERT INTO catalog_prompt_slots (catalog_kind, catalog_id, slot_key, content)
|
||||
SELECT 'focus_area', fa.id, 'anti_patterns',
|
||||
'Nicht nach Kumite-Tiefe, Explosivität oder Wettkampf-Belastung bewerten.'
|
||||
FROM focus_areas fa WHERE fa.name ILIKE 'Gewaltschutz'
|
||||
ON CONFLICT (catalog_kind, catalog_id, slot_key) DO UPDATE SET content = EXCLUDED.content, updated_at = NOW();
|
||||
|
||||
INSERT INTO catalog_prompt_slots (catalog_kind, catalog_id, slot_key, content)
|
||||
SELECT 'training_type', tt.id, 'description',
|
||||
'Partizipation, Verständlichkeit, Freude am Bewegen; weniger maximale Spezialisierung.'
|
||||
FROM training_types tt WHERE tt.name ILIKE 'Breitensport'
|
||||
ON CONFLICT (catalog_kind, catalog_id, slot_key) DO UPDATE SET content = EXCLUDED.content, updated_at = NOW();
|
||||
|
||||
INSERT INTO catalog_prompt_slots (catalog_kind, catalog_id, slot_key, content)
|
||||
SELECT 'training_type', tt.id, 'hints_on_path_qa',
|
||||
'Hohe OK-Rate bei moderatem Schwierigkeitsanstieg; „Perfektion“-Stufen nur optional, nicht als Pflicht-Lücke.'
|
||||
FROM training_types tt WHERE tt.name ILIKE 'Breitensport'
|
||||
ON CONFLICT (catalog_kind, catalog_id, slot_key) DO UPDATE SET content = EXCLUDED.content, updated_at = NOW();
|
||||
|
||||
INSERT INTO catalog_prompt_slots (catalog_kind, catalog_id, slot_key, content)
|
||||
SELECT 'training_type', tt.id, 'rematch_guard',
|
||||
'Keine leeren Slots erzwingen, nur um eine Leistungs-Perfektionsstufe zu füllen.'
|
||||
FROM training_types tt WHERE tt.name ILIKE 'Breitensport'
|
||||
ON CONFLICT (catalog_kind, catalog_id, slot_key) DO UPDATE SET content = EXCLUDED.content, updated_at = NOW();
|
||||
|
||||
INSERT INTO catalog_prompt_slots (catalog_kind, catalog_id, slot_key, content)
|
||||
SELECT 'target_group', tg.id, 'description',
|
||||
'Kinder: kurze Einheiten, spielerische Einstiege, Sicherheit und altersgerechte Komplexität.'
|
||||
FROM target_groups tg WHERE tg.name ILIKE 'Kinder'
|
||||
ON CONFLICT (catalog_kind, catalog_id, slot_key) DO UPDATE SET content = EXCLUDED.content, updated_at = NOW();
|
||||
|
||||
INSERT INTO catalog_prompt_slots (catalog_kind, catalog_id, slot_key, content)
|
||||
SELECT 'target_group', tg.id, 'hints_on_path_qa',
|
||||
'Didaktik ohne Überforderung; klare Regeln und Sicherheit vor Perfektion; Lücken bei Spiel-/Rollenelementen wichtiger als Wettkampftiefe.'
|
||||
FROM target_groups tg WHERE tg.name ILIKE 'Kinder'
|
||||
ON CONFLICT (catalog_kind, catalog_id, slot_key) DO UPDATE SET content = EXCLUDED.content, updated_at = NOW();
|
||||
|
||||
INSERT INTO catalog_prompt_slots (catalog_kind, catalog_id, slot_key, content)
|
||||
SELECT 'target_group', tg.id, 'anti_patterns',
|
||||
'Keine Erwachsenen-Wettkampf-Perfektion als QS-Maßstab.'
|
||||
FROM target_groups tg WHERE tg.name ILIKE 'Kinder'
|
||||
ON CONFLICT (catalog_kind, catalog_id, slot_key) DO UPDATE SET content = EXCLUDED.content, updated_at = NOW();
|
||||
|
||||
INSERT INTO catalog_prompt_slots (catalog_kind, catalog_id, slot_key, content)
|
||||
SELECT 'target_group', tg.id, 'description',
|
||||
'Leistungsgruppe: höhere Anspruchskurven und Spezialisierung sind fachlich passend.'
|
||||
FROM target_groups tg WHERE tg.name ILIKE 'Leistungssportler'
|
||||
ON CONFLICT (catalog_kind, catalog_id, slot_key) DO UPDATE SET content = EXCLUDED.content, updated_at = NOW();
|
||||
|
||||
INSERT INTO catalog_prompt_slots (catalog_kind, catalog_id, slot_key, content)
|
||||
SELECT 'target_group', tg.id, 'hints_on_path_qa',
|
||||
'Höhere Anspruchskurven, Belastungs- und Kombinationsprogressionen sind relevant; Lücken in Spezialisierung können echte Hinweise sein.'
|
||||
FROM target_groups tg WHERE tg.name ILIKE 'Leistungssportler'
|
||||
ON CONFLICT (catalog_kind, catalog_id, slot_key) DO UPDATE SET content = EXCLUDED.content, updated_at = NOW();
|
||||
|
||||
INSERT INTO catalog_prompt_slots (catalog_kind, catalog_id, slot_key, content)
|
||||
SELECT 'style_direction', sd.id, 'description',
|
||||
'Shotokan-Linie: klare Kihon-Struktur, Hüft- und Standarbeit als wiederkehrende Qualitätsanker.'
|
||||
FROM style_directions sd WHERE sd.name ILIKE 'Shotokan'
|
||||
ON CONFLICT (catalog_kind, catalog_id, slot_key) DO UPDATE SET content = EXCLUDED.content, updated_at = NOW();
|
||||
|
||||
INSERT INTO catalog_prompt_slots (catalog_kind, catalog_id, slot_key, content)
|
||||
SELECT 'style_direction', sd.id, 'hints_on_progression',
|
||||
'Nuancen in Stellung und Hüfttechnik, kein neuer Planungstyp.'
|
||||
FROM style_directions sd WHERE sd.name ILIKE 'Shotokan'
|
||||
ON CONFLICT (catalog_kind, catalog_id, slot_key) DO UPDATE SET content = EXCLUDED.content, updated_at = NOW();
|
||||
|
||||
INSERT INTO catalog_prompt_slots (catalog_kind, catalog_id, slot_key, content)
|
||||
SELECT 'training_type', tt.id, 'description',
|
||||
'Wettkampforientiertes Training mit höherer Anspruchskurve und belastungsnahen Phasen.'
|
||||
FROM training_types tt WHERE tt.name ILIKE 'Wettkampf'
|
||||
ON CONFLICT (catalog_kind, catalog_id, slot_key) DO UPDATE SET content = EXCLUDED.content, updated_at = NOW();
|
||||
|
||||
INSERT INTO catalog_prompt_slots (catalog_kind, catalog_id, slot_key, content)
|
||||
SELECT 'training_type', tt.id, 'hints_on_path_qa',
|
||||
'Spezialisierung, Kombination und Belastung unter Druck sind relevant; Lücken in Anwendungs- oder Perfektionsphasen können echte Hinweise sein.'
|
||||
FROM training_types tt WHERE tt.name ILIKE 'Wettkampf'
|
||||
ON CONFLICT (catalog_kind, catalog_id, slot_key) DO UPDATE SET content = EXCLUDED.content, updated_at = NOW();
|
||||
|
|
@ -0,0 +1,199 @@
|
|||
-- Migration 093: Planungs-KI — granulare Katalog-Slot-Platzhalter in Prompt-Templates
|
||||
|
||||
UPDATE ai_prompts
|
||||
SET template = $t$Du bist Assistent für Kampfsport-Trainer und prüfst einen vorgeschlagenen Übungspfad.
|
||||
|
||||
Ziel-Anfrage: {{goal_query}}
|
||||
Semantic Brief: {{semantic_brief_json}}
|
||||
Schritte (JSON): {{steps_json}}
|
||||
Erkannte Lücken: {{gaps_json}}
|
||||
Eingefügte Brücken: {{bridge_inserts_json}}
|
||||
|
||||
Katalog-Kontext für Bewertung (Trainer-Auswahl — leere Zeilen ignorieren):
|
||||
|
||||
Primärfokus:
|
||||
{{focus_area_description}}
|
||||
QS: {{focus_area_hints_on_path_qa}}
|
||||
Vermeiden: {{focus_area_anti_patterns}}
|
||||
|
||||
Trainingsstil:
|
||||
{{training_type_description}}
|
||||
QS: {{training_type_hints_on_path_qa}}
|
||||
|
||||
Zielgruppe:
|
||||
{{target_group_description}}
|
||||
QS: {{target_group_hints_on_path_qa}}
|
||||
|
||||
Stilrichtung:
|
||||
{{style_direction_description}}
|
||||
QS: {{style_direction_hints_on_path_qa}}
|
||||
|
||||
{{catalog_context_json}}
|
||||
|
||||
Wichtig: Wenn Katalog-Slots gesetzt sind, haben diese Vorrang vor allgemeinen Technik-/Wettkampf-Maßstäben.
|
||||
|
||||
Prüfe:
|
||||
1. Deckt der Pfad das Hauptthema der Anfrage ab (nicht nur Oberbegriffe)?
|
||||
2. Ist die Reihenfolge didaktisch sinnvoll (Einstieg → Vertiefung → Ziel)?
|
||||
3. Sind Sprünge zwischen benachbarten Schritten zu groß?
|
||||
4. Sind Brücken-Übungen sinnvoll oder überflüssig?
|
||||
5. Fehlen wichtige Zwischenschritte — gemäß Katalog-QS-Hinweisen, nicht pauschal „Perfektion“?
|
||||
6. Gibt es Schritte ohne Bezug zum Hauptthema?
|
||||
|
||||
Wenn die Reihenfolge verbessert werden sollte: ordered_step_indices = Permutation der aktuellen 0-basierten Schritt-Indizes.
|
||||
Wenn wichtige Zwischenschritte fehlen: suggested_new_exercises mit Titel + Kurzskizze und insert_after_step_index.
|
||||
|
||||
Antworte NUR mit JSON:
|
||||
{
|
||||
"overall_ok": true,
|
||||
"quality_score": 0.85,
|
||||
"topic_coverage": "Kurz: wie gut das Hauptthema abgedeckt ist",
|
||||
"ordered_step_indices": [0, 1, 2, 3],
|
||||
"issues": ["…"],
|
||||
"sequence_notes": ["…"],
|
||||
"recommendations": ["…"],
|
||||
"suggested_new_exercises": []
|
||||
}$t$,
|
||||
default_template = template
|
||||
WHERE slug = 'planning_exercise_path_qa';
|
||||
|
||||
UPDATE ai_prompts
|
||||
SET template = $t$Du bist Assistent für Kampfsport-Trainer und analysierst eine Anfrage für einen Progressionsgraphen.
|
||||
|
||||
Anfrage: {{goal_query}}
|
||||
Semantic Brief: {{semantic_brief_json}}
|
||||
|
||||
Katalog-Kontext (Primärfokus / Trainingsstil / Zielgruppe / Stil — leere Zeilen ignorieren):
|
||||
|
||||
Primärfokus: {{focus_area_description}}
|
||||
Progression: {{focus_area_hints_on_progression}}
|
||||
|
||||
Trainingsstil: {{training_type_description}}
|
||||
Progression: {{training_type_hints_on_progression}}
|
||||
|
||||
Zielgruppe: {{target_group_description}}
|
||||
|
||||
Stilrichtung: {{style_direction_description}}
|
||||
|
||||
Wichtig: Keine Gruppenanalyse — nur didaktischer Pfad. Katalog-Hinweise beachten.
|
||||
|
||||
Antworte NUR mit JSON:
|
||||
{
|
||||
"primary_topic": "Mae Geri",
|
||||
"start_assumption": "Welche Voraussetzungen werden für den Einstieg angenommen",
|
||||
"target_state": "Konkreter Zielzustand der Progression",
|
||||
"success_criteria": ["messbare Kriterien"],
|
||||
"constraints": { "partner_required": false }
|
||||
}$t$,
|
||||
default_template = template
|
||||
WHERE slug = 'planning_progression_goal_analysis';
|
||||
|
||||
UPDATE ai_prompts
|
||||
SET template = $t$Du bist Assistent für Kampfsport-Trainer und erstellst eine didaktische Roadmap für einen Progressionsgraphen.
|
||||
|
||||
Anfrage: {{goal_query}}
|
||||
Zielanalyse: {{goal_analysis_json}}
|
||||
Semantic Brief: {{semantic_brief_json}}
|
||||
Anzahl Major Steps (N): {{max_steps}}
|
||||
|
||||
Katalog-Kontext für Stufenlogik:
|
||||
|
||||
Primärfokus:
|
||||
{{focus_area_description}}
|
||||
Roadmap: {{focus_area_hints_on_progression}}
|
||||
Vermeiden: {{focus_area_anti_patterns}}
|
||||
|
||||
Trainingsstil:
|
||||
{{training_type_description}}
|
||||
Roadmap: {{training_type_hints_on_progression}}
|
||||
|
||||
Zielgruppe:
|
||||
{{target_group_description}}
|
||||
Roadmap: {{target_group_hints_on_progression}}
|
||||
|
||||
Stilrichtung:
|
||||
{{style_direction_description}}
|
||||
Roadmap: {{style_direction_hints_on_progression}}
|
||||
|
||||
{{catalog_context_json}}
|
||||
|
||||
Erzeuge zuerst 8–12 micro_objectives, dann konsolidiere auf genau N major_steps.
|
||||
Phasen: einstieg, grundlage, vertiefung, anwendung, perfektion — Katalog-Roadmap-Hinweise beachten.
|
||||
|
||||
Antworte NUR mit JSON:
|
||||
{
|
||||
"micro_objectives": [
|
||||
{ "id": "m1", "phase": "grundlage", "title": "…", "weight": 0.9, "depends_on": [] }
|
||||
],
|
||||
"major_steps": [
|
||||
{ "index": 0, "phase": "grundlage", "learning_goal": "…", "consolidates": ["m1","m2"], "rationale": "…" }
|
||||
],
|
||||
"consolidation_notes": ["…"]
|
||||
}$t$,
|
||||
default_template = template
|
||||
WHERE slug = 'planning_progression_roadmap';
|
||||
|
||||
UPDATE ai_prompts
|
||||
SET template = $t$Du bist Assistent für Kampfsport-Trainer und spezifizierst didaktische Stufen eines Progressionsgraphen.
|
||||
|
||||
Anfrage: {{goal_query}}
|
||||
Zielanalyse: {{goal_analysis_json}}
|
||||
Major Steps: {{major_steps_json}}
|
||||
Intent-Kontext: {{intent_context_json}}
|
||||
Semantic Brief: {{semantic_brief_json}}
|
||||
|
||||
Katalog-Kontext je Stufe:
|
||||
|
||||
Primärfokus — Progression: {{focus_area_hints_on_progression}}
|
||||
Primärfokus — Vermeiden: {{focus_area_anti_patterns}}
|
||||
|
||||
Trainingsstil — Progression: {{training_type_hints_on_progression}}
|
||||
Trainingsstil — Vermeiden: {{training_type_anti_patterns}}
|
||||
|
||||
Zielgruppe — Progression: {{target_group_hints_on_progression}}
|
||||
Zielgruppe — Vermeiden: {{target_group_anti_patterns}}
|
||||
|
||||
Stilrichtung — Progression: {{style_direction_hints_on_progression}}
|
||||
|
||||
{{catalog_context_json}}
|
||||
|
||||
Für jeden Major Step: messbares Lernziel, load_profile, exercise_type, success_criteria, anti_patterns — Katalog-Slots beachten.
|
||||
|
||||
Antworte NUR mit JSON:
|
||||
{
|
||||
"stage_specs": [
|
||||
{
|
||||
"major_step_index": 0,
|
||||
"learning_goal": "…",
|
||||
"load_profile": ["koordination", "gleichgewicht"],
|
||||
"exercise_type": "kihon_einzel",
|
||||
"success_criteria": ["…"],
|
||||
"anti_patterns": ["…"]
|
||||
}
|
||||
]
|
||||
}$t$,
|
||||
default_template = template
|
||||
WHERE slug = 'planning_progression_stage_spec';
|
||||
|
||||
UPDATE ai_prompts
|
||||
SET template = $t$Du bist Assistent für Kampfsport-Trainer und extrahierst Start, Ziel und Ergänzungen für einen Progressionsgraphen.
|
||||
|
||||
Anfrage: {{goal_query}}
|
||||
Semantic Brief: {{semantic_brief_json}}
|
||||
Trainer-Notizen: {{user_notes}}
|
||||
|
||||
Katalog-Einordnung:
|
||||
Primärfokus: {{focus_area_description}}
|
||||
Trainingsstil: {{training_type_description}}
|
||||
Zielgruppe: {{target_group_description}}
|
||||
|
||||
Antworte NUR mit JSON:
|
||||
{
|
||||
"primary_topic": "…",
|
||||
"start_situation": "…",
|
||||
"target_state": "…",
|
||||
"roadmap_notes": "…",
|
||||
"extraction_notes": "…"
|
||||
}$t$,
|
||||
default_template = template
|
||||
WHERE slug = 'planning_progression_start_target';
|
||||
167
backend/migrations/094_catalog_prompt_slots_full_seed.sql
Normal file
167
backend/migrations/094_catalog_prompt_slots_full_seed.sql
Normal 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;
|
||||
|
|
@ -196,6 +196,13 @@ def openrouter_chat_completion(
|
|||
cc,
|
||||
)
|
||||
|
||||
try:
|
||||
from planning_llm_usage import record_planning_llm_call
|
||||
|
||||
record_planning_llm_call(1)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return joined
|
||||
|
||||
|
||||
|
|
|
|||
16
backend/planning_catalog_prompt_snippets.py
Normal file
16
backend/planning_catalog_prompt_snippets.py
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
"""
|
||||
Katalog-Prompt-Snippets — Abwärtskompatibilität (H1-Importpfade).
|
||||
|
||||
Implementierung: catalog_prompt_slots.py (H2).
|
||||
"""
|
||||
from catalog_prompt_slots import (
|
||||
build_catalog_guidance_for_prompt,
|
||||
get_rematch_guard_for_catalog,
|
||||
pick_active_catalog_item,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"build_catalog_guidance_for_prompt",
|
||||
"get_rematch_guard_for_catalog",
|
||||
"pick_active_catalog_item",
|
||||
]
|
||||
|
|
@ -2082,6 +2082,7 @@ def _run_evaluate_only_path_qa(
|
|||
semantic_brief: PlanningSemanticBrief,
|
||||
steps: List[Dict[str, Any]],
|
||||
roadmap_ctx: Optional[ProgressionRoadmapContext],
|
||||
catalog_context: Optional[ProgressionPlanningCatalogContext] = None,
|
||||
) -> Dict[str, Any]:
|
||||
roadmap_first = roadmap_ctx is not None
|
||||
gaps: List[Dict[str, Any]] = []
|
||||
|
|
@ -2095,6 +2096,9 @@ def _run_evaluate_only_path_qa(
|
|||
gap_fill_offers: List[Dict[str, Any]] = []
|
||||
roadmap_qa_mode: Optional[str] = None
|
||||
|
||||
if catalog_context is None:
|
||||
catalog_context = _resolve_planning_catalog_context(cur, body)
|
||||
|
||||
if body.include_path_qa:
|
||||
if roadmap_first:
|
||||
roadmap_qa_mode = "roadmap_first_lite"
|
||||
|
|
@ -2115,6 +2119,7 @@ def _run_evaluate_only_path_qa(
|
|||
steps=steps,
|
||||
gaps=gaps,
|
||||
bridge_inserts=bridge_inserts,
|
||||
catalog=catalog_context,
|
||||
)
|
||||
|
||||
off_topic_steps = detect_off_topic_steps(
|
||||
|
|
@ -2208,6 +2213,7 @@ def _run_evaluate_only_path_qa(
|
|||
reorder_notes=[],
|
||||
roadmap_qa_mode=roadmap_qa_mode,
|
||||
multistage_qa=multistage_qa,
|
||||
steps=steps,
|
||||
)
|
||||
return {
|
||||
"path_qa": path_qa,
|
||||
|
|
@ -2500,6 +2506,7 @@ def _quick_evaluate_steps_qa(
|
|||
llm_applied=False,
|
||||
roadmap_qa_mode="roadmap_first_lite" if roadmap_first else None,
|
||||
multistage_qa=multistage_qa,
|
||||
steps=steps_list,
|
||||
)
|
||||
if path_qa.get("quality_score") is None:
|
||||
path_qa["quality_score"] = compute_deterministic_path_quality_score(
|
||||
|
|
@ -3072,6 +3079,7 @@ def _suggestion_as_slot_diff(entry: Mapping[str, Any]) -> Dict[str, Any]:
|
|||
|
||||
|
||||
_SLOT_FIT_POOR_THRESHOLD = 0.30
|
||||
_SLOT_FIT_GOOD_THRESHOLD = 0.50
|
||||
|
||||
|
||||
def _off_topic_semantic_scores_by_slot(
|
||||
|
|
@ -3152,9 +3160,18 @@ def _slot_auto_select_library(
|
|||
return False
|
||||
if proposed_slot_score is None:
|
||||
return False
|
||||
if baseline_slot_score is None:
|
||||
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 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(
|
||||
|
|
@ -3391,10 +3408,14 @@ def _build_unified_slot_review_entry(
|
|||
)
|
||||
gap_fill_offers.append(slot_offer)
|
||||
if slot_offer:
|
||||
ai_auto = _slot_auto_select_ai(
|
||||
library_auto_select=bool(library_alt and library_alt.get("auto_select")),
|
||||
has_ai=True,
|
||||
)
|
||||
ai_alt = {
|
||||
"title_hint": slot_offer.get("title_hint") or f"Slot {major_idx + 1}",
|
||||
"gap_offer": slot_offer,
|
||||
"auto_select": False,
|
||||
"auto_select": ai_auto,
|
||||
}
|
||||
|
||||
return {
|
||||
|
|
@ -3812,6 +3833,7 @@ def suggest_progression_path(
|
|||
roadmap_ctx: Optional[ProgressionRoadmapContext] = None
|
||||
roadmap_edited = False
|
||||
roadmap_structured = _roadmap_structured_from_body(body)
|
||||
catalog_context = _resolve_planning_catalog_context(cur, body)
|
||||
|
||||
if body.roadmap_override is not None:
|
||||
try:
|
||||
|
|
@ -3836,6 +3858,7 @@ def suggest_progression_path(
|
|||
cur=cur,
|
||||
include_llm_start_target=body.include_llm_start_target,
|
||||
structured=roadmap_structured,
|
||||
catalog=catalog_context,
|
||||
)
|
||||
progression_roadmap = progression_roadmap_to_api_dict(roadmap_ctx)
|
||||
elif include_roadmap:
|
||||
|
|
@ -3847,6 +3870,7 @@ def suggest_progression_path(
|
|||
include_llm_roadmap=body.include_llm_roadmap,
|
||||
include_llm_start_target=body.include_llm_start_target,
|
||||
structured=roadmap_structured,
|
||||
catalog=catalog_context,
|
||||
)
|
||||
progression_roadmap = progression_roadmap_to_api_dict(roadmap_ctx)
|
||||
|
||||
|
|
@ -3907,6 +3931,7 @@ def suggest_progression_path(
|
|||
semantic_brief=semantic_brief,
|
||||
steps=eval_steps,
|
||||
roadmap_ctx=roadmap_ctx,
|
||||
catalog_context=catalog_context,
|
||||
)
|
||||
return {
|
||||
"goal_query": goal_query,
|
||||
|
|
@ -3937,7 +3962,7 @@ def suggest_progression_path(
|
|||
start_situation=body.start_situation,
|
||||
target_state=body.target_state,
|
||||
roadmap_notes=body.roadmap_notes,
|
||||
catalog_context=_resolve_planning_catalog_context(cur, body),
|
||||
catalog_context=catalog_context,
|
||||
)
|
||||
path_skill_expectations: Optional[Dict[str, Any]] = None
|
||||
if roadmap_ctx and roadmap_ctx.goal_analysis:
|
||||
|
|
@ -4136,6 +4161,7 @@ def suggest_progression_path(
|
|||
steps=steps,
|
||||
gaps=gaps,
|
||||
bridge_inserts=bridge_inserts,
|
||||
catalog=catalog_context,
|
||||
)
|
||||
|
||||
if (
|
||||
|
|
@ -4224,6 +4250,7 @@ def suggest_progression_path(
|
|||
steps=steps,
|
||||
gaps=gaps,
|
||||
bridge_inserts=bridge_inserts,
|
||||
catalog=catalog_context,
|
||||
)
|
||||
|
||||
llm_gap_specs = parse_llm_suggested_new_exercises(
|
||||
|
|
@ -4312,6 +4339,7 @@ def suggest_progression_path(
|
|||
reorder_notes=reorder_notes,
|
||||
roadmap_qa_mode=roadmap_qa_mode,
|
||||
multistage_qa=multistage_qa,
|
||||
steps=steps,
|
||||
)
|
||||
if rematch_log:
|
||||
path_qa["rematch_applied"] = True
|
||||
|
|
|
|||
|
|
@ -9,6 +9,8 @@ import re
|
|||
from typing import Any, Callable, Dict, List, Mapping, Optional, Sequence, Set, Tuple
|
||||
|
||||
from ai_prompt_runtime import AiPromptUnavailableError, load_and_render_ai_prompt
|
||||
from planning_catalog_context import ProgressionPlanningCatalogContext
|
||||
from planning_prompt_variables import merge_planning_prompt_variables
|
||||
from exercise_ai import strip_html_to_plain
|
||||
from openrouter_chat import (
|
||||
effective_openrouter_model_for_prompt_row,
|
||||
|
|
@ -320,6 +322,7 @@ def try_llm_qa_progression_path(
|
|||
steps: Sequence[Mapping[str, Any]],
|
||||
gaps: Sequence[Mapping[str, Any]],
|
||||
bridge_inserts: Sequence[Mapping[str, Any]],
|
||||
catalog: Optional[ProgressionPlanningCatalogContext] = None,
|
||||
) -> Tuple[Optional[Dict[str, Any]], bool]:
|
||||
api_key, _ = normalize_openrouter_env()
|
||||
if not api_key or len(steps) < 2:
|
||||
|
|
@ -354,13 +357,18 @@ def try_llm_qa_progression_path(
|
|||
}
|
||||
)
|
||||
|
||||
variables = {
|
||||
variables = merge_planning_prompt_variables(
|
||||
cur,
|
||||
{
|
||||
"goal_query": goal_query or "",
|
||||
"semantic_brief_json": json.dumps(brief_to_summary_dict(brief), ensure_ascii=False),
|
||||
"steps_json": json.dumps(step_payload, ensure_ascii=False),
|
||||
"gaps_json": json.dumps(list(gaps), ensure_ascii=False),
|
||||
"bridge_inserts_json": json.dumps(list(bridge_inserts), ensure_ascii=False),
|
||||
}
|
||||
},
|
||||
catalog=catalog,
|
||||
slug="planning_exercise_path_qa",
|
||||
)
|
||||
|
||||
try:
|
||||
prow, rendered = load_and_render_ai_prompt(cur, "planning_exercise_path_qa", variables)
|
||||
|
|
@ -688,6 +696,160 @@ def find_step_pair_index(
|
|||
return None
|
||||
|
||||
|
||||
def count_step_assignment_stats(steps: Optional[Sequence[Mapping[str, Any]]]) -> Dict[str, int]:
|
||||
stats = {"total": 0, "empty": 0, "library_filled": 0, "ai_proposal": 0}
|
||||
for raw in steps or []:
|
||||
if not isinstance(raw, dict):
|
||||
continue
|
||||
stats["total"] += 1
|
||||
if raw.get("exercise_id") is not None and not raw.get("is_ai_proposal"):
|
||||
stats["library_filled"] += 1
|
||||
elif raw.get("is_ai_proposal"):
|
||||
stats["ai_proposal"] += 1
|
||||
else:
|
||||
stats["empty"] += 1
|
||||
return stats
|
||||
|
||||
|
||||
def compute_assignment_quality_score(
|
||||
*,
|
||||
steps: Optional[Sequence[Mapping[str, Any]]] = None,
|
||||
off_topic_steps: Optional[Sequence[Mapping[str, Any]]] = None,
|
||||
gaps: Optional[Sequence[Mapping[str, Any]]] = None,
|
||||
) -> float:
|
||||
"""QS der Übungsbesetzung — leere Slots stark abwerten."""
|
||||
stats = count_step_assignment_stats(steps)
|
||||
total = stats["total"]
|
||||
if total <= 0:
|
||||
return 0.45
|
||||
empty = stats["empty"]
|
||||
library = stats["library_filled"]
|
||||
ai = stats["ai_proposal"]
|
||||
fill_credit = (library + 0.55 * ai) / total
|
||||
score = 0.1 + 0.84 * fill_credit
|
||||
if empty > 0:
|
||||
score -= 0.22 * (empty / total)
|
||||
score -= 0.08 * len(off_topic_steps or [])
|
||||
score -= 0.03 * len(gaps or [])
|
||||
return max(0.08, min(0.98, round(score, 4)))
|
||||
|
||||
|
||||
def compute_roadmap_quality_score(
|
||||
*,
|
||||
llm_qa: Optional[Mapping[str, Any]] = None,
|
||||
llm_applied: bool = False,
|
||||
gaps: Optional[Sequence[Mapping[str, Any]]] = None,
|
||||
multistage_qa: Optional[Mapping[str, Any]] = None,
|
||||
) -> float:
|
||||
"""QS der Roadmap-/Stufenlogik — unabhängig von Slot-Befüllung."""
|
||||
if llm_applied and llm_qa and llm_qa.get("quality_score") is not None:
|
||||
try:
|
||||
return max(0.08, min(0.98, round(float(llm_qa["quality_score"]), 4)))
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
score = 0.9
|
||||
score -= 0.05 * len(gaps or [])
|
||||
hint_count = int((multistage_qa or {}).get("optimization_hint_count") or 0)
|
||||
score -= min(0.12, 0.015 * hint_count)
|
||||
return max(0.35, min(0.98, round(score, 4)))
|
||||
|
||||
|
||||
def build_assignment_qa_snapshot(
|
||||
*,
|
||||
steps: Optional[Sequence[Mapping[str, Any]]] = None,
|
||||
off_topic_steps: Optional[Sequence[Mapping[str, Any]]] = None,
|
||||
gaps: Optional[Sequence[Mapping[str, Any]]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
off_topic = list(off_topic_steps or [])
|
||||
stats = count_step_assignment_stats(steps)
|
||||
score = compute_assignment_quality_score(
|
||||
steps=steps,
|
||||
off_topic_steps=off_topic,
|
||||
gaps=gaps,
|
||||
)
|
||||
issues: List[str] = []
|
||||
if stats["empty"] > 0:
|
||||
issues.append(
|
||||
f"{stats['empty']} von {stats['total']} Slot(s) ohne Übung — bitte Bibliothek oder KI-Vorschlag zuweisen",
|
||||
)
|
||||
if stats["ai_proposal"] > 0 and stats["library_filled"] == 0 and stats["empty"] > 0:
|
||||
issues.append(
|
||||
f"{stats['ai_proposal']} KI-Entwurf(e), aber noch {stats['empty']} leere Slot(s)",
|
||||
)
|
||||
for item in off_topic[:5]:
|
||||
title = (item.get("title") or "Schritt").strip()
|
||||
issues.append(f"„{title}“ passt nicht zum Stufen-Ziel")
|
||||
overall_ok = stats["empty"] == 0 and len(off_topic) == 0
|
||||
return {
|
||||
"overall_ok": overall_ok,
|
||||
"quality_score": score,
|
||||
"slot_count": stats["total"],
|
||||
"empty_slot_count": stats["empty"],
|
||||
"library_filled_count": stats["library_filled"],
|
||||
"ai_proposal_count": stats["ai_proposal"],
|
||||
"issues": issues,
|
||||
}
|
||||
|
||||
|
||||
def build_roadmap_qa_snapshot(
|
||||
*,
|
||||
llm_qa: Optional[Mapping[str, Any]] = None,
|
||||
llm_applied: bool = False,
|
||||
gaps: Optional[Sequence[Mapping[str, Any]]] = None,
|
||||
multistage_qa: Optional[Mapping[str, Any]] = None,
|
||||
roadmap_qa_mode: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
score = compute_roadmap_quality_score(
|
||||
llm_qa=llm_qa,
|
||||
llm_applied=llm_applied,
|
||||
gaps=gaps,
|
||||
multistage_qa=multistage_qa,
|
||||
)
|
||||
issues: List[str] = []
|
||||
if not llm_applied:
|
||||
for gap in gaps or []:
|
||||
issues.append(
|
||||
f"Übergang „{gap.get('from_title')}“ → „{gap.get('to_title')}“ schwach (Score {gap.get('gap_score')})",
|
||||
)
|
||||
if llm_applied and llm_qa:
|
||||
issues.extend(str(x).strip() for x in (llm_qa.get("issues") or []) if str(x).strip())
|
||||
overall_ok = bool(llm_qa.get("overall_ok", True)) if llm_applied and llm_qa else len(gaps or []) == 0
|
||||
snapshot: Dict[str, Any] = {
|
||||
"overall_ok": overall_ok,
|
||||
"quality_score": score,
|
||||
"issues": issues[:8],
|
||||
"llm_applied": bool(llm_applied),
|
||||
"roadmap_qa_mode": roadmap_qa_mode,
|
||||
}
|
||||
if llm_applied and llm_qa:
|
||||
snapshot["topic_coverage"] = llm_qa.get("topic_coverage")
|
||||
snapshot["recommendations"] = list(llm_qa.get("recommendations") or [])
|
||||
snapshot["sequence_notes"] = list(llm_qa.get("sequence_notes") or [])
|
||||
return snapshot
|
||||
|
||||
|
||||
def merge_path_quality_scores(
|
||||
roadmap_qa: Mapping[str, Any],
|
||||
assignment_qa: Mapping[str, Any],
|
||||
) -> float:
|
||||
"""Gesamt-QS: schwächere Dimension begrenzt — leere Slots senken den Pfad deutlich."""
|
||||
try:
|
||||
roadmap_score = float(roadmap_qa.get("quality_score"))
|
||||
except (TypeError, ValueError):
|
||||
roadmap_score = None
|
||||
try:
|
||||
assignment_score = float(assignment_qa.get("quality_score"))
|
||||
except (TypeError, ValueError):
|
||||
assignment_score = None
|
||||
if roadmap_score is not None and assignment_score is not None:
|
||||
return round(min(roadmap_score, assignment_score), 4)
|
||||
if assignment_score is not None:
|
||||
return assignment_score
|
||||
if roadmap_score is not None:
|
||||
return roadmap_score
|
||||
return 0.5
|
||||
|
||||
|
||||
def build_path_qa_summary(
|
||||
*,
|
||||
gaps: Sequence[Mapping[str, Any]],
|
||||
|
|
@ -702,6 +864,7 @@ def build_path_qa_summary(
|
|||
reorder_notes: Optional[Sequence[str]] = None,
|
||||
roadmap_qa_mode: Optional[str] = None,
|
||||
multistage_qa: Optional[Mapping[str, Any]] = None,
|
||||
steps: Optional[Sequence[Mapping[str, Any]]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
offers = list(gap_fill_offers or [])
|
||||
off_topic = list(off_topic_steps or [])
|
||||
|
|
@ -726,31 +889,32 @@ def build_path_qa_summary(
|
|||
summary["qa_tiers"] = list(multistage_qa.get("qa_tiers") or [])
|
||||
summary["optimization_hints"] = list(multistage_qa.get("optimization_hints") or [])
|
||||
summary["optimization_hint_count"] = int(multistage_qa.get("optimization_hint_count") or 0)
|
||||
if llm_qa:
|
||||
summary["overall_ok"] = bool(llm_qa.get("overall_ok", True))
|
||||
summary["quality_score"] = llm_qa.get("quality_score")
|
||||
summary["issues"] = list(llm_qa.get("issues") or [])
|
||||
summary["sequence_notes"] = list(llm_qa.get("sequence_notes") or [])
|
||||
summary["topic_coverage"] = llm_qa.get("topic_coverage")
|
||||
summary["recommendations"] = list(llm_qa.get("recommendations") or [])
|
||||
summary["suggested_new_exercises"] = list(llm_qa.get("suggested_new_exercises") or [])
|
||||
else:
|
||||
summary["overall_ok"] = len(gaps) == 0 and len(off_topic) == 0
|
||||
summary["issues"] = [
|
||||
f"Lücke zwischen „{g.get('from_title')}“ und „{g.get('to_title')}“ (Score {g.get('gap_score')})"
|
||||
for g in gaps
|
||||
] if gaps else []
|
||||
if off_topic:
|
||||
summary["issues"] = list(summary["issues"]) + [
|
||||
f"Schritt „{o.get('title')}“ passt nicht zum Pfad-Thema"
|
||||
for o in off_topic
|
||||
]
|
||||
summary["quality_score"] = compute_deterministic_path_quality_score(
|
||||
gaps=gaps,
|
||||
off_topic_steps=off_topic,
|
||||
|
||||
assignment_qa = build_assignment_qa_snapshot(
|
||||
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
|
||||
|
||||
|
||||
|
|
@ -761,31 +925,34 @@ def compute_deterministic_path_quality_score(
|
|||
steps: Optional[Sequence[Mapping[str, Any]]] = None,
|
||||
multistage_qa: Optional[Mapping[str, Any]] = None,
|
||||
) -> float:
|
||||
"""Heuristische Pfad-QS ohne LLM — Basis für Slot-Vergleiche."""
|
||||
score = 0.92
|
||||
score -= 0.08 * len(off_topic_steps or [])
|
||||
score -= 0.05 * len(gaps or [])
|
||||
if steps:
|
||||
empty = sum(
|
||||
1
|
||||
for s in steps
|
||||
if isinstance(s, dict)
|
||||
and s.get("exercise_id") is None
|
||||
and not s.get("is_ai_proposal")
|
||||
"""Heuristische Pfad-QS ohne LLM — Roadmap + Besetzung kombiniert."""
|
||||
roadmap_qa = build_roadmap_qa_snapshot(
|
||||
llm_qa=None,
|
||||
llm_applied=False,
|
||||
gaps=gaps,
|
||||
multistage_qa=multistage_qa,
|
||||
)
|
||||
score -= 0.06 * empty
|
||||
hint_count = int((multistage_qa or {}).get("optimization_hint_count") or 0)
|
||||
score -= min(0.14, 0.02 * hint_count)
|
||||
return max(0.35, min(0.98, round(score, 4)))
|
||||
assignment_qa = build_assignment_qa_snapshot(
|
||||
steps=steps,
|
||||
off_topic_steps=off_topic_steps,
|
||||
gaps=gaps,
|
||||
)
|
||||
return merge_path_quality_scores(roadmap_qa, assignment_qa)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"apply_llm_path_reorder",
|
||||
"build_assignment_qa_snapshot",
|
||||
"build_path_qa_summary",
|
||||
"build_roadmap_qa_snapshot",
|
||||
"compute_assignment_quality_score",
|
||||
"compute_deterministic_path_quality_score",
|
||||
"compute_roadmap_quality_score",
|
||||
"count_step_assignment_stats",
|
||||
"detect_off_topic_steps",
|
||||
"detect_path_gaps",
|
||||
"is_roadmap_planned_neighbor_pair",
|
||||
"merge_path_quality_scores",
|
||||
"strip_off_topic_steps_from_path",
|
||||
"find_step_pair_index",
|
||||
"insert_bridge_exercises",
|
||||
|
|
|
|||
62
backend/planning_llm_usage.py
Normal file
62
backend/planning_llm_usage.py
Normal 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",
|
||||
]
|
||||
|
|
@ -18,6 +18,8 @@ from typing import Any, Dict, List, Mapping, Optional, Sequence, Tuple
|
|||
from pydantic import BaseModel, Field, ValidationError
|
||||
|
||||
from ai_prompt_runtime import AiPromptUnavailableError, load_and_render_ai_prompt
|
||||
from planning_catalog_context import ProgressionPlanningCatalogContext
|
||||
from planning_prompt_variables import merge_planning_prompt_variables
|
||||
from openrouter_chat import (
|
||||
effective_openrouter_model_for_prompt_row,
|
||||
normalize_openrouter_env,
|
||||
|
|
@ -190,12 +192,20 @@ def _run_prompt_json(
|
|||
cur,
|
||||
slug: str,
|
||||
variables: Dict[str, str],
|
||||
*,
|
||||
catalog: Optional[ProgressionPlanningCatalogContext] = None,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
api_key, _ = normalize_openrouter_env()
|
||||
if not api_key or cur is None:
|
||||
return None
|
||||
merged = merge_planning_prompt_variables(
|
||||
cur,
|
||||
variables,
|
||||
catalog=catalog,
|
||||
slug=slug,
|
||||
)
|
||||
try:
|
||||
prow, rendered = load_and_render_ai_prompt(cur, slug, variables)
|
||||
prow, rendered = load_and_render_ai_prompt(cur, slug, merged)
|
||||
model = effective_openrouter_model_for_prompt_row(prow)
|
||||
raw = openrouter_chat_completion(api_key=api_key, model=model, user_content=rendered.text)
|
||||
return _extract_json_object(raw)
|
||||
|
|
@ -212,6 +222,7 @@ def try_llm_start_target_extract(
|
|||
goal_query: str,
|
||||
brief: PlanningSemanticBrief,
|
||||
user_notes: str = "",
|
||||
catalog: Optional[ProgressionPlanningCatalogContext] = None,
|
||||
) -> Tuple[Optional[StartTargetExtractArtifact], bool]:
|
||||
obj = _run_prompt_json(
|
||||
cur,
|
||||
|
|
@ -221,6 +232,7 @@ def try_llm_start_target_extract(
|
|||
"semantic_brief_json": json.dumps(brief_to_summary_dict(brief), ensure_ascii=False),
|
||||
"user_notes": (user_notes or "").strip(),
|
||||
},
|
||||
catalog=catalog,
|
||||
)
|
||||
if not obj:
|
||||
return None, False
|
||||
|
|
@ -236,6 +248,7 @@ def try_llm_goal_analysis(
|
|||
*,
|
||||
goal_query: str,
|
||||
brief: PlanningSemanticBrief,
|
||||
catalog: Optional[ProgressionPlanningCatalogContext] = None,
|
||||
) -> Tuple[Optional[GoalAnalysisArtifact], bool]:
|
||||
obj = _run_prompt_json(
|
||||
cur,
|
||||
|
|
@ -244,6 +257,7 @@ def try_llm_goal_analysis(
|
|||
"goal_query": goal_query or "",
|
||||
"semantic_brief_json": json.dumps(brief_to_summary_dict(brief), ensure_ascii=False),
|
||||
},
|
||||
catalog=catalog,
|
||||
)
|
||||
if not obj:
|
||||
return None, False
|
||||
|
|
@ -261,6 +275,7 @@ def try_llm_roadmap(
|
|||
brief: PlanningSemanticBrief,
|
||||
goal_analysis: GoalAnalysisArtifact,
|
||||
max_steps: int,
|
||||
catalog: Optional[ProgressionPlanningCatalogContext] = None,
|
||||
) -> Tuple[Optional[RoadmapArtifact], bool]:
|
||||
obj = _run_prompt_json(
|
||||
cur,
|
||||
|
|
@ -271,6 +286,7 @@ def try_llm_roadmap(
|
|||
"goal_analysis_json": json.dumps(goal_analysis.model_dump(), ensure_ascii=False),
|
||||
"max_steps": str(int(max_steps)),
|
||||
},
|
||||
catalog=catalog,
|
||||
)
|
||||
if not obj:
|
||||
return None, False
|
||||
|
|
@ -304,6 +320,7 @@ def try_llm_stage_specs(
|
|||
major_steps: Sequence[MajorStep],
|
||||
intent_context: Optional[Mapping[str, Any]] = None,
|
||||
semantic_brief: Optional[PlanningSemanticBrief] = None,
|
||||
catalog: Optional[ProgressionPlanningCatalogContext] = None,
|
||||
) -> Tuple[Optional[List[StageSpecArtifact]], bool]:
|
||||
obj = _run_prompt_json(
|
||||
cur,
|
||||
|
|
@ -318,6 +335,7 @@ def try_llm_stage_specs(
|
|||
ensure_ascii=False,
|
||||
),
|
||||
},
|
||||
catalog=catalog,
|
||||
)
|
||||
if not obj:
|
||||
return None, False
|
||||
|
|
@ -380,6 +398,7 @@ def resolve_roadmap_structured_input(
|
|||
brief: PlanningSemanticBrief,
|
||||
cur=None,
|
||||
include_llm: bool = False,
|
||||
catalog: Optional[ProgressionPlanningCatalogContext] = None,
|
||||
) -> Tuple[RoadmapStructuredInput, StartTargetResolveMeta, Optional[StartTargetExtractArtifact]]:
|
||||
"""Priorität je Feld: Trainer-Eingabe > LLM-Extraktion > Regex (von … bis …)."""
|
||||
user = structured or RoadmapStructuredInput()
|
||||
|
|
@ -395,6 +414,7 @@ def resolve_roadmap_structured_input(
|
|||
goal_query=goal_query,
|
||||
brief=brief,
|
||||
user_notes=user_notes,
|
||||
catalog=catalog,
|
||||
)
|
||||
|
||||
parsed_start, parsed_target = parse_start_target_from_goal_query(goal_query)
|
||||
|
|
@ -1068,6 +1088,7 @@ def run_start_target_resolve_only(
|
|||
cur=None,
|
||||
include_llm_start_target: bool = True,
|
||||
structured: Optional[RoadmapStructuredInput] = None,
|
||||
catalog: Optional[ProgressionPlanningCatalogContext] = None,
|
||||
) -> ProgressionRoadmapContext:
|
||||
"""Nur Start/Ziel/Ergänzungen auflösen — ohne Roadmap-Stufen (Review vor Major Steps)."""
|
||||
brief = semantic_brief or build_semantic_brief(goal_query)
|
||||
|
|
@ -1077,6 +1098,7 @@ def run_start_target_resolve_only(
|
|||
brief=brief,
|
||||
cur=cur,
|
||||
include_llm=include_llm_start_target,
|
||||
catalog=catalog,
|
||||
)
|
||||
topic_override = None
|
||||
if llm_extract and (llm_extract.primary_topic or "").strip():
|
||||
|
|
@ -1112,6 +1134,7 @@ def run_progression_roadmap_pipeline(
|
|||
include_llm_roadmap: bool = False,
|
||||
include_llm_start_target: bool = False,
|
||||
structured: Optional[RoadmapStructuredInput] = None,
|
||||
catalog: Optional[ProgressionPlanningCatalogContext] = None,
|
||||
) -> ProgressionRoadmapContext:
|
||||
"""Workflow-lite: Phase A → B → C. LLM über ai_prompts-Slugs; deterministischer Fallback."""
|
||||
brief = semantic_brief or build_semantic_brief(goal_query)
|
||||
|
|
@ -1121,6 +1144,7 @@ def run_progression_roadmap_pipeline(
|
|||
brief=brief,
|
||||
cur=cur,
|
||||
include_llm=include_llm_start_target,
|
||||
catalog=catalog,
|
||||
)
|
||||
parsed_start, parsed_target = parse_start_target_from_goal_query(goal_query)
|
||||
llm_goal_query = _roadmap_llm_goal_block(
|
||||
|
|
@ -1152,7 +1176,9 @@ def run_progression_roadmap_pipeline(
|
|||
topic_override=topic_override,
|
||||
)
|
||||
if include_llm_roadmap and cur is not None:
|
||||
llm_ga, ga_ok = try_llm_goal_analysis(cur, goal_query=llm_goal_query, brief=brief)
|
||||
llm_ga, ga_ok = try_llm_goal_analysis(
|
||||
cur, goal_query=llm_goal_query, brief=brief, catalog=catalog
|
||||
)
|
||||
if ga_ok and llm_ga:
|
||||
goal_analysis = _merge_structured_into_goal_analysis(
|
||||
llm_ga,
|
||||
|
|
@ -1172,6 +1198,7 @@ def run_progression_roadmap_pipeline(
|
|||
brief=brief,
|
||||
goal_analysis=goal_analysis,
|
||||
max_steps=max_steps,
|
||||
catalog=catalog,
|
||||
)
|
||||
if rm_ok and llm_rm:
|
||||
roadmap = llm_rm
|
||||
|
|
@ -1234,6 +1261,7 @@ def run_progression_roadmap_pipeline(
|
|||
major_steps=roadmap.major_steps,
|
||||
intent_context=intent.to_api_dict(),
|
||||
semantic_brief=brief,
|
||||
catalog=catalog,
|
||||
)
|
||||
if spec_ok and llm_specs:
|
||||
stage_specs = list(llm_specs)
|
||||
|
|
|
|||
118
backend/planning_prompt_variables.py
Normal file
118
backend/planning_prompt_variables.py
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
"""
|
||||
Zentrale Mustache-Variablen für Planungs-KI-Prompts.
|
||||
|
||||
Orchestratoren bauen domänenspezifische Basis-Variablen; dieses Modul merged
|
||||
erweiterbare Provider (Katalog-Slots, später weitere Kontexte).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Callable, Dict, Mapping, Optional
|
||||
|
||||
from catalog_prompt_slots import all_placeholder_keys, empty_catalog_variables
|
||||
from planning_catalog_context import ProgressionPlanningCatalogContext
|
||||
|
||||
PlanningPromptVariableProvider = Callable[..., Dict[str, str]]
|
||||
|
||||
|
||||
def _catalog_slot_variables(
|
||||
*,
|
||||
cur,
|
||||
catalog: Optional[ProgressionPlanningCatalogContext] = None,
|
||||
slug: Optional[str] = None,
|
||||
**_: Any,
|
||||
) -> Dict[str, str]:
|
||||
if cur is None or catalog is None:
|
||||
return empty_catalog_variables()
|
||||
from catalog_prompt_slots import resolve_catalog_prompt_variables
|
||||
|
||||
resolved = resolve_catalog_prompt_variables(cur, catalog, slug=slug)
|
||||
return {k: str(resolved.get(k) or "") for k in all_placeholder_keys()}
|
||||
|
||||
|
||||
_PLANNING_PROMPT_VARIABLE_PROVIDERS: tuple[PlanningPromptVariableProvider, ...] = (
|
||||
_catalog_slot_variables,
|
||||
)
|
||||
|
||||
|
||||
def merge_planning_prompt_variables(
|
||||
cur,
|
||||
base_variables: Mapping[str, str],
|
||||
*,
|
||||
catalog: Optional[ProgressionPlanningCatalogContext] = None,
|
||||
slug: Optional[str] = None,
|
||||
) -> Dict[str, str]:
|
||||
"""Merged Basis-Variablen mit allen registrierten Planungs-Providern."""
|
||||
out = {str(k): "" if v is None else str(v) for k, v in base_variables.items()}
|
||||
ctx: Dict[str, Any] = {"cur": cur, "catalog": catalog, "slug": slug}
|
||||
for provider in _PLANNING_PROMPT_VARIABLE_PROVIDERS:
|
||||
out.update(provider(**ctx))
|
||||
return out
|
||||
|
||||
|
||||
def planning_prompt_placeholder_catalog() -> dict:
|
||||
"""Platzhalter-Katalog für Admin — Slot-Typ × Dimension + Aggregat."""
|
||||
from catalog_prompt_slots import CATALOG_KINDS, SLOT_KEYS, placeholder_key
|
||||
|
||||
slot_labels = {
|
||||
"description": "Allgemeine Beschreibung",
|
||||
"hints_on_progression": "Hinweise Progressionsgraph / Stufen",
|
||||
"hints_on_exercise": "Hinweise Übungsanlage / Gap-Fill",
|
||||
"hints_on_path_qa": "Bewertungsmaßstäbe Pfad-QS",
|
||||
"anti_patterns": "Anti-Patterns (Fehlbewertung vermeiden)",
|
||||
"rematch_guard": "Rematch-Guard (primär Code, optional Prompt)",
|
||||
}
|
||||
kind_labels = {c.kind: c.label_de for c in CATALOG_KINDS}
|
||||
|
||||
slugs_common = [
|
||||
"planning_exercise_path_qa",
|
||||
"planning_progression_roadmap",
|
||||
"planning_progression_stage_spec",
|
||||
"planning_progression_goal_analysis",
|
||||
"planning_progression_start_target",
|
||||
]
|
||||
|
||||
defs = []
|
||||
for cfg in CATALOG_KINDS:
|
||||
for slot in SLOT_KEYS:
|
||||
key = placeholder_key(cfg.kind, slot)
|
||||
defs.append(
|
||||
{
|
||||
"key": key,
|
||||
"placeholder": "{{" + key + "}}",
|
||||
"description": (
|
||||
f"{kind_labels.get(cfg.kind, cfg.kind)} — "
|
||||
f"{slot_labels.get(slot, slot)} (aktiver Eintrag aus planning_catalog_context)."
|
||||
),
|
||||
"used_by_slugs": slugs_common,
|
||||
}
|
||||
)
|
||||
|
||||
defs.extend(
|
||||
[
|
||||
{
|
||||
"key": "catalog_guidance_block",
|
||||
"placeholder": "{{catalog_guidance_block}}",
|
||||
"description": "Aggregierter Markdown-Block aus aktiven Slots (slug-spezifisches Profil).",
|
||||
"used_by_slugs": slugs_common,
|
||||
},
|
||||
{
|
||||
"key": "catalog_context_json",
|
||||
"placeholder": "{{catalog_context_json}}",
|
||||
"description": "Audit-JSON der gewählten Katalog-Einträge und befüllten Slots.",
|
||||
"used_by_slugs": slugs_common[:3],
|
||||
},
|
||||
{
|
||||
"key": "has_catalog_guidance",
|
||||
"placeholder": "{{has_catalog_guidance}}",
|
||||
"description": "„true“ wenn mindestens ein LLM-Slot gesetzt; sonst leer.",
|
||||
"used_by_slugs": slugs_common[:3],
|
||||
},
|
||||
]
|
||||
)
|
||||
return {"context": "planning", "placeholders": defs}
|
||||
|
||||
|
||||
__all__ = [
|
||||
"merge_planning_prompt_variables",
|
||||
"planning_prompt_placeholder_catalog",
|
||||
]
|
||||
|
|
@ -14,9 +14,15 @@ from auth import require_auth
|
|||
from club_tenancy import is_superadmin
|
||||
from ai_prompt_context import ExerciseFormAiPromptContext
|
||||
from ai_prompt_job import resolve_exercise_form_variables
|
||||
from ai_prompt_planning_preview import (
|
||||
PlanningPromptPreviewInput,
|
||||
is_planning_prompt_slug,
|
||||
resolve_planning_prompt_preview_variables,
|
||||
)
|
||||
from ai_prompt_runtime import render_ai_prompt_template_for_row
|
||||
from db import get_cursor, get_db, r2d
|
||||
from prompt_resolver import exercise_placeholder_catalog
|
||||
from planning_prompt_variables import planning_prompt_placeholder_catalog
|
||||
|
||||
router = APIRouter(tags=["admin_ai_prompts"])
|
||||
|
||||
|
|
@ -62,12 +68,22 @@ class AiPromptUpdateBody(BaseModel):
|
|||
|
||||
|
||||
class AiPromptPreviewBody(ExerciseFormAiPromptContext):
|
||||
"""Preview-POST: gleiche Felder wie ExerciseFormAiPromptContext (focus_hint, nicht focus_area_hint)."""
|
||||
"""Preview-POST: Übungs-KI und Planungs-Prompts."""
|
||||
|
||||
goal_query: Optional[str] = Field(default=None, max_length=2000)
|
||||
user_notes: Optional[str] = Field(default=None, max_length=2000)
|
||||
max_steps: Optional[int] = Field(default=None, ge=2, le=10)
|
||||
search_query: Optional[str] = Field(default=None, max_length=2000)
|
||||
|
||||
|
||||
@router.get("/api/admin/ai-prompts/catalog/placeholders")
|
||||
def get_ai_prompt_placeholders_catalog(session: dict = Depends(_require_superadmin)):
|
||||
return exercise_placeholder_catalog()
|
||||
exercise = exercise_placeholder_catalog()
|
||||
planning = planning_prompt_placeholder_catalog()
|
||||
return {
|
||||
"context": "all",
|
||||
"placeholders": list(exercise.get("placeholders") or []) + list(planning.get("placeholders") or []),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/api/admin/ai-prompts")
|
||||
|
|
@ -223,6 +239,17 @@ def preview_ai_prompt(prompt_id: int, body: AiPromptPreviewBody, session: dict =
|
|||
vars_map = resolve_exercise_form_variables(cur, slug, body)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e)) from e
|
||||
elif is_planning_prompt_slug(slug):
|
||||
planning_in = PlanningPromptPreviewInput(
|
||||
goal_query=(body.goal_query or "Mae Geri vom Grundschritt bis zur Kumite-Nähe").strip(),
|
||||
user_notes=(body.user_notes or "").strip(),
|
||||
max_steps=body.max_steps if body.max_steps is not None else 5,
|
||||
search_query=(body.search_query or body.goal_query or "").strip() or None,
|
||||
)
|
||||
try:
|
||||
vars_map = resolve_planning_prompt_preview_variables(cur, slug, planning_in)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e)) from e
|
||||
elif slug == "pipeline":
|
||||
vars_map = {}
|
||||
warn = "Pipeline-Slug: keine Kontextsubstitution fuer Vorschau."
|
||||
|
|
|
|||
97
backend/routers/catalog_prompt_slots.py
Normal file
97
backend/routers/catalog_prompt_slots.py
Normal 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
|
||||
|
|
@ -4,7 +4,7 @@ Optional Übungsvarianten als Knoten-Endpunkte; Sequenz-Bulk-Anlage.
|
|||
AuthZ analog training_plan_templates: Bearbeiten/Löschen wie Übungen; Governance-Übergänge zentral.
|
||||
"""
|
||||
import json
|
||||
from typing import Any, Dict, List, Optional
|
||||
from typing import Any, Dict, List, Mapping, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from pydantic import BaseModel, Field, model_validator
|
||||
|
|
@ -19,6 +19,7 @@ from club_tenancy import (
|
|||
assert_library_content_editable,
|
||||
assert_library_content_governance_transition,
|
||||
assert_valid_governance_visibility,
|
||||
is_platform_admin,
|
||||
library_content_visible_to_profile,
|
||||
)
|
||||
|
||||
|
|
@ -176,6 +177,87 @@ def _assert_variant_for_exercise(cur, exercise_id: int, variant_id: Optional[int
|
|||
raise HTTPException(status_code=400, detail="Variante gehört nicht zur gewählten Übung")
|
||||
|
||||
|
||||
def _exercise_allowed_in_progression_graph(
|
||||
exercise_row: Mapping[str, Any],
|
||||
*,
|
||||
graph_visibility: str,
|
||||
graph_club_id: Optional[int],
|
||||
profile_id: int,
|
||||
role: str,
|
||||
) -> bool:
|
||||
"""Prüft, ob eine Übung zur Sichtbarkeit des Progressionsgraphen passt."""
|
||||
ex_vis = (exercise_row.get("visibility") or "private").strip().lower()
|
||||
gvis = (graph_visibility or "private").strip().lower()
|
||||
if gvis == "private":
|
||||
if ex_vis == "official":
|
||||
return True
|
||||
if ex_vis == "club":
|
||||
return True
|
||||
if ex_vis == "private":
|
||||
if is_platform_admin(role):
|
||||
return True
|
||||
try:
|
||||
return int(exercise_row.get("created_by") or 0) == int(profile_id)
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
return False
|
||||
if gvis == "club":
|
||||
if ex_vis == "official":
|
||||
return True
|
||||
if ex_vis != "club":
|
||||
return False
|
||||
ex_club = exercise_row.get("club_id")
|
||||
if ex_club is None:
|
||||
return False
|
||||
if graph_club_id is None:
|
||||
return True
|
||||
return int(ex_club) == int(graph_club_id)
|
||||
return ex_vis == "official"
|
||||
|
||||
|
||||
def _assert_exercises_allowed_in_graph(
|
||||
cur,
|
||||
graph_id: int,
|
||||
profile_id: int,
|
||||
role: str,
|
||||
*exercise_ids: int,
|
||||
) -> None:
|
||||
"""400 wenn eine Übung nicht zur Graph-Sichtbarkeit passt."""
|
||||
row = _graph_row(cur, graph_id)
|
||||
gvis = (row.get("visibility") or "private").strip().lower()
|
||||
gclub_raw = row.get("club_id")
|
||||
gclub = int(gclub_raw) if gclub_raw is not None else None
|
||||
unique = list(dict.fromkeys(exercise_ids))
|
||||
if not unique:
|
||||
return
|
||||
ph = ",".join(["%s"] * len(unique))
|
||||
cur.execute(
|
||||
f"SELECT id, title, visibility, club_id, created_by FROM exercises WHERE id IN ({ph})",
|
||||
tuple(unique),
|
||||
)
|
||||
by_id = {int(r2d(r)["id"]): r2d(r) for r in cur.fetchall()}
|
||||
for eid in unique:
|
||||
ex = by_id.get(int(eid))
|
||||
if not ex:
|
||||
continue
|
||||
if _exercise_allowed_in_progression_graph(
|
||||
ex,
|
||||
graph_visibility=gvis,
|
||||
graph_club_id=gclub,
|
||||
profile_id=profile_id,
|
||||
role=role,
|
||||
):
|
||||
continue
|
||||
title = (ex.get("title") or "").strip() or f"#{eid}"
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=(
|
||||
f"Übung „{title}“ (Sichtbarkeit: {ex.get('visibility') or 'private'}) "
|
||||
f"passt nicht zum Progressionsgraphen ({gvis})."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _insert_edge_row(
|
||||
cur,
|
||||
graph_id: int,
|
||||
|
|
@ -312,6 +394,22 @@ def _collect_graph_referenced_exercise_ids(cur, graph_id: int) -> set[int]:
|
|||
return ids
|
||||
|
||||
|
||||
def _graph_promotion_transition(graph_visibility: str, target_visibility: str) -> Optional[tuple[str, ...]]:
|
||||
"""
|
||||
Erlaubte Graph-Promotions und welche Übungs-Sichtbarkeiten mit angehoben werden müssen.
|
||||
|
||||
Returns None wenn kein Übungs-Promotion-Hinweis nötig.
|
||||
"""
|
||||
gvis = (graph_visibility or "private").strip().lower()
|
||||
tvis = (target_visibility or "").strip().lower()
|
||||
transitions: Dict[tuple[str, str], tuple[str, ...]] = {
|
||||
("private", "club"): ("private",),
|
||||
("private", "official"): ("private", "club"),
|
||||
("club", "official"): ("private", "club"),
|
||||
}
|
||||
return transitions.get((gvis, tvis))
|
||||
|
||||
|
||||
@router.get("/exercise-progression-graphs/{graph_id}/visibility-promotion-candidates")
|
||||
def list_visibility_promotion_candidates(
|
||||
graph_id: int,
|
||||
|
|
@ -319,7 +417,9 @@ def list_visibility_promotion_candidates(
|
|||
tenant: TenantContext = Depends(get_tenant_context),
|
||||
):
|
||||
"""
|
||||
Private Übungen im Graph, die bei Promotion des Graphen mit angehoben werden müssten.
|
||||
Übungen im Graph, die bei Promotion des Graphen mit angehoben werden müssten.
|
||||
|
||||
Unterstützt: private→club, private→official, club→official.
|
||||
"""
|
||||
profile_id = tenant.profile_id
|
||||
role = tenant.global_role
|
||||
|
|
@ -327,11 +427,13 @@ def list_visibility_promotion_candidates(
|
|||
cur = get_cursor(conn)
|
||||
row = _require_graph_read(cur, graph_id, profile_id, role)
|
||||
graph_vis = (row.get("visibility") or "private").strip().lower()
|
||||
if graph_vis != "private" or target_visibility != "club":
|
||||
target_vis = (target_visibility or "club").strip().lower()
|
||||
need_vis = _graph_promotion_transition(graph_vis, target_vis)
|
||||
if not need_vis:
|
||||
return {
|
||||
"graph_id": graph_id,
|
||||
"graph_visibility": graph_vis,
|
||||
"target_visibility": target_visibility,
|
||||
"target_visibility": target_vis,
|
||||
"exercises": [],
|
||||
}
|
||||
ref_ids = _collect_graph_referenced_exercise_ids(cur, graph_id)
|
||||
|
|
@ -339,19 +441,20 @@ def list_visibility_promotion_candidates(
|
|||
return {
|
||||
"graph_id": graph_id,
|
||||
"graph_visibility": graph_vis,
|
||||
"target_visibility": target_visibility,
|
||||
"target_visibility": target_vis,
|
||||
"exercises": [],
|
||||
}
|
||||
vis_placeholders = ",".join(["%s"] * len(need_vis))
|
||||
ph = ",".join(["%s"] * len(ref_ids))
|
||||
cur.execute(
|
||||
f"""
|
||||
SELECT id, title, visibility, club_id, created_by
|
||||
FROM exercises
|
||||
WHERE id IN ({ph})
|
||||
AND LOWER(TRIM(COALESCE(visibility, ''))) = 'private'
|
||||
AND LOWER(TRIM(COALESCE(visibility, ''))) IN ({vis_placeholders})
|
||||
ORDER BY title
|
||||
""",
|
||||
list(ref_ids),
|
||||
list(ref_ids) + list(need_vis),
|
||||
)
|
||||
exercises = []
|
||||
for ex in cur.fetchall():
|
||||
|
|
@ -359,8 +462,10 @@ def list_visibility_promotion_candidates(
|
|||
if not library_content_visible_to_profile(
|
||||
cur,
|
||||
profile_id,
|
||||
(exd.get("visibility") or "private").strip().lower(),
|
||||
exd.get("club_id"),
|
||||
exd.get("created_by"),
|
||||
role,
|
||||
exd,
|
||||
):
|
||||
continue
|
||||
exercises.append(
|
||||
|
|
@ -373,7 +478,7 @@ def list_visibility_promotion_candidates(
|
|||
return {
|
||||
"graph_id": graph_id,
|
||||
"graph_visibility": graph_vis,
|
||||
"target_visibility": target_visibility,
|
||||
"target_visibility": target_vis,
|
||||
"exercises": exercises,
|
||||
}
|
||||
|
||||
|
|
@ -565,6 +670,9 @@ def create_progression_edge(
|
|||
cur = get_cursor(conn)
|
||||
_require_graph_write(cur, graph_id, profile_id, role)
|
||||
_assert_exercises_exist(cur, body.from_exercise_id, body.to_exercise_id)
|
||||
_assert_exercises_allowed_in_graph(
|
||||
cur, graph_id, profile_id, role, body.from_exercise_id, body.to_exercise_id
|
||||
)
|
||||
fv = body.from_exercise_variant_id
|
||||
tv = body.to_exercise_variant_id
|
||||
_assert_variant_for_exercise(cur, body.from_exercise_id, fv)
|
||||
|
|
@ -613,6 +721,7 @@ def create_progression_sequence(
|
|||
|
||||
ex_ids = [s.exercise_id for s in steps]
|
||||
_assert_exercises_exist(cur, *ex_ids)
|
||||
_assert_exercises_allowed_in_graph(cur, graph_id, profile_id, role, *ex_ids)
|
||||
|
||||
try:
|
||||
for i in range(n_seg):
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ from db import get_db, get_cursor
|
|||
from tenant_context import TenantContext, get_tenant_context
|
||||
from planning_exercise_suggest import PlanningExerciseSuggestRequest, suggest_planning_exercises
|
||||
from planning_exercise_path_builder import ProgressionPathSuggestRequest, suggest_progression_path
|
||||
from planning_llm_usage import planning_llm_call_meter
|
||||
from account_lifecycle import assert_min_account_state
|
||||
from capabilities import probe_capability
|
||||
from club_features import (
|
||||
|
|
@ -46,19 +47,25 @@ def post_planning_exercise_suggest(
|
|||
)
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
with planning_llm_call_meter() as llm_meter:
|
||||
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(
|
||||
feature_id="ai_calls",
|
||||
club_id=club_id,
|
||||
profile_id=tenant.profile_id,
|
||||
portal_role=tenant.global_role,
|
||||
action="planning_suggest",
|
||||
amount=llm_meter.count,
|
||||
cur=cur,
|
||||
tenant=tenant,
|
||||
conn=conn,
|
||||
)
|
||||
result = merge_feature_usage_into_response(result, usage)
|
||||
if isinstance(result, dict):
|
||||
result["llm_call_count"] = llm_meter.count
|
||||
elif uses_ai and isinstance(result, dict):
|
||||
result["llm_call_count"] = 0
|
||||
return result
|
||||
|
||||
|
||||
|
|
@ -70,7 +77,6 @@ def post_progression_path_suggest(
|
|||
uses_ai = (
|
||||
body.include_llm_intent
|
||||
or body.include_llm_path_qa
|
||||
or body.include_ai_gap_fill
|
||||
or body.include_llm_roadmap
|
||||
or body.include_llm_start_target
|
||||
or (body.start_target_only and body.include_llm_start_target)
|
||||
|
|
@ -98,17 +104,23 @@ def post_progression_path_suggest(
|
|||
)
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
with planning_llm_call_meter() as llm_meter:
|
||||
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(
|
||||
feature_id="ai_calls",
|
||||
club_id=club_id,
|
||||
profile_id=tenant.profile_id,
|
||||
portal_role=tenant.global_role,
|
||||
action="progression_path_suggest",
|
||||
amount=llm_meter.count,
|
||||
cur=cur,
|
||||
tenant=tenant,
|
||||
conn=conn,
|
||||
)
|
||||
result = merge_feature_usage_into_response(result, usage)
|
||||
if isinstance(result, dict):
|
||||
result["llm_call_count"] = llm_meter.count
|
||||
elif uses_ai and isinstance(result, dict):
|
||||
result["llm_call_count"] = 0
|
||||
return result
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ EXEMPT_ROUTERS: frozenset[str] = frozenset(
|
|||
"admin_user_content.py", # Superadmin Moderation nutzerangelegter Inhalte; require_auth + is_superadmin — kein Vereinsmandant
|
||||
"admin_rights.py", # Superadmin Rollen/Rechte (Capabilities, Kontingent-Bypass, Pläne); require_auth + is_superadmin — kein Vereinsmandant
|
||||
"catalogs.py",
|
||||
"catalog_prompt_slots.py", # Admin Stammdaten KI-Prompt-Slots; require_auth + admin/superadmin — globaler Katalog, kein Vereinsmandant
|
||||
"skills.py",
|
||||
"maturity_models.py",
|
||||
"matrix_stack_bundle.py",
|
||||
|
|
|
|||
89
backend/tests/test_ai_prompt_planning_preview.py
Normal file
89
backend/tests/test_ai_prompt_planning_preview.py
Normal 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
|
||||
38
backend/tests/test_catalog_slot_fallbacks.py
Normal file
38
backend/tests/test_catalog_slot_fallbacks.py
Normal 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"]
|
||||
80
backend/tests/test_exercise_progression_graph_visibility.py
Normal file
80
backend/tests/test_exercise_progression_graph_visibility.py
Normal 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",
|
||||
)
|
||||
168
backend/tests/test_planning_catalog_prompt_snippets.py
Normal file
168
backend/tests/test_planning_catalog_prompt_snippets.py
Normal 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")
|
||||
|
|
@ -3,19 +3,28 @@ from planning_exercise_path_qa import compute_deterministic_path_quality_score
|
|||
|
||||
|
||||
def test_deterministic_quality_score_penalizes_off_topic():
|
||||
base = compute_deterministic_path_quality_score(gaps=[], off_topic_steps=[])
|
||||
steps = [{"roadmap_major_step_index": 0, "exercise_id": 1}]
|
||||
base = compute_deterministic_path_quality_score(gaps=[], off_topic_steps=[], steps=steps)
|
||||
with_off = compute_deterministic_path_quality_score(
|
||||
gaps=[],
|
||||
off_topic_steps=[{"roadmap_major_step_index": 1}],
|
||||
steps=steps,
|
||||
)
|
||||
assert with_off < base
|
||||
|
||||
|
||||
def test_deterministic_quality_score_penalizes_empty_slots():
|
||||
base = compute_deterministic_path_quality_score(gaps=[], off_topic_steps=[], steps=[])
|
||||
filled = [{"roadmap_major_step_index": 0, "exercise_id": 1}]
|
||||
base = compute_deterministic_path_quality_score(gaps=[], off_topic_steps=[], steps=filled)
|
||||
with_empty = compute_deterministic_path_quality_score(
|
||||
gaps=[],
|
||||
off_topic_steps=[],
|
||||
steps=[{"exercise_id": None}, {"exercise_id": 1}],
|
||||
steps=[{"roadmap_major_step_index": 0, "exercise_id": None}, {"roadmap_major_step_index": 1, "exercise_id": 2}],
|
||||
)
|
||||
all_empty = compute_deterministic_path_quality_score(
|
||||
gaps=[],
|
||||
off_topic_steps=[],
|
||||
steps=[{"roadmap_major_step_index": 0, "exercise_id": None}] * 4,
|
||||
)
|
||||
assert with_empty < base
|
||||
assert all_empty <= 0.15
|
||||
|
|
|
|||
94
backend/tests/test_planning_llm_usage.py
Normal file
94
backend/tests/test_planning_llm_usage.py
Normal 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
|
||||
62
backend/tests/test_planning_path_qa_split.py
Normal file
62
backend/tests/test_planning_path_qa_split.py
Normal 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"])
|
||||
|
|
@ -2,6 +2,7 @@
|
|||
from planning_exercise_path_builder import (
|
||||
_parse_slot_refs_from_text,
|
||||
_problematic_slots_from_path_qa,
|
||||
_slot_auto_select_ai,
|
||||
_slot_auto_select_library,
|
||||
_slot_suggestion_accepted,
|
||||
)
|
||||
|
|
@ -113,6 +114,27 @@ def test_slot_auto_select_requires_higher_score():
|
|||
)
|
||||
|
||||
|
||||
def test_slot_auto_select_empty_slot_requires_good_fit():
|
||||
assert not _slot_auto_select_library(
|
||||
baseline_slot_score=None,
|
||||
proposed_slot_score=0.35,
|
||||
baseline_exercise_id=None,
|
||||
proposed_exercise_id=2,
|
||||
)
|
||||
assert _slot_auto_select_library(
|
||||
baseline_slot_score=None,
|
||||
proposed_slot_score=0.55,
|
||||
baseline_exercise_id=None,
|
||||
proposed_exercise_id=2,
|
||||
)
|
||||
|
||||
|
||||
def test_slot_auto_select_ai_when_library_not_selected():
|
||||
assert _slot_auto_select_ai(library_auto_select=False, has_ai=True)
|
||||
assert not _slot_auto_select_ai(library_auto_select=True, has_ai=True)
|
||||
assert not _slot_auto_select_ai(library_auto_select=False, has_ai=False)
|
||||
|
||||
|
||||
def test_off_topic_slot_gap_spec_for_filled_slot():
|
||||
from planning_exercise_path_builder import _build_off_topic_slot_gap_spec
|
||||
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
# Shinkan Jinkendo Version Information
|
||||
|
||||
APP_VERSION = "0.8.233"
|
||||
APP_VERSION = "0.8.237"
|
||||
BUILD_DATE = "2026-05-22"
|
||||
DB_SCHEMA_VERSION = "20260607090"
|
||||
DB_SCHEMA_VERSION = "20260607094"
|
||||
|
||||
MODULE_VERSIONS = {
|
||||
"legal_documents": "1.4.0", # Admin: Live-Vorschau pro Abschnitt + modale Vollvorschau (Editor + Dokumentenliste)
|
||||
|
|
@ -29,7 +29,7 @@ MODULE_VERSIONS = {
|
|||
"media_lifecycle": "1.1.0", # P-11: Retention-Job ueberspringt Legal-Hold-Assets
|
||||
"admin_ai_skill_retrieval": "1.0.0", # Superadmin CRUD /api/admin/ai-skill-retrieval-profiles (Migration 068)
|
||||
"exercise_enrichment_admin": "1.1.1", # Preview max 3/Request + parallel LLM (Gateway-504 vermeiden)
|
||||
"admin_ai_prompts": "1.0.3", # Migration 070: openrouter_model; PUT/Liste/Detail
|
||||
"admin_ai_prompts": "1.0.5", # H2: granulare Katalog-Slot-Platzhalter im Katalog
|
||||
"ai_prompt_job": "0.2.1", # want_instructions; run_exercise_form_ai_suggestion
|
||||
"ai_prompt_context": "0.2.0", # preparation/trainer_notes; has_instruction_source_text
|
||||
"ai_prompt_runtime": "0.2.2", # Slug planning_exercise_expectation_profile
|
||||
|
|
@ -53,6 +53,40 @@ MODULE_VERSIONS = {
|
|||
}
|
||||
|
||||
CHANGELOG = [
|
||||
{
|
||||
"version": "0.8.237",
|
||||
"date": "2026-05-22",
|
||||
"changes": [
|
||||
"Migration 094: catalog_prompt_slots vollständig befüllt (Karate, SV, alle Trainingsstile/Zielgruppen).",
|
||||
"catalog_slot_fallbacks: Namens-Fallback bis Admin-Override — gleiche Qualität wie H1-Registry.",
|
||||
],
|
||||
},
|
||||
{
|
||||
"version": "0.8.236",
|
||||
"date": "2026-05-22",
|
||||
"changes": [
|
||||
"Stammdaten-Katalog: CatalogPromptSlotsEditor für Fokus, Trainingsstil, Zielgruppe, Stilrichtung.",
|
||||
"Migration 093: ai_prompts mit granularen Slot-Platzhaltern (focus_area_hints_on_path_qa etc.).",
|
||||
],
|
||||
},
|
||||
{
|
||||
"version": "0.8.235",
|
||||
"date": "2026-05-22",
|
||||
"changes": [
|
||||
"Planungs-KI H2: catalog_prompt_slot_types + catalog_prompt_slots — Slot-Werte pro Katalog-Eintrag.",
|
||||
"Granulare Platzhalter focus_area_hints_on_progression etc.; Resolver catalog_prompt_slots.py.",
|
||||
"Admin-API GET/PUT /api/catalog-prompt-slots/{kind}/{id}; H1-Registry entfernt.",
|
||||
],
|
||||
},
|
||||
{
|
||||
"version": "0.8.234",
|
||||
"date": "2026-05-22",
|
||||
"changes": [
|
||||
"Planungs-KI H1: Katalog-Snippets (planning_catalog_prompt_snippets) + zentrale Platzhalter (planning_prompt_variables).",
|
||||
"Pfad-QS, Roadmap, Stufenspec: {{catalog_guidance_block}} aus Trainer-Katalog; Migration 091.",
|
||||
"Admin: Planungs-Platzhalter-Katalog; Preview mit optional planning_catalog_context.",
|
||||
],
|
||||
},
|
||||
{
|
||||
"version": "0.8.226",
|
||||
"date": "2026-05-22",
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
# Shinkan Jinkendo – Entwicklungsstand & Handover
|
||||
|
||||
**Stand:** 2026-05-22
|
||||
**App-Version / DB-Schema:** App **`0.8.233`** (Planungs-KI F11–F14, Katalog-Kontext); DB siehe **`backend/version.py`** (`DB_SCHEMA_VERSION`, Migration **088**).
|
||||
**Stand:** 2026-05-22 (F15 Graph-Match & getrennte Pfad-QS, lokal nach **0.8.233**)
|
||||
**App-Version / DB-Schema:** App **`0.8.233`** (Planungs-KI F11–F14, Katalog-Kontext); **F15** siehe §2.8 — DB unverändert (`DB_SCHEMA_VERSION`, Migration **088**).
|
||||
|
||||
Diese Datei ist die **Einstiegs-Doku für neue Chat-Sessions**: Anforderungen im Detail stehen in `.claude/docs/` (siehe unten); hier der **implementierte Stand**, **Medien-Meilenstein** und **sinnvolle nächste Schritte**.
|
||||
|
||||
|
|
@ -114,11 +114,25 @@ Das Schema ist gegenüber dem Code zurück: Migration **`022_skills_schema_compl
|
|||
| **F12** | Post-Match-Gate, LLM-QA nach Rematch, Gap-Timing, `roadmap_unfilled`-Sync | ✅ **0.8.231–0.8.232** |
|
||||
| **F13** | **`planning_catalog_context`** (Fokus/Stil/TT/ZG) im Match + Graph-Artefakt | ✅ **0.8.233** |
|
||||
| **F14** | **`ProgressionGraphEditor`** — Slot-UI + Planungskontext-Dropdowns | ✅ **0.8.233** |
|
||||
| **F15** | Unified Slot-Review (Match-Dialog), getrennte Pfad-QS, `findings_stale` | ✅ lokal (nach 0.8.233) |
|
||||
| **H1** | Katalog-Prompt-Snippets (modulare LLM-Anweisungen) | 🔲 Spec **`docs/architecture/PLANNING_CATALOG_PROMPT_SNIPPETS.md`** |
|
||||
|
||||
**Architektur (verbindlich):** Drei Schichten — (1) **Katalog-Dimensionen** (DB, jetzt im Match verdrahtet; **H1:** zusätzlich Prompt-Snippets), (2) **Technik-Disambiguierung** (Code, nur bei `topic_type=technique`), (3) **Didaktik** (Roadmap + LLM-QS, nicht im Vokabular). Progressionsgraph = **Roadmap-first**, **keine Gruppenanalyse**. Bestehender Graph = **leichter Nachfolger-Bias** ab Schritt 2. Trainingsplanung = **eigene Pipeline** (Phase G) — Wiederverwendung der Bausteine, siehe Ist-Doku §16.
|
||||
|
||||
**Validierung (Mae Geri, Härtetest):** Pfad-QS vor Optimierung ~65 % → nach Trainer-Roadmap + KI-Gap-Fill **~88 % OK**. Workbench ist **universell** gedacht; Mae Geri war Referenzfall, kein Sonder-Patch.
|
||||
**Validierung (Mae Geri, Härtetest):** Roadmap-QS nach Trainer-Roadmap oft **~85–88 %** — gilt **Stufenlogik**, nicht leere Slots. **Gesamt-Pfad-QS** = Minimum aus **`roadmap_qa`** + **`assignment_qa`** (leere Slots → Besetzung ~8–15 %). Workbench universell; Mae Geri Referenzfall.
|
||||
|
||||
#### F15 — Match-Dialog, Bewertung, Pfad-QS (Stand 2026-05-22)
|
||||
|
||||
| Thema | Ist |
|
||||
|--------|-----|
|
||||
| **„Übungen matchen“** | Schritt 1: `evaluate_only` (wie „Graph bewerten“) · Schritt 2: `unified_slot_review: true` → Dialog **pro Slot** (Bewertung, Bibliotheks-Alternative, optional KI) |
|
||||
| **Vorauswahl Dialog** | Bibliothek nur bei Stufen-Fit **≥ 50 %** und besser als aktuell; bei leerem Slot + schwacher Bibliothek → **KI-Vorschlag** vorausgewählt |
|
||||
| **Übernahme** | Nur gewählte Slots speichern — **keine** automatische teure Nach-Bewertung |
|
||||
| **Bewertung veraltet** | Nach Graph-Änderungen Hinweis im Findings-Panel; persistiert als **`findings_stale`** im `planning_roadmap`-Artefakt (mit Speichern) |
|
||||
| **Getrennte QS** | `path_qa.roadmap_qa` (Stufen/Roadmap/LLM) + `path_qa.assignment_qa` (Slot-Befüllung); **`quality_score`** = Minimum beider |
|
||||
| **UX-Fix** | Slot-Karten: stabiler React-Key (`slot-{index}`) — Lernziel editierbar ohne Fokusverlust |
|
||||
|
||||
**Code:** `ProgressionOptimizeCompareModal.jsx`, `planning_exercise_path_builder.py` (`_build_unified_slot_review_entry`, `_slot_auto_select_*`), `planning_exercise_path_qa.py` (`build_*_qa_snapshot`), `progression_graph_planning_artifact.py` (`findings_stale`), `progressionGraphDraft.js`
|
||||
|
||||
**Backend-Kern:** `planning_progression_roadmap.py`, `planning_exercise_path_builder.py`, `planning_catalog_context.py`, `planning_path_rematch.py`, `planning_path_refine_stage.py`, `planning_path_qa_pipeline.py`, `planning_skill_expectations.py`, `planning_exercise_form_context.py`, `planning_exercise_path_ai_fill.py`, `progression_graph_planning_artifact.py`
|
||||
|
||||
|
|
@ -129,12 +143,12 @@ Das Schema ist gegenüber dem Code zurück: Migration **`022_skills_schema_compl
|
|||
**Offen (priorisiert):**
|
||||
1. Dev-Regression: Gewaltschutz / Breitensport / Kinder (nicht nur Mae Geri)
|
||||
2. **PathBuilder-Parität** — gleiche Katalog-Dropdowns wie GraphEditor
|
||||
3. QS-UI — positive LLM-Hinweise als Highlights
|
||||
4. UI-Wizard (4 Schritte: Ziel → Roadmap → Match → Lücken)
|
||||
5. Graph-Erweiterungsmodus (Start ab Knoten)
|
||||
6. Phase D′ — Auto KI-Gap-Fill bei persistent leeren Slots
|
||||
7. **Trainingsplanung Phase G** — Gruppenkontext-Pack, Scopes `training_section` / `framework_slot` (Ist-Doku §16)
|
||||
8. Technik-Katalog konfigurierbar (Backlog)
|
||||
3. UI-Wizard (4 Schritte: Ziel → Roadmap → Match → Lücken)
|
||||
4. Graph-Erweiterungsmodus (Start ab Knoten)
|
||||
5. Phase D′ — Auto KI-Gap-Fill bei persistent leeren Slots
|
||||
6. **Trainingsplanung Phase G** — Gruppenkontext-Pack, Scopes `training_section` / `framework_slot` (Ist-Doku §16)
|
||||
7. Technik-Katalog konfigurierbar (Backlog)
|
||||
8. **H1** — Katalog-Prompt-Snippets (modulare LLM-Anweisungen)
|
||||
|
||||
#### Übungs-KI Formular / Schnellanlage (Stand **0.8.171**)
|
||||
|
||||
|
|
@ -271,8 +285,7 @@ Das Schema ist gegenüber dem Code zurück: Migration **`022_skills_schema_compl
|
|||
|
||||
1. **H1 Katalog-Prompt-Snippets:** modulare LLM-Anweisungen — **`docs/architecture/PLANNING_CATALOG_PROMPT_SNIPPETS.md`** (Priorität: Primärfokus → Trainingsstil → Zielgruppe → Stilrichtung).
|
||||
2. **Dev-Regression:** Katalog-Match für Gewaltschutz, Breitensport, Kinder — nicht nur Mae-Geri-Härtetest.
|
||||
2. **PathBuilder-Parität:** `planning_catalog_context`-Dropdowns auch in `ExerciseProgressionPathBuilder`.
|
||||
3. **QS-UI:** positive LLM-Empfehlungen als Highlights statt nur Optimierungspotenziale.
|
||||
3. **PathBuilder-Parität:** `planning_catalog_context`-Dropdowns auch in `ExerciseProgressionPathBuilder`.
|
||||
4. **UI-Wizard:** 4 Schritte (Ziel & Katalog → Roadmap → Match → Lücken); Backend-Pipeline unverändert.
|
||||
5. **Phase D′:** automatisches KI-Gap-Fill bei persistent `roadmap_unfilled`.
|
||||
6. **Trainingsplanung G0–G4:** Katalog in Einheits-Editor, Scopes `training_section`/`framework_slot`, Abschnitts-QS, Gruppenkontext-Pack — Details **`PLANNING_PROGRESSION_GRAPH_KI.md`** §16, **`PLANNING_KI_ROADMAP.md`**.
|
||||
|
|
|
|||
|
|
@ -1,229 +1,216 @@
|
|||
# Planungs-KI — Katalog-Snippets für modulare Prompts
|
||||
# Planungs-KI — Katalog-Prompt-Slots (Snippets)
|
||||
|
||||
**Stand:** 2026-05-22
|
||||
**Status:** Spezifikation (Phase **H1** — Umsetzung offen)
|
||||
**Bezüge:** `PLANNING_PROGRESSION_GRAPH_KI.md` §4.4 · `AI_PROMPT_TARGET_ARCHITECTURE.md` §2.4 · `planning_catalog_context.py`
|
||||
**Status:** **H2** umgesetzt (0.8.235) · **H2.1** Admin-UI + granulare Prompts (0.8.236)
|
||||
**Bezüge:** `PLANNING_PROGRESSION_GRAPH_KI.md` §4.4 · `AI_PROMPT_TARGET_ARCHITECTURE.md` §2.4 · `planning_catalog_context.py` · `catalog_prompt_slots.py`
|
||||
|
||||
---
|
||||
|
||||
## 1. Problem
|
||||
|
||||
Seit **F13 (0.8.233)** fließen Primärfokus, Trainingsstil, Zielgruppe und Stilrichtung als `planning_catalog_context` in **Retrieval und Scoring** (`PlanningTargetProfile`).
|
||||
Seit **F13 (0.8.233)** fließen Primärfokus, Trainingsstil, Zielgruppe und Stilrichtung als `planning_catalog_context` in **Retrieval und Scoring**.
|
||||
|
||||
Die **LLM-Prompts** (Roadmap, Stufen-Spec, Pfad-QS, Gap-Fill) erhalten diese Dimensionen **nicht** als differenzierte Bewertungs- und Planungslogik — höchstens indirekt über Freitext oder JSON-Kataloglisten in Intent-Prompts.
|
||||
Die **LLM-Prompts** brauchen zusätzlich **fachliche Textbausteine** pro gewähltem Katalog-Eintrag — editierbar, ohne Code-Deploy, unabhängig von fest codierten Katalog-Namen.
|
||||
|
||||
**Folge:** Ein Breitensport-Pfad kann mit Leistungsgruppen-Kriterien bewertet werden; Gewaltschutz wird wie Technik-Curriculum behandelt; QS-Hinweise und Rematch-Vorschläge passen fachlich nicht zum gewählten Kontext.
|
||||
|
||||
**Ziel:** Gleiche **Prompt-Basis**, aber **kaskadierte Snippet-Blöcke** pro Katalog-Ausprägung — keine Matrix aus Voll-Prompt-Kopien.
|
||||
**Ziel:** Prompts in `ai_prompts` referenzieren **Slot-Typen** (Vokabular). Inhalte hängen an **Katalog-Zeilen** (Stammdaten). Der Resolver füllt Platzhalter zur Laufzeit.
|
||||
|
||||
---
|
||||
|
||||
## 2. Priorität der Dimensionen (absteigend)
|
||||
## 2. Zwei Ebenen (Kern des Modells)
|
||||
|
||||
Verbindliche Reihenfolge bei Konflikten und beim Rollout:
|
||||
| Ebene | Was | Wer pflegt | Beispiel |
|
||||
|-------|-----|------------|----------|
|
||||
| **Slot-Typ** | Art des Textes (festes, erweiterbares Vokabular) | Produkt / Architektur | `hints_on_progression`, `hints_on_path_qa` |
|
||||
| **Slot-Wert** | Konkreter Text pro Katalog-Eintrag | Admin / Redakteur | Gewaltschutz → `hints_on_path_qa`: „Lücken = fehlende Deeskalation …“ |
|
||||
|
||||
| Rang | Dimension | DB-Tabelle | Snippet-Rolle |
|
||||
|------|-----------|------------|----------------|
|
||||
| **1** | **Primärfokus** | `focus_areas` | Definiert *worüber* geplant und bewertet wird (Technik-Curriculum vs. Gewaltschutz vs. Fitness …). **Dominant.** |
|
||||
| **2** | **Trainingsstil** | `training_types` | Definiert *wie* trainiert wird (Methodik, Belastungsaufbau, Wettkampf vs. Breitenansatz im Training). |
|
||||
| **3** | **Zielgruppe** | `target_groups` | Definiert *für wen* (Kinder, Breitensport, Leistungsgruppe) — Tempo, Komplexität, Sicherheit. |
|
||||
| **4** | **Stilrichtung** | `style_directions` | Vereins-/Stil-Linie (Shotokan, WKF …) — **Nuancen**, kein neuer Planungstyp. |
|
||||
|
||||
**Kaskaden-Regel:** Bei widersprüchlichen Snippet-Aussagen gilt: Primärfokus → Trainingsstil → Zielgruppe → Stilrichtung.
|
||||
**Nicht:** feste Attribute pro Eintrag im Code (`planning_lens`, `qa_criteria` …).
|
||||
**Sondern:** beliebig viele Katalog-Einträge × definierte Slot-Typen.
|
||||
|
||||
---
|
||||
|
||||
## 3. Architektur — drei Schichten (Erinnerung)
|
||||
## 3. Dimensionen & Priorität
|
||||
|
||||
| Schicht | Heute | Mit H1 |
|
||||
|---------|-------|--------|
|
||||
| **Retrieval** | Katalog-Gewichte in `merge_catalog_context_into_target` | unverändert |
|
||||
| **Technik-Gates** | `planning_exercise_semantics.py`, nur `topic_type=technique` | unverändert |
|
||||
| **LLM-Prompts** | kaum Katalog-spezifisch | **`catalog_guidance_block`** pro Aufruf |
|
||||
| Rang | Dimension | `catalog_kind` | DB-Tabelle |
|
||||
|------|-----------|----------------|------------|
|
||||
| **1** | Primärfokus | `focus_area` | `focus_areas` |
|
||||
| **2** | Trainingsstil | `training_type` | `training_types` |
|
||||
| **3** | Zielgruppe | `target_group` | `target_groups` |
|
||||
| **4** | Stilrichtung | `style_direction` | `style_directions` |
|
||||
|
||||
Snippets **ersetzen** keine Technik-Disambiguierung und **duplizieren** keine Retrieval-Gewichte — sie steuern **Didaktik, Bewertungsmaßstäbe und Formulierung** für das Modell.
|
||||
**Kaskade:** Bei widersprüchlichen Hinweisen gilt Fokus → Trainingsstil → Zielgruppe → Stilrichtung.
|
||||
|
||||
Pro Dimension im Request: **ein aktiver Eintrag** (`is_primary`, sonst höchstes `weight`).
|
||||
|
||||
---
|
||||
|
||||
## 4. Snippet-Modell
|
||||
## 4. Slot-Typ-Register (Vokabular)
|
||||
|
||||
### 4.1 Lookup-Schlüssel
|
||||
Definiert in **`catalog_prompt_slot_types`** (DB) + Spiegel in `catalog_prompt_slots.py`.
|
||||
|
||||
Pro Katalog-Eintrag ein stabiler **`snippet_key`** (nicht nur numerische ID — IDs können sich in Dev/Import unterscheiden):
|
||||
| `slot_key` | Anzeige (DE) | LLM-Prompt | Code-only |
|
||||
|------------|--------------|------------|-----------|
|
||||
| `description` | Allgemeine Beschreibung | Roadmap, Goal-Analyse, Start/Ziel | — |
|
||||
| `hints_on_progression` | Hinweise Progressionsgraph / Stufen | Roadmap, Stage-Spec | — |
|
||||
| `hints_on_path_qa` | Bewertungsmaßstäbe Pfad-QS | Path-QA | — |
|
||||
| `hints_on_exercise` | Hinweise Übungsanlage / Gap-Fill | Exercise-AI, Suggest (später) | — |
|
||||
| `anti_patterns` | Explizit vermeiden (Fehlbewertung) | Path-QA, Stage-Spec | — |
|
||||
| `rematch_guard` | Kein Auto-Rematch erzwingen | — | Rematch-Loop (H1.5) |
|
||||
|
||||
```
|
||||
focus:{slug} z. B. focus:gewaltschutz
|
||||
training_type:{slug} z. B. training_type:kumite
|
||||
target_group:{slug} z. B. target_group:breitensport
|
||||
style:{slug} z. B. style:shotokan
|
||||
```
|
||||
**Erweiterung:** neuer Slot-Typ = eine Zeile in `catalog_prompt_slot_types` + Resolver/Admin-Katalog — **kein** neues Python-Feld pro Eintrag.
|
||||
|
||||
**Primär** aus `slug` der DB-Zeile; Fallback normalisierter `name` (ASCII, lower, `_`).
|
||||
**Fallback `description`:** Wenn kein Slot-Wert gesetzt → `focus_areas.description` (bzw. `description`-Spalte der jeweiligen Katalog-Tabelle).
|
||||
|
||||
Mehrfachauswahl im UI: pro Dimension **höchstens ein Snippet** — die Zeile mit `is_primary: true`, sonst erste Zeile, sonst höchste `weight`.
|
||||
---
|
||||
|
||||
### 4.2 Snippet-Inhalt (Struktur)
|
||||
## 5. Platzhalter in `ai_prompts`
|
||||
|
||||
Jedes Snippet liefert strukturierte Textbausteine (Deutsch, für LLM):
|
||||
|
||||
| Feld | Pflicht | Inhalt |
|
||||
|------|---------|--------|
|
||||
| `planning_lens` | ja | 2–4 Sätze: Was ist das Planungsziel in dieser Dimension? |
|
||||
| `qa_criteria` | ja | Bullet-artige Kriterien für Pfad-QS (was ist „gut“, was ist kein Mangel) |
|
||||
| `roadmap_hints` | empfohlen | Stufenlogik, typische Phasen, was vermeiden |
|
||||
| `anti_patterns` | optional | Explizite Fehlbewertungen vermeiden (z. B. „keine Wettkampf-Tiefe verlangen“) |
|
||||
| `rematch_guard` | optional | Wann **kein** Auto-Rematch sinnvoll (Breitensport: keine Perfektions-Slots erzwingen) |
|
||||
|
||||
Phase **H1:** flache Markdown-Strings im Code-Modul.
|
||||
Phase **H2 (optional):** Tabelle `planning_catalog_prompt_snippets` oder JSONB an Katalog-Zeilen, Admin-editierbar.
|
||||
|
||||
### 4.3 Platzhalter in `ai_prompts`
|
||||
|
||||
Neue **gemeinsame** Platzhalter (Mustache), in alle betroffenen Prompts einfügen:
|
||||
Mustache-Keys: **`{catalog_kind}_{slot_key}`** (nur `[a-z0-9_]`).
|
||||
|
||||
| Platzhalter | Bedeutung |
|
||||
|-------------|-----------|
|
||||
| `{{catalog_guidance_block}}` | Gerenderter Gesamttext (alle aktiven Snippets, kaskadiert) |
|
||||
| `{{catalog_context_json}}` | Kompakte JSON-Zusammenfassung der gewählten IDs/Namen (Audit) |
|
||||
| `{{#has_catalog_guidance}}` … `{{/has_catalog_guidance}}` | Block nur wenn mindestens ein Snippet aktiv |
|
||||
| `{{focus_area_description}}` | Aktiver Primärfokus — Beschreibung |
|
||||
| `{{focus_area_hints_on_progression}}` | … — Progressions-Hinweise |
|
||||
| `{{focus_area_hints_on_path_qa}}` | … — QS-Hinweise |
|
||||
| `{{focus_area_hints_on_exercise}}` | … — Übungsanlage |
|
||||
| `{{focus_area_anti_patterns}}` | … — Anti-Patterns |
|
||||
| `{{training_type_description}}` | Aktiver Trainingsstil — … |
|
||||
| `{{training_type_hints_on_progression}}` | … |
|
||||
| `{{target_group_hints_on_path_qa}}` | Aktive Zielgruppe — … |
|
||||
| `{{style_direction_hints_on_progression}}` | Aktive Stilrichtung — … |
|
||||
| *(analog für alle Slot-Typen × Dimension)* | |
|
||||
| `{{catalog_guidance_block}}` | **Aggregat** (Abwärtskompatibel): kaskadierter Markdown-Block aus aktiven Slots |
|
||||
| `{{catalog_context_json}}` | Audit: IDs, Namen, gesetzte Slot-Keys |
|
||||
| `{{has_catalog_guidance}}` | `"true"` oder leer |
|
||||
|
||||
**Optional fein (später):** `{{catalog_focus_snippet}}`, `{{catalog_training_type_snippet}}`, … — Phase H1 nur `_block` + JSON.
|
||||
**Hinweis:** `prompt_resolver` unterstützt **keine** `{{#if}}`-Blöcke — leere Slots = leerer String.
|
||||
|
||||
### 4.4 Betroffene Prompt-Slugs (Reihenfolge Einbindung)
|
||||
### 5.1 Prompt-Profile (welche Slots im Aggregat)
|
||||
|
||||
| Priorität | Slug | Migration | Wirkung |
|
||||
|-----------|------|-----------|---------|
|
||||
| 1 | `planning_exercise_path_qa` | bestehend | Pfad-QS, `quality_score`, Empfehlungen |
|
||||
| 2 | `planning_progression_roadmap` | 078 | Major Steps, Didaktik |
|
||||
| 3 | `planning_progression_stage_spec` | 079 | Stufen-Gates, Erfolgskriterien |
|
||||
| 4 | `planning_progression_start_target` | 087 | Start/Ziel-Extraktion |
|
||||
| 5 | `planning_progression_goal_analysis` | 078 | Zielanalyse |
|
||||
| 6 | Gap-Fill / Übungs-KI | 085+ | `planning_context` ergänzen |
|
||||
| Prompt-Slug | Aggregat enthält primär |
|
||||
|-------------|-------------------------|
|
||||
| `planning_exercise_path_qa` | `*_hints_on_path_qa`, `*_anti_patterns`, `*_description` |
|
||||
| `planning_progression_roadmap` | `*_description`, `*_hints_on_progression`, `*_anti_patterns` |
|
||||
| `planning_progression_stage_spec` | `*_hints_on_progression`, `*_anti_patterns` |
|
||||
| `planning_progression_goal_analysis` | `*_description` |
|
||||
|
||||
Intent-Prompts (`planning_exercise_search_intent`, …) **optional** Phase H1.5 — dort bereits Katalog-JSON.
|
||||
Feinsteuerung: im Admin-Template **granulare** Platzhalter nutzen; Aggregat optional.
|
||||
|
||||
---
|
||||
|
||||
## 5. Builder (Backend)
|
||||
## 6. Speicherung (DB)
|
||||
|
||||
**Neues Modul:** `backend/planning_catalog_prompt_snippets.py`
|
||||
### 6.1 `catalog_prompt_slot_types`
|
||||
|
||||
```python
|
||||
def build_catalog_guidance_for_prompt(
|
||||
cur,
|
||||
catalog: Optional[ProgressionPlanningCatalogContext],
|
||||
) -> Dict[str, str]:
|
||||
"""
|
||||
Returns:
|
||||
catalog_guidance_block: str
|
||||
catalog_context_json: str
|
||||
has_catalog_guidance: bool
|
||||
snippet_keys: list[str] # Metadaten für Logs/Tests
|
||||
"""
|
||||
Metadaten je Slot-Typ (`slot_key`, `display_name`, `description`, `applicable_kinds[]`, `sort_order`, `for_llm`, `for_code`).
|
||||
|
||||
### 6.2 `catalog_prompt_slots`
|
||||
|
||||
```text
|
||||
catalog_kind — focus_area | training_type | target_group | style_direction
|
||||
catalog_id — FK auf jeweilige Stammtabelle (logisch, kein polymorpher FK)
|
||||
slot_key — FK → catalog_prompt_slot_types
|
||||
content — TEXT (Markdown/Plain für LLM)
|
||||
UNIQUE (catalog_kind, catalog_id, slot_key)
|
||||
```
|
||||
|
||||
**Ablauf:**
|
||||
|
||||
1. `catalog` aus Request oder `planning_roadmap.planning_catalog_context` (wie F13).
|
||||
2. Pro Dimension aktives Item auflösen → `snippet_key` → Text aus Registry.
|
||||
3. Snippets in **Prioritätsreihenfolge** §2 zu `_block` zusammenfügen (Überschriften: „Primärfokus“, „Trainingsstil“, …).
|
||||
4. Fehlende Snippets: Dimension **weglassen** (kein Default-Text) — besser kein Snippet als falscher.
|
||||
|
||||
**Einbindung:** Orchestratoren rufen Builder auf und mergen in `variables` vor `load_and_render_ai_prompt`:
|
||||
|
||||
- `planning_exercise_path_qa.py` → `try_llm_qa_progression_path`
|
||||
- `planning_progression_roadmap.py` (Roadmap-/Stage-Pipeline)
|
||||
- `planning_exercise_path_builder.py` (catalog an QA/Match durchreichen)
|
||||
|
||||
`ProgressionPathSuggestRequest` trägt `planning_catalog_context` bereits — kein neues API-Feld nötig.
|
||||
Neuer Katalog-Eintrag im Admin → **keine** Code-Änderung; Slots optional befüllen.
|
||||
|
||||
---
|
||||
|
||||
## 6. Beispiel-Snippets (Review-Entwurf)
|
||||
## 7. Laufzeit-Architektur
|
||||
|
||||
### 6.1 Primärfokus — Gewaltschutz (`focus:gewaltschutz`)
|
||||
```text
|
||||
planning_catalog_context (Request / Graph-Artefakt)
|
||||
↓
|
||||
catalog_prompt_slots.resolve_catalog_prompt_variables(cur, catalog, slug?)
|
||||
↓
|
||||
planning_prompt_variables.merge_planning_prompt_variables(...)
|
||||
↓
|
||||
load_and_render_ai_prompt (ai_prompts Template)
|
||||
```
|
||||
|
||||
**planning_lens:** Planung zielt auf Prävention, Deeskalation, Grenzen und sichere Übungsformen — nicht auf Wettkampf-Perfektion oder Technik-Show.
|
||||
**Module:**
|
||||
|
||||
**qa_criteria:** Gute Pfade bauen Sicherheit, Kommunikation und Alternativen auf; „Lücken“ sind fehlende Deeskalations- oder Rollenspiel-Stufen, nicht fehlende Kick-Varianten.
|
||||
|
||||
**anti_patterns:** Nicht nach Kumite-Tiefe, Explosivität oder Wettkampf-Belastung bewerten.
|
||||
|
||||
### 6.2 Primärfokus — Technik / Kumite-Beinarbeit (`focus:kumite` o. ä.)
|
||||
|
||||
**planning_lens:** Curriculum für eine Technik oder Kumite-Teilaspekt; aufeinander aufbauende Belastung und Anwendungsnähe sind erwünscht.
|
||||
|
||||
**qa_criteria:** Kohärente Progression Grundlagen → Anwendung → Vertiefung; Übergänge ohne Sprünge; themenfremde Kraft-/Ausdauer-Inseln abwerten.
|
||||
|
||||
### 6.3 Trainingsstil — Breitensport (`training_type:breitensport` o. Name-Match)
|
||||
|
||||
**planning_lens:** Partizipation, Verständlichkeit, Freude am Bewegen; weniger maximale Spezialisierung.
|
||||
|
||||
**qa_criteria:** Hohe OK-Rate bei moderatem Schwierigkeitsanstieg; „Perfektion“-Stufen nur optional, nicht als Pflicht-Lücke.
|
||||
|
||||
**rematch_guard:** Keine leeren Slots erzwingen, nur um eine Leistungs-Perfektionsstufe zu füllen.
|
||||
|
||||
### 6.4 Zielgruppe — Leistungsgruppe (`target_group:leistungsgruppe`)
|
||||
|
||||
**qa_criteria:** Höhere Anspruchskurven, Belastungs- und Kombinationsprogressionen sind relevant; Lücken in Spezialisierung können echte Hinweise sein.
|
||||
|
||||
*(Weitere Snippets iterativ ergänzen — nicht alle Katalog-Zeilen sofort.)*
|
||||
| Modul | Rolle |
|
||||
|-------|--------|
|
||||
| `catalog_prompt_slots.py` | Slot-Typen, DB-Lese/Schreib, Resolver, Aggregat-Block |
|
||||
| `planning_prompt_variables.py` | Zentrale Provider-Liste (erweiterbar) |
|
||||
| `planning_catalog_prompt_snippets.py` | Deprecated Re-Exports (Tests/Kompatibilität) |
|
||||
|
||||
---
|
||||
|
||||
## 7. Rollout-Phasen
|
||||
## 8. Admin-API
|
||||
|
||||
### H1 — Minimal viable (Progressionsgraph)
|
||||
| Methode | Pfad | Beschreibung |
|
||||
|---------|------|--------------|
|
||||
| GET | `/api/catalog-prompt-slot-types` | Slot-Typ-Register |
|
||||
| GET | `/api/catalog-prompt-slots/{kind}/{catalog_id}` | Alle Slots eines Eintrags |
|
||||
| PUT | `/api/catalog-prompt-slots/{kind}/{catalog_id}` | Slots upserten (Admin) |
|
||||
|
||||
- [ ] Modul `planning_catalog_prompt_snippets.py` + Registry (5–8 Snippets: 2–3 Foki, 2 Trainingsstile, 2 Zielgruppen)
|
||||
- [ ] Einbindung in **`planning_exercise_path_qa`** + **`planning_progression_roadmap`** + **`planning_progression_stage_spec`**
|
||||
- [ ] Migration Prompt-Templates: Abschnitt `{{#has_catalog_guidance}}…{{/has_catalog_guidance}}`
|
||||
- [ ] Tests: gleicher Pfad + unterschiedlicher Katalog → unterschiedlicher `catalog_guidance_block`; Snapshot QA-Variablen
|
||||
- [ ] Dev-Regression: Gewaltschutz, Breitensport, Kinder — **Hinweistexte** müssen zum Kontext passen (nicht Mae-Geri-Kriterien)
|
||||
`kind`: `focus_area` · `training_type` · `target_group` · `style_direction`
|
||||
|
||||
**Später:** UI-Tabs am Katalog-Editor; Versionierung/Audit wie `ai_prompts`.
|
||||
|
||||
---
|
||||
|
||||
## 9. Rollout-Phasen
|
||||
|
||||
### H1 — Bootstrap (0.8.234) ✓
|
||||
|
||||
Hardcodierte `SNIPPET_REGISTRY` — Proof of Concept für `catalog_guidance_block`.
|
||||
|
||||
### H2 — Slot-Modell (0.8.235) ✓
|
||||
|
||||
- [x] Tabellen `catalog_prompt_slot_types`, `catalog_prompt_slots`
|
||||
- [x] Seed aus H1-Texten (Name-Match auf Stammdaten)
|
||||
- [x] Resolver mit granularen Platzhaltern + Aggregat
|
||||
- [x] Admin-API GET/PUT
|
||||
- [x] `SNIPPET_REGISTRY` aus Laufzeit-Pfad entfernt
|
||||
|
||||
### H2.1 — Admin-UI
|
||||
|
||||
- [x] Slot-Editor an Fokusbereich / Trainingsstil / Zielgruppe / Stilrichtung (`CatalogPromptSlotsEditor`, Stammdaten-Katalog)
|
||||
- [x] Prompt-Templates mit granularen Platzhaltern (Migration 093)
|
||||
- [ ] Platzhalter-Hilfe im KI-Prompt-Editor (erweitert)
|
||||
|
||||
### H1.5
|
||||
|
||||
- [ ] Rematch/Refine: `rematch_guard` aus Snippets respektieren (weniger Ping-Pong bei Breitensport)
|
||||
- [ ] Intent-Prompts + Gap-Fill-Kontext
|
||||
- [ ] `rematch_guard` im Rematch-Loop
|
||||
- [ ] Intent-Prompts + Gap-Fill: `hints_on_exercise`
|
||||
|
||||
### H2 — Betrieb
|
||||
### H3 — Trainingsplanung (Phase G)
|
||||
|
||||
- [ ] Snippets in DB, Admin-UI oder Markdown-Import
|
||||
- [ ] Versionierung / Audit wie `ai_prompts`
|
||||
|
||||
### H3 — Phase G (Trainingsplanung)
|
||||
|
||||
- [ ] Gleicher Builder, anderer Orchestrator (Abschnitts-QS, Slot-Suggest)
|
||||
- [ ] Gleicher Resolver, andere Orchestratoren
|
||||
|
||||
---
|
||||
|
||||
## 8. Tests & Akzeptanz
|
||||
## 10. Tests & Akzeptanz
|
||||
|
||||
| Test | Erwartung |
|
||||
|------|-----------|
|
||||
| `test_catalog_prompt_snippets_priority` | Bei Konflikt gewinnt Fokus-Snippet-Text in `_block`-Reihenfolge |
|
||||
| `test_path_qa_variables_include_guidance` | Mit Gewaltschutz-Kontext enthält gerendeter Prompt „Deeskalation“ o. ä., nicht „Kumite-Perfektion“ |
|
||||
| `test_path_qa_no_snippet_without_catalog` | Ohne Katalog: `has_catalog_guidance=false`, Prompt unverändert wie heute |
|
||||
| Manuell | Mae-Geri-Pfad + Breitensport-Kontext: QS-Highlights ohne Leistungs-Belastungs-Forderungen |
|
||||
|
||||
**Nicht Ziel von H1:** Retrieval-Gewichte neu kalibrieren; Technik-Tuples externalisieren (separates Backlog **H** in Roadmap).
|
||||
| Slot aus DB | Gewaltschutz + `hints_on_path_qa` → Platzhalter enthält Deeskalation |
|
||||
| Ohne Katalog | Alle `{{focus_area_*}}` leer; `has_catalog_guidance` leer |
|
||||
| Neuer Eintrag | Leere Slots, kein Crash; `description`-Fallback aus Stammdaten |
|
||||
| Priorität | Aggregat: Primärfokus vor Trainingsstil |
|
||||
|
||||
---
|
||||
|
||||
## 9. Abgrenzung zu anderen Fixes
|
||||
## 11. Abgrenzung
|
||||
|
||||
| Thema | Dokument / Fix |
|
||||
|-------|----------------|
|
||||
| 88 % vs. 65 % falsche Pipeline | Evaluate-only für Pfad-QS; fairer Compare — Code-Stand 2026-05-22 |
|
||||
| Triviale ID-Tausche im Dialog | `_filter_trivial_slot_diffs` |
|
||||
| Katalog nur im Retrieval | F13 — bleibt, Snippets ergänzen LLM-Schicht |
|
||||
|
||||
Snippets lösen **fachliche Fehlbewertung** — nicht Pipeline-Inkonsistenz allein.
|
||||
| Thema | Hinweis |
|
||||
|-------|---------|
|
||||
| Retrieval-Gewichte | `merge_catalog_context_into_target` — unverändert |
|
||||
| Technik-Gates | `planning_exercise_semantics` — unverändert |
|
||||
| Prompt-Text | `ai_prompts` — editierbar, referenziert Slot-Platzhalter |
|
||||
|
||||
---
|
||||
|
||||
## 10. Changelog
|
||||
## 12. Changelog
|
||||
|
||||
| Datum | Änderung |
|
||||
|-------|----------|
|
||||
| 2026-05-22 | Erstfassung — Priorität Primärfokus → Trainingsstil → Zielgruppe → Stilrichtung; H1–H3 Rollout |
|
||||
| 2026-05-22 | **H2.1** (0.8.236): Admin-UI `CatalogPromptSlotsEditor`; Migration 093 granulare Prompt-Templates |
|
||||
| 2026-05-22 | **H2** (0.8.235): Slot-Typ-Register + `catalog_prompt_slots` DB, granulare Platzhalter, Admin-API |
|
||||
| 2026-05-22 | Konzept §4–§8: zwei Ebenen Slot-Typ vs. Slot-Wert; Platzhalter `{kind}_{slot_key}` |
|
||||
| 2026-05-22 | H1 (0.8.234): Bootstrap-Registry — durch H2 ersetzt |
|
||||
| 2026-05-22 | Erstfassung |
|
||||
|
|
|
|||
|
|
@ -89,14 +89,24 @@ Details und Module: **`PLANNING_PROGRESSION_GRAPH_KI.md`**.
|
|||
- [x] Vier Planungskontext-Dropdowns im Editor
|
||||
- [x] `progressionGraphDraft.js` — Artefakt + API-Payload
|
||||
|
||||
### F15 — Match-Dialog & getrennte Pfad-QS (2026-05-22, lokal)
|
||||
|
||||
- [x] **`unified_slot_review`** — Dialog pro Slot (Bibliothek + KI, Stufen-Fit-Vergleich)
|
||||
- [x] Vorauswahl: Bibliothek nur bei Stufen-Fit ≥ 50 %; sonst KI bei leerem/schwachem Slot
|
||||
- [x] Übernahme ohne teure Auto-Nach-Bewertung; manuell „Graph bewerten“
|
||||
- [x] **`path_qa.roadmap_qa`** + **`path_qa.assignment_qa`**; Gesamt = Minimum
|
||||
- [x] **`findings_stale`** im Graph-Artefakt — Hinweis „Bewertung veraltet“ (persistiert)
|
||||
- [x] Slot-Key-Fix — Lernziel editierbar ohne Fokusverlust
|
||||
|
||||
### Validierung (Referenz Mae Geri, 2026-05)
|
||||
|
||||
| Phase | Pfad-QS | Ergebnis |
|
||||
|-------|---------|----------|
|
||||
| Vor Roadmap/KI | ~65 % | Lücken, falsche Reihenfolge, Off-Topic |
|
||||
| Nach Trainer-Roadmap + KI-Gap-Fill | **~88 % OK** | Vollständige Abdeckung; positive LLM-Hinweise |
|
||||
| Phase | Roadmap-QS | Besetzung | Gesamt | Ergebnis |
|
||||
|-------|------------|-----------|--------|----------|
|
||||
| Vor Roadmap/KI | — | — | ~65 % | Lücken, Off-Topic |
|
||||
| Roadmap ok, Slots leer | ~88 % | ~8–15 % | **~8–15 %** | Besetzung fehlt |
|
||||
| Nach Match + Fill | ~88 % | hoch | **~85 %+** | Vollständige Abdeckung |
|
||||
|
||||
**Fazit:** Workbench + Katalog + Roadmap sind universell; Technik-Hardcoding allein reicht für Didaktik nicht.
|
||||
**Fazit:** Roadmap-QS und Besetzungs-QS getrennt betrachten; Workbench + Katalog + Roadmap universell.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -157,6 +157,10 @@ flowchart TB
|
|||
| `auto_refine_stage_spec` | bool | Stufen-Spec bei `stage_mismatch` schärfen (Default **true**) |
|
||||
| `max_rematch_rounds` | int | Rematch-Runden 0–4 (Default **3**) |
|
||||
| `include_path_qa`, `include_llm_path_qa`, `include_ai_gap_fill` | bool | QS, LLM-Ganzpfad, Lücken-Angebote |
|
||||
| `evaluate_only` | bool | Nur QS auf `evaluate_steps[]` — kein Match |
|
||||
| `unified_slot_review` | bool | Pro-Slot-Review (Bibliothek + optional KI) für Match-Dialog; erfordert `baseline_evaluate_steps` + Roadmap |
|
||||
| `baseline_evaluate_steps` | array? | Slot-Stand für Schritt 1 / Review-Baseline |
|
||||
| `baseline_path_qa_snapshot` | object? | `path_qa` aus evaluate_only (Schritt 1 des Match-Flows) |
|
||||
|
||||
### 4.2 Wichtige Response-Felder
|
||||
|
||||
|
|
@ -166,7 +170,9 @@ flowchart TB
|
|||
| `steps[]` | Gematchte Übungen; pro Schritt u. a. `roadmap_*`, `skill_expectations` |
|
||||
| `path_skill_expectations` | Pfadweite Skill-Erwartungen |
|
||||
| `gap_fill_offers[]` | Lücken mit `context_preview`, `goal_for_ai` |
|
||||
| `path_qa` | QS inkl. `qa_tiers`, `optimization_hints`, `rematch_log`, `refine_log` |
|
||||
| `path_qa` | QS inkl. `qa_tiers`, `optimization_hints`, `rematch_log`, `refine_log`; **F15:** auch `roadmap_qa`, `assignment_qa` (siehe §8.1) |
|
||||
| `slot_reviews[]` | Bei `unified_slot_review`: je Slot `library_alternative`, `ai_alternative`, `auto_select`-Flags |
|
||||
| `findings_stale` | Im Graph-Artefakt (nicht API-Response): Bewertung veraltet seit letztem „Graph bewerten“ |
|
||||
| `target_profile_summary` | Erwartungsprofil inkl. Katalog-Dimensionen (nach Match) |
|
||||
| `match_summary` | `library_matches`, `gap_fill_offer_count`, `roadmap_unfilled_count` |
|
||||
|
||||
|
|
@ -221,12 +227,13 @@ Tests: `test_planning_roadmap_stage_match.py`, `test_planning_path_rematch.py`,
|
|||
|
||||
### Referenz-Validierung (Mae Geri, 2026-05)
|
||||
|
||||
| Phase | Pfad-QS | Ergebnis |
|
||||
|-------|---------|----------|
|
||||
| Vor Roadmap/KI-Anpassung | ~65 % | Strukturelle Lücken (Grundlagen, Reihenfolge, Zielgenauigkeit) |
|
||||
| Nach Trainer-Roadmap + KI-Angebote in leeren Slots | **~88 % OK** | Vollständige Curriculum-Abdeckung; positive LLM-Empfehlungen |
|
||||
| Phase | Roadmap-QS | Besetzung | Gesamt (min) | Ergebnis |
|
||||
|-------|------------|-----------|--------------|----------|
|
||||
| Vor Roadmap/KI-Anpassung | — | — | ~65 % | Strukturelle Lücken |
|
||||
| Nach Trainer-Roadmap, **Slots leer** | ~85–88 % | ~8–15 % | **~8–15 %** | Roadmap ok, Besetzung fehlt |
|
||||
| Nach Match + befüllte Slots | ~85–88 % | hoch | **~85 %+** | Vollständige Curriculum-Abdeckung |
|
||||
|
||||
**Lesson:** Workbench + Katalog-Kontext + Roadmap sind der Hebel; Technik-Hardcoding allein reicht nicht für Didaktik.
|
||||
**Lesson:** **`roadmap_qa`** und **`assignment_qa`** getrennt interpretieren; Gesamt-QS allein bei leerer Roadmap irreführend (historisch nur LLM-Roadmap-Score).
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -330,6 +337,21 @@ API: `path_qa.qa_tiers`, `path_qa.optimization_hints` — **kein** anfrage-spezi
|
|||
|
||||
Phase G: gleiche Tiers für Abschnitts-QS nach Einheits-Match.
|
||||
|
||||
### 8.1 Getrennte Pfad-QS — Roadmap vs. Übungsbesetzung (F15)
|
||||
|
||||
`build_path_qa_summary()` in `planning_exercise_path_qa.py` liefert drei Ebenen:
|
||||
|
||||
| Feld | Inhalt | Score-Logik |
|
||||
|------|--------|-------------|
|
||||
| **`roadmap_qa`** | Stufenlogik, LLM `topic_coverage`, Roadmap-Hinweise | LLM-`quality_score` oder heuristisch (Lücken, Hints) |
|
||||
| **`assignment_qa`** | Leere Slots, Off-Topic auf belegten Slots, Fill-Statistik | Stark abwertend bei leeren Slots (~8–15 % bei 100 % leer) |
|
||||
| **`quality_score`** (gesamt) | Anzeige „Pfad-QS gesamt“ | **`min(roadmap_qa, assignment_qa)`** |
|
||||
| **`overall_ok`** | Gesamt-OK | Beide Dimensionen müssen OK sein |
|
||||
|
||||
UI: **`ProgressionFindingsPanel`** — zwei Unterblöcke; Match-Dialog zeigt Roadmap- vs. Besetzungs-Prozent. Nach Graph-Änderung: **`findings_stale: true`** im Artefakt → Hinweis „Bewertung veraltet“ (bis erneut „Graph bewerten“ + Speichern).
|
||||
|
||||
Tests: `test_planning_path_qa_split.py`, `test_planning_deterministic_quality_score.py`
|
||||
|
||||
## 9. Fähigkeiten-Scoring-Anbindung
|
||||
|
||||
Modul: `planning_skill_expectations.py`
|
||||
|
|
@ -379,6 +401,7 @@ Kontext-Helfer: `frontend/src/utils/planningContextForExerciseAi.js`
|
|||
| **F12** | Post-Match-Gate, LLM-QA nach Rematch, Gap-Timing, `roadmap_unfilled`-Sync | ✅ | 0.8.231–0.8.232 |
|
||||
| **F13** | **Katalog-Kontext** (`planning_catalog_context`) im Match + Graph-Artefakt | ✅ | **0.8.233** |
|
||||
| **F14** | `ProgressionGraphEditor` Slot-UI + Planungskontext-Dropdowns | ✅ | 0.8.233 |
|
||||
| **F15** | Unified Slot-Review, getrennte Pfad-QS, `findings_stale`, Match-Vorauswahl | ✅ | lokal (2026-05-22) |
|
||||
| **H1** | **Katalog-Prompt-Snippets** — modulare LLM-Anweisungen pro Dimension | 🔲 | Spec **`PLANNING_CATALOG_PROMPT_SNIPPETS.md`** |
|
||||
| **G** | Trainingsplanung: eigene Pipeline + Wiederverwendung Bausteine (§16) | 🔲 | — |
|
||||
| **UX** | Wizard/Stepper; PathBuilder-Parität Katalog | 🔲 | — |
|
||||
|
|
@ -391,8 +414,7 @@ Kontext-Helfer: `frontend/src/utils/planningContextForExerciseAi.js`
|
|||
|
||||
1. **H1 Katalog-Prompt-Snippets** — modulare LLM-Anweisungen (Priorität: Primärfokus → Trainingsstil → Zielgruppe → Stilrichtung) — **`PLANNING_CATALOG_PROMPT_SNIPPETS.md`**
|
||||
2. **Dev-Regression:** Gewaltschutz + Breitensport + Kinder (ohne Mae Geri) — Katalog-Match verifizieren
|
||||
2. **PathBuilder-Parität** — gleiche `planning_catalog_context`-Dropdowns in `ExerciseProgressionPathBuilder`
|
||||
3. **QS-UI** — positive LLM-Hinweise als „Highlights“, nicht als „Optimierungspotenziale“
|
||||
3. **PathBuilder-Parität** — gleiche `planning_catalog_context`-Dropdowns in `ExerciseProgressionPathBuilder`
|
||||
4. **UI-Wizard** — 4 Schritte (Ziel → Roadmap → Match → Lücken); Backend unverändert
|
||||
5. **Graph-Erweiterungsmodus** — Start ab gewähltem Knoten / letzter Sequenz
|
||||
6. **Phase D′** — automatisches KI-Gap-Fill bei persistent leeren Slots
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ import { Link, useLocation } from 'react-router-dom'
|
|||
import api from '../utils/api'
|
||||
import SkillProfilePanel from './skills/SkillProfilePanel'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import { activeClubMemberships, getTenantClubDependencyKey } from '../utils/activeClub'
|
||||
import { activeClubMemberships, getDefaultClubIdForGovernanceForms, getTenantClubDependencyKey } from '../utils/activeClub'
|
||||
import ProgressionGraphEditor from './ProgressionGraphEditor'
|
||||
import ProgressionGraphListCard from './ProgressionGraphListCard'
|
||||
import { EXERCISE_VISIBILITY_FIELD_LABEL } from '../constants/exerciseGovernanceLabels'
|
||||
|
|
@ -24,6 +24,21 @@ const VIS_OPTIONS = [
|
|||
{ value: 'official', label: 'Offiziell' },
|
||||
]
|
||||
|
||||
const GRAPH_VISIBILITY_PROMOTION_LABEL = {
|
||||
club: 'Vereins-Sichtbarkeit',
|
||||
official: 'offizielle Sichtbarkeit',
|
||||
}
|
||||
|
||||
/** Graph-Promotion mit optionalem Übungs-Anheben (private→club/official, club→official). */
|
||||
function shouldPromptGraphExercisePromotion(prevVis, nextVis) {
|
||||
const p = (prevVis || 'private').trim().toLowerCase()
|
||||
const n = (nextVis || 'private').trim().toLowerCase()
|
||||
return (
|
||||
(p === 'private' && (n === 'club' || n === 'official')) ||
|
||||
(p === 'club' && n === 'official')
|
||||
)
|
||||
}
|
||||
|
||||
function edgeTypeLabel(type) {
|
||||
if (type === 'next_exercise') return 'Nachfolger'
|
||||
if (type === 'sibling') return 'Schwester'
|
||||
|
|
@ -41,7 +56,9 @@ function ExerciseProgressionGraphPanel(
|
|||
const { user } = useAuth()
|
||||
const location = useLocation()
|
||||
const isSuperadmin = user?.role === 'superadmin'
|
||||
const memberClubs = useMemo(() => activeClubMemberships(user?.clubs), [user?.clubs])
|
||||
const tenantClubDepKey = useMemo(() => getTenantClubDependencyKey(user), [user])
|
||||
const [clubsForGovernanceForms, setClubsForGovernanceForms] = useState([])
|
||||
|
||||
const filteredGraphVisOptions = useMemo(
|
||||
() => VIS_OPTIONS.filter((o) => o.value !== 'official' || isSuperadmin),
|
||||
|
|
@ -61,6 +78,37 @@ function ExerciseProgressionGraphPanel(
|
|||
const [metaName, setMetaName] = useState('')
|
||||
const [metaDescription, setMetaDescription] = useState('')
|
||||
const [metaVisibility, setMetaVisibility] = useState('private')
|
||||
const [metaClubSelect, setMetaClubSelect] = useState('')
|
||||
|
||||
const memberClubIdSet = useMemo(
|
||||
() => new Set(memberClubs.map((c) => Number(c.id))),
|
||||
[memberClubs],
|
||||
)
|
||||
|
||||
const sortedMemberClubs = useMemo(
|
||||
() =>
|
||||
[...memberClubs].sort((a, b) =>
|
||||
String(a.name || '').localeCompare(String(b.name || ''), 'de'),
|
||||
),
|
||||
[memberClubs],
|
||||
)
|
||||
|
||||
const sortedOtherGovernanceClubs = useMemo(() => {
|
||||
if (!isSuperadmin || clubsForGovernanceForms.length === 0) return []
|
||||
return clubsForGovernanceForms
|
||||
.filter((c) => !memberClubIdSet.has(Number(c.id)))
|
||||
.sort((a, b) => String(a.name || '').localeCompare(String(b.name || ''), 'de'))
|
||||
}, [isSuperadmin, clubsForGovernanceForms, memberClubIdSet])
|
||||
|
||||
const showGovernanceClubOptgroups =
|
||||
isSuperadmin && sortedMemberClubs.length > 0 && sortedOtherGovernanceClubs.length > 0
|
||||
|
||||
const governanceClubSelectOptions = useMemo(() => {
|
||||
if (isSuperadmin && clubsForGovernanceForms.length > 0) {
|
||||
return [...sortedMemberClubs, ...sortedOtherGovernanceClubs]
|
||||
}
|
||||
return sortedMemberClubs
|
||||
}, [isSuperadmin, clubsForGovernanceForms.length, sortedMemberClubs, sortedOtherGovernanceClubs])
|
||||
|
||||
const [filterAnchorOnly, setFilterAnchorOnly] = useState(!!anchorExerciseId)
|
||||
const [editingEdgeNotes, setEditingEdgeNotes] = useState(null)
|
||||
|
|
@ -125,6 +173,25 @@ function ExerciseProgressionGraphPanel(
|
|||
}
|
||||
}, [refreshGraphs, tenantClubDepKey])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isSuperadmin) {
|
||||
setClubsForGovernanceForms([])
|
||||
return undefined
|
||||
}
|
||||
let cancelled = false
|
||||
;(async () => {
|
||||
try {
|
||||
const list = await api.listClubs()
|
||||
if (!cancelled) setClubsForGovernanceForms(Array.isArray(list) ? list : [])
|
||||
} catch {
|
||||
if (!cancelled) setClubsForGovernanceForms([])
|
||||
}
|
||||
})()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [isSuperadmin, tenantClubDepKey])
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedGraphId) {
|
||||
setSkillProfileData(null)
|
||||
|
|
@ -157,6 +224,7 @@ function ExerciseProgressionGraphPanel(
|
|||
setMetaName('')
|
||||
setMetaDescription('')
|
||||
setMetaVisibility('private')
|
||||
setMetaClubSelect('')
|
||||
return
|
||||
}
|
||||
const g = graphs.find((x) => x.id === selectedGraphId)
|
||||
|
|
@ -164,6 +232,12 @@ function ExerciseProgressionGraphPanel(
|
|||
setMetaName(g.name || '')
|
||||
setMetaDescription(g.description || '')
|
||||
setMetaVisibility(g.visibility || 'private')
|
||||
if (g.club_id != null) {
|
||||
setMetaClubSelect(String(g.club_id))
|
||||
} else {
|
||||
const fallback = getDefaultClubIdForGovernanceForms(user)
|
||||
setMetaClubSelect(fallback != null ? String(fallback) : '')
|
||||
}
|
||||
}
|
||||
let cancelled = false
|
||||
;(async () => {
|
||||
|
|
@ -176,7 +250,17 @@ function ExerciseProgressionGraphPanel(
|
|||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [selectedGraphId, graphs, refreshEdges])
|
||||
}, [selectedGraphId, graphs, refreshEdges, user])
|
||||
|
||||
const resolveGovernanceClubId = useCallback(() => {
|
||||
const g = graphs.find((x) => x.id === selectedGraphId)
|
||||
if (g?.club_id != null) return Number(g.club_id)
|
||||
|
||||
const sel = String(metaClubSelect || '').trim()
|
||||
if (sel && /^\d+$/.test(sel)) return Number(sel)
|
||||
|
||||
return getDefaultClubIdForGovernanceForms(user)
|
||||
}, [graphs, selectedGraphId, metaClubSelect, user])
|
||||
|
||||
const filteredEdges = useMemo(() => {
|
||||
if (!filterAnchorOnly || anchorExerciseId == null) return edges
|
||||
|
|
@ -226,13 +310,7 @@ function ExerciseProgressionGraphPanel(
|
|||
}
|
||||
}
|
||||
|
||||
const resolvePromoteClubId = () => {
|
||||
const g = graphs.find((x) => x.id === selectedGraphId)
|
||||
if (g?.club_id != null) return Number(g.club_id)
|
||||
const memberships = activeClubMemberships(user?.clubs)
|
||||
const active = memberships.find((c) => c.is_active) || memberships[0]
|
||||
return active?.club_id != null ? Number(active.club_id) : null
|
||||
}
|
||||
const resolvePromoteClubId = resolveGovernanceClubId
|
||||
|
||||
const handleSaveMeta = async () => {
|
||||
if (!selectedGraphId) return
|
||||
|
|
@ -247,35 +325,39 @@ function ExerciseProgressionGraphPanel(
|
|||
|
||||
setBusy(true)
|
||||
try {
|
||||
if (prevVis === 'private' && nextVis === 'club') {
|
||||
if (shouldPromptGraphExercisePromotion(prevVis, nextVis)) {
|
||||
const preview = await api.getProgressionGraphVisibilityPromotionCandidates(
|
||||
selectedGraphId,
|
||||
{ targetVisibility: 'club' },
|
||||
{ targetVisibility: nextVis },
|
||||
)
|
||||
const privateExercises = Array.isArray(preview?.exercises) ? preview.exercises : []
|
||||
if (privateExercises.length > 0) {
|
||||
const titles = privateExercises
|
||||
const promotionExercises = Array.isArray(preview?.exercises) ? preview.exercises : []
|
||||
if (promotionExercises.length > 0) {
|
||||
const visLabel = GRAPH_VISIBILITY_PROMOTION_LABEL[nextVis] || nextVis
|
||||
const titles = promotionExercises
|
||||
.slice(0, 8)
|
||||
.map((ex) => `• ${ex.title || `Übung #${ex.id}`}`)
|
||||
.join('\n')
|
||||
const more =
|
||||
privateExercises.length > 8
|
||||
? `\n… und ${privateExercises.length - 8} weitere`
|
||||
promotionExercises.length > 8
|
||||
? `\n… und ${promotionExercises.length - 8} weitere`
|
||||
: ''
|
||||
const promote = window.confirm(
|
||||
`Der Graph wird auf „Verein“ gestellt. Im Graph sind noch ${privateExercises.length} private Übung(en):\n\n${titles}${more}\n\nDiese Übungen ebenfalls auf Vereins-Sichtbarkeit anheben?`,
|
||||
`Der Graph wird auf „${visLabel}“ gestellt. Im Graph sind noch ${promotionExercises.length} Übung(en) mit niedrigerer Sichtbarkeit:\n\n${titles}${more}\n\nDiese Übungen ebenfalls auf ${visLabel} anheben?`,
|
||||
)
|
||||
if (promote) {
|
||||
const clubId = resolvePromoteClubId()
|
||||
let clubId = null
|
||||
if (nextVis === 'club') {
|
||||
clubId = resolvePromoteClubId()
|
||||
if (!clubId) {
|
||||
alert('Kein aktiver Verein — Übungen können nicht auf Verein promoted werden.')
|
||||
} else {
|
||||
const ids = privateExercises.map((ex) => ex.id).filter((id) => id != null)
|
||||
const res = await api.bulkPatchExercisesMetadata({
|
||||
exercise_ids: ids,
|
||||
visibility: 'club',
|
||||
club_id: clubId,
|
||||
})
|
||||
throw new Error(
|
||||
'Kein Verein gewählt — bitte unter „Verein zuordnen“ einen Verein auswählen oder den Vereins-Umschalter nutzen.',
|
||||
)
|
||||
}
|
||||
}
|
||||
const ids = promotionExercises.map((ex) => ex.id).filter((id) => id != null)
|
||||
const bulkPayload = { exercise_ids: ids, visibility: nextVis }
|
||||
if (nextVis === 'club' && clubId != null) bulkPayload.club_id = clubId
|
||||
const res = await api.bulkPatchExercisesMetadata(bulkPayload)
|
||||
if ((res?.failed || []).length) {
|
||||
const f = res.failed[0]
|
||||
throw new Error(f?.detail || 'Übungs-Promotion fehlgeschlagen')
|
||||
|
|
@ -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, {
|
||||
name,
|
||||
description: metaDescription.trim() || null,
|
||||
visibility: metaVisibility,
|
||||
...(promoteClubId != null ? { club_id: promoteClubId } : {}),
|
||||
})
|
||||
await refreshGraphs()
|
||||
alert('Graph-Metadaten gespeichert.')
|
||||
|
|
@ -539,7 +627,14 @@ function ExerciseProgressionGraphPanel(
|
|||
<select
|
||||
className="form-input"
|
||||
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) => (
|
||||
<option key={o.value} value={o.value}>
|
||||
|
|
@ -548,6 +643,42 @@ function ExerciseProgressionGraphPanel(
|
|||
))}
|
||||
</select>
|
||||
</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' }}>
|
||||
<button type="button" className="btn btn-secondary" disabled={busy} onClick={handleSaveMeta}>
|
||||
Metadaten speichern
|
||||
|
|
|
|||
|
|
@ -589,6 +589,7 @@ export default function ExerciseProgressionPathBuilder({
|
|||
const [gapPrepFocusAreaId, setGapPrepFocusAreaId] = useState('')
|
||||
const [gapPrepError, setGapPrepError] = useState('')
|
||||
const [loadedPlanningHint, setLoadedPlanningHint] = useState(false)
|
||||
const [graphGovernance, setGraphGovernance] = useState({ visibility: 'private', clubId: null })
|
||||
const [wizardStep, setWizardStep] = useState(1)
|
||||
const [pathInsertNotice, setPathInsertNotice] = useState('')
|
||||
|
||||
|
|
@ -670,6 +671,10 @@ export default function ExerciseProgressionPathBuilder({
|
|||
.getExerciseProgressionGraph(Number(graphId))
|
||||
.then((g) => {
|
||||
if (cancelled) return
|
||||
setGraphGovernance({
|
||||
visibility: g?.visibility || 'private',
|
||||
clubId: g?.club_id ?? null,
|
||||
})
|
||||
const art = g?.planning_roadmap
|
||||
if (!art) return
|
||||
if (art.goal_query) setGoalQuery(String(art.goal_query))
|
||||
|
|
@ -1056,7 +1061,7 @@ export default function ExerciseProgressionPathBuilder({
|
|||
setQuickSaving(true)
|
||||
setQuickAiError('')
|
||||
try {
|
||||
const payload = buildQuickCreateExercisePayloadFromDraft(quickCreateDraft)
|
||||
const payload = buildQuickCreateExercisePayloadFromDraft(quickCreateDraft, graphGovernance)
|
||||
const created = await api.createExercise(payload)
|
||||
if (!created?.id) throw new Error('Anlegen fehlgeschlagen')
|
||||
insertExerciseFromOffer(created, activeOffer)
|
||||
|
|
|
|||
|
|
@ -11,6 +11,8 @@ import {
|
|||
formatRefineLogEntry,
|
||||
hasRematchSlotHints,
|
||||
pathQaQualityPercent,
|
||||
pathQaHasSplitDimensions,
|
||||
pathQaSubsectionPercent,
|
||||
pathQaShowsStrongResult,
|
||||
resolveHintSlotIndex,
|
||||
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 }) {
|
||||
const { fixHints: optimizationHints } = useMemo(
|
||||
() => splitPathQaHints(pathQa),
|
||||
|
|
@ -310,6 +352,9 @@ export default function ProgressionFindingsPanel({
|
|||
&& (canOptimizeCompare || pathQa?.assignments_preserved || showRematchAction)
|
||||
const qualityPct = pathQaQualityPercent(pathQa)
|
||||
const strongResult = pathQaShowsStrongResult(pathQa)
|
||||
const hasSplitQa = pathQaHasSplitDimensions(pathQa)
|
||||
const roadmapQa = pathQa?.roadmap_qa || null
|
||||
const assignmentQa = pathQa?.assignment_qa || null
|
||||
|
||||
return (
|
||||
<div className="card" style={{ position: 'sticky', top: '12px' }}>
|
||||
|
|
@ -364,9 +409,14 @@ export default function ProgressionFindingsPanel({
|
|||
}}
|
||||
>
|
||||
<strong>
|
||||
Pfad-QS: {pathQa.overall_ok ? 'OK' : 'Hinweise'}
|
||||
Pfad-QS gesamt: {pathQa.overall_ok ? 'OK' : 'Hinweise'}
|
||||
{qualityPct != null ? ` (${qualityPct} %)` : ''}
|
||||
</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 ? (
|
||||
<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
|
||||
|
|
@ -378,7 +428,23 @@ export default function ProgressionFindingsPanel({
|
|||
Starker Pfad — KI-Empfehlungen unten können Feinschliff oder optionale Vertiefung sein.
|
||||
</p>
|
||||
) : 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>
|
||||
) : null}
|
||||
{highlightTexts.length > 0 ? (
|
||||
|
|
@ -415,7 +481,7 @@ export default function ProgressionFindingsPanel({
|
|||
</ul>
|
||||
</>
|
||||
) : 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)' }}>
|
||||
{pathQa.issues.map((issue) => (
|
||||
<li key={issue}>{issue}</li>
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@
|
|||
import React, { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import api from '../utils/api'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import { getDefaultClubIdForGovernanceForms } from '../utils/activeClub'
|
||||
import ExercisePickerModal from './ExercisePickerModal'
|
||||
import ExerciseGapFillPrepModal from './exercises/ExerciseGapFillPrepModal'
|
||||
import ProgressionSlotCard from './ProgressionSlotCard'
|
||||
|
|
@ -37,7 +39,6 @@ import {
|
|||
compareDiffsForDialog,
|
||||
dedupeGapOffersBySlot,
|
||||
draftHasLibrarySlotAssignments,
|
||||
draftRetrievalBoostExerciseIds,
|
||||
EMPTY_PLANNING_CATALOG_CONTEXT,
|
||||
filterGapOffersForUnfilledSlots,
|
||||
hydrateProgressionGraphDraft,
|
||||
|
|
@ -85,6 +86,7 @@ function resolveDefaultFocusAreaId(targetSummary, focusAreas) {
|
|||
}
|
||||
|
||||
export default function ProgressionGraphEditor({ graphId, embedded = false, onSaved }) {
|
||||
const { user } = useAuth()
|
||||
const [graphMeta, setGraphMeta] = useState(null)
|
||||
const [draft, setDraft] = useState(null)
|
||||
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' } = {}) => {
|
||||
setMatchNotice('Schritt 1/2: Pfad bewerten (wie „Graph bewerten“)…')
|
||||
const baselineRes = await fetchPathEvaluate(synced)
|
||||
|
|
@ -880,7 +860,17 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa
|
|||
setSlotQuickSaving(true)
|
||||
setSlotQuickError('')
|
||||
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)
|
||||
if (!created?.id) throw new Error('Anlegen fehlgeschlagen')
|
||||
setDraft((prev) => ({
|
||||
|
|
@ -1149,7 +1139,7 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa
|
|||
|
||||
{draft.slots.map((slot, idx) => (
|
||||
<ProgressionSlotCard
|
||||
key={`slot-${idx}-${slot.learning_goal?.slice(0, 12) || 'x'}`}
|
||||
key={`slot-${idx}`}
|
||||
slot={slot}
|
||||
slotIndex={idx}
|
||||
slotCount={draft.slots.length}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,9 @@ import FormModalOverlay from './FormModalOverlay'
|
|||
import {
|
||||
compareSlotReviews,
|
||||
defaultSelectedCompareDiffs,
|
||||
pathQaHasSplitDimensions,
|
||||
pathQaQualityPercent,
|
||||
pathQaSubsectionPercent,
|
||||
qualityDeltaPercent,
|
||||
rejectedCompareDiffs,
|
||||
slotFitScorePercent,
|
||||
|
|
@ -189,6 +191,9 @@ function SlotReviewRow({ review, selected, onToggle, applying }) {
|
|||
<strong>KI-Vorschlag nutzen</strong>
|
||||
<span style={{ display: 'block', fontSize: '11px', color: 'var(--text3)', marginTop: '2px' }}>
|
||||
{ai.title_hint || 'Neue Übung per KI entwerfen'}
|
||||
{ai.auto_select
|
||||
? ' — empfohlen, Bibliothek passt nicht ausreichend zum Stufen-Ziel'
|
||||
: ''}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
|
|
@ -223,6 +228,9 @@ export default function ProgressionOptimizeCompareModal({
|
|||
|
||||
const baselineQa = comparison.baseline_path_qa
|
||||
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 reviewError = comparison.review_error || null
|
||||
|
||||
|
|
@ -258,8 +266,9 @@ export default function ProgressionOptimizeCompareModal({
|
|||
{title}
|
||||
</h3>
|
||||
<p style={{ fontSize: '12px', color: 'var(--text3)', marginTop: 0, lineHeight: 1.45 }}>
|
||||
Je Slot: aktuelle Bewertung, beste Bibliotheks-Alternative und optional KI. Haken nur
|
||||
vorausgewählt, wenn die Alternative einen höheren Stufen-Fit hat.
|
||||
Je Slot: aktuelle Bewertung, beste Bibliotheks-Alternative und optional KI. Vorauswahl:
|
||||
Bibliothek nur bei klar besserem Stufen-Fit; bei leeren oder schwach passenden Slots eher
|
||||
KI-Vorschlag.
|
||||
</p>
|
||||
|
||||
{reviewError ? (
|
||||
|
|
@ -289,9 +298,19 @@ export default function ProgressionOptimizeCompareModal({
|
|||
>
|
||||
<strong>Dein Pfad</strong>
|
||||
<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>
|
||||
) : null}
|
||||
{hasSplitQa && baselineQa?.roadmap_qa?.topic_coverage ? (
|
||||
<p style={{ margin: '6px 0 0', color: 'var(--text2)' }}>{baselineQa.roadmap_qa.topic_coverage}</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{rejectedCount > 0 ? (
|
||||
|
|
|
|||
166
frontend/src/components/admin/CatalogPromptSlotsEditor.jsx
Normal file
166
frontend/src/components/admin/CatalogPromptSlotsEditor.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import React, { useState } from 'react'
|
||||
import { api } from '../../utils/api'
|
||||
import CatalogPromptSlotsEditor from './CatalogPromptSlotsEditor'
|
||||
|
||||
function DetailPanel({ item, onUpdate, focusAreas }) {
|
||||
const type = item._type
|
||||
|
|
@ -87,6 +88,7 @@ function FocusAreaDetail({ item, onUpdate }) {
|
|||
</button>
|
||||
<button type="button" className="btn btn-danger" onClick={handleDelete}>Löschen</button>
|
||||
</div>
|
||||
<CatalogPromptSlotsEditor catalogKind="focus_area" catalogId={item.id} entryName={form.name} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -169,6 +171,7 @@ function StyleDirectionDetail({ item, onUpdate, focusAreas }) {
|
|||
</button>
|
||||
<button type="button" className="btn btn-danger" onClick={handleDelete}>Löschen</button>
|
||||
</div>
|
||||
<CatalogPromptSlotsEditor catalogKind="style_direction" catalogId={item.id} entryName={form.name} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -251,6 +254,7 @@ function TrainingTypeDetail({ item, onUpdate, focusAreas }) {
|
|||
</button>
|
||||
<button type="button" className="btn btn-danger" onClick={handleDelete}>Löschen</button>
|
||||
</div>
|
||||
<CatalogPromptSlotsEditor catalogKind="training_type" catalogId={item.id} entryName={form.name} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,8 +31,22 @@ export default function AdminAiPromptsPage() {
|
|||
const [pvExec, setPvExec] = useState('<p>Ablauf hier</p>')
|
||||
const [pvHint, setPvHint] = 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 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 [pList, cat] = await Promise.all([
|
||||
api.listAdminAiPrompts(),
|
||||
|
|
@ -133,16 +147,24 @@ export default function AdminAiPromptsPage() {
|
|||
if (!detail?.id) return
|
||||
setError('')
|
||||
try {
|
||||
const body = {
|
||||
title: pvTitle,
|
||||
goal: pvGoal,
|
||||
execution: pvExec,
|
||||
focus_hint: pvHint || undefined,
|
||||
}
|
||||
const body = {}
|
||||
if (isPlanningPreviewSlug) {
|
||||
body.goal_query = pvGoalQuery.trim() || undefined
|
||||
body.user_notes = pvUserNotes.trim() || 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)
|
||||
if (Number.isFinite(fid) && fid >= 1) {
|
||||
body.focus_areas_context = [{ focus_area_id: fid, is_primary: true }]
|
||||
}
|
||||
}
|
||||
const r = await api.previewAdminAiPrompt(detail.id, body)
|
||||
setPvPreview(r)
|
||||
} catch (e) {
|
||||
|
|
@ -171,8 +193,8 @@ export default function AdminAiPromptsPage() {
|
|||
<h1 style={{ margin: 0, fontSize: '1.25rem' }}>KI Prompts</h1>
|
||||
</div>
|
||||
<p style={{ fontSize: '13px', color: 'var(--text3)', marginTop: 0 }}>
|
||||
Datenbankvorlagen (<code>ai_prompts</code>) für Übungs-KI. Platzhalter im Mustache-Stil werden serverseitig
|
||||
aufgelöst — die Vorschau unten ruft kein externes Modell auf.
|
||||
Datenbankvorlagen (<code>ai_prompts</code>) für Übungs- und Planungs-KI. Platzhalter im Mustache-Stil werden
|
||||
serverseitig aufgelöst — die Vorschau unten ruft kein externes Modell auf.
|
||||
</p>
|
||||
{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)' }}>
|
||||
<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 className="form-row">
|
||||
<label className="form-label">Titel</label>
|
||||
|
|
@ -328,6 +399,13 @@ export default function AdminAiPromptsPage() {
|
|||
<label className="form-label">Durchführung (HTML möglich)</label>
|
||||
<textarea className="form-input" rows={4} value={pvExec} onChange={(e) => setPvExec(e.target.value)} />
|
||||
</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()}>
|
||||
Platzhalter auflösen
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { useState, useEffect } from 'react'
|
|||
import { api } from '../utils/api'
|
||||
import AdminPageNav from '../components/AdminPageNav'
|
||||
import PageSectionNav from '../components/PageSectionNav'
|
||||
import CatalogPromptSlotsEditor from '../components/admin/CatalogPromptSlotsEditor'
|
||||
|
||||
const CATALOG_SUBTABS = [
|
||||
{ id: 'focus-areas', label: 'Fokusbereiche' },
|
||||
|
|
@ -62,6 +63,38 @@ export default function AdminCatalogsPage() {
|
|||
// M:N Assignment Matrix
|
||||
const [assignments, setAssignments] = useState([])
|
||||
const [matrixLoading, setMatrixLoading] = useState(false)
|
||||
const [openKiSlots, setOpenKiSlots] = useState(null)
|
||||
|
||||
function toggleKiSlots(kind, id) {
|
||||
const key = `${kind}:${id}`
|
||||
setOpenKiSlots((prev) => (prev === key ? null : key))
|
||||
}
|
||||
|
||||
function renderKiSlotsToggle(kind, id, label = 'KI-Planungstexte') {
|
||||
const key = `${kind}:${id}`
|
||||
const open = openKiSlots === key
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
onClick={() => toggleKiSlots(kind, id)}
|
||||
>
|
||||
{open ? 'KI-Texte ausblenden' : label}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function renderKiSlotsPanel(kind, id, entryName) {
|
||||
const key = `${kind}:${id}`
|
||||
if (openKiSlots !== key) return null
|
||||
return (
|
||||
<CatalogPromptSlotsEditor
|
||||
catalogKind={kind}
|
||||
catalogId={id}
|
||||
entryName={entryName}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadData()
|
||||
|
|
@ -75,14 +108,22 @@ export default function AdminCatalogsPage() {
|
|||
const data = await api.listFocusAreas()
|
||||
setFocusAreas(data)
|
||||
} else if (activeTab === 'training-styles') {
|
||||
const data = await api.listStyleDirections()
|
||||
const [data, areas] = await Promise.all([
|
||||
api.listStyleDirections(),
|
||||
api.listFocusAreas(),
|
||||
])
|
||||
setTrainingStyles(data)
|
||||
setFocusAreas(areas)
|
||||
} else if (activeTab === 'training-characters') {
|
||||
const data = await api.listTrainingCharacters()
|
||||
setTrainingCharacters(data)
|
||||
} else if (activeTab === 'training-types') {
|
||||
const data = await api.listTrainingTypes()
|
||||
const [data, areas] = await Promise.all([
|
||||
api.listTrainingTypes(),
|
||||
api.listFocusAreas(),
|
||||
])
|
||||
setTrainingTypes(data)
|
||||
setFocusAreas(areas)
|
||||
} else if (activeTab === 'skill-categories') {
|
||||
const data = await api.listSkillCategories()
|
||||
setSkillCategories(data)
|
||||
|
|
@ -431,6 +472,11 @@ export default function AdminCatalogsPage() {
|
|||
<button className="btn btn-primary" onClick={() => updateFocusArea(fa.id, editingFA)}>Speichern</button>
|
||||
<button className="btn" onClick={() => setEditingFA(null)}>Abbrechen</button>
|
||||
</div>
|
||||
<CatalogPromptSlotsEditor
|
||||
catalogKind="focus_area"
|
||||
catalogId={fa.id}
|
||||
entryName={editingFA.name}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
|
|
@ -449,10 +495,12 @@ export default function AdminCatalogsPage() {
|
|||
}}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
|
||||
<button className="btn" onClick={() => setEditingFA(fa)}>Bearbeiten</button>
|
||||
{renderKiSlotsToggle('focus_area', fa.id)}
|
||||
<button className="btn" onClick={() => deleteFocusArea(fa.id)}>Löschen</button>
|
||||
</div>
|
||||
{renderKiSlotsPanel('focus_area', fa.id, fa.name)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -539,6 +587,11 @@ export default function AdminCatalogsPage() {
|
|||
<button className="btn btn-primary" onClick={() => updateStyleDirection(ts.id, editingTS)}>Speichern</button>
|
||||
<button className="btn" onClick={() => setEditingTS(null)}>Abbrechen</button>
|
||||
</div>
|
||||
<CatalogPromptSlotsEditor
|
||||
catalogKind="style_direction"
|
||||
catalogId={ts.id}
|
||||
entryName={editingTS.name}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
|
|
@ -554,10 +607,12 @@ export default function AdminCatalogsPage() {
|
|||
</p>
|
||||
)}
|
||||
<p style={{ margin: '8px 0', color: 'var(--text2)' }}>{ts.description}</p>
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
|
||||
<button className="btn" onClick={() => setEditingTS(ts)}>Bearbeiten</button>
|
||||
{renderKiSlotsToggle('style_direction', ts.id)}
|
||||
<button className="btn" onClick={() => deleteStyleDirection(ts.id)}>Löschen</button>
|
||||
</div>
|
||||
{renderKiSlotsPanel('style_direction', ts.id, ts.name)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -730,6 +785,11 @@ export default function AdminCatalogsPage() {
|
|||
<button className="btn btn-primary" onClick={() => updateTrainingType(tt.id, editingTT)}>Speichern</button>
|
||||
<button className="btn" onClick={() => setEditingTT(null)}>Abbrechen</button>
|
||||
</div>
|
||||
<CatalogPromptSlotsEditor
|
||||
catalogKind="training_type"
|
||||
catalogId={tt.id}
|
||||
entryName={editingTT.name}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
|
|
@ -744,10 +804,12 @@ export default function AdminCatalogsPage() {
|
|||
<span style={{ color: 'var(--accent)' }}>{tt.focus_area_icon} {tt.focus_area_name}</span>
|
||||
</p>
|
||||
)}
|
||||
<div style={{ display: 'flex', gap: '8px', marginTop: '12px' }}>
|
||||
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap', marginTop: '12px' }}>
|
||||
<button className="btn" onClick={() => setEditingTT(tt)}>Bearbeiten</button>
|
||||
{renderKiSlotsToggle('training_type', tt.id)}
|
||||
<button className="btn" onClick={() => deleteTrainingType(tt.id)}>Löschen</button>
|
||||
</div>
|
||||
{renderKiSlotsPanel('training_type', tt.id, tt.name)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -956,6 +1018,11 @@ export default function AdminCatalogsPage() {
|
|||
<button className="btn btn-primary" onClick={() => updateTargetGroup(tg.id, tg)}>Speichern</button>
|
||||
<button className="btn" onClick={() => setEditingTG(null)}>Abbrechen</button>
|
||||
</div>
|
||||
<CatalogPromptSlotsEditor
|
||||
catalogKind="target_group"
|
||||
catalogId={tg.id}
|
||||
entryName={tg.name}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
|
|
@ -970,10 +1037,12 @@ export default function AdminCatalogsPage() {
|
|||
<p style={{ margin: '8px 0 0 0', fontSize: '14px' }}>{tg.description}</p>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
|
||||
<button className="btn" onClick={() => setEditingTG(tg.id)}>Bearbeiten</button>
|
||||
{renderKiSlotsToggle('target_group', tg.id)}
|
||||
<button className="btn" style={{ background: 'var(--danger)', color: 'white' }} onClick={() => deleteTargetGroup(tg.id)}>Löschen</button>
|
||||
</div>
|
||||
{renderKiSlotsPanel('target_group', tg.id, tg.name)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -626,6 +626,21 @@ export async function getAdminAiPromptPlaceholdersCatalog() {
|
|||
return request('/api/admin/ai-prompts/catalog/placeholders')
|
||||
}
|
||||
|
||||
export async function listCatalogPromptSlotTypes() {
|
||||
return request('/api/catalog-prompt-slot-types')
|
||||
}
|
||||
|
||||
export async function getCatalogPromptSlots(catalogKind, catalogId) {
|
||||
return request(`/api/catalog-prompt-slots/${encodeURIComponent(catalogKind)}/${catalogId}`)
|
||||
}
|
||||
|
||||
export async function updateCatalogPromptSlots(catalogKind, catalogId, data) {
|
||||
return request(`/api/catalog-prompt-slots/${encodeURIComponent(catalogKind)}/${catalogId}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data || {}),
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Reifegradmodelle / Fähigkeitsmatrix
|
||||
// ============================================================================
|
||||
|
|
@ -1089,6 +1104,9 @@ export const api = {
|
|||
previewAdminAiPrompt,
|
||||
resetAdminAiPromptTemplate,
|
||||
getAdminAiPromptPlaceholdersCatalog,
|
||||
listCatalogPromptSlotTypes,
|
||||
getCatalogPromptSlots,
|
||||
updateCatalogPromptSlots,
|
||||
listStyleDirections,
|
||||
listTrainingStyles,
|
||||
createStyleDirection,
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
* @param {{ visibility?: string, clubId?: number|null }} [governance]
|
||||
* @throws {Error}
|
||||
*/
|
||||
export function buildQuickCreateExercisePayloadFromDraft(draft) {
|
||||
export function buildQuickCreateExercisePayloadFromDraft(draft, governance) {
|
||||
const title = (draft?.title || '').trim()
|
||||
if (title.length < 3) {
|
||||
throw new Error('Titel: mindestens 3 Zeichen.')
|
||||
|
|
@ -239,6 +255,7 @@ export function buildQuickCreateExercisePayloadFromDraft(draft) {
|
|||
if (summary && !stripHtmlToText(summary).trim()) summary = null
|
||||
|
||||
const skills = (draft?.skillChoices || []).filter((c) => c.include).map((c) => c.after)
|
||||
const { visibility, club_id: clubId } = resolveQuickCreateGovernance(governance)
|
||||
|
||||
return {
|
||||
title,
|
||||
|
|
@ -247,7 +264,7 @@ export function buildQuickCreateExercisePayloadFromDraft(draft) {
|
|||
execution,
|
||||
preparation: prep,
|
||||
trainer_notes: trainerNotes,
|
||||
visibility: 'private',
|
||||
visibility,
|
||||
status: 'draft',
|
||||
equipment: [],
|
||||
focus_areas_multi: [{ focus_area_id: fid, is_primary: true }],
|
||||
|
|
@ -256,15 +273,16 @@ export function buildQuickCreateExercisePayloadFromDraft(draft) {
|
|||
target_groups_multi: [],
|
||||
age_groups: [],
|
||||
skills,
|
||||
club_id: null,
|
||||
club_id: clubId,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* createExercise-Payload aus bestätigter Vorschau (Checkbox-Modus).
|
||||
* @param {{ visibility?: string, clubId?: number|null }} [governance]
|
||||
* @throws {Error}
|
||||
*/
|
||||
export function buildQuickCreateExercisePayloadFromPreview(preview, { title, focusAreaId, sketchPlain }) {
|
||||
export function buildQuickCreateExercisePayloadFromPreview(preview, { title, focusAreaId, sketchPlain, ...governance } = {}) {
|
||||
const sketchHtml = aiPlainTextToMinimalHtml(sketchPlain)
|
||||
const fieldMap = {}
|
||||
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 { visibility, club_id: clubId } = resolveQuickCreateGovernance(governance)
|
||||
|
||||
const fid = Number(focusAreaId)
|
||||
if (!Number.isFinite(fid) || fid < 1) {
|
||||
|
|
@ -301,7 +320,7 @@ export function buildQuickCreateExercisePayloadFromPreview(preview, { title, foc
|
|||
execution,
|
||||
preparation: prep,
|
||||
trainer_notes: trainerNotes,
|
||||
visibility: 'private',
|
||||
visibility,
|
||||
status: 'draft',
|
||||
equipment: [],
|
||||
focus_areas_multi: [{ focus_area_id: fid, is_primary: true }],
|
||||
|
|
@ -310,7 +329,7 @@ export function buildQuickCreateExercisePayloadFromPreview(preview, { title, foc
|
|||
target_groups_multi: [],
|
||||
age_groups: [],
|
||||
skills,
|
||||
club_id: null,
|
||||
club_id: clubId,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -173,8 +173,19 @@ export function pathQaQualityPercent(pathQa) {
|
|||
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) {
|
||||
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
|
||||
return Boolean(pathQa?.overall_ok && pct != null && pct >= 80 && !(pathQa?.issues || []).length)
|
||||
}
|
||||
|
|
@ -1073,9 +1084,16 @@ export function compareDiffsForDialog(comparison) {
|
|||
export function defaultSelectedCompareDiffs(comparison) {
|
||||
const reviews = compareSlotReviews(comparison)
|
||||
if (reviews.length > 0) {
|
||||
return reviews
|
||||
.filter((review) => review?.library_alternative?.auto_select)
|
||||
.map((review) => slotReviewSelectionKey(review.roadmap_major_step_index, 'library'))
|
||||
const keys = []
|
||||
for (const review of reviews) {
|
||||
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))
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user