Enhance Gap Fill Offer with Context Preview and Update Version
All checks were successful
Deploy Development / deploy (push) Successful in 43s
Test Suite / pytest-backend (push) Successful in 43s
Test Suite / lint-backend (push) Successful in 1s
Test Suite / build-frontend (push) Successful in 14s
Test Suite / k6 /health Baseline (push) Successful in 34s
Test Suite / playwright-tests (push) Successful in 1m14s

- Added `context_preview` to the `build_gap_fill_offer` function, providing a structured overview of the roadmap snapshot.
- Introduced `gapOfferContextDisplayLines` utility to format context information for UI display, improving clarity for users.
- Updated `ExerciseProgressionPathBuilder` and related components to utilize the new context preview, enhancing the user experience.
- Incremented application version to 0.8.213 to reflect these changes.
This commit is contained in:
Lars 2026-06-09 16:27:03 +02:00
parent f2650dac57
commit d4b1780193
6 changed files with 136 additions and 12 deletions

View File

@ -349,6 +349,7 @@ def build_gap_fill_offer(
step_b=step_b,
roadmap_snapshot=roadmap_snapshot,
)
ctx_preview = dict(roadmap_snapshot) if roadmap_snapshot else None
offer: Dict[str, Any] = {
"offer_id": offer_id,
"source": spec.get("source"),
@ -357,12 +358,14 @@ def build_gap_fill_offer(
"title_hint": spec.get("title_hint"),
"sketch": spec.get("sketch"),
"goal_for_ai": goal_for_ai or spec.get("sketch"),
"context_preview": ctx_preview,
"phase": spec.get("phase"),
"rationale": spec.get("rationale"),
"has_ai_payload": False,
"from_title": (step_a or {}).get("title"),
"to_title": (step_b or {}).get("title"),
"primary_topic": (brief.primary_topic if brief else None),
"roadmap_major_step_index": spec.get("roadmap_major_step_index"),
}
if proposal:
offer["has_ai_payload"] = True

View File

@ -1,5 +1,9 @@
"""Tests Planungs-KI Phase E3 — Lücken-Angebote und Off-Topic."""
from planning_exercise_path_ai_fill import build_gap_fill_goal_text, collect_gap_fill_specs
from planning_exercise_path_ai_fill import (
build_gap_fill_goal_text,
build_gap_fill_offer,
collect_gap_fill_specs,
)
from planning_exercise_path_qa import parse_llm_suggested_new_exercises, strip_off_topic_steps_from_path
from planning_exercise_semantics import build_semantic_brief
@ -113,3 +117,20 @@ def test_build_gap_fill_goal_text_includes_roadmap_snapshot():
assert "explosiver Angriff" in text
assert "variable Rhythmen" in text
assert "timing" in text
def test_build_gap_fill_offer_exposes_context_preview():
brief = build_semantic_brief("Kumite Beinarbeit")
offer = build_gap_fill_offer(
spec={"source": "roadmap_unfilled", "phase": "vertiefung", "title_hint": "Rhythmen"},
steps=[{"title": "A"}, {"title": "B"}],
goal_query="Kumite Beinarbeit",
brief=brief,
roadmap_snapshot={
"start_situation": "Steppbewegung",
"target_state": "explosiver Angriff",
"stage_learning_goal": "variable Rhythmen",
},
)
assert offer["context_preview"]["start_situation"] == "Steppbewegung"
assert "variable Rhythmen" in offer["goal_for_ai"]

View File

@ -1,6 +1,6 @@
# Shinkan Jinkendo Version Information
APP_VERSION = "0.8.212"
APP_VERSION = "0.8.213"
BUILD_DATE = "2026-06-07"
DB_SCHEMA_VERSION = "20260607087"

View File

@ -21,6 +21,7 @@ export default function ExerciseAiSuggestPreviewModal({
applyLabel = 'Übung anlegen',
applyDisabled = false,
zIndex = 2000,
planningContextLines = [],
}) {
useEffect(() => {
if (!draft) return undefined
@ -86,6 +87,31 @@ export default function ExerciseAiSuggestPreviewModal({
<h3 style={{ marginTop: 0, fontSize: '1.1rem', marginBottom: '6px' }}>{dialogTitle}</h3>
<p style={{ fontSize: '13px', color: 'var(--text3)', marginTop: 0, marginBottom: '16px' }}>{hint}</p>
{Array.isArray(planningContextLines) && planningContextLines.length > 0 ? (
<div
style={{
marginBottom: '16px',
padding: '10px 12px',
borderRadius: '8px',
border: '1px solid color-mix(in srgb, var(--accent) 35%, var(--border))',
background: 'color-mix(in srgb, var(--accent) 6%, var(--surface))',
fontSize: '12px',
}}
>
<strong style={{ display: 'block', marginBottom: '6px', fontSize: '13px' }}>
Planungskontext (an KI übergeben)
</strong>
<dl style={{ margin: 0, display: 'grid', gap: '6px' }}>
{planningContextLines.map(({ label, value }) => (
<div key={label}>
<dt style={{ margin: 0, fontSize: '11px', color: 'var(--text3)' }}>{label}</dt>
<dd style={{ margin: '2px 0 0', lineHeight: 1.45, color: 'var(--text2)' }}>{value}</dd>
</div>
))}
</dl>
</div>
) : null}
<div style={{ display: 'grid', gap: '12px', marginBottom: '18px' }}>
<div>
<label className="form-label" htmlFor="ai-draft-title">

View File

@ -10,7 +10,10 @@ import {
buildQuickCreateAiPreview,
buildQuickCreateExercisePayloadFromDraft,
} from '../utils/exerciseAiQuickCreate'
import { buildPathGapPlanningContextForAi } from '../utils/planningContextForExerciseAi'
import {
buildPathGapPlanningContextForAi,
gapOfferContextDisplayLines,
} from '../utils/planningContextForExerciseAi'
function applyResolvedStructuredFromRoadmap(progressionRoadmap, setters) {
const rs = progressionRoadmap?.resolved_structured
@ -20,6 +23,37 @@ function applyResolvedStructuredFromRoadmap(progressionRoadmap, setters) {
if (rs.roadmap_notes) setters.setRoadmapNotes(String(rs.roadmap_notes))
}
function GapOfferContextPreview({ lines }) {
if (!Array.isArray(lines) || lines.length === 0) return null
return (
<details
style={{
marginTop: '8px',
fontSize: '12px',
color: 'var(--text2)',
borderTop: '1px dashed var(--border)',
paddingTop: '8px',
}}
>
<summary style={{ cursor: 'pointer', color: 'var(--accent-dark)', fontWeight: 600 }}>
KI-Kontext für diese Übung ({lines.length} Punkte)
</summary>
<dl style={{ margin: '8px 0 0', display: 'grid', gap: '6px' }}>
{lines.map(({ label, value }) => (
<div key={label}>
<dt style={{ margin: 0, fontSize: '11px', color: 'var(--text3)' }}>{label}</dt>
<dd style={{ margin: '2px 0 0', lineHeight: 1.45 }}>{value}</dd>
</div>
))}
</dl>
<p style={{ margin: '8px 0 0', fontSize: '11px', color: 'var(--text3)', lineHeight: 1.4 }}>
Dieser Kontext wird an die Übungs-KI übergeben (Ziel, Fähigkeiten, Anleitung) nicht nur das
Stufen-Lernziel oben.
</p>
</details>
)
}
function sourceLabel(source) {
const map = {
user: 'manuell',
@ -217,6 +251,7 @@ export default function ExerciseProgressionPathBuilder({
const [quickCreateDraft, setQuickCreateDraft] = useState(null)
const [quickSaving, setQuickSaving] = useState(false)
const [quickAiError, setQuickAiError] = useState('')
const [activePlanningContextLines, setActivePlanningContextLines] = useState([])
useEffect(() => {
let cancelled = false
@ -363,6 +398,18 @@ export default function ExerciseProgressionPathBuilder({
await runGapFillAiSuggest(offer)
}
const gapContextFallbackParams = {
goalQuery,
semanticBrief,
graphId,
pathSteps,
editableMajorSteps,
progressionRoadmap,
startSituation,
targetState,
roadmapNotes,
}
const runGapFillAiSuggest = async (offer) => {
const title = (offer?.title_hint || '').trim()
if (title.length < 3) {
@ -391,17 +438,11 @@ export default function ExerciseProgressionPathBuilder({
setQuickCreateDraft(null)
setQuickSaving(true)
setGeneratingOfferId(offer?.offer_id || null)
const contextLines = gapOfferContextDisplayLines(offer, gapContextFallbackParams)
setActivePlanningContextLines(contextLines)
const planningContext = buildPathGapPlanningContextForAi({
goalQuery,
semanticBrief,
offer,
graphId,
pathSteps,
editableMajorSteps,
progressionRoadmap,
startSituation,
targetState,
roadmapNotes,
...gapContextFallbackParams,
})
try {
const aiRes = await api.suggestExerciseAi({
@ -1193,6 +1234,9 @@ export default function ExerciseProgressionPathBuilder({
{offer.replace_step_index != null ? ' (ersetzt themenfremden Schritt)' : ''}
</p>
) : null}
<GapOfferContextPreview
lines={gapOfferContextDisplayLines(offer, gapContextFallbackParams)}
/>
</div>
<button
type="button"
@ -1384,8 +1428,10 @@ export default function ExerciseProgressionPathBuilder({
onDraftChange={setQuickCreateDraft}
onDiscard={() => {
setQuickCreateDraft(null)
setActivePlanningContextLines([])
if (activeOffer) setQuickCreateOpen(true)
}}
planningContextLines={activePlanningContextLines}
onApply={applyQuickCreateDraft}
focusAreas={focusAreas}
skillsCatalog={skillsCatalog}

View File

@ -132,3 +132,31 @@ export function buildPathGapPlanningContextForAi({
}
return Object.fromEntries(Object.entries(ctx).filter(([, v]) => v != null && v !== ''))
}
/** Lesbare Zeilen für UI — aus API context_preview oder lokal berechnet. */
export function gapOfferContextDisplayLines(offer, fallbackParams = null) {
const raw =
offer?.context_preview ||
(fallbackParams ? buildPathGapPlanningContextForAi({ offer, ...fallbackParams }) : null)
if (!raw) return []
const lines = []
const push = (label, value) => {
const v = String(value || '').trim()
if (v) lines.push({ label, value: v })
}
push('Ausgangslage (Pfad)', raw.start_situation)
push('Gesamtziel (Pfad)', raw.target_state)
push('Ergänzungen', raw.roadmap_notes)
push('Stufen-Lernziel', raw.stage_learning_goal || raw.roadmap_learning_goal)
if (raw.stage_phase) push('Phase', raw.stage_phase)
if (Array.isArray(raw.stage_load_profile) && raw.stage_load_profile.length) {
push('Belastung', raw.stage_load_profile.join(', '))
}
if (Array.isArray(raw.stage_success_criteria) && raw.stage_success_criteria.length) {
push('Erfolgskriterien', raw.stage_success_criteria.slice(0, 3).join(' · '))
}
if (Array.isArray(raw.skill_hints) && raw.skill_hints.length) {
push('Fähigkeiten-Fokus', raw.skill_hints.slice(0, 3).join(' · '))
}
return lines
}