Implement AI Skill Retrieval Profiles and Enhance Exercise AI Functionality
All checks were successful
Deploy Development / deploy (push) Successful in 41s
Test Suite / pytest-backend (push) Successful in 38s
Test Suite / lint-backend (push) Successful in 1s
Test Suite / build-frontend (push) Successful in 12s
Test Suite / k6 /health Baseline (push) Successful in 34s
Test Suite / playwright-tests (push) Successful in 1m13s
All checks were successful
Deploy Development / deploy (push) Successful in 41s
Test Suite / pytest-backend (push) Successful in 38s
Test Suite / lint-backend (push) Successful in 1s
Test Suite / build-frontend (push) Successful in 12s
Test Suite / k6 /health Baseline (push) Successful in 34s
Test Suite / playwright-tests (push) Successful in 1m13s
- Introduced migration 068 for `ai_skill_retrieval_profiles`, enabling configurable weights and quotes for skill catalog prioritization in exercise AI suggestions. - Updated the `POST /api/exercises/ai/suggest` endpoint to include an optional `focus_areas_context` field, allowing for enhanced context in AI-generated suggestions. - Enhanced the `exercise_ai` module to utilize context-based skill selection, incorporating scoring, category caps, and keyword patches for improved AI responses. - Updated the ExerciseFormPageRoot component to pass focus area context to the AI suggestion API, streamlining user interaction with AI-generated content. - Incremented version numbers in `backend/version.py` to reflect the latest changes and ensure accurate tracking in the changelog.
This commit is contained in:
parent
e5291256d0
commit
294b09a5d9
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
||||
|
|
|
|||
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.
|
||||
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",
|
||||
]
|
||||
|
|
|
|||
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
|
||||
|
||||
|
||||
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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user