All checks were successful
Deploy Development / deploy (push) Successful in 42s
Test Suite / pytest-backend (push) Successful in 40s
Test Suite / lint-backend (push) Successful in 1s
Test Suite / build-frontend (push) Successful in 13s
Test Suite / k6 /health Baseline (push) Successful in 33s
Test Suite / playwright-tests (push) Successful in 1m14s
- Introduced new functions to load exercise goals and variant names in chunks, improving data retrieval efficiency. - Integrated semantic scoring into the ranking logic, allowing for more nuanced exercise suggestions based on semantic relevance. - Updated the planning exercise suggestion process to include semantic brief handling, enriching the context for exercise recommendations. - Adjusted the retrieval phase to incorporate dynamic retrieval weights based on semantic strength, enhancing the overall suggestion accuracy. - Incremented version to 0.8.186 and updated changelog to reflect these significant enhancements in planning AI functionality.
371 lines
13 KiB
JavaScript
371 lines
13 KiB
JavaScript
/**
|
|
* Planungs-KI Phase C3: Ziel → Übungspfad vorschlagen → in Progressionsgraph speichern.
|
|
*/
|
|
import React, { useCallback, useState } from 'react'
|
|
import api from '../utils/api'
|
|
|
|
function emptyPathStep() {
|
|
return { exerciseId: null, exerciseTitle: '', variantId: null, variants: [], reasons: [] }
|
|
}
|
|
|
|
function mapApiStepToRow(step) {
|
|
const variants = Array.isArray(step?.variants) ? step.variants : []
|
|
const rawVid = step?.variant_id ?? step?.suggested_variant_id ?? null
|
|
const variantId =
|
|
rawVid != null && Number.isFinite(Number(rawVid)) && Number(rawVid) > 0 ? Number(rawVid) : null
|
|
return {
|
|
exerciseId: step?.exercise_id != null ? Number(step.exercise_id) : null,
|
|
exerciseTitle: (step?.title || '').trim() || (step?.exercise_id ? `Übung #${step.exercise_id}` : ''),
|
|
variantId,
|
|
variants,
|
|
reasons: Array.isArray(step?.reasons) ? step.reasons : [],
|
|
isBridge: Boolean(step?.is_bridge),
|
|
semanticScore: step?.semantic_score,
|
|
}
|
|
}
|
|
|
|
export default function ExerciseProgressionPathBuilder({
|
|
graphId,
|
|
disabled = false,
|
|
onSaved,
|
|
}) {
|
|
const [goalQuery, setGoalQuery] = useState('')
|
|
const [maxSteps, setMaxSteps] = useState(5)
|
|
const [segmentNotes, setSegmentNotes] = useState('')
|
|
const [loading, setLoading] = useState(false)
|
|
const [saving, setSaving] = useState(false)
|
|
const [error, setError] = useState('')
|
|
const [targetSummary, setTargetSummary] = useState(null)
|
|
const [semanticBrief, setSemanticBrief] = useState(null)
|
|
const [pathQa, setPathQa] = useState(null)
|
|
const [pathSteps, setPathSteps] = useState([])
|
|
|
|
const patchStep = useCallback((idx, patch) => {
|
|
setPathSteps((prev) => prev.map((row, i) => (i === idx ? { ...row, ...patch } : row)))
|
|
}, [])
|
|
|
|
const removeStep = useCallback((idx) => {
|
|
setPathSteps((prev) => (prev.length <= 2 ? prev : prev.filter((_, i) => i !== idx)))
|
|
}, [])
|
|
|
|
const moveStep = useCallback((idx, dir) => {
|
|
setPathSteps((prev) => {
|
|
const j = idx + dir
|
|
if (j < 0 || j >= prev.length) return prev
|
|
const next = [...prev]
|
|
const t = next[idx]
|
|
next[idx] = next[j]
|
|
next[j] = t
|
|
return next
|
|
})
|
|
}, [])
|
|
|
|
const suggestPath = async () => {
|
|
const q = (goalQuery || '').trim()
|
|
if (q.length < 3) {
|
|
alert('Ziel-Anfrage: mindestens 3 Zeichen.')
|
|
return
|
|
}
|
|
if (!graphId) {
|
|
alert('Zuerst einen Graphen wählen.')
|
|
return
|
|
}
|
|
setLoading(true)
|
|
setError('')
|
|
try {
|
|
const res = await api.suggestProgressionPath({
|
|
query: q,
|
|
max_steps: Number(maxSteps),
|
|
include_llm_intent: true,
|
|
include_path_qa: true,
|
|
include_llm_path_qa: true,
|
|
progression_graph_id: Number(graphId),
|
|
})
|
|
const rows = (Array.isArray(res?.steps) ? res.steps : []).map(mapApiStepToRow)
|
|
if (rows.length < 2) {
|
|
throw new Error('Zu wenig Schritte im Vorschlag.')
|
|
}
|
|
setPathSteps(rows)
|
|
setTargetSummary(res?.target_profile_summary || null)
|
|
setSemanticBrief(res?.semantic_brief_summary || null)
|
|
setPathQa(res?.path_qa || null)
|
|
if (!segmentNotes.trim() && q) setSegmentNotes(q.slice(0, 400))
|
|
} catch (e) {
|
|
console.error(e)
|
|
setError(e.message || 'Pfad-Vorschlag fehlgeschlagen')
|
|
setPathSteps([])
|
|
setTargetSummary(null)
|
|
setSemanticBrief(null)
|
|
setPathQa(null)
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}
|
|
|
|
const savePathToGraph = async () => {
|
|
if (!graphId) {
|
|
alert('Zuerst einen Graphen wählen.')
|
|
return
|
|
}
|
|
const steps = pathSteps.filter((s) => s.exerciseId != null)
|
|
if (steps.length < 2) {
|
|
alert('Mindestens zwei Schritte mit Übung nötig.')
|
|
return
|
|
}
|
|
const n = steps.length - 1
|
|
const noteRaw = segmentNotes.trim()
|
|
const segment_notes = Array.from({ length: n }, (_, i) => {
|
|
const reasons = (steps[i + 1]?.reasons || []).slice(0, 2).join(' · ')
|
|
if (reasons) return reasons
|
|
return noteRaw || null
|
|
})
|
|
|
|
setSaving(true)
|
|
setError('')
|
|
try {
|
|
await api.createExerciseProgressionSequence(Number(graphId), {
|
|
steps: steps.map((s) => ({
|
|
exercise_id: s.exerciseId,
|
|
variant_id: s.variantId || null,
|
|
})),
|
|
segment_notes,
|
|
})
|
|
setPathSteps([])
|
|
setTargetSummary(null)
|
|
setSemanticBrief(null)
|
|
setPathQa(null)
|
|
if (typeof onSaved === 'function') await onSaved()
|
|
alert(`${n} Nachfolger-Kante(n) aus KI-Pfad gespeichert.`)
|
|
} catch (e) {
|
|
console.error(e)
|
|
setError(e.message || 'Speichern fehlgeschlagen')
|
|
} finally {
|
|
setSaving(false)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div
|
|
className="card"
|
|
style={{
|
|
marginBottom: '12px',
|
|
borderColor: 'color-mix(in srgb, var(--accent) 35%, var(--border))',
|
|
}}
|
|
>
|
|
<h3 style={{ marginTop: 0, fontSize: '1rem' }}>KI: Pfad zum Ziel</h3>
|
|
<p style={{ fontSize: '12px', color: 'var(--text3)', marginTop: 0, lineHeight: 1.45 }}>
|
|
Ziel in Freitext formulieren — die Planungs-KI schlägt eine semantisch passende, aufbauende Reihenfolge vor,
|
|
prüft Lücken (ggf. Brücken-Übungen) und optional per LLM-QS. Nach Review in den Graph speichern.
|
|
</p>
|
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '10px', alignItems: 'flex-end' }}>
|
|
<div className="form-row" style={{ flex: '2 1 240px', marginBottom: 0 }}>
|
|
<label className="form-label">Ziel / Entwicklungsrichtung</label>
|
|
<input
|
|
className="form-input"
|
|
value={goalQuery}
|
|
onChange={(e) => setGoalQuery(e.target.value)}
|
|
placeholder="z. B. sichere Reaktion im Partnertraining aufbauen …"
|
|
disabled={disabled || loading || saving}
|
|
/>
|
|
</div>
|
|
<div className="form-row" style={{ flex: '0 1 120px', marginBottom: 0 }}>
|
|
<label className="form-label">Schritte</label>
|
|
<input
|
|
type="number"
|
|
min={2}
|
|
max={10}
|
|
className="form-input"
|
|
value={maxSteps}
|
|
onChange={(e) => setMaxSteps(Math.max(2, Math.min(10, Number(e.target.value) || 5)))}
|
|
disabled={disabled || loading || saving}
|
|
/>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
className="btn btn-primary"
|
|
disabled={disabled || loading || saving || !graphId}
|
|
onClick={suggestPath}
|
|
>
|
|
{loading ? 'Vorschlag …' : 'Pfad vorschlagen'}
|
|
</button>
|
|
</div>
|
|
|
|
{error ? (
|
|
<p className="form-error" style={{ marginTop: '10px' }}>
|
|
{error}
|
|
</p>
|
|
) : null}
|
|
|
|
{(semanticBrief || targetSummary) && pathSteps.length > 0 ? (
|
|
<div style={{ marginTop: '10px', display: 'flex', flexWrap: 'wrap', gap: '6px' }}>
|
|
{semanticBrief?.primary_topic ? (
|
|
<span className="exercise-tag" style={{ borderColor: 'var(--accent)' }}>
|
|
Thema: {semanticBrief.primary_topic}
|
|
</span>
|
|
) : null}
|
|
{Array.isArray(semanticBrief?.development_arc) &&
|
|
semanticBrief.development_arc.slice(0, 3).map((phase) => (
|
|
<span key={phase} className="exercise-tag">
|
|
{phase}
|
|
</span>
|
|
))}
|
|
{Array.isArray(targetSummary?.focus_areas) &&
|
|
targetSummary.focus_areas.slice(0, 1).map((fa) => (
|
|
<span key={fa} className="exercise-tag">
|
|
Fokus: {fa}
|
|
</span>
|
|
))}
|
|
</div>
|
|
) : null}
|
|
|
|
{pathQa && pathSteps.length > 0 ? (
|
|
<div
|
|
style={{
|
|
marginTop: '10px',
|
|
padding: '10px 12px',
|
|
borderRadius: '8px',
|
|
background: pathQa.overall_ok ? 'color-mix(in srgb, var(--accent) 8%, var(--surface2))' : 'color-mix(in srgb, var(--danger) 8%, var(--surface2))',
|
|
fontSize: '12px',
|
|
lineHeight: 1.45,
|
|
}}
|
|
>
|
|
<strong>
|
|
Pfad-QS: {pathQa.overall_ok ? 'OK' : 'Hinweise'}
|
|
{pathQa.quality_score != null ? ` (${Math.round(Number(pathQa.quality_score) * 100)} %)` : ''}
|
|
</strong>
|
|
{pathQa.topic_coverage ? (
|
|
<p style={{ margin: '6px 0 0', color: 'var(--text2)' }}>{pathQa.topic_coverage}</p>
|
|
) : null}
|
|
{Array.isArray(pathQa.issues) && pathQa.issues.length > 0 ? (
|
|
<ul style={{ margin: '6px 0 0', paddingLeft: '16px', color: 'var(--text2)' }}>
|
|
{pathQa.issues.slice(0, 4).map((issue) => (
|
|
<li key={issue}>{issue}</li>
|
|
))}
|
|
</ul>
|
|
) : null}
|
|
{Number(pathQa.bridge_insert_count) > 0 ? (
|
|
<p style={{ margin: '6px 0 0', color: 'var(--accent-dark)' }}>
|
|
{pathQa.bridge_insert_count} Brücken-Übung(en) eingefügt (Lückenfüller).
|
|
</p>
|
|
) : null}
|
|
{Array.isArray(targetSummary?.top_skills) &&
|
|
targetSummary.top_skills.slice(0, 2).map((sk) => (
|
|
<span key={sk.skill_id} className="exercise-tag">
|
|
{sk.name}
|
|
</span>
|
|
))}
|
|
</div>
|
|
) : null}
|
|
|
|
{pathSteps.length > 0 ? (
|
|
<>
|
|
<div style={{ marginTop: '14px' }}>
|
|
{pathSteps.map((step, idx) => (
|
|
<div
|
|
key={`${step.exerciseId}-${idx}`}
|
|
style={{
|
|
display: 'grid',
|
|
gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))',
|
|
gap: '10px',
|
|
alignItems: 'end',
|
|
marginBottom: '12px',
|
|
paddingBottom: '12px',
|
|
borderBottom: idx < pathSteps.length - 1 ? '1px dashed var(--border)' : 'none',
|
|
}}
|
|
>
|
|
<div className="form-row" style={{ marginBottom: 0 }}>
|
|
<label className="form-label">
|
|
Schritt {idx + 1}
|
|
{step.isBridge ? ' (Brücke)' : ''}
|
|
{idx === 0 ? ' (Einstieg)' : idx === pathSteps.length - 1 ? ' (Zielnähe)' : ''}
|
|
</label>
|
|
<div style={{ fontSize: '13px' }}>
|
|
<strong>{step.exerciseTitle}</strong>
|
|
<span style={{ color: 'var(--text3)' }}> (#{step.exerciseId})</span>
|
|
</div>
|
|
{step.reasons?.length ? (
|
|
<ul
|
|
style={{
|
|
margin: '6px 0 0',
|
|
paddingLeft: '16px',
|
|
fontSize: '11px',
|
|
color: 'var(--accent-dark)',
|
|
}}
|
|
>
|
|
{step.reasons.slice(0, 2).map((r) => (
|
|
<li key={r}>{r}</li>
|
|
))}
|
|
</ul>
|
|
) : null}
|
|
</div>
|
|
<div className="form-row" style={{ marginBottom: 0 }}>
|
|
<label className="form-label">Variante</label>
|
|
<select
|
|
className="form-input"
|
|
value={step.variantId ?? ''}
|
|
onChange={(e) =>
|
|
patchStep(idx, {
|
|
variantId: e.target.value === '' ? null : parseInt(e.target.value, 10),
|
|
})
|
|
}
|
|
disabled={!step.exerciseId}
|
|
>
|
|
<option value="">Gesamte Übung</option>
|
|
{(step.variants || []).map((v) => (
|
|
<option key={v.id} value={v.id}>
|
|
{v.variant_name || `Variante #${v.id}`}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
<div style={{ display: 'flex', gap: '6px', flexWrap: 'wrap' }}>
|
|
<button type="button" className="btn" style={{ fontSize: '12px', padding: '4px 8px' }} onClick={() => moveStep(idx, -1)}>
|
|
↑
|
|
</button>
|
|
<button type="button" className="btn" style={{ fontSize: '12px', padding: '4px 8px' }} onClick={() => moveStep(idx, 1)}>
|
|
↓
|
|
</button>
|
|
<button type="button" className="btn" style={{ fontSize: '12px', padding: '4px 8px' }} onClick={() => removeStep(idx)}>
|
|
Entfernen
|
|
</button>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
<div className="form-row">
|
|
<label className="form-label">Notiz für Kanten (Fallback, optional)</label>
|
|
<textarea
|
|
className="form-input"
|
|
rows={2}
|
|
value={segmentNotes}
|
|
onChange={(e) => setSegmentNotes(e.target.value)}
|
|
placeholder="Wird pro Kante genutzt, wenn keine KI-Begründung vorliegt."
|
|
/>
|
|
</div>
|
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px' }}>
|
|
<button
|
|
type="button"
|
|
className="btn btn-primary"
|
|
disabled={disabled || saving || pathSteps.filter((s) => s.exerciseId).length < 2}
|
|
onClick={savePathToGraph}
|
|
>
|
|
{saving ? 'Speichern …' : 'Pfad in Graph speichern'}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="btn btn-secondary"
|
|
disabled={loading || saving}
|
|
onClick={() => {
|
|
setPathSteps([])
|
|
setTargetSummary(null)
|
|
}}
|
|
>
|
|
Vorschlag verwerfen
|
|
</button>
|
|
</div>
|
|
</>
|
|
) : null}
|
|
</div>
|
|
)
|
|
}
|