diff --git a/.claude/docs/working/ACCESS_LAYER_ENDPOINT_AUDIT.md b/.claude/docs/working/ACCESS_LAYER_ENDPOINT_AUDIT.md index e16267c..8c42a97 100644 --- a/.claude/docs/working/ACCESS_LAYER_ENDPOINT_AUDIT.md +++ b/.claude/docs/working/ACCESS_LAYER_ENDPOINT_AUDIT.md @@ -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. diff --git a/.claude/docs/working/EXERCISE_ENRICHMENT_ADMIN.md b/.claude/docs/working/EXERCISE_ENRICHMENT_ADMIN.md new file mode 100644 index 0000000..8d00e20 --- /dev/null +++ b/.claude/docs/working/EXERCISE_ENRICHMENT_ADMIN.md @@ -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. diff --git a/.claude/docs/working/PLANNING_EXERCISE_SUGGEST_CONTEXT.md b/.claude/docs/working/PLANNING_EXERCISE_SUGGEST_CONTEXT.md new file mode 100644 index 0000000..f602dae --- /dev/null +++ b/.claude/docs/working/PLANNING_EXERCISE_SUGGEST_CONTEXT.md @@ -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`. diff --git a/backend/ai_prompt_runtime.py b/backend/ai_prompt_runtime.py index 315c1d2..5944f36 100644 --- a/backend/ai_prompt_runtime.py +++ b/backend/ai_prompt_runtime.py @@ -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 diff --git a/backend/exercise_enrichment.py b/backend/exercise_enrichment.py new file mode 100644 index 0000000..659226a --- /dev/null +++ b/backend/exercise_enrichment.py @@ -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, + } diff --git a/backend/main.py b/backend/main.py index fbe10cf..3fe7898 100644 --- a/backend/main.py +++ b/backend/main.py @@ -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 /