diff --git a/.claude/docs/technical/AI_PROMPT_SYSTEM_SPEC.md b/.claude/docs/technical/AI_PROMPT_SYSTEM_SPEC.md index baada4d..ba8b968 100644 --- a/.claude/docs/technical/AI_PROMPT_SYSTEM_SPEC.md +++ b/.claude/docs/technical/AI_PROMPT_SYSTEM_SPEC.md @@ -6,6 +6,8 @@ **Autor:** Claude Code **Vorbild:** Mitai Jinkendo Issue #53 + `backend/routers/prompts.py` + Placeholder-System +**Verwandt (Skill-Katalog in Übungs-KI):** `working/AI_SKILL_RETRIEVAL_PROFILES_SPEC.md` — Tabelle **`ai_skill_retrieval_profiles`** (`config`-JSON ergänzt inhaltliche Prompt-/Katalog-Steuerung neben Platzhaltern). + --- ## 1. Konzept diff --git a/.claude/docs/technical/KI_FEATURES_SPEC.md b/.claude/docs/technical/KI_FEATURES_SPEC.md index 2b590e7..0ca3fb7 100644 --- a/.claude/docs/technical/KI_FEATURES_SPEC.md +++ b/.claude/docs/technical/KI_FEATURES_SPEC.md @@ -160,7 +160,38 @@ KI gibt Vorschläge Liefert KI-Vorschläge auf Basis von Eingabe-Text, **bevor** die Übung gespeichert wurde. Wird beim Klick auf „KI-Vorschlag" im Formular aufgerufen. -**Request Body:** +**Required Fields:** mindestens `goal` ODER `execution` + +**Optional – Skill-Katalogpriorisierung (Stand 068):** + +```json +{ + "focus_areas_context": [ + { "focus_area_id": 3, "is_primary": true }, + { "focus_area_id": 1, "is_primary": false } + ], + "focus_area_hint": "Karate, Kumite…" +} +``` + +- `focus_areas_context`: IDs aus Stammdatum **Fokusbereiche**; Primär soll zuerst stehen (`is_primary`). Ohne Feld oder leere Liste gilt das DB-Profil **`is_default`** (`ai_skill_retrieval_profiles`). +- `focus_area_hint`: bleibt lesbarer Text für den Prompt (bestehende Prompts). + + +**Minimal-Beispiel (Mit Fokus für Retrieval):** + +```json +{ + "title": "Maai - Distanzübung", + "goal": "…", + "execution": "…", + "focus_areas_context": [ { "focus_area_id": 1, "is_primary": true } ] +} +``` + + +**Minimal-Beispiel ( ohne Fokus — nur Texts):** + ```json { "title": "Maai - Distanzübung", @@ -169,8 +200,6 @@ Wird beim Klick auf „KI-Vorschlag" im Formular aufgerufen. } ``` -**Required Fields:** mindestens `goal` ODER `execution` (je länger, desto besser) - **Response:** `200 OK` ```json { diff --git a/.claude/docs/working/ACCESS_LAYER_ENDPOINT_AUDIT.md b/.claude/docs/working/ACCESS_LAYER_ENDPOINT_AUDIT.md index 3b89aa4..c592e11 100644 --- a/.claude/docs/working/ACCESS_LAYER_ENDPOINT_AUDIT.md +++ b/.claude/docs/working/ACCESS_LAYER_ENDPOINT_AUDIT.md @@ -13,7 +13,7 @@ Fortlaufend gemäß `ACCESS_LAYER_AND_GOVERNANCE_PLAN.md` Stufe A–C. | exercises | `PATCH /api/exercises/bulk-metadata` | ja | `get_tenant_context` | ja | Liste: UI-Mehrfachwahl; bis 500 IDs; nur Ersteller oder Plattform-Admin | | exercises | `GET .../media/{mid}/file` | ja | `get_tenant_context_flexible` | ja (wie Übung lesen) | Datei oder `?ssetoken`; kein anonymes `/media/` ohne ALLOW_PUBLIC_MEDIA_STATIC | | exercises | übrige geschützte `/api/exercises*` | ja | `get_tenant_context` | ja | PUT Einzelübung: bei Sichtbarkeit `official` Medien-§4.2 (422: Lifecycle/Promotion/Copyright) | -| exercises | POST `/api/exercises/ai/suggest`, POST `/api/exercises/{id}/ai/regenerate` | ja | `get_tenant_context` | nein | Nur Vorschlags-JSON; keine DB-Schreibung; Sendung an OpenRouter | +| exercises | POST `/api/exercises/ai/suggest`, POST `/api/exercises/{id}/ai/regenerate` | ja | `get_tenant_context` | nein | Nur Vorschlags-JSON; keine DB-Schreibung; OpenRouter — suggest optional `focus_areas_context` für Retrieval-Profile | | exercise_progression_graphs | `/api/exercise-progression-graphs*` | ja | `get_tenant_context` | Liste wie Bibliothek; Schreiben Ersteller/Plattform-Admin | Kanten: Lesen wenn Graph lesbar | | training_planning | alle geschützten Endpoints | ja | `get_tenant_context` | ja | Vorlagen-Liste wie Übungen; POST Vorlage Default club_id | | dashboard | `GET /api/dashboard/kpis` | ja | `get_tenant_context` | wie `GET /api/exercises` + `GET /api/training-units` | Aggregat für Dashboard-Kurzüberblick (ein Roundtrip) | @@ -39,7 +39,7 @@ Fortlaufend gemäß `ACCESS_LAYER_AND_GOVERNANCE_PLAN.md` Stufe A–C. **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-22 — `POST /api/exercises/ai/suggest`, `POST /api/exercises/{id}/ai/regenerate` (Übungs-KI, kein Persist durch Endpunkt). +Letzte Änderung: 2026-05-29 — gleiche Endpunkte; `POST /api/exercises/ai/suggest` ergänzt um optionales `focus_areas_context` für `ai_skill_retrieval_profiles` (Migration 068). --- diff --git a/.claude/docs/working/AI_EXERCISE_IMPLEMENTATION_PLAN.md b/.claude/docs/working/AI_EXERCISE_IMPLEMENTATION_PLAN.md index 9c5e042..1fbc161 100644 --- a/.claude/docs/working/AI_EXERCISE_IMPLEMENTATION_PLAN.md +++ b/.claude/docs/working/AI_EXERCISE_IMPLEMENTATION_PLAN.md @@ -1,8 +1,8 @@ # Umsetzungsplan – KI bei Übungen (stufenweise, Driftschutz) -**Version:** 0.1 -**Datum:** 2026-05-22 -**Bezüge:** `functional/AI_EXERCISE_ASSISTANT_VISION.md` · `technical/KI_FEATURES_SPEC.md` · `technical/AI_PROMPT_SYSTEM_SPEC.md` · `technical/AI_TRAINING_PLANNING_CONCEPT.md` (§1.1 Ist-Stand) +**Version:** 0.2 +**Datum:** 2026-05-29 +**Bezüge:** `functional/AI_EXERCISE_ASSISTANT_VISION.md` · **`working/AI_SKILL_RETRIEVAL_PROFILES_SPEC.md`** · `technical/KI_FEATURES_SPEC.md` · `technical/AI_PROMPT_SYSTEM_SPEC.md` · `technical/AI_TRAINING_PLANNING_CONCEPT.md` (§1.1 Ist-Stand) --- @@ -10,10 +10,11 @@ 1. **Spec vor Code:** Request/Response-Felder und Statuscodes an `KI_FEATURES_SPEC.md` ausrichten; Abweichungen zuerst Spec oder dieses Dokument anpassen. 2. **Prompts in der DB:** Keine produktionskritischen Prompt-Langtexte nur im Code; Defaults per **Migration** in `ai_prompts`, Anpassung durch Admins über vorgesehene Oberfläche (später) oder SQL. -3. **Stufen-Slugs & Intensität:** Nur **kanonische** Werte wie in `exercises.py` (`basis` … `optimierung`, `niedrig|mittel|hoch`); LLM-Ausgaben **normalisieren**, ungültige `skill_id` verwerfen. -4. **Kein stiller DB-Write:** KI liefert **Vorschläge**; Persistenz nur über bestehende **PUT/POST exercises** inkl. Trainer-Aktion (und optional `summary_ai_generated` / `ai_suggested` wie Spec). -5. **Mandant:** Übungsbezogene KI-Endpunkte nutzen `Depends(get_tenant_context)`; keine Ausnahme ohne Eintrag in `ACCESS_LAYER_ENDPOINT_AUDIT.md`. -6. **Schema:** Neue DB-Objekte nur nummerierte Migration `backend/migrations/067_*.sql` (oder folgend); `DB_SCHEMA_VERSION` in `backend/version.py` anheben. +3. **Skill-Retrieval-Profile:** Gewichte/Quotes in **`ai_skill_retrieval_profiles.config`** — Spezifikation `working/AI_SKILL_RETRIEVAL_PROFILES_SPEC.md`; kein zweites gleichzeitiges Truth-Repo im Sourcecode außer defensiver Fallback `_FALLBACK_RETRIEVAL_CONFIG` in `exercise_ai.py`. +4. **Stufen-Slugs & Intensität:** Nur **kanonische** Werte wie in `exercises.py` (`basis` … `optimierung`, `niedrig|mittel|hoch`); LLM-Ausgaben **normalisieren**, ungültige `skill_id` verwerfen. +5. **Kein stiller DB-Write:** KI liefert **Vorschläge**; Persistenz nur über bestehende **PUT/POST exercises** inkl. Trainer-Aktion (und optional `summary_ai_generated` / `ai_suggested` wie Spec). +6. **Mandant:** Übungsbezogene KI-Endpunkte nutzen `Depends(get_tenant_context)`; keine Ausnahme ohne Eintrag in `ACCESS_LAYER_ENDPOINT_AUDIT.md`. +7. **Schema:** Neue DB-Objekte nur nummerierte Migration **`backend/migrations/`** (aktuell bis **068**) und `DB_SCHEMA_VERSION` anheben. --- @@ -26,10 +27,11 @@ | **S2** | `httpx`-Client OpenRouter; Modul lädt Prompt, ersetzt Platzhalter, parst Antwort | Unit-/Smoke: 503 ohne Key | | **S3** | `POST /api/exercises/ai/suggest`, `POST /api/exercises/{id}/ai/regenerate` | OpenAPI/Handtest mit Key | | **S4** | Frontend: KI-Vorschlag, **Änderungsdialog** (Vorschau, Kurzfassung wählbar, Fähigkeiten pro Zeile an-/abwählbar), dann Übernahme ins Formular | Manuelle UX-Prüfung | +| **S4b** | **Skill-Retrieval:** Migration **`ai_skill_retrieval_profiles`**, `focus_areas_context` am **`POST …/ai/suggest`**, `exercise_ai` kontextbezogener Katalog (Gewichte, Caps, Keyword-Patches) | Migration 068 angelegt; Smoke mit Gewaltschutz / ohne Fokus | | **S5** | (später) Auto-Fallback beim Speichern laut `KI_FEATURES_SPEC` §7 | Feature-Flag / Config | | **S6** | (später) Zielausbau, Anleitung-only, Varianten, Admin-Masse laut Vision | Separate Epics | -**Aktueller Implementierungsstand nach Merge:** S0–S4 anstreben; S5/S6 nicht Teil dieses Laufs. +**Aktueller Implementierungsstand:** **S4 + S4b** im Code (`exercise_ai` + Formular übermittelt `focus_areas_context`). --- @@ -47,7 +49,7 @@ - **2026-05-22:** Initial; S1–S4 als erster Umsetzungspfad. - **2026-05-22:** S1–S4 im Code umgesetzt (Migration 067, `exercise_ai` + Router, Übungsformular); S5 weiter offen. -- **2026-05-22:** UX: Übernahmedialog für KI-Vorschläge (Vorschau, selektive Übernahme) im Übungsformular (`ExerciseFormPageRoot`). +- **2026-05-29:** **S4b:** Migration **068**, `ai_skill_retrieval_profiles`; suggest `focus_areas_context`; Frontend sendet gesetzte Fokusbereiche; Spec `working/AI_SKILL_RETRIEVAL_PROFILES_SPEC.md`. --- @@ -55,7 +57,11 @@ **Erledigt (2026-05-22):** Migration **`067_ai_prompts_exercise_assistant`**, **`openrouter_chat`**, **`exercise_ai`**, **`POST /api/exercises/ai/suggest`** und **`POST /api/exercises/{id}/ai/regenerate`**, Formular-Schaltflächen (Kurzfassung / Fähigkeiten / kombiniert). -**Nacharbeit S4 UX:** Übernahmedialog **`ExerciseFormPageRoot`**: keine sofortige Überschreibung; Kurzfassung mit Vergleich + Checkbox; Fähigkeiten mit Neu/Aktualisierung, Checkboxen, „Alle auswählen/abwählen“; **`Escape`** schließt; KI-Schaltflächen blockiert solange Dialog offen. +**Erledigt (2026-05-29):** Migration **`068`** / Profil **`ai_skill_retrieval_profiles`** (Standard + Profil Gewaltschutz wenn `focus_areas.name` vorhanden); **`exercise_ai`** — Score/Kategorie-Zapfen/Text-Overlap/Keyword-Zuschläge; **API:** `ExerciseAiSuggestBody.focus_areas_context`; **Regenerate** nutzt DB-Fokuszeilen. -**Bewusst noch nicht:** automatische KI beim Speichern (**S5**), Setzen von `summary_ai_generated` bei manuellen UI-Änderungen, Prompt-Admin-UI, Rate-Limits. +**Nacharbeit S4 UX:** Übernahmedialog **`ExerciseFormPageRoot`**: keine sofortige Überschreibung; Kurzfassung mit Vergleich + Checkbox; Fähigkeiten mit Neu/Aktualisierung, Checkboxen, „Alle auswählen/abwählen“; **`Escape`** schließt; KI-Schaltflächen blockiert solange Dialog offen. + +**Offen nächste Schritte Pflege/Umsetzung:** weitere Retrieval-Profile (z. B. Karate-/Fitness-Schwerpunkt) per SQL später Admin-UI; optionales Feld **`skills.ai_context`** Kurzbeschreibung für KI; automatische KI beim Speichern (**S5**); Prompt-/Profil-Admin-UI ohne SQL; Rate-Limits. + +**Bewusst noch nicht (`summary_ai_generated`):** zurücksetzen bei manueller Kurzfassung im UI; Admin-Pflege `ai_skill_retrieval_profiles`. diff --git a/.claude/docs/working/AI_SKILL_RETRIEVAL_PROFILES_SPEC.md b/.claude/docs/working/AI_SKILL_RETRIEVAL_PROFILES_SPEC.md new file mode 100644 index 0000000..b9411ec --- /dev/null +++ b/.claude/docs/working/AI_SKILL_RETRIEVAL_PROFILES_SPEC.md @@ -0,0 +1,120 @@ +# KI Skill-Retrieval-Profile (`ai_skill_retrieval_profiles`) + +**Version:** 0.1 +**Datum:** 2026-05-29 +**Status:** Umsetzung gestartet (Migration **068**) +**Ziel:** Für `POST /api/exercises/ai/suggest` (Skill-Katalogauszug) **Gewichte und Quoten** steuerbar machen: + +- gebunden an **Übungs-Fokusbereich** (`focus_areas.id`), +- ein **Standardprofil** ohne Fokus, +- **optional zusammengeführte** Profile bei mehreren Fokusbereichen, +- **optional Keyword-Übersteuerungen** aus Ziel/Durchführung (z. B. Rollenspiel vs. Befreiung). + +**Technische Basis:** Skills mit `skills.main_category_id` → `skill_main_categories.slug` (`karate` | `allgemeine`) und `skills.category_id` → `skill_categories.slug` (`kondition`, `selbstverteidigung`, …). + +**Bezüge:** `.claude/docs/working/AI_EXERCISE_IMPLEMENTATION_PLAN.md` · `backend/exercise_ai.py` + +--- + +## 1. Datenmodell + +### Tabelle `ai_skill_retrieval_profiles` + +| Spalte | Typ | Beschreibung | +|--------|-----|--------------| +| `id` | serial | Primärschlüssel | +| `focus_area_id` | int NULL FK → `focus_areas(id)` ON DELETE SET NULL | **`NULL`** nur für Standardeintrag möglich (siehe `is_default`) | +| `is_default` | boolean | Genau **eine** Zeile mit `true` | +| `name` | varchar | Kurzer Name (Admin später) | +| `description` | text | Hinweise für Pflege | +| `active` | boolean | Nur aktive werden geladen | +| `config` | jsonb | Siehe §2 | + +**Constraints / Indizes** + +- Eindeutig: `(focus_area_id)` WHERE `focus_area_id IS NOT NULL` +- Eindeutig: `(is_default)` WHERE `is_default = true` + +--- + +## 2. JSON-Konfiguration `config.version = 1` + +Alle Schlüssel **optional**; fehlende Werte fallen auf **einprogrammierten Fallback** in `exercise_ai.py` zurück (entspricht bisher grob „neutral“). + +### 2.1 Gewichtungen (Ranking) + +| Schlüssel | Typ | Bedeutung | +|-----------|-----|------------| +| `main_slug_weights` | `object[str, float]` | Multiplikator pro Hauptkategorie-Slug (`karate`, `allgemeine`) | +| `category_slug_weights` | `object[str, float]` | Multiplikator pro `skill_categories.slug` | + +Basis-Score (vereinfacht): +`(importance oder 3) × main_w × cat_w × text_overlap_bonus × importance_multiplier` + +### 2.2 Kapazitätsbegrenzung (Liste) + +`_MAX_SKILLS_CATALOG_LINES` (aktuell **240**) Zeilen Gesamt: + +| Schlüssel | Typ | Bedeutung | +|-----------|-----|------------| +| `category_max_share` | `object[str, float]` | Max. Anteil dieser **Unterkategorie** am Endergebnis (0–1), z. B. `{ "kondition": 0.25 }` | +| `main_min_share` | `object[str, float]` | Mindest-Zielanteil Hauptkategorie beim **Auswahl-Greedy** (weich; Rest nach Score aufgefüllt) | + +### 2.3 Text / Token-Sparen + +| Schlüssel | Typ | Standard | Bedeutung | +|-----------|-----|----------|------------| +| `description_plain_max_len` | int | 160 | Gekürzte Beschreibung pro Zeile | +| `karate_relevance_max_len` | int | **0** oder 80 | **`0`** = Feld `karate_relevance`/`relevance_level` in der Promptzeile **weglassen** | + +### 2.4 Keyword-Overrides (optional) + +Liste `keyword_overrides`: jedes Element: + +```json +{ + "keywords_any": ["befreiung", "haltegriff"], + "case_insensitive": true, + "patch": { + "category_slug_weights": { "selbstverteidigung": 2.5 }, + "category_max_share": { "koordination": 0.1 } + } +} +``` + +Textsuche in verkettetem Korpus **Titel, Ziel, Durchführung, Focus-Hint** (bereits plaintext). Reihenfolge: erst Basis-Profile zusammenmergen, dann **alle treffenden Overrides**‑`patch`‑Objekte **flach zusammenführen** (Gewichte multiplikativ übereinander, Caps den strengsten Wert nehmen – aktuelle Implementierung im Code dokumentiert). + +--- + +## 3. Mehrere Fokusbereiche auf der Übung + +Request-Body: `focus_areas_context: [{ "focus_area_id": n, "is_primary": bool }, …]` + +**Aktuelle Merge-Strategie (v1):** Profile laden → **gleichgewichtete Mittelwert-Bildung** der numerischen Gewichte / Caps (implementiert für `main_slug_weights`, `category_slug_weights`, `category_max_share`, `main_min_share`, `*_max_len`). Anschließend **Keyword-Overrides** anwenden. + +**Primär-Fokus:** Im Frontend soll die **primäre** Zeile aus `focus_areas_multi` **zuerst** in der Liste stehen; die Merge-Strategie kann später zu „Primär dominate“ erweitert werden. + +Ohne Kontext oder ohne Treffer auf aktive Profile: **nur Standardprofil** (`is_default`). + +--- + +## 4. Seed-Daten (Migration) + +- **`is_default=true`:** ausgewogene Standard-Gewichte, moderate Caps auf `kondition`/`koordination`, Karate-Relevanz gekürzt. +- **`Gewaltschutz`:** `focus_area_id` per `(SELECT id FROM focus_areas WHERE name = 'Gewaltschutz' LIMIT 1)` — höhere Gewichte für `kognition`, `psychische_faehigkeiten`, `soziale_faehigkeiten`, `selbstverteidigung`; gedrosseltes `kondition`/`koordination`; `karate_relevance_max_len`: 0; Keyword-Patches wie oben können nachgeschärft werden. + +Weitere Profile (Karate-Schwerpunkt etc.) später per Admin-SQL oder UI. + +--- + +## 5. API + +`ExerciseAiSuggestBody` erweitert um **`focus_areas_context`** (Liste). Feld **`focus_area_hint`** bleibt für den **Prompt-Kontext** (bestehende Prompts). + +`POST …/ai/regenerate` nutzt später dieselbe Retrieval-Logik aus den Detail-Daten der Übung (**To-do:** dort `focus_areas_context` aus `exercise_focus_areas` ableiten). + +--- + +## 6. Changelog + +- **2026-05-29:** Erstellt; gekoppelt an Migration **068** und erste `exercise_ai`-Integration. diff --git a/backend/exercise_ai.py b/backend/exercise_ai.py index 55e07ab..255ad4c 100644 --- a/backend/exercise_ai.py +++ b/backend/exercise_ai.py @@ -1,12 +1,16 @@ """ KI-Vorschlaege fuer Uebungsformular: Laedt Prompts aus ai_prompts, ruft OpenRouter auf. Keine persistente Aenderung an exercises — nur Response-DTO fuer das Frontend. + +Skill-Katalog fuer Prompts: priorisierte Auswahl (ai_skill_retrieval_profiles, Fallback-Heuristik). """ from __future__ import annotations +import copy import json +import math import re -from typing import Any, Dict, List, Optional, Tuple +from typing import Any, Dict, List, Mapping, MutableMapping, Optional, Sequence, Tuple from fastapi import HTTPException @@ -24,6 +28,26 @@ _LEGACY_SKILL_LEVEL_SLUG = { } _ALLOWED_SKILL_INTENSITY = frozenset({"niedrig", "mittel", "hoch"}) +_TAG_RE = re.compile(r"<[^>]+>", re.IGNORECASE) +_TOKEN_FIND = re.compile(r"[a-zäöüß0-9]+", re.IGNORECASE) + +_MAX_PLAIN_FIELD = 28_000 +_MAX_SKILLS_CATALOG_LINES = 240 +_MAX_SUMMARY_CHARS = 220 + +_FALLBACK_RETRIEVAL_CONFIG: Dict[str, Any] = { + "version": 1, + "importance_multiplier": 1.0, + "text_overlap_bonus": 2.0, + "main_slug_weights": {"karate": 1.0, "allgemeine": 1.0}, + "category_slug_weights": {}, + "category_max_share": {"kondition": 0.38, "koordination": 0.35}, + "main_min_share": {}, + "description_plain_max_len": 160, + "karate_relevance_max_len": 72, + "keyword_overrides": [], +} + def _normalize_exercise_skill_level(value) -> Optional[str]: if value is None: @@ -50,12 +74,6 @@ def _normalize_exercise_skill_intensity(value) -> str: return key return "mittel" -_TAG_RE = re.compile(r"<[^>]+>", re.IGNORECASE) - -_MAX_PLAIN_FIELD = 28_000 -_MAX_SKILLS_CATALOG_LINES = 240 -_MAX_SUMMARY_CHARS = 220 - def strip_html_to_plain(html: Optional[str], *, max_len: int = _MAX_PLAIN_FIELD) -> str: if not html: @@ -67,6 +85,399 @@ def strip_html_to_plain(html: Optional[str], *, max_len: int = _MAX_PLAIN_FIELD) return t +def _corpus_tokens(*parts: str) -> frozenset: + hay = " ".join(p.strip() for p in parts if p and p.strip()) + ws = {_m.group(0).lower() for _m in _TOKEN_FIND.finditer(hay)} + return frozenset(w for w in ws if len(w) > 1) + + +def _ai_profiles_table_ready(cur) -> bool: + cur.execute("SELECT to_regclass(%s)::text AS t", ("public.ai_skill_retrieval_profiles",)) + row = cur.fetchone() + if row is None: + return False + val = row["t"] if isinstance(row, dict) else row[0] + return val is not None and str(val).strip() != "" + + +def _average_float_dict(dicts: Sequence[Mapping[str, Any]], *, fallback: float) -> Dict[str, float]: + keys: set = set() + for d in dicts: + keys |= set(d.keys()) + out: Dict[str, float] = {} + for k in keys: + vals = [] + for d in dicts: + if k not in d or d[k] is None: + continue + try: + vals.append(float(d[k])) + except (TypeError, ValueError): + continue + out[k] = (sum(vals) / len(vals)) if vals else fallback + return out + + +def _merge_retrieval_configs(configs: Sequence[Dict[str, Any]]) -> Dict[str, Any]: + base = copy.deepcopy(_FALLBACK_RETRIEVAL_CONFIG) + if not configs: + return base + + base["main_slug_weights"] = _average_float_dict( + [c.get("main_slug_weights") or {} for c in configs], + fallback=1.0, + ) + for slug in ("karate", "allgemeine"): + base["main_slug_weights"].setdefault(slug, 1.0) + + base["category_slug_weights"] = _average_float_dict( + [c.get("category_slug_weights") or {} for c in configs], + fallback=1.0, + ) + base["category_max_share"] = _average_float_dict( + [c.get("category_max_share") or {} for c in configs], + fallback=1.0, + ) + base["main_min_share"] = _average_float_dict( + [c.get("main_min_share") or {} for c in configs], + fallback=0.0, + ) + + ims = [] + tbs = [] + dmx = [] + krm = [] + for c in configs: + try: + if c.get("importance_multiplier") is not None: + ims.append(float(c["importance_multiplier"])) + except (TypeError, ValueError): + continue + try: + if c.get("text_overlap_bonus") is not None: + tbs.append(float(c["text_overlap_bonus"])) + except (TypeError, ValueError): + continue + try: + if c.get("description_plain_max_len") is not None: + dmx.append(int(c["description_plain_max_len"])) + except (TypeError, ValueError): + continue + try: + if c.get("karate_relevance_max_len") is not None: + krm.append(int(c["karate_relevance_max_len"])) + except (TypeError, ValueError): + continue + if ims: + base["importance_multiplier"] = sum(ims) / len(ims) + if tbs: + base["text_overlap_bonus"] = sum(tbs) / len(tbs) + if dmx: + base["description_plain_max_len"] = int(round(sum(dmx) / len(dmx))) + if krm: + base["karate_relevance_max_len"] = int(round(sum(krm) / len(krm))) + + overrides: List[Any] = [] + for c in configs: + overrides.extend(c.get("keyword_overrides") or []) + base["keyword_overrides"] = overrides + return base + + +def _mul_weight_dict(target: MutableMapping[str, float], patch: Mapping[str, Any]) -> None: + for k, v in patch.items(): + try: + mul = float(v) + except (TypeError, ValueError): + continue + target[k] = float(target.get(k, 1.0)) * mul + + +def _apply_keyword_overrides(cfg: Dict[str, Any], corpus_lower: str) -> None: + caps = cfg.setdefault("category_max_share", {}) + for ov in cfg.get("keyword_overrides") or []: + keys_any = ov.get("keywords_any") or [] + if not keys_any or not corpus_lower.strip(): + continue + hay = corpus_lower.lower() if corpus_lower else "" + hit = False + for kw in keys_any: + ks = str(kw or "").strip() + if not ks: + continue + ks_l = ks.lower() + hit = ks_l in hay + if hit: + break + if not hit: + continue + patch = ov.get("patch") or {} + _mul_weight_dict(cfg.setdefault("category_slug_weights", {}), patch.get("category_slug_weights") or {}) + _mul_weight_dict(cfg.setdefault("main_slug_weights", {}), patch.get("main_slug_weights") or {}) + for slug, mx in (patch.get("category_max_share") or {}).items(): + try: + mx_f = float(mx) + except (TypeError, ValueError): + continue + cur = float(caps.get(slug, 1.0)) + caps[slug] = min(cur, mx_f) + + +def _ordered_focus_ids(focus_ctx: Optional[Sequence[Tuple[int, bool]]]) -> List[int]: + """Primär zuerst, dann stabil nach ID.""" + if not focus_ctx: + return [] + seen = set() + ordered: List[Tuple[int, bool]] = [] + for fid, isp in sorted(focus_ctx, key=lambda x: (not x[1], x[0])): + try: + i = int(fid) + except (TypeError, ValueError): + continue + if i < 1 or i in seen: + continue + seen.add(i) + ordered.append((i, bool(isp))) + return [fid for fid, _ in ordered] + + +def _load_merged_retrieval_config( + cur, focus_ctx: Optional[Sequence[Tuple[int, bool]]] +) -> Dict[str, Any]: + if not _ai_profiles_table_ready(cur): + return copy.deepcopy(_FALLBACK_RETRIEVAL_CONFIG) + + loaded: List[Dict[str, Any]] = [] + for fid in _ordered_focus_ids(focus_ctx): + cur.execute( + """ + SELECT config + FROM ai_skill_retrieval_profiles + WHERE active = true AND focus_area_id = %s + LIMIT 1 + """, + (fid,), + ) + rw = cur.fetchone() + if not rw: + continue + raw = rw["config"] if isinstance(rw, dict) else rw[0] + if isinstance(raw, str): + try: + raw = json.loads(raw) + except json.JSONDecodeError: + continue + if isinstance(raw, dict): + loaded.append(raw) + + if not loaded: + cur.execute( + """ + SELECT config + FROM ai_skill_retrieval_profiles + WHERE active = true AND is_default = true + LIMIT 1 + """ + ) + rw = cur.fetchone() + if rw: + raw = rw["config"] if isinstance(rw, dict) else rw[0] + if isinstance(raw, str): + try: + raw = json.loads(raw) + except json.JSONDecodeError: + raw = None + if isinstance(raw, dict): + loaded.append(raw) + + return _merge_retrieval_configs(loaded) + + +def _fetch_all_active_skills_for_catalog(cur) -> List[Dict[str, Any]]: + cur.execute( + """ + SELECT s.id, + s.name, + s.category, + s.description, + s.karate_relevance, + s.relevance_level, + s.importance, + COALESCE(m.slug, '') AS main_slug, + COALESCE(c.slug, '') AS category_slug, + c.name AS subcategory_name + FROM skills s + LEFT JOIN skill_main_categories m ON m.id = s.main_category_id + LEFT JOIN skill_categories c ON c.id = s.category_id + WHERE (s.status IS NULL OR s.status = 'active') + """ + ) + return [dict(r) for r in cur.fetchall()] + + +def _score_skill_row( + row: Mapping[str, Any], + cfg: Mapping[str, Any], + corpus_tokens: frozenset, +) -> float: + main_slug = str(row.get("main_slug") or "").strip().lower() + cat_slug = str(row.get("category_slug") or "").strip().lower() + main_w = float((cfg.get("main_slug_weights") or {}).get(main_slug, 1.0)) + cat_w = float((cfg.get("category_slug_weights") or {}).get(cat_slug, 1.0)) + try: + imp = int(row["importance"]) if row.get("importance") is not None else 3 + except (TypeError, ValueError): + imp = 3 + imp = max(1, min(5, imp)) + imp_mult = float(cfg.get("importance_multiplier") or 1.0) + base = float(imp) * imp_mult * max(main_w, 0.05) * max(cat_w, 0.05) + + name = strip_html_to_plain(row.get("name"), max_len=400) + dsc = strip_html_to_plain(row.get("description"), max_len=520) + search_blob = " ".join( + [ + name, + dsc, + cat_slug.replace("_", " "), + str(row.get("category") or ""), + str(row.get("subcategory_name") or ""), + ] + ).lower() + + overlaps = sum(1 for t in corpus_tokens if t and t in search_blob) + tob = float(cfg.get("text_overlap_bonus") or 0.0) + + return base + overlaps * tob + + +def _category_cap_limits(cfg: Mapping[str, Any], n_max: int) -> Dict[str, int]: + out: Dict[str, int] = {} + mx = cfg.get("category_max_share") or {} + if not isinstance(mx, dict): + return out + for slug, raw in mx.items(): + ks = str(slug or "").strip() + if not ks: + continue + try: + sh = float(raw) + except (TypeError, ValueError): + continue + if 0 < sh < 1.0: + out[ks] = max(1, int(math.floor(sh * n_max))) + elif sh >= 1.0: + out[ks] = n_max + 99999 + else: + continue + return out + + +def _pick_catalog_rows(rows_scored: List[Tuple[float, Dict[str, Any]]], cfg: Mapping[str, Any]) -> List[Dict[str, Any]]: + """rows_scored: (score, row_dict) ohne Sortierung-Anforderung.""" + cap_limits = _category_cap_limits(cfg, _MAX_SKILLS_CATALOG_LINES) + ordered = sorted(rows_scored, key=lambda x: (-x[0], str(x[1].get("name") or ""))) + picked: List[Dict[str, Any]] = [] + picked_ids: set = set() + cat_counts: Dict[str, int] = {} + + def under_cap(cat_slug: str) -> bool: + if not cat_slug or cat_slug not in cap_limits: + return True + return cat_counts.get(cat_slug, 0) < cap_limits[cat_slug] + + # Pass 1: Cap respektieren + for _sc, rw in ordered: + if len(picked) >= _MAX_SKILLS_CATALOG_LINES: + break + sid = rw["id"] + if sid in picked_ids: + continue + cslug = str(rw.get("category_slug") or "").strip().lower() + if cslug and not under_cap(cslug): + continue + picked.append(rw) + picked_ids.add(sid) + if cslug: + cat_counts[cslug] = cat_counts.get(cslug, 0) + 1 + + # Pass 2: auffüllen + if len(picked) < _MAX_SKILLS_CATALOG_LINES: + for _sc, rw in ordered: + if len(picked) >= _MAX_SKILLS_CATALOG_LINES: + break + sid = rw["id"] + if sid in picked_ids: + continue + picked.append(rw) + picked_ids.add(sid) + + return picked[:_MAX_SKILLS_CATALOG_LINES] + + +def _format_skill_catalog_line(row: Mapping[str, Any], cfg: Mapping[str, Any]) -> str: + rid = int(row["id"]) + nm = (row.get("name") or "").strip() or f"Skill #{rid}" + cat_legacy = str(row.get("category") or "").strip() + sub = str(row.get("subcategory_name") or "").strip() + main_slug = str(row.get("main_slug") or "").strip() + cats = " / ".join(x for x in (main_slug.upper() if main_slug else "", cat_legacy, sub) if x) + + dmax = int(cfg.get("description_plain_max_len") or 160) + dsc = strip_html_to_plain(row.get("description"), max_len=max(40, min(400, dmax))) + + krmax = int(cfg.get("karate_relevance_max_len") or 0) + kr = strip_html_to_plain(row.get("karate_relevance"), max_len=min(280, krmax)) if krmax > 0 else "" + rel = row.get("relevance_level") + rel_s = str(rel).strip() if rel is not None else "" + + parts = [ + f"- id={rid} | name={nm}", + f" | kategorie={cats or '-'}", + f" | beschreibung={dsc or '-'}", + ] + if krmax > 0 and (kr.strip() or rel_s): + parts.append(f" | karate_relevanz={kr or '-'} | relevanz_stufe={rel_s or '-'}") + return "".join(parts) + + +def _safe_int_importance(value: Any) -> int: + try: + iv = int(value) + except (TypeError, ValueError): + return 0 + return max(1, min(5, iv)) if iv else 0 + + +def build_contextual_skills_catalog_block( + cur, + *, + title: Optional[str], + goal_plain: str, + execution_plain: str, + focus_hint: Optional[str], + focus_ctx: Optional[Sequence[Tuple[int, bool]]], +) -> str: + cfg = _load_merged_retrieval_config(cur, focus_ctx) + corpus_lower = " ".join([title or "", goal_plain or "", execution_plain or "", focus_hint or ""]).lower() + _apply_keyword_overrides(cfg, corpus_lower) + + tok = _corpus_tokens(title or "", goal_plain, execution_plain, focus_hint or "") + skill_rows = _fetch_all_active_skills_for_catalog(cur) + scored: List[Tuple[float, Dict[str, Any]]] = [] + for r in skill_rows: + scored.append((_score_skill_row(r, cfg, tok), r)) + picked = _pick_catalog_rows(scored, cfg) + picked.sort( + key=lambda r: ( + -_safe_int_importance(r.get("importance")), + str(r.get("name") or "").lower(), + ) + ) + + lines = [_format_skill_catalog_line(row, cfg) for row in picked] + return "\n".join(lines) if lines else "(keine aktiven Skills im Katalog)" + + def _load_prompt_row(cur, slug: str) -> Optional[Dict[str, Any]]: cur.execute( """ @@ -93,56 +504,17 @@ def _render_template(template: str, ctx: Dict[str, str]) -> str: return out -def _build_skills_catalog_block(cur) -> str: - cur.execute( - """ - SELECT s.id, s.name, s.category, s.description, s.karate_relevance, s.relevance_level, - sc.name AS subcategory_name - FROM skills s - LEFT JOIN skill_categories sc ON s.category_id = sc.id - WHERE (s.status IS NULL OR s.status = 'active') - ORDER BY s.importance DESC NULLS LAST, s.name - LIMIT %s - """, - (_MAX_SKILLS_CATALOG_LINES,), - ) - lines: List[str] = [] - for r in cur.fetchall(): - rid = int(r["id"]) - nm = (r.get("name") or "").strip() or f"Skill #{rid}" - cat = (r.get("category") or "").strip() - sub = (r.get("subcategory_name") or "").strip() - dsc = strip_html_to_plain(r.get("description"), max_len=320) - kr = strip_html_to_plain(r.get("karate_relevance"), max_len=200) - rel = r.get("relevance_level") - rel_s = "" - if rel is not None: - rel_s = str(rel) - - cats = " / ".join(x for x in (cat, sub) if x) - - blob = ( - f"- id={rid} | name={nm} | kategorie={cats or '-'}" - f" | beschreibung={dsc or '-'} | karate_relevanz={kr or '-'}" - f" | relevanz_stufe={rel_s or '-'}" - ) - lines.append(blob) - return "\n".join(lines) if lines else "(keine aktiven Skills im Katalog)" - - def _extract_json_array(text: str) -> Any: s = text.strip() if s.startswith("```"): s = re.sub(r"^```[a-zA-Z0-9]*\s*", "", s) if s.endswith("```"): s = s[:-3].strip() - # array whole string if s.startswith("["): end = s.rfind("]") if end > 0: s = s[: end + 1] return json.loads(s) - # object wrapping array if s.startswith("{"): obj = json.loads(s) if isinstance(obj, dict): @@ -219,7 +591,6 @@ def _sanitize_skill_entries(cur, rows: Any) -> List[Dict[str, Any]]: item["confidence"] = conf_f out.append(item) - # max 5 return out[:5] @@ -240,6 +611,7 @@ def run_exercise_ai_suggestion( goal: Optional[str], execution: Optional[str], focus_area_hint: Optional[str], + focus_areas_context: Optional[Sequence[Tuple[int, bool]]] = None, want_summary: bool, want_skills: bool, ) -> Dict[str, Any]: @@ -285,7 +657,14 @@ def run_exercise_ai_suggestion( status_code=503, detail="Prompt exercise_skill_suggestions nicht aktiv oder fehlt in DB.", ) - catalog = _build_skills_catalog_block(cur) + catalog = build_contextual_skills_catalog_block( + cur, + title=t_title, + goal_plain=g_plain, + execution_plain=e_plain, + focus_hint=focus or None, + focus_ctx=focus_areas_context, + ) ctx = { "exercise_title": t_title or "-", "exercise_focus_area": focus or "-", @@ -318,3 +697,10 @@ def run_exercise_ai_suggestion( result["skills"] = skills return result + + +__all__ = [ + "build_contextual_skills_catalog_block", + "run_exercise_ai_suggestion", + "strip_html_to_plain", +] diff --git a/backend/migrations/068_ai_skill_retrieval_profiles.sql b/backend/migrations/068_ai_skill_retrieval_profiles.sql new file mode 100644 index 0000000..51fef7c --- /dev/null +++ b/backend/migrations/068_ai_skill_retrieval_profiles.sql @@ -0,0 +1,125 @@ +-- Migration 068: KI Skill-Retrieval-Profile pro Fokusbereich (+ Standardprofil) +-- Purpose: Gewichtungen/Quota fuer exercise_ai Skill-Katalog (OpenRouter Kontext) + +CREATE TABLE IF NOT EXISTS ai_skill_retrieval_profiles ( + id SERIAL PRIMARY KEY, + focus_area_id INT REFERENCES focus_areas(id) ON DELETE CASCADE, + is_default BOOLEAN NOT NULL DEFAULT FALSE, + name VARCHAR(200) NOT NULL, + description TEXT, + active BOOLEAN NOT NULL DEFAULT TRUE, + config JSONB NOT NULL DEFAULT '{}'::jsonb, + updated_at TIMESTAMP DEFAULT NOW() +); + +CREATE UNIQUE INDEX IF NOT EXISTS ux_ai_skill_retrieval_profile_focus_area + ON ai_skill_retrieval_profiles (focus_area_id) + WHERE focus_area_id IS NOT NULL AND active = TRUE; + +CREATE UNIQUE INDEX IF NOT EXISTS ux_ai_skill_retrieval_profile_default_only + ON ai_skill_retrieval_profiles (is_default) + WHERE is_default IS TRUE AND active = TRUE; + +COMMENT ON TABLE ai_skill_retrieval_profiles IS + 'Gewichte/Quota fuer Skill-Katalog in exercise_ai; optional gebunden an focus_areas, genau eine is_default=TRUE'; + +INSERT INTO ai_skill_retrieval_profiles (focus_area_id, is_default, name, description, active, config) +VALUES ( + NULL, + TRUE, + 'Standard', + 'Kein/Undefinierter Fokusbereich: neutrale Gewichte mit sanften Caps auf sehr breite Unterkategorien.', + TRUE, + '{ + "version": 1, + "importance_multiplier": 1, + "text_overlap_bonus": 2, + "main_slug_weights": { "karate": 1, "allgemeine": 1 }, + "category_slug_weights": {}, + "category_max_share": { + "kondition": 0.38, + "koordination": 0.35 + }, + "main_min_share": {}, + "description_plain_max_len": 160, + "karate_relevance_max_len": 72, + "keyword_overrides": [ + { + "keywords_any": ["rollenspiel", "szenario", "deesk", "diskussion"], + "case_insensitive": true, + "patch": { + "category_slug_weights": { + "psychische_faehigkeiten": 1.65, + "soziale_faehigkeiten": 1.65, + "kognition": 1.4 + }, + "category_max_share": { + "kondition": 0.08, + "koordination": 0.1 + } + } + }, + { + "keywords_any": ["befreiung", "haltegriff", "greifer", "umklammer"], + "case_insensitive": true, + "patch": { + "category_slug_weights": { + "selbstverteidigung": 2.2, + "koordination": 0.9 + }, + "main_slug_weights": { "karate": 1.35 } + } + } + ] + }'::jsonb +); + +INSERT INTO ai_skill_retrieval_profiles (focus_area_id, is_default, name, description, active, config) +SELECT + fa.id, + FALSE, + 'Gewaltschutz', + 'Kaum klassische Sportfaehigkeit; Gewicht auf Deeskalation, Kognition/Soziales; SV-Schwerpunkt per Keywords verstaerken.', + TRUE, + '{ + "version": 1, + "importance_multiplier": 1, + "text_overlap_bonus": 2.25, + "main_slug_weights": { "karate": 1.08, "allgemeine": 1.06 }, + "category_slug_weights": { + "kognition": 1.72, + "psychische_faehigkeiten": 1.78, + "soziale_faehigkeiten": 1.78, + "selbstverteidigung": 1.82, + "kondition": 0.32, + "koordination": 0.4 + }, + "category_max_share": { + "kondition": 0.12, + "koordination": 0.16 + }, + "main_min_share": {}, + "description_plain_max_len": 150, + "karate_relevance_max_len": 0, + "keyword_overrides": [ + { + "keywords_any": ["befreiung", "haltegriff", "greifer"], + "case_insensitive": true, + "patch": { + "category_slug_weights": { + "selbstverteidigung": 3.25, + "koordination": 1.08 + }, + "main_slug_weights": { "karate": 1.5 } + } + } + ] + }'::jsonb +FROM focus_areas fa +WHERE fa.name = 'Gewaltschutz' + AND (fa.status IS NULL OR fa.status = 'active') + AND NOT EXISTS ( + SELECT 1 FROM ai_skill_retrieval_profiles p + WHERE p.focus_area_id = fa.id AND p.active = TRUE + ) +LIMIT 1; diff --git a/backend/routers/exercises.py b/backend/routers/exercises.py index 8b9c5a6..d79b0b9 100644 --- a/backend/routers/exercises.py +++ b/backend/routers/exercises.py @@ -358,11 +358,22 @@ class ExerciseMediaFromAsset(BaseModel): media_type: Optional[str] = None +class ExerciseAiFocusCtx(BaseModel): + """Fokusbereich fuer Skill-Kataloggewichte (Migration 068 ai_skill_retrieval_profiles).""" + + focus_area_id: int = Field(..., ge=1) + is_primary: Optional[bool] = False + + class ExerciseAiSuggestBody(BaseModel): title: Optional[str] = Field(None, max_length=300) goal: Optional[str] = Field(None, max_length=64000) execution: Optional[str] = Field(None, max_length=128000) focus_area_hint: Optional[str] = Field(None, max_length=1200) + focus_areas_context: Optional[list[ExerciseAiFocusCtx]] = Field( + None, + description="Optionale Reihenfolge Primär zuerst; steuert Katalogpriorisierung", + ) include_summary: bool = True include_skills: bool = True @@ -2254,6 +2265,22 @@ def list_exercises_like_get( ) +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 []: @@ -2279,12 +2306,17 @@ def exercise_ai_suggest_endpoint( _ = tenant.profile_id with get_db() as conn: cur = get_cursor(conn) + fctx = None + if body.focus_areas_context: + fctx = [(x.focus_area_id, bool(x.is_primary)) for x in body.focus_areas_context] + payload = run_exercise_ai_suggestion( cur, title=(body.title or "").strip(), goal=body.goal, execution=body.execution, focus_area_hint=(body.focus_area_hint or "").strip() or None, + focus_areas_context=fctx, want_summary=body.include_summary, want_skills=body.include_skills, ) @@ -2310,6 +2342,7 @@ def exercise_ai_regenerate_endpoint( raise HTTPException(status_code=404, detail="Übung nicht gefunden") focus = _focus_area_hint_from_detail(exercise) + fctx = _focus_areas_ai_ctx_from_detail(exercise) payload = run_exercise_ai_suggestion( cur, @@ -2317,6 +2350,7 @@ def exercise_ai_regenerate_endpoint( goal=exercise.get("goal"), execution=exercise.get("execution"), focus_area_hint=focus or None, + focus_areas_context=fctx or None, want_summary=want_summary, want_skills=want_skills, ) diff --git a/backend/version.py b/backend/version.py index 70d9a29..c0896ac 100644 --- a/backend/version.py +++ b/backend/version.py @@ -1,8 +1,8 @@ # Shinkan Jinkendo Version Information -APP_VERSION = "0.8.152" -BUILD_DATE = "2026-05-22" -DB_SCHEMA_VERSION = "20260522067" +APP_VERSION = "0.8.153" +BUILD_DATE = "2026-05-29" +DB_SCHEMA_VERSION = "20260529068" MODULE_VERSIONS = { "legal_documents": "1.4.0", # Admin: Live-Vorschau pro Abschnitt + modale Vollvorschau (Editor + Dokumentenliste) @@ -22,7 +22,7 @@ MODULE_VERSIONS = { "skills": "0.1.1", # DB 065 karate_relevance + relevance_level; CRUD unterstützt Felder "skill_profiles": "1.0.0", # Phase 3: gewichtetes Fähigkeiten-Profil + skill-discovery/suggestions "methods": "0.1.0", - "exercises": "2.29.0", # POST exercises/ai/suggest + …/ai/regenerate (OpenRouter); exercise_ai; is_primary fuer exercise_skills + "exercises": "2.30.0", # Migration 068 ai_skill_retrieval_profiles; suggest focus_areas_context; exercise_ai Kontext-Katalog + Gewichtungen "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 @@ -37,6 +37,15 @@ MODULE_VERSIONS = { } CHANGELOG = [ + { + "version": "0.8.153", + "date": "2026-05-29", + "changes": [ + "Migration 068: ai_skill_retrieval_profiles — konfigurierbare Gewichte/Quotes fuer Skill-Katalog in exercise_ai", + "POST /api/exercises/ai/suggest: optionales Body-Feld focus_areas_context; regenerate nutzt gespeicherte Fokusbereiche", + "exercise_ai: kontextbezogene Skill-Auswahl (Score, Kategorie-Caps), Keyword-Patches wie Rollenspiel vs. Haltegriff/Befreiung", + ], + }, { "version": "0.8.152", "date": "2026-05-22", diff --git a/docs/HANDOVER.md b/docs/HANDOVER.md index 741eed2..fba1dad 100644 --- a/docs/HANDOVER.md +++ b/docs/HANDOVER.md @@ -1,7 +1,7 @@ # Shinkan Jinkendo – Entwicklungsstand & Handover -**Stand:** 2026-05-20 -**App-Version / DB-Schema:** App **`0.8.149`** (Einheiten-Editor Vollseite), DB-Schema **`20260515063`** — maßgeblich **`backend/version.py`**: `APP_VERSION`, `DB_SCHEMA_VERSION` +**Stand:** 2026-05-29 +**App-Version / DB-Schema:** App **`0.8.153`** (KI Skill-Retrieval-Profile), DB-Schema **`20260529068`** — maßgeblich **`backend/version.py`**: `APP_VERSION`, `DB_SCHEMA_VERSION` 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**. @@ -88,6 +88,14 @@ 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.153**) + +- **Doku:** Umsetzung `.claude/docs/working/AI_EXERCISE_IMPLEMENTATION_PLAN.md`; Profil-/JSON-Konzept `.claude/docs/working/AI_SKILL_RETRIEVAL_PROFILES_SPEC.md`; API-Felder **`KI_FEATURES_SPEC.md`** §5.2 +- **DB:** Migration **`068`** – Tabelle **`ai_skill_retrieval_profiles`** (Konfig **`config`**) mit Seed „Standard“ + „Gewaltschutz“ (wenn Focus `Gewaltschutz` in `focus_areas` existiert) +- **`exercise_ai`:** Gewichtungen, Kategorie‑Anteil‑Caps (~Token), Keyword-Patches aus Ziel/Durchführung (z. B. Rollenspiel vs. Befreiung/Haltegriff) +- **API:** `POST /api/exercises/ai/suggest` optional **`focus_areas_context`**; **`POST …/ai/regenerate`** verwendet gespeicherte `exercise_focus_areas` automatisch für dieselbe Retrieval-Logik +- **Frontend:** `ExerciseFormPageRoot.jsx` übergibt `focus_areas_context` aus Einordnung; KI-Übernahmedialog nach API-Antwort + --- ## 3. Trainingsrahmenprogramm & Planungs‑Blueprint (kurz) diff --git a/frontend/src/components/exercises/ExerciseFormPageRoot.jsx b/frontend/src/components/exercises/ExerciseFormPageRoot.jsx index 3dcbfd4..13f9329 100644 --- a/frontend/src/components/exercises/ExerciseFormPageRoot.jsx +++ b/frontend/src/components/exercises/ExerciseFormPageRoot.jsx @@ -980,6 +980,18 @@ function ExerciseFormPageRoot() { const snapshotSummaryHtml = formData.summary || '' const snapshotSkills = cloneExerciseSkillRows(formData.skills) + const focusAreasContext = [...(formData.focus_areas_multi || [])] + .map((row) => ({ + focus_area_id: Number(row?.focus_area_id), + is_primary: !!row?.is_primary, + })) + .filter((x) => Number.isFinite(x.focus_area_id) && x.focus_area_id >= 1) + .sort((a, b) => { + const p = Number(!!b.is_primary) - Number(!!a.is_primary) + if (p !== 0) return p + return a.focus_area_id - b.focus_area_id + }) + setAiSuggestBusy(true) try { const res = await api.suggestExerciseAi({ @@ -987,6 +999,7 @@ function ExerciseFormPageRoot() { goal: formData.goal || '', execution: formData.execution || '', focus_area_hint: focusHint || undefined, + focus_areas_context: focusAreasContext.length ? focusAreasContext : undefined, include_summary: summaryOn, include_skills: skillsOn, })