Enhance Planning Exercise Suggestion Features and Update Application Version to 0.8.169
All checks were successful
Deploy Development / deploy (push) Successful in 42s
Test Suite / pytest-backend (push) Successful in 39s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 14s
Test Suite / k6 /health Baseline (push) Successful in 33s
Test Suite / playwright-tests (push) Successful in 1m14s
All checks were successful
Deploy Development / deploy (push) Successful in 42s
Test Suite / pytest-backend (push) Successful in 39s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 14s
Test Suite / k6 /health Baseline (push) Successful in 33s
Test Suite / playwright-tests (push) Successful in 1m14s
- Implemented Phase 1.1 of the planning exercise suggestion functionality, integrating `ExerciseMatchProfile` and `PlanningTargetProfile` for improved exercise scoring based on profile dimensions. - Updated the `suggestPlanningExercises` API to include a new `retrieval_phase` and `target_profile_summary`, enhancing the context provided to the frontend. - Enhanced the `ExercisePickerModal` to display additional information from the planning target profile, including focus areas and top skills, improving user experience during exercise selection. - Incremented application version to 0.8.169 and updated changelog to reflect the new features and improvements in the planning AI capabilities.
This commit is contained in:
parent
d7d45a8927
commit
128a9d752e
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
**Version:** 0.1
|
||||
**Datum:** 2026-05-22
|
||||
**Status:** P0 in Umsetzung (Hybrid-Retrieval ohne LLM-Intent)
|
||||
**Status:** P0.1 — Hybrid-Retrieval + Phase-1-Profil-Score (`profile_v1`); LLM-Rerank P2
|
||||
**Bezüge:** `AI_PLANNING_KI_MULTISTAGE_FORECAST.md` · `AI_PROMPT_TARGET_ARCHITECTURE.md` · `SKILL_SCORING_SPEC.md` · `TRAINING_FRAMEWORK_SPEC.md` §3 (Progressionsgraph)
|
||||
|
||||
---
|
||||
|
|
@ -26,7 +26,8 @@ Trainer in der **Trainingsplanung** sollen Übungen finden oder anlegen können
|
|||
|-------|------|---------|-----|
|
||||
| **S0** | Kontext-Pack | SQL/API, deterministisch | ✅ |
|
||||
| **S1a** | Intent strukturieren | Optional LLM `planning_exercise_search_intent` | Heuristik |
|
||||
| **S1b** | Hybrid-Retrieval | Score: Volltext + Graph + Skills + Plan | ✅ |
|
||||
| **S1b** | Hybrid-Retrieval | Score: Volltext + Graph + Skills + Plan + **Profil** | ✅ |
|
||||
| **S1b+** | Profil-Vorselektion | `ExerciseMatchProfile` × `PlanningTargetProfile` | ✅ `profile_v1` |
|
||||
| **S1c** | Rerank + Begründung | Optional LLM `planning_exercise_search_rank` | Regelbasierte `reasons[]` |
|
||||
| **S2** | Neu-Anlage | Bestehende `suggestExerciseAi` + Pack als Zusatzkontext | Später |
|
||||
|
||||
|
|
@ -81,11 +82,14 @@ score = w_ft * fulltext_rank
|
|||
+ w_prog * progression_hit
|
||||
+ w_skill * skill_jaccard(anchor, candidate)
|
||||
+ w_plan * plan_affinity
|
||||
+ w_profile * profile_match(exercise, target)
|
||||
+ w_repeat * (candidate in unit_plan ? -1 : 0)
|
||||
+ w_group_repeat * (candidate in group_recent ? -0.5 : 0)
|
||||
```
|
||||
|
||||
**`reasons[]`** (regelbasiert, Deutsch): z. B. „Nachfolger im Progressionsgraph“, „Fähigkeiten passen zur Anker-Übung“, „Volltext-Treffer“.
|
||||
**`profile_match`** (0–1): siehe §12–§13 — Katalog-Dimensionen + Skill-Gewichte + Skill-Gap.
|
||||
|
||||
**`reasons[]`** (regelbasiert, Deutsch): z. B. „Nachfolger im Progressionsgraph“, „Fähigkeiten passen zur Anker-Übung“, „Fokusbereich passend zum Planungsziel“, „Deckt Skill-Lücke im bisherigen Plan“, „Volltext-Treffer“.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -121,6 +125,13 @@ score = w_ft * fulltext_rank
|
|||
"planned_count": 4,
|
||||
"anchor_title": "Partner-Fangspiel"
|
||||
},
|
||||
"target_profile_summary": {
|
||||
"sources": ["framework_catalog", "current_unit_plan", "anchor_exercise"],
|
||||
"focus_areas": ["Reaktion & Abwehr"],
|
||||
"top_skills": [{ "skill_id": 12, "name": "Reaktionsgeschwindigkeit", "weight": 1.0 }],
|
||||
"has_skill_gap": true
|
||||
},
|
||||
"retrieval_phase": "profile_v1",
|
||||
"intent_resolved": "suggest_next",
|
||||
"hits": [
|
||||
{
|
||||
|
|
@ -128,14 +139,14 @@ score = w_ft * fulltext_rank
|
|||
"title": "…",
|
||||
"summary": "…",
|
||||
"score": 0.78,
|
||||
"reasons": ["Nachfolger im Progressionsgraph", "3 gemeinsame Fähigkeiten mit Anker-Übung"],
|
||||
"reasons": ["Nachfolger im Progressionsgraph", "Fokusbereich passend zum Planungsziel"],
|
||||
"focus_area": "…"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Modul:** `backend/planning_exercise_suggest.py` · Router `backend/routers/planning_exercise_suggest.py`
|
||||
**Modul:** `backend/planning_exercise_suggest.py` · `backend/planning_exercise_profiles.py` · Router `backend/routers/planning_exercise_suggest.py`
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -166,6 +177,7 @@ Wenn `hits` leer oder Trainer wählt „Mit KI anlegen“:
|
|||
| Phase | Inhalt |
|
||||
|-------|--------|
|
||||
| **P0** ✅ | Context-Pack, Hybrid-Score, API, Picker in Planung |
|
||||
| **P0.1** ✅ | `ExerciseMatchProfile` / `PlanningTargetProfile`, `profile_v1`, `target_profile_summary` |
|
||||
| **P1** | LLM Intent-JSON; Neu-Anlage mit Pack |
|
||||
| **P2** | LLM-Rerank + Kurzbegründung |
|
||||
| **P3** | Skill-Discovery / Framework-Ziele im Pack |
|
||||
|
|
@ -176,6 +188,7 @@ Wenn `hits` leer oder Trainer wählt „Mit KI anlegen“:
|
|||
|
||||
- **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`.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -184,3 +197,84 @@ Wenn `hits` leer oder Trainer wählt „Mit KI anlegen“:
|
|||
- **Ungespeicherte Plan-Änderungen:** API liest DB-Stand der Einheit — offene Formular-Items folgen in P0.1 (Client übergibt `planned_exercise_ids[]`).
|
||||
- **Progressionsgraph-ID:** noch nicht aus UI wählbar (`progression_graph_id` nur per API).
|
||||
- **LLM-Intent / Rerank:** P1/P2 laut Roadmap §9.
|
||||
|
||||
---
|
||||
|
||||
## 12. ExerciseMatchProfile & PlanningTargetProfile (Phase 1)
|
||||
|
||||
Ziel: deterministische Vorselektion über **Profil-Dimensionen** statt nur Titel/Jaccard.
|
||||
|
||||
### 12.1 ExerciseMatchProfile (pro Übung)
|
||||
|
||||
| Feld | Quelle |
|
||||
|------|--------|
|
||||
| `focus_area_ids` | `exercise_focus_areas` (Primary = 1.0, sonst 0.85) |
|
||||
| `style_direction_ids` | `exercise_style_directions` |
|
||||
| `training_type_ids` | `exercise_training_types` |
|
||||
| `target_group_ids` | `exercise_target_groups` |
|
||||
| `skill_weights` | `exercise_skills` × Intensitäts-Multiplikator (`skill_scoring._skill_link_multiplier`) |
|
||||
|
||||
Bulk-Lader: `load_exercise_match_profiles_bulk(cur, exercise_ids)`.
|
||||
|
||||
### 12.2 PlanningTargetProfile (Planungsziel)
|
||||
|
||||
Zusammensetzung aus mehreren Quellen (`sources[]`):
|
||||
|
||||
| Quelle | Inhalt |
|
||||
|--------|--------|
|
||||
| `framework_catalog` | Fokus/Stil/Trainingsstil/Zielgruppe aus `training_framework_program_*` |
|
||||
| `framework_slot_skill_profile` | Skill-Profil des Slot-Blueprints (`profile_for_occurrences`) |
|
||||
| `framework_overall_skill_profile` | Fallback: alle Blueprint-Einheiten des Rahmens |
|
||||
| `current_unit_plan` | Skill-Profil der bereits eingeplanten Übungen dieser Einheit |
|
||||
| `anchor_exercise` | Katalog + Skills der Anker-Übung (Intent-abhängig) |
|
||||
| `skill_gap_vs_plan` | `target_skills − plan_skills` (normalisiert, Schwelle > 0.08) |
|
||||
|
||||
Builder: `build_planning_target_profile(cur, unit=…, planned_exercise_ids=…, anchor_exercise_id=…, intent=…)`.
|
||||
|
||||
Rahmen-Anbindung über `unit.framework_slot_id` oder `origin_framework_slot_id`.
|
||||
|
||||
---
|
||||
|
||||
## 13. Profil-Score (Formeln)
|
||||
|
||||
**Gewichtete Überlappung** (Katalog + Skills):
|
||||
|
||||
```
|
||||
overlap(a, b) = Σ min(a[k], b[k]) / Σ max(a[k], b[k])
|
||||
```
|
||||
|
||||
**Skill-Gap-Abdeckung:**
|
||||
|
||||
```
|
||||
gap_coverage(gap, candidate) = Σ min(gap[k], candidate[k]) / Σ gap[k]
|
||||
```
|
||||
|
||||
**Profil-Score** (intent-gewichtet, Summe Dimensionen = 1.0):
|
||||
|
||||
```
|
||||
profile_score = w_focus * overlap(focus)
|
||||
+ w_style * overlap(style)
|
||||
+ w_tt * overlap(training_type)
|
||||
+ w_tg * overlap(target_group)
|
||||
+ w_skill * overlap(skill_weights)
|
||||
+ w_gap * gap_coverage(skill_gap)
|
||||
```
|
||||
|
||||
Intent-Gewichte (Auszug): `deepen_exercise` → Skill hoch; `continue_plan_goal` → Gap hoch; `free_search` → Gap + Skill moderat.
|
||||
|
||||
Scorer: `score_exercise_against_target(exercise_profile, target_profile, intent=…) → (score, reasons[])`.
|
||||
|
||||
---
|
||||
|
||||
## 14. Hybrid + Profil (P0.1)
|
||||
|
||||
Im Hybrid-Score kommt **`w_profile * profile_score`** hinzu (Intent-abhängig ~0.15–0.35). Jaccard auf Anker-Skills bleibt parallel (schneller Anker-Fokus).
|
||||
|
||||
**Response-Felder:**
|
||||
|
||||
| Feld | Bedeutung |
|
||||
|------|-----------|
|
||||
| `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):** Top 20–40 Kandidaten nach Hybrid+Profil → LLM `planning_exercise_search_rank` mit **Titel + summary + goal**; nur IDs aus Kandidatenliste.
|
||||
|
|
|
|||
448
backend/planning_exercise_profiles.py
Normal file
448
backend/planning_exercise_profiles.py
Normal file
|
|
@ -0,0 +1,448 @@
|
|||
"""
|
||||
ExerciseMatchProfile / PlanningTargetProfile — Phase-1-Vorselektion Planungs-Übungssuche.
|
||||
|
||||
Siehe .claude/docs/working/PLANNING_EXERCISE_SUGGEST_CONTEXT.md §12–§14
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, List, Optional, Sequence, Set, Tuple
|
||||
|
||||
from skill_scoring import (
|
||||
ExerciseOccurrence,
|
||||
collect_unit_exercise_occurrences,
|
||||
fetch_exercise_skills_bulk,
|
||||
profile_for_occurrences,
|
||||
_skill_link_multiplier,
|
||||
DEFAULT_ITEM_MINUTES,
|
||||
)
|
||||
|
||||
|
||||
def _ids_to_weights(ids: Sequence[int], primary_id: Optional[int] = None) -> Dict[int, float]:
|
||||
out: Dict[int, float] = {}
|
||||
for raw in ids or []:
|
||||
try:
|
||||
fid = int(raw)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if fid < 1:
|
||||
continue
|
||||
w = 1.0 if primary_id is not None and fid == int(primary_id) else 0.85
|
||||
out[fid] = max(out.get(fid, 0.0), w)
|
||||
return out
|
||||
|
||||
|
||||
def _merge_weight_maps(*maps: Optional[Dict[int, float]], scale: float = 1.0) -> Dict[int, float]:
|
||||
out: Dict[int, float] = {}
|
||||
for m in maps:
|
||||
if not m:
|
||||
continue
|
||||
for k, v in m.items():
|
||||
try:
|
||||
kid = int(k)
|
||||
val = float(v) * scale
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if kid < 1 or val <= 0:
|
||||
continue
|
||||
out[kid] = max(out.get(kid, 0.0), val)
|
||||
return out
|
||||
|
||||
|
||||
def _normalize_weight_map(m: Dict[int, float]) -> Dict[int, float]:
|
||||
if not m:
|
||||
return {}
|
||||
mx = max(m.values())
|
||||
if mx <= 0:
|
||||
return {}
|
||||
return {k: v / mx for k, v in m.items() if v > 0}
|
||||
|
||||
|
||||
def weighted_overlap(a: Dict[int, float], b: Dict[int, float]) -> float:
|
||||
"""Gewichtete Überlappung 0..1 (min-Summe / max-Summe)."""
|
||||
if not a or not b:
|
||||
return 0.0
|
||||
keys = set(a) | set(b)
|
||||
num = sum(min(a.get(k, 0.0), b.get(k, 0.0)) for k in keys)
|
||||
den = sum(max(a.get(k, 0.0), b.get(k, 0.0)) for k in keys)
|
||||
return num / den if den > 0 else 0.0
|
||||
|
||||
|
||||
def gap_coverage(gap: Dict[int, float], candidate: Dict[int, float]) -> float:
|
||||
"""Anteil der Skill-Lücke, den der Kandidat abdeckt (0..1)."""
|
||||
if not gap:
|
||||
return 0.0
|
||||
total_gap = sum(gap.values())
|
||||
if total_gap <= 0:
|
||||
return 0.0
|
||||
covered = sum(min(gap.get(k, 0.0), candidate.get(k, 0.0)) for k in gap)
|
||||
return covered / total_gap
|
||||
|
||||
|
||||
@dataclass
|
||||
class ExerciseMatchProfile:
|
||||
exercise_id: int
|
||||
focus_area_ids: Dict[int, float] = field(default_factory=dict)
|
||||
style_direction_ids: Dict[int, float] = field(default_factory=dict)
|
||||
training_type_ids: Dict[int, float] = field(default_factory=dict)
|
||||
target_group_ids: Dict[int, float] = field(default_factory=dict)
|
||||
skill_weights: Dict[int, float] = field(default_factory=dict)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"exercise_id": self.exercise_id,
|
||||
"focus_area_ids": self.focus_area_ids,
|
||||
"style_direction_ids": self.style_direction_ids,
|
||||
"training_type_ids": self.training_type_ids,
|
||||
"target_group_ids": self.target_group_ids,
|
||||
"skill_weights": self.skill_weights,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class PlanningTargetProfile:
|
||||
focus_area_ids: Dict[int, float] = field(default_factory=dict)
|
||||
style_direction_ids: Dict[int, float] = field(default_factory=dict)
|
||||
training_type_ids: Dict[int, float] = field(default_factory=dict)
|
||||
target_group_ids: Dict[int, float] = field(default_factory=dict)
|
||||
skill_weights: Dict[int, float] = field(default_factory=dict)
|
||||
skill_gap_weights: Dict[int, float] = field(default_factory=dict)
|
||||
sources: List[str] = field(default_factory=list)
|
||||
|
||||
def to_summary_dict(self, cur, limit_skills: int = 5) -> Dict[str, Any]:
|
||||
focus_labels = _load_focus_labels(cur, list(self.focus_area_ids.keys())[:6])
|
||||
top_skills = sorted(self.skill_weights.items(), key=lambda x: -x[1])[:limit_skills]
|
||||
skill_names = _load_skill_names(cur, [s[0] for s in top_skills])
|
||||
return {
|
||||
"sources": list(self.sources),
|
||||
"focus_areas": focus_labels,
|
||||
"top_skills": [
|
||||
{"skill_id": sid, "name": skill_names.get(sid, f"#{sid}"), "weight": round(w, 2)}
|
||||
for sid, w in top_skills
|
||||
],
|
||||
"has_skill_gap": bool(self.skill_gap_weights),
|
||||
}
|
||||
|
||||
|
||||
def _load_focus_labels(cur, ids: Sequence[int]) -> List[str]:
|
||||
if not ids:
|
||||
return []
|
||||
ph = ",".join(["%s"] * len(ids))
|
||||
cur.execute(
|
||||
f"SELECT id, name FROM focus_areas WHERE id IN ({ph}) ORDER BY name",
|
||||
list(ids),
|
||||
)
|
||||
return [f"{r['name'] or r['id']}" for r in cur.fetchall()]
|
||||
|
||||
|
||||
def _load_skill_names(cur, ids: Sequence[int]) -> Dict[int, str]:
|
||||
if not ids:
|
||||
return {}
|
||||
ph = ",".join(["%s"] * len(ids))
|
||||
cur.execute(f"SELECT id, name FROM skills WHERE id IN ({ph})", list(ids))
|
||||
return {int(r["id"]): str(r["name"] or "") for r in cur.fetchall()}
|
||||
|
||||
|
||||
def _skill_weights_from_profile(skills_out: Sequence[Dict[str, Any]]) -> Dict[int, float]:
|
||||
out: Dict[int, float] = {}
|
||||
for row in skills_out or []:
|
||||
sid = row.get("skill_id")
|
||||
if sid is None:
|
||||
continue
|
||||
w = float(row.get("weight") or row.get("score") or 0)
|
||||
if w > 0:
|
||||
out[int(sid)] = w
|
||||
return out
|
||||
|
||||
|
||||
def _single_exercise_skill_weights(
|
||||
skill_rows: Sequence[Dict[str, Any]],
|
||||
*,
|
||||
minutes: float = DEFAULT_ITEM_MINUTES,
|
||||
) -> Dict[int, float]:
|
||||
out: Dict[int, float] = {}
|
||||
for link in skill_rows or []:
|
||||
sid = link.get("skill_id")
|
||||
if sid is None:
|
||||
continue
|
||||
sid = int(sid)
|
||||
mult = _skill_link_multiplier(
|
||||
intensity=link.get("intensity"),
|
||||
required_level=link.get("required_level"),
|
||||
target_level=link.get("target_level"),
|
||||
)
|
||||
w = minutes * mult
|
||||
if w > 0:
|
||||
out[sid] = out.get(sid, 0.0) + w
|
||||
return out
|
||||
|
||||
|
||||
def _load_relation_maps_bulk(
|
||||
cur,
|
||||
exercise_ids: Sequence[int],
|
||||
table: str,
|
||||
id_column: str,
|
||||
) -> Dict[int, Dict[int, float]]:
|
||||
ids = [int(x) for x in exercise_ids if int(x) > 0]
|
||||
if not ids:
|
||||
return {}
|
||||
ph = ",".join(["%s"] * len(ids))
|
||||
cur.execute(
|
||||
f"""
|
||||
SELECT exercise_id, {id_column} AS rel_id, is_primary
|
||||
FROM {table}
|
||||
WHERE exercise_id IN ({ph})
|
||||
""",
|
||||
ids,
|
||||
)
|
||||
out: Dict[int, Dict[int, float]] = {eid: {} for eid in ids}
|
||||
for row in cur.fetchall():
|
||||
eid = int(row["exercise_id"])
|
||||
rid = int(row["rel_id"])
|
||||
w = 1.0 if row.get("is_primary") else 0.85
|
||||
out.setdefault(eid, {})[rid] = max(out[eid].get(rid, 0.0), w)
|
||||
return out
|
||||
|
||||
|
||||
def load_exercise_match_profiles_bulk(cur, exercise_ids: Sequence[int]) -> Dict[int, ExerciseMatchProfile]:
|
||||
ids = sorted({int(x) for x in exercise_ids if int(x) > 0})
|
||||
if not ids:
|
||||
return {}
|
||||
|
||||
focus_map = _load_relation_maps_bulk(cur, ids, "exercise_focus_areas", "focus_area_id")
|
||||
style_map = _load_relation_maps_bulk(cur, ids, "exercise_style_directions", "style_direction_id")
|
||||
type_map = _load_relation_maps_bulk(cur, ids, "exercise_training_types", "training_type_id")
|
||||
tg_map = _load_relation_maps_bulk(cur, ids, "exercise_target_groups", "target_group_id")
|
||||
skills_bulk = fetch_exercise_skills_bulk(cur, ids)
|
||||
|
||||
profiles: Dict[int, ExerciseMatchProfile] = {}
|
||||
for eid in ids:
|
||||
profiles[eid] = ExerciseMatchProfile(
|
||||
exercise_id=eid,
|
||||
focus_area_ids=focus_map.get(eid, {}),
|
||||
style_direction_ids=style_map.get(eid, {}),
|
||||
training_type_ids=type_map.get(eid, {}),
|
||||
target_group_ids=tg_map.get(eid, {}),
|
||||
skill_weights=_single_exercise_skill_weights(skills_bulk.get(eid, [])),
|
||||
)
|
||||
return profiles
|
||||
|
||||
|
||||
def _resolve_framework_for_unit(cur, unit: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||
slot_id = unit.get("framework_slot_id") or unit.get("origin_framework_slot_id")
|
||||
if not slot_id:
|
||||
return None
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT s.id AS slot_id, s.framework_program_id, s.sort_order, s.title AS slot_title,
|
||||
fp.title AS framework_title, fp.focus_area_id AS header_focus_area_id
|
||||
FROM training_framework_slots s
|
||||
JOIN training_framework_programs fp ON fp.id = s.framework_program_id
|
||||
WHERE s.id = %s
|
||||
""",
|
||||
(int(slot_id),),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
|
||||
def _framework_catalog_weights(cur, framework_id: int) -> Tuple[Dict[int, float], Dict[int, float], Dict[int, float], Dict[int, float]]:
|
||||
cur.execute(
|
||||
"SELECT focus_area_id FROM training_framework_programs WHERE id = %s",
|
||||
(framework_id,),
|
||||
)
|
||||
hdr = cur.fetchone()
|
||||
header_fa = int(hdr["focus_area_id"]) if hdr and hdr.get("focus_area_id") else None
|
||||
|
||||
cur.execute(
|
||||
"SELECT focus_area_id FROM training_framework_program_focus_areas WHERE framework_program_id = %s",
|
||||
(framework_id,),
|
||||
)
|
||||
fa_ids = [int(r["focus_area_id"]) for r in cur.fetchall()]
|
||||
if header_fa and header_fa not in fa_ids:
|
||||
fa_ids.insert(0, header_fa)
|
||||
focus = _ids_to_weights(fa_ids, primary_id=header_fa)
|
||||
|
||||
cur.execute(
|
||||
"SELECT style_direction_id FROM training_framework_program_style_directions WHERE framework_program_id = %s",
|
||||
(framework_id,),
|
||||
)
|
||||
style = _ids_to_weights([int(r["style_direction_id"]) for r in cur.fetchall()])
|
||||
|
||||
cur.execute(
|
||||
"SELECT training_type_id FROM training_framework_program_training_types WHERE framework_program_id = %s",
|
||||
(framework_id,),
|
||||
)
|
||||
tt = _ids_to_weights([int(r["training_type_id"]) for r in cur.fetchall()])
|
||||
|
||||
cur.execute(
|
||||
"SELECT target_group_id FROM training_framework_program_target_groups WHERE framework_program_id = %s",
|
||||
(framework_id,),
|
||||
)
|
||||
tg = _ids_to_weights([int(r["target_group_id"]) for r in cur.fetchall()])
|
||||
|
||||
return focus, style, tt, tg
|
||||
|
||||
|
||||
def _profile_from_unit_occurrences(cur, unit_id: int) -> Dict[int, float]:
|
||||
occ = collect_unit_exercise_occurrences(cur, int(unit_id))
|
||||
if not occ:
|
||||
return {}
|
||||
prof = profile_for_occurrences(cur, occ, reference_max_by_skill=None)
|
||||
return _skill_weights_from_profile(prof.get("skills") or [])
|
||||
|
||||
|
||||
def build_planning_target_profile(
|
||||
cur,
|
||||
*,
|
||||
unit: Dict[str, Any],
|
||||
planned_exercise_ids: Sequence[int],
|
||||
anchor_exercise_id: Optional[int],
|
||||
intent: str,
|
||||
) -> PlanningTargetProfile:
|
||||
sources: List[str] = []
|
||||
focus: Dict[int, float] = {}
|
||||
style: Dict[int, float] = {}
|
||||
tt: Dict[int, float] = {}
|
||||
tg: Dict[int, float] = {}
|
||||
skill_target: Dict[int, float] = {}
|
||||
skill_plan: Dict[int, float] = {}
|
||||
|
||||
fw = _resolve_framework_for_unit(cur, unit)
|
||||
if fw:
|
||||
fid = int(fw["framework_program_id"])
|
||||
f_focus, f_style, f_tt, f_tg = _framework_catalog_weights(cur, fid)
|
||||
focus = _merge_weight_maps(focus, f_focus)
|
||||
style = _merge_weight_maps(style, f_style)
|
||||
tt = _merge_weight_maps(tt, f_tt)
|
||||
tg = _merge_weight_maps(tg, f_tg)
|
||||
sources.append("framework_catalog")
|
||||
|
||||
slot_id = fw.get("slot_id")
|
||||
cur.execute(
|
||||
"SELECT id FROM training_units WHERE framework_slot_id = %s LIMIT 1",
|
||||
(int(slot_id),),
|
||||
)
|
||||
bp = cur.fetchone()
|
||||
if bp and bp.get("id"):
|
||||
slot_skills = _profile_from_unit_occurrences(cur, int(bp["id"]))
|
||||
if slot_skills:
|
||||
skill_target = _merge_weight_maps(skill_target, slot_skills, scale=1.0)
|
||||
sources.append("framework_slot_skill_profile")
|
||||
if not skill_target:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT tu.id FROM training_framework_slots s
|
||||
LEFT JOIN training_units tu ON tu.framework_slot_id = s.id
|
||||
WHERE s.framework_program_id = %s AND tu.id IS NOT NULL
|
||||
""",
|
||||
(fid,),
|
||||
)
|
||||
all_occ: List[ExerciseOccurrence] = []
|
||||
for r in cur.fetchall():
|
||||
all_occ.extend(collect_unit_exercise_occurrences(cur, int(r["id"])))
|
||||
if all_occ:
|
||||
prof = profile_for_occurrences(cur, all_occ, reference_max_by_skill=None)
|
||||
skill_target = _merge_weight_maps(
|
||||
skill_target, _skill_weights_from_profile(prof.get("skills") or []), scale=0.85
|
||||
)
|
||||
sources.append("framework_overall_skill_profile")
|
||||
|
||||
if planned_exercise_ids:
|
||||
occ = [ExerciseOccurrence(exercise_id=int(eid)) for eid in planned_exercise_ids]
|
||||
prof = profile_for_occurrences(cur, occ, reference_max_by_skill=None)
|
||||
skill_plan = _skill_weights_from_profile(prof.get("skills") or [])
|
||||
if skill_plan:
|
||||
sources.append("current_unit_plan")
|
||||
|
||||
if anchor_exercise_id:
|
||||
anchor_profiles = load_exercise_match_profiles_bulk(cur, [int(anchor_exercise_id)])
|
||||
ap = anchor_profiles.get(int(anchor_exercise_id))
|
||||
if ap:
|
||||
if intent in ("deepen_exercise", "suggest_next", "progression_next", "continue_plan_goal"):
|
||||
skill_target = _merge_weight_maps(skill_target, ap.skill_weights, scale=1.0)
|
||||
focus = _merge_weight_maps(focus, ap.focus_area_ids, scale=0.9)
|
||||
style = _merge_weight_maps(style, ap.style_direction_ids, scale=0.75)
|
||||
tt = _merge_weight_maps(tt, ap.training_type_ids, scale=0.75)
|
||||
tg = _merge_weight_maps(tg, ap.target_group_ids, scale=0.75)
|
||||
sources.append("anchor_exercise")
|
||||
|
||||
skill_target = _normalize_weight_map(skill_target)
|
||||
skill_plan_norm = _normalize_weight_map(skill_plan)
|
||||
skill_gap: Dict[int, float] = {}
|
||||
for sid, tw in skill_target.items():
|
||||
pw = skill_plan_norm.get(sid, 0.0)
|
||||
gap = tw - pw * 0.85
|
||||
if gap > 0.08:
|
||||
skill_gap[sid] = gap
|
||||
if skill_gap:
|
||||
sources.append("skill_gap_vs_plan")
|
||||
|
||||
return PlanningTargetProfile(
|
||||
focus_area_ids=_normalize_weight_map(focus) if focus else focus,
|
||||
style_direction_ids=_normalize_weight_map(style) if style else style,
|
||||
training_type_ids=_normalize_weight_map(tt) if tt else tt,
|
||||
target_group_ids=_normalize_weight_map(tg) if tg else tg,
|
||||
skill_weights=skill_target,
|
||||
skill_gap_weights=_normalize_weight_map(skill_gap) if skill_gap else skill_gap,
|
||||
sources=sources,
|
||||
)
|
||||
|
||||
|
||||
def score_exercise_against_target(
|
||||
exercise: ExerciseMatchProfile,
|
||||
target: PlanningTargetProfile,
|
||||
*,
|
||||
intent: str,
|
||||
) -> Tuple[float, List[str]]:
|
||||
"""Profil-Match 0..1 + deutschsprachige Gründe."""
|
||||
reasons: List[str] = []
|
||||
|
||||
focus_sim = weighted_overlap(exercise.focus_area_ids, target.focus_area_ids)
|
||||
style_sim = weighted_overlap(exercise.style_direction_ids, target.style_direction_ids)
|
||||
tt_sim = weighted_overlap(exercise.training_type_ids, target.training_type_ids)
|
||||
tg_sim = weighted_overlap(exercise.target_group_ids, target.target_group_ids)
|
||||
skill_sim = weighted_overlap(
|
||||
_normalize_weight_map(exercise.skill_weights),
|
||||
target.skill_weights,
|
||||
)
|
||||
gap_sim = gap_coverage(target.skill_gap_weights, _normalize_weight_map(exercise.skill_weights))
|
||||
|
||||
if focus_sim >= 0.5 and target.focus_area_ids:
|
||||
reasons.append("Fokusbereich passend zum Planungsziel")
|
||||
if style_sim >= 0.5 and target.style_direction_ids:
|
||||
reasons.append("Stilrichtung passend")
|
||||
if tt_sim >= 0.5 and target.training_type_ids:
|
||||
reasons.append("Trainingsstil passend")
|
||||
if tg_sim >= 0.5 and target.target_group_ids:
|
||||
reasons.append("Zielgruppe passend")
|
||||
if skill_sim >= 0.35 and target.skill_weights:
|
||||
reasons.append("Fähigkeiten-Schwerpunkt passend (Profilmetrik)")
|
||||
if gap_sim >= 0.25 and target.skill_gap_weights:
|
||||
reasons.append("Deckt Skill-Lücke im bisherigen Plan")
|
||||
|
||||
# Intent-gewichtete Dimensionen (Summe = 1.0)
|
||||
if intent == INTENT_FREE_SEARCH:
|
||||
weights = {"focus": 0.15, "style": 0.10, "tt": 0.10, "tg": 0.10, "skill": 0.25, "gap": 0.30}
|
||||
elif intent == INTENT_DEEPEN_EXERCISE:
|
||||
weights = {"focus": 0.15, "style": 0.10, "tt": 0.10, "tg": 0.05, "skill": 0.45, "gap": 0.15}
|
||||
elif intent == INTENT_PROGRESSION_NEXT:
|
||||
weights = {"focus": 0.20, "style": 0.10, "tt": 0.10, "tg": 0.05, "skill": 0.35, "gap": 0.20}
|
||||
else:
|
||||
weights = {"focus": 0.20, "style": 0.10, "tt": 0.10, "tg": 0.10, "skill": 0.30, "gap": 0.20}
|
||||
|
||||
score = (
|
||||
weights["focus"] * focus_sim
|
||||
+ weights["style"] * style_sim
|
||||
+ weights["tt"] * tt_sim
|
||||
+ weights["tg"] * tg_sim
|
||||
+ weights["skill"] * skill_sim
|
||||
+ weights["gap"] * gap_sim
|
||||
)
|
||||
return max(0.0, min(1.0, score)), reasons
|
||||
|
||||
|
||||
# Re-export intent constants for typing (avoid circular import at runtime in suggest module)
|
||||
INTENT_FREE_SEARCH = "free_search"
|
||||
INTENT_DEEPEN_EXERCISE = "deepen_exercise"
|
||||
INTENT_PROGRESSION_NEXT = "progression_next"
|
||||
|
|
@ -11,8 +11,12 @@ from typing import Any, Dict, List, Optional, Sequence, Set, Tuple
|
|||
from fastapi import HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from tenant_context import TenantContext
|
||||
from club_tenancy import library_content_visibility_sql
|
||||
from tenant_context import TenantContext, library_content_visibility_sql
|
||||
from planning_exercise_profiles import (
|
||||
build_planning_target_profile,
|
||||
load_exercise_match_profiles_bulk,
|
||||
score_exercise_against_target,
|
||||
)
|
||||
|
||||
# Planungs-Berechtigung + Sektionen (bestehende Implementierung)
|
||||
from routers.training_planning import (
|
||||
|
|
@ -71,23 +75,31 @@ def resolve_planning_exercise_intent(query: Optional[str], intent_hint: Optional
|
|||
|
||||
def _intent_weights(intent: str) -> Dict[str, float]:
|
||||
base = {
|
||||
"fulltext": 0.25,
|
||||
"progression": 0.25,
|
||||
"skill": 0.20,
|
||||
"plan": 0.10,
|
||||
"fulltext": 0.18,
|
||||
"progression": 0.18,
|
||||
"skill": 0.12,
|
||||
"plan": 0.08,
|
||||
"profile": 0.22,
|
||||
"repeat_unit": -0.30,
|
||||
"repeat_group": -0.15,
|
||||
}
|
||||
if intent == INTENT_SUGGEST_NEXT:
|
||||
return {**base, "progression": 0.35, "skill": 0.25, "plan": 0.15, "fulltext": 0.10}
|
||||
return {
|
||||
**base,
|
||||
"progression": 0.28,
|
||||
"skill": 0.12,
|
||||
"plan": 0.10,
|
||||
"profile": 0.25,
|
||||
"fulltext": 0.08,
|
||||
}
|
||||
if intent == INTENT_PROGRESSION_NEXT:
|
||||
return {**base, "progression": 0.50, "fulltext": 0.15, "skill": 0.15}
|
||||
return {**base, "progression": 0.42, "fulltext": 0.12, "skill": 0.10, "profile": 0.20}
|
||||
if intent == INTENT_DEEPEN_EXERCISE:
|
||||
return {**base, "skill": 0.40, "fulltext": 0.20, "progression": 0.15}
|
||||
return {**base, "skill": 0.15, "profile": 0.35, "fulltext": 0.15, "progression": 0.10}
|
||||
if intent == INTENT_CONTINUE_PLAN:
|
||||
return {**base, "plan": 0.30, "skill": 0.25, "fulltext": 0.15, "progression": 0.10}
|
||||
return {**base, "plan": 0.12, "skill": 0.10, "profile": 0.30, "fulltext": 0.10, "progression": 0.08}
|
||||
if intent == INTENT_FREE_SEARCH:
|
||||
return {**base, "fulltext": 0.55, "progression": 0.10, "skill": 0.10}
|
||||
return {**base, "fulltext": 0.45, "progression": 0.08, "skill": 0.08, "profile": 0.15}
|
||||
return base
|
||||
|
||||
|
||||
|
|
@ -287,6 +299,11 @@ def build_planning_exercise_context_pack(
|
|||
|
||||
return {
|
||||
"unit_id": int(body.unit_id),
|
||||
"unit": {
|
||||
"id": int(body.unit_id),
|
||||
"framework_slot_id": unit.get("framework_slot_id"),
|
||||
"origin_framework_slot_id": unit.get("origin_framework_slot_id"),
|
||||
},
|
||||
"unit_title": (unit.get("title") or unit.get("planned_focus") or "").strip() or None,
|
||||
"group_id": unit.get("group_id"),
|
||||
"group_name": (unit.get("group_name") or "").strip() or None,
|
||||
|
|
@ -313,6 +330,14 @@ def suggest_planning_exercises(
|
|||
query = _normalize_query(body.query)
|
||||
intent = resolve_planning_exercise_intent(query, body.intent_hint)
|
||||
weights = _intent_weights(intent)
|
||||
target_profile = build_planning_target_profile(
|
||||
cur,
|
||||
unit=pack["unit"],
|
||||
planned_exercise_ids=pack["planned_exercise_ids"],
|
||||
anchor_exercise_id=pack.get("anchor_exercise_id"),
|
||||
intent=intent,
|
||||
)
|
||||
target_profile_summary = target_profile.to_summary_dict(cur)
|
||||
|
||||
profile_id = tenant.profile_id
|
||||
role = tenant.global_role
|
||||
|
|
@ -374,9 +399,10 @@ def suggest_planning_exercises(
|
|||
if pack["planned_exercise_ids"]:
|
||||
last_planned_skills = _load_skill_ids_for_exercise(cur, pack["planned_exercise_ids"][-1])
|
||||
|
||||
# Skill-IDs pro Kandidat (Batch)
|
||||
# Skill-IDs + ExerciseMatchProfile pro Kandidat (Batch)
|
||||
cand_ids = [int(r["id"]) for r in rows]
|
||||
skills_by_ex: Dict[int, Set[int]] = {cid: set() for cid in cand_ids}
|
||||
match_profiles = load_exercise_match_profiles_bulk(cur, cand_ids)
|
||||
if cand_ids:
|
||||
ph = ",".join(["%s"] * len(cand_ids))
|
||||
cur.execute(
|
||||
|
|
@ -416,12 +442,20 @@ def suggest_planning_exercises(
|
|||
plan_aff = _skill_jaccard(last_planned_skills, item["skills"])
|
||||
repeat_unit = 1.0 if eid in planned_set else 0.0
|
||||
repeat_group = 1.0 if eid in group_recent_set else 0.0
|
||||
profile_score = 0.0
|
||||
profile_reasons: List[str] = []
|
||||
emp = match_profiles.get(eid)
|
||||
if emp:
|
||||
profile_score, profile_reasons = score_exercise_against_target(
|
||||
emp, target_profile, intent=intent
|
||||
)
|
||||
|
||||
score = (
|
||||
weights["fulltext"] * ft_norm
|
||||
+ weights["progression"] * prog_hit
|
||||
+ weights["skill"] * skill_sim
|
||||
+ weights["plan"] * plan_aff
|
||||
+ weights["profile"] * profile_score
|
||||
+ weights["repeat_unit"] * repeat_unit
|
||||
+ weights["repeat_group"] * repeat_group
|
||||
)
|
||||
|
|
@ -442,11 +476,14 @@ def suggest_planning_exercises(
|
|||
reasons.append("Bereits in dieser Einheit eingeplant")
|
||||
if repeat_group > 0 and repeat_unit <= 0:
|
||||
reasons.append("Kürzlich in der Gruppe verwendet")
|
||||
for pr in profile_reasons:
|
||||
if pr not in reasons:
|
||||
reasons.append(pr)
|
||||
|
||||
if score <= 0 and not reasons and not query:
|
||||
# Leere Query: trotzdem schwache Kandidaten mit Skill/Progression
|
||||
if prog_hit or skill_sim or plan_aff:
|
||||
score = 0.05 + prog_hit * 0.3 + skill_sim * 0.2
|
||||
if prog_hit or skill_sim or plan_aff or profile_score:
|
||||
score = 0.05 + prog_hit * 0.3 + skill_sim * 0.2 + profile_score * 0.25
|
||||
|
||||
hits.append(
|
||||
{
|
||||
|
|
@ -474,6 +511,8 @@ def suggest_planning_exercises(
|
|||
|
||||
return {
|
||||
"context_summary": context_summary,
|
||||
"target_profile_summary": target_profile_summary,
|
||||
"retrieval_phase": "profile_v1",
|
||||
"intent_resolved": intent,
|
||||
"query_normalized": query or None,
|
||||
"hits": hits,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# Shinkan Jinkendo Version Information
|
||||
|
||||
APP_VERSION = "0.8.167"
|
||||
APP_VERSION = "0.8.169"
|
||||
BUILD_DATE = "2026-05-22"
|
||||
DB_SCHEMA_VERSION = "20260531071"
|
||||
|
||||
|
|
@ -27,8 +27,8 @@ MODULE_VERSIONS = {
|
|||
"skills": "0.1.1", # DB 065 karate_relevance + relevance_level; CRUD unterstützt Felder
|
||||
"skill_profiles": "1.0.0", # Phase 3: gewichtetes Fähigkeiten-Profil + skill-discovery/suggestions
|
||||
"methods": "0.1.0",
|
||||
"exercises": "2.34.0", # Planungs-KI P0: POST /planning/exercise-suggest; Picker Kontext
|
||||
"planning_exercise_suggest": "0.1.0", # Kontext-Pack + Hybrid-Retrieval Übungssuche
|
||||
"exercises": "2.35.0", # Planungs-KI P0.1: Profil-Score profile_v1 + target_profile_summary
|
||||
"planning_exercise_suggest": "0.2.1", # Fix Import library_content_visibility_sql aus tenant_context
|
||||
"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
|
||||
|
|
@ -43,6 +43,21 @@ MODULE_VERSIONS = {
|
|||
}
|
||||
|
||||
CHANGELOG = [
|
||||
{
|
||||
"version": "0.8.169",
|
||||
"date": "2026-05-22",
|
||||
"changes": [
|
||||
"Fix: planning_exercise_suggest Import library_content_visibility_sql aus tenant_context (Backend-Start/502).",
|
||||
],
|
||||
},
|
||||
{
|
||||
"version": "0.8.168",
|
||||
"date": "2026-05-22",
|
||||
"changes": [
|
||||
"Planungs-KI P0.1: ExerciseMatchProfile + PlanningTargetProfile — Profil-Score (Fokus, Stil, Skills, Gap) im Hybrid-Retrieval.",
|
||||
"API exercise-suggest: retrieval_phase profile_v1, target_profile_summary; Doku §12–§14.",
|
||||
],
|
||||
},
|
||||
{
|
||||
"version": "0.8.167",
|
||||
"date": "2026-05-22",
|
||||
|
|
|
|||
|
|
@ -89,10 +89,10 @@ 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.167**)
|
||||
### 2.8 KI Assistenz Übungen & Skill-Katalog-Retrieval (Stand **0.8.168**)
|
||||
|
||||
- **Zielarchitektur (Pflicht fuer Erweiterungen):** `.claude/docs/technical/AI_PROMPT_TARGET_ARCHITECTURE.md` — Kontext-Arten, Composition, Einbindung Planung/Rahmen; Phasenplan P0–P4.
|
||||
- **Planungs-Übungssuche (P0):** `.claude/docs/working/PLANNING_EXERCISE_SUGGEST_CONTEXT.md` — Intent-Typen, Context-Pack, Hybrid-Retrieval; **`POST /api/planning/exercise-suggest`**; Frontend **`ExercisePickerModal`** + **`planningContext`** aus **`TrainingUnitEditPage`**.
|
||||
- **Planungs-Übungssuche (P0.1):** `.claude/docs/working/PLANNING_EXERCISE_SUGGEST_CONTEXT.md` — Context-Pack, Hybrid-Retrieval + **Profil-Score** (`profile_v1`, `ExerciseMatchProfile` / `PlanningTargetProfile`); **`POST /api/planning/exercise-suggest`**; Frontend **`ExercisePickerModal`** + **`planningContext`** aus **`TrainingUnitEditPage`**.
|
||||
- **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`**
|
||||
|
|
|
|||
|
|
@ -67,6 +67,7 @@ export default function ExercisePickerModal({
|
|||
const [quickAiError, setQuickAiError] = useState('')
|
||||
const [quickCreateDraft, setQuickCreateDraft] = useState(null)
|
||||
const [planningContextSummary, setPlanningContextSummary] = useState(null)
|
||||
const [planningTargetProfileSummary, setPlanningTargetProfileSummary] = useState(null)
|
||||
const [planningIntentResolved, setPlanningIntentResolved] = useState(null)
|
||||
const pickerScrollRef = useRef(null)
|
||||
|
||||
|
|
@ -153,6 +154,7 @@ export default function ExercisePickerModal({
|
|||
setQuickAiError('')
|
||||
setQuickCreateDraft(null)
|
||||
setPlanningContextSummary(null)
|
||||
setPlanningTargetProfileSummary(null)
|
||||
setPlanningIntentResolved(null)
|
||||
return
|
||||
}
|
||||
|
|
@ -276,6 +278,7 @@ export default function ExercisePickerModal({
|
|||
Array.isArray(exerciseKindAny) && exerciseKindAny.length > 0 ? exerciseKindAny : undefined,
|
||||
})
|
||||
setPlanningContextSummary(res?.context_summary || null)
|
||||
setPlanningTargetProfileSummary(res?.target_profile_summary || null)
|
||||
setPlanningIntentResolved(res?.intent_resolved || null)
|
||||
const hits = (Array.isArray(res?.hits) ? res.hits : []).map((h) => ({
|
||||
id: h.id,
|
||||
|
|
@ -290,6 +293,7 @@ export default function ExercisePickerModal({
|
|||
setHasMore(false)
|
||||
} else {
|
||||
setPlanningContextSummary(null)
|
||||
setPlanningTargetProfileSummary(null)
|
||||
setPlanningIntentResolved(null)
|
||||
const batch = await api.listExercises({
|
||||
...queryBase,
|
||||
|
|
@ -307,6 +311,7 @@ export default function ExercisePickerModal({
|
|||
setList([])
|
||||
setHasMore(false)
|
||||
setPlanningContextSummary(null)
|
||||
setPlanningTargetProfileSummary(null)
|
||||
setPlanningIntentResolved(null)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
|
|
@ -508,7 +513,28 @@ export default function ExercisePickerModal({
|
|||
Anker: {planningContextSummary.anchor_title}
|
||||
</span>
|
||||
) : null}
|
||||
{Array.isArray(planningTargetProfileSummary?.focus_areas) &&
|
||||
planningTargetProfileSummary.focus_areas.length > 0
|
||||
? planningTargetProfileSummary.focus_areas.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, 3).map((sk) => (
|
||||
<span key={sk.skill_id} className="exercise-tag">
|
||||
{sk.name}
|
||||
</span>
|
||||
))
|
||||
: null}
|
||||
</div>
|
||||
{planningTargetProfileSummary?.has_skill_gap ? (
|
||||
<p style={{ margin: '6px 0 0', fontSize: '11px', color: 'var(--text3)' }}>
|
||||
Skill-Lücke zum bisherigen Plan berücksichtigt
|
||||
</p>
|
||||
) : null}
|
||||
{planningIntentResolved ? (
|
||||
<p style={{ margin: '6px 0 0', fontSize: '11px', color: 'var(--text3)' }}>
|
||||
Modus: {planningIntentResolved.replace(/_/g, ' ')}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user