Enhance exercise_ai and openrouter_chat modules with improved JSON handling and error management
All checks were successful
Deploy Development / deploy (push) Successful in 42s
Test Suite / pytest-backend (push) Successful in 38s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 13s
Test Suite / k6 /health Baseline (push) Successful in 33s
Test Suite / playwright-tests (push) Successful in 1m18s
All checks were successful
Deploy Development / deploy (push) Successful in 42s
Test Suite / pytest-backend (push) Successful in 38s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 13s
Test Suite / k6 /health Baseline (push) Successful in 33s
Test Suite / playwright-tests (push) Successful in 1m18s
- 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.
This commit is contained in:
parent
9be69ace5c
commit
a28a9d399a
|
|
@ -505,6 +505,36 @@ def _render_template(template: str, ctx: Dict[str, str]) -> str:
|
||||||
return out
|
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:
|
def _extract_json_array(text: str) -> Any:
|
||||||
s = text.strip()
|
s = text.strip()
|
||||||
if s.startswith("```"):
|
if s.startswith("```"):
|
||||||
|
|
@ -658,6 +688,11 @@ def run_exercise_ai_suggestion(
|
||||||
except OpenRouterError as e:
|
except OpenRouterError as e:
|
||||||
raise HTTPException(status_code=502, detail=f"OpenRouter: {e}") from e
|
raise HTTPException(status_code=502, detail=f"OpenRouter: {e}") from e
|
||||||
text = (raw or "").strip()
|
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:
|
if len(text) > _MAX_SUMMARY_CHARS:
|
||||||
text = text[: _MAX_SUMMARY_CHARS - 1].rstrip() + "…"
|
text = text[: _MAX_SUMMARY_CHARS - 1].rstrip() + "…"
|
||||||
result["summary"] = {"text": text, "ai_generated": True, "model": model}
|
result["summary"] = {"text": text, "ai_generated": True, "model": model}
|
||||||
|
|
@ -698,8 +733,17 @@ def run_exercise_ai_suggestion(
|
||||||
)
|
)
|
||||||
except OpenRouterError as e:
|
except OpenRouterError as e:
|
||||||
raise HTTPException(status_code=502, detail=f"OpenRouter: {e}") from 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:
|
try:
|
||||||
parsed = _extract_json_array(raw)
|
parsed = _extract_json_array(body)
|
||||||
except (json.JSONDecodeError, ValueError) as e:
|
except (json.JSONDecodeError, ValueError) as e:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=502,
|
status_code=502,
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,41 @@ from typing import Any, Dict, List, Optional
|
||||||
import httpx
|
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):
|
class OpenRouterError(Exception):
|
||||||
"""Upstream or transport failure."""
|
"""Upstream or transport failure."""
|
||||||
|
|
||||||
|
|
@ -83,14 +118,17 @@ def openrouter_chat_completion(
|
||||||
|
|
||||||
msg0 = choices[0] if choices else {}
|
msg0 = choices[0] if choices else {}
|
||||||
inner = msg0.get("message") if isinstance(msg0, dict) else None
|
inner = msg0.get("message") if isinstance(msg0, dict) else None
|
||||||
content = ""
|
raw: Any = None
|
||||||
if isinstance(inner, dict):
|
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):
|
elif isinstance(inner, str):
|
||||||
content = inner
|
raw = inner
|
||||||
elif isinstance(msg0.get("content"), str):
|
if raw is None and isinstance(msg0, dict):
|
||||||
content = msg0.get("content") or ""
|
raw = msg0.get("content")
|
||||||
|
|
||||||
|
content = _flatten_message_content(raw)
|
||||||
return content.strip()
|
return content.strip()
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
# Shinkan Jinkendo Version Information
|
# Shinkan Jinkendo Version Information
|
||||||
|
|
||||||
APP_VERSION = "0.8.155"
|
APP_VERSION = "0.8.156"
|
||||||
BUILD_DATE = "2026-05-29"
|
BUILD_DATE = "2026-05-22"
|
||||||
DB_SCHEMA_VERSION = "20260529068"
|
DB_SCHEMA_VERSION = "20260529068"
|
||||||
|
|
||||||
MODULE_VERSIONS = {
|
MODULE_VERSIONS = {
|
||||||
|
|
@ -23,7 +23,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.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_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
|
||||||
|
|
@ -38,6 +38,15 @@ MODULE_VERSIONS = {
|
||||||
}
|
}
|
||||||
|
|
||||||
CHANGELOG = [
|
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",
|
"version": "0.8.155",
|
||||||
"date": "2026-05-29",
|
"date": "2026-05-29",
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
# Shinkan Jinkendo – Entwicklungsstand & Handover
|
# Shinkan Jinkendo – Entwicklungsstand & Handover
|
||||||
|
|
||||||
**Stand:** 2026-05-29
|
**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**.
|
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`)
|
- **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.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
|
- **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)
|
- **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)
|
- **`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`)
|
- **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`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,30 +4,6 @@ import { useAuth } from '../context/AuthContext'
|
||||||
import api from '../utils/api'
|
import api from '../utils/api'
|
||||||
import AdminPageNav from '../components/AdminPageNav'
|
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) {
|
function formatDt(iso) {
|
||||||
if (!iso) return ''
|
if (!iso) return ''
|
||||||
try {
|
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).
|
* Pflege von ai_skill_retrieval_profiles — Gewichte pro Haupt- und Unterkategorie ohne JSON-Bearbeitung.
|
||||||
* Nur Superadmin — Routen-Spiegel: PlatformAdminRoute.
|
* Nur Superadmin (PlatformAdminRoute).
|
||||||
*/
|
*/
|
||||||
export default function AdminAiSkillRetrievalPage() {
|
export default function AdminAiSkillRetrievalPage() {
|
||||||
const { user } = useAuth()
|
const { user } = useAuth()
|
||||||
|
|
@ -49,19 +144,25 @@ export default function AdminAiSkillRetrievalPage() {
|
||||||
|
|
||||||
const [profiles, setProfiles] = useState([])
|
const [profiles, setProfiles] = useState([])
|
||||||
const [focusAreas, setFocusAreas] = useState([])
|
const [focusAreas, setFocusAreas] = useState([])
|
||||||
|
const [skillMainCatalog, setSkillMainCatalog] = useState([])
|
||||||
|
const [skillSubCatalog, setSkillSubCatalog] = useState([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
const [editor, setEditor] = useState(null)
|
const [editor, setEditor] = useState(null)
|
||||||
|
|
||||||
const loadAll = useCallback(async () => {
|
const loadAll = useCallback(async () => {
|
||||||
const [p, faRaw] = await Promise.all([
|
const [p, faRaw, mc, sc] = await Promise.all([
|
||||||
api.listAiSkillRetrievalProfiles(),
|
api.listAiSkillRetrievalProfiles(),
|
||||||
api.listFocusAreas(),
|
api.listFocusAreas(),
|
||||||
|
api.listSkillMainCategories(),
|
||||||
|
api.listSkillCategories({ status: 'active' }),
|
||||||
])
|
])
|
||||||
setProfiles(Array.isArray(p) ? p : [])
|
setProfiles(Array.isArray(p) ? p : [])
|
||||||
const fa = Array.isArray(faRaw) ? faRaw : []
|
const fa = Array.isArray(faRaw) ? faRaw : []
|
||||||
setFocusAreas(fa.filter((a) => !a.status || String(a.status).toLowerCase() === 'active'))
|
setFocusAreas(fa.filter((a) => !a.status || String(a.status).toLowerCase() === 'active'))
|
||||||
|
setSkillMainCatalog(Array.isArray(mc) ? mc : [])
|
||||||
|
setSkillSubCatalog(Array.isArray(sc) ? sc : [])
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -91,11 +192,13 @@ export default function AdminAiSkillRetrievalPage() {
|
||||||
name: '',
|
name: '',
|
||||||
description: '',
|
description: '',
|
||||||
active: true,
|
active: true,
|
||||||
configJson: DEFAULT_CONFIG_JSON,
|
configExtras: { keyword_overrides: [], main_min_share: {} },
|
||||||
|
retrieval: buildRetrievalDraft({}, skillMainCatalog, skillSubCatalog),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const openEdit = (row) => {
|
const openEdit = (row) => {
|
||||||
|
const cfg = row.config && typeof row.config === 'object' ? row.config : {}
|
||||||
setEditor({
|
setEditor({
|
||||||
mode: 'edit',
|
mode: 'edit',
|
||||||
id: row.id,
|
id: row.id,
|
||||||
|
|
@ -106,7 +209,8 @@ export default function AdminAiSkillRetrievalPage() {
|
||||||
name: row.name || '',
|
name: row.name || '',
|
||||||
description: row.description || '',
|
description: row.description || '',
|
||||||
active: !!row.active,
|
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 () => {
|
const save = async () => {
|
||||||
if (!editor) return
|
if (!editor) return
|
||||||
let cfg
|
const cfg = buildConfigPayload(editor.configExtras, editor.retrieval)
|
||||||
try {
|
|
||||||
cfg = parseConfigObject(editor.configJson)
|
|
||||||
} catch (e) {
|
|
||||||
alert(e.message || String(e))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const name = editor.name.trim()
|
const name = editor.name.trim()
|
||||||
if (name.length < 2) {
|
if (name.length < 2) {
|
||||||
|
|
@ -189,16 +287,19 @@ export default function AdminAiSkillRetrievalPage() {
|
||||||
|
|
||||||
if (!isSuperadmin) return <Navigate to="/" replace />
|
if (!isSuperadmin) return <Navigate to="/" replace />
|
||||||
|
|
||||||
|
const retrieval = editor?.retrieval
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="app-page">
|
<div className="app-page">
|
||||||
<AdminPageNav />
|
<AdminPageNav />
|
||||||
|
|
||||||
<h1 style={{ marginTop: 0 }}>KI Skill-Retrieval-Profile</h1>
|
<h1 style={{ marginTop: 0 }}>KI Skill-Retrieval-Profile</h1>
|
||||||
<p style={{ color: 'var(--text2)', maxWidth: '52rem', lineHeight: 1.55, marginBottom: '1rem' }}>
|
<p style={{ color: 'var(--text2)', maxWidth: '52rem', lineHeight: 1.55, marginBottom: '1rem' }}>
|
||||||
Konfiguration für den Skill-Katalog-Kontext bei <strong>Übungs-KI</strong> (OpenRouter).
|
Gewichtungen für den Skill-Katalog bei der <strong>Übungs-KI</strong> (OpenRouter).{' '}
|
||||||
Standardprofil ohne Fokusbereich; weitere Zeilen je <strong>aktivem</strong> Fokusbereich.
|
<strong>Unterkategorien</strong> (z. B. Kondition, Selbstverteidigung): Multiplikatoren optional;{' '}
|
||||||
JSON-Feld <code>config</code> siehe{' '}
|
<strong>max. Anteil</strong> begrenzt den Listenanteil dieser Kategorie (1–100 %). Hauptgruppen
|
||||||
<code style={{ fontSize: '0.88em' }}>.claude/docs/working/AI_SKILL_RETRIEVAL_PROFILES_SPEC.md</code>.
|
(Karate / Allgemein) separat einstellbar — siehe auch{' '}
|
||||||
|
<span style={{ fontSize: '0.88em' }}>.claude/docs/working/AI_SKILL_RETRIEVAL_PROFILES_SPEC.md</span>.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap', marginBottom: '1rem' }}>
|
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap', marginBottom: '1rem' }}>
|
||||||
|
|
@ -262,7 +363,7 @@ export default function AdminAiSkillRetrievalPage() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{editor && (
|
{editor && retrieval && (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
position: 'fixed',
|
position: 'fixed',
|
||||||
|
|
@ -278,9 +379,9 @@ export default function AdminAiSkillRetrievalPage() {
|
||||||
<div
|
<div
|
||||||
className="card"
|
className="card"
|
||||||
style={{
|
style={{
|
||||||
maxWidth: '640px',
|
maxWidth: '920px',
|
||||||
width: '100%',
|
width: '100%',
|
||||||
maxHeight: '90vh',
|
maxHeight: '92vh',
|
||||||
overflow: 'auto',
|
overflow: 'auto',
|
||||||
padding: '1.25rem 1.5rem',
|
padding: '1.25rem 1.5rem',
|
||||||
}}
|
}}
|
||||||
|
|
@ -406,20 +507,188 @@ export default function AdminAiSkillRetrievalPage() {
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="form-row">
|
<hr style={{ border: 'none', borderTop: '1px solid var(--border)', margin: '1.25rem 0' }} />
|
||||||
<label className="form-label" htmlFor="arp-config">
|
|
||||||
Config (JSON)
|
<h3 style={{ margin: '0 0 0.75rem', fontSize: '1rem' }}>Globale Ranking-Parameter</h3>
|
||||||
</label>
|
<div className="form-row" style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0.75rem' }}>
|
||||||
<textarea
|
<div>
|
||||||
id="arp-config"
|
<label className="form-label" htmlFor="arp-imp">
|
||||||
className="form-input"
|
importance_multiplier
|
||||||
style={{ fontFamily: 'ui-monospace, monospace', fontSize: '0.82rem', minHeight: '220px' }}
|
</label>
|
||||||
value={editor.configJson}
|
<input
|
||||||
onChange={(e) => setEditor((ed) => (ed ? { ...ed, configJson: e.target.value } : ed))}
|
id="arp-imp"
|
||||||
/>
|
className="form-input"
|
||||||
|
inputMode="decimal"
|
||||||
|
value={retrieval.importanceMultiplier}
|
||||||
|
onChange={(e) =>
|
||||||
|
setEditor((ed) =>
|
||||||
|
ed?.retrieval ? { ...ed, retrieval: { ...ed.retrieval, importanceMultiplier: e.target.value } } : ed
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="form-label" htmlFor="arp-tob">
|
||||||
|
text_overlap_bonus
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="arp-tob"
|
||||||
|
className="form-input"
|
||||||
|
inputMode="decimal"
|
||||||
|
value={retrieval.textOverlapBonus}
|
||||||
|
onChange={(e) =>
|
||||||
|
setEditor((ed) =>
|
||||||
|
ed?.retrieval ? { ...ed, retrieval: { ...ed.retrieval, textOverlapBonus: e.target.value } } : ed
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="form-row" style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0.75rem' }}>
|
||||||
|
<div>
|
||||||
|
<label className="form-label" htmlFor="arp-dmax">
|
||||||
|
Beschreibung max. Zeichen (je Zeile im Katalog)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="arp-dmax"
|
||||||
|
className="form-input"
|
||||||
|
inputMode="numeric"
|
||||||
|
value={retrieval.descMaxLen}
|
||||||
|
onChange={(e) =>
|
||||||
|
setEditor((ed) =>
|
||||||
|
ed?.retrieval ? { ...ed, retrieval: { ...ed.retrieval, descMaxLen: e.target.value } } : ed
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="form-label" htmlFor="arp-krm">
|
||||||
|
Karate-Relevanz max. Zeichen (0 = weglassen)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="arp-krm"
|
||||||
|
className="form-input"
|
||||||
|
inputMode="numeric"
|
||||||
|
value={retrieval.karateRelMaxLen}
|
||||||
|
onChange={(e) =>
|
||||||
|
setEditor((ed) =>
|
||||||
|
ed?.retrieval ? { ...ed, retrieval: { ...ed.retrieval, karateRelMaxLen: e.target.value } } : ed
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ display: 'flex', gap: '0.5rem', marginTop: '1rem', flexWrap: 'wrap' }}>
|
<h3 style={{ margin: '1.25rem 0 0.5rem', fontSize: '1rem' }}>Hauptkategorien (Multiplikatoren)</h3>
|
||||||
|
<div className="card" style={{ padding: 0, overflow: 'auto' }}>
|
||||||
|
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.88rem' }}>
|
||||||
|
<thead>
|
||||||
|
<tr style={{ textAlign: 'left', borderBottom: '1px solid var(--border)' }}>
|
||||||
|
<th style={{ padding: '8px 10px' }}>Bezeichnung</th>
|
||||||
|
<th style={{ padding: '8px 10px', width: '30%' }}>Multiplikator</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{retrieval.mains.map((m) => (
|
||||||
|
<tr key={m.slug} style={{ borderBottom: '1px solid var(--border)' }}>
|
||||||
|
<td style={{ padding: '8px 10px' }}>
|
||||||
|
<div style={{ fontWeight: 600 }}>{m.name}</div>
|
||||||
|
<div style={{ color: 'var(--text3)', fontSize: '0.8rem' }}>{m.slug}</div>
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '8px 10px' }}>
|
||||||
|
<input
|
||||||
|
className="form-input"
|
||||||
|
inputMode="decimal"
|
||||||
|
value={m.weight}
|
||||||
|
aria-label={`Gewicht ${m.slug}`}
|
||||||
|
onChange={(e) =>
|
||||||
|
setEditor((ed) => {
|
||||||
|
if (!ed?.retrieval) return ed
|
||||||
|
const nm = ed.retrieval.mains.map((row) =>
|
||||||
|
row.slug === m.slug ? { ...row, weight: e.target.value } : row
|
||||||
|
)
|
||||||
|
return { ...ed, retrieval: { ...ed.retrieval, mains: nm } }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 style={{ margin: '1.25rem 0 0.5rem', fontSize: '1rem' }}>
|
||||||
|
Unterkategorien (optional: Gewicht · max. Listenanteil)
|
||||||
|
</h3>
|
||||||
|
<p style={{ margin: '0 0 0.5rem', fontSize: '0.82rem', color: 'var(--text3)', lineHeight: 1.45 }}>
|
||||||
|
Gewicht leer oder 1 = neutral. „Max. Anteil“ in Prozent der Katalogzeilen für diese Unterkategorie
|
||||||
|
(z. B. 25 für 25 %).
|
||||||
|
</p>
|
||||||
|
<div style={{ maxHeight: '280px', overflow: 'auto', border: '1px solid var(--border)', borderRadius: '8px' }}>
|
||||||
|
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.86rem' }}>
|
||||||
|
<thead style={{ position: 'sticky', top: 0, background: 'var(--surface2)', zIndex: 1 }}>
|
||||||
|
<tr style={{ textAlign: 'left', borderBottom: '1px solid var(--border)' }}>
|
||||||
|
<th style={{ padding: '8px 10px' }}>Unterkategorie</th>
|
||||||
|
<th style={{ padding: '8px 10px', width: '110px' }}>Gewicht</th>
|
||||||
|
<th style={{ padding: '8px 10px', width: '120px' }}>Max. %</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{retrieval.categories.map((c) => (
|
||||||
|
<tr key={c.slug} style={{ borderBottom: '1px solid var(--border)' }}>
|
||||||
|
<td style={{ padding: '6px 10px', verticalAlign: 'middle' }}>
|
||||||
|
<span title={c.slug}>{c.label}</span>
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '6px 8px', verticalAlign: 'middle' }}>
|
||||||
|
<input
|
||||||
|
className="form-input"
|
||||||
|
placeholder="1"
|
||||||
|
inputMode="decimal"
|
||||||
|
value={c.weight}
|
||||||
|
aria-label={`Gewicht ${c.slug}`}
|
||||||
|
onChange={(e) =>
|
||||||
|
setEditor((ed) => {
|
||||||
|
if (!ed?.retrieval) return ed
|
||||||
|
const nc = ed.retrieval.categories.map((row) =>
|
||||||
|
row.slug === c.slug ? { ...row, weight: e.target.value } : row
|
||||||
|
)
|
||||||
|
return { ...ed, retrieval: { ...ed.retrieval, categories: nc } }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '6px 8px', verticalAlign: 'middle' }}>
|
||||||
|
<input
|
||||||
|
className="form-input"
|
||||||
|
placeholder="—"
|
||||||
|
inputMode="decimal"
|
||||||
|
value={c.maxSharePct}
|
||||||
|
aria-label={`Max. Anteil ${c.slug}`}
|
||||||
|
onChange={(e) =>
|
||||||
|
setEditor((ed) => {
|
||||||
|
if (!ed?.retrieval) return ed
|
||||||
|
const nc = ed.retrieval.categories.map((row) =>
|
||||||
|
row.slug === c.slug ? { ...row, maxSharePct: e.target.value } : row
|
||||||
|
)
|
||||||
|
return { ...ed, retrieval: { ...ed.retrieval, categories: nc } }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p style={{ margin: '1rem 0 0', fontSize: '0.82rem', color: 'var(--text3)', lineHeight: 1.45 }}>
|
||||||
|
Hinweis: <strong>Keyword-Overrides</strong> und etwaige <strong>main_min_share</strong>-Felder aus der Datenbank werden
|
||||||
|
beim Speichern mitgeschrieben ({editor.configExtras?.keyword_overrides?.length || 0} Overrides). Es gibt dafür
|
||||||
|
keine separate Maske.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', gap: '0.5rem', marginTop: '1.25rem', flexWrap: 'wrap' }}>
|
||||||
<button type="button" className="btn btn-primary" style={{ flex: '1 1 120px' }} onClick={save}>
|
<button type="button" className="btn btn-primary" style={{ flex: '1 1 120px' }} onClick={save}>
|
||||||
Speichern
|
Speichern
|
||||||
</button>
|
</button>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user