KI Implementierung (MVP) auf Übungen #46
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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 (1–100 %). 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 %).
|
||||
</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>
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user