Compare commits
50 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ea7de64061 | |||
| dbc2dfacb9 | |||
| 9b4d091637 | |||
| 4724da28b1 | |||
| 50c9beb4b3 | |||
| 29a5db63e0 | |||
| 6db31e7312 | |||
| 16187fbbd0 | |||
| 9ba35dc022 | |||
| fc5748bef1 | |||
| 3bf012a8f4 | |||
| 69f238d9b8 | |||
| a1b85cd865 | |||
| 4720d70af0 | |||
| 57c464c9f6 | |||
| d42eb3ac52 | |||
| f9df2d31db | |||
| 1e2fdeeb0f | |||
| 7450c269a5 | |||
| 99a5fccaa5 | |||
| 55d87d8d17 | |||
| b35a5ae216 | |||
| bd9cfaa6e4 | |||
| 639392e133 | |||
| d153a22545 | |||
| 81d1e9bdfd | |||
| 3214055531 | |||
| 4c974620d8 | |||
| 3134160003 | |||
| b19940c997 | |||
| dbc9057601 | |||
| 6136813f60 | |||
| d82b805ffa | |||
| c8c40474d1 | |||
| f035b5bb0b | |||
| 696cb09bf4 | |||
| 33fedd5ec6 | |||
| c8a08f8a94 | |||
| d04ebee1f6 | |||
| 0c1fbab0ef | |||
| a7d68c0646 | |||
| a34dc19f5d | |||
| 9227b98431 | |||
| 390b0ecb73 | |||
| 7e7adfab54 | |||
| bce235b9f6 | |||
| 5334836207 | |||
| 38d78abc3b | |||
| a143532486 | |||
| 2f54fef88e |
|
|
@ -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).
|
**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**, **F15** Match-Dialog + getrennte Pfad-QS lokal): 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**): Ist-Doku **`docs/architecture/PLANNING_PROGRESSION_GRAPH_KI.md`**, Handover **`docs/HANDOVER.md`** §2.8.
|
||||||
|
|
||||||
**Referenz:** [`library/FEATURES_DELIVERED_2026-Q2.md`](library/FEATURES_DELIVERED_2026-Q2.md) Abschnitt 12 · Medien-Norm: [`technical/MEDIA_ASSETS_AND_ARCHIVE_SPEC.md`](technical/MEDIA_ASSETS_AND_ARCHIVE_SPEC.md) (inkl. **Abschnitt 11 Inline-Medien**, umgesetzt) · **Fachlicher Nutzerüberblick:** [`../../docs/FACHLICHE_NUTZERFUNKTIONEN.md`](../../docs/FACHLICHE_NUTZERFUNKTIONEN.md)
|
**Referenz:** [`library/FEATURES_DELIVERED_2026-Q2.md`](library/FEATURES_DELIVERED_2026-Q2.md) Abschnitt 12 · Medien-Norm: [`technical/MEDIA_ASSETS_AND_ARCHIVE_SPEC.md`](technical/MEDIA_ASSETS_AND_ARCHIVE_SPEC.md) (inkl. **Abschnitt 11 Inline-Medien**, umgesetzt) · **Fachlicher Nutzerüberblick:** [`../../docs/FACHLICHE_NUTZERFUNKTIONEN.md`](../../docs/FACHLICHE_NUTZERFUNKTIONEN.md)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
# Progressionsgraph — Slot-Editor (Phase B + F15)
|
# Progressionsgraph — Slot-Editor (Phase B)
|
||||||
|
|
||||||
**Stand:** 2026-05-22 · **Status:** Umgesetzt (F14 + F15 lokal nach 0.8.233)
|
**Stand:** 2026-06-10 · **Status:** In Umsetzung
|
||||||
|
|
||||||
## Ziel
|
## Ziel
|
||||||
|
|
||||||
|
|
@ -35,52 +35,35 @@ Leere Slots in der Roadmap sind erlaubt; Kanten nur zwischen aufeinanderfolgende
|
||||||
slots: Slot[], // index = major_step_index
|
slots: Slot[], // index = major_step_index
|
||||||
pathSkillExpectations?,
|
pathSkillExpectations?,
|
||||||
lastFindings?, // path_qa-Snapshot
|
lastFindings?, // path_qa-Snapshot
|
||||||
findingsStale?: boolean, // Bewertung veraltet (↔ Artefakt findings_stale)
|
|
||||||
dirty: boolean,
|
dirty: boolean,
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**Hydration:** `planning_roadmap` + Kanten → Slots; `slot_contents[]` für Entwürfe; Primärkette aus `next_exercise`.
|
**Hydration:** `planning_roadmap` + Kanten → Slots; `slot_contents[]` für Entwürfe; Primärkette aus `next_exercise`.
|
||||||
|
|
||||||
**Speichern:** Batch-Delete bestehender Pfad-/Schwester-Kanten → `edges/sequence` (Primärkette) → einzelne `sibling`-Kanten → `PUT`/`sequence` mit Artefakt inkl. `slot_contents`, `last_findings`, **`findings_stale`**.
|
**Speichern:** Batch-Delete bestehender Pfad-/Schwester-Kanten → `edges/sequence` (Primärkette) → einzelne `sibling`-Kanten → `PUT`/`sequence` mit Artefakt inkl. `slot_contents`, optional `last_findings`.
|
||||||
|
|
||||||
## Findings-Panel
|
## Findings-Panel
|
||||||
|
|
||||||
Nutzt `path_qa`:
|
Nutzt `path_qa` (`overall_ok`, `quality_score`, `issues`, `recommendations`, `gap_fill_offers`, …).
|
||||||
|
|
||||||
| Feld | Bedeutung |
|
|
||||||
|------|-----------|
|
|
||||||
| `quality_score` | Gesamt = **min(`roadmap_qa`, `assignment_qa`)** |
|
|
||||||
| `roadmap_qa` | Stufen/Roadmap (LLM `topic_coverage`, …) |
|
|
||||||
| `assignment_qa` | Slot-Befüllung (`empty_slot_count`, …) |
|
|
||||||
| `overall_ok`, `issues`, `recommendations`, `gap_fill_offers`, … | wie bisher |
|
|
||||||
|
|
||||||
**API:** `POST /api/planning/progression-path-suggest` mit `evaluate_only: true` und `evaluate_steps[]` — QA ohne Re-Match.
|
**API:** `POST /api/planning/progression-path-suggest` mit `evaluate_only: true` und `evaluate_steps[]` — QA ohne Re-Match.
|
||||||
|
|
||||||
**Bewertung veraltet:** Jede Graph-Änderung setzt `findingsStale: true` → Banner im Panel. Nach „Graph bewerten“ → `false`. Persistenz: `planning_roadmap.findings_stale`.
|
Persistenz: `planning_roadmap.last_findings`.
|
||||||
|
|
||||||
## Match-Flow („Übungen matchen“)
|
|
||||||
|
|
||||||
1. **Schritt 1:** `evaluate_only` + volle Pfad-QS (wie „Graph bewerten“)
|
|
||||||
2. **Schritt 2:** `unified_slot_review: true` → **`ProgressionOptimizeCompareModal`**
|
|
||||||
3. Pro Slot: aktuell vs. beste Bibliothek vs. optional KI-Vorschlag
|
|
||||||
4. **Vorauswahl:** Bibliothek nur wenn Stufen-Fit ≥ 50 % und besser als Baseline; sonst KI (bei leerem/schwachem Slot)
|
|
||||||
5. **Übernahme:** nur gewählte Slots speichern — **keine** automatische Nach-Bewertung
|
|
||||||
|
|
||||||
## Artefakt-Erweiterung (`GraphPlanningRoadmapArtifact`)
|
## Artefakt-Erweiterung (`GraphPlanningRoadmapArtifact`)
|
||||||
|
|
||||||
Optional:
|
Zusätzlich optional:
|
||||||
|
|
||||||
- `slot_contents[]` — `{ major_step_index, primary, siblings[] }`
|
- `slot_contents[]` — `{ major_step_index, primary, siblings[] }`
|
||||||
- `last_findings` — letzter `path_qa`-Snapshot
|
- `last_findings` — letzter `path_qa`-Snapshot
|
||||||
- **`findings_stale`** — bool, Bewertung bezieht sich nicht mehr auf aktuellen Graph-Stand
|
|
||||||
|
|
||||||
## UI (konsolidiert)
|
## UI (konsolidiert)
|
||||||
|
|
||||||
- **Eine Oberfläche:** `ExerciseProgressionGraphPanel` embeddet `ProgressionGraphEditor` (Slots + Findings)
|
- **Eine Oberfläche:** `ExerciseProgressionGraphPanel` embeddet `ProgressionGraphEditor` (Slots + Findings)
|
||||||
- Kein separater Slot-Editor, kein 4-Schritt-KI-Wizard, kein `ProgressionChainEditor` im Panel
|
- Kein separater Slot-Editor, kein 4-Schritt-KI-Wizard, kein `ProgressionChainEditor` im Panel
|
||||||
- Route `/progression-graphs/:id` → Redirect nach `/exercises` (Deep-Link wählt Graph)
|
- Route `/progression-graphs/:id` → Redirect nach `/exercises` (Deep-Link wählt Graph)
|
||||||
- **Slot-Keys:** stabil `slot-{index}` (nicht Lernziel-Text) — sonst Fokusverlust beim Tippen
|
- **Phase C:** Übersicht mit Kacheln (Name, Start, Ziel)
|
||||||
|
|
||||||
## Ersetzt (Legacy, nicht mehr im Panel)
|
## Ersetzt (Legacy, nicht mehr im Panel)
|
||||||
|
|
||||||
|
|
@ -88,14 +71,11 @@ Optional:
|
||||||
|
|
||||||
## Implementierungsreihenfolge
|
## Implementierungsreihenfolge
|
||||||
|
|
||||||
| ID | Inhalt | Status |
|
| ID | Inhalt |
|
||||||
|----|--------|--------|
|
|----|--------|
|
||||||
| B.0 | Draft + Laden/Speichern Slots ↔ Kanten | ✅ |
|
| B.0 | Draft + Laden/Speichern Slots ↔ Kanten |
|
||||||
| B.1 | Slot-Karten, Bibliothek + Entwurf | ✅ |
|
| B.1 | Slot-Karten, Bibliothek + Entwurf |
|
||||||
| B.2 | Findings-Panel + `evaluate_only` | ✅ |
|
| B.2 | Findings-Panel + `evaluate_only` |
|
||||||
| B.3 | Entwürfe im Artefakt + „Übung anlegen“ | ✅ |
|
| B.3 | Entwürfe im Artefakt + „Übung anlegen“ |
|
||||||
| B.4 | Route + Panel vereinfachen | ✅ |
|
| B.4 | Route + Panel vereinfachen |
|
||||||
| B.5 | `last_findings` + Phase-C-Vorbereitung | ✅ |
|
| B.5 | `last_findings` + Phase-C-Vorbereitung |
|
||||||
| F15 | Unified Slot-Review, getrennte QS, `findings_stale` | ✅ |
|
|
||||||
|
|
||||||
**Ist-Doku:** `docs/architecture/PLANNING_PROGRESSION_GRAPH_KI.md` §8.1 · `docs/HANDOVER.md` §2.8 F15
|
|
||||||
|
|
|
||||||
|
|
@ -1,317 +0,0 @@
|
||||||
"""
|
|
||||||
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",
|
|
||||||
]
|
|
||||||
|
|
@ -1,432 +0,0 @@
|
||||||
"""
|
|
||||||
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",
|
|
||||||
]
|
|
||||||
|
|
@ -1,284 +0,0 @@
|
||||||
"""
|
|
||||||
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
|
return out
|
||||||
|
|
||||||
# Register routers
|
# Register routers
|
||||||
from routers import auth, profiles, exercises, exercise_progression_graphs, clubs, club_memberships, club_join_requests, club_creation_requests, admin_users, admin_user_content, admin_rights, me_entitlements, platform_media_storage, media_assets, skills, skill_profiles, training_planning, planning_exercise_suggest, dashboard, training_modules, training_framework_programs, catalogs, 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
|
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
|
||||||
|
|
||||||
app.include_router(auth.router)
|
app.include_router(auth.router)
|
||||||
app.include_router(profiles.router)
|
app.include_router(profiles.router)
|
||||||
|
|
@ -269,7 +269,6 @@ app.include_router(dashboard.router)
|
||||||
app.include_router(training_modules.router)
|
app.include_router(training_modules.router)
|
||||||
app.include_router(training_framework_programs.router)
|
app.include_router(training_framework_programs.router)
|
||||||
app.include_router(catalogs.router)
|
app.include_router(catalogs.router)
|
||||||
app.include_router(catalog_prompt_slots.router)
|
|
||||||
app.include_router(maturity_models.router)
|
app.include_router(maturity_models.router)
|
||||||
app.include_router(matrix_stack_bundle.router)
|
app.include_router(matrix_stack_bundle.router)
|
||||||
app.include_router(matrix_editor.router)
|
app.include_router(matrix_editor.router)
|
||||||
|
|
|
||||||
|
|
@ -1,172 +0,0 @@
|
||||||
-- 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';
|
|
||||||
|
|
@ -1,176 +0,0 @@
|
||||||
-- 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();
|
|
||||||
|
|
@ -1,199 +0,0 @@
|
||||||
-- 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';
|
|
||||||
|
|
@ -1,167 +0,0 @@
|
||||||
-- 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,13 +196,6 @@ def openrouter_chat_completion(
|
||||||
cc,
|
cc,
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
|
||||||
from planning_llm_usage import record_planning_llm_call
|
|
||||||
|
|
||||||
record_planning_llm_call(1)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
return joined
|
return joined
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
"""
|
|
||||||
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,7 +2082,6 @@ def _run_evaluate_only_path_qa(
|
||||||
semantic_brief: PlanningSemanticBrief,
|
semantic_brief: PlanningSemanticBrief,
|
||||||
steps: List[Dict[str, Any]],
|
steps: List[Dict[str, Any]],
|
||||||
roadmap_ctx: Optional[ProgressionRoadmapContext],
|
roadmap_ctx: Optional[ProgressionRoadmapContext],
|
||||||
catalog_context: Optional[ProgressionPlanningCatalogContext] = None,
|
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
roadmap_first = roadmap_ctx is not None
|
roadmap_first = roadmap_ctx is not None
|
||||||
gaps: List[Dict[str, Any]] = []
|
gaps: List[Dict[str, Any]] = []
|
||||||
|
|
@ -2096,9 +2095,6 @@ def _run_evaluate_only_path_qa(
|
||||||
gap_fill_offers: List[Dict[str, Any]] = []
|
gap_fill_offers: List[Dict[str, Any]] = []
|
||||||
roadmap_qa_mode: Optional[str] = None
|
roadmap_qa_mode: Optional[str] = None
|
||||||
|
|
||||||
if catalog_context is None:
|
|
||||||
catalog_context = _resolve_planning_catalog_context(cur, body)
|
|
||||||
|
|
||||||
if body.include_path_qa:
|
if body.include_path_qa:
|
||||||
if roadmap_first:
|
if roadmap_first:
|
||||||
roadmap_qa_mode = "roadmap_first_lite"
|
roadmap_qa_mode = "roadmap_first_lite"
|
||||||
|
|
@ -2119,7 +2115,6 @@ def _run_evaluate_only_path_qa(
|
||||||
steps=steps,
|
steps=steps,
|
||||||
gaps=gaps,
|
gaps=gaps,
|
||||||
bridge_inserts=bridge_inserts,
|
bridge_inserts=bridge_inserts,
|
||||||
catalog=catalog_context,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
off_topic_steps = detect_off_topic_steps(
|
off_topic_steps = detect_off_topic_steps(
|
||||||
|
|
@ -2213,7 +2208,6 @@ def _run_evaluate_only_path_qa(
|
||||||
reorder_notes=[],
|
reorder_notes=[],
|
||||||
roadmap_qa_mode=roadmap_qa_mode,
|
roadmap_qa_mode=roadmap_qa_mode,
|
||||||
multistage_qa=multistage_qa,
|
multistage_qa=multistage_qa,
|
||||||
steps=steps,
|
|
||||||
)
|
)
|
||||||
return {
|
return {
|
||||||
"path_qa": path_qa,
|
"path_qa": path_qa,
|
||||||
|
|
@ -2506,7 +2500,6 @@ def _quick_evaluate_steps_qa(
|
||||||
llm_applied=False,
|
llm_applied=False,
|
||||||
roadmap_qa_mode="roadmap_first_lite" if roadmap_first else None,
|
roadmap_qa_mode="roadmap_first_lite" if roadmap_first else None,
|
||||||
multistage_qa=multistage_qa,
|
multistage_qa=multistage_qa,
|
||||||
steps=steps_list,
|
|
||||||
)
|
)
|
||||||
if path_qa.get("quality_score") is None:
|
if path_qa.get("quality_score") is None:
|
||||||
path_qa["quality_score"] = compute_deterministic_path_quality_score(
|
path_qa["quality_score"] = compute_deterministic_path_quality_score(
|
||||||
|
|
@ -3079,7 +3072,6 @@ def _suggestion_as_slot_diff(entry: Mapping[str, Any]) -> Dict[str, Any]:
|
||||||
|
|
||||||
|
|
||||||
_SLOT_FIT_POOR_THRESHOLD = 0.30
|
_SLOT_FIT_POOR_THRESHOLD = 0.30
|
||||||
_SLOT_FIT_GOOD_THRESHOLD = 0.50
|
|
||||||
|
|
||||||
|
|
||||||
def _off_topic_semantic_scores_by_slot(
|
def _off_topic_semantic_scores_by_slot(
|
||||||
|
|
@ -3160,18 +3152,9 @@ def _slot_auto_select_library(
|
||||||
return False
|
return False
|
||||||
if proposed_slot_score is None:
|
if proposed_slot_score is None:
|
||||||
return False
|
return False
|
||||||
effective_baseline = float(baseline_slot_score) if baseline_slot_score is not None else 0.0
|
if baseline_slot_score is None:
|
||||||
if float(proposed_slot_score) <= effective_baseline + 0.001:
|
return True
|
||||||
return False
|
return float(proposed_slot_score) > float(baseline_slot_score) + 0.001
|
||||||
# Leerer Slot: Bibliothek nur vorauswählen, wenn Stufen-Fit klar ausreicht.
|
|
||||||
if baseline_exercise_id is None:
|
|
||||||
return float(proposed_slot_score) >= _SLOT_FIT_GOOD_THRESHOLD
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
def _slot_auto_select_ai(*, library_auto_select: bool, has_ai: bool) -> bool:
|
|
||||||
"""KI-Vorschlag vorauswählen, wenn angeboten und Bibliothek nicht klar besser."""
|
|
||||||
return bool(has_ai and not library_auto_select)
|
|
||||||
|
|
||||||
|
|
||||||
def _build_unified_slot_review_entry(
|
def _build_unified_slot_review_entry(
|
||||||
|
|
@ -3408,14 +3391,10 @@ def _build_unified_slot_review_entry(
|
||||||
)
|
)
|
||||||
gap_fill_offers.append(slot_offer)
|
gap_fill_offers.append(slot_offer)
|
||||||
if slot_offer:
|
if slot_offer:
|
||||||
ai_auto = _slot_auto_select_ai(
|
|
||||||
library_auto_select=bool(library_alt and library_alt.get("auto_select")),
|
|
||||||
has_ai=True,
|
|
||||||
)
|
|
||||||
ai_alt = {
|
ai_alt = {
|
||||||
"title_hint": slot_offer.get("title_hint") or f"Slot {major_idx + 1}",
|
"title_hint": slot_offer.get("title_hint") or f"Slot {major_idx + 1}",
|
||||||
"gap_offer": slot_offer,
|
"gap_offer": slot_offer,
|
||||||
"auto_select": ai_auto,
|
"auto_select": False,
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
@ -3833,7 +3812,6 @@ def suggest_progression_path(
|
||||||
roadmap_ctx: Optional[ProgressionRoadmapContext] = None
|
roadmap_ctx: Optional[ProgressionRoadmapContext] = None
|
||||||
roadmap_edited = False
|
roadmap_edited = False
|
||||||
roadmap_structured = _roadmap_structured_from_body(body)
|
roadmap_structured = _roadmap_structured_from_body(body)
|
||||||
catalog_context = _resolve_planning_catalog_context(cur, body)
|
|
||||||
|
|
||||||
if body.roadmap_override is not None:
|
if body.roadmap_override is not None:
|
||||||
try:
|
try:
|
||||||
|
|
@ -3858,7 +3836,6 @@ def suggest_progression_path(
|
||||||
cur=cur,
|
cur=cur,
|
||||||
include_llm_start_target=body.include_llm_start_target,
|
include_llm_start_target=body.include_llm_start_target,
|
||||||
structured=roadmap_structured,
|
structured=roadmap_structured,
|
||||||
catalog=catalog_context,
|
|
||||||
)
|
)
|
||||||
progression_roadmap = progression_roadmap_to_api_dict(roadmap_ctx)
|
progression_roadmap = progression_roadmap_to_api_dict(roadmap_ctx)
|
||||||
elif include_roadmap:
|
elif include_roadmap:
|
||||||
|
|
@ -3870,7 +3847,6 @@ def suggest_progression_path(
|
||||||
include_llm_roadmap=body.include_llm_roadmap,
|
include_llm_roadmap=body.include_llm_roadmap,
|
||||||
include_llm_start_target=body.include_llm_start_target,
|
include_llm_start_target=body.include_llm_start_target,
|
||||||
structured=roadmap_structured,
|
structured=roadmap_structured,
|
||||||
catalog=catalog_context,
|
|
||||||
)
|
)
|
||||||
progression_roadmap = progression_roadmap_to_api_dict(roadmap_ctx)
|
progression_roadmap = progression_roadmap_to_api_dict(roadmap_ctx)
|
||||||
|
|
||||||
|
|
@ -3931,7 +3907,6 @@ def suggest_progression_path(
|
||||||
semantic_brief=semantic_brief,
|
semantic_brief=semantic_brief,
|
||||||
steps=eval_steps,
|
steps=eval_steps,
|
||||||
roadmap_ctx=roadmap_ctx,
|
roadmap_ctx=roadmap_ctx,
|
||||||
catalog_context=catalog_context,
|
|
||||||
)
|
)
|
||||||
return {
|
return {
|
||||||
"goal_query": goal_query,
|
"goal_query": goal_query,
|
||||||
|
|
@ -3962,7 +3937,7 @@ def suggest_progression_path(
|
||||||
start_situation=body.start_situation,
|
start_situation=body.start_situation,
|
||||||
target_state=body.target_state,
|
target_state=body.target_state,
|
||||||
roadmap_notes=body.roadmap_notes,
|
roadmap_notes=body.roadmap_notes,
|
||||||
catalog_context=catalog_context,
|
catalog_context=_resolve_planning_catalog_context(cur, body),
|
||||||
)
|
)
|
||||||
path_skill_expectations: Optional[Dict[str, Any]] = None
|
path_skill_expectations: Optional[Dict[str, Any]] = None
|
||||||
if roadmap_ctx and roadmap_ctx.goal_analysis:
|
if roadmap_ctx and roadmap_ctx.goal_analysis:
|
||||||
|
|
@ -4161,7 +4136,6 @@ def suggest_progression_path(
|
||||||
steps=steps,
|
steps=steps,
|
||||||
gaps=gaps,
|
gaps=gaps,
|
||||||
bridge_inserts=bridge_inserts,
|
bridge_inserts=bridge_inserts,
|
||||||
catalog=catalog_context,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if (
|
if (
|
||||||
|
|
@ -4250,7 +4224,6 @@ def suggest_progression_path(
|
||||||
steps=steps,
|
steps=steps,
|
||||||
gaps=gaps,
|
gaps=gaps,
|
||||||
bridge_inserts=bridge_inserts,
|
bridge_inserts=bridge_inserts,
|
||||||
catalog=catalog_context,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
llm_gap_specs = parse_llm_suggested_new_exercises(
|
llm_gap_specs = parse_llm_suggested_new_exercises(
|
||||||
|
|
@ -4339,7 +4312,6 @@ def suggest_progression_path(
|
||||||
reorder_notes=reorder_notes,
|
reorder_notes=reorder_notes,
|
||||||
roadmap_qa_mode=roadmap_qa_mode,
|
roadmap_qa_mode=roadmap_qa_mode,
|
||||||
multistage_qa=multistage_qa,
|
multistage_qa=multistage_qa,
|
||||||
steps=steps,
|
|
||||||
)
|
)
|
||||||
if rematch_log:
|
if rematch_log:
|
||||||
path_qa["rematch_applied"] = True
|
path_qa["rematch_applied"] = True
|
||||||
|
|
|
||||||
|
|
@ -9,8 +9,6 @@ import re
|
||||||
from typing import Any, Callable, Dict, List, Mapping, Optional, Sequence, Set, Tuple
|
from typing import Any, Callable, Dict, List, Mapping, Optional, Sequence, Set, Tuple
|
||||||
|
|
||||||
from ai_prompt_runtime import AiPromptUnavailableError, load_and_render_ai_prompt
|
from ai_prompt_runtime import AiPromptUnavailableError, load_and_render_ai_prompt
|
||||||
from planning_catalog_context import ProgressionPlanningCatalogContext
|
|
||||||
from planning_prompt_variables import merge_planning_prompt_variables
|
|
||||||
from exercise_ai import strip_html_to_plain
|
from exercise_ai import strip_html_to_plain
|
||||||
from openrouter_chat import (
|
from openrouter_chat import (
|
||||||
effective_openrouter_model_for_prompt_row,
|
effective_openrouter_model_for_prompt_row,
|
||||||
|
|
@ -322,7 +320,6 @@ def try_llm_qa_progression_path(
|
||||||
steps: Sequence[Mapping[str, Any]],
|
steps: Sequence[Mapping[str, Any]],
|
||||||
gaps: Sequence[Mapping[str, Any]],
|
gaps: Sequence[Mapping[str, Any]],
|
||||||
bridge_inserts: Sequence[Mapping[str, Any]],
|
bridge_inserts: Sequence[Mapping[str, Any]],
|
||||||
catalog: Optional[ProgressionPlanningCatalogContext] = None,
|
|
||||||
) -> Tuple[Optional[Dict[str, Any]], bool]:
|
) -> Tuple[Optional[Dict[str, Any]], bool]:
|
||||||
api_key, _ = normalize_openrouter_env()
|
api_key, _ = normalize_openrouter_env()
|
||||||
if not api_key or len(steps) < 2:
|
if not api_key or len(steps) < 2:
|
||||||
|
|
@ -357,18 +354,13 @@ def try_llm_qa_progression_path(
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
variables = merge_planning_prompt_variables(
|
variables = {
|
||||||
cur,
|
"goal_query": goal_query or "",
|
||||||
{
|
"semantic_brief_json": json.dumps(brief_to_summary_dict(brief), ensure_ascii=False),
|
||||||
"goal_query": goal_query or "",
|
"steps_json": json.dumps(step_payload, ensure_ascii=False),
|
||||||
"semantic_brief_json": json.dumps(brief_to_summary_dict(brief), ensure_ascii=False),
|
"gaps_json": json.dumps(list(gaps), ensure_ascii=False),
|
||||||
"steps_json": json.dumps(step_payload, ensure_ascii=False),
|
"bridge_inserts_json": json.dumps(list(bridge_inserts), 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:
|
try:
|
||||||
prow, rendered = load_and_render_ai_prompt(cur, "planning_exercise_path_qa", variables)
|
prow, rendered = load_and_render_ai_prompt(cur, "planning_exercise_path_qa", variables)
|
||||||
|
|
@ -696,160 +688,6 @@ def find_step_pair_index(
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def count_step_assignment_stats(steps: Optional[Sequence[Mapping[str, Any]]]) -> Dict[str, int]:
|
|
||||||
stats = {"total": 0, "empty": 0, "library_filled": 0, "ai_proposal": 0}
|
|
||||||
for raw in steps or []:
|
|
||||||
if not isinstance(raw, dict):
|
|
||||||
continue
|
|
||||||
stats["total"] += 1
|
|
||||||
if raw.get("exercise_id") is not None and not raw.get("is_ai_proposal"):
|
|
||||||
stats["library_filled"] += 1
|
|
||||||
elif raw.get("is_ai_proposal"):
|
|
||||||
stats["ai_proposal"] += 1
|
|
||||||
else:
|
|
||||||
stats["empty"] += 1
|
|
||||||
return stats
|
|
||||||
|
|
||||||
|
|
||||||
def compute_assignment_quality_score(
|
|
||||||
*,
|
|
||||||
steps: Optional[Sequence[Mapping[str, Any]]] = None,
|
|
||||||
off_topic_steps: Optional[Sequence[Mapping[str, Any]]] = None,
|
|
||||||
gaps: Optional[Sequence[Mapping[str, Any]]] = None,
|
|
||||||
) -> float:
|
|
||||||
"""QS der Übungsbesetzung — leere Slots stark abwerten."""
|
|
||||||
stats = count_step_assignment_stats(steps)
|
|
||||||
total = stats["total"]
|
|
||||||
if total <= 0:
|
|
||||||
return 0.45
|
|
||||||
empty = stats["empty"]
|
|
||||||
library = stats["library_filled"]
|
|
||||||
ai = stats["ai_proposal"]
|
|
||||||
fill_credit = (library + 0.55 * ai) / total
|
|
||||||
score = 0.1 + 0.84 * fill_credit
|
|
||||||
if empty > 0:
|
|
||||||
score -= 0.22 * (empty / total)
|
|
||||||
score -= 0.08 * len(off_topic_steps or [])
|
|
||||||
score -= 0.03 * len(gaps or [])
|
|
||||||
return max(0.08, min(0.98, round(score, 4)))
|
|
||||||
|
|
||||||
|
|
||||||
def compute_roadmap_quality_score(
|
|
||||||
*,
|
|
||||||
llm_qa: Optional[Mapping[str, Any]] = None,
|
|
||||||
llm_applied: bool = False,
|
|
||||||
gaps: Optional[Sequence[Mapping[str, Any]]] = None,
|
|
||||||
multistage_qa: Optional[Mapping[str, Any]] = None,
|
|
||||||
) -> float:
|
|
||||||
"""QS der Roadmap-/Stufenlogik — unabhängig von Slot-Befüllung."""
|
|
||||||
if llm_applied and llm_qa and llm_qa.get("quality_score") is not None:
|
|
||||||
try:
|
|
||||||
return max(0.08, min(0.98, round(float(llm_qa["quality_score"]), 4)))
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
pass
|
|
||||||
score = 0.9
|
|
||||||
score -= 0.05 * len(gaps or [])
|
|
||||||
hint_count = int((multistage_qa or {}).get("optimization_hint_count") or 0)
|
|
||||||
score -= min(0.12, 0.015 * hint_count)
|
|
||||||
return max(0.35, min(0.98, round(score, 4)))
|
|
||||||
|
|
||||||
|
|
||||||
def build_assignment_qa_snapshot(
|
|
||||||
*,
|
|
||||||
steps: Optional[Sequence[Mapping[str, Any]]] = None,
|
|
||||||
off_topic_steps: Optional[Sequence[Mapping[str, Any]]] = None,
|
|
||||||
gaps: Optional[Sequence[Mapping[str, Any]]] = None,
|
|
||||||
) -> Dict[str, Any]:
|
|
||||||
off_topic = list(off_topic_steps or [])
|
|
||||||
stats = count_step_assignment_stats(steps)
|
|
||||||
score = compute_assignment_quality_score(
|
|
||||||
steps=steps,
|
|
||||||
off_topic_steps=off_topic,
|
|
||||||
gaps=gaps,
|
|
||||||
)
|
|
||||||
issues: List[str] = []
|
|
||||||
if stats["empty"] > 0:
|
|
||||||
issues.append(
|
|
||||||
f"{stats['empty']} von {stats['total']} Slot(s) ohne Übung — bitte Bibliothek oder KI-Vorschlag zuweisen",
|
|
||||||
)
|
|
||||||
if stats["ai_proposal"] > 0 and stats["library_filled"] == 0 and stats["empty"] > 0:
|
|
||||||
issues.append(
|
|
||||||
f"{stats['ai_proposal']} KI-Entwurf(e), aber noch {stats['empty']} leere Slot(s)",
|
|
||||||
)
|
|
||||||
for item in off_topic[:5]:
|
|
||||||
title = (item.get("title") or "Schritt").strip()
|
|
||||||
issues.append(f"„{title}“ passt nicht zum Stufen-Ziel")
|
|
||||||
overall_ok = stats["empty"] == 0 and len(off_topic) == 0
|
|
||||||
return {
|
|
||||||
"overall_ok": overall_ok,
|
|
||||||
"quality_score": score,
|
|
||||||
"slot_count": stats["total"],
|
|
||||||
"empty_slot_count": stats["empty"],
|
|
||||||
"library_filled_count": stats["library_filled"],
|
|
||||||
"ai_proposal_count": stats["ai_proposal"],
|
|
||||||
"issues": issues,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def build_roadmap_qa_snapshot(
|
|
||||||
*,
|
|
||||||
llm_qa: Optional[Mapping[str, Any]] = None,
|
|
||||||
llm_applied: bool = False,
|
|
||||||
gaps: Optional[Sequence[Mapping[str, Any]]] = None,
|
|
||||||
multistage_qa: Optional[Mapping[str, Any]] = None,
|
|
||||||
roadmap_qa_mode: Optional[str] = None,
|
|
||||||
) -> Dict[str, Any]:
|
|
||||||
score = compute_roadmap_quality_score(
|
|
||||||
llm_qa=llm_qa,
|
|
||||||
llm_applied=llm_applied,
|
|
||||||
gaps=gaps,
|
|
||||||
multistage_qa=multistage_qa,
|
|
||||||
)
|
|
||||||
issues: List[str] = []
|
|
||||||
if not llm_applied:
|
|
||||||
for gap in gaps or []:
|
|
||||||
issues.append(
|
|
||||||
f"Übergang „{gap.get('from_title')}“ → „{gap.get('to_title')}“ schwach (Score {gap.get('gap_score')})",
|
|
||||||
)
|
|
||||||
if llm_applied and llm_qa:
|
|
||||||
issues.extend(str(x).strip() for x in (llm_qa.get("issues") or []) if str(x).strip())
|
|
||||||
overall_ok = bool(llm_qa.get("overall_ok", True)) if llm_applied and llm_qa else len(gaps or []) == 0
|
|
||||||
snapshot: Dict[str, Any] = {
|
|
||||||
"overall_ok": overall_ok,
|
|
||||||
"quality_score": score,
|
|
||||||
"issues": issues[:8],
|
|
||||||
"llm_applied": bool(llm_applied),
|
|
||||||
"roadmap_qa_mode": roadmap_qa_mode,
|
|
||||||
}
|
|
||||||
if llm_applied and llm_qa:
|
|
||||||
snapshot["topic_coverage"] = llm_qa.get("topic_coverage")
|
|
||||||
snapshot["recommendations"] = list(llm_qa.get("recommendations") or [])
|
|
||||||
snapshot["sequence_notes"] = list(llm_qa.get("sequence_notes") or [])
|
|
||||||
return snapshot
|
|
||||||
|
|
||||||
|
|
||||||
def merge_path_quality_scores(
|
|
||||||
roadmap_qa: Mapping[str, Any],
|
|
||||||
assignment_qa: Mapping[str, Any],
|
|
||||||
) -> float:
|
|
||||||
"""Gesamt-QS: schwächere Dimension begrenzt — leere Slots senken den Pfad deutlich."""
|
|
||||||
try:
|
|
||||||
roadmap_score = float(roadmap_qa.get("quality_score"))
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
roadmap_score = None
|
|
||||||
try:
|
|
||||||
assignment_score = float(assignment_qa.get("quality_score"))
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
assignment_score = None
|
|
||||||
if roadmap_score is not None and assignment_score is not None:
|
|
||||||
return round(min(roadmap_score, assignment_score), 4)
|
|
||||||
if assignment_score is not None:
|
|
||||||
return assignment_score
|
|
||||||
if roadmap_score is not None:
|
|
||||||
return roadmap_score
|
|
||||||
return 0.5
|
|
||||||
|
|
||||||
|
|
||||||
def build_path_qa_summary(
|
def build_path_qa_summary(
|
||||||
*,
|
*,
|
||||||
gaps: Sequence[Mapping[str, Any]],
|
gaps: Sequence[Mapping[str, Any]],
|
||||||
|
|
@ -864,7 +702,6 @@ def build_path_qa_summary(
|
||||||
reorder_notes: Optional[Sequence[str]] = None,
|
reorder_notes: Optional[Sequence[str]] = None,
|
||||||
roadmap_qa_mode: Optional[str] = None,
|
roadmap_qa_mode: Optional[str] = None,
|
||||||
multistage_qa: Optional[Mapping[str, Any]] = None,
|
multistage_qa: Optional[Mapping[str, Any]] = None,
|
||||||
steps: Optional[Sequence[Mapping[str, Any]]] = None,
|
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
offers = list(gap_fill_offers or [])
|
offers = list(gap_fill_offers or [])
|
||||||
off_topic = list(off_topic_steps or [])
|
off_topic = list(off_topic_steps or [])
|
||||||
|
|
@ -889,32 +726,31 @@ def build_path_qa_summary(
|
||||||
summary["qa_tiers"] = list(multistage_qa.get("qa_tiers") or [])
|
summary["qa_tiers"] = list(multistage_qa.get("qa_tiers") or [])
|
||||||
summary["optimization_hints"] = list(multistage_qa.get("optimization_hints") or [])
|
summary["optimization_hints"] = list(multistage_qa.get("optimization_hints") or [])
|
||||||
summary["optimization_hint_count"] = int(multistage_qa.get("optimization_hint_count") or 0)
|
summary["optimization_hint_count"] = int(multistage_qa.get("optimization_hint_count") or 0)
|
||||||
|
|
||||||
assignment_qa = build_assignment_qa_snapshot(
|
|
||||||
steps=steps,
|
|
||||||
off_topic_steps=off_topic,
|
|
||||||
gaps=gaps,
|
|
||||||
)
|
|
||||||
roadmap_qa = build_roadmap_qa_snapshot(
|
|
||||||
llm_qa=llm_qa,
|
|
||||||
llm_applied=llm_applied,
|
|
||||||
gaps=gaps,
|
|
||||||
multistage_qa=multistage_qa,
|
|
||||||
roadmap_qa_mode=roadmap_qa_mode,
|
|
||||||
)
|
|
||||||
summary["assignment_qa"] = assignment_qa
|
|
||||||
summary["roadmap_qa"] = roadmap_qa
|
|
||||||
summary["quality_score"] = merge_path_quality_scores(roadmap_qa, assignment_qa)
|
|
||||||
summary["overall_ok"] = bool(
|
|
||||||
assignment_qa.get("overall_ok")
|
|
||||||
and roadmap_qa.get("overall_ok", True),
|
|
||||||
)
|
|
||||||
summary["topic_coverage"] = roadmap_qa.get("topic_coverage")
|
|
||||||
summary["recommendations"] = list(roadmap_qa.get("recommendations") or [])
|
|
||||||
summary["sequence_notes"] = list(roadmap_qa.get("sequence_notes") or [])
|
|
||||||
summary["issues"] = list(assignment_qa.get("issues") or []) + list(roadmap_qa.get("issues") or [])[:6]
|
|
||||||
if llm_qa:
|
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 [])
|
summary["suggested_new_exercises"] = list(llm_qa.get("suggested_new_exercises") or [])
|
||||||
|
else:
|
||||||
|
summary["overall_ok"] = len(gaps) == 0 and len(off_topic) == 0
|
||||||
|
summary["issues"] = [
|
||||||
|
f"Lücke zwischen „{g.get('from_title')}“ und „{g.get('to_title')}“ (Score {g.get('gap_score')})"
|
||||||
|
for g in gaps
|
||||||
|
] if gaps else []
|
||||||
|
if off_topic:
|
||||||
|
summary["issues"] = list(summary["issues"]) + [
|
||||||
|
f"Schritt „{o.get('title')}“ passt nicht zum Pfad-Thema"
|
||||||
|
for o in off_topic
|
||||||
|
]
|
||||||
|
summary["quality_score"] = compute_deterministic_path_quality_score(
|
||||||
|
gaps=gaps,
|
||||||
|
off_topic_steps=off_topic,
|
||||||
|
steps=steps,
|
||||||
|
multistage_qa=multistage_qa,
|
||||||
|
)
|
||||||
return summary
|
return summary
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -925,34 +761,31 @@ def compute_deterministic_path_quality_score(
|
||||||
steps: Optional[Sequence[Mapping[str, Any]]] = None,
|
steps: Optional[Sequence[Mapping[str, Any]]] = None,
|
||||||
multistage_qa: Optional[Mapping[str, Any]] = None,
|
multistage_qa: Optional[Mapping[str, Any]] = None,
|
||||||
) -> float:
|
) -> float:
|
||||||
"""Heuristische Pfad-QS ohne LLM — Roadmap + Besetzung kombiniert."""
|
"""Heuristische Pfad-QS ohne LLM — Basis für Slot-Vergleiche."""
|
||||||
roadmap_qa = build_roadmap_qa_snapshot(
|
score = 0.92
|
||||||
llm_qa=None,
|
score -= 0.08 * len(off_topic_steps or [])
|
||||||
llm_applied=False,
|
score -= 0.05 * len(gaps or [])
|
||||||
gaps=gaps,
|
if steps:
|
||||||
multistage_qa=multistage_qa,
|
empty = sum(
|
||||||
)
|
1
|
||||||
assignment_qa = build_assignment_qa_snapshot(
|
for s in steps
|
||||||
steps=steps,
|
if isinstance(s, dict)
|
||||||
off_topic_steps=off_topic_steps,
|
and s.get("exercise_id") is None
|
||||||
gaps=gaps,
|
and not s.get("is_ai_proposal")
|
||||||
)
|
)
|
||||||
return merge_path_quality_scores(roadmap_qa, assignment_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)))
|
||||||
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"apply_llm_path_reorder",
|
"apply_llm_path_reorder",
|
||||||
"build_assignment_qa_snapshot",
|
|
||||||
"build_path_qa_summary",
|
"build_path_qa_summary",
|
||||||
"build_roadmap_qa_snapshot",
|
|
||||||
"compute_assignment_quality_score",
|
|
||||||
"compute_deterministic_path_quality_score",
|
"compute_deterministic_path_quality_score",
|
||||||
"compute_roadmap_quality_score",
|
|
||||||
"count_step_assignment_stats",
|
|
||||||
"detect_off_topic_steps",
|
"detect_off_topic_steps",
|
||||||
"detect_path_gaps",
|
"detect_path_gaps",
|
||||||
"is_roadmap_planned_neighbor_pair",
|
"is_roadmap_planned_neighbor_pair",
|
||||||
"merge_path_quality_scores",
|
|
||||||
"strip_off_topic_steps_from_path",
|
"strip_off_topic_steps_from_path",
|
||||||
"find_step_pair_index",
|
"find_step_pair_index",
|
||||||
"insert_bridge_exercises",
|
"insert_bridge_exercises",
|
||||||
|
|
|
||||||
|
|
@ -1,62 +0,0 @@
|
||||||
"""
|
|
||||||
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,8 +18,6 @@ from typing import Any, Dict, List, Mapping, Optional, Sequence, Tuple
|
||||||
from pydantic import BaseModel, Field, ValidationError
|
from pydantic import BaseModel, Field, ValidationError
|
||||||
|
|
||||||
from ai_prompt_runtime import AiPromptUnavailableError, load_and_render_ai_prompt
|
from ai_prompt_runtime import AiPromptUnavailableError, load_and_render_ai_prompt
|
||||||
from planning_catalog_context import ProgressionPlanningCatalogContext
|
|
||||||
from planning_prompt_variables import merge_planning_prompt_variables
|
|
||||||
from openrouter_chat import (
|
from openrouter_chat import (
|
||||||
effective_openrouter_model_for_prompt_row,
|
effective_openrouter_model_for_prompt_row,
|
||||||
normalize_openrouter_env,
|
normalize_openrouter_env,
|
||||||
|
|
@ -192,20 +190,12 @@ def _run_prompt_json(
|
||||||
cur,
|
cur,
|
||||||
slug: str,
|
slug: str,
|
||||||
variables: Dict[str, str],
|
variables: Dict[str, str],
|
||||||
*,
|
|
||||||
catalog: Optional[ProgressionPlanningCatalogContext] = None,
|
|
||||||
) -> Optional[Dict[str, Any]]:
|
) -> Optional[Dict[str, Any]]:
|
||||||
api_key, _ = normalize_openrouter_env()
|
api_key, _ = normalize_openrouter_env()
|
||||||
if not api_key or cur is None:
|
if not api_key or cur is None:
|
||||||
return None
|
return None
|
||||||
merged = merge_planning_prompt_variables(
|
|
||||||
cur,
|
|
||||||
variables,
|
|
||||||
catalog=catalog,
|
|
||||||
slug=slug,
|
|
||||||
)
|
|
||||||
try:
|
try:
|
||||||
prow, rendered = load_and_render_ai_prompt(cur, slug, merged)
|
prow, rendered = load_and_render_ai_prompt(cur, slug, variables)
|
||||||
model = effective_openrouter_model_for_prompt_row(prow)
|
model = effective_openrouter_model_for_prompt_row(prow)
|
||||||
raw = openrouter_chat_completion(api_key=api_key, model=model, user_content=rendered.text)
|
raw = openrouter_chat_completion(api_key=api_key, model=model, user_content=rendered.text)
|
||||||
return _extract_json_object(raw)
|
return _extract_json_object(raw)
|
||||||
|
|
@ -222,7 +212,6 @@ def try_llm_start_target_extract(
|
||||||
goal_query: str,
|
goal_query: str,
|
||||||
brief: PlanningSemanticBrief,
|
brief: PlanningSemanticBrief,
|
||||||
user_notes: str = "",
|
user_notes: str = "",
|
||||||
catalog: Optional[ProgressionPlanningCatalogContext] = None,
|
|
||||||
) -> Tuple[Optional[StartTargetExtractArtifact], bool]:
|
) -> Tuple[Optional[StartTargetExtractArtifact], bool]:
|
||||||
obj = _run_prompt_json(
|
obj = _run_prompt_json(
|
||||||
cur,
|
cur,
|
||||||
|
|
@ -232,7 +221,6 @@ def try_llm_start_target_extract(
|
||||||
"semantic_brief_json": json.dumps(brief_to_summary_dict(brief), ensure_ascii=False),
|
"semantic_brief_json": json.dumps(brief_to_summary_dict(brief), ensure_ascii=False),
|
||||||
"user_notes": (user_notes or "").strip(),
|
"user_notes": (user_notes or "").strip(),
|
||||||
},
|
},
|
||||||
catalog=catalog,
|
|
||||||
)
|
)
|
||||||
if not obj:
|
if not obj:
|
||||||
return None, False
|
return None, False
|
||||||
|
|
@ -248,7 +236,6 @@ def try_llm_goal_analysis(
|
||||||
*,
|
*,
|
||||||
goal_query: str,
|
goal_query: str,
|
||||||
brief: PlanningSemanticBrief,
|
brief: PlanningSemanticBrief,
|
||||||
catalog: Optional[ProgressionPlanningCatalogContext] = None,
|
|
||||||
) -> Tuple[Optional[GoalAnalysisArtifact], bool]:
|
) -> Tuple[Optional[GoalAnalysisArtifact], bool]:
|
||||||
obj = _run_prompt_json(
|
obj = _run_prompt_json(
|
||||||
cur,
|
cur,
|
||||||
|
|
@ -257,7 +244,6 @@ def try_llm_goal_analysis(
|
||||||
"goal_query": goal_query or "",
|
"goal_query": goal_query or "",
|
||||||
"semantic_brief_json": json.dumps(brief_to_summary_dict(brief), ensure_ascii=False),
|
"semantic_brief_json": json.dumps(brief_to_summary_dict(brief), ensure_ascii=False),
|
||||||
},
|
},
|
||||||
catalog=catalog,
|
|
||||||
)
|
)
|
||||||
if not obj:
|
if not obj:
|
||||||
return None, False
|
return None, False
|
||||||
|
|
@ -275,7 +261,6 @@ def try_llm_roadmap(
|
||||||
brief: PlanningSemanticBrief,
|
brief: PlanningSemanticBrief,
|
||||||
goal_analysis: GoalAnalysisArtifact,
|
goal_analysis: GoalAnalysisArtifact,
|
||||||
max_steps: int,
|
max_steps: int,
|
||||||
catalog: Optional[ProgressionPlanningCatalogContext] = None,
|
|
||||||
) -> Tuple[Optional[RoadmapArtifact], bool]:
|
) -> Tuple[Optional[RoadmapArtifact], bool]:
|
||||||
obj = _run_prompt_json(
|
obj = _run_prompt_json(
|
||||||
cur,
|
cur,
|
||||||
|
|
@ -286,7 +271,6 @@ def try_llm_roadmap(
|
||||||
"goal_analysis_json": json.dumps(goal_analysis.model_dump(), ensure_ascii=False),
|
"goal_analysis_json": json.dumps(goal_analysis.model_dump(), ensure_ascii=False),
|
||||||
"max_steps": str(int(max_steps)),
|
"max_steps": str(int(max_steps)),
|
||||||
},
|
},
|
||||||
catalog=catalog,
|
|
||||||
)
|
)
|
||||||
if not obj:
|
if not obj:
|
||||||
return None, False
|
return None, False
|
||||||
|
|
@ -320,7 +304,6 @@ def try_llm_stage_specs(
|
||||||
major_steps: Sequence[MajorStep],
|
major_steps: Sequence[MajorStep],
|
||||||
intent_context: Optional[Mapping[str, Any]] = None,
|
intent_context: Optional[Mapping[str, Any]] = None,
|
||||||
semantic_brief: Optional[PlanningSemanticBrief] = None,
|
semantic_brief: Optional[PlanningSemanticBrief] = None,
|
||||||
catalog: Optional[ProgressionPlanningCatalogContext] = None,
|
|
||||||
) -> Tuple[Optional[List[StageSpecArtifact]], bool]:
|
) -> Tuple[Optional[List[StageSpecArtifact]], bool]:
|
||||||
obj = _run_prompt_json(
|
obj = _run_prompt_json(
|
||||||
cur,
|
cur,
|
||||||
|
|
@ -335,7 +318,6 @@ def try_llm_stage_specs(
|
||||||
ensure_ascii=False,
|
ensure_ascii=False,
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
catalog=catalog,
|
|
||||||
)
|
)
|
||||||
if not obj:
|
if not obj:
|
||||||
return None, False
|
return None, False
|
||||||
|
|
@ -398,7 +380,6 @@ def resolve_roadmap_structured_input(
|
||||||
brief: PlanningSemanticBrief,
|
brief: PlanningSemanticBrief,
|
||||||
cur=None,
|
cur=None,
|
||||||
include_llm: bool = False,
|
include_llm: bool = False,
|
||||||
catalog: Optional[ProgressionPlanningCatalogContext] = None,
|
|
||||||
) -> Tuple[RoadmapStructuredInput, StartTargetResolveMeta, Optional[StartTargetExtractArtifact]]:
|
) -> Tuple[RoadmapStructuredInput, StartTargetResolveMeta, Optional[StartTargetExtractArtifact]]:
|
||||||
"""Priorität je Feld: Trainer-Eingabe > LLM-Extraktion > Regex (von … bis …)."""
|
"""Priorität je Feld: Trainer-Eingabe > LLM-Extraktion > Regex (von … bis …)."""
|
||||||
user = structured or RoadmapStructuredInput()
|
user = structured or RoadmapStructuredInput()
|
||||||
|
|
@ -414,7 +395,6 @@ def resolve_roadmap_structured_input(
|
||||||
goal_query=goal_query,
|
goal_query=goal_query,
|
||||||
brief=brief,
|
brief=brief,
|
||||||
user_notes=user_notes,
|
user_notes=user_notes,
|
||||||
catalog=catalog,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
parsed_start, parsed_target = parse_start_target_from_goal_query(goal_query)
|
parsed_start, parsed_target = parse_start_target_from_goal_query(goal_query)
|
||||||
|
|
@ -1088,7 +1068,6 @@ def run_start_target_resolve_only(
|
||||||
cur=None,
|
cur=None,
|
||||||
include_llm_start_target: bool = True,
|
include_llm_start_target: bool = True,
|
||||||
structured: Optional[RoadmapStructuredInput] = None,
|
structured: Optional[RoadmapStructuredInput] = None,
|
||||||
catalog: Optional[ProgressionPlanningCatalogContext] = None,
|
|
||||||
) -> ProgressionRoadmapContext:
|
) -> ProgressionRoadmapContext:
|
||||||
"""Nur Start/Ziel/Ergänzungen auflösen — ohne Roadmap-Stufen (Review vor Major Steps)."""
|
"""Nur Start/Ziel/Ergänzungen auflösen — ohne Roadmap-Stufen (Review vor Major Steps)."""
|
||||||
brief = semantic_brief or build_semantic_brief(goal_query)
|
brief = semantic_brief or build_semantic_brief(goal_query)
|
||||||
|
|
@ -1098,7 +1077,6 @@ def run_start_target_resolve_only(
|
||||||
brief=brief,
|
brief=brief,
|
||||||
cur=cur,
|
cur=cur,
|
||||||
include_llm=include_llm_start_target,
|
include_llm=include_llm_start_target,
|
||||||
catalog=catalog,
|
|
||||||
)
|
)
|
||||||
topic_override = None
|
topic_override = None
|
||||||
if llm_extract and (llm_extract.primary_topic or "").strip():
|
if llm_extract and (llm_extract.primary_topic or "").strip():
|
||||||
|
|
@ -1134,7 +1112,6 @@ def run_progression_roadmap_pipeline(
|
||||||
include_llm_roadmap: bool = False,
|
include_llm_roadmap: bool = False,
|
||||||
include_llm_start_target: bool = False,
|
include_llm_start_target: bool = False,
|
||||||
structured: Optional[RoadmapStructuredInput] = None,
|
structured: Optional[RoadmapStructuredInput] = None,
|
||||||
catalog: Optional[ProgressionPlanningCatalogContext] = None,
|
|
||||||
) -> ProgressionRoadmapContext:
|
) -> ProgressionRoadmapContext:
|
||||||
"""Workflow-lite: Phase A → B → C. LLM über ai_prompts-Slugs; deterministischer Fallback."""
|
"""Workflow-lite: Phase A → B → C. LLM über ai_prompts-Slugs; deterministischer Fallback."""
|
||||||
brief = semantic_brief or build_semantic_brief(goal_query)
|
brief = semantic_brief or build_semantic_brief(goal_query)
|
||||||
|
|
@ -1144,7 +1121,6 @@ def run_progression_roadmap_pipeline(
|
||||||
brief=brief,
|
brief=brief,
|
||||||
cur=cur,
|
cur=cur,
|
||||||
include_llm=include_llm_start_target,
|
include_llm=include_llm_start_target,
|
||||||
catalog=catalog,
|
|
||||||
)
|
)
|
||||||
parsed_start, parsed_target = parse_start_target_from_goal_query(goal_query)
|
parsed_start, parsed_target = parse_start_target_from_goal_query(goal_query)
|
||||||
llm_goal_query = _roadmap_llm_goal_block(
|
llm_goal_query = _roadmap_llm_goal_block(
|
||||||
|
|
@ -1176,9 +1152,7 @@ def run_progression_roadmap_pipeline(
|
||||||
topic_override=topic_override,
|
topic_override=topic_override,
|
||||||
)
|
)
|
||||||
if include_llm_roadmap and cur is not None:
|
if include_llm_roadmap and cur is not None:
|
||||||
llm_ga, ga_ok = try_llm_goal_analysis(
|
llm_ga, ga_ok = try_llm_goal_analysis(cur, goal_query=llm_goal_query, brief=brief)
|
||||||
cur, goal_query=llm_goal_query, brief=brief, catalog=catalog
|
|
||||||
)
|
|
||||||
if ga_ok and llm_ga:
|
if ga_ok and llm_ga:
|
||||||
goal_analysis = _merge_structured_into_goal_analysis(
|
goal_analysis = _merge_structured_into_goal_analysis(
|
||||||
llm_ga,
|
llm_ga,
|
||||||
|
|
@ -1198,7 +1172,6 @@ def run_progression_roadmap_pipeline(
|
||||||
brief=brief,
|
brief=brief,
|
||||||
goal_analysis=goal_analysis,
|
goal_analysis=goal_analysis,
|
||||||
max_steps=max_steps,
|
max_steps=max_steps,
|
||||||
catalog=catalog,
|
|
||||||
)
|
)
|
||||||
if rm_ok and llm_rm:
|
if rm_ok and llm_rm:
|
||||||
roadmap = llm_rm
|
roadmap = llm_rm
|
||||||
|
|
@ -1261,7 +1234,6 @@ def run_progression_roadmap_pipeline(
|
||||||
major_steps=roadmap.major_steps,
|
major_steps=roadmap.major_steps,
|
||||||
intent_context=intent.to_api_dict(),
|
intent_context=intent.to_api_dict(),
|
||||||
semantic_brief=brief,
|
semantic_brief=brief,
|
||||||
catalog=catalog,
|
|
||||||
)
|
)
|
||||||
if spec_ok and llm_specs:
|
if spec_ok and llm_specs:
|
||||||
stage_specs = list(llm_specs)
|
stage_specs = list(llm_specs)
|
||||||
|
|
|
||||||
|
|
@ -1,118 +0,0 @@
|
||||||
"""
|
|
||||||
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,15 +14,9 @@ from auth import require_auth
|
||||||
from club_tenancy import is_superadmin
|
from club_tenancy import is_superadmin
|
||||||
from ai_prompt_context import ExerciseFormAiPromptContext
|
from ai_prompt_context import ExerciseFormAiPromptContext
|
||||||
from ai_prompt_job import resolve_exercise_form_variables
|
from ai_prompt_job import resolve_exercise_form_variables
|
||||||
from ai_prompt_planning_preview import (
|
|
||||||
PlanningPromptPreviewInput,
|
|
||||||
is_planning_prompt_slug,
|
|
||||||
resolve_planning_prompt_preview_variables,
|
|
||||||
)
|
|
||||||
from ai_prompt_runtime import render_ai_prompt_template_for_row
|
from ai_prompt_runtime import render_ai_prompt_template_for_row
|
||||||
from db import get_cursor, get_db, r2d
|
from db import get_cursor, get_db, r2d
|
||||||
from prompt_resolver import exercise_placeholder_catalog
|
from prompt_resolver import exercise_placeholder_catalog
|
||||||
from planning_prompt_variables import planning_prompt_placeholder_catalog
|
|
||||||
|
|
||||||
router = APIRouter(tags=["admin_ai_prompts"])
|
router = APIRouter(tags=["admin_ai_prompts"])
|
||||||
|
|
||||||
|
|
@ -68,22 +62,12 @@ class AiPromptUpdateBody(BaseModel):
|
||||||
|
|
||||||
|
|
||||||
class AiPromptPreviewBody(ExerciseFormAiPromptContext):
|
class AiPromptPreviewBody(ExerciseFormAiPromptContext):
|
||||||
"""Preview-POST: Übungs-KI und Planungs-Prompts."""
|
"""Preview-POST: gleiche Felder wie ExerciseFormAiPromptContext (focus_hint, nicht focus_area_hint)."""
|
||||||
|
|
||||||
goal_query: Optional[str] = Field(default=None, max_length=2000)
|
|
||||||
user_notes: Optional[str] = Field(default=None, max_length=2000)
|
|
||||||
max_steps: Optional[int] = Field(default=None, ge=2, le=10)
|
|
||||||
search_query: Optional[str] = Field(default=None, max_length=2000)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/api/admin/ai-prompts/catalog/placeholders")
|
@router.get("/api/admin/ai-prompts/catalog/placeholders")
|
||||||
def get_ai_prompt_placeholders_catalog(session: dict = Depends(_require_superadmin)):
|
def get_ai_prompt_placeholders_catalog(session: dict = Depends(_require_superadmin)):
|
||||||
exercise = exercise_placeholder_catalog()
|
return exercise_placeholder_catalog()
|
||||||
planning = planning_prompt_placeholder_catalog()
|
|
||||||
return {
|
|
||||||
"context": "all",
|
|
||||||
"placeholders": list(exercise.get("placeholders") or []) + list(planning.get("placeholders") or []),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/api/admin/ai-prompts")
|
@router.get("/api/admin/ai-prompts")
|
||||||
|
|
@ -239,17 +223,6 @@ def preview_ai_prompt(prompt_id: int, body: AiPromptPreviewBody, session: dict =
|
||||||
vars_map = resolve_exercise_form_variables(cur, slug, body)
|
vars_map = resolve_exercise_form_variables(cur, slug, body)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
raise HTTPException(status_code=400, detail=str(e)) from e
|
raise HTTPException(status_code=400, detail=str(e)) from e
|
||||||
elif is_planning_prompt_slug(slug):
|
|
||||||
planning_in = PlanningPromptPreviewInput(
|
|
||||||
goal_query=(body.goal_query or "Mae Geri vom Grundschritt bis zur Kumite-Nähe").strip(),
|
|
||||||
user_notes=(body.user_notes or "").strip(),
|
|
||||||
max_steps=body.max_steps if body.max_steps is not None else 5,
|
|
||||||
search_query=(body.search_query or body.goal_query or "").strip() or None,
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
vars_map = resolve_planning_prompt_preview_variables(cur, slug, planning_in)
|
|
||||||
except ValueError as e:
|
|
||||||
raise HTTPException(status_code=400, detail=str(e)) from e
|
|
||||||
elif slug == "pipeline":
|
elif slug == "pipeline":
|
||||||
vars_map = {}
|
vars_map = {}
|
||||||
warn = "Pipeline-Slug: keine Kontextsubstitution fuer Vorschau."
|
warn = "Pipeline-Slug: keine Kontextsubstitution fuer Vorschau."
|
||||||
|
|
|
||||||
|
|
@ -1,97 +0,0 @@
|
||||||
"""
|
|
||||||
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.
|
AuthZ analog training_plan_templates: Bearbeiten/Löschen wie Übungen; Governance-Übergänge zentral.
|
||||||
"""
|
"""
|
||||||
import json
|
import json
|
||||||
from typing import Any, Dict, List, Mapping, Optional
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
from pydantic import BaseModel, Field, model_validator
|
from pydantic import BaseModel, Field, model_validator
|
||||||
|
|
@ -19,7 +19,6 @@ from club_tenancy import (
|
||||||
assert_library_content_editable,
|
assert_library_content_editable,
|
||||||
assert_library_content_governance_transition,
|
assert_library_content_governance_transition,
|
||||||
assert_valid_governance_visibility,
|
assert_valid_governance_visibility,
|
||||||
is_platform_admin,
|
|
||||||
library_content_visible_to_profile,
|
library_content_visible_to_profile,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -177,87 +176,6 @@ def _assert_variant_for_exercise(cur, exercise_id: int, variant_id: Optional[int
|
||||||
raise HTTPException(status_code=400, detail="Variante gehört nicht zur gewählten Übung")
|
raise HTTPException(status_code=400, detail="Variante gehört nicht zur gewählten Übung")
|
||||||
|
|
||||||
|
|
||||||
def _exercise_allowed_in_progression_graph(
|
|
||||||
exercise_row: Mapping[str, Any],
|
|
||||||
*,
|
|
||||||
graph_visibility: str,
|
|
||||||
graph_club_id: Optional[int],
|
|
||||||
profile_id: int,
|
|
||||||
role: str,
|
|
||||||
) -> bool:
|
|
||||||
"""Prüft, ob eine Übung zur Sichtbarkeit des Progressionsgraphen passt."""
|
|
||||||
ex_vis = (exercise_row.get("visibility") or "private").strip().lower()
|
|
||||||
gvis = (graph_visibility or "private").strip().lower()
|
|
||||||
if gvis == "private":
|
|
||||||
if ex_vis == "official":
|
|
||||||
return True
|
|
||||||
if ex_vis == "club":
|
|
||||||
return True
|
|
||||||
if ex_vis == "private":
|
|
||||||
if is_platform_admin(role):
|
|
||||||
return True
|
|
||||||
try:
|
|
||||||
return int(exercise_row.get("created_by") or 0) == int(profile_id)
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
return False
|
|
||||||
return False
|
|
||||||
if gvis == "club":
|
|
||||||
if ex_vis == "official":
|
|
||||||
return True
|
|
||||||
if ex_vis != "club":
|
|
||||||
return False
|
|
||||||
ex_club = exercise_row.get("club_id")
|
|
||||||
if ex_club is None:
|
|
||||||
return False
|
|
||||||
if graph_club_id is None:
|
|
||||||
return True
|
|
||||||
return int(ex_club) == int(graph_club_id)
|
|
||||||
return ex_vis == "official"
|
|
||||||
|
|
||||||
|
|
||||||
def _assert_exercises_allowed_in_graph(
|
|
||||||
cur,
|
|
||||||
graph_id: int,
|
|
||||||
profile_id: int,
|
|
||||||
role: str,
|
|
||||||
*exercise_ids: int,
|
|
||||||
) -> None:
|
|
||||||
"""400 wenn eine Übung nicht zur Graph-Sichtbarkeit passt."""
|
|
||||||
row = _graph_row(cur, graph_id)
|
|
||||||
gvis = (row.get("visibility") or "private").strip().lower()
|
|
||||||
gclub_raw = row.get("club_id")
|
|
||||||
gclub = int(gclub_raw) if gclub_raw is not None else None
|
|
||||||
unique = list(dict.fromkeys(exercise_ids))
|
|
||||||
if not unique:
|
|
||||||
return
|
|
||||||
ph = ",".join(["%s"] * len(unique))
|
|
||||||
cur.execute(
|
|
||||||
f"SELECT id, title, visibility, club_id, created_by FROM exercises WHERE id IN ({ph})",
|
|
||||||
tuple(unique),
|
|
||||||
)
|
|
||||||
by_id = {int(r2d(r)["id"]): r2d(r) for r in cur.fetchall()}
|
|
||||||
for eid in unique:
|
|
||||||
ex = by_id.get(int(eid))
|
|
||||||
if not ex:
|
|
||||||
continue
|
|
||||||
if _exercise_allowed_in_progression_graph(
|
|
||||||
ex,
|
|
||||||
graph_visibility=gvis,
|
|
||||||
graph_club_id=gclub,
|
|
||||||
profile_id=profile_id,
|
|
||||||
role=role,
|
|
||||||
):
|
|
||||||
continue
|
|
||||||
title = (ex.get("title") or "").strip() or f"#{eid}"
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=400,
|
|
||||||
detail=(
|
|
||||||
f"Übung „{title}“ (Sichtbarkeit: {ex.get('visibility') or 'private'}) "
|
|
||||||
f"passt nicht zum Progressionsgraphen ({gvis})."
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _insert_edge_row(
|
def _insert_edge_row(
|
||||||
cur,
|
cur,
|
||||||
graph_id: int,
|
graph_id: int,
|
||||||
|
|
@ -394,22 +312,6 @@ def _collect_graph_referenced_exercise_ids(cur, graph_id: int) -> set[int]:
|
||||||
return ids
|
return ids
|
||||||
|
|
||||||
|
|
||||||
def _graph_promotion_transition(graph_visibility: str, target_visibility: str) -> Optional[tuple[str, ...]]:
|
|
||||||
"""
|
|
||||||
Erlaubte Graph-Promotions und welche Übungs-Sichtbarkeiten mit angehoben werden müssen.
|
|
||||||
|
|
||||||
Returns None wenn kein Übungs-Promotion-Hinweis nötig.
|
|
||||||
"""
|
|
||||||
gvis = (graph_visibility or "private").strip().lower()
|
|
||||||
tvis = (target_visibility or "").strip().lower()
|
|
||||||
transitions: Dict[tuple[str, str], tuple[str, ...]] = {
|
|
||||||
("private", "club"): ("private",),
|
|
||||||
("private", "official"): ("private", "club"),
|
|
||||||
("club", "official"): ("private", "club"),
|
|
||||||
}
|
|
||||||
return transitions.get((gvis, tvis))
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/exercise-progression-graphs/{graph_id}/visibility-promotion-candidates")
|
@router.get("/exercise-progression-graphs/{graph_id}/visibility-promotion-candidates")
|
||||||
def list_visibility_promotion_candidates(
|
def list_visibility_promotion_candidates(
|
||||||
graph_id: int,
|
graph_id: int,
|
||||||
|
|
@ -417,9 +319,7 @@ def list_visibility_promotion_candidates(
|
||||||
tenant: TenantContext = Depends(get_tenant_context),
|
tenant: TenantContext = Depends(get_tenant_context),
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Übungen im Graph, die bei Promotion des Graphen mit angehoben werden müssten.
|
Private Ü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
|
profile_id = tenant.profile_id
|
||||||
role = tenant.global_role
|
role = tenant.global_role
|
||||||
|
|
@ -427,13 +327,11 @@ def list_visibility_promotion_candidates(
|
||||||
cur = get_cursor(conn)
|
cur = get_cursor(conn)
|
||||||
row = _require_graph_read(cur, graph_id, profile_id, role)
|
row = _require_graph_read(cur, graph_id, profile_id, role)
|
||||||
graph_vis = (row.get("visibility") or "private").strip().lower()
|
graph_vis = (row.get("visibility") or "private").strip().lower()
|
||||||
target_vis = (target_visibility or "club").strip().lower()
|
if graph_vis != "private" or target_visibility != "club":
|
||||||
need_vis = _graph_promotion_transition(graph_vis, target_vis)
|
|
||||||
if not need_vis:
|
|
||||||
return {
|
return {
|
||||||
"graph_id": graph_id,
|
"graph_id": graph_id,
|
||||||
"graph_visibility": graph_vis,
|
"graph_visibility": graph_vis,
|
||||||
"target_visibility": target_vis,
|
"target_visibility": target_visibility,
|
||||||
"exercises": [],
|
"exercises": [],
|
||||||
}
|
}
|
||||||
ref_ids = _collect_graph_referenced_exercise_ids(cur, graph_id)
|
ref_ids = _collect_graph_referenced_exercise_ids(cur, graph_id)
|
||||||
|
|
@ -441,20 +339,19 @@ def list_visibility_promotion_candidates(
|
||||||
return {
|
return {
|
||||||
"graph_id": graph_id,
|
"graph_id": graph_id,
|
||||||
"graph_visibility": graph_vis,
|
"graph_visibility": graph_vis,
|
||||||
"target_visibility": target_vis,
|
"target_visibility": target_visibility,
|
||||||
"exercises": [],
|
"exercises": [],
|
||||||
}
|
}
|
||||||
vis_placeholders = ",".join(["%s"] * len(need_vis))
|
|
||||||
ph = ",".join(["%s"] * len(ref_ids))
|
ph = ",".join(["%s"] * len(ref_ids))
|
||||||
cur.execute(
|
cur.execute(
|
||||||
f"""
|
f"""
|
||||||
SELECT id, title, visibility, club_id, created_by
|
SELECT id, title, visibility, club_id, created_by
|
||||||
FROM exercises
|
FROM exercises
|
||||||
WHERE id IN ({ph})
|
WHERE id IN ({ph})
|
||||||
AND LOWER(TRIM(COALESCE(visibility, ''))) IN ({vis_placeholders})
|
AND LOWER(TRIM(COALESCE(visibility, ''))) = 'private'
|
||||||
ORDER BY title
|
ORDER BY title
|
||||||
""",
|
""",
|
||||||
list(ref_ids) + list(need_vis),
|
list(ref_ids),
|
||||||
)
|
)
|
||||||
exercises = []
|
exercises = []
|
||||||
for ex in cur.fetchall():
|
for ex in cur.fetchall():
|
||||||
|
|
@ -462,10 +359,8 @@ def list_visibility_promotion_candidates(
|
||||||
if not library_content_visible_to_profile(
|
if not library_content_visible_to_profile(
|
||||||
cur,
|
cur,
|
||||||
profile_id,
|
profile_id,
|
||||||
(exd.get("visibility") or "private").strip().lower(),
|
|
||||||
exd.get("club_id"),
|
|
||||||
exd.get("created_by"),
|
|
||||||
role,
|
role,
|
||||||
|
exd,
|
||||||
):
|
):
|
||||||
continue
|
continue
|
||||||
exercises.append(
|
exercises.append(
|
||||||
|
|
@ -478,7 +373,7 @@ def list_visibility_promotion_candidates(
|
||||||
return {
|
return {
|
||||||
"graph_id": graph_id,
|
"graph_id": graph_id,
|
||||||
"graph_visibility": graph_vis,
|
"graph_visibility": graph_vis,
|
||||||
"target_visibility": target_vis,
|
"target_visibility": target_visibility,
|
||||||
"exercises": exercises,
|
"exercises": exercises,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -670,9 +565,6 @@ def create_progression_edge(
|
||||||
cur = get_cursor(conn)
|
cur = get_cursor(conn)
|
||||||
_require_graph_write(cur, graph_id, profile_id, role)
|
_require_graph_write(cur, graph_id, profile_id, role)
|
||||||
_assert_exercises_exist(cur, body.from_exercise_id, body.to_exercise_id)
|
_assert_exercises_exist(cur, body.from_exercise_id, body.to_exercise_id)
|
||||||
_assert_exercises_allowed_in_graph(
|
|
||||||
cur, graph_id, profile_id, role, body.from_exercise_id, body.to_exercise_id
|
|
||||||
)
|
|
||||||
fv = body.from_exercise_variant_id
|
fv = body.from_exercise_variant_id
|
||||||
tv = body.to_exercise_variant_id
|
tv = body.to_exercise_variant_id
|
||||||
_assert_variant_for_exercise(cur, body.from_exercise_id, fv)
|
_assert_variant_for_exercise(cur, body.from_exercise_id, fv)
|
||||||
|
|
@ -721,7 +613,6 @@ def create_progression_sequence(
|
||||||
|
|
||||||
ex_ids = [s.exercise_id for s in steps]
|
ex_ids = [s.exercise_id for s in steps]
|
||||||
_assert_exercises_exist(cur, *ex_ids)
|
_assert_exercises_exist(cur, *ex_ids)
|
||||||
_assert_exercises_allowed_in_graph(cur, graph_id, profile_id, role, *ex_ids)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
for i in range(n_seg):
|
for i in range(n_seg):
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,6 @@ from db import get_db, get_cursor
|
||||||
from tenant_context import TenantContext, get_tenant_context
|
from tenant_context import TenantContext, get_tenant_context
|
||||||
from planning_exercise_suggest import PlanningExerciseSuggestRequest, suggest_planning_exercises
|
from planning_exercise_suggest import PlanningExerciseSuggestRequest, suggest_planning_exercises
|
||||||
from planning_exercise_path_builder import ProgressionPathSuggestRequest, suggest_progression_path
|
from planning_exercise_path_builder import ProgressionPathSuggestRequest, suggest_progression_path
|
||||||
from planning_llm_usage import planning_llm_call_meter
|
|
||||||
from account_lifecycle import assert_min_account_state
|
from account_lifecycle import assert_min_account_state
|
||||||
from capabilities import probe_capability
|
from capabilities import probe_capability
|
||||||
from club_features import (
|
from club_features import (
|
||||||
|
|
@ -47,25 +46,19 @@ def post_planning_exercise_suggest(
|
||||||
)
|
)
|
||||||
with get_db() as conn:
|
with get_db() as conn:
|
||||||
cur = get_cursor(conn)
|
cur = get_cursor(conn)
|
||||||
with planning_llm_call_meter() as llm_meter:
|
result = suggest_planning_exercises(cur, tenant=tenant, body=body)
|
||||||
result = suggest_planning_exercises(cur, tenant=tenant, body=body)
|
if uses_ai:
|
||||||
if uses_ai and llm_meter.count > 0:
|
|
||||||
usage = consume_club_feature_with_usage(
|
usage = consume_club_feature_with_usage(
|
||||||
feature_id="ai_calls",
|
feature_id="ai_calls",
|
||||||
club_id=club_id,
|
club_id=club_id,
|
||||||
profile_id=tenant.profile_id,
|
profile_id=tenant.profile_id,
|
||||||
portal_role=tenant.global_role,
|
portal_role=tenant.global_role,
|
||||||
action="planning_suggest",
|
action="planning_suggest",
|
||||||
amount=llm_meter.count,
|
|
||||||
cur=cur,
|
cur=cur,
|
||||||
tenant=tenant,
|
tenant=tenant,
|
||||||
conn=conn,
|
conn=conn,
|
||||||
)
|
)
|
||||||
result = merge_feature_usage_into_response(result, usage)
|
result = merge_feature_usage_into_response(result, usage)
|
||||||
if isinstance(result, dict):
|
|
||||||
result["llm_call_count"] = llm_meter.count
|
|
||||||
elif uses_ai and isinstance(result, dict):
|
|
||||||
result["llm_call_count"] = 0
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -77,6 +70,7 @@ def post_progression_path_suggest(
|
||||||
uses_ai = (
|
uses_ai = (
|
||||||
body.include_llm_intent
|
body.include_llm_intent
|
||||||
or body.include_llm_path_qa
|
or body.include_llm_path_qa
|
||||||
|
or body.include_ai_gap_fill
|
||||||
or body.include_llm_roadmap
|
or body.include_llm_roadmap
|
||||||
or body.include_llm_start_target
|
or body.include_llm_start_target
|
||||||
or (body.start_target_only and body.include_llm_start_target)
|
or (body.start_target_only and body.include_llm_start_target)
|
||||||
|
|
@ -104,23 +98,17 @@ def post_progression_path_suggest(
|
||||||
)
|
)
|
||||||
with get_db() as conn:
|
with get_db() as conn:
|
||||||
cur = get_cursor(conn)
|
cur = get_cursor(conn)
|
||||||
with planning_llm_call_meter() as llm_meter:
|
result = suggest_progression_path(cur, tenant=tenant, body=body)
|
||||||
result = suggest_progression_path(cur, tenant=tenant, body=body)
|
if uses_ai:
|
||||||
if uses_ai and llm_meter.count > 0:
|
|
||||||
usage = consume_club_feature_with_usage(
|
usage = consume_club_feature_with_usage(
|
||||||
feature_id="ai_calls",
|
feature_id="ai_calls",
|
||||||
club_id=club_id,
|
club_id=club_id,
|
||||||
profile_id=tenant.profile_id,
|
profile_id=tenant.profile_id,
|
||||||
portal_role=tenant.global_role,
|
portal_role=tenant.global_role,
|
||||||
action="progression_path_suggest",
|
action="progression_path_suggest",
|
||||||
amount=llm_meter.count,
|
|
||||||
cur=cur,
|
cur=cur,
|
||||||
tenant=tenant,
|
tenant=tenant,
|
||||||
conn=conn,
|
conn=conn,
|
||||||
)
|
)
|
||||||
result = merge_feature_usage_into_response(result, usage)
|
result = merge_feature_usage_into_response(result, usage)
|
||||||
if isinstance(result, dict):
|
|
||||||
result["llm_call_count"] = llm_meter.count
|
|
||||||
elif uses_ai and isinstance(result, dict):
|
|
||||||
result["llm_call_count"] = 0
|
|
||||||
return result
|
return result
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,6 @@ EXEMPT_ROUTERS: frozenset[str] = frozenset(
|
||||||
"admin_user_content.py", # Superadmin Moderation nutzerangelegter Inhalte; require_auth + is_superadmin — kein Vereinsmandant
|
"admin_user_content.py", # Superadmin Moderation nutzerangelegter Inhalte; require_auth + is_superadmin — kein Vereinsmandant
|
||||||
"admin_rights.py", # Superadmin Rollen/Rechte (Capabilities, Kontingent-Bypass, Pläne); require_auth + is_superadmin — kein Vereinsmandant
|
"admin_rights.py", # Superadmin Rollen/Rechte (Capabilities, Kontingent-Bypass, Pläne); require_auth + is_superadmin — kein Vereinsmandant
|
||||||
"catalogs.py",
|
"catalogs.py",
|
||||||
"catalog_prompt_slots.py", # Admin Stammdaten KI-Prompt-Slots; require_auth + admin/superadmin — globaler Katalog, kein Vereinsmandant
|
|
||||||
"skills.py",
|
"skills.py",
|
||||||
"maturity_models.py",
|
"maturity_models.py",
|
||||||
"matrix_stack_bundle.py",
|
"matrix_stack_bundle.py",
|
||||||
|
|
|
||||||
|
|
@ -1,89 +0,0 @@
|
||||||
"""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
|
|
||||||
|
|
@ -1,38 +0,0 @@
|
||||||
"""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"]
|
|
||||||
|
|
@ -1,80 +0,0 @@
|
||||||
"""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",
|
|
||||||
)
|
|
||||||
|
|
@ -1,168 +0,0 @@
|
||||||
"""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,28 +3,19 @@ from planning_exercise_path_qa import compute_deterministic_path_quality_score
|
||||||
|
|
||||||
|
|
||||||
def test_deterministic_quality_score_penalizes_off_topic():
|
def test_deterministic_quality_score_penalizes_off_topic():
|
||||||
steps = [{"roadmap_major_step_index": 0, "exercise_id": 1}]
|
base = compute_deterministic_path_quality_score(gaps=[], off_topic_steps=[])
|
||||||
base = compute_deterministic_path_quality_score(gaps=[], off_topic_steps=[], steps=steps)
|
|
||||||
with_off = compute_deterministic_path_quality_score(
|
with_off = compute_deterministic_path_quality_score(
|
||||||
gaps=[],
|
gaps=[],
|
||||||
off_topic_steps=[{"roadmap_major_step_index": 1}],
|
off_topic_steps=[{"roadmap_major_step_index": 1}],
|
||||||
steps=steps,
|
|
||||||
)
|
)
|
||||||
assert with_off < base
|
assert with_off < base
|
||||||
|
|
||||||
|
|
||||||
def test_deterministic_quality_score_penalizes_empty_slots():
|
def test_deterministic_quality_score_penalizes_empty_slots():
|
||||||
filled = [{"roadmap_major_step_index": 0, "exercise_id": 1}]
|
base = compute_deterministic_path_quality_score(gaps=[], off_topic_steps=[], steps=[])
|
||||||
base = compute_deterministic_path_quality_score(gaps=[], off_topic_steps=[], steps=filled)
|
|
||||||
with_empty = compute_deterministic_path_quality_score(
|
with_empty = compute_deterministic_path_quality_score(
|
||||||
gaps=[],
|
gaps=[],
|
||||||
off_topic_steps=[],
|
off_topic_steps=[],
|
||||||
steps=[{"roadmap_major_step_index": 0, "exercise_id": None}, {"roadmap_major_step_index": 1, "exercise_id": 2}],
|
steps=[{"exercise_id": None}, {"exercise_id": 1}],
|
||||||
)
|
|
||||||
all_empty = compute_deterministic_path_quality_score(
|
|
||||||
gaps=[],
|
|
||||||
off_topic_steps=[],
|
|
||||||
steps=[{"roadmap_major_step_index": 0, "exercise_id": None}] * 4,
|
|
||||||
)
|
)
|
||||||
assert with_empty < base
|
assert with_empty < base
|
||||||
assert all_empty <= 0.15
|
|
||||||
|
|
|
||||||
|
|
@ -1,94 +0,0 @@
|
||||||
"""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
|
|
||||||
|
|
@ -1,62 +0,0 @@
|
||||||
"""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,7 +2,6 @@
|
||||||
from planning_exercise_path_builder import (
|
from planning_exercise_path_builder import (
|
||||||
_parse_slot_refs_from_text,
|
_parse_slot_refs_from_text,
|
||||||
_problematic_slots_from_path_qa,
|
_problematic_slots_from_path_qa,
|
||||||
_slot_auto_select_ai,
|
|
||||||
_slot_auto_select_library,
|
_slot_auto_select_library,
|
||||||
_slot_suggestion_accepted,
|
_slot_suggestion_accepted,
|
||||||
)
|
)
|
||||||
|
|
@ -114,27 +113,6 @@ def test_slot_auto_select_requires_higher_score():
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_slot_auto_select_empty_slot_requires_good_fit():
|
|
||||||
assert not _slot_auto_select_library(
|
|
||||||
baseline_slot_score=None,
|
|
||||||
proposed_slot_score=0.35,
|
|
||||||
baseline_exercise_id=None,
|
|
||||||
proposed_exercise_id=2,
|
|
||||||
)
|
|
||||||
assert _slot_auto_select_library(
|
|
||||||
baseline_slot_score=None,
|
|
||||||
proposed_slot_score=0.55,
|
|
||||||
baseline_exercise_id=None,
|
|
||||||
proposed_exercise_id=2,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_slot_auto_select_ai_when_library_not_selected():
|
|
||||||
assert _slot_auto_select_ai(library_auto_select=False, has_ai=True)
|
|
||||||
assert not _slot_auto_select_ai(library_auto_select=True, has_ai=True)
|
|
||||||
assert not _slot_auto_select_ai(library_auto_select=False, has_ai=False)
|
|
||||||
|
|
||||||
|
|
||||||
def test_off_topic_slot_gap_spec_for_filled_slot():
|
def test_off_topic_slot_gap_spec_for_filled_slot():
|
||||||
from planning_exercise_path_builder import _build_off_topic_slot_gap_spec
|
from planning_exercise_path_builder import _build_off_topic_slot_gap_spec
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
# Shinkan Jinkendo Version Information
|
# Shinkan Jinkendo Version Information
|
||||||
|
|
||||||
APP_VERSION = "0.8.237"
|
APP_VERSION = "0.8.233"
|
||||||
BUILD_DATE = "2026-05-22"
|
BUILD_DATE = "2026-05-22"
|
||||||
DB_SCHEMA_VERSION = "20260607094"
|
DB_SCHEMA_VERSION = "20260607090"
|
||||||
|
|
||||||
MODULE_VERSIONS = {
|
MODULE_VERSIONS = {
|
||||||
"legal_documents": "1.4.0", # Admin: Live-Vorschau pro Abschnitt + modale Vollvorschau (Editor + Dokumentenliste)
|
"legal_documents": "1.4.0", # Admin: Live-Vorschau pro Abschnitt + modale Vollvorschau (Editor + Dokumentenliste)
|
||||||
|
|
@ -29,7 +29,7 @@ MODULE_VERSIONS = {
|
||||||
"media_lifecycle": "1.1.0", # P-11: Retention-Job ueberspringt Legal-Hold-Assets
|
"media_lifecycle": "1.1.0", # P-11: Retention-Job ueberspringt Legal-Hold-Assets
|
||||||
"admin_ai_skill_retrieval": "1.0.0", # Superadmin CRUD /api/admin/ai-skill-retrieval-profiles (Migration 068)
|
"admin_ai_skill_retrieval": "1.0.0", # Superadmin CRUD /api/admin/ai-skill-retrieval-profiles (Migration 068)
|
||||||
"exercise_enrichment_admin": "1.1.1", # Preview max 3/Request + parallel LLM (Gateway-504 vermeiden)
|
"exercise_enrichment_admin": "1.1.1", # Preview max 3/Request + parallel LLM (Gateway-504 vermeiden)
|
||||||
"admin_ai_prompts": "1.0.5", # H2: granulare Katalog-Slot-Platzhalter im Katalog
|
"admin_ai_prompts": "1.0.3", # Migration 070: openrouter_model; PUT/Liste/Detail
|
||||||
"ai_prompt_job": "0.2.1", # want_instructions; run_exercise_form_ai_suggestion
|
"ai_prompt_job": "0.2.1", # want_instructions; run_exercise_form_ai_suggestion
|
||||||
"ai_prompt_context": "0.2.0", # preparation/trainer_notes; has_instruction_source_text
|
"ai_prompt_context": "0.2.0", # preparation/trainer_notes; has_instruction_source_text
|
||||||
"ai_prompt_runtime": "0.2.2", # Slug planning_exercise_expectation_profile
|
"ai_prompt_runtime": "0.2.2", # Slug planning_exercise_expectation_profile
|
||||||
|
|
@ -53,40 +53,6 @@ MODULE_VERSIONS = {
|
||||||
}
|
}
|
||||||
|
|
||||||
CHANGELOG = [
|
CHANGELOG = [
|
||||||
{
|
|
||||||
"version": "0.8.237",
|
|
||||||
"date": "2026-05-22",
|
|
||||||
"changes": [
|
|
||||||
"Migration 094: catalog_prompt_slots vollständig befüllt (Karate, SV, alle Trainingsstile/Zielgruppen).",
|
|
||||||
"catalog_slot_fallbacks: Namens-Fallback bis Admin-Override — gleiche Qualität wie H1-Registry.",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"version": "0.8.236",
|
|
||||||
"date": "2026-05-22",
|
|
||||||
"changes": [
|
|
||||||
"Stammdaten-Katalog: CatalogPromptSlotsEditor für Fokus, Trainingsstil, Zielgruppe, Stilrichtung.",
|
|
||||||
"Migration 093: ai_prompts mit granularen Slot-Platzhaltern (focus_area_hints_on_path_qa etc.).",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"version": "0.8.235",
|
|
||||||
"date": "2026-05-22",
|
|
||||||
"changes": [
|
|
||||||
"Planungs-KI H2: catalog_prompt_slot_types + catalog_prompt_slots — Slot-Werte pro Katalog-Eintrag.",
|
|
||||||
"Granulare Platzhalter focus_area_hints_on_progression etc.; Resolver catalog_prompt_slots.py.",
|
|
||||||
"Admin-API GET/PUT /api/catalog-prompt-slots/{kind}/{id}; H1-Registry entfernt.",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"version": "0.8.234",
|
|
||||||
"date": "2026-05-22",
|
|
||||||
"changes": [
|
|
||||||
"Planungs-KI H1: Katalog-Snippets (planning_catalog_prompt_snippets) + zentrale Platzhalter (planning_prompt_variables).",
|
|
||||||
"Pfad-QS, Roadmap, Stufenspec: {{catalog_guidance_block}} aus Trainer-Katalog; Migration 091.",
|
|
||||||
"Admin: Planungs-Platzhalter-Katalog; Preview mit optional planning_catalog_context.",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"version": "0.8.226",
|
"version": "0.8.226",
|
||||||
"date": "2026-05-22",
|
"date": "2026-05-22",
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
# Shinkan Jinkendo – Entwicklungsstand & Handover
|
# Shinkan Jinkendo – Entwicklungsstand & Handover
|
||||||
|
|
||||||
**Stand:** 2026-05-22 (F15 Graph-Match & getrennte Pfad-QS, lokal nach **0.8.233**)
|
**Stand:** 2026-05-22
|
||||||
**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**).
|
**App-Version / DB-Schema:** App **`0.8.233`** (Planungs-KI F11–F14, Katalog-Kontext); DB siehe **`backend/version.py`** (`DB_SCHEMA_VERSION`, Migration **088**).
|
||||||
|
|
||||||
Diese Datei ist die **Einstiegs-Doku für neue Chat-Sessions**: Anforderungen im Detail stehen in `.claude/docs/` (siehe unten); hier der **implementierte Stand**, **Medien-Meilenstein** und **sinnvolle nächste Schritte**.
|
Diese Datei ist die **Einstiegs-Doku für neue Chat-Sessions**: Anforderungen im Detail stehen in `.claude/docs/` (siehe unten); hier der **implementierte Stand**, **Medien-Meilenstein** und **sinnvolle nächste Schritte**.
|
||||||
|
|
||||||
|
|
@ -114,25 +114,11 @@ 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** |
|
| **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** |
|
| **F13** | **`planning_catalog_context`** (Fokus/Stil/TT/ZG) im Match + Graph-Artefakt | ✅ **0.8.233** |
|
||||||
| **F14** | **`ProgressionGraphEditor`** — Slot-UI + Planungskontext-Dropdowns | ✅ **0.8.233** |
|
| **F14** | **`ProgressionGraphEditor`** — Slot-UI + Planungskontext-Dropdowns | ✅ **0.8.233** |
|
||||||
| **F15** | Unified Slot-Review (Match-Dialog), getrennte Pfad-QS, `findings_stale` | ✅ lokal (nach 0.8.233) |
|
|
||||||
| **H1** | Katalog-Prompt-Snippets (modulare LLM-Anweisungen) | 🔲 Spec **`docs/architecture/PLANNING_CATALOG_PROMPT_SNIPPETS.md`** |
|
| **H1** | Katalog-Prompt-Snippets (modulare LLM-Anweisungen) | 🔲 Spec **`docs/architecture/PLANNING_CATALOG_PROMPT_SNIPPETS.md`** |
|
||||||
|
|
||||||
**Architektur (verbindlich):** Drei Schichten — (1) **Katalog-Dimensionen** (DB, jetzt im Match verdrahtet; **H1:** zusätzlich Prompt-Snippets), (2) **Technik-Disambiguierung** (Code, nur bei `topic_type=technique`), (3) **Didaktik** (Roadmap + LLM-QS, nicht im Vokabular). Progressionsgraph = **Roadmap-first**, **keine Gruppenanalyse**. Bestehender Graph = **leichter Nachfolger-Bias** ab Schritt 2. Trainingsplanung = **eigene Pipeline** (Phase G) — Wiederverwendung der Bausteine, siehe Ist-Doku §16.
|
**Architektur (verbindlich):** Drei Schichten — (1) **Katalog-Dimensionen** (DB, jetzt im Match verdrahtet; **H1:** zusätzlich Prompt-Snippets), (2) **Technik-Disambiguierung** (Code, nur bei `topic_type=technique`), (3) **Didaktik** (Roadmap + LLM-QS, nicht im Vokabular). Progressionsgraph = **Roadmap-first**, **keine Gruppenanalyse**. Bestehender Graph = **leichter Nachfolger-Bias** ab Schritt 2. Trainingsplanung = **eigene Pipeline** (Phase G) — Wiederverwendung der Bausteine, siehe Ist-Doku §16.
|
||||||
|
|
||||||
**Validierung (Mae Geri, Härtetest):** 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.
|
**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.
|
||||||
|
|
||||||
#### F15 — Match-Dialog, Bewertung, Pfad-QS (Stand 2026-05-22)
|
|
||||||
|
|
||||||
| Thema | Ist |
|
|
||||||
|--------|-----|
|
|
||||||
| **„Übungen matchen“** | Schritt 1: `evaluate_only` (wie „Graph bewerten“) · Schritt 2: `unified_slot_review: true` → Dialog **pro Slot** (Bewertung, Bibliotheks-Alternative, optional KI) |
|
|
||||||
| **Vorauswahl Dialog** | Bibliothek nur bei Stufen-Fit **≥ 50 %** und besser als aktuell; bei leerem Slot + schwacher Bibliothek → **KI-Vorschlag** vorausgewählt |
|
|
||||||
| **Übernahme** | Nur gewählte Slots speichern — **keine** automatische teure Nach-Bewertung |
|
|
||||||
| **Bewertung veraltet** | Nach Graph-Änderungen Hinweis im Findings-Panel; persistiert als **`findings_stale`** im `planning_roadmap`-Artefakt (mit Speichern) |
|
|
||||||
| **Getrennte QS** | `path_qa.roadmap_qa` (Stufen/Roadmap/LLM) + `path_qa.assignment_qa` (Slot-Befüllung); **`quality_score`** = Minimum beider |
|
|
||||||
| **UX-Fix** | Slot-Karten: stabiler React-Key (`slot-{index}`) — Lernziel editierbar ohne Fokusverlust |
|
|
||||||
|
|
||||||
**Code:** `ProgressionOptimizeCompareModal.jsx`, `planning_exercise_path_builder.py` (`_build_unified_slot_review_entry`, `_slot_auto_select_*`), `planning_exercise_path_qa.py` (`build_*_qa_snapshot`), `progression_graph_planning_artifact.py` (`findings_stale`), `progressionGraphDraft.js`
|
|
||||||
|
|
||||||
**Backend-Kern:** `planning_progression_roadmap.py`, `planning_exercise_path_builder.py`, `planning_catalog_context.py`, `planning_path_rematch.py`, `planning_path_refine_stage.py`, `planning_path_qa_pipeline.py`, `planning_skill_expectations.py`, `planning_exercise_form_context.py`, `planning_exercise_path_ai_fill.py`, `progression_graph_planning_artifact.py`
|
**Backend-Kern:** `planning_progression_roadmap.py`, `planning_exercise_path_builder.py`, `planning_catalog_context.py`, `planning_path_rematch.py`, `planning_path_refine_stage.py`, `planning_path_qa_pipeline.py`, `planning_skill_expectations.py`, `planning_exercise_form_context.py`, `planning_exercise_path_ai_fill.py`, `progression_graph_planning_artifact.py`
|
||||||
|
|
||||||
|
|
@ -143,12 +129,12 @@ Das Schema ist gegenüber dem Code zurück: Migration **`022_skills_schema_compl
|
||||||
**Offen (priorisiert):**
|
**Offen (priorisiert):**
|
||||||
1. Dev-Regression: Gewaltschutz / Breitensport / Kinder (nicht nur Mae Geri)
|
1. Dev-Regression: Gewaltschutz / Breitensport / Kinder (nicht nur Mae Geri)
|
||||||
2. **PathBuilder-Parität** — gleiche Katalog-Dropdowns wie GraphEditor
|
2. **PathBuilder-Parität** — gleiche Katalog-Dropdowns wie GraphEditor
|
||||||
3. UI-Wizard (4 Schritte: Ziel → Roadmap → Match → Lücken)
|
3. QS-UI — positive LLM-Hinweise als Highlights
|
||||||
4. Graph-Erweiterungsmodus (Start ab Knoten)
|
4. UI-Wizard (4 Schritte: Ziel → Roadmap → Match → Lücken)
|
||||||
5. Phase D′ — Auto KI-Gap-Fill bei persistent leeren Slots
|
5. Graph-Erweiterungsmodus (Start ab Knoten)
|
||||||
6. **Trainingsplanung Phase G** — Gruppenkontext-Pack, Scopes `training_section` / `framework_slot` (Ist-Doku §16)
|
6. Phase D′ — Auto KI-Gap-Fill bei persistent leeren Slots
|
||||||
7. Technik-Katalog konfigurierbar (Backlog)
|
7. **Trainingsplanung Phase G** — Gruppenkontext-Pack, Scopes `training_section` / `framework_slot` (Ist-Doku §16)
|
||||||
8. **H1** — Katalog-Prompt-Snippets (modulare LLM-Anweisungen)
|
8. Technik-Katalog konfigurierbar (Backlog)
|
||||||
|
|
||||||
#### Übungs-KI Formular / Schnellanlage (Stand **0.8.171**)
|
#### Übungs-KI Formular / Schnellanlage (Stand **0.8.171**)
|
||||||
|
|
||||||
|
|
@ -285,7 +271,8 @@ Das Schema ist gegenüber dem Code zurück: Migration **`022_skills_schema_compl
|
||||||
|
|
||||||
1. **H1 Katalog-Prompt-Snippets:** modulare LLM-Anweisungen — **`docs/architecture/PLANNING_CATALOG_PROMPT_SNIPPETS.md`** (Priorität: Primärfokus → Trainingsstil → Zielgruppe → Stilrichtung).
|
1. **H1 Katalog-Prompt-Snippets:** modulare LLM-Anweisungen — **`docs/architecture/PLANNING_CATALOG_PROMPT_SNIPPETS.md`** (Priorität: Primärfokus → Trainingsstil → Zielgruppe → Stilrichtung).
|
||||||
2. **Dev-Regression:** Katalog-Match für Gewaltschutz, Breitensport, Kinder — nicht nur Mae-Geri-Härtetest.
|
2. **Dev-Regression:** Katalog-Match für Gewaltschutz, Breitensport, Kinder — nicht nur Mae-Geri-Härtetest.
|
||||||
3. **PathBuilder-Parität:** `planning_catalog_context`-Dropdowns auch in `ExerciseProgressionPathBuilder`.
|
2. **PathBuilder-Parität:** `planning_catalog_context`-Dropdowns auch in `ExerciseProgressionPathBuilder`.
|
||||||
|
3. **QS-UI:** positive LLM-Empfehlungen als Highlights statt nur Optimierungspotenziale.
|
||||||
4. **UI-Wizard:** 4 Schritte (Ziel & Katalog → Roadmap → Match → Lücken); Backend-Pipeline unverändert.
|
4. **UI-Wizard:** 4 Schritte (Ziel & Katalog → Roadmap → Match → Lücken); Backend-Pipeline unverändert.
|
||||||
5. **Phase D′:** automatisches KI-Gap-Fill bei persistent `roadmap_unfilled`.
|
5. **Phase D′:** automatisches KI-Gap-Fill bei persistent `roadmap_unfilled`.
|
||||||
6. **Trainingsplanung 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`**.
|
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,216 +1,229 @@
|
||||||
# Planungs-KI — Katalog-Prompt-Slots (Snippets)
|
# Planungs-KI — Katalog-Snippets für modulare Prompts
|
||||||
|
|
||||||
**Stand:** 2026-05-22
|
**Stand:** 2026-05-22
|
||||||
**Status:** **H2** umgesetzt (0.8.235) · **H2.1** Admin-UI + granulare Prompts (0.8.236)
|
**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` · `catalog_prompt_slots.py`
|
**Bezüge:** `PLANNING_PROGRESSION_GRAPH_KI.md` §4.4 · `AI_PROMPT_TARGET_ARCHITECTURE.md` §2.4 · `planning_catalog_context.py`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 1. Problem
|
## 1. Problem
|
||||||
|
|
||||||
Seit **F13 (0.8.233)** fließen Primärfokus, Trainingsstil, Zielgruppe und Stilrichtung als `planning_catalog_context` in **Retrieval und Scoring**.
|
Seit **F13 (0.8.233)** fließen Primärfokus, Trainingsstil, Zielgruppe und Stilrichtung als `planning_catalog_context` in **Retrieval und Scoring** (`PlanningTargetProfile`).
|
||||||
|
|
||||||
Die **LLM-Prompts** brauchen zusätzlich **fachliche Textbausteine** pro gewähltem Katalog-Eintrag — editierbar, ohne Code-Deploy, unabhängig von fest codierten Katalog-Namen.
|
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.
|
||||||
|
|
||||||
**Ziel:** Prompts in `ai_prompts` referenzieren **Slot-Typen** (Vokabular). Inhalte hängen an **Katalog-Zeilen** (Stammdaten). Der Resolver füllt Platzhalter zur Laufzeit.
|
**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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 2. Zwei Ebenen (Kern des Modells)
|
## 2. Priorität der Dimensionen (absteigend)
|
||||||
|
|
||||||
| Ebene | Was | Wer pflegt | Beispiel |
|
Verbindliche Reihenfolge bei Konflikten und beim Rollout:
|
||||||
|-------|-----|------------|----------|
|
|
||||||
| **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 …“ |
|
|
||||||
|
|
||||||
**Nicht:** feste Attribute pro Eintrag im Code (`planning_lens`, `qa_criteria` …).
|
| Rang | Dimension | DB-Tabelle | Snippet-Rolle |
|
||||||
**Sondern:** beliebig viele Katalog-Einträge × definierte Slot-Typen.
|
|------|-----------|------------|----------------|
|
||||||
|
| **1** | **Primärfokus** | `focus_areas` | Definiert *worüber* geplant und bewertet wird (Technik-Curriculum vs. Gewaltschutz vs. Fitness …). **Dominant.** |
|
||||||
|
| **2** | **Trainingsstil** | `training_types` | Definiert *wie* trainiert wird (Methodik, Belastungsaufbau, Wettkampf vs. Breitenansatz im Training). |
|
||||||
|
| **3** | **Zielgruppe** | `target_groups` | Definiert *für wen* (Kinder, Breitensport, Leistungsgruppe) — Tempo, Komplexität, Sicherheit. |
|
||||||
|
| **4** | **Stilrichtung** | `style_directions` | Vereins-/Stil-Linie (Shotokan, WKF …) — **Nuancen**, kein neuer Planungstyp. |
|
||||||
|
|
||||||
|
**Kaskaden-Regel:** Bei widersprüchlichen Snippet-Aussagen gilt: Primärfokus → Trainingsstil → Zielgruppe → Stilrichtung.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 3. Dimensionen & Priorität
|
## 3. Architektur — drei Schichten (Erinnerung)
|
||||||
|
|
||||||
| Rang | Dimension | `catalog_kind` | DB-Tabelle |
|
| Schicht | Heute | Mit H1 |
|
||||||
|------|-----------|----------------|------------|
|
|---------|-------|--------|
|
||||||
| **1** | Primärfokus | `focus_area` | `focus_areas` |
|
| **Retrieval** | Katalog-Gewichte in `merge_catalog_context_into_target` | unverändert |
|
||||||
| **2** | Trainingsstil | `training_type` | `training_types` |
|
| **Technik-Gates** | `planning_exercise_semantics.py`, nur `topic_type=technique` | unverändert |
|
||||||
| **3** | Zielgruppe | `target_group` | `target_groups` |
|
| **LLM-Prompts** | kaum Katalog-spezifisch | **`catalog_guidance_block`** pro Aufruf |
|
||||||
| **4** | Stilrichtung | `style_direction` | `style_directions` |
|
|
||||||
|
|
||||||
**Kaskade:** Bei widersprüchlichen Hinweisen gilt Fokus → Trainingsstil → Zielgruppe → Stilrichtung.
|
Snippets **ersetzen** keine Technik-Disambiguierung und **duplizieren** keine Retrieval-Gewichte — sie steuern **Didaktik, Bewertungsmaßstäbe und Formulierung** für das Modell.
|
||||||
|
|
||||||
Pro Dimension im Request: **ein aktiver Eintrag** (`is_primary`, sonst höchstes `weight`).
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 4. Slot-Typ-Register (Vokabular)
|
## 4. Snippet-Modell
|
||||||
|
|
||||||
Definiert in **`catalog_prompt_slot_types`** (DB) + Spiegel in `catalog_prompt_slots.py`.
|
### 4.1 Lookup-Schlüssel
|
||||||
|
|
||||||
| `slot_key` | Anzeige (DE) | LLM-Prompt | Code-only |
|
Pro Katalog-Eintrag ein stabiler **`snippet_key`** (nicht nur numerische ID — IDs können sich in Dev/Import unterscheiden):
|
||||||
|------------|--------------|------------|-----------|
|
|
||||||
| `description` | Allgemeine Beschreibung | Roadmap, Goal-Analyse, Start/Ziel | — |
|
|
||||||
| `hints_on_progression` | Hinweise Progressionsgraph / Stufen | Roadmap, Stage-Spec | — |
|
|
||||||
| `hints_on_path_qa` | Bewertungsmaßstäbe Pfad-QS | Path-QA | — |
|
|
||||||
| `hints_on_exercise` | Hinweise Übungsanlage / Gap-Fill | Exercise-AI, Suggest (später) | — |
|
|
||||||
| `anti_patterns` | Explizit vermeiden (Fehlbewertung) | Path-QA, Stage-Spec | — |
|
|
||||||
| `rematch_guard` | Kein Auto-Rematch erzwingen | — | Rematch-Loop (H1.5) |
|
|
||||||
|
|
||||||
**Erweiterung:** neuer Slot-Typ = eine Zeile in `catalog_prompt_slot_types` + Resolver/Admin-Katalog — **kein** neues Python-Feld pro Eintrag.
|
```
|
||||||
|
focus:{slug} z. B. focus:gewaltschutz
|
||||||
|
training_type:{slug} z. B. training_type:kumite
|
||||||
|
target_group:{slug} z. B. target_group:breitensport
|
||||||
|
style:{slug} z. B. style:shotokan
|
||||||
|
```
|
||||||
|
|
||||||
**Fallback `description`:** Wenn kein Slot-Wert gesetzt → `focus_areas.description` (bzw. `description`-Spalte der jeweiligen Katalog-Tabelle).
|
**Primär** aus `slug` der DB-Zeile; Fallback normalisierter `name` (ASCII, lower, `_`).
|
||||||
|
|
||||||
---
|
Mehrfachauswahl im UI: pro Dimension **höchstens ein Snippet** — die Zeile mit `is_primary: true`, sonst erste Zeile, sonst höchste `weight`.
|
||||||
|
|
||||||
## 5. Platzhalter in `ai_prompts`
|
### 4.2 Snippet-Inhalt (Struktur)
|
||||||
|
|
||||||
Mustache-Keys: **`{catalog_kind}_{slot_key}`** (nur `[a-z0-9_]`).
|
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:
|
||||||
|
|
||||||
| Platzhalter | Bedeutung |
|
| Platzhalter | Bedeutung |
|
||||||
|-------------|-----------|
|
|-------------|-----------|
|
||||||
| `{{focus_area_description}}` | Aktiver Primärfokus — Beschreibung |
|
| `{{catalog_guidance_block}}` | Gerenderter Gesamttext (alle aktiven Snippets, kaskadiert) |
|
||||||
| `{{focus_area_hints_on_progression}}` | … — Progressions-Hinweise |
|
| `{{catalog_context_json}}` | Kompakte JSON-Zusammenfassung der gewählten IDs/Namen (Audit) |
|
||||||
| `{{focus_area_hints_on_path_qa}}` | … — QS-Hinweise |
|
| `{{#has_catalog_guidance}}` … `{{/has_catalog_guidance}}` | Block nur wenn mindestens ein Snippet aktiv |
|
||||||
| `{{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 |
|
|
||||||
|
|
||||||
**Hinweis:** `prompt_resolver` unterstützt **keine** `{{#if}}`-Blöcke — leere Slots = leerer String.
|
**Optional fein (später):** `{{catalog_focus_snippet}}`, `{{catalog_training_type_snippet}}`, … — Phase H1 nur `_block` + JSON.
|
||||||
|
|
||||||
### 5.1 Prompt-Profile (welche Slots im Aggregat)
|
### 4.4 Betroffene Prompt-Slugs (Reihenfolge Einbindung)
|
||||||
|
|
||||||
| Prompt-Slug | Aggregat enthält primär |
|
| Priorität | Slug | Migration | Wirkung |
|
||||||
|-------------|-------------------------|
|
|-----------|------|-----------|---------|
|
||||||
| `planning_exercise_path_qa` | `*_hints_on_path_qa`, `*_anti_patterns`, `*_description` |
|
| 1 | `planning_exercise_path_qa` | bestehend | Pfad-QS, `quality_score`, Empfehlungen |
|
||||||
| `planning_progression_roadmap` | `*_description`, `*_hints_on_progression`, `*_anti_patterns` |
|
| 2 | `planning_progression_roadmap` | 078 | Major Steps, Didaktik |
|
||||||
| `planning_progression_stage_spec` | `*_hints_on_progression`, `*_anti_patterns` |
|
| 3 | `planning_progression_stage_spec` | 079 | Stufen-Gates, Erfolgskriterien |
|
||||||
| `planning_progression_goal_analysis` | `*_description` |
|
| 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 |
|
||||||
|
|
||||||
Feinsteuerung: im Admin-Template **granulare** Platzhalter nutzen; Aggregat optional.
|
Intent-Prompts (`planning_exercise_search_intent`, …) **optional** Phase H1.5 — dort bereits Katalog-JSON.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 6. Speicherung (DB)
|
## 5. Builder (Backend)
|
||||||
|
|
||||||
### 6.1 `catalog_prompt_slot_types`
|
**Neues Modul:** `backend/planning_catalog_prompt_snippets.py`
|
||||||
|
|
||||||
Metadaten je Slot-Typ (`slot_key`, `display_name`, `description`, `applicable_kinds[]`, `sort_order`, `for_llm`, `for_code`).
|
```python
|
||||||
|
def build_catalog_guidance_for_prompt(
|
||||||
### 6.2 `catalog_prompt_slots`
|
cur,
|
||||||
|
catalog: Optional[ProgressionPlanningCatalogContext],
|
||||||
```text
|
) -> Dict[str, str]:
|
||||||
catalog_kind — focus_area | training_type | target_group | style_direction
|
"""
|
||||||
catalog_id — FK auf jeweilige Stammtabelle (logisch, kein polymorpher FK)
|
Returns:
|
||||||
slot_key — FK → catalog_prompt_slot_types
|
catalog_guidance_block: str
|
||||||
content — TEXT (Markdown/Plain für LLM)
|
catalog_context_json: str
|
||||||
UNIQUE (catalog_kind, catalog_id, slot_key)
|
has_catalog_guidance: bool
|
||||||
|
snippet_keys: list[str] # Metadaten für Logs/Tests
|
||||||
|
"""
|
||||||
```
|
```
|
||||||
|
|
||||||
Neuer Katalog-Eintrag im Admin → **keine** Code-Änderung; Slots optional befüllen.
|
**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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 7. Laufzeit-Architektur
|
## 6. Beispiel-Snippets (Review-Entwurf)
|
||||||
|
|
||||||
```text
|
### 6.1 Primärfokus — Gewaltschutz (`focus:gewaltschutz`)
|
||||||
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)
|
|
||||||
```
|
|
||||||
|
|
||||||
**Module:**
|
**planning_lens:** Planung zielt auf Prävention, Deeskalation, Grenzen und sichere Übungsformen — nicht auf Wettkampf-Perfektion oder Technik-Show.
|
||||||
|
|
||||||
| Modul | Rolle |
|
**qa_criteria:** Gute Pfade bauen Sicherheit, Kommunikation und Alternativen auf; „Lücken“ sind fehlende Deeskalations- oder Rollenspiel-Stufen, nicht fehlende Kick-Varianten.
|
||||||
|-------|--------|
|
|
||||||
| `catalog_prompt_slots.py` | Slot-Typen, DB-Lese/Schreib, Resolver, Aggregat-Block |
|
**anti_patterns:** Nicht nach Kumite-Tiefe, Explosivität oder Wettkampf-Belastung bewerten.
|
||||||
| `planning_prompt_variables.py` | Zentrale Provider-Liste (erweiterbar) |
|
|
||||||
| `planning_catalog_prompt_snippets.py` | Deprecated Re-Exports (Tests/Kompatibilität) |
|
### 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.)*
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 8. Admin-API
|
## 7. Rollout-Phasen
|
||||||
|
|
||||||
| Methode | Pfad | Beschreibung |
|
### H1 — Minimal viable (Progressionsgraph)
|
||||||
|---------|------|--------------|
|
|
||||||
| 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) |
|
|
||||||
|
|
||||||
`kind`: `focus_area` · `training_type` · `target_group` · `style_direction`
|
- [ ] 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`**
|
||||||
**Später:** UI-Tabs am Katalog-Editor; Versionierung/Audit wie `ai_prompts`.
|
- [ ] 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)
|
||||||
|
|
||||||
## 9. Rollout-Phasen
|
|
||||||
|
|
||||||
### H1 — Bootstrap (0.8.234) ✓
|
|
||||||
|
|
||||||
Hardcodierte `SNIPPET_REGISTRY` — Proof of Concept für `catalog_guidance_block`.
|
|
||||||
|
|
||||||
### H2 — Slot-Modell (0.8.235) ✓
|
|
||||||
|
|
||||||
- [x] Tabellen `catalog_prompt_slot_types`, `catalog_prompt_slots`
|
|
||||||
- [x] Seed aus H1-Texten (Name-Match auf Stammdaten)
|
|
||||||
- [x] Resolver mit granularen Platzhaltern + Aggregat
|
|
||||||
- [x] Admin-API GET/PUT
|
|
||||||
- [x] `SNIPPET_REGISTRY` aus Laufzeit-Pfad entfernt
|
|
||||||
|
|
||||||
### H2.1 — Admin-UI
|
|
||||||
|
|
||||||
- [x] Slot-Editor an Fokusbereich / Trainingsstil / Zielgruppe / Stilrichtung (`CatalogPromptSlotsEditor`, Stammdaten-Katalog)
|
|
||||||
- [x] Prompt-Templates mit granularen Platzhaltern (Migration 093)
|
|
||||||
- [ ] Platzhalter-Hilfe im KI-Prompt-Editor (erweitert)
|
|
||||||
|
|
||||||
### H1.5
|
### H1.5
|
||||||
|
|
||||||
- [ ] `rematch_guard` im Rematch-Loop
|
- [ ] Rematch/Refine: `rematch_guard` aus Snippets respektieren (weniger Ping-Pong bei Breitensport)
|
||||||
- [ ] Intent-Prompts + Gap-Fill: `hints_on_exercise`
|
- [ ] Intent-Prompts + Gap-Fill-Kontext
|
||||||
|
|
||||||
### H3 — Trainingsplanung (Phase G)
|
### H2 — Betrieb
|
||||||
|
|
||||||
- [ ] Gleicher Resolver, andere Orchestratoren
|
- [ ] 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)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 10. Tests & Akzeptanz
|
## 8. Tests & Akzeptanz
|
||||||
|
|
||||||
| Test | Erwartung |
|
| Test | Erwartung |
|
||||||
|------|-----------|
|
|------|-----------|
|
||||||
| Slot aus DB | Gewaltschutz + `hints_on_path_qa` → Platzhalter enthält Deeskalation |
|
| `test_catalog_prompt_snippets_priority` | Bei Konflikt gewinnt Fokus-Snippet-Text in `_block`-Reihenfolge |
|
||||||
| Ohne Katalog | Alle `{{focus_area_*}}` leer; `has_catalog_guidance` leer |
|
| `test_path_qa_variables_include_guidance` | Mit Gewaltschutz-Kontext enthält gerendeter Prompt „Deeskalation“ o. ä., nicht „Kumite-Perfektion“ |
|
||||||
| Neuer Eintrag | Leere Slots, kein Crash; `description`-Fallback aus Stammdaten |
|
| `test_path_qa_no_snippet_without_catalog` | Ohne Katalog: `has_catalog_guidance=false`, Prompt unverändert wie heute |
|
||||||
| Priorität | Aggregat: Primärfokus vor Trainingsstil |
|
| 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).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 11. Abgrenzung
|
## 9. Abgrenzung zu anderen Fixes
|
||||||
|
|
||||||
| Thema | Hinweis |
|
| Thema | Dokument / Fix |
|
||||||
|-------|---------|
|
|-------|----------------|
|
||||||
| Retrieval-Gewichte | `merge_catalog_context_into_target` — unverändert |
|
| 88 % vs. 65 % falsche Pipeline | Evaluate-only für Pfad-QS; fairer Compare — Code-Stand 2026-05-22 |
|
||||||
| Technik-Gates | `planning_exercise_semantics` — unverändert |
|
| Triviale ID-Tausche im Dialog | `_filter_trivial_slot_diffs` |
|
||||||
| Prompt-Text | `ai_prompts` — editierbar, referenziert Slot-Platzhalter |
|
| Katalog nur im Retrieval | F13 — bleibt, Snippets ergänzen LLM-Schicht |
|
||||||
|
|
||||||
|
Snippets lösen **fachliche Fehlbewertung** — nicht Pipeline-Inkonsistenz allein.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 12. Changelog
|
## 10. Changelog
|
||||||
|
|
||||||
| Datum | Änderung |
|
| Datum | Änderung |
|
||||||
|-------|----------|
|
|-------|----------|
|
||||||
| 2026-05-22 | **H2.1** (0.8.236): Admin-UI `CatalogPromptSlotsEditor`; Migration 093 granulare Prompt-Templates |
|
| 2026-05-22 | Erstfassung — Priorität Primärfokus → Trainingsstil → Zielgruppe → Stilrichtung; H1–H3 Rollout |
|
||||||
| 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,24 +89,14 @@ Details und Module: **`PLANNING_PROGRESSION_GRAPH_KI.md`**.
|
||||||
- [x] Vier Planungskontext-Dropdowns im Editor
|
- [x] Vier Planungskontext-Dropdowns im Editor
|
||||||
- [x] `progressionGraphDraft.js` — Artefakt + API-Payload
|
- [x] `progressionGraphDraft.js` — Artefakt + API-Payload
|
||||||
|
|
||||||
### F15 — Match-Dialog & getrennte Pfad-QS (2026-05-22, lokal)
|
|
||||||
|
|
||||||
- [x] **`unified_slot_review`** — Dialog pro Slot (Bibliothek + KI, Stufen-Fit-Vergleich)
|
|
||||||
- [x] Vorauswahl: Bibliothek nur bei Stufen-Fit ≥ 50 %; sonst KI bei leerem/schwachem Slot
|
|
||||||
- [x] Übernahme ohne teure Auto-Nach-Bewertung; manuell „Graph bewerten“
|
|
||||||
- [x] **`path_qa.roadmap_qa`** + **`path_qa.assignment_qa`**; Gesamt = Minimum
|
|
||||||
- [x] **`findings_stale`** im Graph-Artefakt — Hinweis „Bewertung veraltet“ (persistiert)
|
|
||||||
- [x] Slot-Key-Fix — Lernziel editierbar ohne Fokusverlust
|
|
||||||
|
|
||||||
### Validierung (Referenz Mae Geri, 2026-05)
|
### Validierung (Referenz Mae Geri, 2026-05)
|
||||||
|
|
||||||
| Phase | Roadmap-QS | Besetzung | Gesamt | Ergebnis |
|
| Phase | Pfad-QS | Ergebnis |
|
||||||
|-------|------------|-----------|--------|----------|
|
|-------|---------|----------|
|
||||||
| Vor Roadmap/KI | — | — | ~65 % | Lücken, Off-Topic |
|
| Vor Roadmap/KI | ~65 % | Lücken, falsche Reihenfolge, Off-Topic |
|
||||||
| Roadmap ok, Slots leer | ~88 % | ~8–15 % | **~8–15 %** | Besetzung fehlt |
|
| Nach Trainer-Roadmap + KI-Gap-Fill | **~88 % OK** | Vollständige Abdeckung; positive LLM-Hinweise |
|
||||||
| Nach Match + Fill | ~88 % | hoch | **~85 %+** | Vollständige Abdeckung |
|
|
||||||
|
|
||||||
**Fazit:** Roadmap-QS und Besetzungs-QS getrennt betrachten; Workbench + Katalog + Roadmap universell.
|
**Fazit:** Workbench + Katalog + Roadmap sind universell; Technik-Hardcoding allein reicht für Didaktik nicht.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -157,10 +157,6 @@ flowchart TB
|
||||||
| `auto_refine_stage_spec` | bool | Stufen-Spec bei `stage_mismatch` schärfen (Default **true**) |
|
| `auto_refine_stage_spec` | bool | Stufen-Spec bei `stage_mismatch` schärfen (Default **true**) |
|
||||||
| `max_rematch_rounds` | int | Rematch-Runden 0–4 (Default **3**) |
|
| `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 |
|
| `include_path_qa`, `include_llm_path_qa`, `include_ai_gap_fill` | bool | QS, LLM-Ganzpfad, Lücken-Angebote |
|
||||||
| `evaluate_only` | bool | Nur QS auf `evaluate_steps[]` — kein Match |
|
|
||||||
| `unified_slot_review` | bool | Pro-Slot-Review (Bibliothek + optional KI) für Match-Dialog; erfordert `baseline_evaluate_steps` + Roadmap |
|
|
||||||
| `baseline_evaluate_steps` | array? | Slot-Stand für Schritt 1 / Review-Baseline |
|
|
||||||
| `baseline_path_qa_snapshot` | object? | `path_qa` aus evaluate_only (Schritt 1 des Match-Flows) |
|
|
||||||
|
|
||||||
### 4.2 Wichtige Response-Felder
|
### 4.2 Wichtige Response-Felder
|
||||||
|
|
||||||
|
|
@ -170,9 +166,7 @@ flowchart TB
|
||||||
| `steps[]` | Gematchte Übungen; pro Schritt u. a. `roadmap_*`, `skill_expectations` |
|
| `steps[]` | Gematchte Übungen; pro Schritt u. a. `roadmap_*`, `skill_expectations` |
|
||||||
| `path_skill_expectations` | Pfadweite Skill-Erwartungen |
|
| `path_skill_expectations` | Pfadweite Skill-Erwartungen |
|
||||||
| `gap_fill_offers[]` | Lücken mit `context_preview`, `goal_for_ai` |
|
| `gap_fill_offers[]` | Lücken mit `context_preview`, `goal_for_ai` |
|
||||||
| `path_qa` | QS inkl. `qa_tiers`, `optimization_hints`, `rematch_log`, `refine_log`; **F15:** auch `roadmap_qa`, `assignment_qa` (siehe §8.1) |
|
| `path_qa` | QS inkl. `qa_tiers`, `optimization_hints`, `rematch_log`, `refine_log` |
|
||||||
| `slot_reviews[]` | Bei `unified_slot_review`: je Slot `library_alternative`, `ai_alternative`, `auto_select`-Flags |
|
|
||||||
| `findings_stale` | Im Graph-Artefakt (nicht API-Response): Bewertung veraltet seit letztem „Graph bewerten“ |
|
|
||||||
| `target_profile_summary` | Erwartungsprofil inkl. Katalog-Dimensionen (nach Match) |
|
| `target_profile_summary` | Erwartungsprofil inkl. Katalog-Dimensionen (nach Match) |
|
||||||
| `match_summary` | `library_matches`, `gap_fill_offer_count`, `roadmap_unfilled_count` |
|
| `match_summary` | `library_matches`, `gap_fill_offer_count`, `roadmap_unfilled_count` |
|
||||||
|
|
||||||
|
|
@ -227,13 +221,12 @@ Tests: `test_planning_roadmap_stage_match.py`, `test_planning_path_rematch.py`,
|
||||||
|
|
||||||
### Referenz-Validierung (Mae Geri, 2026-05)
|
### Referenz-Validierung (Mae Geri, 2026-05)
|
||||||
|
|
||||||
| Phase | Roadmap-QS | Besetzung | Gesamt (min) | Ergebnis |
|
| Phase | Pfad-QS | Ergebnis |
|
||||||
|-------|------------|-----------|--------------|----------|
|
|-------|---------|----------|
|
||||||
| Vor Roadmap/KI-Anpassung | — | — | ~65 % | Strukturelle Lücken |
|
| Vor Roadmap/KI-Anpassung | ~65 % | Strukturelle Lücken (Grundlagen, Reihenfolge, Zielgenauigkeit) |
|
||||||
| Nach Trainer-Roadmap, **Slots leer** | ~85–88 % | ~8–15 % | **~8–15 %** | Roadmap ok, Besetzung fehlt |
|
| Nach Trainer-Roadmap + KI-Angebote in leeren Slots | **~88 % OK** | Vollständige Curriculum-Abdeckung; positive LLM-Empfehlungen |
|
||||||
| Nach Match + befüllte Slots | ~85–88 % | hoch | **~85 %+** | Vollständige Curriculum-Abdeckung |
|
|
||||||
|
|
||||||
**Lesson:** **`roadmap_qa`** und **`assignment_qa`** getrennt interpretieren; Gesamt-QS allein bei leerer Roadmap irreführend (historisch nur LLM-Roadmap-Score).
|
**Lesson:** Workbench + Katalog-Kontext + Roadmap sind der Hebel; Technik-Hardcoding allein reicht nicht für Didaktik.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -337,21 +330,6 @@ API: `path_qa.qa_tiers`, `path_qa.optimization_hints` — **kein** anfrage-spezi
|
||||||
|
|
||||||
Phase G: gleiche Tiers für Abschnitts-QS nach Einheits-Match.
|
Phase G: gleiche Tiers für Abschnitts-QS nach Einheits-Match.
|
||||||
|
|
||||||
### 8.1 Getrennte Pfad-QS — Roadmap vs. Übungsbesetzung (F15)
|
|
||||||
|
|
||||||
`build_path_qa_summary()` in `planning_exercise_path_qa.py` liefert drei Ebenen:
|
|
||||||
|
|
||||||
| Feld | Inhalt | Score-Logik |
|
|
||||||
|------|--------|-------------|
|
|
||||||
| **`roadmap_qa`** | Stufenlogik, LLM `topic_coverage`, Roadmap-Hinweise | LLM-`quality_score` oder heuristisch (Lücken, Hints) |
|
|
||||||
| **`assignment_qa`** | Leere Slots, Off-Topic auf belegten Slots, Fill-Statistik | Stark abwertend bei leeren Slots (~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
|
## 9. Fähigkeiten-Scoring-Anbindung
|
||||||
|
|
||||||
Modul: `planning_skill_expectations.py`
|
Modul: `planning_skill_expectations.py`
|
||||||
|
|
@ -401,7 +379,6 @@ 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 |
|
| **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** |
|
| **F13** | **Katalog-Kontext** (`planning_catalog_context`) im Match + Graph-Artefakt | ✅ | **0.8.233** |
|
||||||
| **F14** | `ProgressionGraphEditor` Slot-UI + Planungskontext-Dropdowns | ✅ | 0.8.233 |
|
| **F14** | `ProgressionGraphEditor` Slot-UI + Planungskontext-Dropdowns | ✅ | 0.8.233 |
|
||||||
| **F15** | Unified Slot-Review, getrennte Pfad-QS, `findings_stale`, Match-Vorauswahl | ✅ | lokal (2026-05-22) |
|
|
||||||
| **H1** | **Katalog-Prompt-Snippets** — modulare LLM-Anweisungen pro Dimension | 🔲 | Spec **`PLANNING_CATALOG_PROMPT_SNIPPETS.md`** |
|
| **H1** | **Katalog-Prompt-Snippets** — modulare LLM-Anweisungen pro Dimension | 🔲 | Spec **`PLANNING_CATALOG_PROMPT_SNIPPETS.md`** |
|
||||||
| **G** | Trainingsplanung: eigene Pipeline + Wiederverwendung Bausteine (§16) | 🔲 | — |
|
| **G** | Trainingsplanung: eigene Pipeline + Wiederverwendung Bausteine (§16) | 🔲 | — |
|
||||||
| **UX** | Wizard/Stepper; PathBuilder-Parität Katalog | 🔲 | — |
|
| **UX** | Wizard/Stepper; PathBuilder-Parität Katalog | 🔲 | — |
|
||||||
|
|
@ -414,7 +391,8 @@ Kontext-Helfer: `frontend/src/utils/planningContextForExerciseAi.js`
|
||||||
|
|
||||||
1. **H1 Katalog-Prompt-Snippets** — modulare LLM-Anweisungen (Priorität: Primärfokus → Trainingsstil → Zielgruppe → Stilrichtung) — **`PLANNING_CATALOG_PROMPT_SNIPPETS.md`**
|
1. **H1 Katalog-Prompt-Snippets** — modulare LLM-Anweisungen (Priorität: Primärfokus → Trainingsstil → Zielgruppe → Stilrichtung) — **`PLANNING_CATALOG_PROMPT_SNIPPETS.md`**
|
||||||
2. **Dev-Regression:** Gewaltschutz + Breitensport + Kinder (ohne Mae Geri) — Katalog-Match verifizieren
|
2. **Dev-Regression:** Gewaltschutz + Breitensport + Kinder (ohne Mae Geri) — Katalog-Match verifizieren
|
||||||
3. **PathBuilder-Parität** — gleiche `planning_catalog_context`-Dropdowns in `ExerciseProgressionPathBuilder`
|
2. **PathBuilder-Parität** — gleiche `planning_catalog_context`-Dropdowns in `ExerciseProgressionPathBuilder`
|
||||||
|
3. **QS-UI** — positive LLM-Hinweise als „Highlights“, nicht als „Optimierungspotenziale“
|
||||||
4. **UI-Wizard** — 4 Schritte (Ziel → Roadmap → Match → Lücken); Backend unverändert
|
4. **UI-Wizard** — 4 Schritte (Ziel → Roadmap → Match → Lücken); Backend unverändert
|
||||||
5. **Graph-Erweiterungsmodus** — Start ab gewähltem Knoten / letzter Sequenz
|
5. **Graph-Erweiterungsmodus** — Start ab gewähltem Knoten / letzter Sequenz
|
||||||
6. **Phase D′** — automatisches KI-Gap-Fill bei persistent leeren Slots
|
6. **Phase D′** — automatisches KI-Gap-Fill bei persistent leeren Slots
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ import { Link, useLocation } from 'react-router-dom'
|
||||||
import api from '../utils/api'
|
import api from '../utils/api'
|
||||||
import SkillProfilePanel from './skills/SkillProfilePanel'
|
import SkillProfilePanel from './skills/SkillProfilePanel'
|
||||||
import { useAuth } from '../context/AuthContext'
|
import { useAuth } from '../context/AuthContext'
|
||||||
import { activeClubMemberships, getDefaultClubIdForGovernanceForms, getTenantClubDependencyKey } from '../utils/activeClub'
|
import { activeClubMemberships, getTenantClubDependencyKey } from '../utils/activeClub'
|
||||||
import ProgressionGraphEditor from './ProgressionGraphEditor'
|
import ProgressionGraphEditor from './ProgressionGraphEditor'
|
||||||
import ProgressionGraphListCard from './ProgressionGraphListCard'
|
import ProgressionGraphListCard from './ProgressionGraphListCard'
|
||||||
import { EXERCISE_VISIBILITY_FIELD_LABEL } from '../constants/exerciseGovernanceLabels'
|
import { EXERCISE_VISIBILITY_FIELD_LABEL } from '../constants/exerciseGovernanceLabels'
|
||||||
|
|
@ -24,21 +24,6 @@ const VIS_OPTIONS = [
|
||||||
{ value: 'official', label: 'Offiziell' },
|
{ value: 'official', label: 'Offiziell' },
|
||||||
]
|
]
|
||||||
|
|
||||||
const GRAPH_VISIBILITY_PROMOTION_LABEL = {
|
|
||||||
club: 'Vereins-Sichtbarkeit',
|
|
||||||
official: 'offizielle Sichtbarkeit',
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Graph-Promotion mit optionalem Übungs-Anheben (private→club/official, club→official). */
|
|
||||||
function shouldPromptGraphExercisePromotion(prevVis, nextVis) {
|
|
||||||
const p = (prevVis || 'private').trim().toLowerCase()
|
|
||||||
const n = (nextVis || 'private').trim().toLowerCase()
|
|
||||||
return (
|
|
||||||
(p === 'private' && (n === 'club' || n === 'official')) ||
|
|
||||||
(p === 'club' && n === 'official')
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function edgeTypeLabel(type) {
|
function edgeTypeLabel(type) {
|
||||||
if (type === 'next_exercise') return 'Nachfolger'
|
if (type === 'next_exercise') return 'Nachfolger'
|
||||||
if (type === 'sibling') return 'Schwester'
|
if (type === 'sibling') return 'Schwester'
|
||||||
|
|
@ -56,9 +41,7 @@ function ExerciseProgressionGraphPanel(
|
||||||
const { user } = useAuth()
|
const { user } = useAuth()
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
const isSuperadmin = user?.role === 'superadmin'
|
const isSuperadmin = user?.role === 'superadmin'
|
||||||
const memberClubs = useMemo(() => activeClubMemberships(user?.clubs), [user?.clubs])
|
|
||||||
const tenantClubDepKey = useMemo(() => getTenantClubDependencyKey(user), [user])
|
const tenantClubDepKey = useMemo(() => getTenantClubDependencyKey(user), [user])
|
||||||
const [clubsForGovernanceForms, setClubsForGovernanceForms] = useState([])
|
|
||||||
|
|
||||||
const filteredGraphVisOptions = useMemo(
|
const filteredGraphVisOptions = useMemo(
|
||||||
() => VIS_OPTIONS.filter((o) => o.value !== 'official' || isSuperadmin),
|
() => VIS_OPTIONS.filter((o) => o.value !== 'official' || isSuperadmin),
|
||||||
|
|
@ -78,37 +61,6 @@ function ExerciseProgressionGraphPanel(
|
||||||
const [metaName, setMetaName] = useState('')
|
const [metaName, setMetaName] = useState('')
|
||||||
const [metaDescription, setMetaDescription] = useState('')
|
const [metaDescription, setMetaDescription] = useState('')
|
||||||
const [metaVisibility, setMetaVisibility] = useState('private')
|
const [metaVisibility, setMetaVisibility] = useState('private')
|
||||||
const [metaClubSelect, setMetaClubSelect] = useState('')
|
|
||||||
|
|
||||||
const memberClubIdSet = useMemo(
|
|
||||||
() => new Set(memberClubs.map((c) => Number(c.id))),
|
|
||||||
[memberClubs],
|
|
||||||
)
|
|
||||||
|
|
||||||
const sortedMemberClubs = useMemo(
|
|
||||||
() =>
|
|
||||||
[...memberClubs].sort((a, b) =>
|
|
||||||
String(a.name || '').localeCompare(String(b.name || ''), 'de'),
|
|
||||||
),
|
|
||||||
[memberClubs],
|
|
||||||
)
|
|
||||||
|
|
||||||
const sortedOtherGovernanceClubs = useMemo(() => {
|
|
||||||
if (!isSuperadmin || clubsForGovernanceForms.length === 0) return []
|
|
||||||
return clubsForGovernanceForms
|
|
||||||
.filter((c) => !memberClubIdSet.has(Number(c.id)))
|
|
||||||
.sort((a, b) => String(a.name || '').localeCompare(String(b.name || ''), 'de'))
|
|
||||||
}, [isSuperadmin, clubsForGovernanceForms, memberClubIdSet])
|
|
||||||
|
|
||||||
const showGovernanceClubOptgroups =
|
|
||||||
isSuperadmin && sortedMemberClubs.length > 0 && sortedOtherGovernanceClubs.length > 0
|
|
||||||
|
|
||||||
const governanceClubSelectOptions = useMemo(() => {
|
|
||||||
if (isSuperadmin && clubsForGovernanceForms.length > 0) {
|
|
||||||
return [...sortedMemberClubs, ...sortedOtherGovernanceClubs]
|
|
||||||
}
|
|
||||||
return sortedMemberClubs
|
|
||||||
}, [isSuperadmin, clubsForGovernanceForms.length, sortedMemberClubs, sortedOtherGovernanceClubs])
|
|
||||||
|
|
||||||
const [filterAnchorOnly, setFilterAnchorOnly] = useState(!!anchorExerciseId)
|
const [filterAnchorOnly, setFilterAnchorOnly] = useState(!!anchorExerciseId)
|
||||||
const [editingEdgeNotes, setEditingEdgeNotes] = useState(null)
|
const [editingEdgeNotes, setEditingEdgeNotes] = useState(null)
|
||||||
|
|
@ -173,25 +125,6 @@ function ExerciseProgressionGraphPanel(
|
||||||
}
|
}
|
||||||
}, [refreshGraphs, tenantClubDepKey])
|
}, [refreshGraphs, tenantClubDepKey])
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isSuperadmin) {
|
|
||||||
setClubsForGovernanceForms([])
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
let cancelled = false
|
|
||||||
;(async () => {
|
|
||||||
try {
|
|
||||||
const list = await api.listClubs()
|
|
||||||
if (!cancelled) setClubsForGovernanceForms(Array.isArray(list) ? list : [])
|
|
||||||
} catch {
|
|
||||||
if (!cancelled) setClubsForGovernanceForms([])
|
|
||||||
}
|
|
||||||
})()
|
|
||||||
return () => {
|
|
||||||
cancelled = true
|
|
||||||
}
|
|
||||||
}, [isSuperadmin, tenantClubDepKey])
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selectedGraphId) {
|
if (!selectedGraphId) {
|
||||||
setSkillProfileData(null)
|
setSkillProfileData(null)
|
||||||
|
|
@ -224,7 +157,6 @@ function ExerciseProgressionGraphPanel(
|
||||||
setMetaName('')
|
setMetaName('')
|
||||||
setMetaDescription('')
|
setMetaDescription('')
|
||||||
setMetaVisibility('private')
|
setMetaVisibility('private')
|
||||||
setMetaClubSelect('')
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const g = graphs.find((x) => x.id === selectedGraphId)
|
const g = graphs.find((x) => x.id === selectedGraphId)
|
||||||
|
|
@ -232,12 +164,6 @@ function ExerciseProgressionGraphPanel(
|
||||||
setMetaName(g.name || '')
|
setMetaName(g.name || '')
|
||||||
setMetaDescription(g.description || '')
|
setMetaDescription(g.description || '')
|
||||||
setMetaVisibility(g.visibility || 'private')
|
setMetaVisibility(g.visibility || 'private')
|
||||||
if (g.club_id != null) {
|
|
||||||
setMetaClubSelect(String(g.club_id))
|
|
||||||
} else {
|
|
||||||
const fallback = getDefaultClubIdForGovernanceForms(user)
|
|
||||||
setMetaClubSelect(fallback != null ? String(fallback) : '')
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
;(async () => {
|
;(async () => {
|
||||||
|
|
@ -250,17 +176,7 @@ function ExerciseProgressionGraphPanel(
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true
|
cancelled = true
|
||||||
}
|
}
|
||||||
}, [selectedGraphId, graphs, refreshEdges, user])
|
}, [selectedGraphId, graphs, refreshEdges])
|
||||||
|
|
||||||
const resolveGovernanceClubId = useCallback(() => {
|
|
||||||
const g = graphs.find((x) => x.id === selectedGraphId)
|
|
||||||
if (g?.club_id != null) return Number(g.club_id)
|
|
||||||
|
|
||||||
const sel = String(metaClubSelect || '').trim()
|
|
||||||
if (sel && /^\d+$/.test(sel)) return Number(sel)
|
|
||||||
|
|
||||||
return getDefaultClubIdForGovernanceForms(user)
|
|
||||||
}, [graphs, selectedGraphId, metaClubSelect, user])
|
|
||||||
|
|
||||||
const filteredEdges = useMemo(() => {
|
const filteredEdges = useMemo(() => {
|
||||||
if (!filterAnchorOnly || anchorExerciseId == null) return edges
|
if (!filterAnchorOnly || anchorExerciseId == null) return edges
|
||||||
|
|
@ -310,7 +226,13 @@ function ExerciseProgressionGraphPanel(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const resolvePromoteClubId = resolveGovernanceClubId
|
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 handleSaveMeta = async () => {
|
const handleSaveMeta = async () => {
|
||||||
if (!selectedGraphId) return
|
if (!selectedGraphId) return
|
||||||
|
|
@ -325,58 +247,48 @@ function ExerciseProgressionGraphPanel(
|
||||||
|
|
||||||
setBusy(true)
|
setBusy(true)
|
||||||
try {
|
try {
|
||||||
if (shouldPromptGraphExercisePromotion(prevVis, nextVis)) {
|
if (prevVis === 'private' && nextVis === 'club') {
|
||||||
const preview = await api.getProgressionGraphVisibilityPromotionCandidates(
|
const preview = await api.getProgressionGraphVisibilityPromotionCandidates(
|
||||||
selectedGraphId,
|
selectedGraphId,
|
||||||
{ targetVisibility: nextVis },
|
{ targetVisibility: 'club' },
|
||||||
)
|
)
|
||||||
const promotionExercises = Array.isArray(preview?.exercises) ? preview.exercises : []
|
const privateExercises = Array.isArray(preview?.exercises) ? preview.exercises : []
|
||||||
if (promotionExercises.length > 0) {
|
if (privateExercises.length > 0) {
|
||||||
const visLabel = GRAPH_VISIBILITY_PROMOTION_LABEL[nextVis] || nextVis
|
const titles = privateExercises
|
||||||
const titles = promotionExercises
|
|
||||||
.slice(0, 8)
|
.slice(0, 8)
|
||||||
.map((ex) => `• ${ex.title || `Übung #${ex.id}`}`)
|
.map((ex) => `• ${ex.title || `Übung #${ex.id}`}`)
|
||||||
.join('\n')
|
.join('\n')
|
||||||
const more =
|
const more =
|
||||||
promotionExercises.length > 8
|
privateExercises.length > 8
|
||||||
? `\n… und ${promotionExercises.length - 8} weitere`
|
? `\n… und ${privateExercises.length - 8} weitere`
|
||||||
: ''
|
: ''
|
||||||
const promote = window.confirm(
|
const promote = window.confirm(
|
||||||
`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?`,
|
`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?`,
|
||||||
)
|
)
|
||||||
if (promote) {
|
if (promote) {
|
||||||
let clubId = null
|
const clubId = resolvePromoteClubId()
|
||||||
if (nextVis === 'club') {
|
if (!clubId) {
|
||||||
clubId = resolvePromoteClubId()
|
alert('Kein aktiver Verein — Übungen können nicht auf Verein promoted werden.')
|
||||||
if (!clubId) {
|
} else {
|
||||||
throw new Error(
|
const ids = privateExercises.map((ex) => ex.id).filter((id) => id != null)
|
||||||
'Kein Verein gewählt — bitte unter „Verein zuordnen“ einen Verein auswählen oder den Vereins-Umschalter nutzen.',
|
const res = await api.bulkPatchExercisesMetadata({
|
||||||
)
|
exercise_ids: ids,
|
||||||
|
visibility: 'club',
|
||||||
|
club_id: clubId,
|
||||||
|
})
|
||||||
|
if ((res?.failed || []).length) {
|
||||||
|
const f = res.failed[0]
|
||||||
|
throw new Error(f?.detail || 'Übungs-Promotion fehlgeschlagen')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const ids = promotionExercises.map((ex) => ex.id).filter((id) => id != null)
|
|
||||||
const bulkPayload = { exercise_ids: ids, visibility: nextVis }
|
|
||||||
if (nextVis === 'club' && clubId != null) bulkPayload.club_id = clubId
|
|
||||||
const res = await api.bulkPatchExercisesMetadata(bulkPayload)
|
|
||||||
if ((res?.failed || []).length) {
|
|
||||||
const f = res.failed[0]
|
|
||||||
throw new Error(f?.detail || 'Übungs-Promotion fehlgeschlagen')
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const promoteClubId = nextVis === 'club' ? resolvePromoteClubId() : null
|
|
||||||
if (nextVis === 'club' && !promoteClubId) {
|
|
||||||
throw new Error(
|
|
||||||
'Vereins-Sichtbarkeit: Bitte einen Verein unter „Verein zuordnen“ wählen oder den Vereins-Umschalter setzen.',
|
|
||||||
)
|
|
||||||
}
|
|
||||||
await api.updateExerciseProgressionGraph(selectedGraphId, {
|
await api.updateExerciseProgressionGraph(selectedGraphId, {
|
||||||
name,
|
name,
|
||||||
description: metaDescription.trim() || null,
|
description: metaDescription.trim() || null,
|
||||||
visibility: metaVisibility,
|
visibility: metaVisibility,
|
||||||
...(promoteClubId != null ? { club_id: promoteClubId } : {}),
|
|
||||||
})
|
})
|
||||||
await refreshGraphs()
|
await refreshGraphs()
|
||||||
alert('Graph-Metadaten gespeichert.')
|
alert('Graph-Metadaten gespeichert.')
|
||||||
|
|
@ -627,14 +539,7 @@ function ExerciseProgressionGraphPanel(
|
||||||
<select
|
<select
|
||||||
className="form-input"
|
className="form-input"
|
||||||
value={metaVisibility}
|
value={metaVisibility}
|
||||||
onChange={(e) => {
|
onChange={(e) => setMetaVisibility(e.target.value)}
|
||||||
const v = e.target.value
|
|
||||||
setMetaVisibility(v)
|
|
||||||
if (v === 'club' && !metaClubSelect) {
|
|
||||||
const fb = getDefaultClubIdForGovernanceForms(user)
|
|
||||||
if (fb != null) setMetaClubSelect(String(fb))
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{filteredGraphVisOptions.map((o) => (
|
{filteredGraphVisOptions.map((o) => (
|
||||||
<option key={o.value} value={o.value}>
|
<option key={o.value} value={o.value}>
|
||||||
|
|
@ -643,42 +548,6 @@ function ExerciseProgressionGraphPanel(
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
{metaVisibility === 'club' ? (
|
|
||||||
<div className="form-row">
|
|
||||||
<label className="form-label">Verein zuordnen</label>
|
|
||||||
<select
|
|
||||||
className="form-input"
|
|
||||||
value={metaClubSelect}
|
|
||||||
onChange={(e) => setMetaClubSelect(e.target.value)}
|
|
||||||
>
|
|
||||||
<option value="">Aktiver Verein (Vereins-Umschalter / Header)</option>
|
|
||||||
{showGovernanceClubOptgroups ? (
|
|
||||||
<>
|
|
||||||
<optgroup label="Meine Vereine">
|
|
||||||
{sortedMemberClubs.map((c) => (
|
|
||||||
<option key={c.id} value={String(c.id)}>
|
|
||||||
{c.name || `Verein #${c.id}`}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</optgroup>
|
|
||||||
<optgroup label="Weitere Vereine">
|
|
||||||
{sortedOtherGovernanceClubs.map((c) => (
|
|
||||||
<option key={c.id} value={String(c.id)}>
|
|
||||||
{c.name || `Verein #${c.id}`}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</optgroup>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
governanceClubSelectOptions.map((c) => (
|
|
||||||
<option key={c.id} value={String(c.id)}>
|
|
||||||
{c.name || `Verein #${c.id}`}
|
|
||||||
</option>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
|
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
|
||||||
<button type="button" className="btn btn-secondary" disabled={busy} onClick={handleSaveMeta}>
|
<button type="button" className="btn btn-secondary" disabled={busy} onClick={handleSaveMeta}>
|
||||||
Metadaten speichern
|
Metadaten speichern
|
||||||
|
|
|
||||||
|
|
@ -589,7 +589,6 @@ export default function ExerciseProgressionPathBuilder({
|
||||||
const [gapPrepFocusAreaId, setGapPrepFocusAreaId] = useState('')
|
const [gapPrepFocusAreaId, setGapPrepFocusAreaId] = useState('')
|
||||||
const [gapPrepError, setGapPrepError] = useState('')
|
const [gapPrepError, setGapPrepError] = useState('')
|
||||||
const [loadedPlanningHint, setLoadedPlanningHint] = useState(false)
|
const [loadedPlanningHint, setLoadedPlanningHint] = useState(false)
|
||||||
const [graphGovernance, setGraphGovernance] = useState({ visibility: 'private', clubId: null })
|
|
||||||
const [wizardStep, setWizardStep] = useState(1)
|
const [wizardStep, setWizardStep] = useState(1)
|
||||||
const [pathInsertNotice, setPathInsertNotice] = useState('')
|
const [pathInsertNotice, setPathInsertNotice] = useState('')
|
||||||
|
|
||||||
|
|
@ -671,10 +670,6 @@ export default function ExerciseProgressionPathBuilder({
|
||||||
.getExerciseProgressionGraph(Number(graphId))
|
.getExerciseProgressionGraph(Number(graphId))
|
||||||
.then((g) => {
|
.then((g) => {
|
||||||
if (cancelled) return
|
if (cancelled) return
|
||||||
setGraphGovernance({
|
|
||||||
visibility: g?.visibility || 'private',
|
|
||||||
clubId: g?.club_id ?? null,
|
|
||||||
})
|
|
||||||
const art = g?.planning_roadmap
|
const art = g?.planning_roadmap
|
||||||
if (!art) return
|
if (!art) return
|
||||||
if (art.goal_query) setGoalQuery(String(art.goal_query))
|
if (art.goal_query) setGoalQuery(String(art.goal_query))
|
||||||
|
|
@ -1061,7 +1056,7 @@ export default function ExerciseProgressionPathBuilder({
|
||||||
setQuickSaving(true)
|
setQuickSaving(true)
|
||||||
setQuickAiError('')
|
setQuickAiError('')
|
||||||
try {
|
try {
|
||||||
const payload = buildQuickCreateExercisePayloadFromDraft(quickCreateDraft, graphGovernance)
|
const payload = buildQuickCreateExercisePayloadFromDraft(quickCreateDraft)
|
||||||
const created = await api.createExercise(payload)
|
const created = await api.createExercise(payload)
|
||||||
if (!created?.id) throw new Error('Anlegen fehlgeschlagen')
|
if (!created?.id) throw new Error('Anlegen fehlgeschlagen')
|
||||||
insertExerciseFromOffer(created, activeOffer)
|
insertExerciseFromOffer(created, activeOffer)
|
||||||
|
|
|
||||||
|
|
@ -11,8 +11,6 @@ import {
|
||||||
formatRefineLogEntry,
|
formatRefineLogEntry,
|
||||||
hasRematchSlotHints,
|
hasRematchSlotHints,
|
||||||
pathQaQualityPercent,
|
pathQaQualityPercent,
|
||||||
pathQaHasSplitDimensions,
|
|
||||||
pathQaSubsectionPercent,
|
|
||||||
pathQaShowsStrongResult,
|
pathQaShowsStrongResult,
|
||||||
resolveHintSlotIndex,
|
resolveHintSlotIndex,
|
||||||
resolveOfferSlotIndex,
|
resolveOfferSlotIndex,
|
||||||
|
|
@ -28,46 +26,6 @@ function severityStyle(pathQa) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function subsectionSeverityStyle(subsection) {
|
|
||||||
if (!subsection) return {}
|
|
||||||
return {
|
|
||||||
background: subsection.overall_ok
|
|
||||||
? 'color-mix(in srgb, var(--accent) 6%, var(--surface))'
|
|
||||||
: 'color-mix(in srgb, var(--danger) 10%, var(--surface))',
|
|
||||||
border: `1px solid ${subsection.overall_ok ? 'var(--border)' : 'color-mix(in srgb, var(--danger) 35%, var(--border))'}`,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function PathQaDimensionBlock({ title, subsection, children = null }) {
|
|
||||||
if (!subsection) return null
|
|
||||||
const pct = pathQaSubsectionPercent(subsection)
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
marginTop: '10px',
|
|
||||||
padding: '8px 10px',
|
|
||||||
borderRadius: '8px',
|
|
||||||
fontSize: '11px',
|
|
||||||
lineHeight: 1.45,
|
|
||||||
...subsectionSeverityStyle(subsection),
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<strong>
|
|
||||||
{title}: {subsection.overall_ok ? 'OK' : 'Hinweise'}
|
|
||||||
{pct != null ? ` (${pct} %)` : ''}
|
|
||||||
</strong>
|
|
||||||
{Array.isArray(subsection.issues) && subsection.issues.length > 0 ? (
|
|
||||||
<ul style={{ margin: '6px 0 0', paddingLeft: '16px', color: 'var(--text2)' }}>
|
|
||||||
{subsection.issues.slice(0, 5).map((issue) => (
|
|
||||||
<li key={`${title}-${issue}`}>{issue}</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
) : null}
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function PathQaPipelineDetails({ pathQa, fairQa = null, draft, title, compact = false }) {
|
function PathQaPipelineDetails({ pathQa, fairQa = null, draft, title, compact = false }) {
|
||||||
const { fixHints: optimizationHints } = useMemo(
|
const { fixHints: optimizationHints } = useMemo(
|
||||||
() => splitPathQaHints(pathQa),
|
() => splitPathQaHints(pathQa),
|
||||||
|
|
@ -352,9 +310,6 @@ export default function ProgressionFindingsPanel({
|
||||||
&& (canOptimizeCompare || pathQa?.assignments_preserved || showRematchAction)
|
&& (canOptimizeCompare || pathQa?.assignments_preserved || showRematchAction)
|
||||||
const qualityPct = pathQaQualityPercent(pathQa)
|
const qualityPct = pathQaQualityPercent(pathQa)
|
||||||
const strongResult = pathQaShowsStrongResult(pathQa)
|
const strongResult = pathQaShowsStrongResult(pathQa)
|
||||||
const hasSplitQa = pathQaHasSplitDimensions(pathQa)
|
|
||||||
const roadmapQa = pathQa?.roadmap_qa || null
|
|
||||||
const assignmentQa = pathQa?.assignment_qa || null
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="card" style={{ position: 'sticky', top: '12px' }}>
|
<div className="card" style={{ position: 'sticky', top: '12px' }}>
|
||||||
|
|
@ -409,14 +364,9 @@ export default function ProgressionFindingsPanel({
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<strong>
|
<strong>
|
||||||
Pfad-QS gesamt: {pathQa.overall_ok ? 'OK' : 'Hinweise'}
|
Pfad-QS: {pathQa.overall_ok ? 'OK' : 'Hinweise'}
|
||||||
{qualityPct != null ? ` (${qualityPct} %)` : ''}
|
{qualityPct != null ? ` (${qualityPct} %)` : ''}
|
||||||
</strong>
|
</strong>
|
||||||
{hasSplitQa ? (
|
|
||||||
<p style={{ margin: '6px 0 0', color: 'var(--text2)', fontSize: '11px' }}>
|
|
||||||
Gesamt = schwächere Dimension (Roadmap vs. Übungsbesetzung).
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
{pathQa.assignments_preserved ? (
|
{pathQa.assignments_preserved ? (
|
||||||
<p style={{ margin: '6px 0 0', color: 'var(--text2)', fontSize: '11px' }}>
|
<p style={{ margin: '6px 0 0', color: 'var(--text2)', fontSize: '11px' }}>
|
||||||
Bewertung des aktuellen Pfads. „Übungen matchen“ öffnet einen Dialog mit Vorschlägen für
|
Bewertung des aktuellen Pfads. „Übungen matchen“ öffnet einen Dialog mit Vorschlägen für
|
||||||
|
|
@ -428,23 +378,7 @@ export default function ProgressionFindingsPanel({
|
||||||
Starker Pfad — KI-Empfehlungen unten können Feinschliff oder optionale Vertiefung sein.
|
Starker Pfad — KI-Empfehlungen unten können Feinschliff oder optionale Vertiefung sein.
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
{hasSplitQa ? (
|
{pathQa.topic_coverage ? (
|
||||||
<>
|
|
||||||
<PathQaDimensionBlock title="Roadmap & Stufen" subsection={roadmapQa}>
|
|
||||||
{roadmapQa?.topic_coverage ? (
|
|
||||||
<p style={{ margin: '6px 0 0', color: 'var(--text2)' }}>{roadmapQa.topic_coverage}</p>
|
|
||||||
) : null}
|
|
||||||
</PathQaDimensionBlock>
|
|
||||||
<PathQaDimensionBlock title="Übungsbesetzung" subsection={assignmentQa}>
|
|
||||||
{assignmentQa?.empty_slot_count > 0 ? (
|
|
||||||
<p style={{ margin: '6px 0 0', color: 'var(--danger)' }}>
|
|
||||||
{assignmentQa.empty_slot_count} leere Slot(s) — „Übungen matchen“ oder manuell befüllen.
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
</PathQaDimensionBlock>
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
{!hasSplitQa && pathQa.topic_coverage ? (
|
|
||||||
<p style={{ margin: '6px 0 0', color: 'var(--text2)' }}>{pathQa.topic_coverage}</p>
|
<p style={{ margin: '6px 0 0', color: 'var(--text2)' }}>{pathQa.topic_coverage}</p>
|
||||||
) : null}
|
) : null}
|
||||||
{highlightTexts.length > 0 ? (
|
{highlightTexts.length > 0 ? (
|
||||||
|
|
@ -481,7 +415,7 @@ export default function ProgressionFindingsPanel({
|
||||||
</ul>
|
</ul>
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
{Array.isArray(pathQa.issues) && pathQa.issues.length > 0 && !hasSplitQa ? (
|
{Array.isArray(pathQa.issues) && pathQa.issues.length > 0 ? (
|
||||||
<ul style={{ margin: '6px 0 0', paddingLeft: '16px', color: 'var(--text2)' }}>
|
<ul style={{ margin: '6px 0 0', paddingLeft: '16px', color: 'var(--text2)' }}>
|
||||||
{pathQa.issues.map((issue) => (
|
{pathQa.issues.map((issue) => (
|
||||||
<li key={issue}>{issue}</li>
|
<li key={issue}>{issue}</li>
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,6 @@
|
||||||
import React, { useCallback, useEffect, useMemo, useState } from 'react'
|
import React, { useCallback, useEffect, useMemo, useState } from 'react'
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
import api from '../utils/api'
|
import api from '../utils/api'
|
||||||
import { useAuth } from '../context/AuthContext'
|
|
||||||
import { getDefaultClubIdForGovernanceForms } from '../utils/activeClub'
|
|
||||||
import ExercisePickerModal from './ExercisePickerModal'
|
import ExercisePickerModal from './ExercisePickerModal'
|
||||||
import ExerciseGapFillPrepModal from './exercises/ExerciseGapFillPrepModal'
|
import ExerciseGapFillPrepModal from './exercises/ExerciseGapFillPrepModal'
|
||||||
import ProgressionSlotCard from './ProgressionSlotCard'
|
import ProgressionSlotCard from './ProgressionSlotCard'
|
||||||
|
|
@ -39,6 +37,7 @@ import {
|
||||||
compareDiffsForDialog,
|
compareDiffsForDialog,
|
||||||
dedupeGapOffersBySlot,
|
dedupeGapOffersBySlot,
|
||||||
draftHasLibrarySlotAssignments,
|
draftHasLibrarySlotAssignments,
|
||||||
|
draftRetrievalBoostExerciseIds,
|
||||||
EMPTY_PLANNING_CATALOG_CONTEXT,
|
EMPTY_PLANNING_CATALOG_CONTEXT,
|
||||||
filterGapOffersForUnfilledSlots,
|
filterGapOffersForUnfilledSlots,
|
||||||
hydrateProgressionGraphDraft,
|
hydrateProgressionGraphDraft,
|
||||||
|
|
@ -86,7 +85,6 @@ function resolveDefaultFocusAreaId(targetSummary, focusAreas) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ProgressionGraphEditor({ graphId, embedded = false, onSaved }) {
|
export default function ProgressionGraphEditor({ graphId, embedded = false, onSaved }) {
|
||||||
const { user } = useAuth()
|
|
||||||
const [graphMeta, setGraphMeta] = useState(null)
|
const [graphMeta, setGraphMeta] = useState(null)
|
||||||
const [draft, setDraft] = useState(null)
|
const [draft, setDraft] = useState(null)
|
||||||
const [busy, setBusy] = useState(false)
|
const [busy, setBusy] = useState(false)
|
||||||
|
|
@ -477,6 +475,28 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const buildMatchRequestBase = (synced) => {
|
||||||
|
const override = majorStepsToOverridePayload(synced.slots)
|
||||||
|
return {
|
||||||
|
query: (synced.goalQuery || '').trim(),
|
||||||
|
max_steps: synced.slots.length,
|
||||||
|
include_llm_intent: true,
|
||||||
|
include_path_qa: true,
|
||||||
|
include_llm_path_qa: true,
|
||||||
|
include_path_reorder: false,
|
||||||
|
include_ai_gap_fill: true,
|
||||||
|
include_roadmap_preview: true,
|
||||||
|
include_llm_roadmap: false,
|
||||||
|
roadmap_first: true,
|
||||||
|
roadmap_override: override,
|
||||||
|
slot_assignments: slotsToSlotAssignments(synced),
|
||||||
|
retrieval_boost_exercise_ids: draftRetrievalBoostExerciseIds(synced),
|
||||||
|
progression_graph_id: Number(graphId),
|
||||||
|
...roadmapStructuredPayload(synced.startSituation, synced.targetState, synced.roadmapNotes),
|
||||||
|
...catalogApiPayload,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const runMatchCompareFlow = async (synced, { source = 'match' } = {}) => {
|
const runMatchCompareFlow = async (synced, { source = 'match' } = {}) => {
|
||||||
setMatchNotice('Schritt 1/2: Pfad bewerten (wie „Graph bewerten“)…')
|
setMatchNotice('Schritt 1/2: Pfad bewerten (wie „Graph bewerten“)…')
|
||||||
const baselineRes = await fetchPathEvaluate(synced)
|
const baselineRes = await fetchPathEvaluate(synced)
|
||||||
|
|
@ -860,17 +880,7 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa
|
||||||
setSlotQuickSaving(true)
|
setSlotQuickSaving(true)
|
||||||
setSlotQuickError('')
|
setSlotQuickError('')
|
||||||
try {
|
try {
|
||||||
const graphVis = (graphMeta?.visibility || 'private').trim().toLowerCase()
|
const payload = buildQuickCreateExercisePayloadFromDraft(slotQuickCreateDraft)
|
||||||
const graphClubId =
|
|
||||||
graphMeta?.club_id != null
|
|
||||||
? graphMeta.club_id
|
|
||||||
: graphVis === 'club'
|
|
||||||
? getDefaultClubIdForGovernanceForms(user)
|
|
||||||
: null
|
|
||||||
const payload = buildQuickCreateExercisePayloadFromDraft(slotQuickCreateDraft, {
|
|
||||||
visibility: graphVis,
|
|
||||||
clubId: graphClubId,
|
|
||||||
})
|
|
||||||
const created = await api.createExercise(payload)
|
const created = await api.createExercise(payload)
|
||||||
if (!created?.id) throw new Error('Anlegen fehlgeschlagen')
|
if (!created?.id) throw new Error('Anlegen fehlgeschlagen')
|
||||||
setDraft((prev) => ({
|
setDraft((prev) => ({
|
||||||
|
|
@ -1139,7 +1149,7 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa
|
||||||
|
|
||||||
{draft.slots.map((slot, idx) => (
|
{draft.slots.map((slot, idx) => (
|
||||||
<ProgressionSlotCard
|
<ProgressionSlotCard
|
||||||
key={`slot-${idx}`}
|
key={`slot-${idx}-${slot.learning_goal?.slice(0, 12) || 'x'}`}
|
||||||
slot={slot}
|
slot={slot}
|
||||||
slotIndex={idx}
|
slotIndex={idx}
|
||||||
slotCount={draft.slots.length}
|
slotCount={draft.slots.length}
|
||||||
|
|
|
||||||
|
|
@ -6,9 +6,7 @@ import FormModalOverlay from './FormModalOverlay'
|
||||||
import {
|
import {
|
||||||
compareSlotReviews,
|
compareSlotReviews,
|
||||||
defaultSelectedCompareDiffs,
|
defaultSelectedCompareDiffs,
|
||||||
pathQaHasSplitDimensions,
|
|
||||||
pathQaQualityPercent,
|
pathQaQualityPercent,
|
||||||
pathQaSubsectionPercent,
|
|
||||||
qualityDeltaPercent,
|
qualityDeltaPercent,
|
||||||
rejectedCompareDiffs,
|
rejectedCompareDiffs,
|
||||||
slotFitScorePercent,
|
slotFitScorePercent,
|
||||||
|
|
@ -191,9 +189,6 @@ function SlotReviewRow({ review, selected, onToggle, applying }) {
|
||||||
<strong>KI-Vorschlag nutzen</strong>
|
<strong>KI-Vorschlag nutzen</strong>
|
||||||
<span style={{ display: 'block', fontSize: '11px', color: 'var(--text3)', marginTop: '2px' }}>
|
<span style={{ display: 'block', fontSize: '11px', color: 'var(--text3)', marginTop: '2px' }}>
|
||||||
{ai.title_hint || 'Neue Übung per KI entwerfen'}
|
{ai.title_hint || 'Neue Übung per KI entwerfen'}
|
||||||
{ai.auto_select
|
|
||||||
? ' — empfohlen, Bibliothek passt nicht ausreichend zum Stufen-Ziel'
|
|
||||||
: ''}
|
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
|
|
@ -228,9 +223,6 @@ export default function ProgressionOptimizeCompareModal({
|
||||||
|
|
||||||
const baselineQa = comparison.baseline_path_qa
|
const baselineQa = comparison.baseline_path_qa
|
||||||
const baselinePct = pathQaQualityPercent(baselineQa)
|
const baselinePct = pathQaQualityPercent(baselineQa)
|
||||||
const hasSplitQa = pathQaHasSplitDimensions(baselineQa)
|
|
||||||
const roadmapPct = pathQaSubsectionPercent(baselineQa?.roadmap_qa)
|
|
||||||
const assignmentPct = pathQaSubsectionPercent(baselineQa?.assignment_qa)
|
|
||||||
const rejectedCount = rejected.length
|
const rejectedCount = rejected.length
|
||||||
const reviewError = comparison.review_error || null
|
const reviewError = comparison.review_error || null
|
||||||
|
|
||||||
|
|
@ -266,9 +258,8 @@ export default function ProgressionOptimizeCompareModal({
|
||||||
{title}
|
{title}
|
||||||
</h3>
|
</h3>
|
||||||
<p style={{ fontSize: '12px', color: 'var(--text3)', marginTop: 0, lineHeight: 1.45 }}>
|
<p style={{ fontSize: '12px', color: 'var(--text3)', marginTop: 0, lineHeight: 1.45 }}>
|
||||||
Je Slot: aktuelle Bewertung, beste Bibliotheks-Alternative und optional KI. Vorauswahl:
|
Je Slot: aktuelle Bewertung, beste Bibliotheks-Alternative und optional KI. Haken nur
|
||||||
Bibliothek nur bei klar besserem Stufen-Fit; bei leeren oder schwach passenden Slots eher
|
vorausgewählt, wenn die Alternative einen höheren Stufen-Fit hat.
|
||||||
KI-Vorschlag.
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{reviewError ? (
|
{reviewError ? (
|
||||||
|
|
@ -298,19 +289,9 @@ export default function ProgressionOptimizeCompareModal({
|
||||||
>
|
>
|
||||||
<strong>Dein Pfad</strong>
|
<strong>Dein Pfad</strong>
|
||||||
<div style={{ marginTop: '6px' }}>{qaLabel(baselineQa)}</div>
|
<div style={{ marginTop: '6px' }}>{qaLabel(baselineQa)}</div>
|
||||||
{hasSplitQa ? (
|
{baselineQa?.topic_coverage ? (
|
||||||
<div style={{ marginTop: '6px', fontSize: '11px', color: 'var(--text2)', lineHeight: 1.45 }}>
|
|
||||||
Roadmap {roadmapPct != null ? `${roadmapPct} %` : '—'}
|
|
||||||
{' · '}
|
|
||||||
Besetzung {assignmentPct != null ? `${assignmentPct} %` : '—'}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
{!hasSplitQa && baselineQa?.topic_coverage ? (
|
|
||||||
<p style={{ margin: '6px 0 0', color: 'var(--text2)' }}>{baselineQa.topic_coverage}</p>
|
<p style={{ margin: '6px 0 0', color: 'var(--text2)' }}>{baselineQa.topic_coverage}</p>
|
||||||
) : null}
|
) : null}
|
||||||
{hasSplitQa && baselineQa?.roadmap_qa?.topic_coverage ? (
|
|
||||||
<p style={{ margin: '6px 0 0', color: 'var(--text2)' }}>{baselineQa.roadmap_qa.topic_coverage}</p>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{rejectedCount > 0 ? (
|
{rejectedCount > 0 ? (
|
||||||
|
|
|
||||||
|
|
@ -1,166 +0,0 @@
|
||||||
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,6 +1,5 @@
|
||||||
import React, { useState } from 'react'
|
import React, { useState } from 'react'
|
||||||
import { api } from '../../utils/api'
|
import { api } from '../../utils/api'
|
||||||
import CatalogPromptSlotsEditor from './CatalogPromptSlotsEditor'
|
|
||||||
|
|
||||||
function DetailPanel({ item, onUpdate, focusAreas }) {
|
function DetailPanel({ item, onUpdate, focusAreas }) {
|
||||||
const type = item._type
|
const type = item._type
|
||||||
|
|
@ -88,7 +87,6 @@ function FocusAreaDetail({ item, onUpdate }) {
|
||||||
</button>
|
</button>
|
||||||
<button type="button" className="btn btn-danger" onClick={handleDelete}>Löschen</button>
|
<button type="button" className="btn btn-danger" onClick={handleDelete}>Löschen</button>
|
||||||
</div>
|
</div>
|
||||||
<CatalogPromptSlotsEditor catalogKind="focus_area" catalogId={item.id} entryName={form.name} />
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -171,7 +169,6 @@ function StyleDirectionDetail({ item, onUpdate, focusAreas }) {
|
||||||
</button>
|
</button>
|
||||||
<button type="button" className="btn btn-danger" onClick={handleDelete}>Löschen</button>
|
<button type="button" className="btn btn-danger" onClick={handleDelete}>Löschen</button>
|
||||||
</div>
|
</div>
|
||||||
<CatalogPromptSlotsEditor catalogKind="style_direction" catalogId={item.id} entryName={form.name} />
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -254,7 +251,6 @@ function TrainingTypeDetail({ item, onUpdate, focusAreas }) {
|
||||||
</button>
|
</button>
|
||||||
<button type="button" className="btn btn-danger" onClick={handleDelete}>Löschen</button>
|
<button type="button" className="btn btn-danger" onClick={handleDelete}>Löschen</button>
|
||||||
</div>
|
</div>
|
||||||
<CatalogPromptSlotsEditor catalogKind="training_type" catalogId={item.id} entryName={form.name} />
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -31,22 +31,8 @@ export default function AdminAiPromptsPage() {
|
||||||
const [pvExec, setPvExec] = useState('<p>Ablauf hier</p>')
|
const [pvExec, setPvExec] = useState('<p>Ablauf hier</p>')
|
||||||
const [pvHint, setPvHint] = useState('')
|
const [pvHint, setPvHint] = useState('')
|
||||||
const [pvFocusId, setPvFocusId] = useState('')
|
const [pvFocusId, setPvFocusId] = useState('')
|
||||||
const [pvGoalQuery, setPvGoalQuery] = useState(
|
|
||||||
'Mae Geri vom Grundschritt bis zur kontrollierten Kumite-Nähe'
|
|
||||||
)
|
|
||||||
const [pvUserNotes, setPvUserNotes] = useState('Fokus Breitensport, ohne Wettkampfdruck.')
|
|
||||||
const [pvMaxSteps, setPvMaxSteps] = useState('5')
|
|
||||||
const [pvSearchQuery, setPvSearchQuery] = useState('')
|
|
||||||
const [pvPreview, setPvPreview] = useState(null)
|
const [pvPreview, setPvPreview] = useState(null)
|
||||||
|
|
||||||
const selectedSlug = (detail?.slug || '').trim().toLowerCase()
|
|
||||||
const isExercisePreviewSlug = [
|
|
||||||
'exercise_summary',
|
|
||||||
'exercise_skill_suggestions',
|
|
||||||
'exercise_instruction_rewrite',
|
|
||||||
].includes(selectedSlug)
|
|
||||||
const isPlanningPreviewSlug = selectedSlug.startsWith('planning_')
|
|
||||||
|
|
||||||
const loadList = useCallback(async () => {
|
const loadList = useCallback(async () => {
|
||||||
const [pList, cat] = await Promise.all([
|
const [pList, cat] = await Promise.all([
|
||||||
api.listAdminAiPrompts(),
|
api.listAdminAiPrompts(),
|
||||||
|
|
@ -147,23 +133,15 @@ export default function AdminAiPromptsPage() {
|
||||||
if (!detail?.id) return
|
if (!detail?.id) return
|
||||||
setError('')
|
setError('')
|
||||||
try {
|
try {
|
||||||
const body = {}
|
const body = {
|
||||||
if (isPlanningPreviewSlug) {
|
title: pvTitle,
|
||||||
body.goal_query = pvGoalQuery.trim() || undefined
|
goal: pvGoal,
|
||||||
body.user_notes = pvUserNotes.trim() || undefined
|
execution: pvExec,
|
||||||
const ms = parseInt(String(pvMaxSteps).trim(), 10)
|
focus_hint: pvHint || undefined,
|
||||||
if (Number.isFinite(ms) && ms >= 2 && ms <= 10) body.max_steps = ms
|
}
|
||||||
const sq = pvSearchQuery.trim()
|
const fid = parseInt(String(pvFocusId).trim(), 10)
|
||||||
if (sq) body.search_query = sq
|
if (Number.isFinite(fid) && fid >= 1) {
|
||||||
} else if (isExercisePreviewSlug) {
|
body.focus_areas_context = [{ focus_area_id: fid, is_primary: true }]
|
||||||
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)
|
const r = await api.previewAdminAiPrompt(detail.id, body)
|
||||||
setPvPreview(r)
|
setPvPreview(r)
|
||||||
|
|
@ -193,8 +171,8 @@ export default function AdminAiPromptsPage() {
|
||||||
<h1 style={{ margin: 0, fontSize: '1.25rem' }}>KI Prompts</h1>
|
<h1 style={{ margin: 0, fontSize: '1.25rem' }}>KI Prompts</h1>
|
||||||
</div>
|
</div>
|
||||||
<p style={{ fontSize: '13px', color: 'var(--text3)', marginTop: 0 }}>
|
<p style={{ fontSize: '13px', color: 'var(--text3)', marginTop: 0 }}>
|
||||||
Datenbankvorlagen (<code>ai_prompts</code>) für Übungs- und Planungs-KI. Platzhalter im Mustache-Stil werden
|
Datenbankvorlagen (<code>ai_prompts</code>) für Übungs-KI. Platzhalter im Mustache-Stil werden serverseitig
|
||||||
serverseitig aufgelöst — die Vorschau unten ruft kein externes Modell auf.
|
aufgelöst — die Vorschau unten ruft kein externes Modell auf.
|
||||||
</p>
|
</p>
|
||||||
{error ? <p style={{ color: 'var(--danger)' }}>{error}</p> : null}
|
{error ? <p style={{ color: 'var(--danger)' }}>{error}</p> : null}
|
||||||
|
|
||||||
|
|
@ -323,89 +301,33 @@ export default function AdminAiPromptsPage() {
|
||||||
|
|
||||||
<section style={{ marginTop: 20, paddingTop: 16, borderTop: '1px solid var(--border)' }}>
|
<section style={{ marginTop: 20, paddingTop: 16, borderTop: '1px solid var(--border)' }}>
|
||||||
<h4 style={{ margin: '0 0 12px', fontSize: '15px' }}>Vorschau (ohne OpenRouter)</h4>
|
<h4 style={{ margin: '0 0 12px', fontSize: '15px' }}>Vorschau (ohne OpenRouter)</h4>
|
||||||
{isPlanningPreviewSlug ? (
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
|
||||||
<>
|
<div className="form-row">
|
||||||
<p style={{ fontSize: 13, color: 'var(--text3)', marginTop: 0 }}>
|
<label className="form-label">Titel</label>
|
||||||
Beispielkontext für Planungs-Prompts — echte Katalog-Auszüge aus der Datenbank, übrige Felder
|
<input className="form-input" value={pvTitle} onChange={(e) => setPvTitle(e.target.value)} />
|
||||||
sind repräsentative Demo-Daten.
|
</div>
|
||||||
</p>
|
<div className="form-row">
|
||||||
<div className="form-row">
|
<label className="form-label">Fokus-ID (optional, Retrieval‑Raster)</label>
|
||||||
<label className="form-label">Zielanfrage (goal_query)</label>
|
<input
|
||||||
<textarea
|
className="form-input"
|
||||||
className="form-input"
|
placeholder="numerisch"
|
||||||
rows={3}
|
value={pvFocusId}
|
||||||
value={pvGoalQuery}
|
onChange={(e) => setPvFocusId(e.target.value)}
|
||||||
onChange={(e) => setPvGoalQuery(e.target.value)}
|
/>
|
||||||
/>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="form-row">
|
<div className="form-row">
|
||||||
<label className="form-label">Trainer-Notizen (user_notes)</label>
|
<label className="form-label">Fokus-Hinweistext</label>
|
||||||
<textarea
|
<input className="form-input" value={pvHint} onChange={(e) => setPvHint(e.target.value)} />
|
||||||
className="form-input"
|
</div>
|
||||||
rows={2}
|
<div className="form-row">
|
||||||
value={pvUserNotes}
|
<label className="form-label">Ziel (HTML möglich)</label>
|
||||||
onChange={(e) => setPvUserNotes(e.target.value)}
|
<textarea className="form-input" rows={4} value={pvGoal} onChange={(e) => setPvGoal(e.target.value)} />
|
||||||
/>
|
</div>
|
||||||
</div>
|
<div className="form-row">
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
|
<label className="form-label">Durchführung (HTML möglich)</label>
|
||||||
<div className="form-row">
|
<textarea className="form-input" rows={4} value={pvExec} onChange={(e) => setPvExec(e.target.value)} />
|
||||||
<label className="form-label">max_steps (Roadmap)</label>
|
</div>
|
||||||
<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>
|
|
||||||
<input className="form-input" value={pvTitle} onChange={(e) => setPvTitle(e.target.value)} />
|
|
||||||
</div>
|
|
||||||
<div className="form-row">
|
|
||||||
<label className="form-label">Fokus-ID (optional, Retrieval‑Raster)</label>
|
|
||||||
<input
|
|
||||||
className="form-input"
|
|
||||||
placeholder="numerisch"
|
|
||||||
value={pvFocusId}
|
|
||||||
onChange={(e) => setPvFocusId(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="form-row">
|
|
||||||
<label className="form-label">Fokus-Hinweistext</label>
|
|
||||||
<input className="form-input" value={pvHint} onChange={(e) => setPvHint(e.target.value)} />
|
|
||||||
</div>
|
|
||||||
<div className="form-row">
|
|
||||||
<label className="form-label">Ziel (HTML möglich)</label>
|
|
||||||
<textarea className="form-input" rows={4} value={pvGoal} onChange={(e) => setPvGoal(e.target.value)} />
|
|
||||||
</div>
|
|
||||||
<div className="form-row">
|
|
||||||
<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()}>
|
<button type="button" className="btn btn-secondary" onClick={() => runPreview()}>
|
||||||
Platzhalter auflösen
|
Platzhalter auflösen
|
||||||
</button>
|
</button>
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@ import { useState, useEffect } from 'react'
|
||||||
import { api } from '../utils/api'
|
import { api } from '../utils/api'
|
||||||
import AdminPageNav from '../components/AdminPageNav'
|
import AdminPageNav from '../components/AdminPageNav'
|
||||||
import PageSectionNav from '../components/PageSectionNav'
|
import PageSectionNav from '../components/PageSectionNav'
|
||||||
import CatalogPromptSlotsEditor from '../components/admin/CatalogPromptSlotsEditor'
|
|
||||||
|
|
||||||
const CATALOG_SUBTABS = [
|
const CATALOG_SUBTABS = [
|
||||||
{ id: 'focus-areas', label: 'Fokusbereiche' },
|
{ id: 'focus-areas', label: 'Fokusbereiche' },
|
||||||
|
|
@ -63,38 +62,6 @@ export default function AdminCatalogsPage() {
|
||||||
// M:N Assignment Matrix
|
// M:N Assignment Matrix
|
||||||
const [assignments, setAssignments] = useState([])
|
const [assignments, setAssignments] = useState([])
|
||||||
const [matrixLoading, setMatrixLoading] = useState(false)
|
const [matrixLoading, setMatrixLoading] = useState(false)
|
||||||
const [openKiSlots, setOpenKiSlots] = useState(null)
|
|
||||||
|
|
||||||
function toggleKiSlots(kind, id) {
|
|
||||||
const key = `${kind}:${id}`
|
|
||||||
setOpenKiSlots((prev) => (prev === key ? null : key))
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderKiSlotsToggle(kind, id, label = 'KI-Planungstexte') {
|
|
||||||
const key = `${kind}:${id}`
|
|
||||||
const open = openKiSlots === key
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn btn-secondary"
|
|
||||||
onClick={() => toggleKiSlots(kind, id)}
|
|
||||||
>
|
|
||||||
{open ? 'KI-Texte ausblenden' : label}
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderKiSlotsPanel(kind, id, entryName) {
|
|
||||||
const key = `${kind}:${id}`
|
|
||||||
if (openKiSlots !== key) return null
|
|
||||||
return (
|
|
||||||
<CatalogPromptSlotsEditor
|
|
||||||
catalogKind={kind}
|
|
||||||
catalogId={id}
|
|
||||||
entryName={entryName}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadData()
|
loadData()
|
||||||
|
|
@ -108,22 +75,14 @@ export default function AdminCatalogsPage() {
|
||||||
const data = await api.listFocusAreas()
|
const data = await api.listFocusAreas()
|
||||||
setFocusAreas(data)
|
setFocusAreas(data)
|
||||||
} else if (activeTab === 'training-styles') {
|
} else if (activeTab === 'training-styles') {
|
||||||
const [data, areas] = await Promise.all([
|
const data = await api.listStyleDirections()
|
||||||
api.listStyleDirections(),
|
|
||||||
api.listFocusAreas(),
|
|
||||||
])
|
|
||||||
setTrainingStyles(data)
|
setTrainingStyles(data)
|
||||||
setFocusAreas(areas)
|
|
||||||
} else if (activeTab === 'training-characters') {
|
} else if (activeTab === 'training-characters') {
|
||||||
const data = await api.listTrainingCharacters()
|
const data = await api.listTrainingCharacters()
|
||||||
setTrainingCharacters(data)
|
setTrainingCharacters(data)
|
||||||
} else if (activeTab === 'training-types') {
|
} else if (activeTab === 'training-types') {
|
||||||
const [data, areas] = await Promise.all([
|
const data = await api.listTrainingTypes()
|
||||||
api.listTrainingTypes(),
|
|
||||||
api.listFocusAreas(),
|
|
||||||
])
|
|
||||||
setTrainingTypes(data)
|
setTrainingTypes(data)
|
||||||
setFocusAreas(areas)
|
|
||||||
} else if (activeTab === 'skill-categories') {
|
} else if (activeTab === 'skill-categories') {
|
||||||
const data = await api.listSkillCategories()
|
const data = await api.listSkillCategories()
|
||||||
setSkillCategories(data)
|
setSkillCategories(data)
|
||||||
|
|
@ -472,11 +431,6 @@ export default function AdminCatalogsPage() {
|
||||||
<button className="btn btn-primary" onClick={() => updateFocusArea(fa.id, editingFA)}>Speichern</button>
|
<button className="btn btn-primary" onClick={() => updateFocusArea(fa.id, editingFA)}>Speichern</button>
|
||||||
<button className="btn" onClick={() => setEditingFA(null)}>Abbrechen</button>
|
<button className="btn" onClick={() => setEditingFA(null)}>Abbrechen</button>
|
||||||
</div>
|
</div>
|
||||||
<CatalogPromptSlotsEditor
|
|
||||||
catalogKind="focus_area"
|
|
||||||
catalogId={fa.id}
|
|
||||||
entryName={editingFA.name}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -495,12 +449,10 @@ export default function AdminCatalogsPage() {
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
|
<div style={{ display: 'flex', gap: '8px' }}>
|
||||||
<button className="btn" onClick={() => setEditingFA(fa)}>Bearbeiten</button>
|
<button className="btn" onClick={() => setEditingFA(fa)}>Bearbeiten</button>
|
||||||
{renderKiSlotsToggle('focus_area', fa.id)}
|
|
||||||
<button className="btn" onClick={() => deleteFocusArea(fa.id)}>Löschen</button>
|
<button className="btn" onClick={() => deleteFocusArea(fa.id)}>Löschen</button>
|
||||||
</div>
|
</div>
|
||||||
{renderKiSlotsPanel('focus_area', fa.id, fa.name)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -587,11 +539,6 @@ export default function AdminCatalogsPage() {
|
||||||
<button className="btn btn-primary" onClick={() => updateStyleDirection(ts.id, editingTS)}>Speichern</button>
|
<button className="btn btn-primary" onClick={() => updateStyleDirection(ts.id, editingTS)}>Speichern</button>
|
||||||
<button className="btn" onClick={() => setEditingTS(null)}>Abbrechen</button>
|
<button className="btn" onClick={() => setEditingTS(null)}>Abbrechen</button>
|
||||||
</div>
|
</div>
|
||||||
<CatalogPromptSlotsEditor
|
|
||||||
catalogKind="style_direction"
|
|
||||||
catalogId={ts.id}
|
|
||||||
entryName={editingTS.name}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -607,12 +554,10 @@ export default function AdminCatalogsPage() {
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<p style={{ margin: '8px 0', color: 'var(--text2)' }}>{ts.description}</p>
|
<p style={{ margin: '8px 0', color: 'var(--text2)' }}>{ts.description}</p>
|
||||||
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
|
<div style={{ display: 'flex', gap: '8px' }}>
|
||||||
<button className="btn" onClick={() => setEditingTS(ts)}>Bearbeiten</button>
|
<button className="btn" onClick={() => setEditingTS(ts)}>Bearbeiten</button>
|
||||||
{renderKiSlotsToggle('style_direction', ts.id)}
|
|
||||||
<button className="btn" onClick={() => deleteStyleDirection(ts.id)}>Löschen</button>
|
<button className="btn" onClick={() => deleteStyleDirection(ts.id)}>Löschen</button>
|
||||||
</div>
|
</div>
|
||||||
{renderKiSlotsPanel('style_direction', ts.id, ts.name)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -785,11 +730,6 @@ export default function AdminCatalogsPage() {
|
||||||
<button className="btn btn-primary" onClick={() => updateTrainingType(tt.id, editingTT)}>Speichern</button>
|
<button className="btn btn-primary" onClick={() => updateTrainingType(tt.id, editingTT)}>Speichern</button>
|
||||||
<button className="btn" onClick={() => setEditingTT(null)}>Abbrechen</button>
|
<button className="btn" onClick={() => setEditingTT(null)}>Abbrechen</button>
|
||||||
</div>
|
</div>
|
||||||
<CatalogPromptSlotsEditor
|
|
||||||
catalogKind="training_type"
|
|
||||||
catalogId={tt.id}
|
|
||||||
entryName={editingTT.name}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -804,12 +744,10 @@ export default function AdminCatalogsPage() {
|
||||||
<span style={{ color: 'var(--accent)' }}>{tt.focus_area_icon} {tt.focus_area_name}</span>
|
<span style={{ color: 'var(--accent)' }}>{tt.focus_area_icon} {tt.focus_area_name}</span>
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap', marginTop: '12px' }}>
|
<div style={{ display: 'flex', gap: '8px', marginTop: '12px' }}>
|
||||||
<button className="btn" onClick={() => setEditingTT(tt)}>Bearbeiten</button>
|
<button className="btn" onClick={() => setEditingTT(tt)}>Bearbeiten</button>
|
||||||
{renderKiSlotsToggle('training_type', tt.id)}
|
|
||||||
<button className="btn" onClick={() => deleteTrainingType(tt.id)}>Löschen</button>
|
<button className="btn" onClick={() => deleteTrainingType(tt.id)}>Löschen</button>
|
||||||
</div>
|
</div>
|
||||||
{renderKiSlotsPanel('training_type', tt.id, tt.name)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1018,11 +956,6 @@ export default function AdminCatalogsPage() {
|
||||||
<button className="btn btn-primary" onClick={() => updateTargetGroup(tg.id, tg)}>Speichern</button>
|
<button className="btn btn-primary" onClick={() => updateTargetGroup(tg.id, tg)}>Speichern</button>
|
||||||
<button className="btn" onClick={() => setEditingTG(null)}>Abbrechen</button>
|
<button className="btn" onClick={() => setEditingTG(null)}>Abbrechen</button>
|
||||||
</div>
|
</div>
|
||||||
<CatalogPromptSlotsEditor
|
|
||||||
catalogKind="target_group"
|
|
||||||
catalogId={tg.id}
|
|
||||||
entryName={tg.name}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||||
|
|
@ -1037,12 +970,10 @@ export default function AdminCatalogsPage() {
|
||||||
<p style={{ margin: '8px 0 0 0', fontSize: '14px' }}>{tg.description}</p>
|
<p style={{ margin: '8px 0 0 0', fontSize: '14px' }}>{tg.description}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
|
<div style={{ display: 'flex', gap: '8px' }}>
|
||||||
<button className="btn" onClick={() => setEditingTG(tg.id)}>Bearbeiten</button>
|
<button className="btn" onClick={() => setEditingTG(tg.id)}>Bearbeiten</button>
|
||||||
{renderKiSlotsToggle('target_group', tg.id)}
|
|
||||||
<button className="btn" style={{ background: 'var(--danger)', color: 'white' }} onClick={() => deleteTargetGroup(tg.id)}>Löschen</button>
|
<button className="btn" style={{ background: 'var(--danger)', color: 'white' }} onClick={() => deleteTargetGroup(tg.id)}>Löschen</button>
|
||||||
</div>
|
</div>
|
||||||
{renderKiSlotsPanel('target_group', tg.id, tg.name)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -626,21 +626,6 @@ export async function getAdminAiPromptPlaceholdersCatalog() {
|
||||||
return request('/api/admin/ai-prompts/catalog/placeholders')
|
return request('/api/admin/ai-prompts/catalog/placeholders')
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listCatalogPromptSlotTypes() {
|
|
||||||
return request('/api/catalog-prompt-slot-types')
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getCatalogPromptSlots(catalogKind, catalogId) {
|
|
||||||
return request(`/api/catalog-prompt-slots/${encodeURIComponent(catalogKind)}/${catalogId}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function updateCatalogPromptSlots(catalogKind, catalogId, data) {
|
|
||||||
return request(`/api/catalog-prompt-slots/${encodeURIComponent(catalogKind)}/${catalogId}`, {
|
|
||||||
method: 'PUT',
|
|
||||||
body: JSON.stringify(data || {}),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Reifegradmodelle / Fähigkeitsmatrix
|
// Reifegradmodelle / Fähigkeitsmatrix
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
@ -1104,9 +1089,6 @@ export const api = {
|
||||||
previewAdminAiPrompt,
|
previewAdminAiPrompt,
|
||||||
resetAdminAiPromptTemplate,
|
resetAdminAiPromptTemplate,
|
||||||
getAdminAiPromptPlaceholdersCatalog,
|
getAdminAiPromptPlaceholdersCatalog,
|
||||||
listCatalogPromptSlotTypes,
|
|
||||||
getCatalogPromptSlots,
|
|
||||||
updateCatalogPromptSlots,
|
|
||||||
listStyleDirections,
|
listStyleDirections,
|
||||||
listTrainingStyles,
|
listTrainingStyles,
|
||||||
createStyleDirection,
|
createStyleDirection,
|
||||||
|
|
|
||||||
|
|
@ -208,27 +208,11 @@ export function aiPreviewToQuickCreateDraft(preview, { title, focusAreaId, sketc
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Sichtbarkeit/club_id für Schnellanlage (z. B. aus Progressionsgraph).
|
|
||||||
* @param {{ visibility?: string, clubId?: number|null }} [governance]
|
|
||||||
*/
|
|
||||||
function resolveQuickCreateGovernance(governance) {
|
|
||||||
const rawVis = (governance?.visibility || 'private').trim().toLowerCase()
|
|
||||||
const vis = rawVis === 'club' || rawVis === 'official' ? rawVis : 'private'
|
|
||||||
let clubId = null
|
|
||||||
if (vis === 'club' && governance?.clubId != null && governance.clubId !== '') {
|
|
||||||
const n = Number(governance.clubId)
|
|
||||||
if (Number.isFinite(n) && n > 0) clubId = n
|
|
||||||
}
|
|
||||||
return { visibility: vis, club_id: clubId }
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* createExercise-Payload aus bearbeitetem Entwurf.
|
* createExercise-Payload aus bearbeitetem Entwurf.
|
||||||
* @param {{ visibility?: string, clubId?: number|null }} [governance]
|
|
||||||
* @throws {Error}
|
* @throws {Error}
|
||||||
*/
|
*/
|
||||||
export function buildQuickCreateExercisePayloadFromDraft(draft, governance) {
|
export function buildQuickCreateExercisePayloadFromDraft(draft) {
|
||||||
const title = (draft?.title || '').trim()
|
const title = (draft?.title || '').trim()
|
||||||
if (title.length < 3) {
|
if (title.length < 3) {
|
||||||
throw new Error('Titel: mindestens 3 Zeichen.')
|
throw new Error('Titel: mindestens 3 Zeichen.')
|
||||||
|
|
@ -255,7 +239,6 @@ export function buildQuickCreateExercisePayloadFromDraft(draft, governance) {
|
||||||
if (summary && !stripHtmlToText(summary).trim()) summary = null
|
if (summary && !stripHtmlToText(summary).trim()) summary = null
|
||||||
|
|
||||||
const skills = (draft?.skillChoices || []).filter((c) => c.include).map((c) => c.after)
|
const skills = (draft?.skillChoices || []).filter((c) => c.include).map((c) => c.after)
|
||||||
const { visibility, club_id: clubId } = resolveQuickCreateGovernance(governance)
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title,
|
title,
|
||||||
|
|
@ -264,7 +247,7 @@ export function buildQuickCreateExercisePayloadFromDraft(draft, governance) {
|
||||||
execution,
|
execution,
|
||||||
preparation: prep,
|
preparation: prep,
|
||||||
trainer_notes: trainerNotes,
|
trainer_notes: trainerNotes,
|
||||||
visibility,
|
visibility: 'private',
|
||||||
status: 'draft',
|
status: 'draft',
|
||||||
equipment: [],
|
equipment: [],
|
||||||
focus_areas_multi: [{ focus_area_id: fid, is_primary: true }],
|
focus_areas_multi: [{ focus_area_id: fid, is_primary: true }],
|
||||||
|
|
@ -273,16 +256,15 @@ export function buildQuickCreateExercisePayloadFromDraft(draft, governance) {
|
||||||
target_groups_multi: [],
|
target_groups_multi: [],
|
||||||
age_groups: [],
|
age_groups: [],
|
||||||
skills,
|
skills,
|
||||||
club_id: clubId,
|
club_id: null,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* createExercise-Payload aus bestätigter Vorschau (Checkbox-Modus).
|
* createExercise-Payload aus bestätigter Vorschau (Checkbox-Modus).
|
||||||
* @param {{ visibility?: string, clubId?: number|null }} [governance]
|
|
||||||
* @throws {Error}
|
* @throws {Error}
|
||||||
*/
|
*/
|
||||||
export function buildQuickCreateExercisePayloadFromPreview(preview, { title, focusAreaId, sketchPlain, ...governance } = {}) {
|
export function buildQuickCreateExercisePayloadFromPreview(preview, { title, focusAreaId, sketchPlain }) {
|
||||||
const sketchHtml = aiPlainTextToMinimalHtml(sketchPlain)
|
const sketchHtml = aiPlainTextToMinimalHtml(sketchPlain)
|
||||||
const fieldMap = {}
|
const fieldMap = {}
|
||||||
for (const c of preview?.instructionChoices || []) {
|
for (const c of preview?.instructionChoices || []) {
|
||||||
|
|
@ -306,7 +288,6 @@ export function buildQuickCreateExercisePayloadFromPreview(preview, { title, foc
|
||||||
}
|
}
|
||||||
|
|
||||||
const skills = (preview?.skillChoices || []).filter((c) => c.include).map((c) => c.after)
|
const skills = (preview?.skillChoices || []).filter((c) => c.include).map((c) => c.after)
|
||||||
const { visibility, club_id: clubId } = resolveQuickCreateGovernance(governance)
|
|
||||||
|
|
||||||
const fid = Number(focusAreaId)
|
const fid = Number(focusAreaId)
|
||||||
if (!Number.isFinite(fid) || fid < 1) {
|
if (!Number.isFinite(fid) || fid < 1) {
|
||||||
|
|
@ -320,7 +301,7 @@ export function buildQuickCreateExercisePayloadFromPreview(preview, { title, foc
|
||||||
execution,
|
execution,
|
||||||
preparation: prep,
|
preparation: prep,
|
||||||
trainer_notes: trainerNotes,
|
trainer_notes: trainerNotes,
|
||||||
visibility,
|
visibility: 'private',
|
||||||
status: 'draft',
|
status: 'draft',
|
||||||
equipment: [],
|
equipment: [],
|
||||||
focus_areas_multi: [{ focus_area_id: fid, is_primary: true }],
|
focus_areas_multi: [{ focus_area_id: fid, is_primary: true }],
|
||||||
|
|
@ -329,7 +310,7 @@ export function buildQuickCreateExercisePayloadFromPreview(preview, { title, foc
|
||||||
target_groups_multi: [],
|
target_groups_multi: [],
|
||||||
age_groups: [],
|
age_groups: [],
|
||||||
skills,
|
skills,
|
||||||
club_id: clubId,
|
club_id: null,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -173,19 +173,8 @@ export function pathQaQualityPercent(pathQa) {
|
||||||
return Math.round(Number(pathQa.quality_score) * 100)
|
return Math.round(Number(pathQa.quality_score) * 100)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function pathQaSubsectionPercent(subsection) {
|
|
||||||
if (subsection?.quality_score == null || !Number.isFinite(Number(subsection.quality_score))) return null
|
|
||||||
return Math.round(Number(subsection.quality_score) * 100)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function pathQaHasSplitDimensions(pathQa) {
|
|
||||||
return Boolean(pathQa?.roadmap_qa || pathQa?.assignment_qa)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function pathQaShowsStrongResult(pathQa) {
|
export function pathQaShowsStrongResult(pathQa) {
|
||||||
const pct = pathQaQualityPercent(pathQa)
|
const pct = pathQaQualityPercent(pathQa)
|
||||||
const assignmentOk = pathQa?.assignment_qa ? pathQa.assignment_qa.overall_ok !== false : true
|
|
||||||
if (!assignmentOk) return false
|
|
||||||
if (pathQa?.overall_ok && pct != null && pct >= 85) return true
|
if (pathQa?.overall_ok && pct != null && pct >= 85) return true
|
||||||
return Boolean(pathQa?.overall_ok && pct != null && pct >= 80 && !(pathQa?.issues || []).length)
|
return Boolean(pathQa?.overall_ok && pct != null && pct >= 80 && !(pathQa?.issues || []).length)
|
||||||
}
|
}
|
||||||
|
|
@ -1084,16 +1073,9 @@ export function compareDiffsForDialog(comparison) {
|
||||||
export function defaultSelectedCompareDiffs(comparison) {
|
export function defaultSelectedCompareDiffs(comparison) {
|
||||||
const reviews = compareSlotReviews(comparison)
|
const reviews = compareSlotReviews(comparison)
|
||||||
if (reviews.length > 0) {
|
if (reviews.length > 0) {
|
||||||
const keys = []
|
return reviews
|
||||||
for (const review of reviews) {
|
.filter((review) => review?.library_alternative?.auto_select)
|
||||||
const midx = review.roadmap_major_step_index
|
.map((review) => slotReviewSelectionKey(review.roadmap_major_step_index, 'library'))
|
||||||
if (review?.ai_alternative?.auto_select) {
|
|
||||||
keys.push(slotReviewSelectionKey(midx, 'ai'))
|
|
||||||
} else if (review?.library_alternative?.auto_select) {
|
|
||||||
keys.push(slotReviewSelectionKey(midx, 'library'))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return keys
|
|
||||||
}
|
}
|
||||||
return compareDiffsForDialog(comparison).map((d) => Number(d.roadmap_major_step_index))
|
return compareDiffsForDialog(comparison).map((d) => Number(d.roadmap_major_step_index))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user