Verbesserung Suche und Neuanlage von Übungen #50
|
|
@ -1,8 +1,8 @@
|
|||
# Planungs-KI: Übungssuche & Kontext für Neu-Anlage
|
||||
|
||||
**Version:** 0.1
|
||||
**Datum:** 2026-05-22
|
||||
**Status:** P1 — Szenario-Pipeline + LLM Query-Intent-Overlay; P2 LLM-Rerank optional
|
||||
**Version:** 0.2
|
||||
**Datum:** 2026-05-23
|
||||
**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)
|
||||
|
||||
---
|
||||
|
|
@ -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“ |
|
||||
| `anchor_exercise_id`, Titel | Request oder letzte Übung vor Einfügepunkt | Anker |
|
||||
| `anchor_skill_ids[]` | `exercise_skills` | (intern) |
|
||||
| `progression_graph_id` | Request (optional) | Graph |
|
||||
| `progression_successor_ids[]` | `exercise_progression_edges` ab Anker | (intern) |
|
||||
| `progression_graph_id` | Request oder **Auto-Match** vom Anker (sichtbarer Graph mit passenden Ausgangskanten) | Graph |
|
||||
| `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 |
|
||||
| `framework_slot_notes` | Rahmen-Slot falls `framework_slot_id` | (später) |
|
||||
|
||||
|
|
@ -106,6 +109,7 @@ score = w_ft * fulltext_rank
|
|||
"phase_order_index": null,
|
||||
"parallel_stream_order_index": null,
|
||||
"anchor_exercise_id": 456,
|
||||
"anchor_exercise_variant_id": 12,
|
||||
"progression_graph_id": 7,
|
||||
"query": "Schlage mir die nächste Übung vor",
|
||||
"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 |
|
||||
| `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 |
|
||||
| Ü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).
|
||||
|
||||
|
|
@ -174,18 +179,26 @@ Wenn `hits` leer oder Trainer wählt „Mit KI anlegen“:
|
|||
|
||||
## 9. Phasen-Roadmap
|
||||
|
||||
| Phase | Inhalt |
|
||||
|-------|--------|
|
||||
| **P0** ✅ | Context-Pack, Hybrid-Score, API, Picker in Planung |
|
||||
| **P0.1** ✅ | `ExerciseMatchProfile` / `PlanningTargetProfile`, `profile_v1`, `target_profile_summary` |
|
||||
| **P2** ✅ (optional) | LLM-Rerank `planning_exercise_search_rank`, `include_llm_rank`, `llm_rank_applied` |
|
||||
| **P1** ✅ | Szenario-Pipeline + LLM Query-Intent → Erwartungsprofil-Overlay |
|
||||
| **P3** | Skill-Discovery / Framework-Ziele im Pack |
|
||||
| Phase | Inhalt | Status |
|
||||
|-------|--------|--------|
|
||||
| **P0** | Context-Pack, Hybrid-Score, API, Picker in Planung | ✅ |
|
||||
| **P0.1** | `ExerciseMatchProfile` / `PlanningTargetProfile`, `profile_v1` | ✅ |
|
||||
| **P1** | Szenario-Pipeline + LLM Query-Intent → Erwartungsprofil | ✅ |
|
||||
| **P2 / B2** | LLM-Rerank bei engem Top-Feld (max. 2 Calls) | ✅ |
|
||||
| **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
|
||||
|
||||
- **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:** 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`.
|
||||
|
|
@ -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).
|
||||
- **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).
|
||||
- **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? |
|
||||
|-----------------|------------------|-------------|
|
||||
| `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) |
|
||||
| `deepen` | Vertiefung Anker | 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 |
|
||||
|------|-----|---------|-----------|
|
||||
| `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:**
|
||||
|
||||
|
|
@ -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 |
|
||||
| `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.
|
||||
|
|
|
|||
|
|
@ -8,6 +8,10 @@ from __future__ import annotations
|
|||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, List, Optional, Sequence, Set, Tuple
|
||||
|
||||
from planning_exercise_text_signals import (
|
||||
load_framework_planning_text_parts,
|
||||
resolve_planning_text_to_catalog_weights,
|
||||
)
|
||||
from skill_scoring import (
|
||||
ExerciseOccurrence,
|
||||
collect_unit_exercise_occurrences,
|
||||
|
|
@ -339,6 +343,8 @@ def build_planning_target_profile(
|
|||
section_planned_exercise_ids: Optional[Sequence[int]] = None,
|
||||
anchor_exercise_id: Optional[int],
|
||||
intent: str,
|
||||
section_guidance_notes: Optional[str] = None,
|
||||
section_title: Optional[str] = None,
|
||||
) -> PlanningTargetProfile:
|
||||
sources: List[str] = []
|
||||
focus: Dict[int, float] = {}
|
||||
|
|
@ -414,6 +420,30 @@ def build_planning_target_profile(
|
|||
tg = _merge_weight_maps(tg, ap.target_group_ids, scale=0.75)
|
||||
sources.append("anchor_exercise")
|
||||
|
||||
text_parts: List[str] = []
|
||||
if (section_title or "").strip():
|
||||
text_parts.append(str(section_title).strip())
|
||||
if (section_guidance_notes or "").strip():
|
||||
text_parts.append(str(section_guidance_notes).strip())
|
||||
if fw:
|
||||
text_parts.extend(
|
||||
load_framework_planning_text_parts(
|
||||
cur,
|
||||
int(fw["framework_program_id"]),
|
||||
slot_id=int(fw["slot_id"]) if fw.get("slot_id") else None,
|
||||
)
|
||||
)
|
||||
if text_parts:
|
||||
blob = "\n".join(text_parts)
|
||||
tf, ts, ttt, ttg, tsk = resolve_planning_text_to_catalog_weights(cur, blob)
|
||||
if tf or ts or ttt or ttg or tsk:
|
||||
focus = _merge_weight_maps(focus, tf, scale=0.88)
|
||||
style = _merge_weight_maps(style, ts, scale=0.8)
|
||||
tt = _merge_weight_maps(tt, ttt, scale=0.8)
|
||||
tg = _merge_weight_maps(tg, ttg, scale=0.8)
|
||||
skill_target = _merge_weight_maps(skill_target, tsk, scale=0.92)
|
||||
sources.append("planning_text_signals")
|
||||
|
||||
skill_target = _normalize_weight_map(skill_target)
|
||||
skill_plan_norm = _normalize_weight_map(skill_plan)
|
||||
skill_gap: Dict[int, float] = {}
|
||||
|
|
@ -470,6 +500,8 @@ def score_exercise_against_target(
|
|||
reasons.append("Deckt Skill-Lücke im bisherigen Plan")
|
||||
if "query_intent" in (target.sources or []):
|
||||
reasons.append("Passt zur KI-interpretierten Suchanfrage")
|
||||
if "planning_text_signals" in (target.sources or []):
|
||||
reasons.append("Passt zu Abschnitts- oder Rahmen-Zieltext")
|
||||
|
||||
# Intent-gewichtete Dimensionen (Summe = 1.0)
|
||||
if intent == INTENT_FREE_SEARCH:
|
||||
|
|
|
|||
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",
|
||||
]
|
||||
|
|
@ -17,6 +17,17 @@ from planning_exercise_profiles import (
|
|||
|
||||
_MAX_LIBRARY_ROWS = 8000
|
||||
_PROFILE_LOAD_BATCH = 400
|
||||
_PARTNER_TEXT_MARKERS = ("partner", "paar", "paarweise", "zu zweit")
|
||||
|
||||
|
||||
def _exercise_looks_partner_related(row: Mapping[str, Any]) -> bool:
|
||||
parts = [
|
||||
str(row.get("method_archetype") or ""),
|
||||
str(row.get("title") or ""),
|
||||
str(row.get("summary") or ""),
|
||||
]
|
||||
blob = " ".join(parts).lower()
|
||||
return any(m in blob for m in _PARTNER_TEXT_MARKERS)
|
||||
|
||||
|
||||
def _skill_jaccard(a: Set[int], b: Set[int]) -> float:
|
||||
|
|
@ -72,7 +83,7 @@ def fetch_all_visible_exercise_rows(
|
|||
params.extend(ek_filtered)
|
||||
|
||||
sql = f"""
|
||||
SELECT e.id, e.title, e.summary,
|
||||
SELECT e.id, e.title, e.summary, e.method_archetype,
|
||||
(
|
||||
SELECT fa.name FROM exercise_focus_areas efa
|
||||
JOIN focus_areas fa ON fa.id = efa.focus_area_id
|
||||
|
|
@ -139,6 +150,7 @@ def rank_visible_library_hits(
|
|||
anchor_skills = set(pack.get("anchor_skill_ids") or [])
|
||||
anchor_id = pack.get("anchor_exercise_id")
|
||||
progression_notes = pack.get("progression_edge_notes") or {}
|
||||
requires_partner = pack.get("requires_partner")
|
||||
|
||||
last_planned_skills: Set[int] = set()
|
||||
planned_ids = pack.get("planned_exercise_ids") or []
|
||||
|
|
@ -154,6 +166,10 @@ def rank_visible_library_hits(
|
|||
eid = int(row["id"])
|
||||
if anchor_id and eid == int(anchor_id):
|
||||
continue
|
||||
if requires_partner is True and not _exercise_looks_partner_related(row):
|
||||
continue
|
||||
if requires_partner is False and _exercise_looks_partner_related(row):
|
||||
continue
|
||||
cand_rows.append(row)
|
||||
|
||||
cand_ids = [int(r["id"]) for r in cand_rows]
|
||||
|
|
@ -241,6 +257,10 @@ def rank_visible_library_hits(
|
|||
"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 ""))
|
||||
return hits, skills_by_ex
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ Siehe .claude/docs/working/PLANNING_EXERCISE_SUGGEST_CONTEXT.md
|
|||
from __future__ import annotations
|
||||
|
||||
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 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_retrieval import run_multistage_planning_retrieval
|
||||
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 (
|
||||
build_planning_target_with_query_pipeline,
|
||||
compose_retrieval_phase,
|
||||
|
|
@ -53,6 +54,7 @@ class PlanningExerciseSuggestRequest(BaseModel):
|
|||
phase_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_variant_id: Optional[int] = Field(default=None, ge=1)
|
||||
progression_graph_id: Optional[int] = Field(default=None, ge=1)
|
||||
query: Optional[str] = ""
|
||||
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")}
|
||||
|
||||
|
||||
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,
|
||||
graph_id: Optional[int],
|
||||
from_exercise_id: Optional[int],
|
||||
) -> Tuple[Set[int], Dict[int, str]]:
|
||||
if not graph_id or not from_exercise_id:
|
||||
return set(), {}
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT to_exercise_id, notes
|
||||
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)),
|
||||
tenant: TenantContext,
|
||||
pack: Dict[str, Any],
|
||||
body: PlanningExerciseSuggestRequest,
|
||||
*,
|
||||
sections: Optional[Sequence[Dict[str, Any]]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
anchor_variant = _resolve_anchor_variant_id(pack, body, sections)
|
||||
return apply_progression_context_to_pack(
|
||||
cur,
|
||||
tenant,
|
||||
pack,
|
||||
explicit_graph_id=body.progression_graph_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(
|
||||
|
|
@ -469,9 +502,6 @@ def build_planning_exercise_context_pack(
|
|||
planned_ids = _collect_planned_exercise_ids(sections)
|
||||
anchor_id = _resolve_anchor_from_plan(planned_ids, body.anchor_exercise_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))
|
||||
|
||||
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_title": anchor_title,
|
||||
"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),
|
||||
}
|
||||
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_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_name = None
|
||||
|
|
@ -559,9 +583,6 @@ def build_client_planning_context_pack(
|
|||
"anchor_exercise_id": anchor_id,
|
||||
"anchor_title": anchor_title,
|
||||
"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),
|
||||
"context_mode": "client_free",
|
||||
}
|
||||
|
|
@ -580,6 +601,12 @@ def suggest_planning_exercises(
|
|||
pack = build_client_planning_context_pack(cur, tenant=tenant, body=body)
|
||||
pack = _apply_client_planned_override(cur, pack, 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)
|
||||
heuristic_intent = resolve_planning_exercise_intent(query, body.intent_hint)
|
||||
|
||||
|
|
@ -638,14 +665,20 @@ def suggest_planning_exercises(
|
|||
target=target_profile,
|
||||
intent=intent,
|
||||
intent_weights=weights,
|
||||
pack=pack,
|
||||
pack={
|
||||
**pack,
|
||||
"requires_partner": query_intent_summary.get("requires_partner"),
|
||||
},
|
||||
)
|
||||
|
||||
text_signals_applied = "planning_text_signals" in (target_profile.sources or [])
|
||||
|
||||
planned_set = set(pack["planned_exercise_ids"])
|
||||
|
||||
llm_rank_applied = False
|
||||
retrieval_phase = compose_retrieval_phase(
|
||||
full_library=full_library_ranked,
|
||||
text_signals=text_signals_applied,
|
||||
query_intent=query_intent_applied,
|
||||
llm_expectation=llm_expectation_applied,
|
||||
llm_rank=False,
|
||||
|
|
@ -656,6 +689,7 @@ def suggest_planning_exercises(
|
|||
include_llm_rank=body.include_llm_rank,
|
||||
query_intent_applied=query_intent_applied,
|
||||
llm_expectation_applied=llm_expectation_applied,
|
||||
has_planning_reference=has_plan_ref,
|
||||
hits=hits,
|
||||
)
|
||||
if run_llm_rank:
|
||||
|
|
@ -681,6 +715,7 @@ def suggest_planning_exercises(
|
|||
if llm_rank_applied:
|
||||
retrieval_phase = compose_retrieval_phase(
|
||||
full_library=full_library_ranked,
|
||||
text_signals=text_signals_applied,
|
||||
query_intent=query_intent_applied,
|
||||
llm_expectation=llm_expectation_applied,
|
||||
llm_rank=True,
|
||||
|
|
@ -705,6 +740,9 @@ def suggest_planning_exercises(
|
|||
"anchor_exercise_id": pack.get("anchor_exercise_id"),
|
||||
"last_section_exercise_title": pack.get("last_section_exercise_title"),
|
||||
"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"),
|
||||
"unit_skill_profile": pack.get("unit_skill_profile_summary"),
|
||||
"section_skill_profile": pack.get("section_skill_profile_summary"),
|
||||
|
|
@ -719,6 +757,7 @@ def suggest_planning_exercises(
|
|||
"query_intent_summary": query_intent_summary,
|
||||
"retrieval_phase": retrieval_phase,
|
||||
"full_library_ranked": full_library_ranked,
|
||||
"text_signals_applied": text_signals_applied,
|
||||
"profile_preselect_applied": False,
|
||||
"llm_rank_applied": llm_rank_applied,
|
||||
"llm_intent_applied": query_intent_applied,
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ Ablauf:
|
|||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any, Dict, List, Mapping, Optional, Tuple
|
||||
from typing import Any, Dict, List, Mapping, Optional, Sequence, Tuple
|
||||
|
||||
from planning_exercise_expectation import try_build_planning_expectation_from_context
|
||||
from planning_exercise_intent import (
|
||||
|
|
@ -135,6 +135,31 @@ def deterministic_rank_confident(hits: Sequence[Mapping[str, Any]], *, gap_thres
|
|||
return (top - fourth) >= gap_threshold
|
||||
|
||||
|
||||
def hybrid_ranking_ambiguous(
|
||||
hits: Sequence[Mapping[str, Any]],
|
||||
*,
|
||||
top_four_gap: float = 0.08,
|
||||
top_ten_gap: float = 0.055,
|
||||
) -> bool:
|
||||
"""True wenn Top-Kandidaten scores zu nah beieinander liegen — Rerank lohnt sich."""
|
||||
if len(hits) < 3:
|
||||
return False
|
||||
top = float(hits[0].get("score") or 0.0)
|
||||
if len(hits) >= 4:
|
||||
fourth = float(hits[3].get("score") or 0.0)
|
||||
if (top - fourth) < top_four_gap:
|
||||
return True
|
||||
if len(hits) >= 10:
|
||||
tenth = float(hits[9].get("score") or 0.0)
|
||||
if (top - tenth) < top_ten_gap:
|
||||
return True
|
||||
elif len(hits) >= 2:
|
||||
tail = float(hits[min(len(hits) - 1, 9)].get("score") or 0.0)
|
||||
if (top - tail) < top_four_gap:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def should_run_llm_rank_pipeline(
|
||||
query: Optional[str],
|
||||
scenario: str,
|
||||
|
|
@ -142,26 +167,38 @@ def should_run_llm_rank_pipeline(
|
|||
include_llm_rank: bool,
|
||||
query_intent_applied: bool,
|
||||
llm_expectation_applied: bool = False,
|
||||
has_planning_reference: bool = True,
|
||||
hits: Sequence[Mapping[str, Any]],
|
||||
) -> bool:
|
||||
"""
|
||||
Maximal ein LLM-Call pro Request: wenn Intent- oder Erwartungs-LLM lief, kein Rerank.
|
||||
Rerank nur bei längerer, komplexer Anfrage und unklarem Hybrid-Ranking.
|
||||
Phase B2: Rerank bei unklarem Hybrid-Ranking — auch nach Erwartungs-/Intent-LLM.
|
||||
|
||||
Budget: max. 2 LLM-Calls pro Suche (Profil-LLM + optional Rerank).
|
||||
"""
|
||||
if not include_llm_rank:
|
||||
return False
|
||||
if query_intent_applied or llm_expectation_applied:
|
||||
if len(hits) < 3:
|
||||
return False
|
||||
if scenario == SCENARIO_PRESET_NEXT:
|
||||
if not hybrid_ranking_ambiguous(hits):
|
||||
return False
|
||||
|
||||
q = _normalize_query(query)
|
||||
if not q:
|
||||
return False
|
||||
profile_llm = query_intent_applied or llm_expectation_applied
|
||||
|
||||
if scenario == SCENARIO_PRESET_NEXT:
|
||||
return has_planning_reference
|
||||
|
||||
if scenario == SCENARIO_FREE_SEARCH:
|
||||
if len(q) < 10 and not profile_llm:
|
||||
return False
|
||||
return True
|
||||
|
||||
if scenario == SCENARIO_ADDITIVE:
|
||||
return len(q) >= 12 and not deterministic_rank_confident(hits)
|
||||
if len(q) < 22:
|
||||
return False
|
||||
return not deterministic_rank_confident(hits)
|
||||
return len(q) >= 8 or profile_llm
|
||||
|
||||
if profile_llm:
|
||||
return True
|
||||
return len(q) >= 14
|
||||
|
||||
|
||||
def _recalculate_skill_gap(target: PlanningTargetProfile) -> PlanningTargetProfile:
|
||||
|
|
@ -271,6 +308,8 @@ def build_planning_target_with_query_pipeline(
|
|||
section_planned_exercise_ids=section_planned_exercise_ids or [],
|
||||
anchor_exercise_id=anchor_exercise_id,
|
||||
intent=heuristic_intent,
|
||||
section_guidance_notes=(context_summary.get("section_guidance_notes") or None),
|
||||
section_title=(context_summary.get("section_title") or None),
|
||||
)
|
||||
else:
|
||||
base = PlanningTargetProfile(sources=["query_only"])
|
||||
|
|
@ -387,6 +426,7 @@ def compose_retrieval_phase(
|
|||
*,
|
||||
full_library: bool = False,
|
||||
profile_preselect: bool = False,
|
||||
text_signals: bool = False,
|
||||
query_intent: bool = False,
|
||||
llm_expectation: bool = False,
|
||||
llm_rank: bool = False,
|
||||
|
|
@ -394,6 +434,8 @@ def compose_retrieval_phase(
|
|||
parts = ["profile_v1"]
|
||||
if full_library or profile_preselect:
|
||||
parts.append("full_library")
|
||||
if text_signals:
|
||||
parts.append("text_signals")
|
||||
if llm_expectation:
|
||||
parts.append("llm_expectation")
|
||||
elif query_intent:
|
||||
|
|
@ -415,4 +457,5 @@ __all__ = [
|
|||
"should_run_llm_intent_pipeline",
|
||||
"should_run_llm_rank_pipeline",
|
||||
"deterministic_rank_confident",
|
||||
"hybrid_ranking_ambiguous",
|
||||
]
|
||||
|
|
|
|||
201
backend/planning_exercise_text_signals.py
Normal file
201
backend/planning_exercise_text_signals.py
Normal file
|
|
@ -0,0 +1,201 @@
|
|||
"""
|
||||
Phase B: Deterministische Text→Katalog-Signale für PlanningTargetProfile.
|
||||
|
||||
Mappt Abschnitts-guidance, Rahmen-Ziele/-Notizen und Programmbeschreibung
|
||||
auf Skill-/Katalog-Gewichte (ohne LLM).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any, Dict, List, Mapping, Optional, Sequence, Tuple
|
||||
|
||||
_MIN_SKILL_NAME_LEN = 3
|
||||
_MAX_SKILL_MATCHES = 12
|
||||
_MAX_CATALOG_MATCHES = 6
|
||||
|
||||
|
||||
def _normalize_text_blob(*parts: Optional[str]) -> str:
|
||||
chunks: List[str] = []
|
||||
for p in parts:
|
||||
s = (p or "").strip()
|
||||
if s:
|
||||
chunks.append(s)
|
||||
return "\n".join(chunks).lower()
|
||||
|
||||
|
||||
def _load_skills_for_text_match(cur) -> List[Tuple[int, str, int]]:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, name FROM skills
|
||||
WHERE (status IS NULL OR status = 'active')
|
||||
AND name IS NOT NULL AND TRIM(name) <> ''
|
||||
ORDER BY LENGTH(name) DESC, name ASC
|
||||
"""
|
||||
)
|
||||
out: List[Tuple[int, str, int]] = []
|
||||
for row in cur.fetchall():
|
||||
name = str(row.get("name") or "").strip()
|
||||
if len(name) < _MIN_SKILL_NAME_LEN:
|
||||
continue
|
||||
out.append((int(row["id"]), name.lower(), len(name)))
|
||||
return out
|
||||
|
||||
|
||||
def _load_catalog_names(cur, table: str, id_col: str = "id", name_col: str = "name") -> List[Tuple[int, str, int]]:
|
||||
cur.execute(
|
||||
f"""
|
||||
SELECT {id_col} AS id, {name_col} AS name
|
||||
FROM {table}
|
||||
WHERE {name_col} IS NOT NULL AND TRIM({name_col}) <> ''
|
||||
ORDER BY LENGTH({name_col}) DESC, {name_col} ASC
|
||||
"""
|
||||
)
|
||||
out: List[Tuple[int, str, int]] = []
|
||||
for row in cur.fetchall():
|
||||
name = str(row.get("name") or "").strip()
|
||||
if len(name) < 2:
|
||||
continue
|
||||
out.append((int(row["id"]), name.lower(), len(name)))
|
||||
return out
|
||||
|
||||
|
||||
def _match_catalog_names_in_text(
|
||||
text: str,
|
||||
catalog_rows: Sequence[Tuple[int, str, int]],
|
||||
*,
|
||||
weight: float = 0.85,
|
||||
limit: int = _MAX_CATALOG_MATCHES,
|
||||
) -> Dict[int, float]:
|
||||
if not text or not catalog_rows:
|
||||
return {}
|
||||
out: Dict[int, float] = {}
|
||||
for cid, name_lower, _ in catalog_rows:
|
||||
if len(out) >= limit:
|
||||
break
|
||||
if len(name_lower) < 2:
|
||||
continue
|
||||
if name_lower in text:
|
||||
out[cid] = max(out.get(cid, 0.0), weight)
|
||||
return out
|
||||
|
||||
|
||||
def _match_skills_in_text(
|
||||
text: str,
|
||||
skill_rows: Sequence[Tuple[int, str, int]],
|
||||
*,
|
||||
limit: int = _MAX_SKILL_MATCHES,
|
||||
) -> Dict[int, float]:
|
||||
if not text or not skill_rows:
|
||||
return {}
|
||||
out: Dict[int, float] = {}
|
||||
for sid, name_lower, name_len in skill_rows:
|
||||
if len(out) >= limit:
|
||||
break
|
||||
if name_len < _MIN_SKILL_NAME_LEN:
|
||||
continue
|
||||
if name_lower in text:
|
||||
w = min(1.0, 0.72 + min(name_len, 20) * 0.012)
|
||||
out[sid] = max(out.get(sid, 0.0), w)
|
||||
return out
|
||||
|
||||
|
||||
def load_framework_planning_text_parts(
|
||||
cur,
|
||||
framework_program_id: int,
|
||||
*,
|
||||
slot_id: Optional[int] = None,
|
||||
) -> List[str]:
|
||||
"""Sammelt Rahmen-Texte für Text-Signal-Matching."""
|
||||
parts: List[str] = []
|
||||
cur.execute(
|
||||
"SELECT description FROM training_framework_programs WHERE id = %s",
|
||||
(int(framework_program_id),),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if row and (row.get("description") or "").strip():
|
||||
parts.append(str(row["description"]).strip())
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT title, notes FROM training_framework_goals
|
||||
WHERE framework_program_id = %s
|
||||
ORDER BY sort_order ASC
|
||||
""",
|
||||
(int(framework_program_id),),
|
||||
)
|
||||
for g in cur.fetchall():
|
||||
t = (g.get("title") or "").strip()
|
||||
n = (g.get("notes") or "").strip()
|
||||
if t:
|
||||
parts.append(t)
|
||||
if n:
|
||||
parts.append(n)
|
||||
|
||||
if slot_id:
|
||||
cur.execute(
|
||||
"SELECT title, notes FROM training_framework_slots WHERE id = %s",
|
||||
(int(slot_id),),
|
||||
)
|
||||
srow = cur.fetchone()
|
||||
if srow:
|
||||
st = (srow.get("title") or "").strip()
|
||||
sn = (srow.get("notes") or "").strip()
|
||||
if st:
|
||||
parts.append(st)
|
||||
if sn:
|
||||
parts.append(sn)
|
||||
|
||||
return parts
|
||||
|
||||
|
||||
def resolve_planning_text_to_catalog_weights(
|
||||
cur,
|
||||
text_blob: str,
|
||||
) -> Tuple[Dict[int, float], Dict[int, float], Dict[int, float], Dict[int, float], Dict[int, float]]:
|
||||
"""
|
||||
Returns: focus, style, training_type, target_group, skill weight maps.
|
||||
"""
|
||||
text = _normalize_text_blob(text_blob)
|
||||
if not text or len(text) < 3:
|
||||
return {}, {}, {}, {}, {}
|
||||
|
||||
skill_rows = _load_skills_for_text_match(cur)
|
||||
focus_rows = _load_catalog_names(cur, "focus_areas")
|
||||
style_rows = _load_catalog_names(cur, "style_directions")
|
||||
tt_rows = _load_catalog_names(cur, "training_types")
|
||||
tg_rows = _load_catalog_names(cur, "target_groups")
|
||||
|
||||
skills = _match_skills_in_text(text, skill_rows)
|
||||
focus = _match_catalog_names_in_text(text, focus_rows, weight=0.88)
|
||||
style = _match_catalog_names_in_text(text, style_rows, weight=0.82)
|
||||
tt = _match_catalog_names_in_text(text, tt_rows, weight=0.82)
|
||||
tg = _match_catalog_names_in_text(text, tg_rows, weight=0.8)
|
||||
|
||||
if re.search(r"\bpartner\b|\bpaar\b|\bpaarweise\b|\bzu zweit\b", text):
|
||||
for gid, name_lower, _ in tg_rows:
|
||||
if "partner" in name_lower or "paar" in name_lower:
|
||||
tg[gid] = max(tg.get(gid, 0.0), 0.9)
|
||||
break
|
||||
|
||||
return focus, style, tt, tg, skills
|
||||
|
||||
|
||||
def merge_text_signal_summary(
|
||||
summary: Mapping[str, Any],
|
||||
*,
|
||||
text_sources: Sequence[str],
|
||||
matched_skills: Sequence[Mapping[str, Any]],
|
||||
) -> Dict[str, Any]:
|
||||
out = dict(summary)
|
||||
if text_sources:
|
||||
out["text_signal_sources"] = list(text_sources)
|
||||
if matched_skills:
|
||||
out["text_signal_skills"] = list(matched_skills)[:8]
|
||||
return out
|
||||
|
||||
|
||||
__all__ = [
|
||||
"load_framework_planning_text_parts",
|
||||
"merge_text_signal_summary",
|
||||
"resolve_planning_text_to_catalog_weights",
|
||||
]
|
||||
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
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
"""Tests Partner-Filter im Planungs-Retrieval."""
|
||||
from planning_exercise_retrieval import _exercise_looks_partner_related
|
||||
|
||||
|
||||
def test_exercise_partner_heuristic():
|
||||
assert _exercise_looks_partner_related({"title": "Partner-Fangspiel", "summary": ""})
|
||||
assert not _exercise_looks_partner_related({"title": "Kihon Solo", "summary": "Allein"})
|
||||
|
|
@ -56,11 +56,11 @@ def test_should_skip_llm_intent_short_free_search():
|
|||
)
|
||||
|
||||
|
||||
def test_should_skip_llm_rank_when_intent_already_applied():
|
||||
def test_should_run_llm_rank_when_intent_applied_and_ambiguous():
|
||||
from planning_exercise_target_pipeline import SCENARIO_ADDITIVE, should_run_llm_rank_pipeline
|
||||
|
||||
hits = [{"score": 0.5}, {"score": 0.48}, {"score": 0.47}, {"score": 0.46}]
|
||||
assert not should_run_llm_rank_pipeline(
|
||||
assert should_run_llm_rank_pipeline(
|
||||
"Baut auf dem Plan auf und trainiert zusätzlich Schnellkraft mit Partner",
|
||||
SCENARIO_ADDITIVE,
|
||||
include_llm_rank=True,
|
||||
|
|
@ -69,6 +69,36 @@ def test_should_skip_llm_rank_when_intent_already_applied():
|
|||
)
|
||||
|
||||
|
||||
def test_should_skip_llm_rank_when_ranking_confident():
|
||||
from planning_exercise_target_pipeline import SCENARIO_PRESET_NEXT, should_run_llm_rank_pipeline
|
||||
|
||||
hits = [{"score": 0.9}, {"score": 0.5}, {"score": 0.4}, {"score": 0.3}]
|
||||
assert not should_run_llm_rank_pipeline(
|
||||
"",
|
||||
SCENARIO_PRESET_NEXT,
|
||||
include_llm_rank=True,
|
||||
query_intent_applied=False,
|
||||
llm_expectation_applied=True,
|
||||
has_planning_reference=True,
|
||||
hits=hits,
|
||||
)
|
||||
|
||||
|
||||
def test_should_run_llm_rank_for_preset_when_ambiguous():
|
||||
from planning_exercise_target_pipeline import SCENARIO_PRESET_NEXT, should_run_llm_rank_pipeline
|
||||
|
||||
hits = [{"score": 0.42}, {"score": 0.41}, {"score": 0.4}, {"score": 0.39}]
|
||||
assert should_run_llm_rank_pipeline(
|
||||
"",
|
||||
SCENARIO_PRESET_NEXT,
|
||||
include_llm_rank=True,
|
||||
query_intent_applied=False,
|
||||
llm_expectation_applied=True,
|
||||
has_planning_reference=True,
|
||||
hits=hits,
|
||||
)
|
||||
|
||||
|
||||
def test_compose_retrieval_phase():
|
||||
assert compose_retrieval_phase(query_intent=False, llm_rank=False) == "profile_v1"
|
||||
assert compose_retrieval_phase(query_intent=True, llm_rank=True) == "profile_v1+query_intent+llm_rank"
|
||||
|
|
@ -99,10 +129,10 @@ def test_should_run_llm_expectation_for_preset_with_planning_ref():
|
|||
)
|
||||
|
||||
|
||||
def test_should_skip_llm_rank_when_expectation_applied():
|
||||
def test_should_skip_llm_rank_when_expectation_applied_but_confident():
|
||||
from planning_exercise_target_pipeline import SCENARIO_PRESET_NEXT, should_run_llm_rank_pipeline
|
||||
|
||||
hits = [{"score": 0.5}, {"score": 0.48}, {"score": 0.47}, {"score": 0.46}]
|
||||
hits = [{"score": 0.85}, {"score": 0.4}, {"score": 0.35}, {"score": 0.3}]
|
||||
assert not should_run_llm_rank_pipeline(
|
||||
"",
|
||||
SCENARIO_PRESET_NEXT,
|
||||
|
|
|
|||
47
backend/tests/test_planning_exercise_text_signals.py
Normal file
47
backend/tests/test_planning_exercise_text_signals.py
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
"""Tests Phase B: Text-Signale für PlanningTargetProfile."""
|
||||
from planning_exercise_text_signals import resolve_planning_text_to_catalog_weights
|
||||
|
||||
|
||||
def test_resolve_planning_text_matches_skill_and_partner_hint():
|
||||
class _Cur:
|
||||
def execute(self, sql, params=None):
|
||||
self._sql = sql
|
||||
|
||||
def fetchall(self):
|
||||
sql = getattr(self, "_sql", "")
|
||||
if "FROM skills" in sql:
|
||||
return [
|
||||
{"id": 5, "name": "Kime"},
|
||||
{"id": 8, "name": "Schnellkraft"},
|
||||
{"id": 2, "name": "Ab"},
|
||||
]
|
||||
if "FROM focus_areas" in sql:
|
||||
return [{"id": 10, "name": "Karate Technik"}]
|
||||
if "FROM style_directions" in sql:
|
||||
return []
|
||||
if "FROM training_types" in sql:
|
||||
return []
|
||||
if "FROM target_groups" in sql:
|
||||
return [{"id": 3, "name": "Partnerübung"}]
|
||||
return []
|
||||
|
||||
focus, style, tt, tg, skills = resolve_planning_text_to_catalog_weights(
|
||||
_Cur(),
|
||||
"Abschnitt: Kime vertiefen mit Partnerübung",
|
||||
)
|
||||
assert skills.get(5, 0) > 0
|
||||
assert 8 not in skills
|
||||
assert tg.get(3, 0) > 0
|
||||
assert not style and not tt
|
||||
|
||||
|
||||
def test_resolve_planning_text_empty():
|
||||
class _Cur:
|
||||
def execute(self, sql, params=None):
|
||||
pass
|
||||
|
||||
def fetchall(self):
|
||||
return []
|
||||
|
||||
focus, style, tt, tg, skills = resolve_planning_text_to_catalog_weights(_Cur(), " ")
|
||||
assert not focus and not skills
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
# Shinkan Jinkendo Version Information
|
||||
|
||||
APP_VERSION = "0.8.180"
|
||||
APP_VERSION = "0.8.183"
|
||||
BUILD_DATE = "2026-05-23"
|
||||
DB_SCHEMA_VERSION = "20260531074"
|
||||
|
||||
|
|
@ -29,7 +29,7 @@ MODULE_VERSIONS = {
|
|||
"skill_profiles": "1.0.0", # Phase 3: gewichtetes Fähigkeiten-Profil + skill-discovery/suggestions
|
||||
"methods": "0.1.0",
|
||||
"exercises": "2.37.0", # Planungs-KI P1: Szenario-Pipeline + Query-Intent-Overlay
|
||||
"planning_exercise_suggest": "0.8.0", # Phase A: Voll-Library-Ranking gegen Erwartungsprofil
|
||||
"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_programs": "0.1.0",
|
||||
"planning": "0.15.0", # Vorlagen: Strukturvorschau, Bearbeiten inkl. Split-Sessions + Beschreibung
|
||||
|
|
@ -44,6 +44,31 @@ MODULE_VERSIONS = {
|
|||
}
|
||||
|
||||
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",
|
||||
"date": "2026-05-23",
|
||||
"changes": [
|
||||
"Planungs-KI Phase B2: LLM-Rerank bei engem Top-Feld — auch nach Erwartungs-/Intent-LLM (max. 2 Calls).",
|
||||
"Preset „Nächste aus Kontext“: Rerank wenn Ranking unklar; Frontend sendet include_llm_rank immer.",
|
||||
],
|
||||
},
|
||||
{
|
||||
"version": "0.8.181",
|
||||
"date": "2026-05-23",
|
||||
"changes": [
|
||||
"Planungs-KI Phase B: guidance_notes + Rahmen-Ziele/Notizen → Text-Signale im Erwartungsprofil (planning_text_signals).",
|
||||
"requires_partner aus Intent filtert Übungen; retrieval_phase +text_signals; Grund „Passt zu Abschnitts-/Rahmen-Zieltext“.",
|
||||
],
|
||||
},
|
||||
{
|
||||
"version": "0.8.180",
|
||||
"date": "2026-05-23",
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
# Shinkan Jinkendo – Entwicklungsstand & Handover
|
||||
|
||||
**Stand:** 2026-05-31
|
||||
**App-Version / DB-Schema:** App **`0.8.167`** (Planungs-KI Übungssuche P0); DB **`20260531071`** — maßgeblich **`backend/version.py`**.
|
||||
**Stand:** 2026-05-23
|
||||
**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**.
|
||||
|
||||
|
|
@ -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`)
|
||||
- **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
|
||||
- **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`**
|
||||
|
|
@ -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`**
|
||||
- **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 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
|
||||
|
||||
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).
|
||||
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.
|
||||
4. **Tests:** pytest für `media_assets`-Router (Leserechte, Lifecycle, `from-asset`); ggf. Snapshot der Pfad-Umzug-Logik.
|
||||
5. **Retention:** Job-Dokumentation + Betrieb (ENV, Intervall); Dry-Run beschreiben.
|
||||
6. **S3/Adapter:** Speicher-Abstraktion (Spec Abschnitt 7) — wenn Produkt es verlangt.
|
||||
7. **Rahmen/UI:** Kalender „aus Rahmen übernehmen” weiter anbinden (parallel, unabhängig von Medien).
|
||||
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).
|
||||
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`).
|
||||
12. **Backend-Validierung** `method_profile` / `planning_method_profile` je Archetyp (Paket **4g**).
|
||||
### Planungs-KI (priorisiert)
|
||||
|
||||
1. **C2 — Varianten in Treffern:** Planungs-Picker: bei `suggested_variant_id` Variante vorauswählen; optional Varianten-Ranking bei `deepen`/`progression`.
|
||||
2. **C3 — Graph-Builder:** Modus „Pfad zum Ziel“ → sequenzielle Vorschläge → `POST …/edges/sequence` nach Review.
|
||||
3. **Graph-Auswahl UI:** Dropdown neben Auto-Match; Rahmen-Slot / Framework mit Default-Graph verknüpfen.
|
||||
4. **Enrichment:** Skills für Kern-Übungen nachziehen (sonst schwaches Profil-Ranking).
|
||||
5. **D — Neu-Anlage:** `planning_context_json` an `POST /api/exercises/ai/suggest`.
|
||||
|
||||
### Allgemein
|
||||
|
||||
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).
|
||||
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**).
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -31,7 +31,6 @@ const PAGE_SIZE = 100
|
|||
const PLANNING_SUGGEST_LIMIT = 50
|
||||
/** Client-Hinweis — Backend entscheidet final über LLM-Gates (max. 1 Call). */
|
||||
const PLANNING_LLM_INTENT_MIN_CHARS = 10
|
||||
const PLANNING_LLM_RANK_MIN_CHARS = 24
|
||||
const LEVEL_FILTER_OPTS = SKILL_LEVEL_OPTIONS.filter((o) => o.level != null)
|
||||
|
||||
const INITIAL_FILTERS = { ...INITIAL_EXERCISE_LIST_FILTERS }
|
||||
|
|
@ -115,6 +114,7 @@ export default function ExercisePickerModal({
|
|||
phaseOrderIndex: planningContext?.phaseOrderIndex ?? null,
|
||||
parallelStreamOrderIndex: planningContext?.parallelStreamOrderIndex ?? null,
|
||||
anchorExerciseId: planningContext?.anchorExerciseId ?? null,
|
||||
anchorExerciseVariantId: planningContext?.anchorExerciseVariantId ?? null,
|
||||
progressionGraphId: planningContext?.progressionGraphId ?? null,
|
||||
plannedExerciseIds: Array.isArray(planningContext?.plannedExerciseIds)
|
||||
? planningContext.plannedExerciseIds
|
||||
|
|
@ -447,6 +447,10 @@ export default function ExercisePickerModal({
|
|||
activePlanningContext.anchorExerciseId != null
|
||||
? Number(activePlanningContext.anchorExerciseId)
|
||||
: null,
|
||||
anchor_exercise_variant_id:
|
||||
activePlanningContext.anchorExerciseVariantId != null
|
||||
? Number(activePlanningContext.anchorExerciseVariantId)
|
||||
: undefined,
|
||||
progression_graph_id:
|
||||
activePlanningContext.progressionGraphId != null
|
||||
? Number(activePlanningContext.progressionGraphId)
|
||||
|
|
@ -460,7 +464,7 @@ export default function ExercisePickerModal({
|
|||
: undefined,
|
||||
include_llm_intent:
|
||||
query.length >= PLANNING_LLM_INTENT_MIN_CHARS || !(query || '').trim(),
|
||||
include_llm_rank: query.length >= PLANNING_LLM_RANK_MIN_CHARS,
|
||||
include_llm_rank: true,
|
||||
query,
|
||||
intent_hint:
|
||||
activePlanningContext.intentHint || (useFreePlanningSearch && query ? 'free_search' : null),
|
||||
|
|
@ -501,6 +505,7 @@ export default function ExercisePickerModal({
|
|||
title: h.title,
|
||||
summary: h.summary,
|
||||
focus_area: h.focus_area,
|
||||
suggested_variant_id: h.suggested_variant_id ?? null,
|
||||
_planningScore: h.score,
|
||||
_planningReasons: Array.isArray(h.reasons) ? h.reasons : [],
|
||||
updated_at: new Date().toISOString(),
|
||||
|
|
@ -741,6 +746,12 @@ export default function ExercisePickerModal({
|
|||
Anker: {planningContextSummary.anchor_title}
|
||||
</span>
|
||||
) : 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) &&
|
||||
planningTargetProfileSummary.focus_areas.length > 0
|
||||
? 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>
|
||||
) : 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 className="exercise-card__footer">
|
||||
|
|
|
|||
|
|
@ -14,7 +14,110 @@ export default function ExerciseListSearchBar({
|
|||
exerciseCount,
|
||||
allOnPageSelected,
|
||||
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 (
|
||||
<div className="card exercise-search-bar">
|
||||
<label className="form-label">Volltextsuche (Titel, Ziel, …)</label>
|
||||
|
|
|
|||
|
|
@ -12,7 +12,8 @@ import ExerciseListBulkToolbar from './ExerciseListBulkToolbar'
|
|||
import SaveSelectedExercisesAsModuleModal from './SaveSelectedExercisesAsModuleModal'
|
||||
import ExercisePeekModal from '../ExercisePeekModal'
|
||||
import NavStateLink from '../NavStateLink'
|
||||
import ExerciseAiQuickCreateOffer from '../ExerciseAiQuickCreateOffer'
|
||||
import { ExerciseAiQuickCreateTeaser } from '../ExerciseAiQuickCreateOffer'
|
||||
import ExerciseAiQuickCreateModal from './ExerciseAiQuickCreateModal'
|
||||
import ExerciseAiSuggestPreviewModal from '../ExerciseAiSuggestPreviewModal'
|
||||
import {
|
||||
buildQuickCreateAiPreview,
|
||||
|
|
@ -31,6 +32,7 @@ import {
|
|||
snapshotExerciseForSelection,
|
||||
} from '../../utils/exerciseListSelection'
|
||||
import { useExerciseListCatalogsAndQuery } from '../../hooks/useExerciseListCatalogsAndQuery'
|
||||
import { usePlanningExerciseSuggestSearch } from '../../hooks/usePlanningExerciseSuggestSearch'
|
||||
import {
|
||||
INITIAL_EXERCISE_LIST_FILTERS,
|
||||
mergeExerciseListPrefsFromApi,
|
||||
|
|
@ -89,10 +91,15 @@ function ExercisesListPageRoot() {
|
|||
const [peekExercise, setPeekExercise] = useState(null)
|
||||
const [saveModuleModalOpen, setSaveModuleModalOpen] = useState(false)
|
||||
const [aiQuickCreateEnabled, setAiQuickCreateEnabled] = useState(false)
|
||||
const [aiQuickCreateModalOpen, setAiQuickCreateModalOpen] = useState(false)
|
||||
const [quickSaving, setQuickSaving] = useState(false)
|
||||
const [quickAiError, setQuickAiError] = useState('')
|
||||
const [quickCreateDraft, setQuickCreateDraft] = useState(null)
|
||||
|
||||
const planningKi = usePlanningExerciseSuggestSearch({
|
||||
enabled: pageTab === 'list' && aiQuickCreateEnabled,
|
||||
})
|
||||
|
||||
const {
|
||||
title: quickTitle,
|
||||
sketch: quickSketch,
|
||||
|
|
@ -100,9 +107,14 @@ function ExercisesListPageRoot() {
|
|||
setTitle: setQuickTitle,
|
||||
setSketch: setQuickSketch,
|
||||
setFocusAreaId: setQuickFocusAreaId,
|
||||
} = useExerciseAiQuickCreateFields(debouncedSearch, {
|
||||
enabled: pageTab === 'list' && (aiQuickCreateEnabled || debouncedSearch.length >= 3),
|
||||
})
|
||||
} = useExerciseAiQuickCreateFields(
|
||||
aiQuickCreateModalOpen
|
||||
? planningKi.submittedQuery || planningKi.searchInput || debouncedSearch
|
||||
: debouncedSearch,
|
||||
{
|
||||
enabled: pageTab === 'list' && aiQuickCreateModalOpen,
|
||||
},
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!user?.id) return
|
||||
|
|
@ -174,14 +186,18 @@ function ExercisesListPageRoot() {
|
|||
loadingMore,
|
||||
hasMore,
|
||||
loadMore,
|
||||
} = useExerciseListCatalogsAndQuery({ queryBase, pageTab, tenantClubDepKey })
|
||||
} = useExerciseListCatalogsAndQuery({
|
||||
queryBase,
|
||||
pageTab,
|
||||
tenantClubDepKey,
|
||||
skipListFetch: aiQuickCreateEnabled,
|
||||
})
|
||||
|
||||
const showQuickCreateOffer =
|
||||
pageTab === 'list' &&
|
||||
catalogsReady &&
|
||||
!listFetching &&
|
||||
(aiQuickCreateEnabled ||
|
||||
(exercises.length === 0 && selectedEntries.length === 0 && debouncedSearch.length >= 3))
|
||||
const listExercises = exercises
|
||||
const listFetchingResolved = listFetching
|
||||
const exercisesForDisplay = aiQuickCreateEnabled ? planningKi.rows : listExercises
|
||||
const listFetchingDisplay = aiQuickCreateEnabled ? planningKi.loading : listFetchingResolved
|
||||
const hasMoreDisplay = aiQuickCreateEnabled ? false : hasMore
|
||||
|
||||
const selectedIds = useMemo(
|
||||
() => new Set(selectedEntries.map((e) => Number(e.id)).filter((id) => Number.isFinite(id) && id > 0)),
|
||||
|
|
@ -189,15 +205,22 @@ function ExercisesListPageRoot() {
|
|||
)
|
||||
|
||||
const selectedExercisesDisplay = useMemo(
|
||||
() => mergeSelectedWithListEntries(selectedEntries, exercises),
|
||||
[selectedEntries, exercises]
|
||||
() => mergeSelectedWithListEntries(selectedEntries, exercisesForDisplay),
|
||||
[selectedEntries, exercisesForDisplay],
|
||||
)
|
||||
|
||||
const filterResultExercises = useMemo(
|
||||
() => exercises.filter((e) => !selectedIds.has(Number(e.id))),
|
||||
[exercises, selectedIds]
|
||||
() => exercisesForDisplay.filter((e) => !selectedIds.has(Number(e.id))),
|
||||
[exercisesForDisplay, selectedIds],
|
||||
)
|
||||
|
||||
const showKiCreateTeaser =
|
||||
aiQuickCreateEnabled &&
|
||||
planningKi.hasSearched &&
|
||||
!planningKi.loading &&
|
||||
catalogsReady &&
|
||||
filterResultExercises.length > 0
|
||||
|
||||
const focusOptions = useMemo(
|
||||
() =>
|
||||
catalogs.focusAreas.map((fa) => ({
|
||||
|
|
@ -274,9 +297,9 @@ function ExercisesListPageRoot() {
|
|||
|
||||
/** Für Browser-/datalist-Vorschläge (aktuelle Treffer-Titel, begrenzt) */
|
||||
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)
|
||||
}, [exercises])
|
||||
}, [listExercises])
|
||||
|
||||
const clubNameById = useMemo(() => {
|
||||
const m = {}
|
||||
|
|
@ -377,6 +400,7 @@ function ExercisesListPageRoot() {
|
|||
setQuickCreateDraft(
|
||||
aiPreviewToQuickCreateDraft(preview, { title, focusAreaId: focusId, sketchPlain: sketch }),
|
||||
)
|
||||
setAiQuickCreateModalOpen(false)
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
const msg = e?.message || String(e)
|
||||
|
|
@ -597,23 +621,36 @@ function ExercisesListPageRoot() {
|
|||
<div className="exercises-page__header-actions">
|
||||
<label
|
||||
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
|
||||
type="checkbox"
|
||||
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>Neu mit KI-Assistent</span>
|
||||
</label>
|
||||
<NavStateLink
|
||||
to="/exercises/new"
|
||||
returnContext={exercisesModuleReturnContext}
|
||||
className="btn btn-primary"
|
||||
>
|
||||
+ Neu
|
||||
</NavStateLink>
|
||||
{aiQuickCreateEnabled ? (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
onClick={() => setAiQuickCreateModalOpen(true)}
|
||||
>
|
||||
+ Neue Übung mit KI
|
||||
</button>
|
||||
) : (
|
||||
<NavStateLink
|
||||
to="/exercises/new"
|
||||
returnContext={exercisesModuleReturnContext}
|
||||
className="btn btn-primary"
|
||||
>
|
||||
+ Neu
|
||||
</NavStateLink>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<span aria-hidden="true" />
|
||||
|
|
@ -644,6 +681,16 @@ function ExercisesListPageRoot() {
|
|||
) : (
|
||||
<>
|
||||
<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}
|
||||
searchInput={searchInput}
|
||||
onSearchInputChange={setSearchInput}
|
||||
|
|
@ -654,30 +701,15 @@ function ExercisesListPageRoot() {
|
|||
onOpenFilter={() => setFilterModalOpen(true)}
|
||||
filterChips={filterChips}
|
||||
onResetAllFilters={resetAllFilters}
|
||||
exerciseCount={exercises.length}
|
||||
exerciseCount={exercisesForDisplay.length}
|
||||
allOnPageSelected={allOnPageSelected}
|
||||
onToggleSelectAllPage={toggleSelectAllPage}
|
||||
/>
|
||||
|
||||
{showQuickCreateOffer ? (
|
||||
<ExerciseAiQuickCreateOffer
|
||||
searchLabel={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}
|
||||
hint={
|
||||
aiQuickCreateEnabled
|
||||
? 'Titel aus Suche oder manuell; Kurzbeschreibung optional — leer für freien KI-Vorschlag, ausgefüllt als deine Ausgangsidee.'
|
||||
: undefined
|
||||
}
|
||||
{showKiCreateTeaser ? (
|
||||
<ExerciseAiQuickCreateTeaser
|
||||
onExpand={() => setAiQuickCreateModalOpen(true)}
|
||||
disabled={quickSaving}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
|
|
@ -762,22 +794,36 @@ function ExercisesListPageRoot() {
|
|||
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="spinner" />
|
||||
<p className="muted" style={{ marginTop: '12px' }}>
|
||||
Lade Übungen…
|
||||
{aiQuickCreateEnabled ? 'Planungs-KI lädt Vorschläge…' : 'Lade Übungen…'}
|
||||
</p>
|
||||
</div>
|
||||
) : exercises.length === 0 && selectedEntries.length === 0 && !showQuickCreateOffer ? (
|
||||
) : exercisesForDisplay.length === 0 && selectedEntries.length === 0 ? (
|
||||
<div className="card">
|
||||
<p className="exercises-empty-text">
|
||||
{debouncedSearch.length >= 3
|
||||
? 'Keine Übungen gefunden.'
|
||||
: 'Keine Übungen gefunden — Suchbegriff eingeben oder Filter anpassen.'}
|
||||
{aiQuickCreateEnabled
|
||||
? planningKi.hasSearched
|
||||
? '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>
|
||||
{aiQuickCreateEnabled && planningKi.hasSearched ? (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
style={{ marginTop: '12px' }}
|
||||
onClick={() => setAiQuickCreateModalOpen(true)}
|
||||
>
|
||||
Neue Übung mit KI …
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
) : exercises.length === 0 && selectedEntries.length === 0 && showQuickCreateOffer ? null : (
|
||||
) : (
|
||||
<>
|
||||
{selectedEntries.length > 0 ? (
|
||||
<section className="exercises-selection-section" data-testid="exercises-selection-section">
|
||||
|
|
@ -812,13 +858,14 @@ function ExercisesListPageRoot() {
|
|||
) : null
|
||||
) : (
|
||||
<>
|
||||
{listFetching ? (
|
||||
{listFetchingDisplay ? (
|
||||
<p className="exercises-meta-line exercises-meta-line--muted">Aktualisiere Treffer…</p>
|
||||
) : null}
|
||||
<p className="exercises-meta-line">
|
||||
{filterResultExercises.length} Treffer
|
||||
{aiQuickCreateEnabled ? ' (Planungs-KI)' : ''}
|
||||
{selectedEntries.length > 0 ? ' (ohne bereits Ausgewählte)' : ''}
|
||||
{hasMore ? ' · es gibt weitere Einträge' : ''}
|
||||
{hasMoreDisplay ? ' · es gibt weitere Einträge' : ''}
|
||||
</p>
|
||||
<div className="exercises-list-grid" data-testid="exercises-list-grid">
|
||||
{filterResultExercises.map((exercise) => (
|
||||
|
|
@ -833,7 +880,7 @@ function ExercisesListPageRoot() {
|
|||
/>
|
||||
))}
|
||||
</div>
|
||||
{hasMore && (
|
||||
{hasMoreDisplay && (
|
||||
<div className="exercises-load-more">
|
||||
<button type="button" className="btn btn-secondary" disabled={loadingMore} onClick={loadMore}>
|
||||
{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
|
||||
draft={quickCreateDraft}
|
||||
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.
|
||||
*/
|
||||
export function useExerciseListCatalogsAndQuery({ queryBase, pageTab, tenantClubDepKey }) {
|
||||
export function useExerciseListCatalogsAndQuery({ queryBase, pageTab, tenantClubDepKey, skipListFetch = false }) {
|
||||
const [catalogs, setCatalogs] = useState({
|
||||
focusAreas: [],
|
||||
styleDirections: [],
|
||||
|
|
@ -55,7 +55,14 @@ export function useExerciseListCatalogsAndQuery({ queryBase, pageTab, tenantClub
|
|||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!catalogsReady || pageTab !== 'list') return
|
||||
if (!catalogsReady || pageTab !== 'list' || skipListFetch) {
|
||||
if (skipListFetch) {
|
||||
setExercises([])
|
||||
setHasMore(false)
|
||||
setListFetching(false)
|
||||
}
|
||||
return
|
||||
}
|
||||
let cancelled = false
|
||||
const run = async () => {
|
||||
setListFetching(true)
|
||||
|
|
@ -81,7 +88,7 @@ export function useExerciseListCatalogsAndQuery({ queryBase, pageTab, tenantClub
|
|||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [queryBase, catalogsReady, pageTab, tenantClubDepKey])
|
||||
}, [queryBase, catalogsReady, pageTab, tenantClubDepKey, skipListFetch])
|
||||
|
||||
const loadMore = useCallback(async () => {
|
||||
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 sec = secs[sIdx]
|
||||
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 (typeof target?.iIdx === 'number') {
|
||||
const item = sec.items[target.iIdx]
|
||||
if (item?.exercise_id) {
|
||||
anchorExerciseId = Number(item.exercise_id)
|
||||
pickAnchorFromItem(item)
|
||||
} else {
|
||||
for (let i = target.iIdx - 1; i >= 0; i -= 1) {
|
||||
if (sec.items[i]?.exercise_id) {
|
||||
anchorExerciseId = Number(sec.items[i].exercise_id)
|
||||
pickAnchorFromItem(sec.items[i])
|
||||
break
|
||||
}
|
||||
}
|
||||
|
|
@ -149,14 +158,14 @@ export default function TrainingUnitEditPage() {
|
|||
const beforeIx = target.insertBeforeIndex
|
||||
for (let i = beforeIx - 1; i >= 0; i -= 1) {
|
||||
if (sec.items[i]?.exercise_id) {
|
||||
anchorExerciseId = Number(sec.items[i].exercise_id)
|
||||
pickAnchorFromItem(sec.items[i])
|
||||
break
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (let i = sec.items.length - 1; i >= 0; i -= 1) {
|
||||
if (sec.items[i]?.exercise_id) {
|
||||
anchorExerciseId = Number(sec.items[i].exercise_id)
|
||||
pickAnchorFromItem(sec.items[i])
|
||||
break
|
||||
}
|
||||
}
|
||||
|
|
@ -204,6 +213,7 @@ export default function TrainingUnitEditPage() {
|
|||
sectionExerciseCount: sectionPlannedExerciseIds.length,
|
||||
lastExerciseTitle,
|
||||
anchorExerciseId: Number.isFinite(anchorExerciseId) && anchorExerciseId > 0 ? anchorExerciseId : null,
|
||||
anchorExerciseVariantId,
|
||||
progressionGraphId: null,
|
||||
plannedExerciseIds,
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user