diff --git a/backend/exercise_ai.py b/backend/exercise_ai.py
index eb48e2f..ef31606 100644
--- a/backend/exercise_ai.py
+++ b/backend/exercise_ai.py
@@ -505,6 +505,36 @@ def _render_template(template: str, ctx: Dict[str, str]) -> str:
return out
+def _first_balanced_json_array(text: str) -> Optional[str]:
+ """Findet das erste vollständig geschlossene Top-Level-JSON-Array in beliebigem Fließtext."""
+ i = text.find("[")
+ if i < 0:
+ return None
+ depth = 0
+ in_str = False
+ esc = False
+ for j in range(i, len(text)):
+ ch = text[j]
+ if in_str:
+ if esc:
+ esc = False
+ elif ch == "\\":
+ esc = True
+ elif ch == '"':
+ in_str = False
+ continue
+ if ch == '"':
+ in_str = True
+ continue
+ if ch == "[":
+ depth += 1
+ elif ch == "]":
+ depth -= 1
+ if depth == 0:
+ return text[i : j + 1]
+ return None
+
+
def _extract_json_array(text: str) -> Any:
s = text.strip()
if s.startswith("```"):
@@ -658,6 +688,11 @@ def run_exercise_ai_suggestion(
except OpenRouterError as e:
raise HTTPException(status_code=502, detail=f"OpenRouter: {e}") from e
text = (raw or "").strip()
+ if not text:
+ raise HTTPException(
+ status_code=502,
+ detail="OpenRouter/KI lieferte eine leere Kurzfassung (kein Modelltext).",
+ )
if len(text) > _MAX_SUMMARY_CHARS:
text = text[: _MAX_SUMMARY_CHARS - 1].rstrip() + "…"
result["summary"] = {"text": text, "ai_generated": True, "model": model}
@@ -698,8 +733,17 @@ def run_exercise_ai_suggestion(
)
except OpenRouterError as e:
raise HTTPException(status_code=502, detail=f"OpenRouter: {e}") from e
+ body = (raw or "").strip()
+ if not body:
+ raise HTTPException(
+ status_code=502,
+ detail="OpenRouter/KI lieferte leeren Inhalt für Skill-JSON.",
+ )
+ frag = _first_balanced_json_array(body)
+ if frag:
+ body = frag
try:
- parsed = _extract_json_array(raw)
+ parsed = _extract_json_array(body)
except (json.JSONDecodeError, ValueError) as e:
raise HTTPException(
status_code=502,
diff --git a/backend/openrouter_chat.py b/backend/openrouter_chat.py
index 41c640a..abb57fb 100644
--- a/backend/openrouter_chat.py
+++ b/backend/openrouter_chat.py
@@ -10,6 +10,41 @@ from typing import Any, Dict, List, Optional
import httpx
+def _flatten_message_content(content: Any) -> str:
+ """
+ OpenAI-kompatibles Chat-Completion kann `content` als String oder als Liste
+ strukturierter Blöcke liefern (z. B. Anthropic über OpenRouter/Bedrock).
+ """
+ if content is None:
+ return ""
+ if isinstance(content, str):
+ return content.strip()
+ if isinstance(content, list):
+ parts: List[str] = []
+ for block in content:
+ if isinstance(block, str):
+ parts.append(block)
+ elif isinstance(block, dict):
+ t = block.get("type")
+ txt = block.get("text")
+ if txt is None and t == "text":
+ txt = block.get("content")
+ if isinstance(txt, str):
+ parts.append(txt)
+ elif txt is not None:
+ parts.append(str(txt))
+ return "".join(parts).strip()
+ if isinstance(content, dict):
+ txt = content.get("text")
+ if txt is None:
+ txt = content.get("content")
+ if isinstance(txt, str):
+ return txt.strip()
+ if isinstance(txt, list):
+ return _flatten_message_content(txt)
+ return str(content).strip()
+
+
class OpenRouterError(Exception):
"""Upstream or transport failure."""
@@ -83,14 +118,17 @@ def openrouter_chat_completion(
msg0 = choices[0] if choices else {}
inner = msg0.get("message") if isinstance(msg0, dict) else None
- content = ""
+ raw: Any = None
if isinstance(inner, dict):
- content = str(inner.get("content") or "")
+ raw = inner.get("content")
+ if raw is None and inner.get("refusal") is not None:
+ raw = inner.get("refusal")
elif isinstance(inner, str):
- content = inner
- elif isinstance(msg0.get("content"), str):
- content = msg0.get("content") or ""
+ raw = inner
+ if raw is None and isinstance(msg0, dict):
+ raw = msg0.get("content")
+ content = _flatten_message_content(raw)
return content.strip()
diff --git a/backend/version.py b/backend/version.py
index 4407d29..f286dc3 100644
--- a/backend/version.py
+++ b/backend/version.py
@@ -1,7 +1,7 @@
# Shinkan Jinkendo Version Information
-APP_VERSION = "0.8.155"
-BUILD_DATE = "2026-05-29"
+APP_VERSION = "0.8.156"
+BUILD_DATE = "2026-05-22"
DB_SCHEMA_VERSION = "20260529068"
MODULE_VERSIONS = {
@@ -23,7 +23,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.30.1", # exercise_ai: Skills-JSON max. 250 Eintraege, Sanitize bricht nach 5 gueltigen ab (Performance)
+ "exercises": "2.30.2", # OpenRouter strukturierte content-Bloecke (Claude4/Bedrock); exercise_ai Robustheit + klare 502 wenn leeres Modell JSON; Retrieval-Admin Formular Gewichte/Unterkategorien
"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
@@ -38,6 +38,15 @@ MODULE_VERSIONS = {
}
CHANGELOG = [
+ {
+ "version": "0.8.156",
+ "date": "2026-05-22",
+ "changes": [
+ "OpenRouter Client: Assistant-Content als Liste/Bloecke (Claude über Bedrock/OpenRouter)",
+ "exercise_ai: leere Modelantwort als 502; Skill-JSON aus Fließtext per balanciertem Array-Zuschnitt robuster parsen",
+ "Admin Retrieval-Profil: Gewichte/Unterkategorien per Formular ohne JSON-Editor",
+ ],
+ },
{
"version": "0.8.155",
"date": "2026-05-29",
diff --git a/docs/HANDOVER.md b/docs/HANDOVER.md
index 14b73e5..8e3f9d1 100644
--- a/docs/HANDOVER.md
+++ b/docs/HANDOVER.md
@@ -1,7 +1,7 @@
# Shinkan Jinkendo – Entwicklungsstand & Handover
**Stand:** 2026-05-29
-**App-Version / DB-Schema:** App **`0.8.154`** (KI Retrieval-Profil-Pflege im Admin), DB-Schema **`20260529068`** — maßgeblich **`backend/version.py`**: `APP_VERSION`, `DB_SCHEMA_VERSION`
+**App-Version / DB-Schema:** App **`0.8.156`** (KI Retrieval-Admin Formular · OpenRouter Inhaltsbloecke Fix), 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,13 +88,13 @@ 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.154**)
+### 2.8 KI Assistenz Übungen & Skill-Katalog-Retrieval (Stand **0.8.156**)
- **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 — **Pflege:** Superadmin **`GET/POST/PUT/DELETE /api/admin/ai-skill-retrieval-profiles*`** (`routers/ai_skill_retrieval_admin.py`)
-- **Frontend:** `ExerciseFormPageRoot.jsx` übergibt `focus_areas_context` aus Einordnung; KI-Übernahmedialog nach API-Antwort — **Pflege:** **`AdminAiSkillRetrievalPage.jsx`**, Route **`/admin/ai-skill-retrieval`** (Nav „KI Retrieval“, nur Superadmin)
+- **Frontend:** `ExerciseFormPageRoot.jsx` übergibt `focus_areas_context` aus Einordnung; KI-Übernahmedialog nach API-Antwort — **Pflege:** **`AdminAiSkillRetrievalPage.jsx`**, Route **`/admin/ai-skill-retrieval`** (Nav „KI Retrieval“, nur Superadmin): **Gewichte pro Haupt- und Unterkategorie ohne JSON-Editor**; OpenRouter-Parser für Claude/Bedrock-Inhaltsbloecke in `openrouter_chat.py`
---
diff --git a/frontend/src/pages/AdminAiSkillRetrievalPage.jsx b/frontend/src/pages/AdminAiSkillRetrievalPage.jsx
index 0b19571..cb55110 100644
--- a/frontend/src/pages/AdminAiSkillRetrievalPage.jsx
+++ b/frontend/src/pages/AdminAiSkillRetrievalPage.jsx
@@ -4,30 +4,6 @@ import { useAuth } from '../context/AuthContext'
import api from '../utils/api'
import AdminPageNav from '../components/AdminPageNav'
-/** Startvorlage — alle Schlüssel optional; Fehlwerte nutzen Fallbacks in exercise_ai (siehe Spec). */
-const DEFAULT_CONFIG_JSON = `{
- "version": 1,
- "importance_multiplier": 1,
- "text_overlap_bonus": 2,
- "main_slug_weights": { "karate": 1, "allgemeine": 1 },
- "category_slug_weights": {},
- "category_max_share": {},
- "main_min_share": {},
- "description_plain_max_len": 160,
- "karate_relevance_max_len": 72,
- "keyword_overrides": []
-}`
-
-function parseConfigObject(text) {
- const t = text.trim()
- if (!t) return {}
- const parsed = JSON.parse(t)
- if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
- throw new Error('Config muss ein JSON-Objekt sein.')
- }
- return parsed
-}
-
function formatDt(iso) {
if (!iso) return ''
try {
@@ -39,9 +15,128 @@ function formatDt(iso) {
}
}
+/** Bestehende Zusatzfelder aus der DB übernehmen (keine eigene Pflegeoberfläche). */
+function configExtrasFromRow(cfg) {
+ const c = cfg && typeof cfg === 'object' ? cfg : {}
+ return {
+ keyword_overrides: Array.isArray(c.keyword_overrides) ? c.keyword_overrides : [],
+ main_min_share: c.main_min_share && typeof c.main_min_share === 'object' ? { ...c.main_min_share } : {},
+ }
+}
+
+function buildRetrievalDraft(cfg, mainList, subList) {
+ const c = cfg && typeof cfg === 'object' ? cfg : {}
+ const mainW = c.main_slug_weights || {}
+ const catW = c.category_slug_weights || {}
+ const catCap = c.category_max_share || {}
+
+ let mains = (mainList || [])
+ .map((m) => {
+ const slug = String(m.slug || '').trim()
+ if (!slug) return null
+ const w = mainW[slug]
+ return {
+ slug,
+ name: String(m.name || '').trim() || slug,
+ weight: w != null && w !== '' ? String(w) : '1',
+ }
+ })
+ .filter(Boolean)
+
+ if (mains.length === 0) {
+ mains = ['karate', 'allgemeine'].map((slug) => ({
+ slug,
+ name: slug,
+ weight: mainW[slug] != null && mainW[slug] !== '' ? String(mainW[slug]) : '1',
+ }))
+ }
+
+ const categories = (subList || [])
+ .map((sc) => {
+ const slug = String(sc.slug || '').trim()
+ if (!slug) return null
+ const capNum = catCap[slug]
+ let maxSharePct = ''
+ if (capNum != null && capNum !== '') {
+ const v = Number(capNum)
+ if (Number.isFinite(v) && v > 0 && v <= 1) {
+ maxSharePct = String(Math.round(v * 1000) / 10)
+ } else if (Number.isFinite(v) && v > 1 && v <= 100) {
+ maxSharePct = String(v)
+ }
+ }
+ const w = catW[slug]
+ return {
+ slug,
+ label: [sc.main_category_name, sc.name].filter(Boolean).join(' · ') || sc.name || slug,
+ weight: w != null && w !== '' ? String(w) : '',
+ maxSharePct,
+ }
+ })
+ .filter(Boolean)
+ .sort((a, b) => a.label.localeCompare(b.label, 'de'))
+
+ return {
+ importanceMultiplier: c.importance_multiplier != null ? String(c.importance_multiplier) : '1',
+ textOverlapBonus: c.text_overlap_bonus != null ? String(c.text_overlap_bonus) : '2',
+ descMaxLen: c.description_plain_max_len != null ? String(c.description_plain_max_len) : '160',
+ karateRelMaxLen: c.karate_relevance_max_len != null ? String(c.karate_relevance_max_len) : '72',
+ mains,
+ categories,
+ }
+}
+
+function buildConfigPayload(extras, draft) {
+ const next = {
+ version: 1,
+ importance_multiplier: parseFloat(String(draft.importanceMultiplier).replace(',', '.')) || 1,
+ text_overlap_bonus: parseFloat(String(draft.textOverlapBonus).replace(',', '.')) || 2,
+ description_plain_max_len: Math.max(
+ 40,
+ Math.min(400, parseInt(String(draft.descMaxLen), 10) || 160),
+ ),
+ karate_relevance_max_len: Math.max(
+ 0,
+ Math.min(280, parseInt(String(draft.karateRelMaxLen), 10) || 0),
+ ),
+ main_slug_weights: {},
+ category_slug_weights: {},
+ category_max_share: {},
+ keyword_overrides: Array.isArray(extras.keyword_overrides) ? extras.keyword_overrides : [],
+ main_min_share:
+ extras.main_min_share && typeof extras.main_min_share === 'object' ? { ...extras.main_min_share } : {},
+ }
+
+ for (const m of draft.mains || []) {
+ if (!m.slug) continue
+ const w = parseFloat(String(m.weight).replace(',', '.'))
+ if (Number.isFinite(w) && w > 0) next.main_slug_weights[m.slug] = w
+ }
+ for (const slug of ['karate', 'allgemeine']) {
+ if (next.main_slug_weights[slug] == null) next.main_slug_weights[slug] = 1
+ }
+
+ for (const row of draft.categories || []) {
+ if (!row.slug) continue
+ const w = parseFloat(String(row.weight).replace(',', '.'))
+ if (Number.isFinite(w) && w > 0 && w !== 1) next.category_slug_weights[row.slug] = w
+
+ const capRaw = String(row.maxSharePct || '').trim().replace(',', '.')
+ if (capRaw) {
+ const v = parseFloat(capRaw)
+ if (Number.isFinite(v) && v > 0) {
+ const frac = v > 1 ? v / 100 : v
+ if (frac > 0 && frac <= 1) next.category_max_share[row.slug] = frac
+ }
+ }
+ }
+
+ return next
+}
+
/**
- * Pflege von ai_skill_retrieval_profiles (KI Skill-Katalog Gewichte/Quoten).
- * Nur Superadmin — Routen-Spiegel: PlatformAdminRoute.
+ * Pflege von ai_skill_retrieval_profiles — Gewichte pro Haupt- und Unterkategorie ohne JSON-Bearbeitung.
+ * Nur Superadmin (PlatformAdminRoute).
*/
export default function AdminAiSkillRetrievalPage() {
const { user } = useAuth()
@@ -49,19 +144,25 @@ export default function AdminAiSkillRetrievalPage() {
const [profiles, setProfiles] = useState([])
const [focusAreas, setFocusAreas] = useState([])
+ const [skillMainCatalog, setSkillMainCatalog] = useState([])
+ const [skillSubCatalog, setSkillSubCatalog] = useState([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [editor, setEditor] = useState(null)
const loadAll = useCallback(async () => {
- const [p, faRaw] = await Promise.all([
+ const [p, faRaw, mc, sc] = await Promise.all([
api.listAiSkillRetrievalProfiles(),
api.listFocusAreas(),
+ api.listSkillMainCategories(),
+ api.listSkillCategories({ status: 'active' }),
])
setProfiles(Array.isArray(p) ? p : [])
const fa = Array.isArray(faRaw) ? faRaw : []
setFocusAreas(fa.filter((a) => !a.status || String(a.status).toLowerCase() === 'active'))
+ setSkillMainCatalog(Array.isArray(mc) ? mc : [])
+ setSkillSubCatalog(Array.isArray(sc) ? sc : [])
}, [])
useEffect(() => {
@@ -91,11 +192,13 @@ export default function AdminAiSkillRetrievalPage() {
name: '',
description: '',
active: true,
- configJson: DEFAULT_CONFIG_JSON,
+ configExtras: { keyword_overrides: [], main_min_share: {} },
+ retrieval: buildRetrievalDraft({}, skillMainCatalog, skillSubCatalog),
})
}
const openEdit = (row) => {
+ const cfg = row.config && typeof row.config === 'object' ? row.config : {}
setEditor({
mode: 'edit',
id: row.id,
@@ -106,7 +209,8 @@ export default function AdminAiSkillRetrievalPage() {
name: row.name || '',
description: row.description || '',
active: !!row.active,
- configJson: JSON.stringify(row.config && typeof row.config === 'object' ? row.config : {}, null, 2),
+ configExtras: configExtrasFromRow(cfg),
+ retrieval: buildRetrievalDraft(cfg, skillMainCatalog, skillSubCatalog),
})
}
@@ -114,13 +218,7 @@ export default function AdminAiSkillRetrievalPage() {
const save = async () => {
if (!editor) return
- let cfg
- try {
- cfg = parseConfigObject(editor.configJson)
- } catch (e) {
- alert(e.message || String(e))
- return
- }
+ const cfg = buildConfigPayload(editor.configExtras, editor.retrieval)
const name = editor.name.trim()
if (name.length < 2) {
@@ -189,16 +287,19 @@ export default function AdminAiSkillRetrievalPage() {
if (!isSuperadmin) return
- Konfiguration für den Skill-Katalog-Kontext bei Übungs-KI (OpenRouter).
- Standardprofil ohne Fokusbereich; weitere Zeilen je aktivem Fokusbereich.
- JSON-Feld config siehe{' '}
- .claude/docs/working/AI_SKILL_RETRIEVAL_PROFILES_SPEC.md.
+ Gewichtungen für den Skill-Katalog bei der Übungs-KI (OpenRouter).{' '}
+ Unterkategorien (z. B. Kondition, Selbstverteidigung): Multiplikatoren optional;{' '}
+ max. Anteil begrenzt den Listenanteil dieser Kategorie (1–100 %). Hauptgruppen
+ (Karate / Allgemein) separat einstellbar — siehe auch{' '}
+ .claude/docs/working/AI_SKILL_RETRIEVAL_PROFILES_SPEC.md.