Compare commits
17 Commits
fc5748bef1
...
9ba35dc022
| Author | SHA1 | Date | |
|---|---|---|---|
| 9ba35dc022 | |||
| 46fae3da33 | |||
| f4196c3580 | |||
| d1d8539b42 | |||
| a8633235f2 | |||
| 5c882985e0 | |||
| 04cc77d501 | |||
| 8e68261bc1 | |||
| b0611b9f7f | |||
| 614c2dcfaa | |||
| f5c886fc13 | |||
| d019c20338 | |||
| 905bce198f | |||
| 45e3b5f4f6 | |||
| 207817376d | |||
| 128a9d752e | |||
| d7d45a8927 |
|
|
@ -37,17 +37,19 @@ Fortlaufend gemäß `ACCESS_LAYER_AND_GOVERNANCE_PLAN.md` Stufe A–C.
|
|||
| import_wiki / import_wiki_admin | Wiki-Import | Werkzeug | `require_auth`/Admin | Admin | EXEMPT |
|
||||
| ai_skill_retrieval_admin | `/api/admin/ai-skill-retrieval-profiles*` (CRUD) | Plattform | `require_auth` | nur `superadmin`; JSON `config` | EXEMPT wie `admin_users`; kein Vereinsbezug |
|
||||
| ai_prompts_admin | `/api/admin/ai-prompts*` (Liste, Detail, PUT, Preview, Reset) | Plattform | `require_auth` | nur `superadmin` | EXEMPT; globale `ai_prompts` ohne Mandantenkontext |
|
||||
| exercise_enrichment_admin | `/api/admin/exercise-enrichment/*` (Kandidaten, Preview, Apply) | Plattform | `require_auth` | nur `superadmin` | EXEMPT; plattformweite Übungsliste + Skill-Schreibung; kein TenantContext |
|
||||
|
||||
**Legende:** Router auf der EXEMPT-Liste des Scripts sind globale oder Auth-only-Pfade; sobald ein Router Vereinsdaten oder Bibliotheks-Sichtbarkeit erhält, EXEMPT entfernen und `get_tenant_context` einführen.
|
||||
|
||||
**Pflege / Drift:** Änderungen an Mandanten, Governance (`visibility`/`club_id`) oder neuen inhaltsbezogenen Endpoints → eine Zeile in dieser Tabelle anpassen und `PRODUCTION_READINESS_AUDIT_2026-05.md` prüfen.
|
||||
|
||||
Letzte Änderung: 2026-05-30 — Superadmin `/api/admin/ai-prompts*` (Prompt-Pflege, Vorschau ohne OpenRouter); weiterhin suggest + Retrieval-Profile.
|
||||
Letzte Änderung: 2026-05-23 — Superadmin `/api/admin/exercise-enrichment/*` (Batch-KI Skills, Status in_review).
|
||||
|
||||
---
|
||||
|
||||
### Changelog (Fortführung)
|
||||
|
||||
- **2026-05-23:** Superadmin-API `exercise_enrichment_admin` (Batch-Übungs-Anreicherung KI) dokumentiert.
|
||||
- **2026-05-30:** Superadmin-API `ai_prompts_admin` (`/api/admin/ai-prompts*`) dokumentiert.
|
||||
- **2026-05-29:** Superadmin-API `ai_skill_retrieval_admin` (Retrieval-Profile) dokumentiert.
|
||||
- **2026-05-22:** Übungs-KI-Endpunkte (Suggest/Regenerate) dokumentiert.
|
||||
|
|
|
|||
68
.claude/docs/working/EXERCISE_ENRICHMENT_ADMIN.md
Normal file
68
.claude/docs/working/EXERCISE_ENRICHMENT_ADMIN.md
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
# Superadmin: Übungs-Anreicherung per KI
|
||||
|
||||
Stand: 2026-05-23 · App 0.8.178
|
||||
|
||||
## Zweck
|
||||
|
||||
Plattform-weites Werkzeug für Superadmins, um Übungen (typisch `draft`, ohne Skills) **batchweise** per KI mit Fähigkeiten anzureichern und kontrolliert auf `in_review` zu setzen.
|
||||
|
||||
Verbessert indirekt die Planungs-KI (`POST /api/planning/exercise-suggest`), die gegen Skill-Profile rankt — unvollständige `exercise_skills` führen dort zu Volltext-dominiertem Ranking.
|
||||
|
||||
## UI
|
||||
|
||||
- Route: `/admin/exercise-enrichment` (nur Superadmin)
|
||||
- Admin-Menü: „Übungs-Anreicherung“
|
||||
|
||||
## API
|
||||
|
||||
Prefix: `/api/admin/exercise-enrichment`
|
||||
|
||||
| Methode | Pfad | Beschreibung |
|
||||
|---------|------|--------------|
|
||||
| GET | `/candidates` | Paginierte Kandidaten (Filter: status, visibility, focus_area, without_skills, with_ai_suggested_skills, include_club, search) |
|
||||
| POST | `/preview` | Dry-Run — `{ exercise_ids[], modes: { skills, summary }, merge_mode }` |
|
||||
| POST | `/apply` | `{ items: [{ exercise_id, merged_skills }], merge_mode, set_status }` |
|
||||
|
||||
Auth: `require_auth` + `is_superadmin` — **kein** `TenantContext` (EXEMPT, siehe ACCESS_LAYER_ENDPOINT_AUDIT.md).
|
||||
|
||||
## KI
|
||||
|
||||
Wiederverwendet `run_exercise_form_ai_suggestion` → Prompts `exercise_skill_suggestions` (MVP Pflicht), optional `exercise_summary`. Skill-Katalog via `build_contextual_skills_catalog_block` / `ai_skill_retrieval_profiles`.
|
||||
|
||||
## Merge-Modi (Skills)
|
||||
|
||||
- `additive` (Default): manuelle Skills bleiben; KI ergänzt neue; bestehende `ai_suggested`-Links werden aktualisiert
|
||||
- `replace_ai_only`: nur `ai_suggested=true` entfernen, dann KI-Set anwenden
|
||||
- `replace_all`: alle Skills ersetzen (explizit)
|
||||
|
||||
## Defaults
|
||||
|
||||
- Kandidaten: **Status** primär (Default `draft`); Sichtbarkeit Default **`private`**, wählbar bis „Alle“
|
||||
- Skill-Merge Default: **`replace_all`** (alle Skills KI-neu, `ai_suggested=true` — unterscheidbar von manuell)
|
||||
- Nach Apply: `set_status=in_review` (nie automatisch `approved`)
|
||||
- Batch: keine Gesamtgrenze (bis 10.000 IDs); **Analyze** + explizite Nutzerbestätigung
|
||||
- **Preview:** max. **3 Übungen/HTTP-Request** (parallel LLM), Frontend chunked — vermeidet Gateway-504 (~60s Fritz!Box)
|
||||
- **Apply:** HTTP-Chunks à 25 (nur DB, kein LLM)
|
||||
|
||||
## Inhalte (modular)
|
||||
|
||||
| Modus | Prompt | Apply-Felder |
|
||||
|-------|--------|--------------|
|
||||
| Skills | `exercise_skill_suggestions` | `exercise_skills` inkl. Intensität, required/target_level, `ai_suggested` |
|
||||
| Summary | `exercise_summary` | `summary`, `summary_ai_generated=true` |
|
||||
| Anleitung | `exercise_instruction_rewrite` | `goal`, `execution`, `preparation`, `trainer_notes` |
|
||||
|
||||
## API (ergänzt)
|
||||
|
||||
| Methode | Pfad | Beschreibung |
|
||||
|---------|------|--------------|
|
||||
| GET | `/candidate-ids` | Alle IDs zum Filter (Select-all) |
|
||||
| POST | `/analyze` | `{ exercise_ids[], modes }` → Kosten-Schätzung vor Start |
|
||||
|
||||
## Keine Migration
|
||||
|
||||
Bestehende Spalte `exercise_skills.ai_suggested` reicht; kein Enrichment-Log in MVP.
|
||||
|
||||
## Tests
|
||||
|
||||
`backend/tests/test_exercise_enrichment_admin.py` — 403, Merge-Logik, Status draft→in_review.
|
||||
352
.claude/docs/working/PLANNING_EXERCISE_SUGGEST_CONTEXT.md
Normal file
352
.claude/docs/working/PLANNING_EXERCISE_SUGGEST_CONTEXT.md
Normal file
|
|
@ -0,0 +1,352 @@
|
|||
# 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
|
||||
**Bezüge:** `AI_PLANNING_KI_MULTISTAGE_FORECAST.md` · `AI_PROMPT_TARGET_ARCHITECTURE.md` · `SKILL_SCORING_SPEC.md` · `TRAINING_FRAMEWORK_SPEC.md` §3 (Progressionsgraph)
|
||||
|
||||
---
|
||||
|
||||
## 1. Ziel
|
||||
|
||||
Trainer in der **Trainingsplanung** sollen Übungen finden oder anlegen können mit natürlichen Anfragen wie:
|
||||
|
||||
- „Vertiefung zu Übung XY“
|
||||
- „Nächste sinnvolle Übung im Progressionsgraph Z“
|
||||
- „Baut auf der bisherigen Planung auf — Reaktionsschnelligkeit mit Partnern“
|
||||
- **Preset:** „Schlage mir die nächste Übung vor“
|
||||
|
||||
**Suche** (Bibliothek) und **Neu mit KI-Assistent** (Anlage) nutzen dasselbe **`PlanningExerciseContextPack`** — unterschiedliches Ergebnis (Treffer vs. Entwurf).
|
||||
|
||||
---
|
||||
|
||||
## 2. Architektur (Mehrstufig)
|
||||
|
||||
| Stufe | Name | Technik | P0 |
|
||||
|-------|------|---------|-----|
|
||||
| **S0** | Kontext-Pack | SQL/API, deterministisch | ✅ |
|
||||
| **S1a** | Intent strukturieren | LLM `planning_exercise_search_intent` (Szenario-Pipeline) | ✅ P1 |
|
||||
| **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 |
|
||||
|
||||
Zwischen jeder Stufe: **nur erlaubte `exercise_id`s** (Governance / Sichtbarkeit).
|
||||
|
||||
---
|
||||
|
||||
## 3. Intent-Typen
|
||||
|
||||
| `intent_hint` | Bedeutung | Retrieval-Gewichtung (P0) |
|
||||
|---------------|-----------|---------------------------|
|
||||
| `suggest_next` | Nächste Übung (Default bei leerer/kurzer Query) | Progression + Skill-Overlap + Plan-Kontinuität |
|
||||
| `progression_next` | Explizit Graph-Folge | Progression hoch |
|
||||
| `deepen_exercise` | Vertiefung zu Anker-Übung | Skill-Overlap hoch, ähnlicher Fokus |
|
||||
| `continue_plan_goal` | Auf bisherigen Plan aufbauen | Plan-Kontinuität, Wiederholungsstrafe |
|
||||
| `free_search` | Freitext / Stichwort | Volltext hoch |
|
||||
|
||||
**S1a (später):** Freitext → JSON `{ intent, skill_hints[], requires_partner, level_hint, … }` validiert per Pydantic.
|
||||
|
||||
**P0:** `intent_hint` vom Client oder Keyword-Heuristik auf `query`.
|
||||
|
||||
---
|
||||
|
||||
## 4. PlanningExerciseContextPack (S0)
|
||||
|
||||
Serverseitig aus Request + DB (tokenbewusst für spätere LLM-Stufen):
|
||||
|
||||
| Feld | Quelle | UI-Chip |
|
||||
|------|--------|---------|
|
||||
| `unit_id`, Titel, `group_id`, Gruppenname | `training_units` + `training_groups` | Gruppe · Einheit |
|
||||
| `section_order_index`, Abschnittstitel | `training_unit_sections` | Abschnitt |
|
||||
| `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) |
|
||||
| `group_recent_exercise_ids[]` | Letzte Einheiten derselben Gruppe | Wiederholungsstrafe |
|
||||
| `framework_slot_notes` | Rahmen-Slot falls `framework_slot_id` | (später) |
|
||||
|
||||
**Berechtigung:** `get_tenant_context` + `_assert_training_unit_permission` wie `GET /training-units/{id}`.
|
||||
|
||||
---
|
||||
|
||||
## 5. Hybrid-Retrieval (S1b, P0)
|
||||
|
||||
Kandidaten: sichtbare Übungen (`library_content_visibility_sql`), ohne `archived`, max. ~400 (recent).
|
||||
|
||||
**Score** (0–1, gewichtet nach Intent):
|
||||
|
||||
```
|
||||
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)
|
||||
```
|
||||
|
||||
**`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“.
|
||||
|
||||
---
|
||||
|
||||
## 6. API
|
||||
|
||||
### `POST /api/planning/exercise-suggest`
|
||||
|
||||
**Body:**
|
||||
|
||||
```json
|
||||
{
|
||||
"unit_id": 123,
|
||||
"section_order_index": 0,
|
||||
"phase_order_index": null,
|
||||
"parallel_stream_order_index": null,
|
||||
"anchor_exercise_id": 456,
|
||||
"progression_graph_id": 7,
|
||||
"query": "Schlage mir die nächste Übung vor",
|
||||
"intent_hint": "suggest_next",
|
||||
"limit": 20,
|
||||
"exercise_kind_any": ["simple"]
|
||||
}
|
||||
```
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"context_summary": {
|
||||
"unit_title": "…",
|
||||
"group_name": "…",
|
||||
"section_title": "Hauptteil",
|
||||
"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": [
|
||||
{
|
||||
"id": 99,
|
||||
"title": "…",
|
||||
"summary": "…",
|
||||
"score": 0.78,
|
||||
"reasons": ["Nachfolger im Progressionsgraph", "Fokusbereich passend zum Planungsziel"],
|
||||
"focus_area": "…"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Modul:** `backend/planning_exercise_suggest.py` · `backend/planning_exercise_profiles.py` · Router `backend/routers/planning_exercise_suggest.py`
|
||||
|
||||
---
|
||||
|
||||
## 7. Frontend
|
||||
|
||||
| Ort | Verhalten |
|
||||
|-----|-----------|
|
||||
| `ExercisePickerModal` | Prop `planningContext` → Planungs-API statt reiner `listExercises`; Kontext-Chips; `reasons` unter Treffer |
|
||||
| `TrainingUnitEditPage` | `planningContext` aus Einheit + Picker-Ziel (Anker = letzte Übung im Abschnitt) |
|
||||
| Rahmen / Kombi-Formular | analog, sobald `unit_id` / Slot-Blueprint bekannt |
|
||||
| Übungsliste | weiter Volltext; Schalter „Neu mit KI-Assistent“ ohne Planungs-Pack |
|
||||
|
||||
**Zweites Suchfeld** im Picker: Query = Volltext + ergänzender Begriff (ODER in P0 als Konkatenation an Backend).
|
||||
|
||||
---
|
||||
|
||||
## 8. Neu-Anlage (Anbindung, Phase P1)
|
||||
|
||||
Wenn `hits` leer oder Trainer wählt „Mit KI anlegen“:
|
||||
|
||||
- Gleiches `context_summary` an `suggestExerciseAi` anhängen (Felder `planning_context_json` o. ä. — noch offen)
|
||||
- Kurzbeschreibung optional leer (freier Vorschlag) oder aus Intent/Skizze
|
||||
|
||||
---
|
||||
|
||||
## 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 |
|
||||
|
||||
---
|
||||
|
||||
## 10. Changelog
|
||||
|
||||
- **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`.
|
||||
- **2026-05-22:** P2 — LLM-Rerank optional (`include_llm_rank`); Client `planned_exercise_ids[]`; Prompt Migration 072.
|
||||
|
||||
---
|
||||
|
||||
## 11. Bekannte P0-Lücken
|
||||
|
||||
- **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).
|
||||
- **LLM-Intent:** ✅ P1 Szenario-Pipeline + `planning_exercise_search_intent` (Migration 073).
|
||||
|
||||
---
|
||||
|
||||
## 16. Szenario-Pipeline & Query-Erwartungsprofil (P1)
|
||||
|
||||
Komplexe Planungsanfragen brauchen **Schritte vor** dem Profil-Match — nicht jede Query ist gleich.
|
||||
|
||||
### 16.1 Szenario-Klassen
|
||||
|
||||
| `scenario_kind` | Typische Anfrage | LLM Intent? |
|
||||
|-----------------|------------------|-------------|
|
||||
| `preset_next` | „Nächste Übung vorschlagen“ (Preset) | Nein — nur Basis-Profil |
|
||||
| `progression` | Progressionsgraph / Pfad | Ja (wenn Freitext) |
|
||||
| `deepen` | Vertiefung Anker | Ja |
|
||||
| `continue_plan` | Auf bisherigen Plan aufbauen | Ja |
|
||||
| `additive_constraint` | Plan **+** Zusatz (z. B. Schnellkraft) | Ja |
|
||||
| `free_search` | Offene Stichwortsuche | Ja |
|
||||
|
||||
**Routing:** `planning_exercise_target_pipeline.classify_planning_scenario()` → `should_run_llm_intent_pipeline()`.
|
||||
|
||||
### 16.2 Pipeline (Reihenfolge)
|
||||
|
||||
```
|
||||
S0 Kontext-Pack
|
||||
→ Heuristik-Intent + Szenario
|
||||
→ [optional] LLM planning_exercise_search_intent
|
||||
→ Basis PlanningTargetProfile (Rahmen, Plan, Anker, Gap)
|
||||
→ Merge Query-Overlay (Katalog-IDs aus Hints)
|
||||
→ Hybrid-Retrieval + Profil-Score
|
||||
→ [optional] LLM-Rerank
|
||||
```
|
||||
|
||||
Module: `planning_exercise_target_pipeline.py` · `planning_exercise_intent.py`
|
||||
|
||||
### 16.3 API (Erweiterung)
|
||||
|
||||
| Request | Default | Bedeutung |
|
||||
|---------|---------|-----------|
|
||||
| `include_llm_intent` | `true` | LLM nur wenn Szenario ≠ preset_next und Query nicht leer |
|
||||
|
||||
| Response | Bedeutung |
|
||||
|----------|-----------|
|
||||
| `scenario_kind` | Szenario-Klasse |
|
||||
| `query_intent_summary` | intent, llm_applied, rationale, skill_hints_resolved |
|
||||
| `intent_heuristic` | Heuristik vor LLM |
|
||||
| `retrieval_phase` | z. B. `profile_v1+query_intent+llm_rank` |
|
||||
|
||||
**Prompt 073:** `planning_exercise_search_intent` — Ausgabe JSON mit `skill_hints`, `focus_hints`, `emphasis` (`additive`|`replace`).
|
||||
|
||||
---
|
||||
|
||||
## 15. LLM-Rerank (P2)
|
||||
|
||||
**Request:**
|
||||
|
||||
| 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` |
|
||||
|
||||
**Response:**
|
||||
|
||||
| Feld | Wert |
|
||||
|------|------|
|
||||
| `retrieval_phase` | `profile_v1` oder `profile_v1+llm_rank` |
|
||||
| `llm_rank_applied` | `true` wenn LLM erfolgreich sortiert hat |
|
||||
| `hits[].llm_rank` | optional: Position nach LLM (1…n) |
|
||||
|
||||
**Fallback:** Kein API-Key, inaktiver Prompt oder Parse-Fehler → Hybrid-Reihenfolge unverändert, `llm_rank_applied: false`.
|
||||
|
||||
**Prompt:** Migration **072**, Slug `planning_exercise_search_rank` — Kandidaten als JSON mit Titel, summary, goal (Plaintext), skills; Ausgabe `{ ranked_ids, reasons }`.
|
||||
|
||||
---
|
||||
|
||||
## 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):** siehe §15 — optional per `include_llm_rank`.
|
||||
|
|
@ -11,6 +11,14 @@ from typing import Any, Dict, Mapping, Optional, Tuple
|
|||
|
||||
from prompt_resolver import MustacheRenderResult, render_mustache_template
|
||||
|
||||
_PLANNING_AI_SLUGS = frozenset(
|
||||
{
|
||||
"planning_exercise_search_rank",
|
||||
"planning_exercise_search_intent",
|
||||
"planning_exercise_expectation_profile",
|
||||
}
|
||||
)
|
||||
|
||||
_EXERCISE_AI_SLUGS = frozenset(
|
||||
{
|
||||
"exercise_summary",
|
||||
|
|
@ -26,12 +34,15 @@ class AiPromptContextKind(str, Enum):
|
|||
ohne bestehende Slugs zu invalidieren.
|
||||
"""
|
||||
|
||||
PLANNING_EXERCISE_SEARCH = "planning_exercise_search"
|
||||
EXERCISE_FORM_AI = "exercise_form_ai"
|
||||
|
||||
|
||||
def context_kind_for_slug(slug: str) -> Optional[AiPromptContextKind]:
|
||||
"""Ordnet einen DB-Slug einer Kontext-Art zu, sofern registriert."""
|
||||
s = (slug or "").strip().lower()
|
||||
if s in _PLANNING_AI_SLUGS:
|
||||
return AiPromptContextKind.PLANNING_EXERCISE_SEARCH
|
||||
if s in _EXERCISE_AI_SLUGS:
|
||||
return AiPromptContextKind.EXERCISE_FORM_AI
|
||||
return None
|
||||
|
|
|
|||
536
backend/exercise_enrichment.py
Normal file
536
backend/exercise_enrichment.py
Normal file
|
|
@ -0,0 +1,536 @@
|
|||
"""
|
||||
Superadmin-Werkzeug: Übungs-Anreicherung per KI (Skills + optional Metadaten).
|
||||
|
||||
Wiederverwendet run_exercise_form_ai_suggestion / exercise_ai — keine neue OpenRouter-Pipeline.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List, Literal, Optional
|
||||
|
||||
from ai_prompt_context import ExerciseFormAiPromptContext
|
||||
from ai_prompt_job import run_exercise_form_ai_suggestion
|
||||
from exercise_ai import strip_html_to_plain
|
||||
from exercise_rich_text import normalize_inline_exercise_media_markup
|
||||
|
||||
from routers.exercises import (
|
||||
enrich_exercise_detail,
|
||||
normalize_exercise_skill_intensity,
|
||||
normalize_exercise_skill_level,
|
||||
)
|
||||
|
||||
SkillMergeMode = Literal["additive", "replace_ai_only", "replace_all"]
|
||||
|
||||
SKILL_MERGE_MODES = frozenset({"additive", "replace_ai_only", "replace_all"})
|
||||
DEFAULT_SET_STATUS = "in_review"
|
||||
# Max. IDs pro Apply-HTTP-Anfrage (kein LLM).
|
||||
MAX_BATCH_EXERCISES = 50
|
||||
# Preview: pro Request nur wenige Übungen — sonst Gateway-504 (Fritz!Box o.ä. ~60s).
|
||||
MAX_PREVIEW_BATCH_EXERCISES = 3
|
||||
|
||||
_INSTRUCTION_FIELDS = ("goal", "execution", "preparation", "trainer_notes")
|
||||
_SKILL_COMPARE_KEYS = ("intensity", "required_level", "target_level", "is_primary")
|
||||
|
||||
|
||||
def _focus_areas_ai_ctx_from_detail(exercise: Dict[str, Any]) -> list[tuple[int, bool]]:
|
||||
rows: list[tuple[int, bool]] = []
|
||||
for row in exercise.get("focus_areas") or []:
|
||||
if not isinstance(row, dict):
|
||||
continue
|
||||
try:
|
||||
fid = int(row.get("focus_area_id"))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if fid < 1:
|
||||
continue
|
||||
rows.append((fid, bool(row.get("is_primary"))))
|
||||
rows.sort(key=lambda x: (not x[1], x[0]))
|
||||
return rows
|
||||
|
||||
|
||||
def _focus_area_hint_from_detail(exercise: Dict[str, Any]) -> str:
|
||||
parts: List[str] = []
|
||||
for row in exercise.get("focus_areas") or []:
|
||||
if isinstance(row, dict):
|
||||
nm = (row.get("name") or "").strip()
|
||||
if nm:
|
||||
parts.append(nm)
|
||||
txt = ", ".join(parts).strip()
|
||||
if len(txt) > 900:
|
||||
return txt[:899] + "…"
|
||||
return txt
|
||||
|
||||
|
||||
def build_form_context_from_exercise(exercise: Dict[str, Any]) -> ExerciseFormAiPromptContext:
|
||||
focus = _focus_area_hint_from_detail(exercise)
|
||||
fctx = _focus_areas_ai_ctx_from_detail(exercise)
|
||||
return ExerciseFormAiPromptContext.from_focus_tuples(
|
||||
title=str(exercise.get("title") or "").strip(),
|
||||
goal=exercise.get("goal"),
|
||||
execution=exercise.get("execution"),
|
||||
preparation=exercise.get("preparation"),
|
||||
trainer_notes=exercise.get("trainer_notes"),
|
||||
focus_hint=focus or None,
|
||||
focus_tuples=fctx or None,
|
||||
)
|
||||
|
||||
|
||||
def validate_exercise_for_enrichment(
|
||||
exercise: Dict[str, Any],
|
||||
*,
|
||||
want_skills: bool = False,
|
||||
want_summary: bool = False,
|
||||
want_instructions: bool = False,
|
||||
) -> Optional[str]:
|
||||
title = str(exercise.get("title") or "").strip()
|
||||
if not title:
|
||||
return "Titel fehlt"
|
||||
|
||||
ctx = build_form_context_from_exercise(exercise)
|
||||
g_plain = strip_html_to_plain(exercise.get("goal"))
|
||||
e_plain = strip_html_to_plain(exercise.get("execution"))
|
||||
|
||||
if want_skills or want_summary:
|
||||
if not (g_plain.strip() or e_plain.strip()):
|
||||
return "Mindestens Ziel oder Durchführung muss Inhalt liefern (für Skills/Kurzfassung)"
|
||||
|
||||
if want_instructions and not ctx.has_instruction_source_text():
|
||||
return "Für Anleitungs-Überarbeitung fehlt Ausgangstext (Titel oder Anleitungsfeld)"
|
||||
|
||||
if not (want_skills or want_summary or want_instructions):
|
||||
return "Kein Anreicherungsmodus aktiv"
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _normalize_skill_row(raw: Dict[str, Any], *, ai_suggested: bool) -> Dict[str, Any]:
|
||||
return {
|
||||
"skill_id": int(raw["skill_id"]),
|
||||
"skill_name": (raw.get("skill_name") or "").strip() or f"Skill #{raw['skill_id']}",
|
||||
"skill_category": raw.get("skill_category"),
|
||||
"is_primary": bool(raw.get("is_primary")),
|
||||
"intensity": normalize_exercise_skill_intensity(raw.get("intensity")),
|
||||
"required_level": normalize_exercise_skill_level(raw.get("required_level")),
|
||||
"target_level": normalize_exercise_skill_level(raw.get("target_level")),
|
||||
"ai_suggested": ai_suggested,
|
||||
}
|
||||
|
||||
|
||||
def _skill_meta_differs(a: Dict[str, Any], b: Dict[str, Any]) -> bool:
|
||||
for k in _SKILL_COMPARE_KEYS:
|
||||
av = a.get(k)
|
||||
bv = b.get(k)
|
||||
if k in ("required_level", "target_level"):
|
||||
av = normalize_exercise_skill_level(av)
|
||||
bv = normalize_exercise_skill_level(bv)
|
||||
elif k == "intensity":
|
||||
av = normalize_exercise_skill_intensity(av)
|
||||
bv = normalize_exercise_skill_intensity(bv)
|
||||
elif k == "is_primary":
|
||||
av = bool(av)
|
||||
bv = bool(bv)
|
||||
if av != bv:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def merge_skills(
|
||||
existing: List[Dict[str, Any]],
|
||||
suggested: List[Dict[str, Any]],
|
||||
mode: SkillMergeMode,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Merge-Modi: additive | replace_ai_only | replace_all (alle KI-Skills mit ai_suggested=true)."""
|
||||
existing_norm = [_normalize_skill_row(s, ai_suggested=bool(s.get("ai_suggested"))) for s in existing]
|
||||
suggested_norm = [_normalize_skill_row(s, ai_suggested=True) for s in suggested]
|
||||
|
||||
suggested_by_id = {int(s["skill_id"]): s for s in suggested_norm}
|
||||
|
||||
if mode == "replace_all":
|
||||
return list(suggested_norm)
|
||||
|
||||
if mode == "replace_ai_only":
|
||||
manual = [s for s in existing_norm if not s.get("ai_suggested")]
|
||||
manual_ids = {int(s["skill_id"]) for s in manual}
|
||||
result = list(manual)
|
||||
for s in suggested_norm:
|
||||
sid = int(s["skill_id"])
|
||||
if sid in manual_ids:
|
||||
continue
|
||||
result.append(s)
|
||||
return result
|
||||
|
||||
# additive
|
||||
result: List[Dict[str, Any]] = []
|
||||
seen: set[int] = set()
|
||||
for s in existing_norm:
|
||||
sid = int(s["skill_id"])
|
||||
seen.add(sid)
|
||||
if sid in suggested_by_id and s.get("ai_suggested"):
|
||||
merged = {**s, **suggested_by_id[sid], "ai_suggested": True}
|
||||
result.append(merged)
|
||||
else:
|
||||
result.append(dict(s))
|
||||
for s in suggested_norm:
|
||||
sid = int(s["skill_id"])
|
||||
if sid not in seen:
|
||||
result.append(s)
|
||||
seen.add(sid)
|
||||
return result
|
||||
|
||||
|
||||
def compute_skill_diff(
|
||||
before: List[Dict[str, Any]],
|
||||
after: List[Dict[str, Any]],
|
||||
) -> Dict[str, Any]:
|
||||
before_ids = {int(s["skill_id"]): s for s in before}
|
||||
after_ids = {int(s["skill_id"]): s for s in after}
|
||||
added = [after_ids[i] for i in sorted(after_ids) if i not in before_ids]
|
||||
removed = [before_ids[i] for i in sorted(before_ids) if i not in after_ids]
|
||||
changed: List[Dict[str, Any]] = []
|
||||
for sid in before_ids:
|
||||
if sid in after_ids and _skill_meta_differs(before_ids[sid], after_ids[sid]):
|
||||
changed.append(
|
||||
{
|
||||
"skill_id": sid,
|
||||
"skill_name": after_ids[sid].get("skill_name") or before_ids[sid].get("skill_name"),
|
||||
"before": before_ids[sid],
|
||||
"after": after_ids[sid],
|
||||
}
|
||||
)
|
||||
kept = [
|
||||
before_ids[i]
|
||||
for i in sorted(before_ids)
|
||||
if i in after_ids and i not in {c["skill_id"] for c in changed}
|
||||
]
|
||||
return {"added": added, "removed": removed, "changed": changed, "kept": kept}
|
||||
|
||||
|
||||
def _skills_from_ai_payload(payload: Dict[str, Any]) -> List[Dict[str, Any]]:
|
||||
rows = payload.get("skills")
|
||||
if not isinstance(rows, list):
|
||||
return []
|
||||
return [_normalize_skill_row(r, ai_suggested=True) for r in rows if isinstance(r, dict) and r.get("skill_id")]
|
||||
|
||||
|
||||
def _summary_from_ai_payload(payload: Dict[str, Any]) -> Optional[str]:
|
||||
block = payload.get("summary")
|
||||
if isinstance(block, dict):
|
||||
text = (block.get("text") or "").strip()
|
||||
return text or None
|
||||
if isinstance(block, str) and block.strip():
|
||||
return block.strip()
|
||||
return None
|
||||
|
||||
|
||||
def _instructions_from_ai_payload(payload: Dict[str, Any]) -> Dict[str, str]:
|
||||
block = payload.get("instructions")
|
||||
if not isinstance(block, dict):
|
||||
return {}
|
||||
fields = block.get("fields")
|
||||
if not isinstance(fields, dict):
|
||||
return {}
|
||||
out: Dict[str, str] = {}
|
||||
for key in _INSTRUCTION_FIELDS:
|
||||
val = fields.get(key)
|
||||
if val is not None and str(val).strip():
|
||||
out[key] = str(val).strip()
|
||||
return out
|
||||
|
||||
|
||||
def _instruction_snapshot(exercise: Dict[str, Any]) -> Dict[str, str]:
|
||||
out: Dict[str, str] = {}
|
||||
for key in _INSTRUCTION_FIELDS:
|
||||
raw = exercise.get(key)
|
||||
plain = strip_html_to_plain(raw, max_len=400) if raw else ""
|
||||
if plain.strip():
|
||||
out[key] = plain.strip()
|
||||
return out
|
||||
|
||||
|
||||
def compute_instruction_diff(
|
||||
before: Dict[str, str],
|
||||
after: Dict[str, str],
|
||||
) -> Dict[str, Any]:
|
||||
changed: List[Dict[str, Any]] = []
|
||||
added: List[str] = []
|
||||
for key in _INSTRUCTION_FIELDS:
|
||||
b = (before.get(key) or "").strip()
|
||||
a = (after.get(key) or "").strip()
|
||||
if not a:
|
||||
continue
|
||||
if not b:
|
||||
added.append(key)
|
||||
elif b != strip_html_to_plain(a, max_len=400).strip() and b != a:
|
||||
changed.append({"field": key, "before_plain": b, "after_html": a})
|
||||
return {"changed_fields": changed, "added_fields": added}
|
||||
|
||||
|
||||
def preview_exercise_enrichment(
|
||||
cur,
|
||||
exercise_id: int,
|
||||
*,
|
||||
want_skills: bool = True,
|
||||
want_summary: bool = False,
|
||||
want_instructions: bool = False,
|
||||
merge_mode: SkillMergeMode = "additive",
|
||||
) -> Dict[str, Any]:
|
||||
exercise = enrich_exercise_detail(exercise_id, cur)
|
||||
if not exercise:
|
||||
return {"exercise_id": exercise_id, "ok": False, "error": "Übung nicht gefunden"}
|
||||
|
||||
skip_reason = validate_exercise_for_enrichment(
|
||||
exercise,
|
||||
want_skills=want_skills,
|
||||
want_summary=want_summary,
|
||||
want_instructions=want_instructions,
|
||||
)
|
||||
if skip_reason:
|
||||
return {
|
||||
"exercise_id": exercise_id,
|
||||
"ok": False,
|
||||
"skipped": True,
|
||||
"error": skip_reason,
|
||||
"title": exercise.get("title"),
|
||||
"status": exercise.get("status"),
|
||||
}
|
||||
|
||||
existing = exercise.get("skills") or []
|
||||
suggested: List[Dict[str, Any]] = []
|
||||
ai_meta: Dict[str, Any] = {}
|
||||
payload: Dict[str, Any] = {}
|
||||
suggested_summary: Optional[str] = None
|
||||
suggested_instructions: Dict[str, str] = {}
|
||||
|
||||
if want_skills or want_summary or want_instructions:
|
||||
ctx = build_form_context_from_exercise(exercise)
|
||||
payload = run_exercise_form_ai_suggestion(
|
||||
cur,
|
||||
ctx,
|
||||
want_summary=want_summary,
|
||||
want_skills=want_skills,
|
||||
want_instructions=want_instructions,
|
||||
)
|
||||
if want_skills:
|
||||
suggested = _skills_from_ai_payload(payload)
|
||||
if want_summary:
|
||||
suggested_summary = _summary_from_ai_payload(payload)
|
||||
if want_instructions:
|
||||
suggested_instructions = _instructions_from_ai_payload(payload)
|
||||
ai_meta = {
|
||||
"models": payload.get("models_by_slug") or {},
|
||||
"llm_calls": sum([want_skills, want_summary, want_instructions]),
|
||||
}
|
||||
|
||||
merged = merge_skills(existing, suggested, merge_mode) if want_skills else list(existing)
|
||||
diff = compute_skill_diff(existing, merged) if want_skills else None
|
||||
|
||||
existing_summary = (exercise.get("summary") or "").strip() or None
|
||||
instr_before = _instruction_snapshot(exercise)
|
||||
instr_after_plain = {
|
||||
k: strip_html_to_plain(v, max_len=400) for k, v in suggested_instructions.items()
|
||||
}
|
||||
instruction_diff = (
|
||||
compute_instruction_diff(instr_before, instr_after_plain) if want_instructions else None
|
||||
)
|
||||
|
||||
return {
|
||||
"exercise_id": exercise_id,
|
||||
"ok": True,
|
||||
"title": exercise.get("title"),
|
||||
"status": exercise.get("status"),
|
||||
"visibility": exercise.get("visibility"),
|
||||
"primary_focus_name": _primary_focus_from_exercise(exercise),
|
||||
"existing_skills": existing,
|
||||
"suggested_skills": suggested,
|
||||
"merged_skills": merged,
|
||||
"diff": diff,
|
||||
"existing_summary": existing_summary,
|
||||
"suggested_summary": suggested_summary,
|
||||
"existing_instructions": instr_before,
|
||||
"suggested_instructions": suggested_instructions,
|
||||
"instruction_diff": instruction_diff,
|
||||
"ai_meta": ai_meta,
|
||||
}
|
||||
|
||||
|
||||
def _primary_focus_from_exercise(exercise: Dict[str, Any]) -> Optional[str]:
|
||||
for row in exercise.get("focus_areas") or []:
|
||||
if isinstance(row, dict) and row.get("is_primary"):
|
||||
return (row.get("name") or "").strip() or None
|
||||
for row in exercise.get("focus_areas") or []:
|
||||
if isinstance(row, dict):
|
||||
nm = (row.get("name") or "").strip()
|
||||
if nm:
|
||||
return nm
|
||||
return None
|
||||
|
||||
|
||||
def persist_merged_skills(cur, exercise_id: int, merged: List[Dict[str, Any]], merge_mode: SkillMergeMode) -> None:
|
||||
if merge_mode == "replace_all":
|
||||
cur.execute("DELETE FROM exercise_skills WHERE exercise_id = %s", (exercise_id,))
|
||||
elif merge_mode == "replace_ai_only":
|
||||
cur.execute(
|
||||
"DELETE FROM exercise_skills WHERE exercise_id = %s AND ai_suggested = true",
|
||||
(exercise_id,),
|
||||
)
|
||||
|
||||
for sk in merged:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO exercise_skills
|
||||
(exercise_id, skill_id, is_primary, intensity, required_level, target_level, ai_suggested)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s)
|
||||
ON CONFLICT (exercise_id, skill_id) DO UPDATE SET
|
||||
intensity = CASE
|
||||
WHEN exercise_skills.ai_suggested = false AND %s = 'additive'
|
||||
THEN exercise_skills.intensity ELSE EXCLUDED.intensity END,
|
||||
required_level = CASE
|
||||
WHEN exercise_skills.ai_suggested = false AND %s = 'additive'
|
||||
THEN exercise_skills.required_level ELSE EXCLUDED.required_level END,
|
||||
target_level = CASE
|
||||
WHEN exercise_skills.ai_suggested = false AND %s = 'additive'
|
||||
THEN exercise_skills.target_level ELSE EXCLUDED.target_level END,
|
||||
is_primary = CASE
|
||||
WHEN exercise_skills.ai_suggested = false AND %s = 'additive'
|
||||
THEN exercise_skills.is_primary ELSE EXCLUDED.is_primary END,
|
||||
ai_suggested = CASE
|
||||
WHEN exercise_skills.ai_suggested = false AND %s = 'additive'
|
||||
THEN exercise_skills.ai_suggested ELSE EXCLUDED.ai_suggested END
|
||||
""",
|
||||
(
|
||||
exercise_id,
|
||||
int(sk["skill_id"]),
|
||||
bool(sk.get("is_primary")),
|
||||
normalize_exercise_skill_intensity(sk.get("intensity")),
|
||||
normalize_exercise_skill_level(sk.get("required_level")),
|
||||
normalize_exercise_skill_level(sk.get("target_level")),
|
||||
bool(sk.get("ai_suggested")),
|
||||
merge_mode,
|
||||
merge_mode,
|
||||
merge_mode,
|
||||
merge_mode,
|
||||
merge_mode,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _normalize_instruction_fields(fields: Optional[Dict[str, Any]]) -> Dict[str, str]:
|
||||
if not fields:
|
||||
return {}
|
||||
out: Dict[str, str] = {}
|
||||
for key in _INSTRUCTION_FIELDS:
|
||||
if key not in fields:
|
||||
continue
|
||||
raw = fields.get(key)
|
||||
if raw is None or not str(raw).strip():
|
||||
continue
|
||||
out[key] = normalize_inline_exercise_media_markup(str(raw).strip())
|
||||
return out
|
||||
|
||||
|
||||
def apply_exercise_enrichment(
|
||||
cur,
|
||||
exercise_id: int,
|
||||
*,
|
||||
merged_skills: Optional[List[Dict[str, Any]]] = None,
|
||||
merge_mode: SkillMergeMode = "additive",
|
||||
set_status: Optional[str] = DEFAULT_SET_STATUS,
|
||||
apply_skills: bool = False,
|
||||
summary_text: Optional[str] = None,
|
||||
apply_summary: bool = False,
|
||||
instruction_fields: Optional[Dict[str, Any]] = None,
|
||||
apply_instructions: bool = False,
|
||||
) -> Dict[str, Any]:
|
||||
exercise = enrich_exercise_detail(exercise_id, cur)
|
||||
if not exercise:
|
||||
return {"exercise_id": exercise_id, "ok": False, "error": "Übung nicht gefunden"}
|
||||
|
||||
skip_reason = validate_exercise_for_enrichment(
|
||||
exercise,
|
||||
want_skills=apply_skills,
|
||||
want_summary=apply_summary,
|
||||
want_instructions=apply_instructions,
|
||||
)
|
||||
if skip_reason:
|
||||
return {
|
||||
"exercise_id": exercise_id,
|
||||
"ok": False,
|
||||
"skipped": True,
|
||||
"error": skip_reason,
|
||||
}
|
||||
|
||||
skills_list = merged_skills or []
|
||||
if apply_skills:
|
||||
if not skills_list and merge_mode != "replace_all":
|
||||
return {
|
||||
"exercise_id": exercise_id,
|
||||
"ok": False,
|
||||
"error": "Keine Skills zum Anwenden",
|
||||
}
|
||||
persist_merged_skills(cur, exercise_id, skills_list, merge_mode)
|
||||
|
||||
sets: List[str] = []
|
||||
vals: List[Any] = []
|
||||
|
||||
if apply_summary and summary_text is not None:
|
||||
text = str(summary_text).strip()
|
||||
if text:
|
||||
sets.extend(["summary = %s", "summary_ai_generated = true"])
|
||||
vals.append(text[:220])
|
||||
|
||||
if apply_instructions:
|
||||
norm = _normalize_instruction_fields(instruction_fields)
|
||||
for key, val in norm.items():
|
||||
sets.append(f"{key} = %s")
|
||||
vals.append(val)
|
||||
|
||||
new_status = (set_status or "").strip().lower() or None
|
||||
if new_status:
|
||||
if new_status == "approved":
|
||||
return {
|
||||
"exercise_id": exercise_id,
|
||||
"ok": False,
|
||||
"error": "Automatisches Freigeben (approved) ist nicht erlaubt",
|
||||
}
|
||||
if new_status not in ("draft", "in_review", "archived"):
|
||||
return {"exercise_id": exercise_id, "ok": False, "error": "Ungültiger Ziel-Status"}
|
||||
sets.append("status = %s")
|
||||
vals.append(new_status)
|
||||
|
||||
if sets:
|
||||
sets.append("updated_at = NOW()")
|
||||
vals.append(exercise_id)
|
||||
cur.execute(
|
||||
f"UPDATE exercises SET {', '.join(sets)} WHERE id = %s",
|
||||
tuple(vals),
|
||||
)
|
||||
elif not apply_skills:
|
||||
return {"exercise_id": exercise_id, "ok": False, "error": "Nichts anzuwenden"}
|
||||
|
||||
return {
|
||||
"exercise_id": exercise_id,
|
||||
"ok": True,
|
||||
"status": new_status or exercise.get("status"),
|
||||
"skills_applied": len(skills_list) if apply_skills else 0,
|
||||
"summary_applied": apply_summary and bool(summary_text and str(summary_text).strip()),
|
||||
"instructions_applied": apply_instructions and bool(_normalize_instruction_fields(instruction_fields)),
|
||||
}
|
||||
|
||||
|
||||
def estimate_llm_calls(
|
||||
*,
|
||||
exercise_count: int,
|
||||
want_skills: bool,
|
||||
want_summary: bool,
|
||||
want_instructions: bool = False,
|
||||
) -> Dict[str, Any]:
|
||||
per_skills = exercise_count if want_skills else 0
|
||||
per_summary = exercise_count if want_summary else 0
|
||||
per_instructions = exercise_count if want_instructions else 0
|
||||
total = per_skills + per_summary + per_instructions
|
||||
return {
|
||||
"total": total,
|
||||
"per_exercise": sum([want_skills, want_summary, want_instructions]),
|
||||
"skills": per_skills,
|
||||
"summary": per_summary,
|
||||
"instructions": per_instructions,
|
||||
}
|
||||
|
|
@ -193,7 +193,7 @@ def read_root():
|
|||
return out
|
||||
|
||||
# Register routers
|
||||
from routers import auth, profiles, exercises, exercise_progression_graphs, clubs, club_memberships, club_join_requests, admin_users, platform_media_storage, media_assets, skills, skill_profiles, training_planning, dashboard, training_modules, training_framework_programs, catalogs, maturity_models, matrix_stack_bundle, matrix_editor, import_wiki, import_wiki_admin, legal_documents, content_reports, ai_prompts_admin, ai_skill_retrieval_admin
|
||||
from routers import auth, profiles, exercises, exercise_progression_graphs, clubs, club_memberships, club_join_requests, admin_users, platform_media_storage, media_assets, skills, skill_profiles, training_planning, planning_exercise_suggest, dashboard, training_modules, training_framework_programs, catalogs, maturity_models, matrix_stack_bundle, matrix_editor, import_wiki, import_wiki_admin, legal_documents, content_reports, ai_prompts_admin, ai_skill_retrieval_admin, exercise_enrichment_admin
|
||||
|
||||
app.include_router(auth.router)
|
||||
app.include_router(profiles.router)
|
||||
|
|
@ -210,6 +210,7 @@ app.include_router(media_assets.admin_legal_hold_router)
|
|||
app.include_router(skills.router)
|
||||
app.include_router(skill_profiles.router)
|
||||
app.include_router(training_planning.router)
|
||||
app.include_router(planning_exercise_suggest.router)
|
||||
app.include_router(dashboard.router)
|
||||
app.include_router(training_modules.router)
|
||||
app.include_router(training_framework_programs.router)
|
||||
|
|
@ -223,6 +224,7 @@ app.include_router(legal_documents.router)
|
|||
app.include_router(content_reports.router)
|
||||
app.include_router(ai_prompts_admin.router)
|
||||
app.include_router(ai_skill_retrieval_admin.router)
|
||||
app.include_router(exercise_enrichment_admin.router)
|
||||
|
||||
# Lokale Übungs-Medien: standardmäßig nur über geschützten API-Pfad
|
||||
# GET /api/exercises/{id}/media/{mid}/file (?ssetoken für <img>/<video>).
|
||||
|
|
|
|||
|
|
@ -0,0 +1,54 @@
|
|||
-- Migration 072: KI-Prompt Planungs-Übungssuche — LLM-Rerank (Phase 2)
|
||||
-- Spec: .claude/docs/working/PLANNING_EXERCISE_SUGGEST_CONTEXT.md §14
|
||||
|
||||
INSERT INTO ai_prompts (
|
||||
slug, display_name, description, template,
|
||||
category, output_format, output_schema, is_system_default, default_template, active, sort_order
|
||||
)
|
||||
SELECT
|
||||
'planning_exercise_search_rank',
|
||||
'Planungs-Übungssuche Rerank',
|
||||
'Ordnet Kandidaten für die Trainingsplanung nach Intent und Kontext; nur IDs aus candidates_json.',
|
||||
$t$Du bist Assistent für Kampfsport-Trainer bei der Trainingsplanung.
|
||||
Ordne die vorgegebenen Übungs-Kandidaten nach Eignung für die aktuelle Planungssituation.
|
||||
|
||||
Regeln:
|
||||
- Verwende NUR exercise_id-Werte aus candidates_json (keine erfundenen IDs).
|
||||
- Berücksichtige search_query, intent, planning_context_json und target_profile_json.
|
||||
- Bewerte anhand von Titel, summary, goal und skills jedes Kandidaten.
|
||||
- Gib maximal {{result_limit}} IDs in sinnvoller Reihenfolge zurück (beste zuerst).
|
||||
- Kurze Begründung pro Top-Treffer auf Deutsch (1 Satz, sachlich).
|
||||
|
||||
Intent-Hinweise:
|
||||
- suggest_next / progression_next: logische Fortsetzung, Progression, passende Skills
|
||||
- deepen_exercise: Vertiefung zum Anker, ähnlicher Fokus
|
||||
- continue_plan_goal: schließt an bisherigen Plan und Skill-Lücken an
|
||||
- free_search: Freitext-Relevanz
|
||||
|
||||
Kontext:
|
||||
Intent: {{intent}}
|
||||
Suchanfrage: {{search_query}}
|
||||
Planung: {{planning_context_json}}
|
||||
Zielprofil: {{target_profile_json}}
|
||||
|
||||
Kandidaten (JSON):
|
||||
{{candidates_json}}
|
||||
|
||||
Antworte NUR mit JSON (kein Text davor/danach):
|
||||
{
|
||||
"ranked_ids": [123, 456],
|
||||
"reasons": { "123": "…", "456": "…" }
|
||||
}$t$,
|
||||
'training',
|
||||
'json',
|
||||
'{"type":"object","required":["ranked_ids"],"properties":{"ranked_ids":{"type":"array","items":{"type":"integer"}},"reasons":{"type":"object"}}}'::jsonb,
|
||||
true,
|
||||
NULL,
|
||||
true,
|
||||
10
|
||||
WHERE NOT EXISTS (SELECT 1 FROM ai_prompts WHERE slug = 'planning_exercise_search_rank');
|
||||
|
||||
UPDATE ai_prompts
|
||||
SET default_template = template
|
||||
WHERE slug = 'planning_exercise_search_rank'
|
||||
AND (default_template IS NULL OR TRIM(default_template) = '');
|
||||
|
|
@ -0,0 +1,74 @@
|
|||
-- Migration 073: KI-Prompt Planungs-Übungssuche — Intent/Query-Overlay (P1)
|
||||
-- Spec: .claude/docs/working/PLANNING_EXERCISE_SUGGEST_CONTEXT.md §16
|
||||
|
||||
INSERT INTO ai_prompts (
|
||||
slug, display_name, description, template,
|
||||
category, output_format, output_schema, is_system_default, default_template, active, sort_order
|
||||
)
|
||||
SELECT
|
||||
'planning_exercise_search_intent',
|
||||
'Planungs-Übungssuche Intent',
|
||||
'Strukturiert Freitext-Anfrage in Intent, Szenario und Katalog-Hints für Erwartungsprofil-Overlay.',
|
||||
$t$Du bist Assistent für Kampfsport-Trainer in der Trainingsplanung.
|
||||
Analysiere die Suchanfrage im Kontext der Einheit und des bisherigen Plans.
|
||||
|
||||
Ziel: JSON für ein Erwartungsprofil-Overlay (Fähigkeiten, Fokus, Stil …) — NICHT Übungs-IDs erfinden.
|
||||
|
||||
Szenario-Klassen (scenario):
|
||||
- preset_next: nur „nächste Übung“ ohne Zusatz — selten bei Freitext
|
||||
- progression: Progressionsgraph / Pfad / Folgeübung im Graph
|
||||
- deepen: Vertiefung zur Anker-Übung
|
||||
- continue_plan: baut auf bisherigem Plan der Einheit auf
|
||||
- additive_constraint: Plan beibehalten UND zusätzliche Anforderung (z. B. „außerdem Schnellkraft“)
|
||||
- free_search: offene Stichwortsuche / neues Thema
|
||||
|
||||
Intent (intent): suggest_next | progression_next | deepen_exercise | continue_plan_goal | free_search
|
||||
|
||||
emphasis:
|
||||
- additive: Zusatz zur bestehenden Planung (Default bei „zusätzlich/auch/dazu“)
|
||||
- replace: Suchanfrage soll Schwerpunkt eher ersetzen
|
||||
- neutral: nur leichte Gewichtung
|
||||
|
||||
Nutze skill_hints/focus_hints etc. mit Namen aus den Katalog-JSONs (beste Übereinstimmung).
|
||||
Bei requires_partner: true/false/null wenn Partnerbezug erkennbar.
|
||||
|
||||
Eingabe:
|
||||
Suchanfrage: {{search_query}}
|
||||
Heuristik-Intent: {{heuristic_intent}}
|
||||
Szenario-Hinweis (Server): {{scenario_hint}}
|
||||
Planungskontext: {{planning_context_json}}
|
||||
Basis-Zielprofil (deterministisch): {{target_profile_json}}
|
||||
|
||||
Kataloge (Auszug — nur diese Namen/IDs verwenden):
|
||||
Skills: {{skills_catalog_json}}
|
||||
Fokus: {{focus_areas_catalog_json}}
|
||||
Trainingsstil: {{training_types_catalog_json}}
|
||||
Stilrichtung: {{style_directions_catalog_json}}
|
||||
Zielgruppe: {{target_groups_catalog_json}}
|
||||
|
||||
Antworte NUR mit JSON:
|
||||
{
|
||||
"intent": "continue_plan_goal",
|
||||
"scenario": "additive_constraint",
|
||||
"skill_hints": [{"name": "Schnellkraft", "weight": 1.0}],
|
||||
"focus_hints": [],
|
||||
"style_hints": [],
|
||||
"training_type_hints": [],
|
||||
"target_group_hints": [],
|
||||
"requires_partner": null,
|
||||
"emphasis": "additive",
|
||||
"rationale": "Kurz auf Deutsch, 1 Satz"
|
||||
}$t$,
|
||||
'training',
|
||||
'json',
|
||||
'{"type":"object","required":["intent","scenario"],"properties":{"intent":{"type":"string"},"scenario":{"type":"string"},"skill_hints":{"type":"array"},"emphasis":{"type":"string"},"rationale":{"type":"string"}}}'::jsonb,
|
||||
true,
|
||||
NULL,
|
||||
true,
|
||||
11
|
||||
WHERE NOT EXISTS (SELECT 1 FROM ai_prompts WHERE slug = 'planning_exercise_search_intent');
|
||||
|
||||
UPDATE ai_prompts
|
||||
SET default_template = template
|
||||
WHERE slug = 'planning_exercise_search_intent'
|
||||
AND (default_template IS NULL OR TRIM(default_template) = '');
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
-- Migration 074: KI-Prompt Planungs-Übungssuche — Erwartungsprofil aus Planungskontext (Preset)
|
||||
-- Spec: .claude/docs/working/PLANNING_EXERCISE_SUGGEST_CONTEXT.md §16
|
||||
|
||||
INSERT INTO ai_prompts (
|
||||
slug, display_name, description, template,
|
||||
category, output_format, output_schema, is_system_default, default_template, active, sort_order
|
||||
)
|
||||
SELECT
|
||||
'planning_exercise_expectation_profile',
|
||||
'Planungs-Übungssuche Erwartungsprofil',
|
||||
'Leitet aus Einheit, Abschnitt, Anker und bisherigem Plan ein Erwartungsprofil für die nächste Übung ab (ohne Freitext-Anfrage).',
|
||||
$t$Du bist Assistent für Kampfsport-Trainer in der Trainingsplanung.
|
||||
Der Trainer wählt „nächste Übung aus Kontext“ — es gibt KEINE zusätzliche Freitext-Suchanfrage.
|
||||
|
||||
Deine Aufgabe: Aus dem Planungskontext und dem deterministischen Basis-Zielprofil ein präzises Erwartungsprofil ableiten:
|
||||
- Was soll die nächste Übung fachlich leisten (Fortsetzen, Vertiefen, Lücke schließen, Abwechslung)?
|
||||
- Welche Fähigkeiten, Fokus-Bereiche, Trainingsstile passen dazu?
|
||||
- Berücksichtige: Rahmen/Einheit, Abschnittsziel (guidance_notes), letzte Übung im Abschnitt, Anker-Übung, Skill-Profile Einheit vs. Abschnitt, Skill-Lücken im Basisprofil.
|
||||
|
||||
Intent (intent): meist suggest_next oder continue_plan_goal; progression_next nur wenn Progressionsgraph/Anker klar nahelegt; deepen_exercise nur bei klarer Vertiefungslage.
|
||||
|
||||
continuation (optional, Kurzlabel):
|
||||
- build_on_section: nahtlos an Abschnitt/letzte Übung anknüpfen
|
||||
- close_skill_gap: fehlende Fähigkeiten aus Plan/Rahmen nachziehen
|
||||
- deepen_anchor: Anker-Übung vertiefen
|
||||
- variety: bewusst variieren nach bisherigem Block
|
||||
- balance_load: Belastung ausgleichen / Tempo wechseln
|
||||
|
||||
Nutze skill_hints/focus_hints etc. mit Namen aus den Katalog-JSONs (beste Übereinstimmung).
|
||||
emphasis: fast immer additive (baut auf Basisprofil auf), nur replace wenn Kontext eindeutig neuen Schwerpunkt verlangt.
|
||||
|
||||
Eingabe:
|
||||
Heuristik-Intent: {{heuristic_intent}}
|
||||
Planungskontext: {{planning_context_json}}
|
||||
Basis-Zielprofil (deterministisch): {{target_profile_json}}
|
||||
|
||||
Kataloge (Auszug — nur diese Namen/IDs verwenden):
|
||||
Skills: {{skills_catalog_json}}
|
||||
Fokus: {{focus_areas_catalog_json}}
|
||||
Trainingsstil: {{training_types_catalog_json}}
|
||||
Stilrichtung: {{style_directions_catalog_json}}
|
||||
Zielgruppe: {{target_groups_catalog_json}}
|
||||
|
||||
Antworte NUR mit JSON:
|
||||
{
|
||||
"intent": "suggest_next",
|
||||
"scenario": "preset_next",
|
||||
"continuation": "build_on_section",
|
||||
"skill_hints": [{"name": "Kime", "weight": 0.9}],
|
||||
"focus_hints": [],
|
||||
"style_hints": [],
|
||||
"training_type_hints": [],
|
||||
"target_group_hints": [],
|
||||
"requires_partner": null,
|
||||
"emphasis": "additive",
|
||||
"rationale": "Kurz auf Deutsch, 1–2 Sätze: warum diese nächste Übung sinnvoll ist"
|
||||
}$t$,
|
||||
'training',
|
||||
'json',
|
||||
'{"type":"object","required":["intent","scenario","rationale"],"properties":{"intent":{"type":"string"},"scenario":{"type":"string"},"continuation":{"type":"string"},"skill_hints":{"type":"array"},"emphasis":{"type":"string"},"rationale":{"type":"string"}}}'::jsonb,
|
||||
true,
|
||||
NULL,
|
||||
true,
|
||||
12
|
||||
WHERE NOT EXISTS (SELECT 1 FROM ai_prompts WHERE slug = 'planning_exercise_expectation_profile');
|
||||
|
||||
UPDATE ai_prompts
|
||||
SET default_template = template
|
||||
WHERE slug = 'planning_exercise_expectation_profile'
|
||||
AND (default_template IS NULL OR TRIM(default_template) = '');
|
||||
69
backend/planning_exercise_expectation.py
Normal file
69
backend/planning_exercise_expectation.py
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
"""
|
||||
Preset „Nächste aus Kontext“: LLM leitet Erwartungsprofil aus Planungskontext ab.
|
||||
|
||||
Prompt: planning_exercise_expectation_profile (Migration 074)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any, Dict, Mapping, Optional, Tuple
|
||||
|
||||
from planning_exercise_intent import (
|
||||
PlanningQueryIntentParsed,
|
||||
_compact_json,
|
||||
_load_compact_catalog,
|
||||
_load_skills_catalog_compact,
|
||||
parse_planning_query_intent_response,
|
||||
)
|
||||
from ai_prompt_runtime import AiPromptUnavailableError, load_and_render_ai_prompt
|
||||
from openrouter_chat import (
|
||||
effective_openrouter_model_for_prompt_row,
|
||||
normalize_openrouter_env,
|
||||
openrouter_chat_completion,
|
||||
)
|
||||
|
||||
_logger = logging.getLogger("shinkan.planning_exercise_expectation")
|
||||
|
||||
|
||||
def try_build_planning_expectation_from_context(
|
||||
cur,
|
||||
*,
|
||||
heuristic_intent: str,
|
||||
context_summary: Mapping[str, Any],
|
||||
target_profile_summary: Mapping[str, Any],
|
||||
) -> Tuple[Optional[PlanningQueryIntentParsed], bool]:
|
||||
"""
|
||||
LLM-Erwartungsprofil für preset_next / leere Anfrage mit Planungsbezug.
|
||||
Returns (parsed overlay, applied).
|
||||
"""
|
||||
api_key, _ = normalize_openrouter_env()
|
||||
if not api_key:
|
||||
return None, False
|
||||
|
||||
variables = {
|
||||
"heuristic_intent": heuristic_intent or "suggest_next",
|
||||
"planning_context_json": _compact_json(dict(context_summary or {})),
|
||||
"target_profile_json": _compact_json(dict(target_profile_summary or {})),
|
||||
"skills_catalog_json": _compact_json(_load_skills_catalog_compact(cur)),
|
||||
"focus_areas_catalog_json": _compact_json(_load_compact_catalog(cur, "focus_areas", "id")),
|
||||
"training_types_catalog_json": _compact_json(_load_compact_catalog(cur, "training_types", "id")),
|
||||
"style_directions_catalog_json": _compact_json(_load_compact_catalog(cur, "style_directions", "id")),
|
||||
"target_groups_catalog_json": _compact_json(_load_compact_catalog(cur, "target_groups", "id")),
|
||||
}
|
||||
|
||||
try:
|
||||
prow, rendered = load_and_render_ai_prompt(cur, "planning_exercise_expectation_profile", variables)
|
||||
model = effective_openrouter_model_for_prompt_row(prow)
|
||||
raw = openrouter_chat_completion(api_key=api_key, model=model, user_content=rendered.text)
|
||||
parsed = parse_planning_query_intent_response(raw)
|
||||
if parsed.scenario not in ("preset_next", "continue_plan", "free_search"):
|
||||
parsed = parsed.model_copy(update={"scenario": "preset_next"})
|
||||
return parsed, True
|
||||
except AiPromptUnavailableError:
|
||||
return None, False
|
||||
except Exception as exc:
|
||||
_logger.warning("Planungs-Erwartungsprofil-LLM fehlgeschlagen: %s", exc)
|
||||
return None, False
|
||||
|
||||
|
||||
__all__ = ["try_build_planning_expectation_from_context"]
|
||||
272
backend/planning_exercise_intent.py
Normal file
272
backend/planning_exercise_intent.py
Normal file
|
|
@ -0,0 +1,272 @@
|
|||
"""
|
||||
P1: LLM-Intent aus Planungs-Suchfrage → strukturiertes Query-Overlay für PlanningTargetProfile.
|
||||
|
||||
Prompt: planning_exercise_search_intent (Migration 073)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from typing import Any, Dict, List, Mapping, Optional, Sequence, Set, Tuple
|
||||
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
from ai_prompt_runtime import AiPromptUnavailableError, load_and_render_ai_prompt
|
||||
from openrouter_chat import (
|
||||
effective_openrouter_model_for_prompt_row,
|
||||
normalize_openrouter_env,
|
||||
openrouter_chat_completion,
|
||||
)
|
||||
|
||||
_logger = logging.getLogger("shinkan.planning_exercise_intent")
|
||||
|
||||
VALID_PARSED_INTENTS = {
|
||||
"suggest_next",
|
||||
"progression_next",
|
||||
"deepen_exercise",
|
||||
"continue_plan_goal",
|
||||
"free_search",
|
||||
}
|
||||
|
||||
VALID_SCENARIOS = {
|
||||
"preset_next",
|
||||
"progression",
|
||||
"deepen",
|
||||
"continue_plan",
|
||||
"additive_constraint",
|
||||
"free_search",
|
||||
}
|
||||
|
||||
VALID_EMPHASIS = {"additive", "replace", "neutral"}
|
||||
|
||||
|
||||
class SkillHint(BaseModel):
|
||||
name: str = Field(..., min_length=1, max_length=120)
|
||||
weight: float = Field(default=1.0, ge=0.1, le=1.0)
|
||||
|
||||
|
||||
class PlanningQueryIntentParsed(BaseModel):
|
||||
intent: str = "free_search"
|
||||
scenario: str = "free_search"
|
||||
skill_hints: List[SkillHint] = Field(default_factory=list)
|
||||
focus_hints: List[str] = Field(default_factory=list)
|
||||
style_hints: List[str] = Field(default_factory=list)
|
||||
training_type_hints: List[str] = Field(default_factory=list)
|
||||
target_group_hints: List[str] = Field(default_factory=list)
|
||||
requires_partner: Optional[bool] = None
|
||||
emphasis: str = "additive"
|
||||
rationale: Optional[str] = Field(default=None, max_length=400)
|
||||
|
||||
@field_validator("intent")
|
||||
@classmethod
|
||||
def _intent(cls, v: str) -> str:
|
||||
s = (v or "").strip().lower()
|
||||
return s if s in VALID_PARSED_INTENTS else "free_search"
|
||||
|
||||
@field_validator("scenario")
|
||||
@classmethod
|
||||
def _scenario(cls, v: str) -> str:
|
||||
s = (v or "").strip().lower()
|
||||
return s if s in VALID_SCENARIOS else "free_search"
|
||||
|
||||
@field_validator("emphasis")
|
||||
@classmethod
|
||||
def _emphasis(cls, v: str) -> str:
|
||||
s = (v or "").strip().lower()
|
||||
return s if s in VALID_EMPHASIS else "additive"
|
||||
|
||||
@field_validator("focus_hints", "style_hints", "training_type_hints", "target_group_hints", mode="before")
|
||||
@classmethod
|
||||
def _str_list(cls, v: Any) -> List[str]:
|
||||
if not v:
|
||||
return []
|
||||
if isinstance(v, str):
|
||||
return [v.strip()] if v.strip() else []
|
||||
out: List[str] = []
|
||||
for item in v:
|
||||
s = str(item or "").strip()
|
||||
if s and s not in out:
|
||||
out.append(s[:120])
|
||||
return out[:8]
|
||||
|
||||
|
||||
def _extract_json_object(text: str) -> Dict[str, Any]:
|
||||
s = (text or "").strip()
|
||||
if s.startswith("```"):
|
||||
s = re.sub(r"^```[a-zA-Z0-9]*\s*", "", s)
|
||||
if s.endswith("```"):
|
||||
s = s[:-3].strip()
|
||||
start = s.find("{")
|
||||
end = s.rfind("}")
|
||||
if start < 0 or end <= start:
|
||||
raise ValueError("Kein JSON-Objekt in LLM-Antwort")
|
||||
obj = json.loads(s[start : end + 1])
|
||||
if not isinstance(obj, dict):
|
||||
raise ValueError("LLM-Antwort ist kein JSON-Objekt")
|
||||
return obj
|
||||
|
||||
|
||||
def parse_planning_query_intent_response(text: str) -> PlanningQueryIntentParsed:
|
||||
obj = _extract_json_object(text)
|
||||
return PlanningQueryIntentParsed.model_validate(obj)
|
||||
|
||||
|
||||
def _compact_json(obj: Any) -> str:
|
||||
return json.dumps(obj, ensure_ascii=False, separators=(",", ":"))
|
||||
|
||||
|
||||
def _load_compact_catalog(cur, table: str, id_col: str, name_col: str = "name", limit: int = 80) -> List[Dict[str, Any]]:
|
||||
cur.execute(
|
||||
f"""
|
||||
SELECT {id_col} AS id, {name_col} AS name
|
||||
FROM {table}
|
||||
ORDER BY {name_col} ASC NULLS LAST
|
||||
LIMIT %s
|
||||
""",
|
||||
(limit,),
|
||||
)
|
||||
return [{"id": int(r["id"]), "name": str(r["name"] or "")[:80]} for r in cur.fetchall()]
|
||||
|
||||
|
||||
def _load_skills_catalog_compact(cur, limit: int = 120) -> List[Dict[str, Any]]:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, name, category
|
||||
FROM skills
|
||||
WHERE status IS NULL OR status = 'active'
|
||||
ORDER BY name ASC
|
||||
LIMIT %s
|
||||
""",
|
||||
(limit,),
|
||||
)
|
||||
return [
|
||||
{
|
||||
"id": int(r["id"]),
|
||||
"name": str(r["name"] or "")[:80],
|
||||
"category": str(r.get("category") or "")[:40],
|
||||
}
|
||||
for r in cur.fetchall()
|
||||
]
|
||||
|
||||
|
||||
def _resolve_name_hint(cur, table: str, hint: str, *, extra_where: str = "") -> Optional[int]:
|
||||
h = (hint or "").strip()
|
||||
if len(h) < 2:
|
||||
return None
|
||||
q = h.lower()
|
||||
cur.execute(
|
||||
f"""
|
||||
SELECT id, name
|
||||
FROM {table}
|
||||
WHERE LOWER(name) LIKE %s {extra_where}
|
||||
ORDER BY CASE WHEN LOWER(name) = %s THEN 0 WHEN LOWER(name) LIKE %s THEN 1 ELSE 2 END,
|
||||
LENGTH(name) ASC
|
||||
LIMIT 1
|
||||
""",
|
||||
(f"%{q}%", q, f"{q}%"),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
return int(row["id"]) if row else None
|
||||
|
||||
|
||||
def resolve_query_intent_catalog_ids(
|
||||
cur,
|
||||
parsed: PlanningQueryIntentParsed,
|
||||
) -> Tuple[Dict[int, float], Dict[int, float], Dict[int, float], Dict[int, float], Dict[int, float], List[Dict[str, Any]]]:
|
||||
"""
|
||||
Mappt Text-Hints auf Katalog-IDs. Returns (focus, style, tt, tg, skills, resolved_skills_meta).
|
||||
"""
|
||||
focus: Dict[int, float] = {}
|
||||
style: Dict[int, float] = {}
|
||||
tt: Dict[int, float] = {}
|
||||
tg: Dict[int, float] = {}
|
||||
skills: Dict[int, float] = {}
|
||||
resolved_skills: List[Dict[str, Any]] = []
|
||||
|
||||
for hint in parsed.focus_hints:
|
||||
fid = _resolve_name_hint(cur, "focus_areas", hint)
|
||||
if fid:
|
||||
focus[fid] = max(focus.get(fid, 0.0), 0.9)
|
||||
|
||||
for hint in parsed.style_hints:
|
||||
sid = _resolve_name_hint(cur, "style_directions", hint)
|
||||
if sid:
|
||||
style[sid] = max(style.get(sid, 0.0), 0.85)
|
||||
|
||||
for hint in parsed.training_type_hints:
|
||||
tid = _resolve_name_hint(cur, "training_types", hint)
|
||||
if tid:
|
||||
tt[tid] = max(tt.get(tid, 0.0), 0.85)
|
||||
|
||||
for hint in parsed.target_group_hints:
|
||||
gid = _resolve_name_hint(cur, "target_groups", hint)
|
||||
if gid:
|
||||
tg[gid] = max(tg.get(gid, 0.0), 0.85)
|
||||
|
||||
for sh in parsed.skill_hints[:8]:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, name FROM skills
|
||||
WHERE (status IS NULL OR status = 'active')
|
||||
AND LOWER(name) LIKE %s
|
||||
ORDER BY CASE WHEN LOWER(name) = %s THEN 0 WHEN LOWER(name) LIKE %s THEN 1 ELSE 2 END,
|
||||
LENGTH(name) ASC
|
||||
LIMIT 1
|
||||
""",
|
||||
(f"%{sh.name.lower()}%", sh.name.lower(), f"{sh.name.lower()}%"),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if row:
|
||||
sid = int(row["id"])
|
||||
skills[sid] = max(skills.get(sid, 0.0), float(sh.weight))
|
||||
resolved_skills.append({"skill_id": sid, "name": str(row["name"] or sh.name), "weight": skills[sid]})
|
||||
|
||||
return focus, style, tt, tg, skills, resolved_skills
|
||||
|
||||
|
||||
def try_parse_planning_query_intent(
|
||||
cur,
|
||||
*,
|
||||
query: str,
|
||||
heuristic_intent: str,
|
||||
scenario_hint: str,
|
||||
context_summary: Mapping[str, Any],
|
||||
target_profile_summary: Mapping[str, Any],
|
||||
) -> Tuple[Optional[PlanningQueryIntentParsed], bool]:
|
||||
api_key, _ = normalize_openrouter_env()
|
||||
if not api_key or not (query or "").strip():
|
||||
return None, False
|
||||
|
||||
variables = {
|
||||
"search_query": (query or "").strip(),
|
||||
"heuristic_intent": heuristic_intent or "",
|
||||
"scenario_hint": scenario_hint or "",
|
||||
"planning_context_json": _compact_json(dict(context_summary or {})),
|
||||
"target_profile_json": _compact_json(dict(target_profile_summary or {})),
|
||||
"skills_catalog_json": _compact_json(_load_skills_catalog_compact(cur)),
|
||||
"focus_areas_catalog_json": _compact_json(_load_compact_catalog(cur, "focus_areas", "id")),
|
||||
"training_types_catalog_json": _compact_json(_load_compact_catalog(cur, "training_types", "id")),
|
||||
"style_directions_catalog_json": _compact_json(_load_compact_catalog(cur, "style_directions", "id")),
|
||||
"target_groups_catalog_json": _compact_json(_load_compact_catalog(cur, "target_groups", "id")),
|
||||
}
|
||||
|
||||
try:
|
||||
prow, rendered = load_and_render_ai_prompt(cur, "planning_exercise_search_intent", variables)
|
||||
model = effective_openrouter_model_for_prompt_row(prow)
|
||||
raw = openrouter_chat_completion(api_key=api_key, model=model, user_content=rendered.text)
|
||||
parsed = parse_planning_query_intent_response(raw)
|
||||
return parsed, True
|
||||
except AiPromptUnavailableError:
|
||||
return None, False
|
||||
except Exception as exc:
|
||||
_logger.warning("Planungs-Intent-LLM fehlgeschlagen: %s", exc)
|
||||
return None, False
|
||||
|
||||
|
||||
__all__ = [
|
||||
"PlanningQueryIntentParsed",
|
||||
"parse_planning_query_intent_response",
|
||||
"resolve_query_intent_catalog_ids",
|
||||
"try_parse_planning_query_intent",
|
||||
]
|
||||
223
backend/planning_exercise_llm_rank.py
Normal file
223
backend/planning_exercise_llm_rank.py
Normal file
|
|
@ -0,0 +1,223 @@
|
|||
"""
|
||||
Phase 2 Planungs-Übungssuche: LLM-Rerank über Hybrid-Kandidaten.
|
||||
|
||||
Prompt-Slug: planning_exercise_search_rank (Migration 072)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from typing import Any, Dict, List, Mapping, Optional, Sequence, Set, Tuple
|
||||
|
||||
from ai_prompt_runtime import AiPromptUnavailableError, load_and_render_ai_prompt
|
||||
from exercise_ai import strip_html_to_plain
|
||||
from openrouter_chat import (
|
||||
effective_openrouter_model_for_prompt_row,
|
||||
normalize_openrouter_env,
|
||||
openrouter_chat_completion,
|
||||
)
|
||||
|
||||
_logger = logging.getLogger("shinkan.planning_exercise_llm_rank")
|
||||
|
||||
_LLM_RERANK_POOL = 32
|
||||
_MAX_GOAL_PLAIN = 480
|
||||
_MAX_SUMMARY_PLAIN = 320
|
||||
_MAX_REASON_LEN = 160
|
||||
|
||||
|
||||
def _compact_json(obj: Any) -> str:
|
||||
return json.dumps(obj, ensure_ascii=False, separators=(",", ":"))
|
||||
|
||||
|
||||
def _extract_json_object(text: str) -> Dict[str, Any]:
|
||||
s = (text or "").strip()
|
||||
if s.startswith("```"):
|
||||
s = re.sub(r"^```[a-zA-Z0-9]*\s*", "", s)
|
||||
if s.endswith("```"):
|
||||
s = s[:-3].strip()
|
||||
start = s.find("{")
|
||||
end = s.rfind("}")
|
||||
if start < 0 or end <= start:
|
||||
raise ValueError("Kein JSON-Objekt in LLM-Antwort")
|
||||
obj = json.loads(s[start : end + 1])
|
||||
if not isinstance(obj, dict):
|
||||
raise ValueError("LLM-Antwort ist kein JSON-Objekt")
|
||||
return obj
|
||||
|
||||
|
||||
def parse_planning_exercise_rank_response(
|
||||
text: str,
|
||||
allowed_ids: Set[int],
|
||||
) -> Tuple[List[int], Dict[int, str]]:
|
||||
"""
|
||||
Validiert LLM-Ranking: nur erlaubte exercise_id, dedupliziert, Reihenfolge beibehalten.
|
||||
"""
|
||||
obj = _extract_json_object(text)
|
||||
ranked_raw = obj.get("ranked_ids") or obj.get("ranked") or obj.get("ids")
|
||||
if not isinstance(ranked_raw, list):
|
||||
raise ValueError("ranked_ids fehlt oder ist keine Liste")
|
||||
|
||||
ranked: List[int] = []
|
||||
seen: Set[int] = set()
|
||||
for raw in ranked_raw:
|
||||
try:
|
||||
eid = int(raw)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if eid < 1 or eid not in allowed_ids or eid in seen:
|
||||
continue
|
||||
seen.add(eid)
|
||||
ranked.append(eid)
|
||||
|
||||
reasons_out: Dict[int, str] = {}
|
||||
reasons_raw = obj.get("reasons") or obj.get("reasons_by_id") or {}
|
||||
if isinstance(reasons_raw, dict):
|
||||
for k, v in reasons_raw.items():
|
||||
try:
|
||||
eid = int(k)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if eid not in allowed_ids:
|
||||
continue
|
||||
txt = str(v or "").strip()
|
||||
if txt:
|
||||
reasons_out[eid] = txt[:_MAX_REASON_LEN]
|
||||
|
||||
return ranked, reasons_out
|
||||
|
||||
|
||||
def _build_candidate_payload(
|
||||
hit: Mapping[str, Any],
|
||||
*,
|
||||
goal_plain: str,
|
||||
skill_names: Sequence[str],
|
||||
) -> Dict[str, Any]:
|
||||
return {
|
||||
"id": int(hit["id"]),
|
||||
"title": str(hit.get("title") or "").strip()[:200],
|
||||
"summary": strip_html_to_plain(hit.get("summary"), max_len=_MAX_SUMMARY_PLAIN),
|
||||
"goal": goal_plain,
|
||||
"skills": list(skill_names)[:8],
|
||||
"retrieval_score": float(hit.get("score") or 0.0),
|
||||
}
|
||||
|
||||
|
||||
def _load_exercise_goals(cur, exercise_ids: Sequence[int]) -> Dict[int, str]:
|
||||
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 id, goal FROM exercises WHERE id IN ({ph})",
|
||||
ids,
|
||||
)
|
||||
return {int(r["id"]): str(r.get("goal") or "") for r in cur.fetchall()}
|
||||
|
||||
|
||||
def _load_skill_names(cur, skill_ids: Sequence[int]) -> Dict[int, str]:
|
||||
ids = sorted({int(x) for x in skill_ids if int(x) > 0})
|
||||
if not ids:
|
||||
return {}
|
||||
ph = ",".join(["%s"] * len(ids))
|
||||
cur.execute(f"SELECT id, name FROM skills WHERE id IN ({ph})", ids)
|
||||
return {int(r["id"]): str(r.get("name") or "") for r in cur.fetchall()}
|
||||
|
||||
|
||||
def try_llm_rerank_planning_hits(
|
||||
cur,
|
||||
*,
|
||||
hits: List[Dict[str, Any]],
|
||||
skills_by_ex: Mapping[int, Set[int]],
|
||||
query: str,
|
||||
intent: str,
|
||||
context_summary: Mapping[str, Any],
|
||||
target_profile_summary: Mapping[str, Any],
|
||||
limit: int,
|
||||
) -> Tuple[List[Dict[str, Any]], bool]:
|
||||
"""
|
||||
Optionaler LLM-Rerank der Top-Kandidaten. Bei Fehler: Original-Reihenfolge, llm_applied=False.
|
||||
"""
|
||||
if not hits:
|
||||
return hits, False
|
||||
|
||||
api_key, _ = normalize_openrouter_env()
|
||||
if not api_key:
|
||||
return hits, False
|
||||
|
||||
pool = hits[:_LLM_RERANK_POOL]
|
||||
allowed_ids = {int(h["id"]) for h in pool}
|
||||
goals = _load_exercise_goals(cur, list(allowed_ids))
|
||||
|
||||
all_skill_ids: Set[int] = set()
|
||||
for eid in allowed_ids:
|
||||
all_skill_ids.update(skills_by_ex.get(eid) or set())
|
||||
skill_name_map = _load_skill_names(cur, list(all_skill_ids))
|
||||
|
||||
candidates: List[Dict[str, Any]] = []
|
||||
for hit in pool:
|
||||
eid = int(hit["id"])
|
||||
sk_ids = sorted(skills_by_ex.get(eid) or set())
|
||||
sk_names = [skill_name_map.get(sid, f"#{sid}") for sid in sk_ids[:8]]
|
||||
goal_plain = strip_html_to_plain(goals.get(eid), max_len=_MAX_GOAL_PLAIN)
|
||||
candidates.append(
|
||||
_build_candidate_payload(hit, goal_plain=goal_plain, skill_names=sk_names)
|
||||
)
|
||||
|
||||
variables = {
|
||||
"search_query": query or "",
|
||||
"intent": intent or "",
|
||||
"planning_context_json": _compact_json(dict(context_summary or {})),
|
||||
"target_profile_json": _compact_json(dict(target_profile_summary or {})),
|
||||
"candidates_json": _compact_json(candidates),
|
||||
"result_limit": str(max(1, min(int(limit), 50))),
|
||||
}
|
||||
|
||||
try:
|
||||
prow, rendered = load_and_render_ai_prompt(cur, "planning_exercise_search_rank", variables)
|
||||
model = effective_openrouter_model_for_prompt_row(prow)
|
||||
raw = openrouter_chat_completion(
|
||||
api_key=api_key,
|
||||
model=model,
|
||||
user_content=rendered.text,
|
||||
)
|
||||
ranked_ids, llm_reasons = parse_planning_exercise_rank_response(raw, allowed_ids)
|
||||
except AiPromptUnavailableError:
|
||||
return hits, False
|
||||
except Exception as exc:
|
||||
_logger.warning("Planungs-LLM-Rerank fehlgeschlagen: %s", exc)
|
||||
return hits, False
|
||||
|
||||
if not ranked_ids:
|
||||
return hits, False
|
||||
|
||||
hit_by_id = {int(h["id"]): h for h in hits}
|
||||
reranked: List[Dict[str, Any]] = []
|
||||
used: Set[int] = set()
|
||||
for eid in ranked_ids:
|
||||
hit = hit_by_id.get(eid)
|
||||
if not hit:
|
||||
continue
|
||||
used.add(eid)
|
||||
new_hit = dict(hit)
|
||||
reasons = list(hit.get("reasons") or [])
|
||||
llm_reason = llm_reasons.get(eid)
|
||||
if llm_reason and llm_reason not in reasons:
|
||||
reasons.insert(0, llm_reason)
|
||||
new_hit["reasons"] = reasons
|
||||
new_hit["llm_rank"] = len(reranked) + 1
|
||||
reranked.append(new_hit)
|
||||
|
||||
for hit in hits:
|
||||
eid = int(hit["id"])
|
||||
if eid in used:
|
||||
continue
|
||||
reranked.append(dict(hit))
|
||||
|
||||
return reranked[: max(int(limit), len(reranked))], True
|
||||
|
||||
|
||||
__all__ = [
|
||||
"parse_planning_exercise_rank_response",
|
||||
"try_llm_rerank_planning_hits",
|
||||
]
|
||||
498
backend/planning_exercise_profiles.py
Normal file
498
backend/planning_exercise_profiles.py
Normal file
|
|
@ -0,0 +1,498 @@
|
|||
"""
|
||||
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)
|
||||
skill_plan_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 _profile_from_exercise_ids(cur, exercise_ids: Sequence[int]) -> Dict[int, float]:
|
||||
ids = [int(x) for x in exercise_ids if int(x) > 0]
|
||||
if not ids:
|
||||
return {}
|
||||
occ = [ExerciseOccurrence(exercise_id=eid) for eid in ids]
|
||||
prof = profile_for_occurrences(cur, occ, reference_max_by_skill=None)
|
||||
return _skill_weights_from_profile(prof.get("skills") or [])
|
||||
|
||||
|
||||
def skill_profile_summary_from_exercise_ids(
|
||||
cur,
|
||||
exercise_ids: Sequence[int],
|
||||
*,
|
||||
limit_skills: int = 8,
|
||||
) -> Dict[str, Any]:
|
||||
"""Kompaktes Fähigkeitenprofil für LLM-Kontext und UI."""
|
||||
ids = [int(x) for x in exercise_ids if int(x) > 0]
|
||||
if not ids:
|
||||
return {"exercise_count": 0, "skills": []}
|
||||
occ = [ExerciseOccurrence(exercise_id=eid) for eid in ids]
|
||||
prof = profile_for_occurrences(cur, occ, reference_max_by_skill=None)
|
||||
skills_out = prof.get("skills") or []
|
||||
top = sorted(skills_out, key=lambda s: -float(s.get("weight") or s.get("score") or 0))[:limit_skills]
|
||||
names = _load_skill_names(cur, [int(s["skill_id"]) for s in top if s.get("skill_id") is not None])
|
||||
return {
|
||||
"exercise_count": len(ids),
|
||||
"skills": [
|
||||
{
|
||||
"skill_id": int(s["skill_id"]),
|
||||
"name": names.get(int(s["skill_id"]), f"#{s['skill_id']}"),
|
||||
"weight": round(float(s.get("weight") or s.get("score") or 0), 3),
|
||||
}
|
||||
for s in top
|
||||
if s.get("skill_id") is not None
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def build_planning_target_profile(
|
||||
cur,
|
||||
*,
|
||||
unit: Dict[str, Any],
|
||||
planned_exercise_ids: Sequence[int],
|
||||
section_planned_exercise_ids: Optional[Sequence[int]] = None,
|
||||
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")
|
||||
|
||||
section_ids = [int(x) for x in (section_planned_exercise_ids or []) if int(x) > 0]
|
||||
if section_ids:
|
||||
section_skills = _profile_from_exercise_ids(cur, section_ids)
|
||||
if section_skills:
|
||||
skill_target = _merge_weight_maps(skill_target, section_skills, scale=1.0)
|
||||
sources.append("current_section_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,
|
||||
skill_plan_weights=skill_plan_norm,
|
||||
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")
|
||||
if "query_intent" in (target.sources or []):
|
||||
reasons.append("Passt zur KI-interpretierten Suchanfrage")
|
||||
|
||||
# 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"
|
||||
309
backend/planning_exercise_retrieval.py
Normal file
309
backend/planning_exercise_retrieval.py
Normal file
|
|
@ -0,0 +1,309 @@
|
|||
"""
|
||||
Mehrstufiges Retrieval für Planungs-Übungssuche (Phase A).
|
||||
|
||||
Stufen:
|
||||
S1b-0 Gesamte sichtbare Bibliothek (Governance + Hard-Filter, kein Profil-OR-Pool)
|
||||
S1b-1 Deterministischer Hybrid-Score auf allen Kandidaten → sortiert
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List, Mapping, Optional, Sequence, Set, Tuple
|
||||
|
||||
from planning_exercise_profiles import (
|
||||
PlanningTargetProfile,
|
||||
load_exercise_match_profiles_bulk,
|
||||
score_exercise_against_target,
|
||||
)
|
||||
|
||||
_MAX_LIBRARY_ROWS = 8000
|
||||
_PROFILE_LOAD_BATCH = 400
|
||||
|
||||
|
||||
def _skill_jaccard(a: Set[int], b: Set[int]) -> float:
|
||||
if not a or not b:
|
||||
return 0.0
|
||||
inter = len(a & b)
|
||||
union = len(a | b)
|
||||
return inter / union if union else 0.0
|
||||
|
||||
|
||||
def _normalize_exercise_kind_filter(exercise_kind_any: Optional[List[str]]) -> List[str]:
|
||||
out: List[str] = []
|
||||
if not exercise_kind_any:
|
||||
return out
|
||||
for raw in exercise_kind_any:
|
||||
s = str(raw or "").strip().lower()
|
||||
if s in ("simple", "combination") and s not in out:
|
||||
out.append(s)
|
||||
return out
|
||||
|
||||
|
||||
def fetch_all_visible_exercise_rows(
|
||||
cur,
|
||||
*,
|
||||
vis_sql: str,
|
||||
vis_params: Sequence[Any],
|
||||
query: str,
|
||||
exercise_kind_any: Optional[List[str]],
|
||||
max_rows: int = _MAX_LIBRARY_ROWS,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
S1b-0: Alle sichtbaren Übungen (ohne Profil-/Volltext-Pool-Vorselektion).
|
||||
|
||||
Hard-Filter: Governance, nicht archiviert, optional exercise_kind.
|
||||
Volltext-Rank nur als Score-Signal in SELECT, nicht als WHERE-Filter.
|
||||
"""
|
||||
where = [vis_sql, "COALESCE(e.status, '') <> %s"]
|
||||
params: List[Any] = []
|
||||
|
||||
if query:
|
||||
ft_select = "ts_rank_cd(e.search_vector, plainto_tsquery('german', %s)) AS ft_rank"
|
||||
params.append(query)
|
||||
else:
|
||||
ft_select = "0.0::float AS ft_rank"
|
||||
|
||||
params.extend(vis_params)
|
||||
params.append("archived")
|
||||
|
||||
ek_filtered = _normalize_exercise_kind_filter(exercise_kind_any)
|
||||
if ek_filtered:
|
||||
ph = ",".join(["%s"] * len(ek_filtered))
|
||||
where.append(f"(LOWER(TRIM(COALESCE(e.exercise_kind::text,''))) IN ({ph}))")
|
||||
params.extend(ek_filtered)
|
||||
|
||||
sql = f"""
|
||||
SELECT e.id, e.title, e.summary,
|
||||
(
|
||||
SELECT fa.name FROM exercise_focus_areas efa
|
||||
JOIN focus_areas fa ON fa.id = efa.focus_area_id
|
||||
WHERE efa.exercise_id = e.id
|
||||
ORDER BY efa.is_primary DESC NULLS LAST, fa.name ASC
|
||||
LIMIT 1
|
||||
) AS primary_focus_name,
|
||||
{ft_select}
|
||||
FROM exercises e
|
||||
WHERE {' AND '.join(where)}
|
||||
ORDER BY e.id ASC
|
||||
LIMIT %s
|
||||
"""
|
||||
params.append(int(max_rows))
|
||||
cur.execute(sql, params)
|
||||
return [dict(r) for r in cur.fetchall()]
|
||||
|
||||
|
||||
def _load_match_profiles_chunked(cur, exercise_ids: Sequence[int], *, batch: int = _PROFILE_LOAD_BATCH):
|
||||
ids = sorted({int(x) for x in exercise_ids if int(x) > 0})
|
||||
if not ids:
|
||||
return {}
|
||||
out: Dict[int, Any] = {}
|
||||
for i in range(0, len(ids), batch):
|
||||
chunk = ids[i : i + batch]
|
||||
out.update(load_exercise_match_profiles_bulk(cur, chunk))
|
||||
return out
|
||||
|
||||
|
||||
def _load_skill_sets_chunked(cur, exercise_ids: Sequence[int], *, batch: int = _PROFILE_LOAD_BATCH) -> Dict[int, Set[int]]:
|
||||
ids = sorted({int(x) for x in exercise_ids if int(x) > 0})
|
||||
out: Dict[int, Set[int]] = {eid: set() for eid in ids}
|
||||
if not ids:
|
||||
return out
|
||||
for i in range(0, len(ids), batch):
|
||||
chunk = ids[i : i + batch]
|
||||
ph = ",".join(["%s"] * len(chunk))
|
||||
cur.execute(
|
||||
f"SELECT exercise_id, skill_id FROM exercise_skills WHERE exercise_id IN ({ph})",
|
||||
chunk,
|
||||
)
|
||||
for row in cur.fetchall():
|
||||
eid = int(row["exercise_id"])
|
||||
sid = row.get("skill_id")
|
||||
if sid is not None:
|
||||
out.setdefault(eid, set()).add(int(sid))
|
||||
return out
|
||||
|
||||
|
||||
def rank_visible_library_hits(
|
||||
cur,
|
||||
rows: Sequence[Dict[str, Any]],
|
||||
*,
|
||||
query: str,
|
||||
intent: str,
|
||||
intent_weights: Mapping[str, float],
|
||||
target: PlanningTargetProfile,
|
||||
pack: Mapping[str, Any],
|
||||
) -> Tuple[List[Dict[str, Any]], Dict[int, Set[int]]]:
|
||||
"""S1b-1: Hybrid-Score auf der gesamten sichtbaren Bibliothek."""
|
||||
planned_set = set(pack.get("planned_exercise_ids") or [])
|
||||
group_recent_set = set(pack.get("group_recent_exercise_ids") or [])
|
||||
progression_set = set(pack.get("progression_successor_ids") or [])
|
||||
anchor_skills = set(pack.get("anchor_skill_ids") or [])
|
||||
anchor_id = pack.get("anchor_exercise_id")
|
||||
progression_notes = pack.get("progression_edge_notes") or {}
|
||||
|
||||
last_planned_skills: Set[int] = set()
|
||||
planned_ids = pack.get("planned_exercise_ids") or []
|
||||
if planned_ids:
|
||||
cur.execute(
|
||||
"SELECT skill_id FROM exercise_skills WHERE exercise_id = %s",
|
||||
(int(planned_ids[-1]),),
|
||||
)
|
||||
last_planned_skills = {int(r["skill_id"]) for r in cur.fetchall() if r.get("skill_id")}
|
||||
|
||||
cand_rows: List[Dict[str, Any]] = []
|
||||
for row in rows:
|
||||
eid = int(row["id"])
|
||||
if anchor_id and eid == int(anchor_id):
|
||||
continue
|
||||
cand_rows.append(row)
|
||||
|
||||
cand_ids = [int(r["id"]) for r in cand_rows]
|
||||
match_profiles = _load_match_profiles_chunked(cur, cand_ids)
|
||||
skills_by_ex = _load_skill_sets_chunked(cur, cand_ids)
|
||||
|
||||
max_ft = 0.0
|
||||
scored_items: List[Dict[str, Any]] = []
|
||||
for row in cand_rows:
|
||||
eid = int(row["id"])
|
||||
ft = float(row.get("ft_rank") or 0.0)
|
||||
if ft > max_ft:
|
||||
max_ft = ft
|
||||
scored_items.append(
|
||||
{
|
||||
"row": row,
|
||||
"eid": eid,
|
||||
"ft": ft,
|
||||
"skills": skills_by_ex.get(eid, set()),
|
||||
}
|
||||
)
|
||||
|
||||
weights = dict(intent_weights)
|
||||
hits: List[Dict[str, Any]] = []
|
||||
for item in scored_items:
|
||||
eid = item["eid"]
|
||||
row = item["row"]
|
||||
ft_norm = (item["ft"] / max_ft) if max_ft > 0 else 0.0
|
||||
prog_hit = 1.0 if eid in progression_set else 0.0
|
||||
skill_sim = _skill_jaccard(anchor_skills, item["skills"]) if anchor_skills else 0.0
|
||||
plan_aff = 0.0
|
||||
if last_planned_skills and item["skills"]:
|
||||
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, 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
|
||||
)
|
||||
|
||||
reasons: List[str] = []
|
||||
if query and ft_norm >= 0.35:
|
||||
reasons.append("Volltext-Treffer")
|
||||
if prog_hit > 0:
|
||||
note = progression_notes.get(eid)
|
||||
reasons.append(
|
||||
f"Nachfolger im Progressionsgraph{f': {note}' if note else ''}"
|
||||
)
|
||||
if skill_sim >= 0.2 and anchor_id:
|
||||
reasons.append("Fähigkeiten passen zur Anker-Übung")
|
||||
if plan_aff >= 0.25:
|
||||
reasons.append("Schließt an Skills der letzten geplanten Übung an")
|
||||
if repeat_unit > 0:
|
||||
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:
|
||||
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(
|
||||
{
|
||||
"id": eid,
|
||||
"title": row.get("title"),
|
||||
"summary": row.get("summary"),
|
||||
"focus_area": row.get("primary_focus_name"),
|
||||
"score": round(max(0.0, min(1.0, score)), 4),
|
||||
"reasons": reasons,
|
||||
}
|
||||
)
|
||||
|
||||
hits.sort(key=lambda h: (-h["score"], h.get("title") or ""))
|
||||
return hits, skills_by_ex
|
||||
|
||||
|
||||
def run_multistage_planning_retrieval(
|
||||
cur,
|
||||
*,
|
||||
vis_sql: str,
|
||||
vis_params: Sequence[Any],
|
||||
query: str,
|
||||
exercise_kind_any: Optional[List[str]],
|
||||
target: PlanningTargetProfile,
|
||||
intent: str,
|
||||
intent_weights: Mapping[str, float],
|
||||
pack: Mapping[str, Any],
|
||||
) -> Tuple[List[Dict[str, Any]], Dict[int, Set[int]], bool]:
|
||||
"""Orchestriert S1b-0 → S1b-1 (Voll-Library-Ranking)."""
|
||||
rows = fetch_all_visible_exercise_rows(
|
||||
cur,
|
||||
vis_sql=vis_sql,
|
||||
vis_params=vis_params,
|
||||
query=query,
|
||||
exercise_kind_any=exercise_kind_any,
|
||||
)
|
||||
hits, skills_by_ex = rank_visible_library_hits(
|
||||
cur,
|
||||
rows,
|
||||
query=query,
|
||||
intent=intent,
|
||||
intent_weights=intent_weights,
|
||||
target=target,
|
||||
pack=pack,
|
||||
)
|
||||
full_library_ranked = len(rows) > 0
|
||||
return hits, skills_by_ex, full_library_ranked
|
||||
|
||||
|
||||
# Legacy-Alias für Tests / externe Imports
|
||||
fetch_retrieval_candidate_rows = fetch_all_visible_exercise_rows
|
||||
hybrid_score_planning_hits = rank_visible_library_hits
|
||||
|
||||
|
||||
def profile_preselect_rows(
|
||||
cur,
|
||||
rows: Sequence[Dict[str, Any]],
|
||||
*,
|
||||
target: PlanningTargetProfile,
|
||||
intent: str,
|
||||
progression_successor_ids: Set[int],
|
||||
query: str,
|
||||
preselect_limit: int = 160,
|
||||
) -> Tuple[List[Dict[str, Any]], bool]:
|
||||
"""Deprecated: Phase A rankt die volle Library — keine separate Vorselektion."""
|
||||
_ = (cur, target, intent, progression_successor_ids, query, preselect_limit)
|
||||
return list(rows), False
|
||||
|
||||
|
||||
__all__ = [
|
||||
"fetch_all_visible_exercise_rows",
|
||||
"fetch_retrieval_candidate_rows",
|
||||
"hybrid_score_planning_hits",
|
||||
"profile_preselect_rows",
|
||||
"rank_visible_library_hits",
|
||||
"run_multistage_planning_retrieval",
|
||||
]
|
||||
732
backend/planning_exercise_suggest.py
Normal file
732
backend/planning_exercise_suggest.py
Normal file
|
|
@ -0,0 +1,732 @@
|
|||
"""
|
||||
Planungs-KI P0: Kontext-Pack + Hybrid-Retrieval für Übungssuche in der Trainingsplanung.
|
||||
|
||||
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 fastapi import HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
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_target_pipeline import (
|
||||
build_planning_target_with_query_pipeline,
|
||||
compose_retrieval_phase,
|
||||
should_run_llm_rank_pipeline,
|
||||
)
|
||||
|
||||
# Planungs-Berechtigung + Sektionen (bestehende Implementierung)
|
||||
from routers.training_planning import (
|
||||
_assert_training_unit_permission,
|
||||
_fetch_sections,
|
||||
_has_planning_role,
|
||||
)
|
||||
|
||||
INTENT_SUGGEST_NEXT = "suggest_next"
|
||||
INTENT_PROGRESSION_NEXT = "progression_next"
|
||||
INTENT_DEEPEN_EXERCISE = "deepen_exercise"
|
||||
INTENT_CONTINUE_PLAN = "continue_plan_goal"
|
||||
INTENT_FREE_SEARCH = "free_search"
|
||||
|
||||
VALID_INTENTS = {
|
||||
INTENT_SUGGEST_NEXT,
|
||||
INTENT_PROGRESSION_NEXT,
|
||||
INTENT_DEEPEN_EXERCISE,
|
||||
INTENT_CONTINUE_PLAN,
|
||||
INTENT_FREE_SEARCH,
|
||||
}
|
||||
|
||||
|
||||
_LLM_RERANK_PRE_LIMIT = 32
|
||||
|
||||
|
||||
class PlanningExerciseSuggestRequest(BaseModel):
|
||||
unit_id: Optional[int] = Field(default=None, ge=1)
|
||||
group_id: Optional[int] = Field(default=None, ge=1)
|
||||
section_order_index: Optional[int] = Field(default=None, ge=0)
|
||||
phase_order_index: Optional[int] = Field(default=None, ge=0)
|
||||
parallel_stream_order_index: Optional[int] = Field(default=None, ge=0)
|
||||
anchor_exercise_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
|
||||
planned_exercise_ids: Optional[List[int]] = None
|
||||
section_title: Optional[str] = None
|
||||
section_guidance_notes: Optional[str] = None
|
||||
section_planned_exercise_ids: Optional[List[int]] = None
|
||||
include_llm_intent: bool = True
|
||||
include_llm_rank: bool = False
|
||||
limit: int = Field(default=20, ge=1, le=50)
|
||||
exercise_kind_any: Optional[List[str]] = None
|
||||
|
||||
|
||||
def resolve_planning_exercise_intent(query: Optional[str], intent_hint: Optional[str]) -> str:
|
||||
hint = (intent_hint or "").strip().lower()
|
||||
if hint in VALID_INTENTS:
|
||||
return hint
|
||||
q = (query or "").strip().lower()
|
||||
if not q:
|
||||
return INTENT_SUGGEST_NEXT
|
||||
if any(w in q for w in ("nächste", "naechste", "vorschlag", "vorschlagen", "empfehl")):
|
||||
return INTENT_SUGGEST_NEXT
|
||||
if "vertief" in q:
|
||||
return INTENT_DEEPEN_EXERCISE
|
||||
if "progression" in q or "graph" in q or "pfad" in q:
|
||||
return INTENT_PROGRESSION_NEXT
|
||||
if "aufbau" in q or "planung" in q or "bisher" in q:
|
||||
return INTENT_CONTINUE_PLAN
|
||||
return INTENT_FREE_SEARCH
|
||||
|
||||
|
||||
def _intent_weights(intent: str) -> Dict[str, float]:
|
||||
base = {
|
||||
"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.28,
|
||||
"skill": 0.12,
|
||||
"plan": 0.10,
|
||||
"profile": 0.25,
|
||||
"fulltext": 0.08,
|
||||
}
|
||||
if intent == INTENT_PROGRESSION_NEXT:
|
||||
return {**base, "progression": 0.42, "fulltext": 0.12, "skill": 0.10, "profile": 0.20}
|
||||
if intent == INTENT_DEEPEN_EXERCISE:
|
||||
return {**base, "skill": 0.15, "profile": 0.35, "fulltext": 0.15, "progression": 0.10}
|
||||
if intent == INTENT_CONTINUE_PLAN:
|
||||
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.45, "progression": 0.08, "skill": 0.08, "profile": 0.15}
|
||||
return base
|
||||
|
||||
|
||||
def _collect_planned_exercise_ids(sections: Sequence[Dict[str, Any]]) -> List[int]:
|
||||
out: List[int] = []
|
||||
seen: Set[int] = set()
|
||||
for sec in sorted(sections, key=lambda s: int(s.get("order_index") or 0)):
|
||||
items = sec.get("items") or []
|
||||
for it in sorted(items, key=lambda x: int(x.get("order_index") or 0)):
|
||||
if str(it.get("item_type") or "").strip().lower() == "note":
|
||||
continue
|
||||
raw = it.get("exercise_id")
|
||||
if raw is None:
|
||||
continue
|
||||
try:
|
||||
eid = int(raw)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if eid < 1 or eid in seen:
|
||||
continue
|
||||
seen.add(eid)
|
||||
out.append(eid)
|
||||
return out
|
||||
|
||||
|
||||
def _resolve_anchor_from_plan(
|
||||
planned_ids: Sequence[int],
|
||||
anchor_exercise_id: Optional[int],
|
||||
) -> Optional[int]:
|
||||
if anchor_exercise_id and int(anchor_exercise_id) > 0:
|
||||
return int(anchor_exercise_id)
|
||||
if planned_ids:
|
||||
return int(planned_ids[-1])
|
||||
return None
|
||||
|
||||
|
||||
def _load_exercise_titles(cur, exercise_ids: Sequence[int]) -> Dict[int, str]:
|
||||
if not exercise_ids:
|
||||
return {}
|
||||
ids = list(dict.fromkeys(int(x) for x in exercise_ids if int(x) > 0))
|
||||
ph = ",".join(["%s"] * len(ids))
|
||||
cur.execute(
|
||||
f"SELECT id, title FROM exercises WHERE id IN ({ph})",
|
||||
ids,
|
||||
)
|
||||
return {int(r["id"]): str(r["title"] or "").strip() for r in cur.fetchall()}
|
||||
|
||||
|
||||
def _load_skill_ids_for_exercise(cur, exercise_id: Optional[int]) -> Set[int]:
|
||||
if not exercise_id:
|
||||
return set()
|
||||
cur.execute(
|
||||
"SELECT skill_id FROM exercise_skills WHERE exercise_id = %s",
|
||||
(int(exercise_id),),
|
||||
)
|
||||
return {int(r["skill_id"]) for r in cur.fetchall() if r.get("skill_id")}
|
||||
|
||||
|
||||
def _load_progression_successors(
|
||||
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)),
|
||||
)
|
||||
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(
|
||||
cur,
|
||||
group_id: Optional[int],
|
||||
exclude_unit_id: Optional[int] = None,
|
||||
limit: int = 40,
|
||||
) -> Set[int]:
|
||||
if not group_id:
|
||||
return set()
|
||||
if exclude_unit_id is not None:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT tusi.exercise_id AS eid
|
||||
FROM training_units tu
|
||||
INNER JOIN training_unit_sections tus ON tus.training_unit_id = tu.id
|
||||
INNER JOIN training_unit_section_items tusi ON tusi.section_id = tus.id
|
||||
WHERE tu.group_id = %s
|
||||
AND tu.id <> %s
|
||||
AND tusi.exercise_id IS NOT NULL
|
||||
AND COALESCE(tu.status, '') <> 'cancelled'
|
||||
ORDER BY tu.planned_date DESC NULLS LAST, tu.id DESC, tusi.order_index DESC
|
||||
LIMIT 200
|
||||
""",
|
||||
(int(group_id), int(exclude_unit_id)),
|
||||
)
|
||||
else:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT tusi.exercise_id AS eid
|
||||
FROM training_units tu
|
||||
INNER JOIN training_unit_sections tus ON tus.training_unit_id = tu.id
|
||||
INNER JOIN training_unit_section_items tusi ON tusi.section_id = tus.id
|
||||
WHERE tu.group_id = %s
|
||||
AND tusi.exercise_id IS NOT NULL
|
||||
AND COALESCE(tu.status, '') <> 'cancelled'
|
||||
ORDER BY tu.planned_date DESC NULLS LAST, tu.id DESC, tusi.order_index DESC
|
||||
LIMIT 200
|
||||
""",
|
||||
(int(group_id),),
|
||||
)
|
||||
out: Set[int] = set()
|
||||
for r in cur.fetchall():
|
||||
if r.get("eid") is None:
|
||||
continue
|
||||
out.add(int(r["eid"]))
|
||||
if len(out) >= limit:
|
||||
break
|
||||
return out
|
||||
|
||||
|
||||
def _section_for_context(
|
||||
sections: Sequence[Dict[str, Any]],
|
||||
section_order_index: Optional[int],
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
if section_order_index is None:
|
||||
return None
|
||||
target = int(section_order_index)
|
||||
for sec in sections:
|
||||
if int(sec.get("order_index") or -1) == target:
|
||||
return sec
|
||||
if 0 <= target < len(sections):
|
||||
return sections[target]
|
||||
return None
|
||||
|
||||
|
||||
def _collect_exercise_ids_from_section(sec: Optional[Dict[str, Any]]) -> List[int]:
|
||||
if not sec:
|
||||
return []
|
||||
out: List[int] = []
|
||||
seen: Set[int] = set()
|
||||
for it in sorted(sec.get("items") or [], key=lambda x: int(x.get("order_index") or 0)):
|
||||
if str(it.get("item_type") or "").strip().lower() == "note":
|
||||
continue
|
||||
raw = it.get("exercise_id")
|
||||
if raw is None:
|
||||
continue
|
||||
try:
|
||||
eid = int(raw)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if eid < 1 or eid in seen:
|
||||
continue
|
||||
seen.add(eid)
|
||||
out.append(eid)
|
||||
return out
|
||||
|
||||
|
||||
def _resolve_last_exercise_in_section(sec: Optional[Dict[str, Any]]) -> Tuple[Optional[int], Optional[str]]:
|
||||
if not sec:
|
||||
return None, None
|
||||
last_id: Optional[int] = None
|
||||
last_title: Optional[str] = None
|
||||
for it in sorted(sec.get("items") or [], key=lambda x: int(x.get("order_index") or 0)):
|
||||
if str(it.get("item_type") or "").strip().lower() == "note":
|
||||
continue
|
||||
raw = it.get("exercise_id")
|
||||
if raw is None:
|
||||
continue
|
||||
try:
|
||||
eid = int(raw)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if eid < 1:
|
||||
continue
|
||||
last_id = eid
|
||||
t = (it.get("exercise_title") or "").strip()
|
||||
last_title = t or None
|
||||
return last_id, last_title
|
||||
|
||||
|
||||
def _attach_planning_context_details(
|
||||
cur,
|
||||
pack: Dict[str, Any],
|
||||
*,
|
||||
sections: Optional[Sequence[Dict[str, Any]]] = None,
|
||||
body: Optional[PlanningExerciseSuggestRequest] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Abschnitt, Fähigkeitenprofile und letzte Übung anreichern."""
|
||||
sec: Optional[Dict[str, Any]] = None
|
||||
section_idx = pack.get("section_order_index")
|
||||
if sections is not None and section_idx is not None:
|
||||
sec = _section_for_context(sections, section_idx)
|
||||
|
||||
section_ids = _collect_exercise_ids_from_section(sec)
|
||||
if body and body.section_planned_exercise_ids:
|
||||
section_ids = []
|
||||
seen: Set[int] = set()
|
||||
for raw in body.section_planned_exercise_ids:
|
||||
try:
|
||||
eid = int(raw)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if eid < 1 or eid in seen:
|
||||
continue
|
||||
seen.add(eid)
|
||||
section_ids.append(eid)
|
||||
elif pack.get("section_planned_exercise_ids"):
|
||||
section_ids = list(pack.get("section_planned_exercise_ids") or [])
|
||||
|
||||
section_title = pack.get("section_title")
|
||||
if body and (body.section_title or "").strip():
|
||||
section_title = (body.section_title or "").strip()
|
||||
elif sec and (sec.get("title") or "").strip():
|
||||
section_title = (sec.get("title") or "").strip()
|
||||
|
||||
guidance = None
|
||||
if body and (body.section_guidance_notes or "").strip():
|
||||
guidance = (body.section_guidance_notes or "").strip()
|
||||
elif sec and (sec.get("guidance_notes") or "").strip():
|
||||
guidance = (sec.get("guidance_notes") or "").strip()
|
||||
|
||||
last_in_section_id, last_in_section_title = _resolve_last_exercise_in_section(sec)
|
||||
if body and not last_in_section_id and pack.get("anchor_exercise_id"):
|
||||
last_in_section_id = pack.get("anchor_exercise_id")
|
||||
last_in_section_title = pack.get("anchor_title")
|
||||
|
||||
unit_ids = list(pack.get("planned_exercise_ids") or [])
|
||||
pack["section_title"] = section_title
|
||||
pack["section_guidance_notes"] = guidance
|
||||
pack["section_planned_exercise_ids"] = section_ids
|
||||
pack["section_exercise_count"] = len(section_ids)
|
||||
pack["last_section_exercise_id"] = last_in_section_id
|
||||
pack["last_section_exercise_title"] = last_in_section_title
|
||||
pack["unit_skill_profile_summary"] = skill_profile_summary_from_exercise_ids(cur, unit_ids)
|
||||
pack["section_skill_profile_summary"] = skill_profile_summary_from_exercise_ids(cur, section_ids)
|
||||
pack["has_planning_reference"] = bool(
|
||||
unit_ids
|
||||
or section_ids
|
||||
or pack.get("anchor_exercise_id")
|
||||
or (pack.get("unit") or {}).get("framework_slot_id")
|
||||
or (pack.get("unit") or {}).get("origin_framework_slot_id")
|
||||
)
|
||||
return pack
|
||||
|
||||
|
||||
def _section_title_for_index(sections: Sequence[Dict[str, Any]], section_order_index: Optional[int]) -> Optional[str]:
|
||||
if section_order_index is None:
|
||||
return None
|
||||
for sec in sections:
|
||||
if int(sec.get("order_index") or -1) == int(section_order_index):
|
||||
t = (sec.get("title") or "").strip()
|
||||
return t or None
|
||||
return None
|
||||
|
||||
|
||||
def _normalize_query(query: Optional[str]) -> str:
|
||||
return re.sub(r"\s+", " ", (query or "").strip())
|
||||
|
||||
|
||||
def _apply_client_planned_override(
|
||||
cur,
|
||||
pack: Dict[str, Any],
|
||||
body: PlanningExerciseSuggestRequest,
|
||||
) -> Dict[str, Any]:
|
||||
"""Client-Plan (ungespeichertes Formular) überschreibt DB-Stand."""
|
||||
if not body.planned_exercise_ids:
|
||||
return pack
|
||||
planned_ids: List[int] = []
|
||||
seen: Set[int] = set()
|
||||
for raw in body.planned_exercise_ids:
|
||||
try:
|
||||
eid = int(raw)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if eid < 1 or eid in seen:
|
||||
continue
|
||||
seen.add(eid)
|
||||
planned_ids.append(eid)
|
||||
if not planned_ids:
|
||||
return pack
|
||||
|
||||
pack["planned_exercise_ids"] = planned_ids
|
||||
if not body.anchor_exercise_id:
|
||||
anchor_id = _resolve_anchor_from_plan(planned_ids, None)
|
||||
pack["anchor_exercise_id"] = anchor_id
|
||||
if anchor_id:
|
||||
titles = _load_exercise_titles(cur, [anchor_id])
|
||||
pack["anchor_title"] = titles.get(anchor_id)
|
||||
pack["anchor_skill_ids"] = sorted(_load_skill_ids_for_exercise(cur, anchor_id))
|
||||
else:
|
||||
pack["anchor_title"] = None
|
||||
pack["anchor_skill_ids"] = []
|
||||
return pack
|
||||
|
||||
|
||||
def build_planning_exercise_context_pack(
|
||||
cur,
|
||||
*,
|
||||
tenant: TenantContext,
|
||||
body: PlanningExerciseSuggestRequest,
|
||||
) -> Dict[str, Any]:
|
||||
profile_id = tenant.profile_id
|
||||
role = tenant.global_role
|
||||
|
||||
if not _has_planning_role(role):
|
||||
raise HTTPException(status_code=403, detail="Nur Trainer dürfen Planungs-Vorschläge abrufen")
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT tu.*, tg.name AS group_name
|
||||
FROM training_units tu
|
||||
LEFT JOIN training_groups tg ON tg.id = tu.group_id
|
||||
WHERE tu.id = %s
|
||||
""",
|
||||
(body.unit_id,),
|
||||
)
|
||||
unit_row = cur.fetchone()
|
||||
if not unit_row:
|
||||
raise HTTPException(status_code=404, detail="Trainingseinheit nicht gefunden")
|
||||
unit = dict(unit_row)
|
||||
|
||||
if unit.get("framework_slot_id"):
|
||||
if role not in ("admin", "superadmin"):
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT fp.created_by FROM training_framework_slots s
|
||||
JOIN training_framework_programs fp ON fp.id = s.framework_program_id
|
||||
WHERE s.id = %s
|
||||
""",
|
||||
(unit["framework_slot_id"],),
|
||||
)
|
||||
fr = cur.fetchone()
|
||||
cb = fr["created_by"] if fr else None
|
||||
if unit.get("created_by") != profile_id and cb != profile_id:
|
||||
raise HTTPException(status_code=403, detail="Keine Berechtigung")
|
||||
else:
|
||||
if not unit.get("group_id"):
|
||||
raise HTTPException(status_code=404, detail="Trainingseinheit nicht gefunden")
|
||||
_assert_training_unit_permission(cur, unit, profile_id, role)
|
||||
|
||||
sections = _fetch_sections(cur, int(body.unit_id))
|
||||
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])
|
||||
anchor_title = titles.get(anchor_id) if anchor_id else None
|
||||
|
||||
pack = {
|
||||
"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,
|
||||
"section_order_index": body.section_order_index,
|
||||
"section_title": _section_title_for_index(sections, body.section_order_index),
|
||||
"planned_exercise_ids": planned_ids,
|
||||
"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)
|
||||
|
||||
|
||||
def build_client_planning_context_pack(
|
||||
cur,
|
||||
*,
|
||||
tenant: TenantContext,
|
||||
body: PlanningExerciseSuggestRequest,
|
||||
) -> Dict[str, Any]:
|
||||
"""Freie / Client-Kontext-Suche ohne persistierte training_units.id (Formular, Rahmen-Slot)."""
|
||||
role = tenant.global_role
|
||||
if not _has_planning_role(role):
|
||||
raise HTTPException(status_code=403, detail="Nur Trainer dürfen Planungs-Vorschläge abrufen")
|
||||
|
||||
planned_ids: List[int] = []
|
||||
if body.planned_exercise_ids:
|
||||
seen: Set[int] = set()
|
||||
for raw in body.planned_exercise_ids:
|
||||
try:
|
||||
eid = int(raw)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if eid < 1 or eid in seen:
|
||||
continue
|
||||
seen.add(eid)
|
||||
planned_ids.append(eid)
|
||||
|
||||
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
|
||||
if group_id:
|
||||
cur.execute("SELECT name FROM training_groups WHERE id = %s", (int(group_id),))
|
||||
gr = cur.fetchone()
|
||||
if gr:
|
||||
group_name = (gr.get("name") or "").strip() or None
|
||||
|
||||
group_recent = _load_group_recent_exercise_ids(cur, group_id, exclude_unit_id=None)
|
||||
titles = _load_exercise_titles(cur, [x for x in [anchor_id] if x])
|
||||
anchor_title = titles.get(anchor_id) if anchor_id else None
|
||||
|
||||
pack = {
|
||||
"unit_id": None,
|
||||
"unit": {
|
||||
"id": None,
|
||||
"framework_slot_id": None,
|
||||
"origin_framework_slot_id": None,
|
||||
},
|
||||
"unit_title": None,
|
||||
"group_id": group_id,
|
||||
"group_name": group_name,
|
||||
"section_order_index": body.section_order_index,
|
||||
"section_title": (body.section_title or "").strip() or None,
|
||||
"planned_exercise_ids": planned_ids,
|
||||
"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",
|
||||
}
|
||||
return _attach_planning_context_details(cur, pack, sections=None, body=body)
|
||||
|
||||
|
||||
def suggest_planning_exercises(
|
||||
cur,
|
||||
*,
|
||||
tenant: TenantContext,
|
||||
body: PlanningExerciseSuggestRequest,
|
||||
) -> Dict[str, Any]:
|
||||
if body.unit_id:
|
||||
pack = build_planning_exercise_context_pack(cur, tenant=tenant, body=body)
|
||||
else:
|
||||
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)
|
||||
query = _normalize_query(body.query)
|
||||
heuristic_intent = resolve_planning_exercise_intent(query, body.intent_hint)
|
||||
|
||||
has_plan_ref = bool(pack.get("has_planning_reference"))
|
||||
expectation_mode = "planning_hybrid" if has_plan_ref else "query_only"
|
||||
|
||||
pipeline_context = {
|
||||
"unit_title": pack.get("unit_title"),
|
||||
"group_name": pack.get("group_name"),
|
||||
"section_title": pack.get("section_title"),
|
||||
"section_guidance_notes": pack.get("section_guidance_notes"),
|
||||
"section_exercise_count": pack.get("section_exercise_count"),
|
||||
"planned_count": len(pack.get("planned_exercise_ids") or []),
|
||||
"anchor_title": pack.get("anchor_title"),
|
||||
"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"),
|
||||
"unit_skill_profile": pack.get("unit_skill_profile_summary"),
|
||||
"section_skill_profile": pack.get("section_skill_profile_summary"),
|
||||
"has_planning_reference": has_plan_ref,
|
||||
"expectation_mode": expectation_mode,
|
||||
}
|
||||
target_profile, intent, scenario_kind, query_intent_summary = build_planning_target_with_query_pipeline(
|
||||
cur,
|
||||
unit=pack["unit"],
|
||||
planned_exercise_ids=pack["planned_exercise_ids"],
|
||||
section_planned_exercise_ids=pack.get("section_planned_exercise_ids") or [],
|
||||
anchor_exercise_id=pack.get("anchor_exercise_id"),
|
||||
query=query,
|
||||
heuristic_intent=heuristic_intent,
|
||||
include_llm_intent=body.include_llm_intent,
|
||||
context_summary=pipeline_context,
|
||||
has_planning_reference=has_plan_ref,
|
||||
)
|
||||
weights = _intent_weights(intent)
|
||||
target_profile_summary = target_profile.to_summary_dict(cur)
|
||||
query_intent_applied = bool(query_intent_summary.get("llm_applied"))
|
||||
llm_expectation_applied = bool(query_intent_summary.get("llm_expectation_applied"))
|
||||
profile_llm_applied = bool(query_intent_summary.get("profile_llm_applied"))
|
||||
|
||||
profile_id = tenant.profile_id
|
||||
role = tenant.global_role
|
||||
vis_sql, vis_params = library_content_visibility_sql(
|
||||
alias="e",
|
||||
profile_id=profile_id,
|
||||
role=role,
|
||||
effective_club_id=tenant.effective_club_id,
|
||||
)
|
||||
|
||||
hits, skills_by_ex, full_library_ranked = run_multistage_planning_retrieval(
|
||||
cur,
|
||||
vis_sql=vis_sql,
|
||||
vis_params=vis_params,
|
||||
query=query,
|
||||
exercise_kind_any=body.exercise_kind_any,
|
||||
target=target_profile,
|
||||
intent=intent,
|
||||
intent_weights=weights,
|
||||
pack=pack,
|
||||
)
|
||||
|
||||
planned_set = set(pack["planned_exercise_ids"])
|
||||
|
||||
llm_rank_applied = False
|
||||
retrieval_phase = compose_retrieval_phase(
|
||||
full_library=full_library_ranked,
|
||||
query_intent=query_intent_applied,
|
||||
llm_expectation=llm_expectation_applied,
|
||||
llm_rank=False,
|
||||
)
|
||||
run_llm_rank = should_run_llm_rank_pipeline(
|
||||
query,
|
||||
scenario_kind,
|
||||
include_llm_rank=body.include_llm_rank,
|
||||
query_intent_applied=query_intent_applied,
|
||||
llm_expectation_applied=llm_expectation_applied,
|
||||
hits=hits,
|
||||
)
|
||||
if run_llm_rank:
|
||||
pre_limit = max(int(body.limit), _LLM_RERANK_PRE_LIMIT)
|
||||
pool_hits = hits[:pre_limit]
|
||||
pool_hits, llm_rank_applied = try_llm_rerank_planning_hits(
|
||||
cur,
|
||||
hits=pool_hits,
|
||||
skills_by_ex=skills_by_ex,
|
||||
query=query,
|
||||
intent=intent,
|
||||
context_summary={
|
||||
"unit_title": pack.get("unit_title"),
|
||||
"group_name": pack.get("group_name"),
|
||||
"section_title": pack.get("section_title"),
|
||||
"planned_count": len(planned_set),
|
||||
"anchor_title": pack.get("anchor_title"),
|
||||
"intent": intent,
|
||||
},
|
||||
target_profile_summary=target_profile_summary,
|
||||
limit=int(body.limit),
|
||||
)
|
||||
if llm_rank_applied:
|
||||
retrieval_phase = compose_retrieval_phase(
|
||||
full_library=full_library_ranked,
|
||||
query_intent=query_intent_applied,
|
||||
llm_expectation=llm_expectation_applied,
|
||||
llm_rank=True,
|
||||
)
|
||||
tail = hits[pre_limit:]
|
||||
hits = pool_hits + tail
|
||||
else:
|
||||
hits = pool_hits[: int(body.limit)]
|
||||
else:
|
||||
hits = hits[: int(body.limit)]
|
||||
|
||||
hits = hits[: int(body.limit)]
|
||||
|
||||
context_summary = {
|
||||
"unit_title": pack.get("unit_title"),
|
||||
"group_name": pack.get("group_name"),
|
||||
"section_title": pack.get("section_title"),
|
||||
"section_guidance_notes": pack.get("section_guidance_notes"),
|
||||
"section_exercise_count": pack.get("section_exercise_count"),
|
||||
"planned_count": len(planned_set),
|
||||
"anchor_title": pack.get("anchor_title"),
|
||||
"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"),
|
||||
"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"),
|
||||
"has_planning_reference": pack.get("has_planning_reference"),
|
||||
"expectation_mode": expectation_mode,
|
||||
}
|
||||
|
||||
return {
|
||||
"context_summary": context_summary,
|
||||
"target_profile_summary": target_profile_summary,
|
||||
"scenario_kind": scenario_kind,
|
||||
"query_intent_summary": query_intent_summary,
|
||||
"retrieval_phase": retrieval_phase,
|
||||
"full_library_ranked": full_library_ranked,
|
||||
"profile_preselect_applied": False,
|
||||
"llm_rank_applied": llm_rank_applied,
|
||||
"llm_intent_applied": query_intent_applied,
|
||||
"llm_expectation_applied": llm_expectation_applied,
|
||||
"profile_llm_applied": profile_llm_applied,
|
||||
"intent_resolved": intent,
|
||||
"intent_heuristic": heuristic_intent,
|
||||
"query_normalized": query or None,
|
||||
"expectation_mode": expectation_mode,
|
||||
"hits": hits,
|
||||
}
|
||||
418
backend/planning_exercise_target_pipeline.py
Normal file
418
backend/planning_exercise_target_pipeline.py
Normal file
|
|
@ -0,0 +1,418 @@
|
|||
"""
|
||||
Szenario-Routing und Erwartungsprofil-Pipeline für Planungs-Übungssuche (P1).
|
||||
|
||||
Ablauf:
|
||||
1. Heuristik: Intent + Szenario-Klasse aus Query/Kontext
|
||||
2. Optional LLM (planning_exercise_search_intent) bei komplexen Anfragen
|
||||
3. Deterministisches Basis-Profil (Rahmen, Plan, Anker)
|
||||
4. Query-Overlay mergen → PlanningTargetProfile für Vorselektion
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any, Dict, List, Mapping, Optional, Tuple
|
||||
|
||||
from planning_exercise_expectation import try_build_planning_expectation_from_context
|
||||
from planning_exercise_intent import (
|
||||
PlanningQueryIntentParsed,
|
||||
resolve_query_intent_catalog_ids,
|
||||
try_parse_planning_query_intent,
|
||||
)
|
||||
from planning_exercise_profiles import (
|
||||
PlanningTargetProfile,
|
||||
_merge_weight_maps,
|
||||
_normalize_weight_map,
|
||||
build_planning_target_profile,
|
||||
)
|
||||
|
||||
SCENARIO_PRESET_NEXT = "preset_next"
|
||||
SCENARIO_PROGRESSION = "progression"
|
||||
SCENARIO_DEEPEN = "deepen"
|
||||
SCENARIO_CONTINUE_PLAN = "continue_plan"
|
||||
SCENARIO_ADDITIVE = "additive_constraint"
|
||||
SCENARIO_FREE_SEARCH = "free_search"
|
||||
|
||||
_SIMPLE_PRESET_PATTERNS = (
|
||||
r"^(schlage?\s+(mir\s+)?(die\s+)?(n[aä]chste|naechste)\s+(sinnvolle\s+)?(übung|uebung)\s*(vor)?\.?)$",
|
||||
r"^(n[aä]chste|naechste)\s+(übung|uebung)\s*(vorschlag|vorschlagen|empfehl\w*)?\.?$",
|
||||
r"^(vorschlag|vorschlagen|empfehl\w*)\s*(für|fuer)?\s*(die\s+)?(n[aä]chste|naechste)?\s*(übung|uebung)?\.?$",
|
||||
r"^n[aä]chste\s+übung$",
|
||||
r"^n[aä]chste\s+uebung$",
|
||||
r"^(n[aä]chste|naechste)\s+(übung|uebung)\s+planen\.?$",
|
||||
)
|
||||
|
||||
_ADDITIVE_MARKERS = (
|
||||
"zusätzlich",
|
||||
"zusaetzlich",
|
||||
"auch ",
|
||||
" außerdem",
|
||||
" ausserdem",
|
||||
" dazu",
|
||||
" extra",
|
||||
" mehr ",
|
||||
" und dabei",
|
||||
" sowie ",
|
||||
)
|
||||
|
||||
|
||||
def _normalize_query(q: Optional[str]) -> str:
|
||||
return re.sub(r"\s+", " ", (q or "").strip())
|
||||
|
||||
|
||||
def is_simple_preset_query(query: Optional[str]) -> bool:
|
||||
q = _normalize_query(query).lower()
|
||||
if not q:
|
||||
return True
|
||||
for pat in _SIMPLE_PRESET_PATTERNS:
|
||||
if re.match(pat, q, flags=re.IGNORECASE):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def classify_planning_scenario(
|
||||
query: Optional[str],
|
||||
heuristic_intent: str,
|
||||
) -> str:
|
||||
q = _normalize_query(query).lower()
|
||||
if not q or is_simple_preset_query(q):
|
||||
return SCENARIO_PRESET_NEXT
|
||||
if heuristic_intent == "progression_next":
|
||||
return SCENARIO_PROGRESSION
|
||||
if heuristic_intent == "deepen_exercise":
|
||||
return SCENARIO_DEEPEN
|
||||
if any(m in f" {q} " for m in _ADDITIVE_MARKERS):
|
||||
return SCENARIO_ADDITIVE
|
||||
if heuristic_intent == "continue_plan_goal":
|
||||
return SCENARIO_CONTINUE_PLAN
|
||||
if heuristic_intent == "free_search":
|
||||
return SCENARIO_FREE_SEARCH
|
||||
if heuristic_intent == "suggest_next":
|
||||
return SCENARIO_CONTINUE_PLAN
|
||||
return SCENARIO_FREE_SEARCH
|
||||
|
||||
|
||||
def should_run_llm_expectation_pipeline(
|
||||
scenario: str,
|
||||
*,
|
||||
include_llm_intent: bool,
|
||||
has_planning_reference: bool,
|
||||
) -> bool:
|
||||
"""Preset/leere Anfrage mit Planungsbezug → LLM-Erwartungsprofil statt Query-Intent."""
|
||||
if not include_llm_intent:
|
||||
return False
|
||||
if not has_planning_reference:
|
||||
return False
|
||||
return scenario == SCENARIO_PRESET_NEXT
|
||||
|
||||
|
||||
def should_run_llm_intent_pipeline(
|
||||
query: Optional[str],
|
||||
scenario: str,
|
||||
*,
|
||||
include_llm_intent: bool,
|
||||
) -> bool:
|
||||
if not include_llm_intent:
|
||||
return False
|
||||
if scenario == SCENARIO_PRESET_NEXT:
|
||||
return False
|
||||
q = _normalize_query(query)
|
||||
if not q:
|
||||
return False
|
||||
# Kurze Stichwortsuche: Volltext + Profil reichen — kein Intent-LLM
|
||||
if scenario == SCENARIO_FREE_SEARCH and len(q) < 14:
|
||||
return False
|
||||
if scenario in (SCENARIO_CONTINUE_PLAN, SCENARIO_PROGRESSION) and len(q) < 18:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def deterministic_rank_confident(hits: Sequence[Mapping[str, Any]], *, gap_threshold: float = 0.12) -> bool:
|
||||
"""True wenn Hybrid-Ranking schon klar genug ist — LLM-Rerank sparen."""
|
||||
if len(hits) < 4:
|
||||
return True
|
||||
top = float(hits[0].get("score") or 0.0)
|
||||
fourth = float(hits[3].get("score") or 0.0)
|
||||
return (top - fourth) >= gap_threshold
|
||||
|
||||
|
||||
def should_run_llm_rank_pipeline(
|
||||
query: Optional[str],
|
||||
scenario: str,
|
||||
*,
|
||||
include_llm_rank: bool,
|
||||
query_intent_applied: bool,
|
||||
llm_expectation_applied: bool = False,
|
||||
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.
|
||||
"""
|
||||
if not include_llm_rank:
|
||||
return False
|
||||
if query_intent_applied or llm_expectation_applied:
|
||||
return False
|
||||
if scenario == SCENARIO_PRESET_NEXT:
|
||||
return False
|
||||
q = _normalize_query(query)
|
||||
if not q:
|
||||
return False
|
||||
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)
|
||||
|
||||
|
||||
def _recalculate_skill_gap(target: PlanningTargetProfile) -> PlanningTargetProfile:
|
||||
skill_target = _normalize_weight_map(dict(target.skill_weights))
|
||||
skill_plan_norm = _normalize_weight_map(dict(target.skill_plan_weights))
|
||||
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
|
||||
sources = list(target.sources)
|
||||
if skill_gap and "skill_gap_vs_plan" not in sources:
|
||||
sources.append("skill_gap_vs_plan")
|
||||
elif not skill_gap:
|
||||
sources = [s for s in sources if s != "skill_gap_vs_plan"]
|
||||
return PlanningTargetProfile(
|
||||
focus_area_ids=target.focus_area_ids,
|
||||
style_direction_ids=target.style_direction_ids,
|
||||
training_type_ids=target.training_type_ids,
|
||||
target_group_ids=target.target_group_ids,
|
||||
skill_weights=skill_target,
|
||||
skill_gap_weights=_normalize_weight_map(skill_gap) if skill_gap else {},
|
||||
skill_plan_weights=target.skill_plan_weights,
|
||||
sources=sources,
|
||||
)
|
||||
|
||||
|
||||
def merge_query_overlay_into_target(
|
||||
base: PlanningTargetProfile,
|
||||
*,
|
||||
focus: Dict[int, float],
|
||||
style: Dict[int, float],
|
||||
tt: Dict[int, float],
|
||||
tg: Dict[int, float],
|
||||
skills: Dict[int, float],
|
||||
emphasis: str = "additive",
|
||||
scenario: str,
|
||||
) -> PlanningTargetProfile:
|
||||
sources = list(base.sources)
|
||||
if "query_intent" not in sources:
|
||||
sources.append("query_intent")
|
||||
|
||||
if emphasis == "replace" or scenario == SCENARIO_FREE_SEARCH:
|
||||
skill_w = _merge_weight_maps({}, skills, scale=1.0)
|
||||
if skills:
|
||||
skill_w = _normalize_weight_map(_merge_weight_maps(base.skill_weights, skills, scale=0.55))
|
||||
if emphasis == "replace":
|
||||
skill_w = _normalize_weight_map(skills)
|
||||
focus_w = _merge_weight_maps(base.focus_area_ids, focus, scale=0.5 if emphasis == "replace" else 0.85)
|
||||
style_w = _merge_weight_maps(base.style_direction_ids, style, scale=0.5)
|
||||
tt_w = _merge_weight_maps(base.training_type_ids, tt, scale=0.5)
|
||||
tg_w = _merge_weight_maps(base.target_group_ids, tg, scale=0.5)
|
||||
else:
|
||||
skill_scale = 1.0 if scenario == SCENARIO_ADDITIVE else 0.85
|
||||
skill_w = _merge_weight_maps(base.skill_weights, skills, scale=skill_scale)
|
||||
focus_w = _merge_weight_maps(base.focus_area_ids, focus, scale=0.9)
|
||||
style_w = _merge_weight_maps(base.style_direction_ids, style, scale=0.75)
|
||||
tt_w = _merge_weight_maps(base.training_type_ids, tt, scale=0.75)
|
||||
tg_w = _merge_weight_maps(base.target_group_ids, tg, scale=0.75)
|
||||
|
||||
out = PlanningTargetProfile(
|
||||
focus_area_ids=_normalize_weight_map(focus_w) if focus_w else focus_w,
|
||||
style_direction_ids=_normalize_weight_map(style_w) if style_w else style_w,
|
||||
training_type_ids=_normalize_weight_map(tt_w) if tt_w else tt_w,
|
||||
target_group_ids=_normalize_weight_map(tg_w) if tg_w else tg_w,
|
||||
skill_weights=_normalize_weight_map(skill_w) if skill_w else skill_w,
|
||||
skill_gap_weights=dict(base.skill_gap_weights),
|
||||
skill_plan_weights=dict(base.skill_plan_weights),
|
||||
sources=sources,
|
||||
)
|
||||
return _recalculate_skill_gap(out)
|
||||
|
||||
|
||||
def build_planning_target_with_query_pipeline(
|
||||
cur,
|
||||
*,
|
||||
unit: Dict[str, Any],
|
||||
planned_exercise_ids: List[int],
|
||||
section_planned_exercise_ids: Optional[List[int]] = None,
|
||||
anchor_exercise_id: Optional[int],
|
||||
query: Optional[str],
|
||||
heuristic_intent: str,
|
||||
include_llm_intent: bool,
|
||||
context_summary: Mapping[str, Any],
|
||||
has_planning_reference: bool = True,
|
||||
) -> Tuple[PlanningTargetProfile, str, str, Dict[str, Any]]:
|
||||
"""
|
||||
Returns: target_profile, resolved_intent, scenario_kind, query_intent_summary dict
|
||||
|
||||
Ohne Planungsbezug (keine Übungen/Anker/Rahmen): Erwartungsprofil primär aus Suchtext (query_only).
|
||||
Mit Planungsbezug: hybrid aus Plan + optional Query-Overlay.
|
||||
"""
|
||||
scenario = classify_planning_scenario(query, heuristic_intent)
|
||||
resolved_intent = heuristic_intent
|
||||
llm_applied = False
|
||||
llm_expectation_applied = False
|
||||
parsed: Optional[PlanningQueryIntentParsed] = None
|
||||
expectation_parsed: Optional[PlanningQueryIntentParsed] = None
|
||||
resolved_skills: List[Dict[str, Any]] = []
|
||||
|
||||
if has_planning_reference:
|
||||
base = build_planning_target_profile(
|
||||
cur,
|
||||
unit=unit,
|
||||
planned_exercise_ids=planned_exercise_ids,
|
||||
section_planned_exercise_ids=section_planned_exercise_ids or [],
|
||||
anchor_exercise_id=anchor_exercise_id,
|
||||
intent=heuristic_intent,
|
||||
)
|
||||
else:
|
||||
base = PlanningTargetProfile(sources=["query_only"])
|
||||
|
||||
base_summary = base.to_summary_dict(cur)
|
||||
target = base
|
||||
|
||||
if should_run_llm_expectation_pipeline(
|
||||
scenario,
|
||||
include_llm_intent=include_llm_intent,
|
||||
has_planning_reference=has_planning_reference,
|
||||
):
|
||||
expectation_parsed, llm_expectation_applied = try_build_planning_expectation_from_context(
|
||||
cur,
|
||||
heuristic_intent=heuristic_intent,
|
||||
context_summary=context_summary,
|
||||
target_profile_summary=base_summary,
|
||||
)
|
||||
parsed = expectation_parsed
|
||||
if parsed and llm_expectation_applied:
|
||||
if parsed.intent in {
|
||||
"suggest_next",
|
||||
"progression_next",
|
||||
"deepen_exercise",
|
||||
"continue_plan_goal",
|
||||
"free_search",
|
||||
}:
|
||||
resolved_intent = parsed.intent
|
||||
focus, style, tt, tg, skills, resolved_skills = resolve_query_intent_catalog_ids(cur, parsed)
|
||||
if focus or style or tt or tg or skills or parsed.rationale:
|
||||
target = merge_query_overlay_into_target(
|
||||
base,
|
||||
focus=focus,
|
||||
style=style,
|
||||
tt=tt,
|
||||
tg=tg,
|
||||
skills=skills,
|
||||
emphasis=parsed.emphasis or "additive",
|
||||
scenario=SCENARIO_PRESET_NEXT,
|
||||
)
|
||||
if "context_expectation" not in target.sources:
|
||||
target.sources.append("context_expectation")
|
||||
elif should_run_llm_intent_pipeline(query, scenario, include_llm_intent=include_llm_intent):
|
||||
parsed, llm_applied = try_parse_planning_query_intent(
|
||||
cur,
|
||||
query=_normalize_query(query),
|
||||
heuristic_intent=heuristic_intent,
|
||||
scenario_hint=scenario,
|
||||
context_summary=context_summary,
|
||||
target_profile_summary=base_summary,
|
||||
)
|
||||
|
||||
if parsed and llm_applied and not llm_expectation_applied:
|
||||
if parsed.intent in {
|
||||
"suggest_next",
|
||||
"progression_next",
|
||||
"deepen_exercise",
|
||||
"continue_plan_goal",
|
||||
"free_search",
|
||||
}:
|
||||
resolved_intent = parsed.intent
|
||||
if parsed.scenario in VALID_SCENARIOS_SET:
|
||||
scenario = parsed.scenario
|
||||
|
||||
focus, style, tt, tg, skills, resolved_skills = resolve_query_intent_catalog_ids(cur, parsed)
|
||||
if focus or style or tt or tg or skills:
|
||||
overlay_scenario = scenario
|
||||
overlay_emphasis = parsed.emphasis
|
||||
if not has_planning_reference:
|
||||
overlay_scenario = SCENARIO_FREE_SEARCH
|
||||
overlay_emphasis = "replace"
|
||||
target = merge_query_overlay_into_target(
|
||||
base,
|
||||
focus=focus,
|
||||
style=style,
|
||||
tt=tt,
|
||||
tg=tg,
|
||||
skills=skills,
|
||||
emphasis=overlay_emphasis,
|
||||
scenario=overlay_scenario,
|
||||
)
|
||||
elif not has_planning_reference and _normalize_query(query):
|
||||
# Kein LLM, aber Freitext: leichtes Profil bleibt leer — Retrieval nutzt Volltext
|
||||
target = PlanningTargetProfile(sources=["query_only"])
|
||||
|
||||
query_intent_summary: Dict[str, Any] = {
|
||||
"scenario": scenario,
|
||||
"intent": resolved_intent,
|
||||
"heuristic_intent": heuristic_intent,
|
||||
"llm_applied": llm_applied,
|
||||
"llm_expectation_applied": llm_expectation_applied,
|
||||
"profile_llm_applied": llm_applied or llm_expectation_applied,
|
||||
"emphasis": parsed.emphasis if parsed else None,
|
||||
"rationale": (parsed.rationale if parsed else None),
|
||||
"skill_hints_resolved": resolved_skills,
|
||||
"requires_partner": parsed.requires_partner if parsed else None,
|
||||
"expectation_mode": "planning_hybrid" if has_planning_reference else "query_only",
|
||||
}
|
||||
|
||||
return target, resolved_intent, scenario, query_intent_summary
|
||||
|
||||
|
||||
VALID_SCENARIOS_SET = {
|
||||
SCENARIO_PRESET_NEXT,
|
||||
SCENARIO_PROGRESSION,
|
||||
SCENARIO_DEEPEN,
|
||||
SCENARIO_CONTINUE_PLAN,
|
||||
SCENARIO_ADDITIVE,
|
||||
SCENARIO_FREE_SEARCH,
|
||||
}
|
||||
|
||||
|
||||
def compose_retrieval_phase(
|
||||
*,
|
||||
full_library: bool = False,
|
||||
profile_preselect: bool = False,
|
||||
query_intent: bool = False,
|
||||
llm_expectation: bool = False,
|
||||
llm_rank: bool = False,
|
||||
) -> str:
|
||||
parts = ["profile_v1"]
|
||||
if full_library or profile_preselect:
|
||||
parts.append("full_library")
|
||||
if llm_expectation:
|
||||
parts.append("llm_expectation")
|
||||
elif query_intent:
|
||||
parts.append("query_intent")
|
||||
if llm_rank:
|
||||
parts.append("llm_rank")
|
||||
return "+".join(parts)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"SCENARIO_ADDITIVE",
|
||||
"SCENARIO_PRESET_NEXT",
|
||||
"build_planning_target_with_query_pipeline",
|
||||
"classify_planning_scenario",
|
||||
"compose_retrieval_phase",
|
||||
"is_simple_preset_query",
|
||||
"merge_query_overlay_into_target",
|
||||
"should_run_llm_expectation_pipeline",
|
||||
"should_run_llm_intent_pipeline",
|
||||
"should_run_llm_rank_pipeline",
|
||||
"deterministic_rank_confident",
|
||||
]
|
||||
448
backend/routers/exercise_enrichment_admin.py
Normal file
448
backend/routers/exercise_enrichment_admin.py
Normal file
|
|
@ -0,0 +1,448 @@
|
|||
"""
|
||||
Superadmin API: Batch-Anreicherung von Übungen per KI (Skills, Kurzfassung, Anleitung).
|
||||
|
||||
# ACCESS_LAYER exempt: Plattform-weites Superadmin-Werkzeug ohne TenantContext.
|
||||
Siehe ACCESS_LAYER_ENDPOINT_AUDIT.md.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from typing import Any, Dict, List, Literal, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
from auth import require_auth
|
||||
from club_tenancy import is_superadmin
|
||||
from db import get_cursor, get_db, r2d
|
||||
from exercise_enrichment import (
|
||||
DEFAULT_SET_STATUS,
|
||||
MAX_BATCH_EXERCISES,
|
||||
MAX_PREVIEW_BATCH_EXERCISES,
|
||||
SKILL_MERGE_MODES,
|
||||
SkillMergeMode,
|
||||
apply_exercise_enrichment,
|
||||
estimate_llm_calls,
|
||||
preview_exercise_enrichment,
|
||||
)
|
||||
|
||||
_PREVIEW_MAX_WORKERS = 3
|
||||
|
||||
router = APIRouter(tags=["admin_exercise_enrichment"])
|
||||
|
||||
_VALID_STATUS_FILTER = frozenset({"draft", "in_review", "approved", "archived"})
|
||||
_VALID_VISIBILITY = frozenset({"private", "club", "official", "all"})
|
||||
_MAX_CANDIDATES_LIMIT = 100
|
||||
_MAX_ANALYZE_IDS = 10_000
|
||||
|
||||
|
||||
def _require_superadmin(session: dict) -> dict:
|
||||
role = (session.get("role") or "").strip().lower()
|
||||
if not is_superadmin(role):
|
||||
raise HTTPException(status_code=403, detail="Nur Superadmins")
|
||||
return session
|
||||
|
||||
|
||||
def _normalize_id_list(raw: Optional[List[int]], *, max_items: Optional[int] = None) -> List[int]:
|
||||
if not raw:
|
||||
return []
|
||||
seen: set[int] = set()
|
||||
out: List[int] = []
|
||||
for x in raw:
|
||||
try:
|
||||
xi = int(x)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if xi < 1 or xi in seen:
|
||||
continue
|
||||
seen.add(xi)
|
||||
out.append(xi)
|
||||
if max_items is not None and len(out) >= max_items:
|
||||
break
|
||||
return out
|
||||
|
||||
|
||||
def _build_candidates_where(
|
||||
*,
|
||||
status: str,
|
||||
visibility: Optional[str],
|
||||
focus_area_id: Optional[int],
|
||||
without_skills: bool,
|
||||
with_ai_suggested_skills: bool,
|
||||
) -> tuple[list[str], list[Any]]:
|
||||
st = (status or "draft").strip().lower()
|
||||
if st not in _VALID_STATUS_FILTER:
|
||||
raise HTTPException(status_code=400, detail="Ungültiger Status-Filter")
|
||||
|
||||
where: List[str] = ["e.status = %s"]
|
||||
params: List[Any] = [st]
|
||||
|
||||
vis_raw = (visibility or "private").strip().lower()
|
||||
if vis_raw and vis_raw != "all":
|
||||
if vis_raw not in _VALID_VISIBILITY - {"all"}:
|
||||
raise HTTPException(status_code=400, detail="Ungültiger Visibility-Filter")
|
||||
where.append("e.visibility = %s")
|
||||
params.append(vis_raw)
|
||||
|
||||
if focus_area_id is not None:
|
||||
where.append(
|
||||
"EXISTS (SELECT 1 FROM exercise_focus_areas efa "
|
||||
"WHERE efa.exercise_id = e.id AND efa.focus_area_id = %s)"
|
||||
)
|
||||
params.append(int(focus_area_id))
|
||||
|
||||
if without_skills:
|
||||
where.append(
|
||||
"NOT EXISTS (SELECT 1 FROM exercise_skills es WHERE es.exercise_id = e.id)"
|
||||
)
|
||||
|
||||
if with_ai_suggested_skills:
|
||||
where.append(
|
||||
"EXISTS (SELECT 1 FROM exercise_skills es "
|
||||
"WHERE es.exercise_id = e.id AND es.ai_suggested = true)"
|
||||
)
|
||||
|
||||
return where, params
|
||||
|
||||
|
||||
class EnrichmentModes(BaseModel):
|
||||
skills: bool = True
|
||||
summary: bool = False
|
||||
instructions: bool = False
|
||||
|
||||
|
||||
class EnrichmentPreviewBody(BaseModel):
|
||||
exercise_ids: List[int] = Field(..., min_length=1, max_length=MAX_PREVIEW_BATCH_EXERCISES)
|
||||
modes: EnrichmentModes = Field(default_factory=EnrichmentModes)
|
||||
merge_mode: SkillMergeMode = "replace_all"
|
||||
|
||||
@field_validator("merge_mode")
|
||||
@classmethod
|
||||
def _merge_mode_ok(cls, v: str) -> str:
|
||||
if v not in SKILL_MERGE_MODES:
|
||||
raise ValueError("merge_mode: additive, replace_ai_only oder replace_all")
|
||||
return v
|
||||
|
||||
|
||||
class EnrichmentAnalyzeBody(BaseModel):
|
||||
exercise_ids: List[int] = Field(..., min_length=1, max_length=_MAX_ANALYZE_IDS)
|
||||
modes: EnrichmentModes = Field(default_factory=EnrichmentModes)
|
||||
|
||||
|
||||
class EnrichmentApplyItem(BaseModel):
|
||||
exercise_id: int = Field(..., ge=1)
|
||||
merged_skills: List[Dict[str, Any]] = Field(default_factory=list)
|
||||
summary: Optional[str] = None
|
||||
instruction_fields: Optional[Dict[str, Any]] = None
|
||||
|
||||
|
||||
class EnrichmentApplyBody(BaseModel):
|
||||
items: List[EnrichmentApplyItem] = Field(..., min_length=1, max_length=MAX_BATCH_EXERCISES)
|
||||
modes: EnrichmentModes = Field(default_factory=EnrichmentModes)
|
||||
merge_mode: SkillMergeMode = "replace_all"
|
||||
set_status: Optional[str] = DEFAULT_SET_STATUS
|
||||
|
||||
@field_validator("merge_mode")
|
||||
@classmethod
|
||||
def _merge_mode_ok(cls, v: str) -> str:
|
||||
if v not in SKILL_MERGE_MODES:
|
||||
raise ValueError("merge_mode: additive, replace_ai_only oder replace_all")
|
||||
return v
|
||||
|
||||
@field_validator("set_status")
|
||||
@classmethod
|
||||
def _status_ok(cls, v: Optional[str]) -> Optional[str]:
|
||||
if v is None:
|
||||
return None
|
||||
s = str(v).strip().lower()
|
||||
if s == "approved":
|
||||
raise ValueError("Automatisches Freigeben (approved) ist nicht erlaubt")
|
||||
if s not in _VALID_STATUS_FILTER:
|
||||
raise ValueError("Ungültiger Ziel-Status")
|
||||
return s
|
||||
|
||||
|
||||
@router.get("/api/admin/exercise-enrichment/candidates")
|
||||
def list_enrichment_candidates(
|
||||
session: dict = Depends(require_auth),
|
||||
status: str = Query(default="draft"),
|
||||
visibility: Optional[str] = Query(default="private"),
|
||||
focus_area_id: Optional[int] = Query(default=None, ge=1),
|
||||
without_skills: bool = Query(default=False),
|
||||
with_ai_suggested_skills: bool = Query(default=False),
|
||||
search: Optional[str] = Query(default=None, max_length=200),
|
||||
limit: int = Query(default=25, ge=1, le=_MAX_CANDIDATES_LIMIT),
|
||||
offset: int = Query(default=0, ge=0),
|
||||
):
|
||||
"""Paginierte Kandidatenliste für Superadmin-Anreicherung."""
|
||||
_require_superadmin(session)
|
||||
|
||||
where, params = _build_candidates_where(
|
||||
status=status,
|
||||
visibility=visibility,
|
||||
focus_area_id=focus_area_id,
|
||||
without_skills=without_skills,
|
||||
with_ai_suggested_skills=with_ai_suggested_skills,
|
||||
)
|
||||
|
||||
qtext = (search or "").strip()
|
||||
if qtext:
|
||||
where.append("e.search_vector @@ plainto_tsquery('german', %s)")
|
||||
params.append(qtext)
|
||||
|
||||
where_sql = " AND ".join(where)
|
||||
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
cur.execute(f"SELECT COUNT(*) AS c FROM exercises e WHERE {where_sql}", tuple(params))
|
||||
count_row = cur.fetchone()
|
||||
total = int(count_row["c"] if isinstance(count_row, dict) else count_row[0])
|
||||
|
||||
cur.execute(
|
||||
f"""
|
||||
SELECT e.id, e.title, e.status, e.visibility, e.summary, e.updated_at,
|
||||
(
|
||||
SELECT fa.name FROM exercise_focus_areas efa
|
||||
JOIN focus_areas fa ON fa.id = efa.focus_area_id
|
||||
WHERE efa.exercise_id = e.id
|
||||
ORDER BY efa.is_primary DESC NULLS LAST, fa.name ASC
|
||||
LIMIT 1
|
||||
) AS primary_focus_name,
|
||||
(
|
||||
SELECT COUNT(*)::int FROM exercise_skills es WHERE es.exercise_id = e.id
|
||||
) AS skill_count,
|
||||
(
|
||||
SELECT COUNT(*)::int FROM exercise_skills es
|
||||
WHERE es.exercise_id = e.id AND es.ai_suggested = true
|
||||
) AS ai_suggested_skill_count
|
||||
FROM exercises e
|
||||
WHERE {where_sql}
|
||||
ORDER BY e.updated_at DESC, e.id DESC
|
||||
LIMIT %s OFFSET %s
|
||||
""",
|
||||
tuple(params + [limit, offset]),
|
||||
)
|
||||
rows = [r2d(r) for r in cur.fetchall()]
|
||||
|
||||
return {
|
||||
"items": rows,
|
||||
"total": total,
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/api/admin/exercise-enrichment/candidate-ids")
|
||||
def list_enrichment_candidate_ids(
|
||||
session: dict = Depends(require_auth),
|
||||
status: str = Query(default="draft"),
|
||||
visibility: Optional[str] = Query(default="private"),
|
||||
focus_area_id: Optional[int] = Query(default=None, ge=1),
|
||||
without_skills: bool = Query(default=False),
|
||||
with_ai_suggested_skills: bool = Query(default=False),
|
||||
search: Optional[str] = Query(default=None, max_length=200),
|
||||
):
|
||||
"""Alle IDs zum aktuellen Filter (für „Alle auswählen“) — ohne Pagination-Obergrenze."""
|
||||
_require_superadmin(session)
|
||||
|
||||
where, params = _build_candidates_where(
|
||||
status=status,
|
||||
visibility=visibility,
|
||||
focus_area_id=focus_area_id,
|
||||
without_skills=without_skills,
|
||||
with_ai_suggested_skills=with_ai_suggested_skills,
|
||||
)
|
||||
|
||||
qtext = (search or "").strip()
|
||||
if qtext:
|
||||
where.append("e.search_vector @@ plainto_tsquery('german', %s)")
|
||||
params.append(qtext)
|
||||
|
||||
where_sql = " AND ".join(where)
|
||||
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
cur.execute(
|
||||
f"SELECT e.id FROM exercises e WHERE {where_sql} ORDER BY e.updated_at DESC, e.id DESC",
|
||||
tuple(params),
|
||||
)
|
||||
ids = [int(r["id"] if isinstance(r, dict) else r[0]) for r in cur.fetchall()]
|
||||
|
||||
return {"ids": ids, "total": len(ids)}
|
||||
|
||||
|
||||
@router.post("/api/admin/exercise-enrichment/analyze")
|
||||
def analyze_enrichment(body: EnrichmentAnalyzeBody, session: dict = Depends(require_auth)):
|
||||
"""Kosten-/Umfangsanalyse vor dem Batch-Lauf (explizite Nutzerbestätigung)."""
|
||||
_require_superadmin(session)
|
||||
|
||||
ids = _normalize_id_list(body.exercise_ids, max_items=_MAX_ANALYZE_IDS)
|
||||
if not ids:
|
||||
raise HTTPException(status_code=400, detail="Keine gültigen Übungs-IDs")
|
||||
|
||||
if not body.modes.skills and not body.modes.summary and not body.modes.instructions:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Mindestens ein Modus (skills, summary oder instructions) aktivieren",
|
||||
)
|
||||
|
||||
est = estimate_llm_calls(
|
||||
exercise_count=len(ids),
|
||||
want_skills=body.modes.skills,
|
||||
want_summary=body.modes.summary,
|
||||
want_instructions=body.modes.instructions,
|
||||
)
|
||||
|
||||
return {
|
||||
"exercise_count": len(ids),
|
||||
"estimated_llm_calls": est,
|
||||
"modes": body.modes.model_dump(),
|
||||
"warning": (
|
||||
f"Batch mit {len(ids)} Übungen und ca. {est['total']} LLM-Aufrufen — Kosten beachten."
|
||||
if len(ids) >= 25 or est["total"] >= 50
|
||||
else None
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _preview_one_exercise(
|
||||
ex_id: int,
|
||||
*,
|
||||
want_skills: bool,
|
||||
want_summary: bool,
|
||||
want_instructions: bool,
|
||||
merge_mode: SkillMergeMode,
|
||||
) -> Dict[str, Any]:
|
||||
"""Einzel-Preview mit eigener DB-Connection (Thread-Pool)."""
|
||||
try:
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
row = preview_exercise_enrichment(
|
||||
cur,
|
||||
ex_id,
|
||||
want_skills=want_skills,
|
||||
want_summary=want_summary,
|
||||
want_instructions=want_instructions,
|
||||
merge_mode=merge_mode,
|
||||
)
|
||||
return row
|
||||
except HTTPException as he:
|
||||
d = he.detail
|
||||
return {"exercise_id": ex_id, "ok": False, "error": d if isinstance(d, str) else str(d)}
|
||||
except Exception as exc: # pragma: no cover
|
||||
return {"exercise_id": ex_id, "ok": False, "error": str(exc)}
|
||||
|
||||
|
||||
@router.post("/api/admin/exercise-enrichment/preview")
|
||||
def preview_enrichment(body: EnrichmentPreviewBody, session: dict = Depends(require_auth)):
|
||||
"""Dry-Run: KI-Vorschläge laden, nichts speichern (max. 3 Übungen/Request, parallel)."""
|
||||
_require_superadmin(session)
|
||||
|
||||
ids = _normalize_id_list(body.exercise_ids, max_items=MAX_PREVIEW_BATCH_EXERCISES)
|
||||
if not ids:
|
||||
raise HTTPException(status_code=400, detail="Keine gültigen Übungs-IDs")
|
||||
|
||||
modes = body.modes
|
||||
if not modes.skills and not modes.summary and not modes.instructions:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Mindestens ein Modus (skills, summary oder instructions) aktivieren",
|
||||
)
|
||||
|
||||
results: List[Dict[str, Any]] = []
|
||||
errors: List[Dict[str, Any]] = []
|
||||
|
||||
workers = min(_PREVIEW_MAX_WORKERS, len(ids))
|
||||
with ThreadPoolExecutor(max_workers=workers) as pool:
|
||||
futures = [
|
||||
pool.submit(
|
||||
_preview_one_exercise,
|
||||
ex_id,
|
||||
want_skills=modes.skills,
|
||||
want_summary=modes.summary,
|
||||
want_instructions=modes.instructions,
|
||||
merge_mode=body.merge_mode,
|
||||
)
|
||||
for ex_id in ids
|
||||
]
|
||||
for fut in as_completed(futures):
|
||||
row = fut.result()
|
||||
if row.get("ok"):
|
||||
results.append(row)
|
||||
else:
|
||||
errors.append(row)
|
||||
|
||||
results.sort(key=lambda r: int(r.get("exercise_id") or 0))
|
||||
errors.sort(key=lambda r: int(r.get("exercise_id") or 0))
|
||||
|
||||
est = estimate_llm_calls(
|
||||
exercise_count=len(ids),
|
||||
want_skills=modes.skills,
|
||||
want_summary=modes.summary,
|
||||
want_instructions=modes.instructions,
|
||||
)
|
||||
|
||||
return {
|
||||
"results": results,
|
||||
"errors": errors,
|
||||
"processed": len(ids),
|
||||
"ok_count": len(results),
|
||||
"error_count": len(errors),
|
||||
"estimated_llm_calls": est,
|
||||
"merge_mode": body.merge_mode,
|
||||
"modes": modes.model_dump(),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/api/admin/exercise-enrichment/apply")
|
||||
def apply_enrichment(body: EnrichmentApplyBody, session: dict = Depends(require_auth)):
|
||||
"""Vorschläge anwenden und optional Status setzen (Default: in_review)."""
|
||||
_require_superadmin(session)
|
||||
|
||||
if not body.items:
|
||||
raise HTTPException(status_code=400, detail="Keine Items")
|
||||
|
||||
modes = body.modes
|
||||
if not modes.skills and not modes.summary and not modes.instructions:
|
||||
raise HTTPException(status_code=400, detail="Kein Anwendungsmodus aktiv")
|
||||
|
||||
applied: List[Dict[str, Any]] = []
|
||||
failed: List[Dict[str, Any]] = []
|
||||
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
for item in body.items:
|
||||
ex_id = int(item.exercise_id)
|
||||
try:
|
||||
row = apply_exercise_enrichment(
|
||||
cur,
|
||||
ex_id,
|
||||
merged_skills=item.merged_skills,
|
||||
merge_mode=body.merge_mode,
|
||||
set_status=body.set_status,
|
||||
apply_skills=modes.skills,
|
||||
summary_text=item.summary,
|
||||
apply_summary=modes.summary,
|
||||
instruction_fields=item.instruction_fields,
|
||||
apply_instructions=modes.instructions,
|
||||
)
|
||||
if row.get("ok"):
|
||||
applied.append(row)
|
||||
else:
|
||||
failed.append(row)
|
||||
except HTTPException as he:
|
||||
d = he.detail
|
||||
failed.append({"exercise_id": ex_id, "ok": False, "error": d if isinstance(d, str) else str(d)})
|
||||
except Exception as exc: # pragma: no cover
|
||||
failed.append({"exercise_id": ex_id, "ok": False, "error": str(exc)})
|
||||
conn.commit()
|
||||
|
||||
return {
|
||||
"applied": applied,
|
||||
"failed": failed,
|
||||
"applied_count": len(applied),
|
||||
"failed_count": len(failed),
|
||||
"set_status": body.set_status,
|
||||
"merge_mode": body.merge_mode,
|
||||
"modes": modes.model_dump(),
|
||||
}
|
||||
20
backend/routers/planning_exercise_suggest.py
Normal file
20
backend/routers/planning_exercise_suggest.py
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
"""
|
||||
POST /api/planning/exercise-suggest — planungsgebundene Übungssuche (Hybrid + Profil + optional LLM-Rerank).
|
||||
"""
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
from db import get_db, get_cursor
|
||||
from tenant_context import TenantContext, get_tenant_context
|
||||
from planning_exercise_suggest import PlanningExerciseSuggestRequest, suggest_planning_exercises
|
||||
|
||||
router = APIRouter(prefix="/api/planning", tags=["planning_exercise_suggest"])
|
||||
|
||||
|
||||
@router.post("/exercise-suggest")
|
||||
def post_planning_exercise_suggest(
|
||||
body: PlanningExerciseSuggestRequest,
|
||||
tenant: TenantContext = Depends(get_tenant_context),
|
||||
):
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
return suggest_planning_exercises(cur, tenant=tenant, body=body)
|
||||
|
|
@ -25,6 +25,7 @@ EXEMPT_ROUTERS: frozenset[str] = frozenset(
|
|||
"legal_documents.py", # ACCESS_LAYER exempt: Plattform-Rechtstexte ohne Vereinsbezug; öffentlicher Endpoint ohne Auth, Admin-Endpoints require_auth + is_superadmin()
|
||||
"ai_skill_retrieval_admin.py", # Superadmin-Plattform-Konfiguration Skill-KI-Retrieval; require_auth + is_superadmin — kein Vereinsmandant
|
||||
"ai_prompts_admin.py", # Superadmin ai_prompts; require_auth + is_superadmin — kein Vereinsmandant
|
||||
"exercise_enrichment_admin.py", # Superadmin Batch-Übungs-Anreicherung KI; require_auth + is_superadmin — kein Vereinsmandant
|
||||
"catalogs.py",
|
||||
"skills.py",
|
||||
"maturity_models.py",
|
||||
|
|
|
|||
282
backend/tests/test_exercise_enrichment_admin.py
Normal file
282
backend/tests/test_exercise_enrichment_admin.py
Normal file
|
|
@ -0,0 +1,282 @@
|
|||
"""Superadmin Übungs-Anreicherung — Auth, Merge-Logik, Status, Analyze."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
os.environ.setdefault("SKIP_DB_MIGRATE", "1")
|
||||
|
||||
from auth import require_auth
|
||||
from exercise_enrichment import (
|
||||
apply_exercise_enrichment,
|
||||
compute_skill_diff,
|
||||
estimate_llm_calls,
|
||||
merge_skills,
|
||||
persist_merged_skills,
|
||||
validate_exercise_for_enrichment,
|
||||
)
|
||||
from main import app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client() -> TestClient:
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clear_overrides():
|
||||
yield
|
||||
app.dependency_overrides.pop(require_auth, None)
|
||||
|
||||
|
||||
def test_candidates_requires_superadmin(client: TestClient) -> None:
|
||||
def _admin():
|
||||
return {"profile_id": 1, "role": "admin"}
|
||||
|
||||
app.dependency_overrides[require_auth] = _admin
|
||||
r = client.get("/api/admin/exercise-enrichment/candidates", headers={"X-Auth-Token": "t"})
|
||||
assert r.status_code == 403
|
||||
|
||||
|
||||
def test_preview_requires_superadmin(client: TestClient) -> None:
|
||||
def _trainer():
|
||||
return {"profile_id": 1, "role": "trainer"}
|
||||
|
||||
app.dependency_overrides[require_auth] = _trainer
|
||||
r = client.post(
|
||||
"/api/admin/exercise-enrichment/preview",
|
||||
headers={"X-Auth-Token": "t", "Content-Type": "application/json"},
|
||||
json={"exercise_ids": [1], "modes": {"skills": True}},
|
||||
)
|
||||
assert r.status_code == 403
|
||||
|
||||
|
||||
@patch("routers.exercise_enrichment_admin.get_db")
|
||||
def test_candidates_ok_for_superadmin(mock_get_db, client: TestClient) -> None:
|
||||
def _super():
|
||||
return {"profile_id": 1, "role": "superadmin"}
|
||||
|
||||
app.dependency_overrides[require_auth] = _super
|
||||
|
||||
mock_cm = MagicMock()
|
||||
mock_conn = MagicMock()
|
||||
mock_cm.__enter__.return_value = mock_conn
|
||||
mock_cm.__exit__.return_value = False
|
||||
mock_cur = MagicMock()
|
||||
mock_cur.fetchone.side_effect = [{"c": 2}, None]
|
||||
mock_cur.fetchall.return_value = [
|
||||
{
|
||||
"id": 10,
|
||||
"title": "Kata Basics",
|
||||
"status": "draft",
|
||||
"visibility": "private",
|
||||
"summary": "",
|
||||
"updated_at": "2026-05-23T10:00:00",
|
||||
"primary_focus_name": "Karate",
|
||||
"skill_count": 0,
|
||||
"ai_suggested_skill_count": 0,
|
||||
}
|
||||
]
|
||||
mock_get_db.return_value = mock_cm
|
||||
|
||||
with patch("routers.exercise_enrichment_admin.get_cursor", return_value=mock_cur):
|
||||
r = client.get(
|
||||
"/api/admin/exercise-enrichment/candidates?status=draft&without_skills=true",
|
||||
headers={"X-Auth-Token": "t"},
|
||||
)
|
||||
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["total"] == 2
|
||||
assert len(body["items"]) == 1
|
||||
assert body["items"][0]["id"] == 10
|
||||
|
||||
|
||||
def test_analyze_returns_llm_estimate(client: TestClient) -> None:
|
||||
def _super():
|
||||
return {"profile_id": 1, "role": "superadmin"}
|
||||
|
||||
app.dependency_overrides[require_auth] = _super
|
||||
r = client.post(
|
||||
"/api/admin/exercise-enrichment/analyze",
|
||||
headers={"X-Auth-Token": "t", "Content-Type": "application/json"},
|
||||
json={
|
||||
"exercise_ids": [1, 2, 3],
|
||||
"modes": {"skills": True, "summary": True, "instructions": False},
|
||||
},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["exercise_count"] == 3
|
||||
est = body["estimated_llm_calls"]
|
||||
assert est["total"] == 6
|
||||
assert est["skills"] == 3
|
||||
assert est["summary"] == 3
|
||||
|
||||
|
||||
def test_validate_exercise_requires_title_and_content() -> None:
|
||||
assert (
|
||||
validate_exercise_for_enrichment({"title": "", "goal": "<p>x</p>"}, want_skills=True)
|
||||
== "Titel fehlt"
|
||||
)
|
||||
assert (
|
||||
validate_exercise_for_enrichment({"title": "Foo", "goal": "", "execution": ""}, want_skills=True)
|
||||
== "Mindestens Ziel oder Durchführung muss Inhalt liefern (für Skills/Kurzfassung)"
|
||||
)
|
||||
assert validate_exercise_for_enrichment({"title": "Foo", "goal": "<p>Ziel</p>"}, want_skills=True) is None
|
||||
|
||||
|
||||
def test_merge_skills_additive_keeps_manual() -> None:
|
||||
existing = [
|
||||
{
|
||||
"skill_id": 1,
|
||||
"skill_name": "Manual",
|
||||
"intensity": "hoch",
|
||||
"required_level": "aufbau",
|
||||
"target_level": "fortgeschritten",
|
||||
"is_primary": True,
|
||||
"ai_suggested": False,
|
||||
}
|
||||
]
|
||||
suggested = [
|
||||
{
|
||||
"skill_id": 1,
|
||||
"skill_name": "Manual AI",
|
||||
"intensity": "niedrig",
|
||||
"required_level": "basis",
|
||||
"target_level": "grundlagen",
|
||||
"is_primary": False,
|
||||
},
|
||||
{
|
||||
"skill_id": 2,
|
||||
"skill_name": "New AI",
|
||||
"intensity": "mittel",
|
||||
"required_level": "grundlagen",
|
||||
"target_level": "aufbau",
|
||||
"is_primary": False,
|
||||
},
|
||||
]
|
||||
merged = merge_skills(existing, suggested, "additive")
|
||||
assert len(merged) == 2
|
||||
manual = next(s for s in merged if s["skill_id"] == 1)
|
||||
assert manual["intensity"] == "hoch"
|
||||
assert manual["ai_suggested"] is False
|
||||
ai_new = next(s for s in merged if s["skill_id"] == 2)
|
||||
assert ai_new["ai_suggested"] is True
|
||||
assert ai_new["intensity"] == "mittel"
|
||||
|
||||
|
||||
def test_merge_skills_replace_all_marks_ai() -> None:
|
||||
existing = [
|
||||
{"skill_id": 1, "skill_name": "M", "intensity": "hoch", "ai_suggested": False},
|
||||
]
|
||||
suggested = [
|
||||
{"skill_id": 3, "skill_name": "New AI", "intensity": "niedrig", "required_level": "basis", "target_level": "aufbau"},
|
||||
]
|
||||
merged = merge_skills(existing, suggested, "replace_all")
|
||||
assert len(merged) == 1
|
||||
assert merged[0]["skill_id"] == 3
|
||||
assert merged[0]["ai_suggested"] is True
|
||||
assert merged[0]["intensity"] == "niedrig"
|
||||
|
||||
|
||||
def test_compute_skill_diff_added_and_removed() -> None:
|
||||
before = [{"skill_id": 1, "skill_name": "A", "intensity": "mittel", "ai_suggested": False}]
|
||||
after = [
|
||||
{"skill_id": 1, "skill_name": "A", "intensity": "mittel", "ai_suggested": False},
|
||||
{"skill_id": 2, "skill_name": "B", "intensity": "hoch", "ai_suggested": True},
|
||||
]
|
||||
diff = compute_skill_diff(before, after)
|
||||
assert len(diff["added"]) == 1
|
||||
assert diff["added"][0]["skill_id"] == 2
|
||||
|
||||
|
||||
@patch("exercise_enrichment.enrich_exercise_detail")
|
||||
def test_apply_sets_in_review(mock_enrich) -> None:
|
||||
mock_enrich.return_value = {
|
||||
"id": 5,
|
||||
"title": "Test",
|
||||
"goal": "<p>G</p>",
|
||||
"execution": "",
|
||||
"status": "draft",
|
||||
"skills": [],
|
||||
}
|
||||
mock_cur = MagicMock()
|
||||
row = apply_exercise_enrichment(
|
||||
mock_cur,
|
||||
5,
|
||||
merged_skills=[
|
||||
{
|
||||
"skill_id": 9,
|
||||
"skill_name": "Kick",
|
||||
"intensity": "mittel",
|
||||
"required_level": "grundlagen",
|
||||
"target_level": "aufbau",
|
||||
"is_primary": True,
|
||||
"ai_suggested": True,
|
||||
}
|
||||
],
|
||||
merge_mode="replace_all",
|
||||
set_status="in_review",
|
||||
apply_skills=True,
|
||||
)
|
||||
assert row["ok"] is True
|
||||
assert row["status"] == "in_review"
|
||||
assert mock_cur.execute.call_count >= 2
|
||||
|
||||
|
||||
def test_apply_rejects_approved_status() -> None:
|
||||
mock_cur = MagicMock()
|
||||
with patch(
|
||||
"exercise_enrichment.enrich_exercise_detail",
|
||||
return_value={
|
||||
"title": "T",
|
||||
"goal": "<p>G</p>",
|
||||
"status": "draft",
|
||||
"skills": [],
|
||||
},
|
||||
):
|
||||
row = apply_exercise_enrichment(
|
||||
mock_cur,
|
||||
1,
|
||||
merged_skills=[{"skill_id": 1, "intensity": "mittel", "ai_suggested": True}],
|
||||
set_status="approved",
|
||||
apply_skills=True,
|
||||
)
|
||||
assert row["ok"] is False
|
||||
assert "approved" in row["error"]
|
||||
|
||||
|
||||
def test_persist_merged_skills_uses_upsert() -> None:
|
||||
mock_cur = MagicMock()
|
||||
persist_merged_skills(
|
||||
mock_cur,
|
||||
7,
|
||||
[
|
||||
{
|
||||
"skill_id": 3,
|
||||
"intensity": "mittel",
|
||||
"required_level": "grundlagen",
|
||||
"target_level": "aufbau",
|
||||
"is_primary": False,
|
||||
"ai_suggested": True,
|
||||
}
|
||||
],
|
||||
"replace_all",
|
||||
)
|
||||
sql = mock_cur.execute.call_args_list[-1][0][0]
|
||||
assert "INSERT INTO exercise_skills" in sql
|
||||
|
||||
|
||||
def test_estimate_llm_calls_breakdown() -> None:
|
||||
est = estimate_llm_calls(
|
||||
exercise_count=100,
|
||||
want_skills=True,
|
||||
want_summary=False,
|
||||
want_instructions=True,
|
||||
)
|
||||
assert est["total"] == 200
|
||||
assert est["per_exercise"] == 2
|
||||
109
backend/tests/test_planning_exercise_retrieval.py
Normal file
109
backend/tests/test_planning_exercise_retrieval.py
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
"""Tests Planungs-Retrieval Phase A (Voll-Library-Ranking)."""
|
||||
from planning_exercise_profiles import ExerciseMatchProfile, PlanningTargetProfile
|
||||
from planning_exercise_retrieval import (
|
||||
fetch_all_visible_exercise_rows,
|
||||
rank_visible_library_hits,
|
||||
)
|
||||
|
||||
|
||||
def test_fetch_all_visible_has_no_profile_or_pool_filter():
|
||||
captured = {}
|
||||
|
||||
class _Cur:
|
||||
def execute(self, sql, params):
|
||||
captured["sql"] = sql
|
||||
captured["params"] = list(params)
|
||||
|
||||
def fetchall(self):
|
||||
return []
|
||||
|
||||
fetch_all_visible_exercise_rows(
|
||||
_Cur(),
|
||||
vis_sql="(e.visibility = 'official' OR (e.visibility = 'private' AND e.created_by = %s))",
|
||||
vis_params=[42],
|
||||
query="Kime Partner",
|
||||
exercise_kind_any=["simple"],
|
||||
)
|
||||
|
||||
sql = captured["sql"].lower()
|
||||
assert "exercise_skills" not in sql
|
||||
assert "@@ plainto_tsquery" not in sql
|
||||
assert " exists " not in sql.replace("primary_focus_name", "")
|
||||
params = captured["params"]
|
||||
assert params[0] == "Kime Partner"
|
||||
assert params[1] == 42
|
||||
assert params[2] == "archived"
|
||||
assert params[3] == "simple"
|
||||
assert params[-1] == 8000
|
||||
|
||||
|
||||
def test_rank_visible_library_prefers_profile_over_untagged_pool_miss():
|
||||
"""Übung ohne Pool-Tags, aber hoher Profil-Match, muss vor schwachem Treffer ranken."""
|
||||
|
||||
target = PlanningTargetProfile(
|
||||
focus_area_ids={10: 1.0},
|
||||
skill_weights={5: 1.0},
|
||||
sources=["framework_catalog"],
|
||||
)
|
||||
rows = [
|
||||
{"id": 1, "title": "Schwach", "summary": "", "primary_focus_name": "X", "ft_rank": 0.9},
|
||||
{"id": 2, "title": "Stark", "summary": "", "primary_focus_name": "Karate", "ft_rank": 0.0},
|
||||
]
|
||||
|
||||
profiles = {
|
||||
1: ExerciseMatchProfile(exercise_id=1, focus_area_ids={99: 1.0}),
|
||||
2: ExerciseMatchProfile(exercise_id=2, focus_area_ids={10: 1.0}, skill_weights={5: 1.0}),
|
||||
}
|
||||
|
||||
class _Cur:
|
||||
def execute(self, sql, params=None):
|
||||
return None
|
||||
|
||||
def fetchall(self):
|
||||
return []
|
||||
|
||||
def _fake_load(cur, exercise_ids, *, batch=400):
|
||||
_ = (cur, batch)
|
||||
return {eid: profiles[eid] for eid in exercise_ids if eid in profiles}
|
||||
|
||||
def _fake_skills(cur, exercise_ids, *, batch=400):
|
||||
_ = (cur, batch)
|
||||
return {1: {99}, 2: {5, 10}}
|
||||
|
||||
import planning_exercise_retrieval as mod
|
||||
|
||||
orig_profiles = mod._load_match_profiles_chunked
|
||||
orig_skills = mod._load_skill_sets_chunked
|
||||
try:
|
||||
mod._load_match_profiles_chunked = _fake_load
|
||||
mod._load_skill_sets_chunked = _fake_skills
|
||||
hits, _ = rank_visible_library_hits(
|
||||
_Cur(),
|
||||
rows,
|
||||
query="",
|
||||
intent="suggest_next",
|
||||
intent_weights={
|
||||
"fulltext": 0.08,
|
||||
"progression": 0.28,
|
||||
"skill": 0.12,
|
||||
"plan": 0.10,
|
||||
"profile": 0.25,
|
||||
"repeat_unit": -0.30,
|
||||
"repeat_group": -0.15,
|
||||
},
|
||||
target=target,
|
||||
pack={
|
||||
"planned_exercise_ids": [],
|
||||
"group_recent_exercise_ids": [],
|
||||
"progression_successor_ids": [],
|
||||
"anchor_skill_ids": [],
|
||||
"anchor_exercise_id": None,
|
||||
"progression_edge_notes": {},
|
||||
},
|
||||
)
|
||||
finally:
|
||||
mod._load_match_profiles_chunked = orig_profiles
|
||||
mod._load_skill_sets_chunked = orig_skills
|
||||
|
||||
assert hits[0]["id"] == 2
|
||||
assert hits[0]["score"] > hits[1]["score"]
|
||||
170
backend/tests/test_planning_exercise_suggest.py
Normal file
170
backend/tests/test_planning_exercise_suggest.py
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
"""Tests Planungs-Übungssuche: Intent, Szenario-Pipeline, LLM-Parser."""
|
||||
from planning_exercise_suggest import resolve_planning_exercise_intent
|
||||
from planning_exercise_intent import parse_planning_query_intent_response
|
||||
from planning_exercise_llm_rank import parse_planning_exercise_rank_response
|
||||
from planning_exercise_target_pipeline import (
|
||||
SCENARIO_ADDITIVE,
|
||||
SCENARIO_PRESET_NEXT,
|
||||
classify_planning_scenario,
|
||||
compose_retrieval_phase,
|
||||
is_simple_preset_query,
|
||||
should_run_llm_intent_pipeline,
|
||||
)
|
||||
|
||||
|
||||
def test_resolve_planning_exercise_intent_defaults():
|
||||
assert resolve_planning_exercise_intent("", None) == "suggest_next"
|
||||
assert resolve_planning_exercise_intent(" ", "suggest_next") == "suggest_next"
|
||||
|
||||
|
||||
def test_resolve_planning_exercise_intent_keywords():
|
||||
assert resolve_planning_exercise_intent("Vertiefung Partner", None) == "deepen_exercise"
|
||||
assert resolve_planning_exercise_intent("nächste übung", None) == "suggest_next"
|
||||
assert resolve_planning_exercise_intent("progression graph", None) == "progression_next"
|
||||
|
||||
|
||||
def test_classify_planning_scenario_preset():
|
||||
assert is_simple_preset_query("Schlage mir die nächste Übung vor")
|
||||
assert is_simple_preset_query("nächste Übung planen")
|
||||
assert classify_planning_scenario("", "suggest_next") == SCENARIO_PRESET_NEXT
|
||||
assert classify_planning_scenario("nächste übung", "suggest_next") == SCENARIO_PRESET_NEXT
|
||||
assert classify_planning_scenario("nächste Übung planen", "suggest_next") == SCENARIO_PRESET_NEXT
|
||||
|
||||
|
||||
def test_classify_planning_scenario_additive():
|
||||
q = "Baut auf der Planung auf und trainiert zusätzlich Schnellkraft"
|
||||
assert classify_planning_scenario(q, "continue_plan_goal") == SCENARIO_ADDITIVE
|
||||
assert should_run_llm_intent_pipeline(q, SCENARIO_ADDITIVE, include_llm_intent=True)
|
||||
|
||||
|
||||
def test_should_skip_llm_for_preset():
|
||||
assert not should_run_llm_intent_pipeline("", SCENARIO_PRESET_NEXT, include_llm_intent=True)
|
||||
assert not should_run_llm_intent_pipeline(
|
||||
"nächste übung",
|
||||
SCENARIO_PRESET_NEXT,
|
||||
include_llm_intent=True,
|
||||
)
|
||||
|
||||
|
||||
def test_should_skip_llm_intent_short_free_search():
|
||||
from planning_exercise_target_pipeline import SCENARIO_FREE_SEARCH, should_run_llm_intent_pipeline
|
||||
|
||||
assert not should_run_llm_intent_pipeline(
|
||||
"Partnerübung",
|
||||
SCENARIO_FREE_SEARCH,
|
||||
include_llm_intent=True,
|
||||
)
|
||||
|
||||
|
||||
def test_should_skip_llm_rank_when_intent_already_applied():
|
||||
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(
|
||||
"Baut auf dem Plan auf und trainiert zusätzlich Schnellkraft mit Partner",
|
||||
SCENARIO_ADDITIVE,
|
||||
include_llm_rank=True,
|
||||
query_intent_applied=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"
|
||||
|
||||
assert (
|
||||
compose_retrieval_phase(full_library=True, query_intent=True, llm_rank=False)
|
||||
== "profile_v1+full_library+query_intent"
|
||||
)
|
||||
|
||||
|
||||
def test_should_run_llm_expectation_for_preset_with_planning_ref():
|
||||
from planning_exercise_target_pipeline import should_run_llm_expectation_pipeline
|
||||
|
||||
assert should_run_llm_expectation_pipeline(
|
||||
SCENARIO_PRESET_NEXT,
|
||||
include_llm_intent=True,
|
||||
has_planning_reference=True,
|
||||
)
|
||||
assert not should_run_llm_expectation_pipeline(
|
||||
SCENARIO_PRESET_NEXT,
|
||||
include_llm_intent=False,
|
||||
has_planning_reference=True,
|
||||
)
|
||||
assert not should_run_llm_expectation_pipeline(
|
||||
SCENARIO_ADDITIVE,
|
||||
include_llm_intent=True,
|
||||
has_planning_reference=True,
|
||||
)
|
||||
|
||||
|
||||
def test_should_skip_llm_rank_when_expectation_applied():
|
||||
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}]
|
||||
assert not should_run_llm_rank_pipeline(
|
||||
"",
|
||||
SCENARIO_PRESET_NEXT,
|
||||
include_llm_rank=True,
|
||||
query_intent_applied=False,
|
||||
llm_expectation_applied=True,
|
||||
hits=hits,
|
||||
)
|
||||
|
||||
|
||||
def test_compose_retrieval_phase_llm_expectation():
|
||||
assert (
|
||||
compose_retrieval_phase(llm_expectation=True)
|
||||
== "profile_v1+llm_expectation"
|
||||
)
|
||||
assert (
|
||||
compose_retrieval_phase(full_library=True, llm_expectation=True)
|
||||
== "profile_v1+full_library+llm_expectation"
|
||||
)
|
||||
|
||||
|
||||
def test_query_only_expectation_without_planning_reference():
|
||||
from planning_exercise_profiles import PlanningTargetProfile
|
||||
from planning_exercise_target_pipeline import build_planning_target_with_query_pipeline
|
||||
|
||||
class _Cur:
|
||||
pass
|
||||
|
||||
target, intent, scenario, summary = build_planning_target_with_query_pipeline(
|
||||
_Cur(),
|
||||
unit={"id": None, "framework_slot_id": None, "origin_framework_slot_id": None},
|
||||
planned_exercise_ids=[],
|
||||
section_planned_exercise_ids=[],
|
||||
anchor_exercise_id=None,
|
||||
query="Partnerübung Reaktion",
|
||||
heuristic_intent="free_search",
|
||||
include_llm_intent=False,
|
||||
context_summary={"expectation_mode": "query_only"},
|
||||
has_planning_reference=False,
|
||||
)
|
||||
assert intent == "free_search"
|
||||
assert summary.get("expectation_mode") == "query_only"
|
||||
assert target.sources == ["query_only"] or "query_only" in target.sources
|
||||
|
||||
|
||||
def test_parse_planning_query_intent_response():
|
||||
parsed = parse_planning_query_intent_response(
|
||||
'{"intent":"continue_plan_goal","scenario":"additive_constraint",'
|
||||
'"skill_hints":[{"name":"Schnellkraft","weight":1}],"emphasis":"additive",'
|
||||
'"rationale":"Zusatz Schnellkraft"}'
|
||||
)
|
||||
assert parsed.intent == "continue_plan_goal"
|
||||
assert parsed.scenario == "additive_constraint"
|
||||
assert parsed.skill_hints[0].name == "Schnellkraft"
|
||||
|
||||
|
||||
def test_parse_planning_exercise_rank_response_filters_ids():
|
||||
allowed = {10, 20, 30}
|
||||
ranked, reasons = parse_planning_exercise_rank_response(
|
||||
'{"ranked_ids":[20,999,20,10],"reasons":{"20":"Passt gut","999":"ignore"}}',
|
||||
allowed,
|
||||
)
|
||||
assert ranked == [20, 10]
|
||||
assert reasons[20] == "Passt gut"
|
||||
assert 999 not in reasons
|
||||
|
|
@ -1,8 +1,8 @@
|
|||
# Shinkan Jinkendo Version Information
|
||||
|
||||
APP_VERSION = "0.8.166"
|
||||
BUILD_DATE = "2026-05-22"
|
||||
DB_SCHEMA_VERSION = "20260531071"
|
||||
APP_VERSION = "0.8.180"
|
||||
BUILD_DATE = "2026-05-23"
|
||||
DB_SCHEMA_VERSION = "20260531074"
|
||||
|
||||
MODULE_VERSIONS = {
|
||||
"legal_documents": "1.4.0", # Admin: Live-Vorschau pro Abschnitt + modale Vollvorschau (Editor + Dokumentenliste)
|
||||
|
|
@ -19,15 +19,17 @@ MODULE_VERSIONS = {
|
|||
"media_legal_hold": "1.0.0", # P-11: Sofortsperre-Services (set_legal_hold, release_legal_hold)
|
||||
"media_lifecycle": "1.1.0", # P-11: Retention-Job ueberspringt Legal-Hold-Assets
|
||||
"admin_ai_skill_retrieval": "1.0.0", # Superadmin CRUD /api/admin/ai-skill-retrieval-profiles (Migration 068)
|
||||
"exercise_enrichment_admin": "1.1.1", # Preview max 3/Request + parallel LLM (Gateway-504 vermeiden)
|
||||
"admin_ai_prompts": "1.0.3", # Migration 070: openrouter_model; PUT/Liste/Detail
|
||||
"ai_prompt_job": "0.2.1", # want_instructions; run_exercise_form_ai_suggestion
|
||||
"ai_prompt_context": "0.2.0", # preparation/trainer_notes; has_instruction_source_text
|
||||
"ai_prompt_runtime": "0.2.0", # load_and_render_ai_prompt, AiPromptUnavailableError, render_ai_prompt_template_for_row
|
||||
"ai_prompt_runtime": "0.2.2", # Slug planning_exercise_expectation_profile
|
||||
"groups": "0.1.0",
|
||||
"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.33.0", # KI Schnellanlage: Suche+Anlage kombiniert; Rich-Text-Editor; Übungsliste KI-Schalter
|
||||
"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
|
||||
"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
|
||||
|
|
@ -42,6 +44,116 @@ MODULE_VERSIONS = {
|
|||
}
|
||||
|
||||
CHANGELOG = [
|
||||
{
|
||||
"version": "0.8.180",
|
||||
"date": "2026-05-23",
|
||||
"changes": [
|
||||
"Fix: Übungs-Anreicherung Vorschau — max. 3 Übungen/Request, parallel LLM; Frontend-Pakete + Retry bei 504.",
|
||||
],
|
||||
},
|
||||
{
|
||||
"version": "0.8.179",
|
||||
"date": "2026-05-23",
|
||||
"changes": [
|
||||
"Übungs-Anreicherung: Dialog mit Merge-Modus, Skills/Summary/Anleitung, Kosten-Analyse + Bestätigung.",
|
||||
"Alle aus Filter auswählen (300+), chunked Preview/Apply; Default Sichtbarkeit privat, replace_all empfohlen.",
|
||||
],
|
||||
},
|
||||
{
|
||||
"version": "0.8.178",
|
||||
"date": "2026-05-23",
|
||||
"changes": [
|
||||
"Superadmin-Werkzeug Übungs-Anreicherung (KI): Kandidaten filtern, Vorschau, Batch-Apply Skills + Status in_review.",
|
||||
"API /api/admin/exercise-enrichment/* — wiederverwendet exercise_ai / run_exercise_form_ai_suggestion.",
|
||||
],
|
||||
},
|
||||
{
|
||||
"version": "0.8.177",
|
||||
"date": "2026-05-22",
|
||||
"changes": [
|
||||
"Planungs-Übungssuche Phase A: gesamte sichtbare Bibliothek deterministisch gegen Erwartungsprofil gerankt.",
|
||||
"Kein OR-Profil-Pool mehr; Volltext nur noch als Score-Signal; retrieval_phase full_library.",
|
||||
],
|
||||
},
|
||||
{
|
||||
"version": "0.8.176",
|
||||
"date": "2026-05-22",
|
||||
"changes": [
|
||||
"Fix: Planungs-Übungssuche mit Suchtext — SQL-Parameter für ts_rank/plainto_tsquery korrekt gebunden (500).",
|
||||
"Preset-Erkennung: „nächste Übung planen“.",
|
||||
],
|
||||
},
|
||||
{
|
||||
"version": "0.8.175",
|
||||
"date": "2026-05-22",
|
||||
"changes": [
|
||||
"Planungs-KI: „Nächste aus Kontext“ — LLM leitet Erwartungsprofil aus Planungskontext ab (Prompt 074).",
|
||||
"API: llm_expectation_applied, profile_llm_applied; Retrieval-Phase llm_expectation; max. 1 LLM-Call.",
|
||||
],
|
||||
},
|
||||
{
|
||||
"version": "0.8.174",
|
||||
"date": "2026-05-22",
|
||||
"changes": [
|
||||
"Planungs-KI: Abschnitts-Kontext (guidance_notes, Übungszahl, letzte Übung), Fähigkeitenprofil Einheit/Abschnitt an LLM.",
|
||||
"Erwartungsprofil hybrid (Planungsbezug) vs. query_only (nur Suchtext); current_section_plan im Target-Profil.",
|
||||
],
|
||||
},
|
||||
{
|
||||
"version": "0.8.173",
|
||||
"date": "2026-05-22",
|
||||
"changes": [
|
||||
"Planungs-KI: mehrstufiges Profil-Retrieval (Pool → Profil-Vorselektion → Hybrid); LLM max. 1 Call pro Suche.",
|
||||
"LLM-Gates: kein Intent bei Kurz-Stichwort/preset; kein Rerank wenn Intent-LLM lief oder Ranking klar.",
|
||||
],
|
||||
},
|
||||
{
|
||||
"version": "0.8.172",
|
||||
"date": "2026-05-22",
|
||||
"changes": [
|
||||
"Planungs-KI UI: Menüpunkt „Planungs-KI: Übung vorschlagen“ im +-Dialog; Freitext-Suche ohne gespeicherte unit_id (client_free).",
|
||||
"API exercise-suggest: unit_id optional, group_id für Client-Kontext.",
|
||||
],
|
||||
},
|
||||
{
|
||||
"version": "0.8.171",
|
||||
"date": "2026-05-22",
|
||||
"changes": [
|
||||
"Planungs-KI P1: Szenario-Pipeline (preset/progression/additive/…) + LLM Intent planning_exercise_search_intent → Erwartungsprofil-Overlay.",
|
||||
"API: scenario_kind, query_intent_summary, include_llm_intent; Migration 073.",
|
||||
],
|
||||
},
|
||||
{
|
||||
"version": "0.8.170",
|
||||
"date": "2026-05-22",
|
||||
"changes": [
|
||||
"Planungs-KI P2: optionaler LLM-Rerank (planning_exercise_search_rank) mit Titel/summary/goal; include_llm_rank.",
|
||||
"Client planned_exercise_ids für ungespeicherten Plan; Migration 072 Prompt.",
|
||||
],
|
||||
},
|
||||
{
|
||||
"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",
|
||||
"changes": [
|
||||
"Planungs-KI P0: POST /api/planning/exercise-suggest — Kontext-Pack (Einheit, Plan, Anker, Graph) + Hybrid-Score.",
|
||||
"ExercisePickerModal: Planungskontext aus TrainingUnitEditPage; Treffer mit Begründungen; Doku PLANNING_EXERCISE_SUGGEST_CONTEXT.md.",
|
||||
],
|
||||
},
|
||||
{
|
||||
"version": "0.8.166",
|
||||
"date": "2026-05-22",
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
# Shinkan Jinkendo – Entwicklungsstand & Handover
|
||||
|
||||
**Stand:** 2026-05-31
|
||||
**App-Version / DB-Schema:** App **`0.8.166`** (KI Schnellanlage Suche+Anlage); DB **`20260531071`** — maßgeblich **`backend/version.py`**.
|
||||
**App-Version / DB-Schema:** App **`0.8.167`** (Planungs-KI Übungssuche P0); DB **`20260531071`** — 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,9 @@ 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.166**)
|
||||
### 2.8 KI Assistenz Übungen & Skill-Katalog-Retrieval (Stand **0.8.171**)
|
||||
|
||||
- **Zielarchitektur (Pflicht fuer Erweiterungen):** `.claude/docs/technical/AI_PROMPT_TARGET_ARCHITECTURE.md` — Kontext-Arten, Composition, Einbindung Planung/Rahmen; Phasenplan P0–P4.
|
||||
- **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.
|
||||
- **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`**
|
||||
|
|
|
|||
|
|
@ -56,6 +56,7 @@ const LegalPage = lazy(() => import('./pages/LegalPage'))
|
|||
const AdminLegalDocumentsPage = lazy(() => import('./pages/AdminLegalDocumentsPage'))
|
||||
const AdminAiSkillRetrievalPage = lazy(() => import('./pages/AdminAiSkillRetrievalPage'))
|
||||
const AdminAiPromptsPage = lazy(() => import('./pages/AdminAiPromptsPage'))
|
||||
const AdminExerciseEnrichmentPage = lazy(() => import('./pages/AdminExerciseEnrichmentPage'))
|
||||
const SettingsLegalPage = lazy(() => import('./pages/SettingsLegalPage'))
|
||||
|
||||
/** Shield „Admin“: nur Super-Admin (global). Vereinsorga: Vereine → Mitglieder. */
|
||||
|
|
@ -318,6 +319,14 @@ const appRouter = createBrowserRouter([
|
|||
</PlatformAdminRoute>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'admin/exercise-enrichment',
|
||||
element: (
|
||||
<PlatformAdminRoute>
|
||||
<AdminExerciseEnrichmentPage />
|
||||
</PlatformAdminRoute>
|
||||
),
|
||||
},
|
||||
{ path: 'trainer-contexts', element: <TrainerContextsPage /> },
|
||||
],
|
||||
},
|
||||
|
|
|
|||
|
|
@ -76,6 +76,14 @@ export async function quickCreateTrainingUnit(data) {
|
|||
})
|
||||
}
|
||||
|
||||
/** Planungs-KI: kontextgebundene Übungssuche (Hybrid + Profil + optional LLM-Rerank). */
|
||||
export async function suggestPlanningExercises(body = {}) {
|
||||
return request('/api/planning/exercise-suggest', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
}
|
||||
|
||||
/** Rahmen-Slot → geplante Einheit (tiefe Kopie, origin_framework_slot_id). */
|
||||
export async function createTrainingUnitFromFrameworkSlot(data) {
|
||||
return request('/api/training-units/from-framework-slot', {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { NavLink } from 'react-router-dom'
|
||||
import { TreePine, FolderTree, Download, Grid3x3, Users, Scale, Brain, Sparkles } from 'lucide-react'
|
||||
import { TreePine, FolderTree, Download, Grid3x3, Users, Scale, Brain, Sparkles, Wand2 } from 'lucide-react'
|
||||
|
||||
/**
|
||||
* Admin-Seiten-Navigation (horizontal) — nur für Super-Admins (globaler Portal-Mandant).
|
||||
|
|
@ -13,6 +13,7 @@ export default function AdminPageNav() {
|
|||
{ to: '/admin/mediawiki-import', label: 'Wiki-Import', icon: Download },
|
||||
{ to: '/admin/legal-documents', label: 'Rechtstexte', icon: Scale },
|
||||
{ to: '/admin/ai-prompts', label: 'KI Prompts', icon: Sparkles },
|
||||
{ to: '/admin/exercise-enrichment', label: 'Übungs-Anreicherung', icon: Wand2 },
|
||||
]
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -3,6 +3,39 @@ import React from 'react'
|
|||
/**
|
||||
* Inline-Angebot: aus Suchstring neue Übung per KI anlegen (Fokusbereich + optional Titel/Skizze).
|
||||
*/
|
||||
/** Kompakter Einstieg unter Trefferliste — expandiert zum vollen Formular. */
|
||||
export function ExerciseAiQuickCreateTeaser({ onExpand, disabled = false }) {
|
||||
return (
|
||||
<div
|
||||
className="card"
|
||||
style={{
|
||||
padding: '12px 16px',
|
||||
marginTop: 16,
|
||||
borderColor: 'var(--border)',
|
||||
background: 'var(--surface2)',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: '10px 12px',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: '0.92rem', lineHeight: 1.45 }}>
|
||||
<strong>Nichts Richtiges dabei?</strong>{' '}
|
||||
<span style={{ color: 'var(--text2)' }}>Neue Übung mit KI anlegen und direkt übernehmen.</span>
|
||||
</span>
|
||||
<button type="button" className="btn btn-secondary" disabled={disabled} onClick={() => onExpand?.()}>
|
||||
Neue Übung anlegen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function ExerciseAiQuickCreateOffer({
|
||||
searchLabel,
|
||||
title,
|
||||
|
|
@ -19,6 +52,7 @@ export default function ExerciseAiQuickCreateOffer({
|
|||
showSketchField = true,
|
||||
sketchOptional = true,
|
||||
hint,
|
||||
headline,
|
||||
}) {
|
||||
const canRun =
|
||||
!busy &&
|
||||
|
|
@ -37,7 +71,7 @@ export default function ExerciseAiQuickCreateOffer({
|
|||
}}
|
||||
>
|
||||
<strong style={{ display: 'block', marginBottom: '6px', fontSize: '0.95rem' }}>
|
||||
Keine passende Übung gefunden
|
||||
{headline || 'Keine passende Übung gefunden'}
|
||||
</strong>
|
||||
<p style={{ margin: '0 0 12px', fontSize: '13px', color: 'var(--text2)', lineHeight: 1.45 }}>
|
||||
{hint ||
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ import SkillTreeMultiSelect from './SkillTreeMultiSelect'
|
|||
import ExerciseFocusRulePicker from './ExerciseFocusRulePicker'
|
||||
import CatalogRulePicker from './CatalogRulePicker'
|
||||
import ExerciseAiSuggestPreviewModal from './ExerciseAiSuggestPreviewModal'
|
||||
import ExerciseAiQuickCreateOffer from './ExerciseAiQuickCreateOffer'
|
||||
import ExerciseAiQuickCreateOffer, { ExerciseAiQuickCreateTeaser } from './ExerciseAiQuickCreateOffer'
|
||||
import { useExerciseAiQuickCreateFields } from '../hooks/useExerciseAiQuickCreateFields'
|
||||
import {
|
||||
buildQuickCreateAiPreview,
|
||||
|
|
@ -27,6 +27,11 @@ import {
|
|||
} from '../utils/exerciseAiQuickCreate'
|
||||
|
||||
const PAGE_SIZE = 100
|
||||
/** Backend POST /api/planning/exercise-suggest erlaubt max. 50 */
|
||||
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 }
|
||||
|
|
@ -38,6 +43,16 @@ export default function ExercisePickerModal({
|
|||
multiSelect = false,
|
||||
onSelectExercises = null,
|
||||
enableQuickCreateDraft = false,
|
||||
/** Gespeicherte training_units.id — aktiviert Planungs-KI (robuster als nur planningContext). */
|
||||
planningUnitId = null,
|
||||
/** 'planning' = Planungs-KI-API; 'library' = Volltext-Bibliothek */
|
||||
pickerMode = 'library',
|
||||
/** Planungs-KI auch ohne gespeicherte unit_id (Client-Kontext / Freitext). */
|
||||
enableFreePlanningSearch = false,
|
||||
/** true auf TrainingUnitEditPage: Hinweis wenn Planungs-KI ohne Einheit und ohne Freitext. */
|
||||
expectPlanningSearch = false,
|
||||
/** Planungs-Kontext für KI-Suche (Abschnitt, Anker, Plan …) */
|
||||
planningContext = null,
|
||||
/** Wenn gesetzt: z. B. ['simple'] oder ['combination'] — sonst alle Übungsarten */
|
||||
exerciseKindAny = undefined,
|
||||
}) {
|
||||
|
|
@ -64,8 +79,87 @@ export default function ExercisePickerModal({
|
|||
const [quickSaving, setQuickSaving] = useState(false)
|
||||
const [quickAiError, setQuickAiError] = useState('')
|
||||
const [quickCreateDraft, setQuickCreateDraft] = useState(null)
|
||||
const [quickCreateExpanded, setQuickCreateExpanded] = useState(false)
|
||||
const [planningContextSummary, setPlanningContextSummary] = useState(null)
|
||||
const [planningTargetProfileSummary, setPlanningTargetProfileSummary] = useState(null)
|
||||
const [planningLlmRankApplied, setPlanningLlmRankApplied] = useState(false)
|
||||
const [planningLlmIntentApplied, setPlanningLlmIntentApplied] = useState(false)
|
||||
const [planningRetrievalPhase, setPlanningRetrievalPhase] = useState('')
|
||||
const [planningQueryIntentSummary, setPlanningQueryIntentSummary] = useState(null)
|
||||
const [planningIntentResolved, setPlanningIntentResolved] = useState(null)
|
||||
const [planningHasSearched, setPlanningHasSearched] = useState(false)
|
||||
const [planningSubmittedQuery, setPlanningSubmittedQuery] = useState('')
|
||||
const [planningSearchTick, setPlanningSearchTick] = useState(0)
|
||||
const pickerScrollRef = useRef(null)
|
||||
|
||||
const resolvedPlanningUnitId = useMemo(() => {
|
||||
const raw = planningUnitId ?? planningContext?.unitId
|
||||
const id = Number(raw)
|
||||
return Number.isFinite(id) && id > 0 ? id : null
|
||||
}, [planningUnitId, planningContext?.unitId])
|
||||
|
||||
const activePlanningContext = useMemo(() => {
|
||||
if (pickerMode !== 'planning') return null
|
||||
const groupIdRaw = planningContext?.groupId
|
||||
const groupId = Number(groupIdRaw)
|
||||
const base = {
|
||||
groupId: Number.isFinite(groupId) && groupId > 0 ? groupId : null,
|
||||
sectionOrderIndex: planningContext?.sectionOrderIndex ?? 0,
|
||||
sectionTitle: planningContext?.sectionTitle ?? null,
|
||||
sectionGuidanceNotes: planningContext?.sectionGuidanceNotes ?? null,
|
||||
sectionPlannedExerciseIds: Array.isArray(planningContext?.sectionPlannedExerciseIds)
|
||||
? planningContext.sectionPlannedExerciseIds
|
||||
: [],
|
||||
sectionExerciseCount: planningContext?.sectionExerciseCount ?? null,
|
||||
lastExerciseTitle: planningContext?.lastExerciseTitle ?? null,
|
||||
phaseOrderIndex: planningContext?.phaseOrderIndex ?? null,
|
||||
parallelStreamOrderIndex: planningContext?.parallelStreamOrderIndex ?? null,
|
||||
anchorExerciseId: planningContext?.anchorExerciseId ?? null,
|
||||
progressionGraphId: planningContext?.progressionGraphId ?? null,
|
||||
plannedExerciseIds: Array.isArray(planningContext?.plannedExerciseIds)
|
||||
? planningContext.plannedExerciseIds
|
||||
: [],
|
||||
intentHint: planningContext?.intentHint ?? null,
|
||||
}
|
||||
if (!resolvedPlanningUnitId) {
|
||||
if (!enableFreePlanningSearch && !planningContext) return null
|
||||
return { unitId: null, ...base }
|
||||
}
|
||||
return {
|
||||
unitId: resolvedPlanningUnitId,
|
||||
...base,
|
||||
}
|
||||
}, [pickerMode, resolvedPlanningUnitId, enableFreePlanningSearch, planningContext])
|
||||
|
||||
const usePlanningSearch = pickerMode === 'planning' && activePlanningContext != null
|
||||
const useFreePlanningSearch = usePlanningSearch && !resolvedPlanningUnitId
|
||||
const planningSearchBlocked = Boolean(
|
||||
pickerMode === 'planning' &&
|
||||
expectPlanningSearch &&
|
||||
!resolvedPlanningUnitId &&
|
||||
!enableFreePlanningSearch
|
||||
)
|
||||
|
||||
/** Gemeinsamer Suchtext — Planung: nur nach Button; Bibliothek: debounced live. */
|
||||
const effectivePickerQuery = useMemo(() => {
|
||||
if (usePlanningSearch) {
|
||||
return planningSubmittedQuery
|
||||
}
|
||||
return [debouncedSearch, debouncedAi].filter(Boolean).join(' ').trim()
|
||||
}, [usePlanningSearch, planningSubmittedQuery, debouncedSearch, debouncedAi])
|
||||
|
||||
const submitPlanningSearch = useCallback((queryOverride) => {
|
||||
const q =
|
||||
queryOverride !== undefined && queryOverride !== null
|
||||
? String(queryOverride).trim()
|
||||
: (searchInput || aiSearchInput).trim()
|
||||
setPlanningSubmittedQuery(q)
|
||||
setPlanningHasSearched(true)
|
||||
setQuickCreateExpanded(false)
|
||||
setList([])
|
||||
setPlanningSearchTick((t) => t + 1)
|
||||
}, [searchInput, aiSearchInput])
|
||||
|
||||
const {
|
||||
title: quickTitle,
|
||||
sketch: quickSketch,
|
||||
|
|
@ -74,7 +168,7 @@ export default function ExercisePickerModal({
|
|||
setSketch: setQuickSketch,
|
||||
setFocusAreaId: setQuickFocusAreaId,
|
||||
resetQuickCreateFields,
|
||||
} = useExerciseAiQuickCreateFields(debouncedSearch, { enabled: open && enableQuickCreateDraft })
|
||||
} = useExerciseAiQuickCreateFields(effectivePickerQuery, { enabled: open && enableQuickCreateDraft })
|
||||
|
||||
const toggleMultiPick = (ex) => {
|
||||
setMultiPicked((prev) =>
|
||||
|
|
@ -82,6 +176,10 @@ export default function ExercisePickerModal({
|
|||
)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!usePlanningSearch) setQuickCreateExpanded(false)
|
||||
}, [effectivePickerQuery, usePlanningSearch])
|
||||
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => setDebouncedSearch(searchInput.trim()), 350)
|
||||
return () => clearTimeout(t)
|
||||
|
|
@ -92,12 +190,45 @@ export default function ExercisePickerModal({
|
|||
return () => clearTimeout(t)
|
||||
}, [aiSearchInput])
|
||||
|
||||
const showQuickCreateOffer =
|
||||
const canOfferQuickCreate =
|
||||
enableQuickCreateDraft &&
|
||||
catalogsReady &&
|
||||
!loading &&
|
||||
debouncedSearch.length >= 3 &&
|
||||
list.length === 0
|
||||
(usePlanningSearch ? planningHasSearched : effectivePickerQuery.length >= 3)
|
||||
|
||||
const showQuickCreateFull = canOfferQuickCreate && (list.length === 0 || quickCreateExpanded)
|
||||
const showQuickCreateTeaser = canOfferQuickCreate && list.length > 0 && !quickCreateExpanded
|
||||
|
||||
const quickCreateHeadline = usePlanningSearch
|
||||
? 'Nichts Richtiges dabei?'
|
||||
: list.length > 0
|
||||
? 'Neue Übung anlegen'
|
||||
: undefined
|
||||
|
||||
const quickCreateHint = usePlanningSearch
|
||||
? effectivePickerQuery
|
||||
? `Aus Planungsanfrage „${effectivePickerQuery}“ oder eigener Idee — KI schlägt Texte vor, danach bearbeiten und übernehmen.`
|
||||
: 'Aus Planungskontext oder eigener Idee — KI schlägt Texte vor, danach bearbeiten und übernehmen.'
|
||||
: undefined
|
||||
|
||||
const renderQuickCreateOffer = () => (
|
||||
<ExerciseAiQuickCreateOffer
|
||||
searchLabel={effectivePickerQuery || undefined}
|
||||
title={quickTitle}
|
||||
onTitleChange={setQuickTitle}
|
||||
sketch={quickSketch}
|
||||
onSketchChange={setQuickSketch}
|
||||
focusAreaId={quickFocusAreaId}
|
||||
onFocusAreaChange={setQuickFocusAreaId}
|
||||
focusAreas={catalogs.focusAreas}
|
||||
catalogsReady={catalogsReady}
|
||||
busy={quickSaving}
|
||||
error={quickAiError}
|
||||
onRunAi={runQuickCreateAiSuggest}
|
||||
headline={quickCreateHeadline}
|
||||
hint={quickCreateHint}
|
||||
/>
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
|
|
@ -146,6 +277,17 @@ export default function ExercisePickerModal({
|
|||
setQuickSaving(false)
|
||||
setQuickAiError('')
|
||||
setQuickCreateDraft(null)
|
||||
setQuickCreateExpanded(false)
|
||||
setPlanningContextSummary(null)
|
||||
setPlanningTargetProfileSummary(null)
|
||||
setPlanningLlmRankApplied(false)
|
||||
setPlanningLlmIntentApplied(false)
|
||||
setPlanningRetrievalPhase('')
|
||||
setPlanningQueryIntentSummary(null)
|
||||
setPlanningIntentResolved(null)
|
||||
setPlanningHasSearched(false)
|
||||
setPlanningSubmittedQuery('')
|
||||
setPlanningSearchTick(0)
|
||||
return
|
||||
}
|
||||
setFilters(mergeExerciseListPrefsFromApi(user?.exercise_list_prefs))
|
||||
|
|
@ -230,8 +372,7 @@ export default function ExercisePickerModal({
|
|||
if (filters.skill_max_level) q.skill_max_level = n(filters.skill_max_level)
|
||||
if (filters.exclude_without_focus) q.exclude_without_focus = true
|
||||
if (filters.include_archived) q.include_archived = true
|
||||
if (debouncedSearch) q.search = debouncedSearch
|
||||
if (debouncedAi) q.ai_search = debouncedAi
|
||||
if (effectivePickerQuery) q.search = effectivePickerQuery
|
||||
if (
|
||||
Array.isArray(exerciseKindAny) &&
|
||||
exerciseKindAny.length > 0
|
||||
|
|
@ -239,12 +380,19 @@ export default function ExercisePickerModal({
|
|||
q.exercise_kind_any = exerciseKindAny
|
||||
}
|
||||
return q
|
||||
}, [filters, debouncedSearch, debouncedAi, exerciseKindAny])
|
||||
}, [filters, effectivePickerQuery, exerciseKindAny])
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
if (!open || !catalogsReady) return
|
||||
const reloadLibrary = useCallback(async () => {
|
||||
if (!open || !catalogsReady || usePlanningSearch) return
|
||||
setLoading(true)
|
||||
try {
|
||||
setPlanningContextSummary(null)
|
||||
setPlanningTargetProfileSummary(null)
|
||||
setPlanningLlmRankApplied(false)
|
||||
setPlanningLlmIntentApplied(false)
|
||||
setPlanningRetrievalPhase('')
|
||||
setPlanningQueryIntentSummary(null)
|
||||
setPlanningIntentResolved(null)
|
||||
const batch = await api.listExercises({
|
||||
...queryBase,
|
||||
include_archived: true,
|
||||
|
|
@ -262,11 +410,138 @@ export default function ExercisePickerModal({
|
|||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [open, catalogsReady, queryBase])
|
||||
}, [open, catalogsReady, usePlanningSearch, queryBase])
|
||||
|
||||
const reloadPlanning = useCallback(async () => {
|
||||
if (!open || !catalogsReady || !usePlanningSearch || planningSearchTick === 0) return
|
||||
if (planningSearchBlocked || !activePlanningContext) {
|
||||
setList([])
|
||||
setHasMore(false)
|
||||
setPlanningContextSummary(null)
|
||||
setPlanningTargetProfileSummary(null)
|
||||
setPlanningLlmRankApplied(false)
|
||||
setPlanningLlmIntentApplied(false)
|
||||
setPlanningRetrievalPhase('')
|
||||
setPlanningQueryIntentSummary(null)
|
||||
setPlanningIntentResolved(null)
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
setLoading(true)
|
||||
try {
|
||||
const query = planningSubmittedQuery
|
||||
const requestBody = {
|
||||
section_order_index:
|
||||
activePlanningContext.sectionOrderIndex != null
|
||||
? Number(activePlanningContext.sectionOrderIndex)
|
||||
: null,
|
||||
phase_order_index:
|
||||
activePlanningContext.phaseOrderIndex != null
|
||||
? Number(activePlanningContext.phaseOrderIndex)
|
||||
: null,
|
||||
parallel_stream_order_index:
|
||||
activePlanningContext.parallelStreamOrderIndex != null
|
||||
? Number(activePlanningContext.parallelStreamOrderIndex)
|
||||
: null,
|
||||
anchor_exercise_id:
|
||||
activePlanningContext.anchorExerciseId != null
|
||||
? Number(activePlanningContext.anchorExerciseId)
|
||||
: null,
|
||||
progression_graph_id:
|
||||
activePlanningContext.progressionGraphId != null
|
||||
? Number(activePlanningContext.progressionGraphId)
|
||||
: null,
|
||||
planned_exercise_ids:
|
||||
Array.isArray(activePlanningContext.plannedExerciseIds) &&
|
||||
activePlanningContext.plannedExerciseIds.length > 0
|
||||
? activePlanningContext.plannedExerciseIds
|
||||
.map((x) => Number(x))
|
||||
.filter((x) => Number.isFinite(x) && x > 0)
|
||||
: undefined,
|
||||
include_llm_intent:
|
||||
query.length >= PLANNING_LLM_INTENT_MIN_CHARS || !(query || '').trim(),
|
||||
include_llm_rank: query.length >= PLANNING_LLM_RANK_MIN_CHARS,
|
||||
query,
|
||||
intent_hint:
|
||||
activePlanningContext.intentHint || (useFreePlanningSearch && query ? 'free_search' : null),
|
||||
limit: PLANNING_SUGGEST_LIMIT,
|
||||
exercise_kind_any:
|
||||
Array.isArray(exerciseKindAny) && exerciseKindAny.length > 0 ? exerciseKindAny : undefined,
|
||||
}
|
||||
if (resolvedPlanningUnitId) {
|
||||
requestBody.unit_id = Number(resolvedPlanningUnitId)
|
||||
}
|
||||
if (activePlanningContext.groupId) {
|
||||
requestBody.group_id = Number(activePlanningContext.groupId)
|
||||
}
|
||||
if (activePlanningContext.sectionTitle) {
|
||||
requestBody.section_title = String(activePlanningContext.sectionTitle)
|
||||
}
|
||||
if (activePlanningContext.sectionGuidanceNotes) {
|
||||
requestBody.section_guidance_notes = String(activePlanningContext.sectionGuidanceNotes)
|
||||
}
|
||||
if (
|
||||
Array.isArray(activePlanningContext.sectionPlannedExerciseIds) &&
|
||||
activePlanningContext.sectionPlannedExerciseIds.length > 0
|
||||
) {
|
||||
requestBody.section_planned_exercise_ids = activePlanningContext.sectionPlannedExerciseIds
|
||||
.map((x) => Number(x))
|
||||
.filter((x) => Number.isFinite(x) && x > 0)
|
||||
}
|
||||
const res = await api.suggestPlanningExercises(requestBody)
|
||||
setPlanningContextSummary(res?.context_summary || null)
|
||||
setPlanningTargetProfileSummary(res?.target_profile_summary || null)
|
||||
setPlanningLlmRankApplied(Boolean(res?.llm_rank_applied))
|
||||
setPlanningLlmIntentApplied(Boolean(res?.profile_llm_applied ?? res?.llm_intent_applied))
|
||||
setPlanningRetrievalPhase(res?.retrieval_phase || '')
|
||||
setPlanningQueryIntentSummary(res?.query_intent_summary || null)
|
||||
setPlanningIntentResolved(res?.intent_resolved || null)
|
||||
const hits = (Array.isArray(res?.hits) ? res.hits : []).map((h) => ({
|
||||
id: h.id,
|
||||
title: h.title,
|
||||
summary: h.summary,
|
||||
focus_area: h.focus_area,
|
||||
_planningScore: h.score,
|
||||
_planningReasons: Array.isArray(h.reasons) ? h.reasons : [],
|
||||
updated_at: new Date().toISOString(),
|
||||
}))
|
||||
setList(hits)
|
||||
setHasMore(false)
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
alert(e.message || 'Laden fehlgeschlagen')
|
||||
setList([])
|
||||
setHasMore(false)
|
||||
setPlanningContextSummary(null)
|
||||
setPlanningTargetProfileSummary(null)
|
||||
setPlanningLlmRankApplied(false)
|
||||
setPlanningLlmIntentApplied(false)
|
||||
setPlanningRetrievalPhase('')
|
||||
setPlanningQueryIntentSummary(null)
|
||||
setPlanningIntentResolved(null)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [
|
||||
open,
|
||||
catalogsReady,
|
||||
usePlanningSearch,
|
||||
planningSearchTick,
|
||||
planningSearchBlocked,
|
||||
activePlanningContext,
|
||||
planningSubmittedQuery,
|
||||
exerciseKindAny,
|
||||
resolvedPlanningUnitId,
|
||||
useFreePlanningSearch,
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
reload()
|
||||
}, [reload])
|
||||
reloadLibrary()
|
||||
}, [reloadLibrary])
|
||||
|
||||
useEffect(() => {
|
||||
reloadPlanning()
|
||||
}, [reloadPlanning])
|
||||
|
||||
const loadMore = async () => {
|
||||
if (!hasMore || loadingMore || loading) return
|
||||
|
|
@ -298,7 +573,11 @@ export default function ExercisePickerModal({
|
|||
const rowVirtualizer = useVirtualizer({
|
||||
count: list.length,
|
||||
getScrollElement: () => pickerScrollRef.current,
|
||||
estimateSize: () => 88,
|
||||
estimateSize: (index) => {
|
||||
const ex = list[index]
|
||||
const rc = ex?._planningReasons?.length || 0
|
||||
return rc > 0 ? 96 + Math.min(rc, 3) * 14 : 88
|
||||
},
|
||||
overscan: 8,
|
||||
getItemKey: (index) => String(list[index]?.id ?? index),
|
||||
})
|
||||
|
|
@ -406,7 +685,13 @@ export default function ExercisePickerModal({
|
|||
>
|
||||
<div className="admin-modal-sheet__header">
|
||||
<h3 className="admin-modal-sheet__title">
|
||||
{multiSelect ? 'Übungen auswählen' : 'Übung auswählen'}
|
||||
{usePlanningSearch
|
||||
? multiSelect
|
||||
? 'Planungs-KI: Übungen vorschlagen'
|
||||
: 'Planungs-KI: Übung vorschlagen'
|
||||
: multiSelect
|
||||
? 'Übungen auswählen'
|
||||
: 'Übung auswählen'}
|
||||
</h3>
|
||||
<button type="button" className="btn btn-secondary admin-modal-sheet__close" onClick={onClose}>
|
||||
Schließen
|
||||
|
|
@ -414,32 +699,253 @@ export default function ExercisePickerModal({
|
|||
</div>
|
||||
|
||||
<div style={{ padding: '0 1rem 0.75rem', borderBottom: '1px solid var(--border)', flexShrink: 0 }}>
|
||||
{usePlanningSearch && planningContextSummary ? (
|
||||
<div
|
||||
style={{
|
||||
marginBottom: '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' }}>Planungskontext</strong>
|
||||
<div style={{ marginTop: '4px', display: 'flex', flexWrap: 'wrap', gap: '6px' }}>
|
||||
{planningContextSummary.group_name ? (
|
||||
<span className="exercise-tag">{planningContextSummary.group_name}</span>
|
||||
) : null}
|
||||
{planningContextSummary.unit_title ? (
|
||||
<span className="exercise-tag">{planningContextSummary.unit_title}</span>
|
||||
) : null}
|
||||
{planningContextSummary.section_title ? (
|
||||
<span className="exercise-tag">{planningContextSummary.section_title}</span>
|
||||
) : null}
|
||||
{planningContextSummary.section_exercise_count != null ? (
|
||||
<span className="exercise-tag">
|
||||
{planningContextSummary.section_exercise_count} Übungen im Abschnitt
|
||||
</span>
|
||||
) : null}
|
||||
{planningContextSummary.last_section_exercise_title ? (
|
||||
<span className="exercise-tag">
|
||||
Letzte: {planningContextSummary.last_section_exercise_title}
|
||||
</span>
|
||||
) : null}
|
||||
{planningContextSummary.planned_count != null ? (
|
||||
<span className="exercise-tag">{planningContextSummary.planned_count} Übungen im Plan</span>
|
||||
) : null}
|
||||
{planningContextSummary.anchor_title ? (
|
||||
<span className="exercise-tag exercise-tag--accent">
|
||||
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>
|
||||
{planningContextSummary.section_guidance_notes ? (
|
||||
<p style={{ margin: '6px 0 0', fontSize: '11px', color: 'var(--text2)' }}>
|
||||
Abschnitt: {planningContextSummary.section_guidance_notes}
|
||||
</p>
|
||||
) : null}
|
||||
{planningContextSummary.expectation_mode ? (
|
||||
<p style={{ margin: '4px 0 0', fontSize: '11px', color: 'var(--text3)' }}>
|
||||
Erwartungsprofil:{' '}
|
||||
{planningContextSummary.expectation_mode === 'query_only'
|
||||
? 'nur Suchtext'
|
||||
: 'Planung + optional Suchtext'}
|
||||
</p>
|
||||
) : null}
|
||||
{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}
|
||||
{planningQueryIntentSummary?.rationale ? (
|
||||
<p style={{ margin: '6px 0 0', fontSize: '11px', color: 'var(--text2)' }}>
|
||||
{planningQueryIntentSummary.rationale}
|
||||
</p>
|
||||
) : null}
|
||||
{planningIntentResolved ? (
|
||||
<p style={{ margin: '6px 0 0', fontSize: '11px', color: 'var(--text3)' }}>
|
||||
Modus: {planningIntentResolved.replace(/_/g, ' ')}
|
||||
{planningQueryIntentSummary?.scenario
|
||||
? ` · ${String(planningQueryIntentSummary.scenario).replace(/_/g, ' ')}`
|
||||
: null}
|
||||
{planningLlmRankApplied ? ' · KI-Ranking aktiv' : null}
|
||||
{planningLlmIntentApplied
|
||||
? planningQueryIntentSummary?.llm_expectation_applied
|
||||
? ' · KI-Erwartungsprofil aktiv'
|
||||
: ' · KI-Intent aktiv'
|
||||
: null}
|
||||
{!planningLlmRankApplied && !planningLlmIntentApplied && usePlanningSearch
|
||||
? ' · ohne LLM (Profil/Hybrid)'
|
||||
: null}
|
||||
{planningRetrievalPhase ? ` · ${planningRetrievalPhase}` : null}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
{planningSearchBlocked ? (
|
||||
<p
|
||||
style={{
|
||||
margin: '0 0 10px',
|
||||
padding: '10px 12px',
|
||||
borderRadius: '8px',
|
||||
background: 'color-mix(in srgb, var(--danger) 12%, var(--surface2))',
|
||||
border: '1px solid color-mix(in srgb, var(--danger) 35%, var(--border))',
|
||||
fontSize: '13px',
|
||||
color: 'var(--text1)',
|
||||
lineHeight: 1.45,
|
||||
}}
|
||||
>
|
||||
<strong>Planungs-KI noch nicht verfügbar.</strong> Bitte zuerst <strong>Speichern</strong> oder den
|
||||
Menüpunkt <strong>Planungs-KI: Übung vorschlagen</strong> nutzen (Freitext ohne gespeicherte Einheit).
|
||||
</p>
|
||||
) : null}
|
||||
{pickerMode === 'library' && expectPlanningSearch ? (
|
||||
<p
|
||||
style={{
|
||||
margin: '0 0 10px',
|
||||
padding: '8px 10px',
|
||||
borderRadius: '8px',
|
||||
background: 'var(--surface2)',
|
||||
border: '1px solid var(--border)',
|
||||
fontSize: '12px',
|
||||
color: 'var(--text2)',
|
||||
}}
|
||||
>
|
||||
<strong style={{ color: 'var(--text1)' }}>Bibliothekssuche (Volltext)</strong> — für Planungs-KI mit
|
||||
Kontext oder Freitext-Anfrage den Menüpunkt{' '}
|
||||
<strong>Planungs-KI: Übung vorschlagen …</strong> unter dem <strong>+</strong> wählen.
|
||||
</p>
|
||||
) : null}
|
||||
{useFreePlanningSearch ? (
|
||||
<p
|
||||
style={{
|
||||
margin: '0 0 10px',
|
||||
padding: '8px 10px',
|
||||
borderRadius: '8px',
|
||||
background: 'var(--surface2)',
|
||||
border: '1px solid var(--border)',
|
||||
fontSize: '12px',
|
||||
color: 'var(--text2)',
|
||||
}}
|
||||
>
|
||||
<strong style={{ color: 'var(--text1)' }}>Freie Planungs-KI</strong> — Anker und bisherige Übungen aus
|
||||
dem Formular; nach Speichern kommen Gruppe, Historie und Rahmen dazu.
|
||||
</p>
|
||||
) : null}
|
||||
{!usePlanningSearch && !planningSearchBlocked && !expectPlanningSearch ? (
|
||||
<p
|
||||
style={{
|
||||
margin: '0 0 10px',
|
||||
padding: '8px 10px',
|
||||
borderRadius: '8px',
|
||||
background: 'var(--surface2)',
|
||||
border: '1px solid var(--border)',
|
||||
fontSize: '12px',
|
||||
color: 'var(--text2)',
|
||||
}}
|
||||
>
|
||||
<strong style={{ color: 'var(--text1)' }}>Bibliothekssuche (Volltext)</strong>
|
||||
</p>
|
||||
) : null}
|
||||
<div style={{ display: 'grid', gap: '0.65rem' }}>
|
||||
<div>
|
||||
<label className="form-label">Volltextsuche</label>
|
||||
<input
|
||||
type="search"
|
||||
className="form-input"
|
||||
placeholder="Stichwort, Titelfragment…"
|
||||
value={searchInput}
|
||||
onChange={(e) => setSearchInput(e.target.value)}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="form-label">
|
||||
Semantisch /{' '}
|
||||
<span title="aktuell gleiche Datenbanksuche wie Volltext; später KI-Verfeinerung möglich">KI-Feld</span>
|
||||
</label>
|
||||
<input
|
||||
type="search"
|
||||
className="form-input"
|
||||
placeholder="zweites Suchkonzept oder Umschreibung…"
|
||||
value={aiSearchInput}
|
||||
onChange={(e) => setAiSearchInput(e.target.value)}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
{usePlanningSearch ? (
|
||||
<div>
|
||||
<label className="form-label">Planungs-Anfrage (KI)</label>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem', alignItems: 'stretch' }}>
|
||||
<input
|
||||
type="text"
|
||||
className="form-input"
|
||||
style={{ flex: '1 1 220px', minWidth: 0 }}
|
||||
placeholder="z. B. Vertiefung Reaktion mit Partner, baut auf dem Plan auf …"
|
||||
value={searchInput || aiSearchInput}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value
|
||||
setSearchInput(v)
|
||||
setAiSearchInput(v)
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
submitPlanningSearch()
|
||||
}
|
||||
}}
|
||||
autoComplete="off"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
disabled={loading || planningSearchBlocked}
|
||||
onClick={() => submitPlanningSearch()}
|
||||
>
|
||||
{loading ? 'Suche …' : 'Vorschläge laden'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
disabled={loading || planningSearchBlocked}
|
||||
title="Leere Anfrage — nur Planungskontext (Anker, Plan, Profil), ohne LLM"
|
||||
onClick={() => {
|
||||
setSearchInput('')
|
||||
setAiSearchInput('')
|
||||
submitPlanningSearch('')
|
||||
}}
|
||||
>
|
||||
Nächste aus Kontext
|
||||
</button>
|
||||
</div>
|
||||
<p style={{ margin: '4px 0 0', fontSize: '11px', color: 'var(--text3)' }}>
|
||||
Suche startet erst per Button (oder Enter) — nicht beim Tippen. LLM nur bei längeren Anfragen,
|
||||
maximal ein KI-Call pro Suche.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div>
|
||||
<label className="form-label">Volltextsuche</label>
|
||||
<input
|
||||
type="search"
|
||||
className="form-input"
|
||||
placeholder="Stichwort, Titelfragment…"
|
||||
value={searchInput}
|
||||
onChange={(e) => setSearchInput(e.target.value)}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="form-label">
|
||||
Ergänzung /{' '}
|
||||
<span title="Wird mit Volltextsuche kombiniert">zweites Suchfeld</span>
|
||||
</label>
|
||||
<input
|
||||
type="search"
|
||||
className="form-input"
|
||||
placeholder="zweites Suchkonzept oder Umschreibung…"
|
||||
value={aiSearchInput}
|
||||
onChange={(e) => setAiSearchInput(e.target.value)}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem', alignItems: 'center' }}>
|
||||
<button type="button" className="btn btn-secondary" onClick={() => setFilterOpen(!filterOpen)}>
|
||||
{filterOpen ? 'Filter ausblenden' : 'Erweiterte Filter'}
|
||||
|
|
@ -593,32 +1099,26 @@ export default function ExercisePickerModal({
|
|||
<div className="spinner" />
|
||||
</div>
|
||||
) : list.length === 0 ? (
|
||||
showQuickCreateOffer ? (
|
||||
<ExerciseAiQuickCreateOffer
|
||||
searchLabel={debouncedSearch}
|
||||
title={quickTitle}
|
||||
onTitleChange={setQuickTitle}
|
||||
sketch={quickSketch}
|
||||
onSketchChange={setQuickSketch}
|
||||
focusAreaId={quickFocusAreaId}
|
||||
onFocusAreaChange={setQuickFocusAreaId}
|
||||
focusAreas={catalogs.focusAreas}
|
||||
catalogsReady={catalogsReady}
|
||||
busy={quickSaving}
|
||||
error={quickAiError}
|
||||
onRunAi={runQuickCreateAiSuggest}
|
||||
/>
|
||||
showQuickCreateFull ? (
|
||||
renderQuickCreateOffer()
|
||||
) : (
|
||||
<p style={{ color: 'var(--text2)', textAlign: 'center' }}>
|
||||
{debouncedSearch.length >= 3
|
||||
? 'Keine Treffer.'
|
||||
: 'Suchbegriff eingeben (mind. 3 Zeichen) …'}
|
||||
<p style={{ color: 'var(--text2)', textAlign: 'center', lineHeight: 1.5 }}>
|
||||
{usePlanningSearch
|
||||
? !planningHasSearched
|
||||
? 'Anfrage formulieren und „Vorschläge laden“ klicken — oder „Nächste aus Kontext“ ohne Freitext.'
|
||||
: effectivePickerQuery
|
||||
? 'Keine KI-Vorschläge für diese Anfrage.'
|
||||
: 'Keine Vorschläge aus dem Planungskontext — Anker, Plan oder Profil prüfen.'
|
||||
: effectivePickerQuery.length >= 3
|
||||
? 'Keine Treffer.'
|
||||
: 'Suchbegriff eingeben (mind. 3 Zeichen) …'}
|
||||
</p>
|
||||
)
|
||||
) : (
|
||||
<>
|
||||
<p style={{ fontSize: '13px', color: 'var(--text2)', marginBottom: 10 }}>
|
||||
{list.length} angezeigt{hasMore ? ' · weiter unten „Mehr laden“' : ''}
|
||||
{usePlanningSearch ? `${list.length} KI-Vorschläge` : `${list.length} angezeigt`}
|
||||
{hasMore ? ' · weiter unten „Mehr laden“' : ''}
|
||||
</p>
|
||||
<div
|
||||
role="list"
|
||||
|
|
@ -661,6 +1161,20 @@ export default function ExercisePickerModal({
|
|||
Kombination
|
||||
</span>
|
||||
) : null}
|
||||
{Array.isArray(ex._planningReasons) && ex._planningReasons.length > 0 ? (
|
||||
<ul
|
||||
style={{
|
||||
margin: '6px 0 0',
|
||||
paddingLeft: '16px',
|
||||
fontSize: '11px',
|
||||
color: 'var(--accent-dark)',
|
||||
}}
|
||||
>
|
||||
{ex._planningReasons.slice(0, 3).map((r) => (
|
||||
<li key={r}>{r}</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
</>
|
||||
)
|
||||
return (
|
||||
|
|
@ -735,6 +1249,15 @@ export default function ExercisePickerModal({
|
|||
</button>
|
||||
</div>
|
||||
)}
|
||||
{showQuickCreateTeaser ? (
|
||||
<ExerciseAiQuickCreateTeaser
|
||||
disabled={quickSaving}
|
||||
onExpand={() => setQuickCreateExpanded(true)}
|
||||
/>
|
||||
) : null}
|
||||
{showQuickCreateFull && quickCreateExpanded ? (
|
||||
<div style={{ marginTop: 4 }}>{renderQuickCreateOffer()}</div>
|
||||
) : null}
|
||||
{multiSelect && typeof onSelectExercises === 'function' ? (
|
||||
<div
|
||||
className="exercise-picker-multi-footer"
|
||||
|
|
|
|||
|
|
@ -253,6 +253,7 @@ export default function TrainingUnitSectionsEditor({
|
|||
sections,
|
||||
onSectionsChange,
|
||||
onRequestExercisePick,
|
||||
onRequestPlanningExercisePick,
|
||||
onRequestTrainingModulePick,
|
||||
onPeekExercise,
|
||||
showExecutionExtras = false,
|
||||
|
|
@ -2591,12 +2592,23 @@ export default function TrainingUnitSectionsEditor({
|
|||
</p>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '6px' }}>
|
||||
{onRequestPlanningExercisePick ? (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary framework-ctrl framework-ctrl--xs"
|
||||
onClick={() =>
|
||||
onRequestPlanningExercisePick?.({ sectionIndex: sIdx })
|
||||
}
|
||||
>
|
||||
+ Planungs-KI
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary framework-ctrl framework-ctrl--xs"
|
||||
onClick={() => onRequestExercisePick?.({ sectionIndex: sIdx })}
|
||||
>
|
||||
+ Übung
|
||||
+ Übung (Bibliothek)
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -2807,9 +2819,25 @@ export default function TrainingUnitSectionsEditor({
|
|||
ändern.
|
||||
</p>
|
||||
<div className="tu-insert-chooser-actions">
|
||||
{onRequestPlanningExercisePick ? (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary tu-insert-chooser-actions__full"
|
||||
onClick={() => {
|
||||
const { sIdx, beforeIx } = insertChooser
|
||||
closeInsertChooser()
|
||||
onRequestPlanningExercisePick?.({
|
||||
sectionIndex: sIdx,
|
||||
insertBeforeIndex: beforeIx,
|
||||
})
|
||||
}}
|
||||
>
|
||||
Planungs-KI: Übung vorschlagen …
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary tu-insert-chooser-actions__full"
|
||||
className={`btn ${onRequestPlanningExercisePick ? 'btn-secondary' : 'btn-primary'} tu-insert-chooser-actions__full`}
|
||||
onClick={() => {
|
||||
const { sIdx, beforeIx } = insertChooser
|
||||
closeInsertChooser()
|
||||
|
|
@ -2819,7 +2847,7 @@ export default function TrainingUnitSectionsEditor({
|
|||
})
|
||||
}}
|
||||
>
|
||||
Übung auswählen …
|
||||
{onRequestPlanningExercisePick ? 'Übung aus Bibliothek …' : 'Übung auswählen …'}
|
||||
</button>
|
||||
{onRequestTrainingModulePick ? (
|
||||
<button
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ export default function TrainingUnitFormShell({
|
|||
onRequestSaveAsModule,
|
||||
onRequestTrainingModulePick,
|
||||
onRequestExercisePick,
|
||||
onRequestPlanningExercisePick,
|
||||
onPeekExercise,
|
||||
formId = 'planning-unit-form',
|
||||
}) {
|
||||
|
|
@ -427,6 +428,7 @@ export default function TrainingUnitFormShell({
|
|||
}
|
||||
onRequestTrainingModulePick={onRequestTrainingModulePick}
|
||||
onRequestExercisePick={onRequestExercisePick}
|
||||
onRequestPlanningExercisePick={onRequestPlanningExercisePick}
|
||||
onPeekExercise={onPeekExercise}
|
||||
showExecutionExtras={Boolean(editingUnit) && sectionsEditMode === 'debrief'}
|
||||
enableParallelPhaseControls
|
||||
|
|
|
|||
910
frontend/src/pages/AdminExerciseEnrichmentPage.jsx
Normal file
910
frontend/src/pages/AdminExerciseEnrichmentPage.jsx
Normal file
|
|
@ -0,0 +1,910 @@
|
|||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Navigate } from 'react-router-dom'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import api from '../utils/api'
|
||||
import AdminPageNav from '../components/AdminPageNav'
|
||||
|
||||
const STATUS_OPTIONS = [
|
||||
{ value: 'draft', label: 'Entwurf' },
|
||||
{ value: 'in_review', label: 'In Prüfung' },
|
||||
{ value: 'approved', label: 'Freigegeben' },
|
||||
{ value: 'archived', label: 'Archiviert' },
|
||||
]
|
||||
|
||||
const VISIBILITY_OPTIONS = [
|
||||
{ value: 'private', label: 'Privat (Standard)' },
|
||||
{ value: 'all', label: 'Alle Sichtbarkeiten' },
|
||||
{ value: 'official', label: 'Offiziell' },
|
||||
{ value: 'club', label: 'Verein' },
|
||||
]
|
||||
|
||||
const MERGE_OPTIONS = [
|
||||
{
|
||||
value: 'replace_all',
|
||||
label: 'Alle Skills ersetzen (empfohlen)',
|
||||
hint: 'Bestehende Zuordnungen werden entfernt. Alle neuen Skills erhalten ai_suggested=true — klar als KI erkennbar.',
|
||||
},
|
||||
{
|
||||
value: 'replace_ai_only',
|
||||
label: 'Nur bisherige KI-Skills ersetzen',
|
||||
hint: 'Manuelle Skills bleiben; nur ai_suggested=true wird neu gesetzt.',
|
||||
},
|
||||
{
|
||||
value: 'additive',
|
||||
label: 'Ergänzen (manuell behalten)',
|
||||
hint: 'Manuelle Skills bleiben unverändert; KI ergänzt neue. Intensität/Level nur bei KI-Skills.',
|
||||
},
|
||||
]
|
||||
|
||||
const INSTRUCTION_LABELS = {
|
||||
goal: 'Ziel',
|
||||
execution: 'Durchführung',
|
||||
preparation: 'Vorbereitung',
|
||||
trainer_notes: 'Trainer-Hinweise',
|
||||
}
|
||||
|
||||
/** Preview: kleine Pakete — Gateway (Fritz!Box o.ä.) timeout oft ~60s bei LLM-Ketten. */
|
||||
const PREVIEW_CHUNK_SIZE = 3
|
||||
const APPLY_CHUNK_SIZE = 25
|
||||
|
||||
function skillLabel(sk) {
|
||||
if (!sk) return '—'
|
||||
const name = sk.skill_name || `Skill #${sk.skill_id}`
|
||||
const inten = sk.intensity ? ` · Int. ${sk.intensity}` : ''
|
||||
const from = sk.required_level || '?'
|
||||
const to = sk.target_level || from
|
||||
const lvl = ` · ${from}→${to}`
|
||||
const ai = sk.ai_suggested ? ' · KI' : ' · manuell'
|
||||
return `${name}${inten}${lvl}${ai}`
|
||||
}
|
||||
|
||||
function buildFilterParams(filters) {
|
||||
const params = { status: filters.status, visibility: filters.visibility || 'private' }
|
||||
if (filters.focusAreaId) params.focus_area_id = Number(filters.focusAreaId)
|
||||
if (filters.withoutSkills) params.without_skills = true
|
||||
if (filters.withAiSuggested) params.with_ai_suggested_skills = true
|
||||
if (filters.search.trim()) params.search = filters.search.trim()
|
||||
return params
|
||||
}
|
||||
|
||||
function DiffBlock({ diff }) {
|
||||
if (!diff) return null
|
||||
const hasAny =
|
||||
(diff.added?.length || 0) + (diff.changed?.length || 0) + (diff.removed?.length || 0) > 0
|
||||
if (!hasAny) {
|
||||
return (
|
||||
<p className="text-muted" style={{ margin: '8px 0 0', fontSize: '0.9rem' }}>
|
||||
Keine Skill-Änderungen.
|
||||
</p>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div style={{ fontSize: '0.88rem', marginTop: 8 }}>
|
||||
{diff.added?.length > 0 && (
|
||||
<div>
|
||||
<strong style={{ color: 'var(--accent)' }}>Neu:</strong>{' '}
|
||||
{diff.added.map((s) => skillLabel(s)).join('; ')}
|
||||
</div>
|
||||
)}
|
||||
{diff.changed?.length > 0 && (
|
||||
<div style={{ marginTop: 4 }}>
|
||||
<strong>Geändert:</strong>{' '}
|
||||
{diff.changed.map((c) => (
|
||||
<span key={c.skill_id}>
|
||||
{c.skill_name}: {skillLabel(c.before)} → {skillLabel(c.after)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{diff.removed?.length > 0 && (
|
||||
<div style={{ marginTop: 4 }}>
|
||||
<strong style={{ color: 'var(--danger)' }}>Entfernt:</strong>{' '}
|
||||
{diff.removed.map((s) => skillLabel(s)).join('; ')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function RunDialog({
|
||||
open,
|
||||
onClose,
|
||||
exerciseCount,
|
||||
analysis,
|
||||
modes,
|
||||
setModes,
|
||||
mergeMode,
|
||||
setMergeMode,
|
||||
setStatusAfterApply,
|
||||
statusAfterApply,
|
||||
costConfirmed,
|
||||
setCostConfirmed,
|
||||
onStartPreview,
|
||||
jobRunning,
|
||||
}) {
|
||||
if (!open) return null
|
||||
|
||||
const est = analysis?.estimated_llm_calls
|
||||
const totalCalls = est?.total ?? 0
|
||||
const mergeHint = MERGE_OPTIONS.find((o) => o.value === mergeMode)?.hint || ''
|
||||
|
||||
return (
|
||||
<div
|
||||
className="modal-overlay"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="enrichment-run-title"
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget && !jobRunning) onClose()
|
||||
}}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
background: 'rgba(0,0,0,0.45)',
|
||||
zIndex: 1000,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: 16,
|
||||
}}
|
||||
>
|
||||
<div className="card" style={{ maxWidth: 520, width: '100%', padding: 20, maxHeight: '90vh', overflow: 'auto' }}>
|
||||
<h2 id="enrichment-run-title" style={{ margin: '0 0 12px', fontSize: '1.15rem' }}>
|
||||
Batch-Anreicherung konfigurieren
|
||||
</h2>
|
||||
|
||||
<div
|
||||
style={{
|
||||
background: 'var(--surface2)',
|
||||
borderRadius: 8,
|
||||
padding: 12,
|
||||
marginBottom: 16,
|
||||
fontSize: '0.92rem',
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<strong>{exerciseCount}</strong> Übung(en) ausgewählt
|
||||
</div>
|
||||
{est && (
|
||||
<div style={{ marginTop: 8, color: 'var(--text2)' }}>
|
||||
Geschätzte LLM-Aufrufe: <strong>{totalCalls}</strong>
|
||||
{modes.skills && est.skills > 0 && (
|
||||
<span> · Skills: {est.skills}</span>
|
||||
)}
|
||||
{modes.summary && est.summary > 0 && (
|
||||
<span> · Kurzfassung: {est.summary}</span>
|
||||
)}
|
||||
{modes.instructions && est.instructions > 0 && (
|
||||
<span> · Anleitung: {est.instructions}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{exerciseCount >= 10 && (
|
||||
<p style={{ margin: '10px 0 0', color: 'var(--text2)', fontSize: '0.88rem' }}>
|
||||
Die Vorschau läuft in Paketen à 3 Übungen (Gateway-Timeout vermeiden). Fenster offen lassen —
|
||||
bei {exerciseCount} Übungen ca. {Math.ceil(exerciseCount / 3)} Pakete.
|
||||
</p>
|
||||
)}
|
||||
{exerciseCount >= 25 && (
|
||||
<p style={{ margin: '6px 0 0', color: 'var(--danger)', fontSize: '0.88rem' }}>
|
||||
Großer Batch — OpenRouter-Kosten und Gesamtlaufzeit (mehrere Minuten) beachten.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<fieldset style={{ border: 'none', padding: 0, margin: '0 0 16px' }}>
|
||||
<legend style={{ fontWeight: 600, marginBottom: 8 }}>Inhalte per KI</legend>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
<label style={{ display: 'flex', gap: 8, cursor: 'pointer' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={modes.skills}
|
||||
onChange={(e) => setModes((m) => ({ ...m, skills: e.target.checked }))}
|
||||
/>
|
||||
Fähigkeiten (inkl. Intensität & Levelbereich)
|
||||
</label>
|
||||
<label style={{ display: 'flex', gap: 8, cursor: 'pointer' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={modes.summary}
|
||||
onChange={(e) => setModes((m) => ({ ...m, summary: e.target.checked }))}
|
||||
/>
|
||||
Kurzfassung (Summary)
|
||||
</label>
|
||||
<label style={{ display: 'flex', gap: 8, cursor: 'pointer' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={modes.instructions}
|
||||
onChange={(e) => setModes((m) => ({ ...m, instructions: e.target.checked }))}
|
||||
/>
|
||||
Anleitung (Ziel, Durchführung, Vorbereitung, Trainer-Hinweise)
|
||||
</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
{modes.skills && (
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<label className="form-label">
|
||||
Skill-Merge-Modus
|
||||
<select className="form-input" value={mergeMode} onChange={(e) => setMergeMode(e.target.value)}>
|
||||
{MERGE_OPTIONS.map((o) => (
|
||||
<option key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
{mergeHint && (
|
||||
<p style={{ margin: '6px 0 0', fontSize: '0.85rem', color: 'var(--text2)' }}>{mergeHint}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<label className="form-label" style={{ marginBottom: 16 }}>
|
||||
Nach erfolgreichem Apply Status
|
||||
<select
|
||||
className="form-input"
|
||||
value={statusAfterApply}
|
||||
onChange={(e) => setStatusAfterApply(e.target.value)}
|
||||
>
|
||||
<option value="in_review">In Prüfung</option>
|
||||
<option value="draft">Entwurf (unverändert)</option>
|
||||
<option value="">Status nicht ändern</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: 8,
|
||||
alignItems: 'flex-start',
|
||||
marginBottom: 16,
|
||||
cursor: 'pointer',
|
||||
fontSize: '0.9rem',
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={costConfirmed}
|
||||
onChange={(e) => setCostConfirmed(e.target.checked)}
|
||||
style={{ marginTop: 3 }}
|
||||
/>
|
||||
<span>
|
||||
Ich bestätige <strong>{exerciseCount}</strong> Übung(en) und ca.{' '}
|
||||
<strong>{totalCalls}</strong> LLM-Aufruf(e) — mir sind die Kosten bewusst.
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end', flexWrap: 'wrap' }}>
|
||||
<button type="button" className="btn btn-secondary" disabled={jobRunning} onClick={onClose}>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
disabled={
|
||||
jobRunning ||
|
||||
!costConfirmed ||
|
||||
exerciseCount === 0 ||
|
||||
(!modes.skills && !modes.summary && !modes.instructions)
|
||||
}
|
||||
onClick={onStartPreview}
|
||||
>
|
||||
Vorschau starten
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Superadmin: Batch-Anreicherung von Übungen per KI.
|
||||
*/
|
||||
export default function AdminExerciseEnrichmentPage() {
|
||||
const { user } = useAuth()
|
||||
const isSuperadmin = user?.role === 'superadmin'
|
||||
|
||||
const [filters, setFilters] = useState({
|
||||
status: 'draft',
|
||||
visibility: 'private',
|
||||
focusAreaId: '',
|
||||
withoutSkills: true,
|
||||
withAiSuggested: false,
|
||||
search: '',
|
||||
})
|
||||
const [focusAreas, setFocusAreas] = useState([])
|
||||
const [items, setItems] = useState([])
|
||||
const [total, setTotal] = useState(0)
|
||||
const [offset, setOffset] = useState(0)
|
||||
const limit = 25
|
||||
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [selected, setSelected] = useState(() => new Set())
|
||||
|
||||
const [dialogOpen, setDialogOpen] = useState(false)
|
||||
const [analysis, setAnalysis] = useState(null)
|
||||
const [modes, setModes] = useState({ skills: true, summary: false, instructions: false })
|
||||
const [mergeMode, setMergeMode] = useState('replace_all')
|
||||
const [setStatusAfterApply, setSetStatusAfterApply] = useState('in_review')
|
||||
const [costConfirmed, setCostConfirmed] = useState(false)
|
||||
|
||||
const [previewRows, setPreviewRows] = useState([])
|
||||
const [previewErrors, setPreviewErrors] = useState([])
|
||||
const [previewMeta, setPreviewMeta] = useState(null)
|
||||
const [showPreview, setShowPreview] = useState(false)
|
||||
|
||||
const [jobRunning, setJobRunning] = useState(false)
|
||||
const [jobProgress, setJobProgress] = useState({ done: 0, total: 0, phase: '' })
|
||||
const abortRef = useRef(false)
|
||||
|
||||
const selectedIds = useMemo(() => Array.from(selected), [selected])
|
||||
const filterParams = useMemo(() => buildFilterParams(filters), [filters])
|
||||
|
||||
const loadCandidates = useCallback(async () => {
|
||||
const data = await api.listExerciseEnrichmentCandidates({ ...filterParams, limit, offset })
|
||||
setItems(Array.isArray(data.items) ? data.items : [])
|
||||
setTotal(Number(data.total) || 0)
|
||||
}, [filterParams, offset])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isSuperadmin) return
|
||||
let cancelled = false
|
||||
;(async () => {
|
||||
setLoading(true)
|
||||
setError('')
|
||||
try {
|
||||
const faRaw = await api.listFocusAreas()
|
||||
if (!cancelled) {
|
||||
const fa = Array.isArray(faRaw) ? faRaw : []
|
||||
setFocusAreas(fa.filter((a) => !a.status || String(a.status).toLowerCase() === 'active'))
|
||||
}
|
||||
await loadCandidates()
|
||||
} catch (e) {
|
||||
if (!cancelled) setError(e.message || String(e))
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false)
|
||||
}
|
||||
})()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [isSuperadmin, loadCandidates])
|
||||
|
||||
useEffect(() => {
|
||||
if (!dialogOpen || selectedIds.length === 0) return
|
||||
let cancelled = false
|
||||
;(async () => {
|
||||
try {
|
||||
const data = await api.analyzeExerciseEnrichment({
|
||||
exercise_ids: selectedIds,
|
||||
modes,
|
||||
})
|
||||
if (!cancelled) {
|
||||
setAnalysis(data)
|
||||
setCostConfirmed(false)
|
||||
}
|
||||
} catch (e) {
|
||||
if (!cancelled) setError(e.message || String(e))
|
||||
}
|
||||
})()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [dialogOpen, modes, selectedIds])
|
||||
|
||||
if (!isSuperadmin) return <Navigate to="/" replace />
|
||||
|
||||
const pageIds = items.map((r) => r.id)
|
||||
const allPageSelected = pageIds.length > 0 && pageIds.every((id) => selected.has(id))
|
||||
|
||||
function toggleRow(id) {
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(id)) next.delete(id)
|
||||
else next.add(id)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
function togglePageAll() {
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (allPageSelected) pageIds.forEach((id) => next.delete(id))
|
||||
else pageIds.forEach((id) => next.add(id))
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
async function selectAllMatchingFilter() {
|
||||
setError('')
|
||||
try {
|
||||
const data = await api.listExerciseEnrichmentCandidateIds(filterParams)
|
||||
const ids = Array.isArray(data.ids) ? data.ids : []
|
||||
setSelected(new Set(ids))
|
||||
} catch (e) {
|
||||
setError(e.message || String(e))
|
||||
}
|
||||
}
|
||||
|
||||
async function openRunDialog() {
|
||||
if (selectedIds.length === 0) {
|
||||
setError('Bitte mindestens eine Übung auswählen.')
|
||||
return
|
||||
}
|
||||
setError('')
|
||||
setCostConfirmed(false)
|
||||
setModes({ skills: true, summary: false, instructions: false })
|
||||
setMergeMode('replace_all')
|
||||
setDialogOpen(true)
|
||||
}
|
||||
|
||||
async function previewChunkWithRetry(chunk, attempt = 0) {
|
||||
try {
|
||||
return await api.previewExerciseEnrichment({
|
||||
exercise_ids: chunk,
|
||||
modes,
|
||||
merge_mode: mergeMode,
|
||||
})
|
||||
} catch (e) {
|
||||
const msg = e.message || String(e)
|
||||
const isTimeout = /504|502|timeout/i.test(msg)
|
||||
if (isTimeout && attempt < 2) {
|
||||
await new Promise((r) => setTimeout(r, 2500))
|
||||
return previewChunkWithRetry(chunk, attempt + 1)
|
||||
}
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
async function runPreviewFromDialog() {
|
||||
setDialogOpen(false)
|
||||
setError('')
|
||||
setJobRunning(true)
|
||||
abortRef.current = false
|
||||
const allResults = []
|
||||
const allErrors = []
|
||||
let estTotal = 0
|
||||
const totalChunks = Math.ceil(selectedIds.length / PREVIEW_CHUNK_SIZE)
|
||||
|
||||
setJobProgress({ done: 0, total: selectedIds.length, phase: 'Vorschau' })
|
||||
|
||||
try {
|
||||
for (let i = 0; i < selectedIds.length; i += PREVIEW_CHUNK_SIZE) {
|
||||
if (abortRef.current) break
|
||||
const chunk = selectedIds.slice(i, i + PREVIEW_CHUNK_SIZE)
|
||||
const chunkNo = Math.floor(i / PREVIEW_CHUNK_SIZE) + 1
|
||||
setJobProgress({
|
||||
done: i,
|
||||
total: selectedIds.length,
|
||||
phase: `Vorschau Paket ${chunkNo}/${totalChunks}`,
|
||||
})
|
||||
const resp = await previewChunkWithRetry(chunk)
|
||||
allResults.push(...(resp.results || []))
|
||||
allErrors.push(...(resp.errors || []))
|
||||
const est = resp.estimated_llm_calls
|
||||
estTotal += typeof est === 'object' ? est.total || 0 : est || 0
|
||||
setJobProgress({
|
||||
done: Math.min(i + chunk.length, selectedIds.length),
|
||||
total: selectedIds.length,
|
||||
phase: `Vorschau Paket ${chunkNo}/${totalChunks}`,
|
||||
})
|
||||
}
|
||||
setPreviewRows(allResults)
|
||||
setPreviewErrors(allErrors)
|
||||
setPreviewMeta({
|
||||
estimated_llm_calls: estTotal,
|
||||
merge_mode: mergeMode,
|
||||
modes: { ...modes },
|
||||
})
|
||||
setShowPreview(true)
|
||||
} catch (e) {
|
||||
setError(e.message || String(e))
|
||||
} finally {
|
||||
setJobRunning(false)
|
||||
setJobProgress({ done: 0, total: 0, phase: '' })
|
||||
}
|
||||
}
|
||||
|
||||
function cancelJob() {
|
||||
abortRef.current = true
|
||||
}
|
||||
|
||||
async function runApply() {
|
||||
const applyItems = previewRows
|
||||
.filter((r) => r.ok)
|
||||
.map((r) => ({
|
||||
exercise_id: r.exercise_id,
|
||||
merged_skills: r.merged_skills || [],
|
||||
summary: r.suggested_summary || null,
|
||||
instruction_fields: r.suggested_instructions || null,
|
||||
}))
|
||||
if (applyItems.length === 0) {
|
||||
setError('Keine anwendbaren Vorschau-Ergebnisse.')
|
||||
return
|
||||
}
|
||||
|
||||
const statusLabel =
|
||||
setStatusAfterApply === 'in_review'
|
||||
? 'In Prüfung'
|
||||
: setStatusAfterApply || 'unverändert'
|
||||
if (
|
||||
!window.confirm(
|
||||
`${applyItems.length} Übung(en) speichern und Status „${statusLabel}“ setzen?`,
|
||||
)
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
setError('')
|
||||
setJobRunning(true)
|
||||
abortRef.current = false
|
||||
const appliedAll = []
|
||||
const failedAll = []
|
||||
|
||||
setJobProgress({ done: 0, total: applyItems.length, phase: 'Anwenden' })
|
||||
|
||||
try {
|
||||
for (let i = 0; i < applyItems.length; i += APPLY_CHUNK_SIZE) {
|
||||
if (abortRef.current) break
|
||||
const chunk = applyItems.slice(i, i + APPLY_CHUNK_SIZE)
|
||||
const resp = await api.applyExerciseEnrichment({
|
||||
items: chunk,
|
||||
modes: previewMeta?.modes || modes,
|
||||
merge_mode: mergeMode,
|
||||
set_status: setStatusAfterApply || null,
|
||||
})
|
||||
appliedAll.push(...(resp.applied || []))
|
||||
failedAll.push(...(resp.failed || []))
|
||||
setJobProgress({
|
||||
done: Math.min(i + chunk.length, applyItems.length),
|
||||
total: applyItems.length,
|
||||
phase: 'Anwenden',
|
||||
})
|
||||
}
|
||||
setPreviewRows([])
|
||||
setShowPreview(false)
|
||||
setSelected(new Set())
|
||||
await loadCandidates()
|
||||
const msg = `${appliedAll.length} OK, ${failedAll.length} Fehler`
|
||||
if (failedAll.length) setError(msg)
|
||||
else setError('')
|
||||
window.alert(msg)
|
||||
} catch (e) {
|
||||
setError(e.message || String(e))
|
||||
} finally {
|
||||
setJobRunning(false)
|
||||
setJobProgress({ done: 0, total: 0, phase: '' })
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="page" style={{ paddingBottom: 80 }}>
|
||||
<AdminPageNav />
|
||||
<header style={{ marginBottom: 16 }}>
|
||||
<h1 style={{ margin: '0 0 4px', fontSize: '1.35rem' }}>Übungs-Anreicherung (KI)</h1>
|
||||
<p style={{ margin: 0, color: 'var(--text2)', fontSize: '0.95rem' }}>
|
||||
Batchweise Anreicherung nach Status — Vorschau ohne Speichern, danach kontrolliert in Prüfung
|
||||
überführen. Für große Bestände (300+) alle passenden Übungen auswählen und Kosten bestätigen.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{error && (
|
||||
<div className="card" style={{ borderColor: 'var(--danger)', marginBottom: 12, padding: 12 }}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="card" style={{ marginBottom: 16, padding: 16 }}>
|
||||
<div className="form-row" style={{ flexWrap: 'wrap', gap: 12 }}>
|
||||
<label className="form-label" style={{ minWidth: 120 }}>
|
||||
Status
|
||||
<select
|
||||
className="form-input"
|
||||
value={filters.status}
|
||||
onChange={(e) => {
|
||||
setOffset(0)
|
||||
setFilters((f) => ({ ...f, status: e.target.value }))
|
||||
}}
|
||||
>
|
||||
{STATUS_OPTIONS.map((o) => (
|
||||
<option key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="form-label" style={{ minWidth: 160 }}>
|
||||
Sichtbarkeit
|
||||
<select
|
||||
className="form-input"
|
||||
value={filters.visibility}
|
||||
onChange={(e) => {
|
||||
setOffset(0)
|
||||
setFilters((f) => ({ ...f, visibility: e.target.value }))
|
||||
}}
|
||||
>
|
||||
{VISIBILITY_OPTIONS.map((o) => (
|
||||
<option key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="form-label" style={{ minWidth: 140 }}>
|
||||
Fokusbereich
|
||||
<select
|
||||
className="form-input"
|
||||
value={filters.focusAreaId}
|
||||
onChange={(e) => {
|
||||
setOffset(0)
|
||||
setFilters((f) => ({ ...f, focusAreaId: e.target.value }))
|
||||
}}
|
||||
>
|
||||
<option value="">—</option>
|
||||
{focusAreas.map((fa) => (
|
||||
<option key={fa.id} value={fa.id}>
|
||||
{fa.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="form-label" style={{ flex: '1 1 180px' }}>
|
||||
Suche
|
||||
<input
|
||||
className="form-input"
|
||||
value={filters.search}
|
||||
placeholder="Titel, Stichworte…"
|
||||
onChange={(e) => setFilters((f) => ({ ...f, search: e.target.value }))}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
setOffset(0)
|
||||
loadCandidates().catch((err) => setError(err.message))
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 16, marginTop: 12, alignItems: 'center' }}>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 6, cursor: 'pointer' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={filters.withoutSkills}
|
||||
onChange={(e) => {
|
||||
setOffset(0)
|
||||
setFilters((f) => ({ ...f, withoutSkills: e.target.checked }))
|
||||
}}
|
||||
/>
|
||||
Ohne Skills
|
||||
</label>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 6, cursor: 'pointer' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={filters.withAiSuggested}
|
||||
onChange={(e) => {
|
||||
setOffset(0)
|
||||
setFilters((f) => ({ ...f, withAiSuggested: e.target.checked }))
|
||||
}}
|
||||
/>
|
||||
Mit KI-Skills
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
onClick={() => {
|
||||
setOffset(0)
|
||||
loadCandidates().catch((err) => setError(err.message))
|
||||
}}
|
||||
>
|
||||
Filtern
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card" style={{ marginBottom: 16, padding: 16 }}>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, alignItems: 'center' }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
disabled={jobRunning || total === 0}
|
||||
onClick={selectAllMatchingFilter}
|
||||
>
|
||||
Alle {total} aus Filter auswählen
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
disabled={jobRunning || selectedIds.length === 0}
|
||||
onClick={openRunDialog}
|
||||
>
|
||||
Anreicherung konfigurieren ({selectedIds.length})
|
||||
</button>
|
||||
{showPreview && previewRows.length > 0 && (
|
||||
<button type="button" className="btn btn-primary" disabled={jobRunning} onClick={runApply}>
|
||||
Anwenden & Status setzen
|
||||
</button>
|
||||
)}
|
||||
{jobRunning && (
|
||||
<button type="button" className="btn btn-secondary" onClick={cancelJob}>
|
||||
Abbrechen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{jobRunning && jobProgress.total > 0 && (
|
||||
<p style={{ margin: '10px 0 0', fontSize: '0.9rem' }}>
|
||||
{jobProgress.phase}: {jobProgress.done}/{jobProgress.total}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<RunDialog
|
||||
open={dialogOpen}
|
||||
onClose={() => setDialogOpen(false)}
|
||||
exerciseCount={selectedIds.length}
|
||||
analysis={analysis}
|
||||
modes={modes}
|
||||
setModes={setModes}
|
||||
mergeMode={mergeMode}
|
||||
setMergeMode={setMergeMode}
|
||||
setStatusAfterApply={setStatusAfterApply}
|
||||
statusAfterApply={setStatusAfterApply}
|
||||
costConfirmed={costConfirmed}
|
||||
setCostConfirmed={setCostConfirmed}
|
||||
onStartPreview={runPreviewFromDialog}
|
||||
jobRunning={jobRunning}
|
||||
/>
|
||||
|
||||
{showPreview && (
|
||||
<div className="card" style={{ marginBottom: 16, padding: 16 }}>
|
||||
<h2 style={{ margin: '0 0 12px', fontSize: '1.1rem' }}>Vorschau</h2>
|
||||
{previewMeta && (
|
||||
<p style={{ margin: '0 0 12px', color: 'var(--text2)', fontSize: '0.9rem' }}>
|
||||
{previewRows.length} OK · {previewErrors.length} Fehler/übersprungen · ca.{' '}
|
||||
{previewMeta.estimated_llm_calls} LLM-Call(s)
|
||||
</p>
|
||||
)}
|
||||
{previewRows.map((row) => (
|
||||
<div
|
||||
key={row.exercise_id}
|
||||
style={{ borderTop: '1px solid var(--border)', paddingTop: 12, marginTop: 12 }}
|
||||
>
|
||||
<strong>{row.title || `#${row.exercise_id}`}</strong>
|
||||
<span style={{ color: 'var(--text2)', marginLeft: 8, fontSize: '0.85rem' }}>
|
||||
{row.status} · {row.visibility} · {row.primary_focus_name || '—'}
|
||||
</span>
|
||||
{(previewMeta?.modes?.skills || modes.skills) && (
|
||||
<div style={{ marginTop: 6, fontSize: '0.88rem' }}>
|
||||
<div>
|
||||
<span style={{ color: 'var(--text2)' }}>Vorhanden:</span>{' '}
|
||||
{(row.existing_skills || []).map((s) => skillLabel(s)).join('; ') || '—'}
|
||||
</div>
|
||||
<div style={{ marginTop: 4 }}>
|
||||
<span style={{ color: 'var(--text2)' }}>Vorgeschlagen:</span>{' '}
|
||||
{(row.suggested_skills || []).map((s) => skillLabel(s)).join('; ') || '—'}
|
||||
</div>
|
||||
<DiffBlock diff={row.diff} />
|
||||
</div>
|
||||
)}
|
||||
{(previewMeta?.modes?.summary || modes.summary) && row.suggested_summary && (
|
||||
<div style={{ marginTop: 8, fontSize: '0.88rem' }}>
|
||||
<span style={{ color: 'var(--text2)' }}>Kurzfassung:</span>{' '}
|
||||
{row.existing_summary ? `"${row.existing_summary}" → ` : ''}
|
||||
"{row.suggested_summary}"
|
||||
</div>
|
||||
)}
|
||||
{(previewMeta?.modes?.instructions || modes.instructions) &&
|
||||
row.suggested_instructions &&
|
||||
Object.keys(row.suggested_instructions).length > 0 && (
|
||||
<div style={{ marginTop: 8, fontSize: '0.88rem' }}>
|
||||
<span style={{ color: 'var(--text2)' }}>Anleitung geändert:</span>{' '}
|
||||
{Object.keys(row.suggested_instructions)
|
||||
.map((k) => INSTRUCTION_LABELS[k] || k)
|
||||
.join(', ')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{previewErrors.map((err) => (
|
||||
<div
|
||||
key={`err-${err.exercise_id}`}
|
||||
style={{ color: 'var(--danger)', marginTop: 8, fontSize: '0.88rem' }}
|
||||
>
|
||||
#{err.exercise_id}: {err.error || 'Fehler'}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="card" style={{ padding: 0, overflow: 'hidden' }}>
|
||||
{loading ? (
|
||||
<p style={{ padding: 16 }}>Lade…</p>
|
||||
) : (
|
||||
<>
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.92rem' }}>
|
||||
<thead>
|
||||
<tr style={{ background: 'var(--surface2)', textAlign: 'left' }}>
|
||||
<th style={{ padding: '10px 12px', width: 40 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={allPageSelected}
|
||||
onChange={togglePageAll}
|
||||
aria-label="Alle auf Seite"
|
||||
/>
|
||||
</th>
|
||||
<th style={{ padding: '10px 12px' }}>Titel</th>
|
||||
<th style={{ padding: '10px 12px' }}>Status</th>
|
||||
<th style={{ padding: '10px 12px' }}>Sichtbarkeit</th>
|
||||
<th style={{ padding: '10px 12px' }}>Fokus</th>
|
||||
<th style={{ padding: '10px 12px' }}>Skills</th>
|
||||
<th style={{ padding: '10px 12px' }}>KI-Skills</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={7} style={{ padding: 16, color: 'var(--text2)' }}>
|
||||
Keine Kandidaten für die Filter.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
items.map((row) => (
|
||||
<tr key={row.id} style={{ borderTop: '1px solid var(--border)' }}>
|
||||
<td style={{ padding: '10px 12px' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected.has(row.id)}
|
||||
onChange={() => toggleRow(row.id)}
|
||||
aria-label={`Auswahl ${row.title}`}
|
||||
/>
|
||||
</td>
|
||||
<td style={{ padding: '10px 12px' }}>{row.title}</td>
|
||||
<td style={{ padding: '10px 12px' }}>{row.status}</td>
|
||||
<td style={{ padding: '10px 12px' }}>{row.visibility}</td>
|
||||
<td style={{ padding: '10px 12px' }}>{row.primary_focus_name || '—'}</td>
|
||||
<td style={{ padding: '10px 12px' }}>{row.skill_count ?? 0}</td>
|
||||
<td style={{ padding: '10px 12px' }}>{row.ai_suggested_skill_count ?? 0}</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
padding: '12px 16px',
|
||||
borderTop: '1px solid var(--border)',
|
||||
}}
|
||||
>
|
||||
<span style={{ color: 'var(--text2)', fontSize: '0.88rem' }}>
|
||||
{total} gesamt · {selectedIds.length} ausgewählt
|
||||
</span>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
disabled={offset <= 0}
|
||||
onClick={() => setOffset((o) => Math.max(0, o - limit))}
|
||||
>
|
||||
Zurück
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
disabled={offset + limit >= total}
|
||||
onClick={() => setOffset((o) => o + limit)}
|
||||
>
|
||||
Weiter
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -238,6 +238,101 @@ export default function TrainingFrameworkProgramEditPage() {
|
|||
const [targetGroupsCatalog, setTargetGroupsCatalog] = useState([])
|
||||
const [sectionPickerCtx, setSectionPickerCtx] = useState(null)
|
||||
const [peekCtx, setPeekCtx] = useState(null)
|
||||
|
||||
const frameworkPlanningContext = useMemo(() => {
|
||||
if (!sectionPickerCtx) return null
|
||||
const { slotIdx, sectionIndex: sIdx, itemIndex: iIdx, insertBeforeIndex: beforeIx } = sectionPickerCtx
|
||||
const slot = form.slots?.[slotIdx]
|
||||
const secs = slot?.sections?.length ? slot.sections : [defaultSection('Ablauf')]
|
||||
const sec = secs[sIdx]
|
||||
let anchorExerciseId = null
|
||||
if (sec?.items?.length) {
|
||||
if (typeof iIdx === 'number') {
|
||||
const item = sec.items[iIdx]
|
||||
if (item?.exercise_id) {
|
||||
anchorExerciseId = Number(item.exercise_id)
|
||||
} else {
|
||||
for (let i = iIdx - 1; i >= 0; i -= 1) {
|
||||
if (sec.items[i]?.exercise_id) {
|
||||
anchorExerciseId = Number(sec.items[i].exercise_id)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (typeof beforeIx === 'number') {
|
||||
for (let i = beforeIx - 1; i >= 0; i -= 1) {
|
||||
if (sec.items[i]?.exercise_id) {
|
||||
anchorExerciseId = Number(sec.items[i].exercise_id)
|
||||
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)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const plannedExerciseIds = []
|
||||
const seenPlan = new Set()
|
||||
for (const s of secs) {
|
||||
for (const it of s?.items || []) {
|
||||
if (String(it?.item_type || '').toLowerCase() === 'note') continue
|
||||
const eid = Number(it?.exercise_id)
|
||||
if (!Number.isFinite(eid) || eid < 1 || seenPlan.has(eid)) continue
|
||||
seenPlan.add(eid)
|
||||
plannedExerciseIds.push(eid)
|
||||
}
|
||||
}
|
||||
const sectionPlannedExerciseIds = []
|
||||
const seenSec = new Set()
|
||||
for (const it of sec?.items || []) {
|
||||
if (String(it?.item_type || '').toLowerCase() === 'note') continue
|
||||
const eid = Number(it?.exercise_id)
|
||||
if (!Number.isFinite(eid) || eid < 1 || seenSec.has(eid)) continue
|
||||
seenSec.add(eid)
|
||||
sectionPlannedExerciseIds.push(eid)
|
||||
}
|
||||
let lastExerciseTitle = null
|
||||
if (sec?.items?.length) {
|
||||
for (let i = sec.items.length - 1; i >= 0; i -= 1) {
|
||||
const it = sec.items[i]
|
||||
if (String(it?.item_type || '').toLowerCase() === 'note') continue
|
||||
if (it?.exercise_id) {
|
||||
lastExerciseTitle = (it.exercise_title || '').trim() || null
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
unitId: null,
|
||||
groupId: null,
|
||||
sectionOrderIndex: sIdx,
|
||||
sectionTitle: (sec?.title || '').trim() || null,
|
||||
sectionGuidanceNotes: (sec?.guidance_notes || '').trim() || null,
|
||||
sectionPlannedExerciseIds,
|
||||
sectionExerciseCount: sectionPlannedExerciseIds.length,
|
||||
lastExerciseTitle,
|
||||
anchorExerciseId: Number.isFinite(anchorExerciseId) && anchorExerciseId > 0 ? anchorExerciseId : null,
|
||||
progressionGraphId: null,
|
||||
plannedExerciseIds,
|
||||
}
|
||||
}, [sectionPickerCtx, form.slots])
|
||||
|
||||
const openFrameworkExercisePicker = useCallback((slotIdx, ctx, pickerMode = 'library') => {
|
||||
setSectionPickerCtx({
|
||||
slotIdx,
|
||||
sectionIndex: ctx.sectionIndex,
|
||||
itemIndex: typeof ctx.itemIndex === 'number' ? ctx.itemIndex : undefined,
|
||||
insertBeforeIndex:
|
||||
typeof ctx.insertBeforeIndex === 'number' && Number.isFinite(ctx.insertBeforeIndex)
|
||||
? ctx.insertBeforeIndex
|
||||
: undefined,
|
||||
pickerMode,
|
||||
})
|
||||
}, [])
|
||||
const [editingGoalIdx, setEditingGoalIdx] = useState(null)
|
||||
const [goalMenuGi, setGoalMenuGi] = useState(null)
|
||||
/** Nur schmal: Stammdaten | Plan (Ziele übereinander, darunter Slots) — Desktop: alles sichtbar */
|
||||
|
|
@ -911,17 +1006,8 @@ export default function TrainingFrameworkProgramEditPage() {
|
|||
),
|
||||
}))
|
||||
}}
|
||||
onRequestExercisePick={({ sectionIndex, itemIndex, insertBeforeIndex }) =>
|
||||
setSectionPickerCtx({
|
||||
slotIdx: si,
|
||||
sectionIndex,
|
||||
itemIndex: typeof itemIndex === 'number' ? itemIndex : undefined,
|
||||
insertBeforeIndex:
|
||||
typeof insertBeforeIndex === 'number' && Number.isFinite(insertBeforeIndex)
|
||||
? insertBeforeIndex
|
||||
: undefined,
|
||||
})
|
||||
}
|
||||
onRequestExercisePick={(ctx) => openFrameworkExercisePicker(si, ctx, 'library')}
|
||||
onRequestPlanningExercisePick={(ctx) => openFrameworkExercisePicker(si, ctx, 'planning')}
|
||||
onPeekExercise={(id, variantId) =>
|
||||
setPeekCtx({ exerciseId: id, variantId: variantId ?? null })
|
||||
}
|
||||
|
|
@ -1366,6 +1452,9 @@ export default function TrainingFrameworkProgramEditPage() {
|
|||
open={sectionPickerCtx != null}
|
||||
multiSelect
|
||||
enableQuickCreateDraft
|
||||
pickerMode={sectionPickerCtx?.pickerMode === 'planning' ? 'planning' : 'library'}
|
||||
enableFreePlanningSearch
|
||||
planningContext={frameworkPlanningContext}
|
||||
onClose={() => setSectionPickerCtx(null)}
|
||||
onSelectExercises={async (picked) => {
|
||||
if (!sectionPickerCtx || !picked?.length) return
|
||||
|
|
|
|||
|
|
@ -123,6 +123,105 @@ export default function TrainingUnitEditPage() {
|
|||
const [publishFrameworkOpen, setPublishFrameworkOpen] = useState(false)
|
||||
const [saveModuleOpen, setSaveModuleOpen] = useState(false)
|
||||
|
||||
const exercisePickerPlanningContext = useMemo(() => {
|
||||
if (!exercisePickerTarget) return null
|
||||
const resolvedUnitId =
|
||||
editingUnit?.id ?? (Number.isFinite(unitId) && unitId > 0 ? unitId : null)
|
||||
const target = exercisePickerTarget
|
||||
const secs = formData.sections || []
|
||||
const sIdx = target?.sIdx ?? 0
|
||||
const sec = secs[sIdx]
|
||||
let anchorExerciseId = 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)
|
||||
} else {
|
||||
for (let i = target.iIdx - 1; i >= 0; i -= 1) {
|
||||
if (sec.items[i]?.exercise_id) {
|
||||
anchorExerciseId = Number(sec.items[i].exercise_id)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (typeof target?.insertBeforeIndex === 'number') {
|
||||
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)
|
||||
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)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const plannedExerciseIds = []
|
||||
const seenPlan = new Set()
|
||||
for (const sec of secs) {
|
||||
for (const it of sec?.items || []) {
|
||||
if (String(it?.item_type || '').toLowerCase() === 'note') continue
|
||||
const eid = Number(it?.exercise_id)
|
||||
if (!Number.isFinite(eid) || eid < 1 || seenPlan.has(eid)) continue
|
||||
seenPlan.add(eid)
|
||||
plannedExerciseIds.push(eid)
|
||||
}
|
||||
}
|
||||
const sectionPlannedExerciseIds = []
|
||||
const seenSec = new Set()
|
||||
for (const it of sec?.items || []) {
|
||||
if (String(it?.item_type || '').toLowerCase() === 'note') continue
|
||||
const eid = Number(it?.exercise_id)
|
||||
if (!Number.isFinite(eid) || eid < 1 || seenSec.has(eid)) continue
|
||||
seenSec.add(eid)
|
||||
sectionPlannedExerciseIds.push(eid)
|
||||
}
|
||||
let lastExerciseTitle = null
|
||||
if (sec?.items?.length) {
|
||||
for (let i = sec.items.length - 1; i >= 0; i -= 1) {
|
||||
const it = sec.items[i]
|
||||
if (String(it?.item_type || '').toLowerCase() === 'note') continue
|
||||
if (it?.exercise_id) {
|
||||
lastExerciseTitle = (it.exercise_title || '').trim() || null
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
const groupIdRaw = Number(formData.group_id)
|
||||
return {
|
||||
unitId: resolvedUnitId ? Number(resolvedUnitId) : null,
|
||||
groupId: Number.isFinite(groupIdRaw) && groupIdRaw > 0 ? groupIdRaw : null,
|
||||
sectionOrderIndex: sIdx,
|
||||
sectionTitle: (sec?.title || '').trim() || null,
|
||||
sectionGuidanceNotes: (sec?.guidance_notes || '').trim() || null,
|
||||
sectionPlannedExerciseIds,
|
||||
sectionExerciseCount: sectionPlannedExerciseIds.length,
|
||||
lastExerciseTitle,
|
||||
anchorExerciseId: Number.isFinite(anchorExerciseId) && anchorExerciseId > 0 ? anchorExerciseId : null,
|
||||
progressionGraphId: null,
|
||||
plannedExerciseIds,
|
||||
}
|
||||
}, [editingUnit?.id, unitId, exercisePickerTarget, formData.sections, formData.group_id])
|
||||
|
||||
const openExercisePicker = useCallback(({ sectionIndex, itemIndex, insertBeforeIndex, pickerMode = 'library' }) => {
|
||||
setExercisePickerTarget({
|
||||
sIdx: sectionIndex,
|
||||
iIdx: typeof itemIndex === 'number' ? itemIndex : undefined,
|
||||
insertBeforeIndex:
|
||||
typeof insertBeforeIndex === 'number' && Number.isFinite(insertBeforeIndex)
|
||||
? insertBeforeIndex
|
||||
: undefined,
|
||||
pickerMode,
|
||||
})
|
||||
setExercisePickerOpen(true)
|
||||
}, [])
|
||||
|
||||
const goBack = useCallback(() => {
|
||||
goNavReturn(navigate, location, {
|
||||
path: PLANNING_HUB_PATH,
|
||||
|
|
@ -688,17 +787,8 @@ export default function TrainingUnitEditPage() {
|
|||
onRequestPublishToFramework={() => editingUnit?.id && setPublishFrameworkOpen(true)}
|
||||
onRequestSaveAsModule={() => editingUnit?.id && setSaveModuleOpen(true)}
|
||||
onRequestTrainingModulePick={(ctx) => void openModuleApplyModal(ctx)}
|
||||
onRequestExercisePick={({ sectionIndex, itemIndex, insertBeforeIndex }) => {
|
||||
setExercisePickerTarget({
|
||||
sIdx: sectionIndex,
|
||||
iIdx: typeof itemIndex === 'number' ? itemIndex : undefined,
|
||||
insertBeforeIndex:
|
||||
typeof insertBeforeIndex === 'number' && Number.isFinite(insertBeforeIndex)
|
||||
? insertBeforeIndex
|
||||
: undefined,
|
||||
})
|
||||
setExercisePickerOpen(true)
|
||||
}}
|
||||
onRequestExercisePick={(ctx) => openExercisePicker({ ...ctx, pickerMode: 'library' })}
|
||||
onRequestPlanningExercisePick={(ctx) => openExercisePicker({ ...ctx, pickerMode: 'planning' })}
|
||||
onPeekExercise={(id, variantId, peekExtras) =>
|
||||
setPlanningPeekCtx({ exerciseId: id, variantId: variantId ?? null, peekExtras: peekExtras ?? null })
|
||||
}
|
||||
|
|
@ -752,6 +842,14 @@ export default function TrainingUnitEditPage() {
|
|||
open={exercisePickerOpen}
|
||||
multiSelect
|
||||
enableQuickCreateDraft
|
||||
expectPlanningSearch
|
||||
pickerMode={exercisePickerTarget?.pickerMode === 'planning' ? 'planning' : 'library'}
|
||||
enableFreePlanningSearch
|
||||
planningUnitId={
|
||||
editingUnit?.id ??
|
||||
(Number.isFinite(unitId) && unitId > 0 ? unitId : null)
|
||||
}
|
||||
planningContext={exercisePickerPlanningContext}
|
||||
onClose={() => {
|
||||
setExercisePickerOpen(false)
|
||||
setExercisePickerTarget(null)
|
||||
|
|
|
|||
|
|
@ -388,6 +388,50 @@ export async function deleteAiSkillRetrievalProfile(profileId) {
|
|||
return request(`/api/admin/ai-skill-retrieval-profiles/${profileId}`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
/** Superadmin: Übungs-Anreicherung per KI (Batch Skills) */
|
||||
export async function listExerciseEnrichmentCandidates(params = {}) {
|
||||
const q = new URLSearchParams()
|
||||
Object.entries(params).forEach(([k, v]) => {
|
||||
if (v === undefined || v === null || v === '') return
|
||||
if (typeof v === 'boolean') q.set(k, v ? 'true' : 'false')
|
||||
else q.set(k, String(v))
|
||||
})
|
||||
const qs = q.toString()
|
||||
return request(`/api/admin/exercise-enrichment/candidates${qs ? `?${qs}` : ''}`)
|
||||
}
|
||||
|
||||
export async function previewExerciseEnrichment(body) {
|
||||
return request('/api/admin/exercise-enrichment/preview', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
}
|
||||
|
||||
export async function applyExerciseEnrichment(body) {
|
||||
return request('/api/admin/exercise-enrichment/apply', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
}
|
||||
|
||||
export async function listExerciseEnrichmentCandidateIds(params = {}) {
|
||||
const q = new URLSearchParams()
|
||||
Object.entries(params).forEach(([k, v]) => {
|
||||
if (v === undefined || v === null || v === '') return
|
||||
if (typeof v === 'boolean') q.set(k, v ? 'true' : 'false')
|
||||
else q.set(k, String(v))
|
||||
})
|
||||
const qs = q.toString()
|
||||
return request(`/api/admin/exercise-enrichment/candidate-ids${qs ? `?${qs}` : ''}`)
|
||||
}
|
||||
|
||||
export async function analyzeExerciseEnrichment(body) {
|
||||
return request('/api/admin/exercise-enrichment/analyze', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
}
|
||||
|
||||
/** Superadmin: KI Prompt-Templates (ai_prompts) */
|
||||
export async function listAdminAiPrompts() {
|
||||
return request('/api/admin/ai-prompts')
|
||||
|
|
@ -844,6 +888,11 @@ export const api = {
|
|||
createAiSkillRetrievalProfile,
|
||||
updateAiSkillRetrievalProfile,
|
||||
deleteAiSkillRetrievalProfile,
|
||||
listExerciseEnrichmentCandidates,
|
||||
previewExerciseEnrichment,
|
||||
applyExerciseEnrichment,
|
||||
listExerciseEnrichmentCandidateIds,
|
||||
analyzeExerciseEnrichment,
|
||||
listAdminAiPrompts,
|
||||
getAdminAiPrompt,
|
||||
updateAdminAiPrompt,
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user