From a28a9d399a0df280bd36de9e67749a2138141b59 Mon Sep 17 00:00:00 2001 From: Lars Date: Fri, 22 May 2026 10:09:07 +0200 Subject: [PATCH] Enhance exercise_ai and openrouter_chat modules with improved JSON handling and error management - Added a new function `_first_balanced_json_array` to extract the first complete top-level JSON array from arbitrary text, enhancing robustness in parsing. - Updated the `run_exercise_ai_suggestion` function to raise clear HTTP exceptions for empty responses from the OpenRouter, ensuring better error handling. - Introduced `_flatten_message_content` in the `openrouter_chat` module to handle structured message content from OpenAI, improving compatibility with various content formats. - Incremented the application version to 0.8.156 and updated the changelog to reflect these enhancements, including improved error messages and JSON parsing capabilities. --- backend/exercise_ai.py | 46 ++- backend/openrouter_chat.py | 48 ++- backend/version.py | 15 +- docs/HANDOVER.md | 6 +- .../src/pages/AdminAiSkillRetrievalPage.jsx | 379 +++++++++++++++--- 5 files changed, 427 insertions(+), 67 deletions(-) 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 + const retrieval = editor?.retrieval + return (

KI Skill-Retrieval-Profile

- 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.

@@ -262,7 +363,7 @@ export default function AdminAiSkillRetrievalPage() {
)} - {editor && ( + {editor && retrieval && (
-
- -