Update Planning Exercise Suggestion and Context Handling
All checks were successful
Deploy Development / deploy (push) Successful in 42s
Test Suite / pytest-backend (push) Successful in 40s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 13s
Test Suite / k6 /health Baseline (push) Successful in 33s
Test Suite / playwright-tests (push) Successful in 1m13s
Test Suite / pytest-backend (pull_request) Successful in 37s
Test Suite / lint-backend (pull_request) Successful in 0s
Test Suite / build-frontend (pull_request) Successful in 13s
Test Suite / k6 /health Baseline (pull_request) Successful in 33s
Test Suite / playwright-tests (pull_request) Successful in 1m15s
All checks were successful
Deploy Development / deploy (push) Successful in 42s
Test Suite / pytest-backend (push) Successful in 40s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 13s
Test Suite / k6 /health Baseline (push) Successful in 33s
Test Suite / playwright-tests (push) Successful in 1m13s
Test Suite / pytest-backend (pull_request) Successful in 37s
Test Suite / lint-backend (pull_request) Successful in 0s
Test Suite / build-frontend (pull_request) Successful in 13s
Test Suite / k6 /health Baseline (pull_request) Successful in 33s
Test Suite / playwright-tests (pull_request) Successful in 1m15s
- Incremented version to 0.8.183, reflecting the implementation of Phase C1 enhancements. - Added support for progression graph auto-matching and variant-aware successors in exercise suggestions. - Updated request and response structures to include `anchor_exercise_variant_id`, `progression_graph_name`, and `suggested_variant_id`. - Enhanced frontend components to integrate planning AI search capabilities, including a new modal for exercise creation and improved context display in the exercise list. - Updated changelog to document these significant improvements in planning AI functionality.
This commit is contained in:
parent
50aff849d8
commit
b2157d8a40
|
|
@ -1,8 +1,8 @@
|
||||||
# Planungs-KI: Übungssuche & Kontext für Neu-Anlage
|
# Planungs-KI: Übungssuche & Kontext für Neu-Anlage
|
||||||
|
|
||||||
**Version:** 0.1
|
**Version:** 0.2
|
||||||
**Datum:** 2026-05-22
|
**Datum:** 2026-05-23
|
||||||
**Status:** P1 — Szenario-Pipeline + LLM Query-Intent-Overlay; P2 LLM-Rerank optional
|
**Status:** P0–P2 ✅ · Phase A/B/B2 ✅ · **Phase C1 ✅** (Graph auto-match + variantenbewusste Nachfolger) · C2–C3 geplant
|
||||||
**Bezüge:** `AI_PLANNING_KI_MULTISTAGE_FORECAST.md` · `AI_PROMPT_TARGET_ARCHITECTURE.md` · `SKILL_SCORING_SPEC.md` · `TRAINING_FRAMEWORK_SPEC.md` §3 (Progressionsgraph)
|
**Bezüge:** `AI_PLANNING_KI_MULTISTAGE_FORECAST.md` · `AI_PROMPT_TARGET_ARCHITECTURE.md` · `SKILL_SCORING_SPEC.md` · `TRAINING_FRAMEWORK_SPEC.md` §3 (Progressionsgraph)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
@ -62,8 +62,11 @@ Serverseitig aus Request + DB (tokenbewusst für spätere LLM-Stufen):
|
||||||
| `planned_exercise_ids[]` | Items der Einheit (Reihenfolge) | „N Übungen im Plan“ |
|
| `planned_exercise_ids[]` | Items der Einheit (Reihenfolge) | „N Übungen im Plan“ |
|
||||||
| `anchor_exercise_id`, Titel | Request oder letzte Übung vor Einfügepunkt | Anker |
|
| `anchor_exercise_id`, Titel | Request oder letzte Übung vor Einfügepunkt | Anker |
|
||||||
| `anchor_skill_ids[]` | `exercise_skills` | (intern) |
|
| `anchor_skill_ids[]` | `exercise_skills` | (intern) |
|
||||||
| `progression_graph_id` | Request (optional) | Graph |
|
| `progression_graph_id` | Request oder **Auto-Match** vom Anker (sichtbarer Graph mit passenden Ausgangskanten) | Graph |
|
||||||
| `progression_successor_ids[]` | `exercise_progression_edges` ab Anker | (intern) |
|
| `progression_graph_name`, `progression_graph_auto_resolved` | Response `context_summary` | Graph (auto) |
|
||||||
|
| `anchor_exercise_variant_id` | Request / Abschnitt-Item / DB | (intern) |
|
||||||
|
| `progression_successor_ids[]` | `exercise_progression_edges` ab Anker (variantenbewusst, Migration **034**) | (intern) |
|
||||||
|
| `progression_successor_variants` | `to_exercise_variant_id` pro Nachfolger | (intern) |
|
||||||
| `group_recent_exercise_ids[]` | Letzte Einheiten derselben Gruppe | Wiederholungsstrafe |
|
| `group_recent_exercise_ids[]` | Letzte Einheiten derselben Gruppe | Wiederholungsstrafe |
|
||||||
| `framework_slot_notes` | Rahmen-Slot falls `framework_slot_id` | (später) |
|
| `framework_slot_notes` | Rahmen-Slot falls `framework_slot_id` | (später) |
|
||||||
|
|
||||||
|
|
@ -106,6 +109,7 @@ score = w_ft * fulltext_rank
|
||||||
"phase_order_index": null,
|
"phase_order_index": null,
|
||||||
"parallel_stream_order_index": null,
|
"parallel_stream_order_index": null,
|
||||||
"anchor_exercise_id": 456,
|
"anchor_exercise_id": 456,
|
||||||
|
"anchor_exercise_variant_id": 12,
|
||||||
"progression_graph_id": 7,
|
"progression_graph_id": 7,
|
||||||
"query": "Schlage mir die nächste Übung vor",
|
"query": "Schlage mir die nächste Übung vor",
|
||||||
"intent_hint": "suggest_next",
|
"intent_hint": "suggest_next",
|
||||||
|
|
@ -156,8 +160,9 @@ score = w_ft * fulltext_rank
|
||||||
|-----|-----------|
|
|-----|-----------|
|
||||||
| `ExercisePickerModal` | Prop `planningContext` → Planungs-API statt reiner `listExercises`; Kontext-Chips; `reasons` unter Treffer |
|
| `ExercisePickerModal` | Prop `planningContext` → Planungs-API statt reiner `listExercises`; Kontext-Chips; `reasons` unter Treffer |
|
||||||
| `TrainingUnitEditPage` | `planningContext` aus Einheit + Picker-Ziel (Anker = letzte Übung im Abschnitt) |
|
| `TrainingUnitEditPage` | `planningContext` aus Einheit + Picker-Ziel (Anker = letzte Übung im Abschnitt) |
|
||||||
|
| **`ExercisesListPageRoot`** | Schalter **„Neu mit KI-Assistent“**: Planungs-KI-Suche (frei, ohne `unit_id`) + Neuanlage im Modal; **„+ Neu“** ausgeblendet |
|
||||||
| Rahmen / Kombi-Formular | analog, sobald `unit_id` / Slot-Blueprint bekannt |
|
| Rahmen / Kombi-Formular | analog, sobald `unit_id` / Slot-Blueprint bekannt |
|
||||||
| Übungsliste | weiter Volltext; Schalter „Neu mit KI-Assistent“ ohne Planungs-Pack |
|
| Übungsliste (ohne KI-Schalter) | weiter Volltext |
|
||||||
|
|
||||||
**Zweites Suchfeld** im Picker: Query = Volltext + ergänzender Begriff (ODER in P0 als Konkatenation an Backend).
|
**Zweites Suchfeld** im Picker: Query = Volltext + ergänzender Begriff (ODER in P0 als Konkatenation an Backend).
|
||||||
|
|
||||||
|
|
@ -174,18 +179,26 @@ Wenn `hits` leer oder Trainer wählt „Mit KI anlegen“:
|
||||||
|
|
||||||
## 9. Phasen-Roadmap
|
## 9. Phasen-Roadmap
|
||||||
|
|
||||||
| Phase | Inhalt |
|
| Phase | Inhalt | Status |
|
||||||
|-------|--------|
|
|-------|--------|--------|
|
||||||
| **P0** ✅ | Context-Pack, Hybrid-Score, API, Picker in Planung |
|
| **P0** | Context-Pack, Hybrid-Score, API, Picker in Planung | ✅ |
|
||||||
| **P0.1** ✅ | `ExerciseMatchProfile` / `PlanningTargetProfile`, `profile_v1`, `target_profile_summary` |
|
| **P0.1** | `ExerciseMatchProfile` / `PlanningTargetProfile`, `profile_v1` | ✅ |
|
||||||
| **P2** ✅ (optional) | LLM-Rerank `planning_exercise_search_rank`, `include_llm_rank`, `llm_rank_applied` |
|
| **P1** | Szenario-Pipeline + LLM Query-Intent → Erwartungsprofil | ✅ |
|
||||||
| **P1** ✅ | Szenario-Pipeline + LLM Query-Intent → Erwartungsprofil-Overlay |
|
| **P2 / B2** | LLM-Rerank bei engem Top-Feld (max. 2 Calls) | ✅ |
|
||||||
| **P3** | Skill-Discovery / Framework-Ziele im Pack |
|
| **P3** | Skill-Discovery / Framework-Ziele im Pack | 🔲 |
|
||||||
|
| **A** | Voll-Library Hybrid-Ranking | ✅ **0.8.177** |
|
||||||
|
| **B** | Text-Signale guidance/Rahmen-Ziele | ✅ **0.8.181** |
|
||||||
|
| **C1** | Graph auto-match + variantenbewusste Nachfolger | ✅ **0.8.183** |
|
||||||
|
| **C2** | Varianten in Trefferliste / Picker | 🔲 |
|
||||||
|
| **C3** | Graph-Builder (Ziel → Pfad → speichern) | 🔲 |
|
||||||
|
| **D** | Neu-Anlage: Pack an `suggestExerciseAi` | 🔲 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 10. Changelog
|
## 10. Changelog
|
||||||
|
|
||||||
|
- **2026-05-23:** Phase C1 — Graph auto-match, variantenbewusste Nachfolger (`planning_exercise_progression.py`).
|
||||||
|
- **2026-05-23:** Phase B2 — Rerank bei engem Top-Feld; Phase B — Text-Signale; Phase A — Voll-Library (siehe §17–§19).
|
||||||
- **2026-05-22:** Erstfassung; P0 API + Planungs-Picker.
|
- **2026-05-22:** Erstfassung; P0 API + Planungs-Picker.
|
||||||
- **2026-05-22:** P0 implementiert (`planning_exercise_suggest.py`, Router, Picker); unsaved Formular-Plan noch nicht an API (nur persistierte Einheit).
|
- **2026-05-22:** P0 implementiert (`planning_exercise_suggest.py`, Router, Picker); unsaved Formular-Plan noch nicht an API (nur persistierte Einheit).
|
||||||
- **2026-05-22:** P0.1 — `planning_exercise_profiles.py`, Profil-Score in Hybrid-Retrieval, `retrieval_phase: profile_v1`, `target_profile_summary`.
|
- **2026-05-22:** P0.1 — `planning_exercise_profiles.py`, Profil-Score in Hybrid-Retrieval, `retrieval_phase: profile_v1`, `target_profile_summary`.
|
||||||
|
|
@ -193,11 +206,16 @@ Wenn `hits` leer oder Trainer wählt „Mit KI anlegen“:
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 11. Bekannte P0-Lücken
|
## 11. Bekannte Lücken & Backlog
|
||||||
|
|
||||||
- **Ungespeicherte Plan-Änderungen:** ✅ Client übergibt `planned_exercise_ids[]` aus Formular (TrainingUnitEditPage).
|
- **Ungespeicherte Plan-Änderungen:** ✅ Client übergibt `planned_exercise_ids[]` aus Formular (TrainingUnitEditPage).
|
||||||
- **Progressionsgraph-ID:** noch nicht aus UI wählbar (`progression_graph_id` nur per API).
|
- **Progressionsgraph-ID:** ✅ Auto-Match vom Anker (**C1**); manuelle Auswahl in UI noch offen.
|
||||||
|
- **Anker-Variante:** ✅ Client + DB (**C1**); Picker wählt Variante bei Treffer noch nicht (**C2**).
|
||||||
|
- **Graph-Builder:** Ziel eingeben → aufbauende Übungen → in Graph speichern (**C3**) — Compound-Nutzen über viele Pläne.
|
||||||
|
- **Varianten-Suche:** Library-Picker nutzt `include_variants`; Planungs-KI rankt primär **Übungsebene** — Varianten-Expansion nur gezielt (**C2**).
|
||||||
|
- **Enrichment:** Superadmin-Tool für Skills; Datenqualität der Bibliothek entscheidend für Profil-Score.
|
||||||
- **LLM-Intent:** ✅ P1 Szenario-Pipeline + `planning_exercise_search_intent` (Migration 073).
|
- **LLM-Intent:** ✅ P1 Szenario-Pipeline + `planning_exercise_search_intent` (Migration 073).
|
||||||
|
- **Preset + LLM:** ✅ Erwartungs-LLM (074) bei Planungsbezug; Preset ohne Plan = kein Erwartungs-LLM.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -209,7 +227,7 @@ Komplexe Planungsanfragen brauchen **Schritte vor** dem Profil-Match — nicht j
|
||||||
|
|
||||||
| `scenario_kind` | Typische Anfrage | LLM Intent? |
|
| `scenario_kind` | Typische Anfrage | LLM Intent? |
|
||||||
|-----------------|------------------|-------------|
|
|-----------------|------------------|-------------|
|
||||||
| `preset_next` | „Nächste Übung vorschlagen“ (Preset) | Nein — nur Basis-Profil |
|
| `preset_next` | „Nächste Übung vorschlagen“ (Preset) | Erwartungs-LLM (074) wenn Planungsbezug |
|
||||||
| `progression` | Progressionsgraph / Pfad | Ja (wenn Freitext) |
|
| `progression` | Progressionsgraph / Pfad | Ja (wenn Freitext) |
|
||||||
| `deepen` | Vertiefung Anker | Ja |
|
| `deepen` | Vertiefung Anker | Ja |
|
||||||
| `continue_plan` | Auf bisherigen Plan aufbauen | Ja |
|
| `continue_plan` | Auf bisherigen Plan aufbauen | Ja |
|
||||||
|
|
@ -256,7 +274,7 @@ Module: `planning_exercise_target_pipeline.py` · `planning_exercise_intent.py`
|
||||||
| Feld | Typ | Default | Bedeutung |
|
| Feld | Typ | Default | Bedeutung |
|
||||||
|------|-----|---------|-----------|
|
|------|-----|---------|-----------|
|
||||||
| `planned_exercise_ids` | `int[]` | — | Optional: Reihenfolge aus Formular (überschreibt DB-Plan) |
|
| `planned_exercise_ids` | `int[]` | — | Optional: Reihenfolge aus Formular (überschreibt DB-Plan) |
|
||||||
| `include_llm_rank` | `bool` | `false` | Top-32 Hybrid-Kandidaten → OpenRouter Prompt `planning_exercise_search_rank` |
|
| `include_llm_rank` | `bool` | `true` (Client) | Backend gated (B2): Rerank nur bei engem Top-Feld, max. 2 LLM-Calls |
|
||||||
|
|
||||||
**Response:**
|
**Response:**
|
||||||
|
|
||||||
|
|
@ -349,4 +367,60 @@ Im Hybrid-Score kommt **`w_profile * profile_score`** hinzu (Intent-abhängig ~0
|
||||||
| `retrieval_phase` | `"profile_v1"` — Phase-1 aktiv, kein LLM-Rerank |
|
| `retrieval_phase` | `"profile_v1"` — Phase-1 aktiv, kein LLM-Rerank |
|
||||||
| `target_profile_summary` | Lesbare Kurzinfo für UI-Chips (Fokus, Top-Skills, Quellen) |
|
| `target_profile_summary` | Lesbare Kurzinfo für UI-Chips (Fokus, Top-Skills, Quellen) |
|
||||||
|
|
||||||
**Phase 2 (P2):** siehe §15 — optional per `include_llm_rank`.
|
**Phase 2 (P2 / B2):** siehe §15 und §18 — `include_llm_rank: true` vom Client, Backend entscheidet.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 17. Phase A — Voll-Library-Ranking (0.8.177)
|
||||||
|
|
||||||
|
- Kein OR-Profil-Pool (~500 Übungen) mehr.
|
||||||
|
- Alle sichtbaren Übungen (bis 8000) werden hybrid gescored (`fetch_all_visible_exercise_rows` + `rank_visible_library_hits`).
|
||||||
|
- API: `full_library_ranked: true`, `retrieval_phase` enthält `+full_library+`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 18. Phase B / B2 — Text-Signale & Rerank-Gates (0.8.181–0.8.182)
|
||||||
|
|
||||||
|
**B — Text-Signale (`planning_exercise_text_signals.py`):**
|
||||||
|
|
||||||
|
- `section_guidance_notes`, Rahmen-Ziele/Notizen → Skill-/Katalog-Gewichte ohne LLM.
|
||||||
|
- `requires_partner` aus Intent filtert Kandidaten.
|
||||||
|
- `retrieval_phase +text_signals`.
|
||||||
|
|
||||||
|
**B2 — Rerank bei unklarem Ranking:**
|
||||||
|
|
||||||
|
- `hybrid_ranking_ambiguous(hits)` (Top-4-/Top-10-Gap).
|
||||||
|
- Rerank auch nach Erwartungs-/Intent-LLM, wenn Scores eng beieinander.
|
||||||
|
- Budget: max. **2** LLM-Calls (Profil + optional Rerank).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 19. Phase C1 — Progressionsgraph im Planungskontext (0.8.183)
|
||||||
|
|
||||||
|
**Modul:** `planning_exercise_progression.py`
|
||||||
|
|
||||||
|
### Auto-Match Graph
|
||||||
|
|
||||||
|
Wenn `progression_graph_id` fehlt und Anker-Übung gesetzt: sichtbarer Graph mit passender `next_exercise`-Kante vom Anker (variantenbewusst). Bevorzugung: variantenspezifische Kanten > Anzahl Kanten.
|
||||||
|
|
||||||
|
### Variantenbewusste Nachfolger (Migration 034)
|
||||||
|
|
||||||
|
Generische Kante (`from_exercise_variant_id IS NULL`) gilt für jeden Anker; variantenspezifische Kante nur bei passender Anker-Variante.
|
||||||
|
|
||||||
|
Treffer: optional `hits[].suggested_variant_id`.
|
||||||
|
|
||||||
|
### Request / Response
|
||||||
|
|
||||||
|
| Feld | Bedeutung |
|
||||||
|
|------|-----------|
|
||||||
|
| `anchor_exercise_variant_id` | Request — Variante der Anker-Übung |
|
||||||
|
| `progression_graph_name` | Response — Name des (auto-)Graphs |
|
||||||
|
| `progression_graph_auto_resolved` | Response — Auto-Match aktiv |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 20. Phase C2 / C3 — Roadmap (offen)
|
||||||
|
|
||||||
|
**C2:** Varianten in Trefferliste / Picker-Auswahl bei Graph-Treffern.
|
||||||
|
|
||||||
|
**C3:** Graph-Builder — Ziel eingeben, aufbauende Übungen vorschlagen, nach Review in Graph speichern (`POST …/edges/sequence`). Nutzen über viele Pläne hinweg.
|
||||||
|
|
|
||||||
210
backend/planning_exercise_progression.py
Normal file
210
backend/planning_exercise_progression.py
Normal file
|
|
@ -0,0 +1,210 @@
|
||||||
|
"""
|
||||||
|
Progressionsgraph-Auflösung für Planungs-KI (Phase C1).
|
||||||
|
|
||||||
|
Variantenbewusste Nachfolger-Kanten (Migration 034) und Auto-Match eines sichtbaren Graphen
|
||||||
|
anhand der Anker-Übung, wenn der Client keine graph_id sendet.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Dict, List, Mapping, Optional, Sequence, Set, Tuple
|
||||||
|
|
||||||
|
from tenant_context import TenantContext, library_content_visibility_sql
|
||||||
|
|
||||||
|
ProgressionSuccessorBundle = Tuple[Set[int], Dict[int, str], Dict[int, Optional[int]]]
|
||||||
|
|
||||||
|
|
||||||
|
def edge_matches_anchor_from(
|
||||||
|
edge: Mapping[str, Any],
|
||||||
|
from_variant_id: Optional[int],
|
||||||
|
) -> bool:
|
||||||
|
"""Kante gilt als Ausgang vom Anker: generische Kante oder passende Varianten-Kante."""
|
||||||
|
edge_var = edge.get("from_exercise_variant_id")
|
||||||
|
if edge_var is None:
|
||||||
|
return True
|
||||||
|
if from_variant_id is None:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
return int(edge_var) == int(from_variant_id)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def filter_outgoing_progression_edges(
|
||||||
|
edges: Sequence[Mapping[str, Any]],
|
||||||
|
*,
|
||||||
|
from_variant_id: Optional[int],
|
||||||
|
) -> List[Mapping[str, Any]]:
|
||||||
|
return [e for e in edges if edge_matches_anchor_from(e, from_variant_id)]
|
||||||
|
|
||||||
|
|
||||||
|
def parse_successors_from_edges(
|
||||||
|
edges: Sequence[Mapping[str, Any]],
|
||||||
|
) -> ProgressionSuccessorBundle:
|
||||||
|
ids: Set[int] = set()
|
||||||
|
notes: Dict[int, str] = {}
|
||||||
|
variants: Dict[int, Optional[int]] = {}
|
||||||
|
for row in edges:
|
||||||
|
tid = int(row["to_exercise_id"])
|
||||||
|
ids.add(tid)
|
||||||
|
n = (row.get("notes") or "").strip()
|
||||||
|
if n:
|
||||||
|
notes[tid] = n
|
||||||
|
raw_v = row.get("to_exercise_variant_id")
|
||||||
|
variants[tid] = int(raw_v) if raw_v is not None else None
|
||||||
|
return ids, notes, variants
|
||||||
|
|
||||||
|
|
||||||
|
def rank_progression_graph_rows(rows: Sequence[Mapping[str, Any]]) -> Optional[Mapping[str, Any]]:
|
||||||
|
if not rows:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _key(row: Mapping[str, Any]) -> Tuple[int, int, int]:
|
||||||
|
var_match = int(row.get("variant_match_count") or 0)
|
||||||
|
out_count = int(row.get("outgoing_count") or 0)
|
||||||
|
gid = int(row.get("id") or 0)
|
||||||
|
return (var_match, out_count, gid)
|
||||||
|
|
||||||
|
return max(rows, key=_key)
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_progression_graph_for_planning(
|
||||||
|
cur,
|
||||||
|
tenant: TenantContext,
|
||||||
|
*,
|
||||||
|
from_exercise_id: Optional[int],
|
||||||
|
from_variant_id: Optional[int],
|
||||||
|
explicit_graph_id: Optional[int],
|
||||||
|
) -> Tuple[Optional[int], Optional[str], bool]:
|
||||||
|
"""
|
||||||
|
Liefert (graph_id, graph_name, auto_resolved).
|
||||||
|
|
||||||
|
Bei explicit_graph_id: Sichtbarkeit prüfen, kein Auto-Match.
|
||||||
|
Sonst: sichtbarer Graph mit passenden Ausgangskanten vom Anker.
|
||||||
|
"""
|
||||||
|
profile_id = tenant.profile_id
|
||||||
|
role = tenant.global_role
|
||||||
|
vis_sql, vis_params = library_content_visibility_sql(
|
||||||
|
alias="g",
|
||||||
|
profile_id=profile_id,
|
||||||
|
role=role,
|
||||||
|
effective_club_id=tenant.effective_club_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
if explicit_graph_id and int(explicit_graph_id) > 0:
|
||||||
|
gid = int(explicit_graph_id)
|
||||||
|
cur.execute(
|
||||||
|
f"""
|
||||||
|
SELECT g.id, g.name
|
||||||
|
FROM exercise_progression_graphs g
|
||||||
|
WHERE g.id = %s AND ({vis_sql})
|
||||||
|
""",
|
||||||
|
[gid, *vis_params],
|
||||||
|
)
|
||||||
|
row = cur.fetchone()
|
||||||
|
if not row:
|
||||||
|
return None, None, False
|
||||||
|
name = (row.get("name") or "").strip() or None
|
||||||
|
return gid, name, False
|
||||||
|
|
||||||
|
if not from_exercise_id or int(from_exercise_id) < 1:
|
||||||
|
return None, None, False
|
||||||
|
|
||||||
|
anchor_var = int(from_variant_id) if from_variant_id is not None else None
|
||||||
|
cur.execute(
|
||||||
|
f"""
|
||||||
|
SELECT g.id, g.name,
|
||||||
|
COUNT(*)::int AS outgoing_count,
|
||||||
|
COUNT(*) FILTER (
|
||||||
|
WHERE e.from_exercise_variant_id IS NOT NULL
|
||||||
|
AND (%s IS NOT NULL)
|
||||||
|
AND e.from_exercise_variant_id = %s
|
||||||
|
)::int AS variant_match_count
|
||||||
|
FROM exercise_progression_edges e
|
||||||
|
INNER JOIN exercise_progression_graphs g ON g.id = e.graph_id
|
||||||
|
WHERE e.from_exercise_id = %s
|
||||||
|
AND LOWER(TRIM(e.edge_type)) = 'next_exercise'
|
||||||
|
AND ({vis_sql})
|
||||||
|
AND (
|
||||||
|
e.from_exercise_variant_id IS NULL
|
||||||
|
OR (%s IS NULL)
|
||||||
|
OR e.from_exercise_variant_id = %s
|
||||||
|
)
|
||||||
|
GROUP BY g.id, g.name
|
||||||
|
""",
|
||||||
|
[anchor_var, anchor_var, int(from_exercise_id), *vis_params, anchor_var, anchor_var],
|
||||||
|
)
|
||||||
|
picked = rank_progression_graph_rows(cur.fetchall())
|
||||||
|
if not picked:
|
||||||
|
return None, None, False
|
||||||
|
gid = int(picked["id"])
|
||||||
|
name = (picked.get("name") or "").strip() or None
|
||||||
|
return gid, name, True
|
||||||
|
|
||||||
|
|
||||||
|
def load_progression_successors_for_anchor(
|
||||||
|
cur,
|
||||||
|
*,
|
||||||
|
graph_id: Optional[int],
|
||||||
|
from_exercise_id: Optional[int],
|
||||||
|
from_variant_id: Optional[int],
|
||||||
|
) -> ProgressionSuccessorBundle:
|
||||||
|
if not graph_id or not from_exercise_id:
|
||||||
|
return set(), {}, {}
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT to_exercise_id, to_exercise_variant_id, notes, from_exercise_variant_id
|
||||||
|
FROM exercise_progression_edges
|
||||||
|
WHERE graph_id = %s AND from_exercise_id = %s
|
||||||
|
AND LOWER(TRIM(edge_type)) = 'next_exercise'
|
||||||
|
""",
|
||||||
|
(int(graph_id), int(from_exercise_id)),
|
||||||
|
)
|
||||||
|
rows = [dict(r) for r in cur.fetchall()]
|
||||||
|
filtered = filter_outgoing_progression_edges(rows, from_variant_id=from_variant_id)
|
||||||
|
return parse_successors_from_edges(filtered)
|
||||||
|
|
||||||
|
|
||||||
|
def apply_progression_context_to_pack(
|
||||||
|
cur,
|
||||||
|
tenant: TenantContext,
|
||||||
|
pack: Dict[str, Any],
|
||||||
|
*,
|
||||||
|
explicit_graph_id: Optional[int],
|
||||||
|
anchor_variant_id: Optional[int],
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""Pack um aufgelösten Graph und Nachfolger anreichern."""
|
||||||
|
anchor_id = pack.get("anchor_exercise_id")
|
||||||
|
pack["anchor_exercise_variant_id"] = anchor_variant_id
|
||||||
|
|
||||||
|
graph_id, graph_name, auto_resolved = resolve_progression_graph_for_planning(
|
||||||
|
cur,
|
||||||
|
tenant,
|
||||||
|
from_exercise_id=anchor_id,
|
||||||
|
from_variant_id=anchor_variant_id,
|
||||||
|
explicit_graph_id=explicit_graph_id,
|
||||||
|
)
|
||||||
|
pack["progression_graph_id"] = graph_id
|
||||||
|
pack["progression_graph_name"] = graph_name
|
||||||
|
pack["progression_graph_auto_resolved"] = bool(auto_resolved)
|
||||||
|
|
||||||
|
succ_ids, notes, succ_variants = load_progression_successors_for_anchor(
|
||||||
|
cur,
|
||||||
|
graph_id=graph_id,
|
||||||
|
from_exercise_id=anchor_id,
|
||||||
|
from_variant_id=anchor_variant_id,
|
||||||
|
)
|
||||||
|
pack["progression_successor_ids"] = sorted(succ_ids)
|
||||||
|
pack["progression_edge_notes"] = notes
|
||||||
|
pack["progression_successor_variants"] = succ_variants
|
||||||
|
return pack
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"apply_progression_context_to_pack",
|
||||||
|
"edge_matches_anchor_from",
|
||||||
|
"filter_outgoing_progression_edges",
|
||||||
|
"load_progression_successors_for_anchor",
|
||||||
|
"parse_successors_from_edges",
|
||||||
|
"rank_progression_graph_rows",
|
||||||
|
"resolve_progression_graph_for_planning",
|
||||||
|
]
|
||||||
|
|
@ -257,6 +257,10 @@ def rank_visible_library_hits(
|
||||||
"reasons": reasons,
|
"reasons": reasons,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
succ_variants = pack.get("progression_successor_variants") or {}
|
||||||
|
suggested_vid = succ_variants.get(eid)
|
||||||
|
if suggested_vid:
|
||||||
|
hits[-1]["suggested_variant_id"] = int(suggested_vid)
|
||||||
|
|
||||||
hits.sort(key=lambda h: (-h["score"], h.get("title") or ""))
|
hits.sort(key=lambda h: (-h["score"], h.get("title") or ""))
|
||||||
return hits, skills_by_ex
|
return hits, skills_by_ex
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ Siehe .claude/docs/working/PLANNING_EXERCISE_SUGGEST_CONTEXT.md
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import re
|
import re
|
||||||
from typing import Any, Dict, List, Optional, Sequence, Set, Tuple
|
from typing import Any, Dict, List, Mapping, Optional, Sequence, Set, Tuple
|
||||||
|
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
@ -15,6 +15,7 @@ from tenant_context import TenantContext, library_content_visibility_sql
|
||||||
from planning_exercise_profiles import skill_profile_summary_from_exercise_ids
|
from planning_exercise_profiles import skill_profile_summary_from_exercise_ids
|
||||||
from planning_exercise_retrieval import run_multistage_planning_retrieval
|
from planning_exercise_retrieval import run_multistage_planning_retrieval
|
||||||
from planning_exercise_llm_rank import try_llm_rerank_planning_hits
|
from planning_exercise_llm_rank import try_llm_rerank_planning_hits
|
||||||
|
from planning_exercise_progression import apply_progression_context_to_pack
|
||||||
from planning_exercise_target_pipeline import (
|
from planning_exercise_target_pipeline import (
|
||||||
build_planning_target_with_query_pipeline,
|
build_planning_target_with_query_pipeline,
|
||||||
compose_retrieval_phase,
|
compose_retrieval_phase,
|
||||||
|
|
@ -53,6 +54,7 @@ class PlanningExerciseSuggestRequest(BaseModel):
|
||||||
phase_order_index: Optional[int] = Field(default=None, ge=0)
|
phase_order_index: Optional[int] = Field(default=None, ge=0)
|
||||||
parallel_stream_order_index: Optional[int] = Field(default=None, ge=0)
|
parallel_stream_order_index: Optional[int] = Field(default=None, ge=0)
|
||||||
anchor_exercise_id: Optional[int] = Field(default=None, ge=1)
|
anchor_exercise_id: Optional[int] = Field(default=None, ge=1)
|
||||||
|
anchor_exercise_variant_id: Optional[int] = Field(default=None, ge=1)
|
||||||
progression_graph_id: Optional[int] = Field(default=None, ge=1)
|
progression_graph_id: Optional[int] = Field(default=None, ge=1)
|
||||||
query: Optional[str] = ""
|
query: Optional[str] = ""
|
||||||
intent_hint: Optional[str] = None
|
intent_hint: Optional[str] = None
|
||||||
|
|
@ -169,31 +171,62 @@ def _load_skill_ids_for_exercise(cur, exercise_id: Optional[int]) -> Set[int]:
|
||||||
return {int(r["skill_id"]) for r in cur.fetchall() if r.get("skill_id")}
|
return {int(r["skill_id"]) for r in cur.fetchall() if r.get("skill_id")}
|
||||||
|
|
||||||
|
|
||||||
def _load_progression_successors(
|
def _resolve_anchor_variant_id(
|
||||||
|
pack: Mapping[str, Any],
|
||||||
|
body: PlanningExerciseSuggestRequest,
|
||||||
|
sections: Optional[Sequence[Dict[str, Any]]] = None,
|
||||||
|
) -> Optional[int]:
|
||||||
|
raw = body.anchor_exercise_variant_id
|
||||||
|
if raw is not None:
|
||||||
|
try:
|
||||||
|
vid = int(raw)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
vid = 0
|
||||||
|
if vid > 0:
|
||||||
|
return vid
|
||||||
|
anchor_id = pack.get("anchor_exercise_id")
|
||||||
|
if not anchor_id or not sections:
|
||||||
|
return None
|
||||||
|
sec = _section_for_context(sections, pack.get("section_order_index"))
|
||||||
|
if not sec:
|
||||||
|
return None
|
||||||
|
target = int(anchor_id)
|
||||||
|
for it in sorted(sec.get("items") or [], key=lambda x: int(x.get("order_index") or 0), reverse=True):
|
||||||
|
if str(it.get("item_type") or "").strip().lower() == "note":
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
eid = int(it.get("exercise_id"))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
if eid != target:
|
||||||
|
continue
|
||||||
|
raw_v = it.get("exercise_variant_id")
|
||||||
|
if raw_v is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
vid = int(raw_v)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
return vid if vid > 0 else None
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _finalize_progression_context(
|
||||||
cur,
|
cur,
|
||||||
graph_id: Optional[int],
|
tenant: TenantContext,
|
||||||
from_exercise_id: Optional[int],
|
pack: Dict[str, Any],
|
||||||
) -> Tuple[Set[int], Dict[int, str]]:
|
body: PlanningExerciseSuggestRequest,
|
||||||
if not graph_id or not from_exercise_id:
|
*,
|
||||||
return set(), {}
|
sections: Optional[Sequence[Dict[str, Any]]] = None,
|
||||||
cur.execute(
|
) -> Dict[str, Any]:
|
||||||
"""
|
anchor_variant = _resolve_anchor_variant_id(pack, body, sections)
|
||||||
SELECT to_exercise_id, notes
|
return apply_progression_context_to_pack(
|
||||||
FROM exercise_progression_edges
|
cur,
|
||||||
WHERE graph_id = %s AND from_exercise_id = %s
|
tenant,
|
||||||
AND LOWER(TRIM(edge_type)) = 'next_exercise'
|
pack,
|
||||||
""",
|
explicit_graph_id=body.progression_graph_id,
|
||||||
(int(graph_id), int(from_exercise_id)),
|
anchor_variant_id=anchor_variant,
|
||||||
)
|
)
|
||||||
ids: Set[int] = set()
|
|
||||||
notes: Dict[int, str] = {}
|
|
||||||
for row in cur.fetchall():
|
|
||||||
tid = int(row["to_exercise_id"])
|
|
||||||
ids.add(tid)
|
|
||||||
n = (row.get("notes") or "").strip()
|
|
||||||
if n:
|
|
||||||
notes[tid] = n
|
|
||||||
return ids, notes
|
|
||||||
|
|
||||||
|
|
||||||
def _load_group_recent_exercise_ids(
|
def _load_group_recent_exercise_ids(
|
||||||
|
|
@ -469,9 +502,6 @@ def build_planning_exercise_context_pack(
|
||||||
planned_ids = _collect_planned_exercise_ids(sections)
|
planned_ids = _collect_planned_exercise_ids(sections)
|
||||||
anchor_id = _resolve_anchor_from_plan(planned_ids, body.anchor_exercise_id)
|
anchor_id = _resolve_anchor_from_plan(planned_ids, body.anchor_exercise_id)
|
||||||
anchor_skills = _load_skill_ids_for_exercise(cur, anchor_id)
|
anchor_skills = _load_skill_ids_for_exercise(cur, anchor_id)
|
||||||
progression_ids, progression_notes = _load_progression_successors(
|
|
||||||
cur, body.progression_graph_id, anchor_id
|
|
||||||
)
|
|
||||||
group_recent = _load_group_recent_exercise_ids(cur, unit.get("group_id"), int(body.unit_id))
|
group_recent = _load_group_recent_exercise_ids(cur, unit.get("group_id"), int(body.unit_id))
|
||||||
|
|
||||||
titles = _load_exercise_titles(cur, [x for x in [anchor_id] if x])
|
titles = _load_exercise_titles(cur, [x for x in [anchor_id] if x])
|
||||||
|
|
@ -493,9 +523,6 @@ def build_planning_exercise_context_pack(
|
||||||
"anchor_exercise_id": anchor_id,
|
"anchor_exercise_id": anchor_id,
|
||||||
"anchor_title": anchor_title,
|
"anchor_title": anchor_title,
|
||||||
"anchor_skill_ids": sorted(anchor_skills),
|
"anchor_skill_ids": sorted(anchor_skills),
|
||||||
"progression_graph_id": body.progression_graph_id,
|
|
||||||
"progression_successor_ids": sorted(progression_ids),
|
|
||||||
"progression_edge_notes": progression_notes,
|
|
||||||
"group_recent_exercise_ids": sorted(group_recent),
|
"group_recent_exercise_ids": sorted(group_recent),
|
||||||
}
|
}
|
||||||
return _attach_planning_context_details(cur, pack, sections=sections, body=body)
|
return _attach_planning_context_details(cur, pack, sections=sections, body=body)
|
||||||
|
|
@ -527,9 +554,6 @@ def build_client_planning_context_pack(
|
||||||
|
|
||||||
anchor_id = _resolve_anchor_from_plan(planned_ids, body.anchor_exercise_id)
|
anchor_id = _resolve_anchor_from_plan(planned_ids, body.anchor_exercise_id)
|
||||||
anchor_skills = _load_skill_ids_for_exercise(cur, anchor_id)
|
anchor_skills = _load_skill_ids_for_exercise(cur, anchor_id)
|
||||||
progression_ids, progression_notes = _load_progression_successors(
|
|
||||||
cur, body.progression_graph_id, anchor_id
|
|
||||||
)
|
|
||||||
|
|
||||||
group_id = body.group_id
|
group_id = body.group_id
|
||||||
group_name = None
|
group_name = None
|
||||||
|
|
@ -559,9 +583,6 @@ def build_client_planning_context_pack(
|
||||||
"anchor_exercise_id": anchor_id,
|
"anchor_exercise_id": anchor_id,
|
||||||
"anchor_title": anchor_title,
|
"anchor_title": anchor_title,
|
||||||
"anchor_skill_ids": sorted(anchor_skills),
|
"anchor_skill_ids": sorted(anchor_skills),
|
||||||
"progression_graph_id": body.progression_graph_id,
|
|
||||||
"progression_successor_ids": sorted(progression_ids),
|
|
||||||
"progression_edge_notes": progression_notes,
|
|
||||||
"group_recent_exercise_ids": sorted(group_recent),
|
"group_recent_exercise_ids": sorted(group_recent),
|
||||||
"context_mode": "client_free",
|
"context_mode": "client_free",
|
||||||
}
|
}
|
||||||
|
|
@ -580,6 +601,12 @@ def suggest_planning_exercises(
|
||||||
pack = build_client_planning_context_pack(cur, tenant=tenant, body=body)
|
pack = build_client_planning_context_pack(cur, tenant=tenant, body=body)
|
||||||
pack = _apply_client_planned_override(cur, pack, body)
|
pack = _apply_client_planned_override(cur, pack, body)
|
||||||
pack = _attach_planning_context_details(cur, pack, body=body)
|
pack = _attach_planning_context_details(cur, pack, body=body)
|
||||||
|
sections_for_variant = None
|
||||||
|
if body.unit_id and not (body.anchor_exercise_variant_id and int(body.anchor_exercise_variant_id) > 0):
|
||||||
|
sections_for_variant = _fetch_sections(cur, int(body.unit_id))
|
||||||
|
pack = _finalize_progression_context(
|
||||||
|
cur, tenant, pack, body, sections=sections_for_variant
|
||||||
|
)
|
||||||
query = _normalize_query(body.query)
|
query = _normalize_query(body.query)
|
||||||
heuristic_intent = resolve_planning_exercise_intent(query, body.intent_hint)
|
heuristic_intent = resolve_planning_exercise_intent(query, body.intent_hint)
|
||||||
|
|
||||||
|
|
@ -713,6 +740,9 @@ def suggest_planning_exercises(
|
||||||
"anchor_exercise_id": pack.get("anchor_exercise_id"),
|
"anchor_exercise_id": pack.get("anchor_exercise_id"),
|
||||||
"last_section_exercise_title": pack.get("last_section_exercise_title"),
|
"last_section_exercise_title": pack.get("last_section_exercise_title"),
|
||||||
"progression_graph_id": pack.get("progression_graph_id"),
|
"progression_graph_id": pack.get("progression_graph_id"),
|
||||||
|
"progression_graph_name": pack.get("progression_graph_name"),
|
||||||
|
"progression_graph_auto_resolved": pack.get("progression_graph_auto_resolved"),
|
||||||
|
"anchor_exercise_variant_id": pack.get("anchor_exercise_variant_id"),
|
||||||
"context_mode": pack.get("context_mode") or ("unit" if pack.get("unit_id") else "client_free"),
|
"context_mode": pack.get("context_mode") or ("unit" if pack.get("unit_id") else "client_free"),
|
||||||
"unit_skill_profile": pack.get("unit_skill_profile_summary"),
|
"unit_skill_profile": pack.get("unit_skill_profile_summary"),
|
||||||
"section_skill_profile": pack.get("section_skill_profile_summary"),
|
"section_skill_profile": pack.get("section_skill_profile_summary"),
|
||||||
|
|
|
||||||
56
backend/tests/test_planning_exercise_progression.py
Normal file
56
backend/tests/test_planning_exercise_progression.py
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
"""Tests Progressionsgraph-Auflösung für Planungs-KI (Phase C1)."""
|
||||||
|
from planning_exercise_progression import (
|
||||||
|
edge_matches_anchor_from,
|
||||||
|
filter_outgoing_progression_edges,
|
||||||
|
parse_successors_from_edges,
|
||||||
|
rank_progression_graph_rows,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_edge_matches_anchor_from_generic_edge():
|
||||||
|
assert edge_matches_anchor_from({"from_exercise_variant_id": None}, None)
|
||||||
|
assert edge_matches_anchor_from({"from_exercise_variant_id": None}, 5)
|
||||||
|
|
||||||
|
|
||||||
|
def test_edge_matches_anchor_from_variant_specific():
|
||||||
|
assert edge_matches_anchor_from({"from_exercise_variant_id": 3}, 3)
|
||||||
|
assert not edge_matches_anchor_from({"from_exercise_variant_id": 3}, None)
|
||||||
|
assert not edge_matches_anchor_from({"from_exercise_variant_id": 3}, 4)
|
||||||
|
|
||||||
|
|
||||||
|
def test_filter_outgoing_progression_edges():
|
||||||
|
edges = [
|
||||||
|
{"from_exercise_variant_id": None, "to_exercise_id": 10},
|
||||||
|
{"from_exercise_variant_id": 2, "to_exercise_id": 11},
|
||||||
|
]
|
||||||
|
filtered = filter_outgoing_progression_edges(edges, from_variant_id=2)
|
||||||
|
assert len(filtered) == 2
|
||||||
|
only_generic = filter_outgoing_progression_edges(edges, from_variant_id=None)
|
||||||
|
assert len(only_generic) == 1
|
||||||
|
assert only_generic[0]["to_exercise_id"] == 10
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_successors_from_edges():
|
||||||
|
ids, notes, variants = parse_successors_from_edges(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"to_exercise_id": 20,
|
||||||
|
"to_exercise_variant_id": 7,
|
||||||
|
"notes": " leicht ",
|
||||||
|
},
|
||||||
|
{"to_exercise_id": 21, "to_exercise_variant_id": None, "notes": ""},
|
||||||
|
]
|
||||||
|
)
|
||||||
|
assert ids == {20, 21}
|
||||||
|
assert notes[20] == "leicht"
|
||||||
|
assert variants[20] == 7
|
||||||
|
assert variants[21] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_rank_progression_graph_rows_prefers_variant_match():
|
||||||
|
rows = [
|
||||||
|
{"id": 1, "variant_match_count": 0, "outgoing_count": 5},
|
||||||
|
{"id": 2, "variant_match_count": 2, "outgoing_count": 1},
|
||||||
|
]
|
||||||
|
best = rank_progression_graph_rows(rows)
|
||||||
|
assert best["id"] == 2
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
# Shinkan Jinkendo Version Information
|
# Shinkan Jinkendo Version Information
|
||||||
|
|
||||||
APP_VERSION = "0.8.182"
|
APP_VERSION = "0.8.183"
|
||||||
BUILD_DATE = "2026-05-23"
|
BUILD_DATE = "2026-05-23"
|
||||||
DB_SCHEMA_VERSION = "20260531074"
|
DB_SCHEMA_VERSION = "20260531074"
|
||||||
|
|
||||||
|
|
@ -29,7 +29,7 @@ MODULE_VERSIONS = {
|
||||||
"skill_profiles": "1.0.0", # Phase 3: gewichtetes Fähigkeiten-Profil + skill-discovery/suggestions
|
"skill_profiles": "1.0.0", # Phase 3: gewichtetes Fähigkeiten-Profil + skill-discovery/suggestions
|
||||||
"methods": "0.1.0",
|
"methods": "0.1.0",
|
||||||
"exercises": "2.37.0", # Planungs-KI P1: Szenario-Pipeline + Query-Intent-Overlay
|
"exercises": "2.37.0", # Planungs-KI P1: Szenario-Pipeline + Query-Intent-Overlay
|
||||||
"planning_exercise_suggest": "0.10.0", # Phase B2: Rerank bei engem Top-Feld, auch nach Profil-LLM
|
"planning_exercise_suggest": "0.11.0", # Phase C1: Graph auto-match + variantenbewusste Nachfolger
|
||||||
"training_units": "0.4.0", # POST .../publish-to-framework: Ablauf aus geplanter Einheit → Rahmen-Slot-Blueprint
|
"training_units": "0.4.0", # POST .../publish-to-framework: Ablauf aus geplanter Einheit → Rahmen-Slot-Blueprint
|
||||||
"training_programs": "0.1.0",
|
"training_programs": "0.1.0",
|
||||||
"planning": "0.15.0", # Vorlagen: Strukturvorschau, Bearbeiten inkl. Split-Sessions + Beschreibung
|
"planning": "0.15.0", # Vorlagen: Strukturvorschau, Bearbeiten inkl. Split-Sessions + Beschreibung
|
||||||
|
|
@ -44,6 +44,15 @@ MODULE_VERSIONS = {
|
||||||
}
|
}
|
||||||
|
|
||||||
CHANGELOG = [
|
CHANGELOG = [
|
||||||
|
{
|
||||||
|
"version": "0.8.183",
|
||||||
|
"date": "2026-05-23",
|
||||||
|
"changes": [
|
||||||
|
"Planungs-KI Phase C1: Progressionsgraph auto-match vom Anker; variantenbewusste Nachfolger-Kanten (034).",
|
||||||
|
"Request/Response: anchor_exercise_variant_id, progression_graph_name, suggested_variant_id in Treffern.",
|
||||||
|
"Frontend Übungsliste: Planungs-KI-Suche im KI-Assistent-Modus; Neuanlage im Dialog statt „+ Neu“.",
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"version": "0.8.182",
|
"version": "0.8.182",
|
||||||
"date": "2026-05-23",
|
"date": "2026-05-23",
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
# Shinkan Jinkendo – Entwicklungsstand & Handover
|
# Shinkan Jinkendo – Entwicklungsstand & Handover
|
||||||
|
|
||||||
**Stand:** 2026-05-31
|
**Stand:** 2026-05-23
|
||||||
**App-Version / DB-Schema:** App **`0.8.167`** (Planungs-KI Übungssuche P0); DB **`20260531071`** — maßgeblich **`backend/version.py`**.
|
**App-Version / DB-Schema:** App **`0.8.183`** (Planungs-KI Phase C1); DB **`20260531074`** — maßgeblich **`backend/version.py`**.
|
||||||
|
|
||||||
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**.
|
||||||
|
|
||||||
|
|
@ -89,9 +89,34 @@ Das Schema ist gegenüber dem Code zurück: Migration **`022_skills_schema_compl
|
||||||
- **Varianten:** Speichern in der **Aktionsleiste** persistiert zuerst geänderte Varianten (`persistPendingVariantChanges`), dann Übungs-Stammdaten; „Variante anlegen“ als `type="button"` ohne verschachteltes Formular (`createVariantFromDraft`)
|
- **Varianten:** Speichern in der **Aktionsleiste** persistiert zuerst geänderte Varianten (`persistPendingVariantChanges`), dann Übungs-Stammdaten; „Variante anlegen“ als `type="button"` ohne verschachteltes Formular (`createVariantFromDraft`)
|
||||||
- **Governance (Übungen):** Owner = `created_by`; Bearbeiten = Ersteller, Plattform-Admin oder `can_plan_in_club` bei `visibility=club`; Löschen `club` = nur `club_admin`; Details **`FEATURES_DELIVERED_2026-Q2.md`** §16, **`EXERCISES_API_SPEC.md`** Permissions
|
- **Governance (Übungen):** Owner = `created_by`; Bearbeiten = Ersteller, Plattform-Admin oder `can_plan_in_club` bei `visibility=club`; Löschen `club` = nur `club_admin`; Details **`FEATURES_DELIVERED_2026-Q2.md`** §16, **`EXERCISES_API_SPEC.md`** Permissions
|
||||||
|
|
||||||
### 2.8 KI Assistenz Übungen & Skill-Katalog-Retrieval (Stand **0.8.171**)
|
### 2.8 KI Assistenz Übungen & Planungs-KI Übungssuche (Stand **0.8.183**)
|
||||||
|
|
||||||
- **Planungs-Übungssuche (P1):** Szenario-Pipeline + **LLM Query-Intent** (`planning_exercise_search_intent`) → Erwartungsprofil-Overlay; danach Hybrid + optional LLM-Rerank — `.claude/docs/working/PLANNING_EXERCISE_SUGGEST_CONTEXT.md` §16.
|
**Spec / Pipeline:** `.claude/docs/working/PLANNING_EXERCISE_SUGGEST_CONTEXT.md`
|
||||||
|
|
||||||
|
| Phase | Inhalt | Status |
|
||||||
|
|-------|--------|--------|
|
||||||
|
| **P0** | Kontext-Pack, Hybrid-Score, Planungs-Picker | ✅ |
|
||||||
|
| **P0.1** | `ExerciseMatchProfile` / `PlanningTargetProfile`, `profile_v1` | ✅ |
|
||||||
|
| **P1** | Szenario-Pipeline + LLM Intent (`073`) + Erwartungsprofil (`074`) | ✅ |
|
||||||
|
| **P2 / B2** | LLM-Rerank (`072`) bei engem Top-Feld, max. 2 LLM-Calls | ✅ **0.8.182** |
|
||||||
|
| **A** | Voll-Library deterministisch ranken (kein OR-Profil-Pool) | ✅ **0.8.177** |
|
||||||
|
| **B** | Text-Signale aus guidance/Rahmen-Zielen (`planning_text_signals`) | ✅ **0.8.181** |
|
||||||
|
| **C1** | Progressionsgraph auto-match + variantenbewusste Nachfolger | ✅ **0.8.183** |
|
||||||
|
| **C2** | Varianten in Trefferliste / Picker-Auswahl | 🔲 |
|
||||||
|
| **C3** | Graph-Builder: Ziel → Pfad vorschlagen → in Graph speichern | 🔲 |
|
||||||
|
| **D** | `planning_context` an `suggestExerciseAi` (Neu-Anlage) | 🔲 |
|
||||||
|
|
||||||
|
**Backend:** `planning_exercise_suggest.py`, `planning_exercise_retrieval.py`, `planning_exercise_profiles.py`, `planning_exercise_target_pipeline.py`, `planning_exercise_progression.py` · Router `POST /api/planning/exercise-suggest`
|
||||||
|
|
||||||
|
**Frontend:** `ExercisePickerModal` (Planung) · **`ExercisesListPageRoot`** — Schalter „Neu mit KI-Assistent“: Planungs-KI-Suche + Neuanlage-Modal (statt „+ Neu“) · `TrainingUnitEditPage` — `planningContext`
|
||||||
|
|
||||||
|
**Superadmin:** Übungs-Anreicherung (Skills) — `exercise_enrichment_admin` (**0.8.178+**), separater Admin-Flow
|
||||||
|
|
||||||
|
**Offen (Qualität):** Bibliothek durchgängig mit Skills (Enrichment-Datenarbeit); manuelle Graph-Auswahl in UI; Progressionsgraph-Builder; Skill-Discovery/Framework-Pfade im Pack (P3)
|
||||||
|
|
||||||
|
#### Übungs-KI Formular / Schnellanlage (Stand **0.8.171**)
|
||||||
|
|
||||||
|
- **Planungs-Übungssuche:** siehe Tabelle oben — getrennt von Formular-KI.
|
||||||
- **Doku:** Umsetzung `.claude/docs/working/AI_EXERCISE_IMPLEMENTATION_PLAN.md`; Profil-/JSON-Konzept `.claude/docs/working/AI_SKILL_RETRIEVAL_PROFILES_SPEC.md`; Ist-Prompt/UI **`AI_PROMPT_SYSTEM_SPEC.md`**; API-Felder **`KI_FEATURES_SPEC.md`** §5.2
|
- **Doku:** Umsetzung `.claude/docs/working/AI_EXERCISE_IMPLEMENTATION_PLAN.md`; Profil-/JSON-Konzept `.claude/docs/working/AI_SKILL_RETRIEVAL_PROFILES_SPEC.md`; Ist-Prompt/UI **`AI_PROMPT_SYSTEM_SPEC.md`**; API-Felder **`KI_FEATURES_SPEC.md`** §5.2
|
||||||
- **Kontext / Job:** **`ai_prompt_context`** (Titel, Ziel, Durchführung, Vorbereitung, Trainer-Hinweise, Fokus); **`ai_prompt_job`** — **`run_exercise_form_ai_suggestion`**; **`ai_prompt_runtime`**; **`exercise_ai`** — OpenRouter
|
- **Kontext / Job:** **`ai_prompt_context`** (Titel, Ziel, Durchführung, Vorbereitung, Trainer-Hinweise, Fokus); **`ai_prompt_job`** — **`run_exercise_form_ai_suggestion`**; **`ai_prompt_runtime`**; **`exercise_ai`** — OpenRouter
|
||||||
- **DB:** **`067`** ai_prompts · **`069`** default_template · **`068`** ai_skill_retrieval_profiles · **`070`** openrouter_model · **`071`** **`exercise_instruction_rewrite`**
|
- **DB:** **`067`** ai_prompts · **`069`** default_template · **`068`** ai_skill_retrieval_profiles · **`070`** openrouter_model · **`071`** **`exercise_instruction_rewrite`**
|
||||||
|
|
@ -100,7 +125,7 @@ Das Schema ist gegenüber dem Code zurück: Migration **`022_skills_schema_compl
|
||||||
- **Pflege:** Superadmin **`/admin/ai-prompts`**, **`/admin/ai-skill-retrieval`**
|
- **Pflege:** Superadmin **`/admin/ai-prompts`**, **`/admin/ai-skill-retrieval`**
|
||||||
- **Diagnose:** **`SHINKAN_AI_DEBUG=1`** — Logs `shinkan.exercise_ai`, `shinkan.openrouter`
|
- **Diagnose:** **`SHINKAN_AI_DEBUG=1`** — Logs `shinkan.exercise_ai`, `shinkan.openrouter`
|
||||||
- **Frontend Formular:** Tab **Anleitung** — **„KI: Anleitung überarbeiten“**; Vorschau-Dialog pro Feld (**`ExerciseFormPageRoot.jsx`**)
|
- **Frontend Formular:** Tab **Anleitung** — **„KI: Anleitung überarbeiten“**; Vorschau-Dialog pro Feld (**`ExerciseFormPageRoot.jsx`**)
|
||||||
- **Frontend Schnellanlage:** **`ExercisePickerModal`** (Planung/Rahmen) — Volltextsuche; bei keinem Treffer **„Mit KI anlegen“** (Suchstring → Titel/Skizze); Entwurf im **Rich-Text-Dialog** bearbeiten, dann speichern & übernehmen. **`ExercisesListPageRoot`** — gleiches Muster + Schalter **„KI-Anlage“** in der Suchleiste.
|
- **Frontend Schnellanlage:** **`ExercisePickerModal`** (Planung/Rahmen) — Volltext vs. Planungs-KI; KI-Neuanlage im Picker. **`ExercisesListPageRoot`** — Schalter **„Neu mit KI-Assistent“**: Planungs-KI-Suche (`usePlanningExerciseSuggestSearch`) + **`ExerciseAiQuickCreateModal`**; normales **„+ Neu“** ausgeblendet solange aktiv.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -220,18 +245,28 @@ Das Schema ist gegenüber dem Code zurück: Migration **`022_skills_schema_compl
|
||||||
|
|
||||||
## 7. Nächste Session — sinnvolle Arbeitspakete
|
## 7. Nächste Session — sinnvolle Arbeitspakete
|
||||||
|
|
||||||
1. **Coaching & Breakout (Regression):** Mehrphasen-Einheit mit zwei Splits und Ganzgruppen dazwischen — Rejoin-Karten, Nachbereitung speichern, Anzeige in Plan & Ablauf (`docs/HANDOVER.md` Arbeitspaket-Tabelle).
|
### Planungs-KI (priorisiert)
|
||||||
2. **P-13 Frontend-Verifikation:** Melde-Flow in Medienbibliothek, Inbox-Workflow (Status, Archiv, Wiedereröffnen), Club-Admin-Ansicht manuell auf Dev-System durchspielen. E-Mail-Benachrichtigungen verifizieren (SMTP-Log).
|
|
||||||
3. **Inline (Spec Abschnitt 11):** Basis umgesetzt — verbleibend: gezielte UX-Politik; optional Server-Normalisierung/Absicherung prüfen, falls Produkt es verlangt.
|
1. **C2 — Varianten in Treffern:** Planungs-Picker: bei `suggested_variant_id` Variante vorauswählen; optional Varianten-Ranking bei `deepen`/`progression`.
|
||||||
4. **Tests:** pytest für `media_assets`-Router (Leserechte, Lifecycle, `from-asset`); ggf. Snapshot der Pfad-Umzug-Logik.
|
2. **C3 — Graph-Builder:** Modus „Pfad zum Ziel“ → sequenzielle Vorschläge → `POST …/edges/sequence` nach Review.
|
||||||
5. **Retention:** Job-Dokumentation + Betrieb (ENV, Intervall); Dry-Run beschreiben.
|
3. **Graph-Auswahl UI:** Dropdown neben Auto-Match; Rahmen-Slot / Framework mit Default-Graph verknüpfen.
|
||||||
6. **S3/Adapter:** Speicher-Abstraktion (Spec Abschnitt 7) — wenn Produkt es verlangt.
|
4. **Enrichment:** Skills für Kern-Übungen nachziehen (sonst schwaches Profil-Ranking).
|
||||||
7. **Rahmen/UI:** Kalender „aus Rahmen übernehmen” weiter anbinden (parallel, unabhängig von Medien).
|
5. **D — Neu-Anlage:** `planning_context_json` an `POST /api/exercises/ai/suggest`.
|
||||||
8. **Fachlicher Nutzerüberblick:** bei größeren UX-Änderungen **`docs/FACHLICHE_NUTZERFUNKTIONEN.md`** mitpflegen.
|
|
||||||
9. **Kombinations-Coach (Archetyp B/C):** Fachspez § 10.4 / **§ 10.6**; nach Implementierung **Anhang A** + `TRAINING_MODULES_IMPLEMENTATION_PLAN.md` aktualisieren (kein Doc-Drift).
|
### Allgemein
|
||||||
10. **Archetyp-Administration:** Konfiguration oder DB statt nur `COMBINATION_ARCHETYPE_IDS` / `combinationArchetypes.js` (Paket **4e**).
|
|
||||||
11. **Kombi-Zeitfelder:** Massen-Vorbelegung aller Slots aus Archetyp/Global + optionales Modal beim Archetypwechsel (Paket **4f**, `COMBINATION_TIMING_PROFILE_PLAN.md`).
|
6. **Coaching & Breakout (Regression):** Mehrphasen-Einheit mit zwei Splits und Ganzgruppen dazwischen — Rejoin-Karten, Nachbereitung speichern, Anzeige in Plan & Ablauf (`docs/HANDOVER.md` Arbeitspaket-Tabelle).
|
||||||
12. **Backend-Validierung** `method_profile` / `planning_method_profile` je Archetyp (Paket **4g**).
|
7. **P-13 Frontend-Verifikation:** Melde-Flow in Medienbibliothek, Inbox-Workflow (Status, Archiv, Wiedereröffnen), Club-Admin-Ansicht manuell auf Dev-System durchspielen. E-Mail-Benachrichtigungen verifizieren (SMTP-Log).
|
||||||
|
8. **Inline (Spec Abschnitt 11):** Basis umgesetzt — verbleibend: gezielte UX-Politik; optional Server-Normalisierung/Absicherung prüfen, falls Produkt es verlangt.
|
||||||
|
9. **Tests:** pytest für `media_assets`-Router (Leserechte, Lifecycle, `from-asset`); ggf. Snapshot der Pfad-Umzug-Logik.
|
||||||
|
10. **Retention:** Job-Dokumentation + Betrieb (ENV, Intervall); Dry-Run beschreiben.
|
||||||
|
11. **S3/Adapter:** Speicher-Abstraktion (Spec Abschnitt 7) — wenn Produkt es verlangt.
|
||||||
|
12. **Rahmen/UI:** Kalender „aus Rahmen übernehmen” weiter anbinden (parallel, unabhängig von Medien).
|
||||||
|
13. **Fachlicher Nutzerüberblick:** bei größeren UX-Änderungen **`docs/FACHLICHE_NUTZERFUNKTIONEN.md`** mitpflegen.
|
||||||
|
14. **Kombinations-Coach (Archetyp B/C):** Fachspez § 10.4 / **§ 10.6**; nach Implementierung **Anhang A** + `TRAINING_MODULES_IMPLEMENTATION_PLAN.md` aktualisieren (kein Doc-Drift).
|
||||||
|
15. **Archetyp-Administration:** Konfiguration oder DB statt nur `COMBINATION_ARCHETYPE_IDS` / `combinationArchetypes.js` (Paket **4e**).
|
||||||
|
16. **Kombi-Zeitfelder:** Massen-Vorbelegung aller Slots aus Archetyp/Global + optionales Modal beim Archetypwechsel (Paket **4f**, `COMBINATION_TIMING_PROFILE_PLAN.md`).
|
||||||
|
17. **Backend-Validierung** `method_profile` / `planning_method_profile` je Archetyp (Paket **4g**).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -114,6 +114,7 @@ export default function ExercisePickerModal({
|
||||||
phaseOrderIndex: planningContext?.phaseOrderIndex ?? null,
|
phaseOrderIndex: planningContext?.phaseOrderIndex ?? null,
|
||||||
parallelStreamOrderIndex: planningContext?.parallelStreamOrderIndex ?? null,
|
parallelStreamOrderIndex: planningContext?.parallelStreamOrderIndex ?? null,
|
||||||
anchorExerciseId: planningContext?.anchorExerciseId ?? null,
|
anchorExerciseId: planningContext?.anchorExerciseId ?? null,
|
||||||
|
anchorExerciseVariantId: planningContext?.anchorExerciseVariantId ?? null,
|
||||||
progressionGraphId: planningContext?.progressionGraphId ?? null,
|
progressionGraphId: planningContext?.progressionGraphId ?? null,
|
||||||
plannedExerciseIds: Array.isArray(planningContext?.plannedExerciseIds)
|
plannedExerciseIds: Array.isArray(planningContext?.plannedExerciseIds)
|
||||||
? planningContext.plannedExerciseIds
|
? planningContext.plannedExerciseIds
|
||||||
|
|
@ -446,6 +447,10 @@ export default function ExercisePickerModal({
|
||||||
activePlanningContext.anchorExerciseId != null
|
activePlanningContext.anchorExerciseId != null
|
||||||
? Number(activePlanningContext.anchorExerciseId)
|
? Number(activePlanningContext.anchorExerciseId)
|
||||||
: null,
|
: null,
|
||||||
|
anchor_exercise_variant_id:
|
||||||
|
activePlanningContext.anchorExerciseVariantId != null
|
||||||
|
? Number(activePlanningContext.anchorExerciseVariantId)
|
||||||
|
: undefined,
|
||||||
progression_graph_id:
|
progression_graph_id:
|
||||||
activePlanningContext.progressionGraphId != null
|
activePlanningContext.progressionGraphId != null
|
||||||
? Number(activePlanningContext.progressionGraphId)
|
? Number(activePlanningContext.progressionGraphId)
|
||||||
|
|
@ -500,6 +505,7 @@ export default function ExercisePickerModal({
|
||||||
title: h.title,
|
title: h.title,
|
||||||
summary: h.summary,
|
summary: h.summary,
|
||||||
focus_area: h.focus_area,
|
focus_area: h.focus_area,
|
||||||
|
suggested_variant_id: h.suggested_variant_id ?? null,
|
||||||
_planningScore: h.score,
|
_planningScore: h.score,
|
||||||
_planningReasons: Array.isArray(h.reasons) ? h.reasons : [],
|
_planningReasons: Array.isArray(h.reasons) ? h.reasons : [],
|
||||||
updated_at: new Date().toISOString(),
|
updated_at: new Date().toISOString(),
|
||||||
|
|
@ -740,6 +746,12 @@ export default function ExercisePickerModal({
|
||||||
Anker: {planningContextSummary.anchor_title}
|
Anker: {planningContextSummary.anchor_title}
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
|
{planningContextSummary.progression_graph_name ? (
|
||||||
|
<span className="exercise-tag">
|
||||||
|
Graph: {planningContextSummary.progression_graph_name}
|
||||||
|
{planningContextSummary.progression_graph_auto_resolved ? ' (auto)' : ''}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
{Array.isArray(planningTargetProfileSummary?.focus_areas) &&
|
{Array.isArray(planningTargetProfileSummary?.focus_areas) &&
|
||||||
planningTargetProfileSummary.focus_areas.length > 0
|
planningTargetProfileSummary.focus_areas.length > 0
|
||||||
? planningTargetProfileSummary.focus_areas.map((fa) => (
|
? planningTargetProfileSummary.focus_areas.map((fa) => (
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,90 @@
|
||||||
|
import React, { useEffect } from 'react'
|
||||||
|
import ExerciseAiQuickCreateOffer from '../ExerciseAiQuickCreateOffer'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* KI-gestützte Neuanlage in einem Dialog — statt normalem „+ Neu“-Formular.
|
||||||
|
*/
|
||||||
|
export default function ExerciseAiQuickCreateModal({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
searchLabel,
|
||||||
|
title,
|
||||||
|
onTitleChange,
|
||||||
|
sketch,
|
||||||
|
onSketchChange,
|
||||||
|
focusAreaId,
|
||||||
|
onFocusAreaChange,
|
||||||
|
focusAreas = [],
|
||||||
|
catalogsReady = true,
|
||||||
|
busy = false,
|
||||||
|
error = '',
|
||||||
|
onRunAi,
|
||||||
|
}) {
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return undefined
|
||||||
|
const onKey = (e) => {
|
||||||
|
if (e.key === 'Escape' && !busy) {
|
||||||
|
e.preventDefault()
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.addEventListener('keydown', onKey)
|
||||||
|
return () => window.removeEventListener('keydown', onKey)
|
||||||
|
}, [open, busy, onClose])
|
||||||
|
|
||||||
|
if (!open) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="admin-modal-backdrop"
|
||||||
|
role="presentation"
|
||||||
|
onClick={(e) => {
|
||||||
|
if (e.target === e.currentTarget && !busy) onClose()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="admin-modal-sheet exercise-ai-quick-create-modal"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="exercise-ai-quick-create-title"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
style={{ maxWidth: '640px' }}
|
||||||
|
>
|
||||||
|
<div className="admin-modal-sheet__header">
|
||||||
|
<h3 id="exercise-ai-quick-create-title" className="admin-modal-sheet__title">
|
||||||
|
Neue Übung mit KI
|
||||||
|
</h3>
|
||||||
|
<button type="button" className="btn btn-secondary admin-modal-sheet__close" disabled={busy} onClick={onClose}>
|
||||||
|
Schließen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="admin-modal-sheet__body">
|
||||||
|
<p className="muted" style={{ marginTop: 0, marginBottom: '12px', lineHeight: 1.45 }}>
|
||||||
|
Die KI schlägt Titel, Ziel, Anleitung und Fähigkeiten vor — danach bearbeiten und als Entwurf speichern.
|
||||||
|
Für die normale manuelle Anlage den KI-Assistenten ausschalten.
|
||||||
|
</p>
|
||||||
|
<ExerciseAiQuickCreateOffer
|
||||||
|
searchLabel={searchLabel}
|
||||||
|
title={title}
|
||||||
|
onTitleChange={onTitleChange}
|
||||||
|
sketch={sketch}
|
||||||
|
onSketchChange={onSketchChange}
|
||||||
|
focusAreaId={focusAreaId}
|
||||||
|
onFocusAreaChange={onFocusAreaChange}
|
||||||
|
focusAreas={focusAreas}
|
||||||
|
catalogsReady={catalogsReady}
|
||||||
|
busy={busy}
|
||||||
|
error={error}
|
||||||
|
onRunAi={onRunAi}
|
||||||
|
headline="Übung vorschlagen lassen"
|
||||||
|
hint={
|
||||||
|
searchLabel
|
||||||
|
? `Optional aus Suchanfrage „${searchLabel}“ — Titel mindestens 3 Zeichen, Fokusbereich Pflicht.`
|
||||||
|
: 'Titel mindestens 3 Zeichen, Fokusbereich Pflicht; Kurzbeschreibung optional.'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -241,6 +241,22 @@ export default function ExerciseListCard({
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
{Array.isArray(exercise._planningReasons) && exercise._planningReasons.length > 0 ? (
|
||||||
|
<ul
|
||||||
|
className="exercise-card__planning-reasons"
|
||||||
|
style={{
|
||||||
|
margin: '8px 0 0',
|
||||||
|
paddingLeft: '18px',
|
||||||
|
fontSize: '12px',
|
||||||
|
color: 'var(--accent-dark)',
|
||||||
|
lineHeight: 1.4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{exercise._planningReasons.slice(0, 3).map((r) => (
|
||||||
|
<li key={r}>{r}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="exercise-card__footer">
|
<div className="exercise-card__footer">
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,110 @@ export default function ExerciseListSearchBar({
|
||||||
exerciseCount,
|
exerciseCount,
|
||||||
allOnPageSelected,
|
allOnPageSelected,
|
||||||
onToggleSelectAllPage,
|
onToggleSelectAllPage,
|
||||||
|
kiSearchMode = false,
|
||||||
|
planningSearchInput = '',
|
||||||
|
onPlanningSearchInputChange,
|
||||||
|
onSubmitPlanningSearch,
|
||||||
|
planningSearchLoading = false,
|
||||||
|
planningHasSearched = false,
|
||||||
|
planningRetrievalPhase = '',
|
||||||
|
planningContextSummary = null,
|
||||||
|
planningTargetProfileSummary = null,
|
||||||
|
planningSearchError = '',
|
||||||
}) {
|
}) {
|
||||||
|
if (kiSearchMode) {
|
||||||
|
return (
|
||||||
|
<div className="card exercise-search-bar exercise-search-bar--ki">
|
||||||
|
<label className="form-label">Planungs-Anfrage (KI)</label>
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem', alignItems: 'stretch' }}>
|
||||||
|
<input
|
||||||
|
type="search"
|
||||||
|
className="form-input exercise-search-bar__primary"
|
||||||
|
placeholder="z. B. Partnerübung Reaktion, Vertiefung Schnellkraft …"
|
||||||
|
value={planningSearchInput}
|
||||||
|
onChange={(e) => onPlanningSearchInputChange?.(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault()
|
||||||
|
onSubmitPlanningSearch?.()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
autoComplete="off"
|
||||||
|
enterKeyHint="search"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary"
|
||||||
|
disabled={planningSearchLoading}
|
||||||
|
onClick={() => onSubmitPlanningSearch?.()}
|
||||||
|
>
|
||||||
|
{planningSearchLoading ? 'Suche …' : 'Vorschläge laden'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="exercise-search-hint" style={{ marginTop: '10px' }}>
|
||||||
|
<strong>Planungs-KI</strong> durchsucht die sichtbare Bibliothek mit Profil-Score und optional LLM — nicht
|
||||||
|
die einfache Volltextsuche. Filter und „Meine Übungen“ gelten hier nicht; KI-Assistent ausschalten für
|
||||||
|
klassische Liste.
|
||||||
|
{planningRetrievalPhase ? (
|
||||||
|
<>
|
||||||
|
{' '}
|
||||||
|
· Phase: <code>{planningRetrievalPhase}</code>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</p>
|
||||||
|
{planningSearchError ? (
|
||||||
|
<p className="form-error" style={{ marginTop: '8px' }}>
|
||||||
|
{planningSearchError}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
{planningHasSearched && planningContextSummary ? (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginTop: '10px',
|
||||||
|
padding: '8px 10px',
|
||||||
|
borderRadius: '8px',
|
||||||
|
background: 'var(--surface2)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
fontSize: '12px',
|
||||||
|
color: 'var(--text2)',
|
||||||
|
lineHeight: 1.45,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<strong style={{ color: 'var(--text1)', fontSize: '13px' }}>KI-Kontext</strong>
|
||||||
|
<div style={{ marginTop: '4px', display: 'flex', flexWrap: 'wrap', gap: '6px' }}>
|
||||||
|
{planningContextSummary.expectation_mode ? (
|
||||||
|
<span className="exercise-tag">
|
||||||
|
{planningContextSummary.expectation_mode === 'query_only' ? 'Freitext-Profil' : 'Hybrid-Profil'}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
{Array.isArray(planningTargetProfileSummary?.focus_areas) &&
|
||||||
|
planningTargetProfileSummary.focus_areas.length > 0
|
||||||
|
? planningTargetProfileSummary.focus_areas.slice(0, 2).map((fa) => (
|
||||||
|
<span key={fa} className="exercise-tag">
|
||||||
|
Fokus: {fa}
|
||||||
|
</span>
|
||||||
|
))
|
||||||
|
: null}
|
||||||
|
{Array.isArray(planningTargetProfileSummary?.top_skills) &&
|
||||||
|
planningTargetProfileSummary.top_skills.length > 0
|
||||||
|
? planningTargetProfileSummary.top_skills.slice(0, 2).map((sk) => (
|
||||||
|
<span key={sk.skill_id} className="exercise-tag">
|
||||||
|
{sk.name}
|
||||||
|
</span>
|
||||||
|
))
|
||||||
|
: null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{!planningHasSearched ? (
|
||||||
|
<p className="muted" style={{ marginTop: '10px', marginBottom: 0, fontSize: '13px' }}>
|
||||||
|
Anfrage formulieren und „Vorschläge laden“ klicken.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="card exercise-search-bar">
|
<div className="card exercise-search-bar">
|
||||||
<label className="form-label">Volltextsuche (Titel, Ziel, …)</label>
|
<label className="form-label">Volltextsuche (Titel, Ziel, …)</label>
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,8 @@ import ExerciseListBulkToolbar from './ExerciseListBulkToolbar'
|
||||||
import SaveSelectedExercisesAsModuleModal from './SaveSelectedExercisesAsModuleModal'
|
import SaveSelectedExercisesAsModuleModal from './SaveSelectedExercisesAsModuleModal'
|
||||||
import ExercisePeekModal from '../ExercisePeekModal'
|
import ExercisePeekModal from '../ExercisePeekModal'
|
||||||
import NavStateLink from '../NavStateLink'
|
import NavStateLink from '../NavStateLink'
|
||||||
import ExerciseAiQuickCreateOffer from '../ExerciseAiQuickCreateOffer'
|
import { ExerciseAiQuickCreateTeaser } from '../ExerciseAiQuickCreateOffer'
|
||||||
|
import ExerciseAiQuickCreateModal from './ExerciseAiQuickCreateModal'
|
||||||
import ExerciseAiSuggestPreviewModal from '../ExerciseAiSuggestPreviewModal'
|
import ExerciseAiSuggestPreviewModal from '../ExerciseAiSuggestPreviewModal'
|
||||||
import {
|
import {
|
||||||
buildQuickCreateAiPreview,
|
buildQuickCreateAiPreview,
|
||||||
|
|
@ -31,6 +32,7 @@ import {
|
||||||
snapshotExerciseForSelection,
|
snapshotExerciseForSelection,
|
||||||
} from '../../utils/exerciseListSelection'
|
} from '../../utils/exerciseListSelection'
|
||||||
import { useExerciseListCatalogsAndQuery } from '../../hooks/useExerciseListCatalogsAndQuery'
|
import { useExerciseListCatalogsAndQuery } from '../../hooks/useExerciseListCatalogsAndQuery'
|
||||||
|
import { usePlanningExerciseSuggestSearch } from '../../hooks/usePlanningExerciseSuggestSearch'
|
||||||
import {
|
import {
|
||||||
INITIAL_EXERCISE_LIST_FILTERS,
|
INITIAL_EXERCISE_LIST_FILTERS,
|
||||||
mergeExerciseListPrefsFromApi,
|
mergeExerciseListPrefsFromApi,
|
||||||
|
|
@ -89,10 +91,15 @@ function ExercisesListPageRoot() {
|
||||||
const [peekExercise, setPeekExercise] = useState(null)
|
const [peekExercise, setPeekExercise] = useState(null)
|
||||||
const [saveModuleModalOpen, setSaveModuleModalOpen] = useState(false)
|
const [saveModuleModalOpen, setSaveModuleModalOpen] = useState(false)
|
||||||
const [aiQuickCreateEnabled, setAiQuickCreateEnabled] = useState(false)
|
const [aiQuickCreateEnabled, setAiQuickCreateEnabled] = useState(false)
|
||||||
|
const [aiQuickCreateModalOpen, setAiQuickCreateModalOpen] = useState(false)
|
||||||
const [quickSaving, setQuickSaving] = useState(false)
|
const [quickSaving, setQuickSaving] = useState(false)
|
||||||
const [quickAiError, setQuickAiError] = useState('')
|
const [quickAiError, setQuickAiError] = useState('')
|
||||||
const [quickCreateDraft, setQuickCreateDraft] = useState(null)
|
const [quickCreateDraft, setQuickCreateDraft] = useState(null)
|
||||||
|
|
||||||
|
const planningKi = usePlanningExerciseSuggestSearch({
|
||||||
|
enabled: pageTab === 'list' && aiQuickCreateEnabled,
|
||||||
|
})
|
||||||
|
|
||||||
const {
|
const {
|
||||||
title: quickTitle,
|
title: quickTitle,
|
||||||
sketch: quickSketch,
|
sketch: quickSketch,
|
||||||
|
|
@ -100,9 +107,14 @@ function ExercisesListPageRoot() {
|
||||||
setTitle: setQuickTitle,
|
setTitle: setQuickTitle,
|
||||||
setSketch: setQuickSketch,
|
setSketch: setQuickSketch,
|
||||||
setFocusAreaId: setQuickFocusAreaId,
|
setFocusAreaId: setQuickFocusAreaId,
|
||||||
} = useExerciseAiQuickCreateFields(debouncedSearch, {
|
} = useExerciseAiQuickCreateFields(
|
||||||
enabled: pageTab === 'list' && (aiQuickCreateEnabled || debouncedSearch.length >= 3),
|
aiQuickCreateModalOpen
|
||||||
})
|
? planningKi.submittedQuery || planningKi.searchInput || debouncedSearch
|
||||||
|
: debouncedSearch,
|
||||||
|
{
|
||||||
|
enabled: pageTab === 'list' && aiQuickCreateModalOpen,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!user?.id) return
|
if (!user?.id) return
|
||||||
|
|
@ -174,14 +186,18 @@ function ExercisesListPageRoot() {
|
||||||
loadingMore,
|
loadingMore,
|
||||||
hasMore,
|
hasMore,
|
||||||
loadMore,
|
loadMore,
|
||||||
} = useExerciseListCatalogsAndQuery({ queryBase, pageTab, tenantClubDepKey })
|
} = useExerciseListCatalogsAndQuery({
|
||||||
|
queryBase,
|
||||||
|
pageTab,
|
||||||
|
tenantClubDepKey,
|
||||||
|
skipListFetch: aiQuickCreateEnabled,
|
||||||
|
})
|
||||||
|
|
||||||
const showQuickCreateOffer =
|
const listExercises = exercises
|
||||||
pageTab === 'list' &&
|
const listFetchingResolved = listFetching
|
||||||
catalogsReady &&
|
const exercisesForDisplay = aiQuickCreateEnabled ? planningKi.rows : listExercises
|
||||||
!listFetching &&
|
const listFetchingDisplay = aiQuickCreateEnabled ? planningKi.loading : listFetchingResolved
|
||||||
(aiQuickCreateEnabled ||
|
const hasMoreDisplay = aiQuickCreateEnabled ? false : hasMore
|
||||||
(exercises.length === 0 && selectedEntries.length === 0 && debouncedSearch.length >= 3))
|
|
||||||
|
|
||||||
const selectedIds = useMemo(
|
const selectedIds = useMemo(
|
||||||
() => new Set(selectedEntries.map((e) => Number(e.id)).filter((id) => Number.isFinite(id) && id > 0)),
|
() => new Set(selectedEntries.map((e) => Number(e.id)).filter((id) => Number.isFinite(id) && id > 0)),
|
||||||
|
|
@ -189,15 +205,22 @@ function ExercisesListPageRoot() {
|
||||||
)
|
)
|
||||||
|
|
||||||
const selectedExercisesDisplay = useMemo(
|
const selectedExercisesDisplay = useMemo(
|
||||||
() => mergeSelectedWithListEntries(selectedEntries, exercises),
|
() => mergeSelectedWithListEntries(selectedEntries, exercisesForDisplay),
|
||||||
[selectedEntries, exercises]
|
[selectedEntries, exercisesForDisplay],
|
||||||
)
|
)
|
||||||
|
|
||||||
const filterResultExercises = useMemo(
|
const filterResultExercises = useMemo(
|
||||||
() => exercises.filter((e) => !selectedIds.has(Number(e.id))),
|
() => exercisesForDisplay.filter((e) => !selectedIds.has(Number(e.id))),
|
||||||
[exercises, selectedIds]
|
[exercisesForDisplay, selectedIds],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const showKiCreateTeaser =
|
||||||
|
aiQuickCreateEnabled &&
|
||||||
|
planningKi.hasSearched &&
|
||||||
|
!planningKi.loading &&
|
||||||
|
catalogsReady &&
|
||||||
|
filterResultExercises.length > 0
|
||||||
|
|
||||||
const focusOptions = useMemo(
|
const focusOptions = useMemo(
|
||||||
() =>
|
() =>
|
||||||
catalogs.focusAreas.map((fa) => ({
|
catalogs.focusAreas.map((fa) => ({
|
||||||
|
|
@ -274,9 +297,9 @@ function ExercisesListPageRoot() {
|
||||||
|
|
||||||
/** Für Browser-/datalist-Vorschläge (aktuelle Treffer-Titel, begrenzt) */
|
/** Für Browser-/datalist-Vorschläge (aktuelle Treffer-Titel, begrenzt) */
|
||||||
const searchTitleSuggestions = useMemo(() => {
|
const searchTitleSuggestions = useMemo(() => {
|
||||||
const titles = exercises.map((e) => (e.title || '').trim()).filter(Boolean)
|
const titles = listExercises.map((e) => (e.title || '').trim()).filter(Boolean)
|
||||||
return [...new Set(titles)].slice(0, 80)
|
return [...new Set(titles)].slice(0, 80)
|
||||||
}, [exercises])
|
}, [listExercises])
|
||||||
|
|
||||||
const clubNameById = useMemo(() => {
|
const clubNameById = useMemo(() => {
|
||||||
const m = {}
|
const m = {}
|
||||||
|
|
@ -377,6 +400,7 @@ function ExercisesListPageRoot() {
|
||||||
setQuickCreateDraft(
|
setQuickCreateDraft(
|
||||||
aiPreviewToQuickCreateDraft(preview, { title, focusAreaId: focusId, sketchPlain: sketch }),
|
aiPreviewToQuickCreateDraft(preview, { title, focusAreaId: focusId, sketchPlain: sketch }),
|
||||||
)
|
)
|
||||||
|
setAiQuickCreateModalOpen(false)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e)
|
console.error(e)
|
||||||
const msg = e?.message || String(e)
|
const msg = e?.message || String(e)
|
||||||
|
|
@ -597,23 +621,36 @@ function ExercisesListPageRoot() {
|
||||||
<div className="exercises-page__header-actions">
|
<div className="exercises-page__header-actions">
|
||||||
<label
|
<label
|
||||||
className="exercises-ai-assistant-toggle"
|
className="exercises-ai-assistant-toggle"
|
||||||
title="Neue Übung per KI vorschlagen — Titel, optional Kurzbeschreibung, Fokusbereich"
|
title="Planungs-KI-Suche in der Bibliothek und Neuanlage per KI-Dialog (ohne normales Formular „+ Neu“)"
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={aiQuickCreateEnabled}
|
checked={aiQuickCreateEnabled}
|
||||||
onChange={(e) => setAiQuickCreateEnabled(e.target.checked)}
|
onChange={(e) => {
|
||||||
|
setAiQuickCreateEnabled(e.target.checked)
|
||||||
|
if (!e.target.checked) setAiQuickCreateModalOpen(false)
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<span className="exercises-ai-assistant-toggle__track" aria-hidden="true" />
|
<span className="exercises-ai-assistant-toggle__track" aria-hidden="true" />
|
||||||
<span>Neu mit KI-Assistent</span>
|
<span>Neu mit KI-Assistent</span>
|
||||||
</label>
|
</label>
|
||||||
<NavStateLink
|
{aiQuickCreateEnabled ? (
|
||||||
to="/exercises/new"
|
<button
|
||||||
returnContext={exercisesModuleReturnContext}
|
type="button"
|
||||||
className="btn btn-primary"
|
className="btn btn-primary"
|
||||||
>
|
onClick={() => setAiQuickCreateModalOpen(true)}
|
||||||
+ Neu
|
>
|
||||||
</NavStateLink>
|
+ Neue Übung mit KI
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<NavStateLink
|
||||||
|
to="/exercises/new"
|
||||||
|
returnContext={exercisesModuleReturnContext}
|
||||||
|
className="btn btn-primary"
|
||||||
|
>
|
||||||
|
+ Neu
|
||||||
|
</NavStateLink>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<span aria-hidden="true" />
|
<span aria-hidden="true" />
|
||||||
|
|
@ -644,6 +681,16 @@ function ExercisesListPageRoot() {
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<ExerciseListSearchBar
|
<ExerciseListSearchBar
|
||||||
|
kiSearchMode={aiQuickCreateEnabled}
|
||||||
|
planningSearchInput={planningKi.searchInput}
|
||||||
|
onPlanningSearchInputChange={planningKi.setSearchInput}
|
||||||
|
onSubmitPlanningSearch={() => planningKi.submitSearch()}
|
||||||
|
planningSearchLoading={planningKi.loading}
|
||||||
|
planningHasSearched={planningKi.hasSearched}
|
||||||
|
planningRetrievalPhase={planningKi.retrievalPhase}
|
||||||
|
planningContextSummary={planningKi.contextSummary}
|
||||||
|
planningTargetProfileSummary={planningKi.targetProfileSummary}
|
||||||
|
planningSearchError={planningKi.error}
|
||||||
searchTitleSuggestions={searchTitleSuggestions}
|
searchTitleSuggestions={searchTitleSuggestions}
|
||||||
searchInput={searchInput}
|
searchInput={searchInput}
|
||||||
onSearchInputChange={setSearchInput}
|
onSearchInputChange={setSearchInput}
|
||||||
|
|
@ -654,30 +701,15 @@ function ExercisesListPageRoot() {
|
||||||
onOpenFilter={() => setFilterModalOpen(true)}
|
onOpenFilter={() => setFilterModalOpen(true)}
|
||||||
filterChips={filterChips}
|
filterChips={filterChips}
|
||||||
onResetAllFilters={resetAllFilters}
|
onResetAllFilters={resetAllFilters}
|
||||||
exerciseCount={exercises.length}
|
exerciseCount={exercisesForDisplay.length}
|
||||||
allOnPageSelected={allOnPageSelected}
|
allOnPageSelected={allOnPageSelected}
|
||||||
onToggleSelectAllPage={toggleSelectAllPage}
|
onToggleSelectAllPage={toggleSelectAllPage}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{showQuickCreateOffer ? (
|
{showKiCreateTeaser ? (
|
||||||
<ExerciseAiQuickCreateOffer
|
<ExerciseAiQuickCreateTeaser
|
||||||
searchLabel={debouncedSearch || undefined}
|
onExpand={() => setAiQuickCreateModalOpen(true)}
|
||||||
title={quickTitle}
|
disabled={quickSaving}
|
||||||
onTitleChange={setQuickTitle}
|
|
||||||
sketch={quickSketch}
|
|
||||||
onSketchChange={setQuickSketch}
|
|
||||||
focusAreaId={quickFocusAreaId}
|
|
||||||
onFocusAreaChange={setQuickFocusAreaId}
|
|
||||||
focusAreas={catalogs.focusAreas}
|
|
||||||
catalogsReady={catalogsReady}
|
|
||||||
busy={quickSaving}
|
|
||||||
error={quickAiError}
|
|
||||||
onRunAi={runQuickCreateAiSuggest}
|
|
||||||
hint={
|
|
||||||
aiQuickCreateEnabled
|
|
||||||
? 'Titel aus Suche oder manuell; Kurzbeschreibung optional — leer für freien KI-Vorschlag, ausgefüllt als deine Ausgangsidee.'
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
|
@ -762,22 +794,36 @@ function ExercisesListPageRoot() {
|
||||||
onClose={() => setPeekExercise(null)}
|
onClose={() => setPeekExercise(null)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{listFetching && exercises.length === 0 && selectedEntries.length === 0 ? (
|
{listFetchingDisplay && exercisesForDisplay.length === 0 && selectedEntries.length === 0 ? (
|
||||||
<div className="card empty-state" style={{ padding: '2rem 1rem' }}>
|
<div className="card empty-state" style={{ padding: '2rem 1rem' }}>
|
||||||
<div className="spinner" />
|
<div className="spinner" />
|
||||||
<p className="muted" style={{ marginTop: '12px' }}>
|
<p className="muted" style={{ marginTop: '12px' }}>
|
||||||
Lade Übungen…
|
{aiQuickCreateEnabled ? 'Planungs-KI lädt Vorschläge…' : 'Lade Übungen…'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : exercises.length === 0 && selectedEntries.length === 0 && !showQuickCreateOffer ? (
|
) : exercisesForDisplay.length === 0 && selectedEntries.length === 0 ? (
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<p className="exercises-empty-text">
|
<p className="exercises-empty-text">
|
||||||
{debouncedSearch.length >= 3
|
{aiQuickCreateEnabled
|
||||||
? 'Keine Übungen gefunden.'
|
? planningKi.hasSearched
|
||||||
: 'Keine Übungen gefunden — Suchbegriff eingeben oder Filter anpassen.'}
|
? 'Keine passenden Übungen — neue Übung mit KI anlegen?'
|
||||||
|
: 'Planungs-Anfrage formulieren und „Vorschläge laden“ klicken — oder „Neue Übung mit KI“ oben.'
|
||||||
|
: debouncedSearch.length >= 3
|
||||||
|
? 'Keine Übungen gefunden.'
|
||||||
|
: 'Keine Übungen gefunden — Suchbegriff eingeben oder Filter anpassen.'}
|
||||||
</p>
|
</p>
|
||||||
|
{aiQuickCreateEnabled && planningKi.hasSearched ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary"
|
||||||
|
style={{ marginTop: '12px' }}
|
||||||
|
onClick={() => setAiQuickCreateModalOpen(true)}
|
||||||
|
>
|
||||||
|
Neue Übung mit KI …
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
) : exercises.length === 0 && selectedEntries.length === 0 && showQuickCreateOffer ? null : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{selectedEntries.length > 0 ? (
|
{selectedEntries.length > 0 ? (
|
||||||
<section className="exercises-selection-section" data-testid="exercises-selection-section">
|
<section className="exercises-selection-section" data-testid="exercises-selection-section">
|
||||||
|
|
@ -812,13 +858,14 @@ function ExercisesListPageRoot() {
|
||||||
) : null
|
) : null
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{listFetching ? (
|
{listFetchingDisplay ? (
|
||||||
<p className="exercises-meta-line exercises-meta-line--muted">Aktualisiere Treffer…</p>
|
<p className="exercises-meta-line exercises-meta-line--muted">Aktualisiere Treffer…</p>
|
||||||
) : null}
|
) : null}
|
||||||
<p className="exercises-meta-line">
|
<p className="exercises-meta-line">
|
||||||
{filterResultExercises.length} Treffer
|
{filterResultExercises.length} Treffer
|
||||||
|
{aiQuickCreateEnabled ? ' (Planungs-KI)' : ''}
|
||||||
{selectedEntries.length > 0 ? ' (ohne bereits Ausgewählte)' : ''}
|
{selectedEntries.length > 0 ? ' (ohne bereits Ausgewählte)' : ''}
|
||||||
{hasMore ? ' · es gibt weitere Einträge' : ''}
|
{hasMoreDisplay ? ' · es gibt weitere Einträge' : ''}
|
||||||
</p>
|
</p>
|
||||||
<div className="exercises-list-grid" data-testid="exercises-list-grid">
|
<div className="exercises-list-grid" data-testid="exercises-list-grid">
|
||||||
{filterResultExercises.map((exercise) => (
|
{filterResultExercises.map((exercise) => (
|
||||||
|
|
@ -833,7 +880,7 @@ function ExercisesListPageRoot() {
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
{hasMore && (
|
{hasMoreDisplay && (
|
||||||
<div className="exercises-load-more">
|
<div className="exercises-load-more">
|
||||||
<button type="button" className="btn btn-secondary" disabled={loadingMore} onClick={loadMore}>
|
<button type="button" className="btn btn-secondary" disabled={loadingMore} onClick={loadMore}>
|
||||||
{loadingMore ? 'Laden…' : 'Mehr laden'}
|
{loadingMore ? 'Laden…' : 'Mehr laden'}
|
||||||
|
|
@ -845,6 +892,27 @@ function ExercisesListPageRoot() {
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<ExerciseAiQuickCreateModal
|
||||||
|
open={aiQuickCreateModalOpen}
|
||||||
|
onClose={() => {
|
||||||
|
if (!quickSaving) setAiQuickCreateModalOpen(false)
|
||||||
|
}}
|
||||||
|
searchLabel={
|
||||||
|
planningKi.submittedQuery || planningKi.searchInput || debouncedSearch || undefined
|
||||||
|
}
|
||||||
|
title={quickTitle}
|
||||||
|
onTitleChange={setQuickTitle}
|
||||||
|
sketch={quickSketch}
|
||||||
|
onSketchChange={setQuickSketch}
|
||||||
|
focusAreaId={quickFocusAreaId}
|
||||||
|
onFocusAreaChange={setQuickFocusAreaId}
|
||||||
|
focusAreas={catalogs.focusAreas}
|
||||||
|
catalogsReady={catalogsReady}
|
||||||
|
busy={quickSaving}
|
||||||
|
error={quickAiError}
|
||||||
|
onRunAi={runQuickCreateAiSuggest}
|
||||||
|
/>
|
||||||
|
|
||||||
<ExerciseAiSuggestPreviewModal
|
<ExerciseAiSuggestPreviewModal
|
||||||
draft={quickCreateDraft}
|
draft={quickCreateDraft}
|
||||||
onDraftChange={setQuickCreateDraft}
|
onDraftChange={setQuickCreateDraft}
|
||||||
|
|
|
||||||
5
frontend/src/constants/planningExerciseSuggest.js
Normal file
5
frontend/src/constants/planningExerciseSuggest.js
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
/** Backend POST /api/planning/exercise-suggest erlaubt max. 50 */
|
||||||
|
export const PLANNING_SUGGEST_LIMIT = 50
|
||||||
|
|
||||||
|
/** Client-Hinweis — Backend entscheidet final über LLM-Gates. */
|
||||||
|
export const PLANNING_LLM_INTENT_MIN_CHARS = 10
|
||||||
|
|
@ -6,7 +6,7 @@ export const EXERCISE_LIST_PAGE_SIZE = 100
|
||||||
/**
|
/**
|
||||||
* Lädt Kataloge für Filter/Bulk einmalig und hält die Übungsliste (Offset + Keyset „Mehr laden“) synchron zu queryBase.
|
* Lädt Kataloge für Filter/Bulk einmalig und hält die Übungsliste (Offset + Keyset „Mehr laden“) synchron zu queryBase.
|
||||||
*/
|
*/
|
||||||
export function useExerciseListCatalogsAndQuery({ queryBase, pageTab, tenantClubDepKey }) {
|
export function useExerciseListCatalogsAndQuery({ queryBase, pageTab, tenantClubDepKey, skipListFetch = false }) {
|
||||||
const [catalogs, setCatalogs] = useState({
|
const [catalogs, setCatalogs] = useState({
|
||||||
focusAreas: [],
|
focusAreas: [],
|
||||||
styleDirections: [],
|
styleDirections: [],
|
||||||
|
|
@ -55,7 +55,14 @@ export function useExerciseListCatalogsAndQuery({ queryBase, pageTab, tenantClub
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!catalogsReady || pageTab !== 'list') return
|
if (!catalogsReady || pageTab !== 'list' || skipListFetch) {
|
||||||
|
if (skipListFetch) {
|
||||||
|
setExercises([])
|
||||||
|
setHasMore(false)
|
||||||
|
setListFetching(false)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
const run = async () => {
|
const run = async () => {
|
||||||
setListFetching(true)
|
setListFetching(true)
|
||||||
|
|
@ -81,7 +88,7 @@ export function useExerciseListCatalogsAndQuery({ queryBase, pageTab, tenantClub
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true
|
cancelled = true
|
||||||
}
|
}
|
||||||
}, [queryBase, catalogsReady, pageTab, tenantClubDepKey])
|
}, [queryBase, catalogsReady, pageTab, tenantClubDepKey, skipListFetch])
|
||||||
|
|
||||||
const loadMore = useCallback(async () => {
|
const loadMore = useCallback(async () => {
|
||||||
if (loadingMore || !hasMore) return
|
if (loadingMore || !hasMore) return
|
||||||
|
|
|
||||||
120
frontend/src/hooks/usePlanningExerciseSuggestSearch.js
Normal file
120
frontend/src/hooks/usePlanningExerciseSuggestSearch.js
Normal file
|
|
@ -0,0 +1,120 @@
|
||||||
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
|
import api from '../utils/api'
|
||||||
|
import { PLANNING_LLM_INTENT_MIN_CHARS, PLANNING_SUGGEST_LIMIT } from '../constants/planningExerciseSuggest'
|
||||||
|
|
||||||
|
export function mapPlanningHitsToListRows(hits) {
|
||||||
|
return (Array.isArray(hits) ? hits : []).map((h) => ({
|
||||||
|
id: h.id,
|
||||||
|
title: h.title,
|
||||||
|
summary: h.summary,
|
||||||
|
focus_area: h.focus_area,
|
||||||
|
focus_area_names: h.focus_area ? [h.focus_area] : [],
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
_planningScore: h.score,
|
||||||
|
_planningReasons: Array.isArray(h.reasons) ? h.reasons : [],
|
||||||
|
suggested_variant_id: h.suggested_variant_id ?? null,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Freie Planungs-KI-Suche (ohne unit_id) — z. B. Übungsliste im KI-Assistent-Modus.
|
||||||
|
*/
|
||||||
|
export function usePlanningExerciseSuggestSearch({ enabled, groupId = null } = {}) {
|
||||||
|
const [searchInput, setSearchInput] = useState('')
|
||||||
|
const [submittedQuery, setSubmittedQuery] = useState('')
|
||||||
|
const [searchTick, setSearchTick] = useState(0)
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [hasSearched, setHasSearched] = useState(false)
|
||||||
|
const [rows, setRows] = useState([])
|
||||||
|
const [contextSummary, setContextSummary] = useState(null)
|
||||||
|
const [targetProfileSummary, setTargetProfileSummary] = useState(null)
|
||||||
|
const [retrievalPhase, setRetrievalPhase] = useState('')
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
const reset = useCallback(() => {
|
||||||
|
setSearchInput('')
|
||||||
|
setSubmittedQuery('')
|
||||||
|
setSearchTick(0)
|
||||||
|
setHasSearched(false)
|
||||||
|
setRows([])
|
||||||
|
setContextSummary(null)
|
||||||
|
setTargetProfileSummary(null)
|
||||||
|
setRetrievalPhase('')
|
||||||
|
setError('')
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const submitSearch = useCallback(
|
||||||
|
(queryOverride) => {
|
||||||
|
const q =
|
||||||
|
queryOverride !== undefined && queryOverride !== null
|
||||||
|
? String(queryOverride).trim()
|
||||||
|
: searchInput.trim()
|
||||||
|
setSubmittedQuery(q)
|
||||||
|
setHasSearched(true)
|
||||||
|
setSearchTick((t) => t + 1)
|
||||||
|
},
|
||||||
|
[searchInput],
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!enabled) {
|
||||||
|
reset()
|
||||||
|
}
|
||||||
|
}, [enabled, reset])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!enabled || searchTick === 0) return undefined
|
||||||
|
let cancelled = false
|
||||||
|
;(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
const query = submittedQuery
|
||||||
|
const body = {
|
||||||
|
query,
|
||||||
|
include_llm_intent: query.length >= PLANNING_LLM_INTENT_MIN_CHARS || !query,
|
||||||
|
include_llm_rank: true,
|
||||||
|
intent_hint: query ? 'free_search' : 'suggest_next',
|
||||||
|
limit: PLANNING_SUGGEST_LIMIT,
|
||||||
|
}
|
||||||
|
const gid = Number(groupId)
|
||||||
|
if (Number.isFinite(gid) && gid > 0) body.group_id = gid
|
||||||
|
const res = await api.suggestPlanningExercises(body)
|
||||||
|
if (cancelled) return
|
||||||
|
setContextSummary(res?.context_summary || null)
|
||||||
|
setTargetProfileSummary(res?.target_profile_summary || null)
|
||||||
|
setRetrievalPhase(res?.retrieval_phase || '')
|
||||||
|
setRows(mapPlanningHitsToListRows(res?.hits))
|
||||||
|
} catch (e) {
|
||||||
|
if (!cancelled) {
|
||||||
|
console.error(e)
|
||||||
|
setError(e.message || 'Planungs-KI-Suche fehlgeschlagen')
|
||||||
|
setRows([])
|
||||||
|
setContextSummary(null)
|
||||||
|
setTargetProfileSummary(null)
|
||||||
|
setRetrievalPhase('')
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) setLoading(false)
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [enabled, searchTick, submittedQuery, groupId])
|
||||||
|
|
||||||
|
return {
|
||||||
|
searchInput,
|
||||||
|
setSearchInput,
|
||||||
|
submittedQuery,
|
||||||
|
submitSearch,
|
||||||
|
loading,
|
||||||
|
hasSearched,
|
||||||
|
rows,
|
||||||
|
contextSummary,
|
||||||
|
targetProfileSummary,
|
||||||
|
retrievalPhase,
|
||||||
|
error,
|
||||||
|
reset,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -132,15 +132,24 @@ export default function TrainingUnitEditPage() {
|
||||||
const sIdx = target?.sIdx ?? 0
|
const sIdx = target?.sIdx ?? 0
|
||||||
const sec = secs[sIdx]
|
const sec = secs[sIdx]
|
||||||
let anchorExerciseId = null
|
let anchorExerciseId = null
|
||||||
|
let anchorExerciseVariantId = null
|
||||||
|
const pickAnchorFromItem = (item) => {
|
||||||
|
if (!item?.exercise_id) return
|
||||||
|
anchorExerciseId = Number(item.exercise_id)
|
||||||
|
const rawVar = item.exercise_variant_id
|
||||||
|
const vid = Number(rawVar)
|
||||||
|
anchorExerciseVariantId =
|
||||||
|
rawVar != null && Number.isFinite(vid) && vid > 0 ? vid : null
|
||||||
|
}
|
||||||
if (sec?.items?.length) {
|
if (sec?.items?.length) {
|
||||||
if (typeof target?.iIdx === 'number') {
|
if (typeof target?.iIdx === 'number') {
|
||||||
const item = sec.items[target.iIdx]
|
const item = sec.items[target.iIdx]
|
||||||
if (item?.exercise_id) {
|
if (item?.exercise_id) {
|
||||||
anchorExerciseId = Number(item.exercise_id)
|
pickAnchorFromItem(item)
|
||||||
} else {
|
} else {
|
||||||
for (let i = target.iIdx - 1; i >= 0; i -= 1) {
|
for (let i = target.iIdx - 1; i >= 0; i -= 1) {
|
||||||
if (sec.items[i]?.exercise_id) {
|
if (sec.items[i]?.exercise_id) {
|
||||||
anchorExerciseId = Number(sec.items[i].exercise_id)
|
pickAnchorFromItem(sec.items[i])
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -149,14 +158,14 @@ export default function TrainingUnitEditPage() {
|
||||||
const beforeIx = target.insertBeforeIndex
|
const beforeIx = target.insertBeforeIndex
|
||||||
for (let i = beforeIx - 1; i >= 0; i -= 1) {
|
for (let i = beforeIx - 1; i >= 0; i -= 1) {
|
||||||
if (sec.items[i]?.exercise_id) {
|
if (sec.items[i]?.exercise_id) {
|
||||||
anchorExerciseId = Number(sec.items[i].exercise_id)
|
pickAnchorFromItem(sec.items[i])
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
for (let i = sec.items.length - 1; i >= 0; i -= 1) {
|
for (let i = sec.items.length - 1; i >= 0; i -= 1) {
|
||||||
if (sec.items[i]?.exercise_id) {
|
if (sec.items[i]?.exercise_id) {
|
||||||
anchorExerciseId = Number(sec.items[i].exercise_id)
|
pickAnchorFromItem(sec.items[i])
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -204,6 +213,7 @@ export default function TrainingUnitEditPage() {
|
||||||
sectionExerciseCount: sectionPlannedExerciseIds.length,
|
sectionExerciseCount: sectionPlannedExerciseIds.length,
|
||||||
lastExerciseTitle,
|
lastExerciseTitle,
|
||||||
anchorExerciseId: Number.isFinite(anchorExerciseId) && anchorExerciseId > 0 ? anchorExerciseId : null,
|
anchorExerciseId: Number.isFinite(anchorExerciseId) && anchorExerciseId > 0 ? anchorExerciseId : null,
|
||||||
|
anchorExerciseVariantId,
|
||||||
progressionGraphId: null,
|
progressionGraphId: null,
|
||||||
plannedExerciseIds,
|
plannedExerciseIds,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user