KI Implementierung (MVP) auf Übungen #46
|
|
@ -6,6 +6,8 @@
|
||||||
**Autor:** Claude Code
|
**Autor:** Claude Code
|
||||||
**Vorbild:** Mitai Jinkendo Issue #53 + `backend/routers/prompts.py` + Placeholder-System
|
**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
|
## 1. Konzept
|
||||||
|
|
|
||||||
|
|
@ -160,7 +160,38 @@ KI gibt Vorschläge
|
||||||
Liefert KI-Vorschläge auf Basis von Eingabe-Text, **bevor** die Übung gespeichert wurde.
|
Liefert KI-Vorschläge auf Basis von Eingabe-Text, **bevor** die Übung gespeichert wurde.
|
||||||
Wird beim Klick auf „KI-Vorschlag" im Formular aufgerufen.
|
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
|
```json
|
||||||
{
|
{
|
||||||
"title": "Maai - Distanzübung",
|
"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`
|
**Response:** `200 OK`
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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 | `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 | `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 | ü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 |
|
| 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 |
|
| 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) |
|
| 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.
|
**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).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
# Umsetzungsplan – KI bei Übungen (stufenweise, Driftschutz)
|
# Umsetzungsplan – KI bei Übungen (stufenweise, Driftschutz)
|
||||||
|
|
||||||
**Version:** 0.1
|
**Version:** 0.2
|
||||||
**Datum:** 2026-05-22
|
**Datum:** 2026-05-29
|
||||||
**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)
|
**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.
|
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.
|
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.
|
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. **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).
|
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. **Mandant:** Übungsbezogene KI-Endpunkte nutzen `Depends(get_tenant_context)`; keine Ausnahme ohne Eintrag in `ACCESS_LAYER_ENDPOINT_AUDIT.md`.
|
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. **Schema:** Neue DB-Objekte nur nummerierte Migration `backend/migrations/067_*.sql` (oder folgend); `DB_SCHEMA_VERSION` in `backend/version.py` anheben.
|
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 |
|
| **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 |
|
| **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 |
|
| **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 |
|
| **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 |
|
| **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:** 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:** 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).
|
**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`.
|
||||||
|
|
||||||
|
|
|
||||||
120
.claude/docs/working/AI_SKILL_RETRIEVAL_PROFILES_SPEC.md
Normal file
120
.claude/docs/working/AI_SKILL_RETRIEVAL_PROFILES_SPEC.md
Normal file
|
|
@ -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.
|
||||||
|
|
@ -1,12 +1,16 @@
|
||||||
"""
|
"""
|
||||||
KI-Vorschlaege fuer Uebungsformular: Laedt Prompts aus ai_prompts, ruft OpenRouter auf.
|
KI-Vorschlaege fuer Uebungsformular: Laedt Prompts aus ai_prompts, ruft OpenRouter auf.
|
||||||
Keine persistente Aenderung an exercises — nur Response-DTO fuer das Frontend.
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import copy
|
||||||
import json
|
import json
|
||||||
|
import math
|
||||||
import re
|
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
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
|
@ -24,6 +28,26 @@ _LEGACY_SKILL_LEVEL_SLUG = {
|
||||||
}
|
}
|
||||||
_ALLOWED_SKILL_INTENSITY = frozenset({"niedrig", "mittel", "hoch"})
|
_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]:
|
def _normalize_exercise_skill_level(value) -> Optional[str]:
|
||||||
if value is None:
|
if value is None:
|
||||||
|
|
@ -50,12 +74,6 @@ def _normalize_exercise_skill_intensity(value) -> str:
|
||||||
return key
|
return key
|
||||||
return "mittel"
|
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:
|
def strip_html_to_plain(html: Optional[str], *, max_len: int = _MAX_PLAIN_FIELD) -> str:
|
||||||
if not html:
|
if not html:
|
||||||
|
|
@ -67,6 +85,399 @@ def strip_html_to_plain(html: Optional[str], *, max_len: int = _MAX_PLAIN_FIELD)
|
||||||
return t
|
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]]:
|
def _load_prompt_row(cur, slug: str) -> Optional[Dict[str, Any]]:
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"""
|
"""
|
||||||
|
|
@ -93,56 +504,17 @@ def _render_template(template: str, ctx: Dict[str, str]) -> str:
|
||||||
return out
|
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:
|
def _extract_json_array(text: str) -> Any:
|
||||||
s = text.strip()
|
s = text.strip()
|
||||||
if s.startswith("```"):
|
if s.startswith("```"):
|
||||||
s = re.sub(r"^```[a-zA-Z0-9]*\s*", "", s)
|
s = re.sub(r"^```[a-zA-Z0-9]*\s*", "", s)
|
||||||
if s.endswith("```"):
|
if s.endswith("```"):
|
||||||
s = s[:-3].strip()
|
s = s[:-3].strip()
|
||||||
# array whole string
|
|
||||||
if s.startswith("["):
|
if s.startswith("["):
|
||||||
end = s.rfind("]")
|
end = s.rfind("]")
|
||||||
if end > 0:
|
if end > 0:
|
||||||
s = s[: end + 1]
|
s = s[: end + 1]
|
||||||
return json.loads(s)
|
return json.loads(s)
|
||||||
# object wrapping array
|
|
||||||
if s.startswith("{"):
|
if s.startswith("{"):
|
||||||
obj = json.loads(s)
|
obj = json.loads(s)
|
||||||
if isinstance(obj, dict):
|
if isinstance(obj, dict):
|
||||||
|
|
@ -219,7 +591,6 @@ def _sanitize_skill_entries(cur, rows: Any) -> List[Dict[str, Any]]:
|
||||||
item["confidence"] = conf_f
|
item["confidence"] = conf_f
|
||||||
out.append(item)
|
out.append(item)
|
||||||
|
|
||||||
# max 5
|
|
||||||
return out[:5]
|
return out[:5]
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -240,6 +611,7 @@ def run_exercise_ai_suggestion(
|
||||||
goal: Optional[str],
|
goal: Optional[str],
|
||||||
execution: Optional[str],
|
execution: Optional[str],
|
||||||
focus_area_hint: Optional[str],
|
focus_area_hint: Optional[str],
|
||||||
|
focus_areas_context: Optional[Sequence[Tuple[int, bool]]] = None,
|
||||||
want_summary: bool,
|
want_summary: bool,
|
||||||
want_skills: bool,
|
want_skills: bool,
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
|
|
@ -285,7 +657,14 @@ def run_exercise_ai_suggestion(
|
||||||
status_code=503,
|
status_code=503,
|
||||||
detail="Prompt exercise_skill_suggestions nicht aktiv oder fehlt in DB.",
|
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 = {
|
ctx = {
|
||||||
"exercise_title": t_title or "-",
|
"exercise_title": t_title or "-",
|
||||||
"exercise_focus_area": focus or "-",
|
"exercise_focus_area": focus or "-",
|
||||||
|
|
@ -318,3 +697,10 @@ def run_exercise_ai_suggestion(
|
||||||
result["skills"] = skills
|
result["skills"] = skills
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"build_contextual_skills_catalog_block",
|
||||||
|
"run_exercise_ai_suggestion",
|
||||||
|
"strip_html_to_plain",
|
||||||
|
]
|
||||||
|
|
|
||||||
125
backend/migrations/068_ai_skill_retrieval_profiles.sql
Normal file
125
backend/migrations/068_ai_skill_retrieval_profiles.sql
Normal file
|
|
@ -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;
|
||||||
|
|
@ -358,11 +358,22 @@ class ExerciseMediaFromAsset(BaseModel):
|
||||||
media_type: Optional[str] = None
|
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):
|
class ExerciseAiSuggestBody(BaseModel):
|
||||||
title: Optional[str] = Field(None, max_length=300)
|
title: Optional[str] = Field(None, max_length=300)
|
||||||
goal: Optional[str] = Field(None, max_length=64000)
|
goal: Optional[str] = Field(None, max_length=64000)
|
||||||
execution: Optional[str] = Field(None, max_length=128000)
|
execution: Optional[str] = Field(None, max_length=128000)
|
||||||
focus_area_hint: Optional[str] = Field(None, max_length=1200)
|
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_summary: bool = True
|
||||||
include_skills: 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:
|
def _focus_area_hint_from_detail(exercise: Dict[str, Any]) -> str:
|
||||||
parts: List[str] = []
|
parts: List[str] = []
|
||||||
for row in exercise.get("focus_areas") or []:
|
for row in exercise.get("focus_areas") or []:
|
||||||
|
|
@ -2279,12 +2306,17 @@ def exercise_ai_suggest_endpoint(
|
||||||
_ = tenant.profile_id
|
_ = tenant.profile_id
|
||||||
with get_db() as conn:
|
with get_db() as conn:
|
||||||
cur = get_cursor(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(
|
payload = run_exercise_ai_suggestion(
|
||||||
cur,
|
cur,
|
||||||
title=(body.title or "").strip(),
|
title=(body.title or "").strip(),
|
||||||
goal=body.goal,
|
goal=body.goal,
|
||||||
execution=body.execution,
|
execution=body.execution,
|
||||||
focus_area_hint=(body.focus_area_hint or "").strip() or None,
|
focus_area_hint=(body.focus_area_hint or "").strip() or None,
|
||||||
|
focus_areas_context=fctx,
|
||||||
want_summary=body.include_summary,
|
want_summary=body.include_summary,
|
||||||
want_skills=body.include_skills,
|
want_skills=body.include_skills,
|
||||||
)
|
)
|
||||||
|
|
@ -2310,6 +2342,7 @@ def exercise_ai_regenerate_endpoint(
|
||||||
raise HTTPException(status_code=404, detail="Übung nicht gefunden")
|
raise HTTPException(status_code=404, detail="Übung nicht gefunden")
|
||||||
|
|
||||||
focus = _focus_area_hint_from_detail(exercise)
|
focus = _focus_area_hint_from_detail(exercise)
|
||||||
|
fctx = _focus_areas_ai_ctx_from_detail(exercise)
|
||||||
|
|
||||||
payload = run_exercise_ai_suggestion(
|
payload = run_exercise_ai_suggestion(
|
||||||
cur,
|
cur,
|
||||||
|
|
@ -2317,6 +2350,7 @@ def exercise_ai_regenerate_endpoint(
|
||||||
goal=exercise.get("goal"),
|
goal=exercise.get("goal"),
|
||||||
execution=exercise.get("execution"),
|
execution=exercise.get("execution"),
|
||||||
focus_area_hint=focus or None,
|
focus_area_hint=focus or None,
|
||||||
|
focus_areas_context=fctx or None,
|
||||||
want_summary=want_summary,
|
want_summary=want_summary,
|
||||||
want_skills=want_skills,
|
want_skills=want_skills,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
# Shinkan Jinkendo Version Information
|
# Shinkan Jinkendo Version Information
|
||||||
|
|
||||||
APP_VERSION = "0.8.152"
|
APP_VERSION = "0.8.153"
|
||||||
BUILD_DATE = "2026-05-22"
|
BUILD_DATE = "2026-05-29"
|
||||||
DB_SCHEMA_VERSION = "20260522067"
|
DB_SCHEMA_VERSION = "20260529068"
|
||||||
|
|
||||||
MODULE_VERSIONS = {
|
MODULE_VERSIONS = {
|
||||||
"legal_documents": "1.4.0", # Admin: Live-Vorschau pro Abschnitt + modale Vollvorschau (Editor + Dokumentenliste)
|
"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
|
"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
|
"skill_profiles": "1.0.0", # Phase 3: gewichtetes Fähigkeiten-Profil + skill-discovery/suggestions
|
||||||
"methods": "0.1.0",
|
"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_units": "0.4.0", # POST .../publish-to-framework: Ablauf aus geplanter Einheit → Rahmen-Slot-Blueprint
|
||||||
"training_programs": "0.1.0",
|
"training_programs": "0.1.0",
|
||||||
"planning": "0.15.0", # Vorlagen: Strukturvorschau, Bearbeiten inkl. Split-Sessions + Beschreibung
|
"planning": "0.15.0", # Vorlagen: Strukturvorschau, Bearbeiten inkl. Split-Sessions + Beschreibung
|
||||||
|
|
@ -37,6 +37,15 @@ MODULE_VERSIONS = {
|
||||||
}
|
}
|
||||||
|
|
||||||
CHANGELOG = [
|
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",
|
"version": "0.8.152",
|
||||||
"date": "2026-05-22",
|
"date": "2026-05-22",
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
# Shinkan Jinkendo – Entwicklungsstand & Handover
|
# Shinkan Jinkendo – Entwicklungsstand & Handover
|
||||||
|
|
||||||
**Stand:** 2026-05-20
|
**Stand:** 2026-05-29
|
||||||
**App-Version / DB-Schema:** App **`0.8.149`** (Einheiten-Editor Vollseite), DB-Schema **`20260515063`** — maßgeblich **`backend/version.py`**: `APP_VERSION`, `DB_SCHEMA_VERSION`
|
**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**.
|
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`)
|
- **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
|
- **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)
|
## 3. Trainingsrahmenprogramm & Planungs‑Blueprint (kurz)
|
||||||
|
|
|
||||||
|
|
@ -980,6 +980,18 @@ function ExerciseFormPageRoot() {
|
||||||
const snapshotSummaryHtml = formData.summary || ''
|
const snapshotSummaryHtml = formData.summary || ''
|
||||||
const snapshotSkills = cloneExerciseSkillRows(formData.skills)
|
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)
|
setAiSuggestBusy(true)
|
||||||
try {
|
try {
|
||||||
const res = await api.suggestExerciseAi({
|
const res = await api.suggestExerciseAi({
|
||||||
|
|
@ -987,6 +999,7 @@ function ExerciseFormPageRoot() {
|
||||||
goal: formData.goal || '',
|
goal: formData.goal || '',
|
||||||
execution: formData.execution || '',
|
execution: formData.execution || '',
|
||||||
focus_area_hint: focusHint || undefined,
|
focus_area_hint: focusHint || undefined,
|
||||||
|
focus_areas_context: focusAreasContext.length ? focusAreasContext : undefined,
|
||||||
include_summary: summaryOn,
|
include_summary: summaryOn,
|
||||||
include_skills: skillsOn,
|
include_skills: skillsOn,
|
||||||
})
|
})
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user