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

- 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:
Lars 2026-05-22 10:09:07 +02:00
parent 9be69ace5c
commit a28a9d399a
5 changed files with 427 additions and 67 deletions

View File

@ -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,

View File

@ -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()

View File

@ -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",

View File

@ -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, KategorieAnteilCaps (~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`
---

View File

@ -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 <Navigate to="/" replace />
const retrieval = editor?.retrieval
return (
<div className="app-page">
<AdminPageNav />
<h1 style={{ marginTop: 0 }}>KI Skill-Retrieval-Profile</h1>
<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).
Standardprofil ohne Fokusbereich; weitere Zeilen je <strong>aktivem</strong> Fokusbereich.
JSON-Feld <code>config</code> siehe{' '}
<code style={{ fontSize: '0.88em' }}>.claude/docs/working/AI_SKILL_RETRIEVAL_PROFILES_SPEC.md</code>.
Gewichtungen für den Skill-Katalog bei der <strong>Übungs-KI</strong> (OpenRouter).{' '}
<strong>Unterkategorien</strong> (z.B. Kondition, Selbstverteidigung): Multiplikatoren optional;{' '}
<strong>max. Anteil</strong> begrenzt den Listenanteil dieser Kategorie (1100&nbsp;%). Hauptgruppen
(Karate / Allgemein) separat einstellbar siehe auch{' '}
<span style={{ fontSize: '0.88em' }}>.claude/docs/working/AI_SKILL_RETRIEVAL_PROFILES_SPEC.md</span>.
</p>
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap', marginBottom: '1rem' }}>
@ -262,7 +363,7 @@ export default function AdminAiSkillRetrievalPage() {
</div>
)}
{editor && (
{editor && retrieval && (
<div
style={{
position: 'fixed',
@ -278,9 +379,9 @@ export default function AdminAiSkillRetrievalPage() {
<div
className="card"
style={{
maxWidth: '640px',
maxWidth: '920px',
width: '100%',
maxHeight: '90vh',
maxHeight: '92vh',
overflow: 'auto',
padding: '1.25rem 1.5rem',
}}
@ -406,20 +507,188 @@ export default function AdminAiSkillRetrievalPage() {
</label>
</div>
<div className="form-row">
<label className="form-label" htmlFor="arp-config">
Config (JSON)
</label>
<textarea
id="arp-config"
className="form-input"
style={{ fontFamily: 'ui-monospace, monospace', fontSize: '0.82rem', minHeight: '220px' }}
value={editor.configJson}
onChange={(e) => setEditor((ed) => (ed ? { ...ed, configJson: e.target.value } : ed))}
/>
<hr style={{ border: 'none', borderTop: '1px solid var(--border)', margin: '1.25rem 0' }} />
<h3 style={{ margin: '0 0 0.75rem', fontSize: '1rem' }}>Globale Ranking-Parameter</h3>
<div className="form-row" style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0.75rem' }}>
<div>
<label className="form-label" htmlFor="arp-imp">
importance_multiplier
</label>
<input
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 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&nbsp;%).
</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}>
Speichern
</button>