Enhance Planning Exercise Path AI and UI Integration
All checks were successful
Deploy Development / deploy (push) Successful in 45s
Test Suite / pytest-backend (push) Successful in 40s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 13s
Test Suite / k6 /health Baseline (push) Successful in 34s
Test Suite / playwright-tests (push) Successful in 1m13s

- Updated the AI gap filling logic to include structured offers for unfilled gaps, improving the user experience in the Exercise Progression Path Builder.
- Introduced new functions for detecting off-topic steps and parsing LLM-suggested exercises, enhancing the contextual relevance of exercise suggestions.
- Enhanced the frontend components to support new AI proposal features, including quick creation modals for newly suggested exercises.
- Incremented version to 0.8.190 and updated changelog to reflect these improvements in planning AI functionality.
This commit is contained in:
Lars 2026-05-23 12:59:46 +02:00
parent 8d1dd59c3c
commit 3450a9296a
7 changed files with 915 additions and 88 deletions

View File

@ -0,0 +1,85 @@
-- Migration 077: Planungs-Pfad-QA — strukturierte Neuanlage-Vorschläge (Phase E3)
UPDATE ai_prompts
SET template = $t$Du bist Assistent für Kampfsport-Trainer und prüfst einen vorgeschlagenen Übungspfad.
Ziel-Anfrage: {{goal_query}}
Semantic Brief: {{semantic_brief_json}}
Schritte (JSON): {{steps_json}}
Erkannte Lücken: {{gaps_json}}
Eingefügte Brücken: {{bridge_inserts_json}}
Prüfe:
1. Deckt der Pfad das Hauptthema der Anfrage ab (nicht nur Oberbegriffe)?
2. Ist die Reihenfolge didaktisch sinnvoll (Einstieg Vertiefung Ziel)?
3. Sind Sprünge zwischen benachbarten Schritten zu groß?
4. Sind Brücken-Übungen sinnvoll oder überflüssig?
5. Fehlen wichtige Zwischenschritte (Kraft, Geschwindigkeit, Anwendung, Perfektion)?
6. Gibt es Schritte ohne Bezug zum Hauptthema (z. B. reine Kraftübungen bei einer Technik)?
Wenn die Reihenfolge verbessert werden sollte: ordered_step_indices = Permutation der aktuellen 0-basierten Schritt-Indizes (beste didaktische Reihenfolge).
Nur Indizes aus dem steps_json verwenden Länge muss exakt der Schrittzahl entsprechen.
Wenn wichtige Zwischenschritte fehlen oder Schritte themenfremd sind: suggested_new_exercises mit konkreten Übungs-Ideen (Titel + Kurzskizze), jeweils mit insert_after_step_index (0-basiert: nach welchem Schritt einfügen).
Antworte NUR mit JSON:
{
"overall_ok": true,
"quality_score": 0.85,
"topic_coverage": "Kurz: wie gut das Hauptthema abgedeckt ist",
"ordered_step_indices": [0, 1, 2, 3],
"issues": [""],
"sequence_notes": [""],
"recommendations": [""],
"suggested_new_exercises": [
{
"title_hint": "Mae Geri Kraftentwicklung am Sandsack",
"sketch": "Gezielte Kraft- und Schnelligkeitsentwicklung für Mae Geri …",
"phase": "vertiefung",
"insert_after_step_index": 2,
"rationale": "Schließt Lücke zwischen Grundlagen und Gleichgewichtstritt"
}
]
}$t$,
default_template = $t$Du bist Assistent für Kampfsport-Trainer und prüfst einen vorgeschlagenen Übungspfad.
Ziel-Anfrage: {{goal_query}}
Semantic Brief: {{semantic_brief_json}}
Schritte (JSON): {{steps_json}}
Erkannte Lücken: {{gaps_json}}
Eingefügte Brücken: {{bridge_inserts_json}}
Prüfe:
1. Deckt der Pfad das Hauptthema der Anfrage ab (nicht nur Oberbegriffe)?
2. Ist die Reihenfolge didaktisch sinnvoll (Einstieg Vertiefung Ziel)?
3. Sind Sprünge zwischen benachbarten Schritten zu groß?
4. Sind Brücken-Übungen sinnvoll oder überflüssig?
5. Fehlen wichtige Zwischenschritte (Kraft, Geschwindigkeit, Anwendung, Perfektion)?
6. Gibt es Schritte ohne Bezug zum Hauptthema (z. B. reine Kraftübungen bei einer Technik)?
Wenn die Reihenfolge verbessert werden sollte: ordered_step_indices = Permutation der aktuellen 0-basierten Schritt-Indizes (beste didaktische Reihenfolge).
Nur Indizes aus dem steps_json verwenden Länge muss exakt der Schrittzahl entsprechen.
Wenn wichtige Zwischenschritte fehlen oder Schritte themenfremd sind: suggested_new_exercises mit konkreten Übungs-Ideen (Titel + Kurzskizze), jeweils mit insert_after_step_index (0-basiert: nach welchem Schritt einfügen).
Antworte NUR mit JSON:
{
"overall_ok": true,
"quality_score": 0.85,
"topic_coverage": "Kurz: wie gut das Hauptthema abgedeckt ist",
"ordered_step_indices": [0, 1, 2, 3],
"issues": [""],
"sequence_notes": [""],
"recommendations": [""],
"suggested_new_exercises": [
{
"title_hint": "Mae Geri Kraftentwicklung am Sandsack",
"sketch": "Gezielte Kraft- und Schnelligkeitsentwicklung für Mae Geri …",
"phase": "vertiefung",
"insert_after_step_index": 2,
"rationale": "Schließt Lücke zwischen Grundlagen und Gleichgewichtstritt"
}
]
}$t$,
output_schema = '{"type":"object","required":["overall_ok"],"properties":{"overall_ok":{"type":"boolean"},"quality_score":{"type":"number"},"issues":{"type":"array"},"sequence_notes":{"type":"array"},"recommendations":{"type":"array"},"ordered_step_indices":{"type":"array"},"suggested_new_exercises":{"type":"array"}}}'::jsonb
WHERE slug = 'planning_exercise_path_qa';

View File

@ -1,16 +1,17 @@
"""
Planungs-KI Phase E2: KI-Neuanlage-Vorschläge für unüberbrückbare Pfad-Lücken.
Planungs-KI Phase E2/E3: KI-Neuanlage für Pfad-Lücken + strukturierte Angebote für die UI.
"""
from __future__ import annotations
import logging
from typing import Any, Dict, Mapping, Optional
import uuid
from typing import Any, Dict, List, Mapping, Optional, Sequence, Tuple
from ai_prompt_context import ExerciseFormAiPromptContext
from ai_prompt_job import run_exercise_form_ai_suggestion
from exercise_ai import strip_html_to_plain
from planning_exercise_path_qa import find_step_pair_index
from planning_exercise_semantics import PlanningSemanticBrief
_logger = logging.getLogger("shinkan.planning_exercise_path_ai_fill")
@ -23,19 +24,27 @@ def _build_gap_ai_context(
step_a: Mapping[str, Any],
step_b: Mapping[str, Any],
gap: Mapping[str, Any],
title_hint: Optional[str] = None,
sketch_hint: Optional[str] = None,
) -> ExerciseFormAiPromptContext:
topic = (brief.primary_topic or "Technik").strip()
phase = gap.get("expected_phase") or "vertiefung"
from_title = (step_a.get("title") or f"Übung #{step_a.get('exercise_id')}").strip()
to_title = (step_b.get("title") or f"Übung #{step_b.get('exercise_id')}").strip()
title = f"Brücke {topic} ({phase})"
goal = (
f"Planungsziel: {goal_query}\n\n"
f"Didaktische Brücken-Übung zwischen „{from_title}“ und „{to_title}“.\n"
f"Phase: {phase}. Thema: {topic}. "
f"Die Übung schließt die Lücke im Progressionspfad und bereitet sinnvoll auf den nächsten Schritt vor."
)
title = (title_hint or f"Brücke {topic} ({phase})").strip()[:280]
sketch = (sketch_hint or "").strip()
goal_parts = [
f"Planungsziel: {goal_query}",
"",
f"Didaktische Brücken-Übung zwischen „{from_title}“ und „{to_title}“.",
f"Phase: {phase}. Thema: {topic}.",
"Die Übung schließt die Lücke im Progressionspfad und bereitet sinnvoll auf den nächsten Schritt vor.",
]
if sketch:
goal_parts.extend(["", f"Hinweis: {sketch}"])
goal = "\n".join(goal_parts)
focus_hint = topic if brief.topic_type == "technique" else None
if brief.must_phrases:
focus_hint = ", ".join(brief.must_phrases[:2])
@ -81,8 +90,8 @@ def ai_proposal_to_path_step(
"is_ai_proposal": True,
"ai_suggestion": dict(ai_payload),
"bridge_for_gap": {
"from_exercise_id": int(step_a["exercise_id"]),
"to_exercise_id": int(step_b["exercise_id"]),
"from_exercise_id": step_a.get("exercise_id"),
"to_exercise_id": step_b.get("exercise_id"),
"gap_score": gap.get("gap_score"),
"expected_phase": gap.get("expected_phase"),
},
@ -97,6 +106,8 @@ def try_suggest_ai_bridge_step(
step_a: Mapping[str, Any],
step_b: Mapping[str, Any],
gap: Mapping[str, Any],
title_hint: Optional[str] = None,
sketch_hint: Optional[str] = None,
) -> Optional[Dict[str, Any]]:
"""Ruft exercise AI suggest auf — kein Speichern in DB."""
ctx = _build_gap_ai_context(
@ -105,6 +116,8 @@ def try_suggest_ai_bridge_step(
step_a=step_a,
step_b=step_b,
gap=gap,
title_hint=title_hint,
sketch_hint=sketch_hint,
)
g_plain = strip_html_to_plain(ctx.goal)
if not g_plain.strip() and not (ctx.title or "").strip():
@ -132,6 +145,217 @@ def try_suggest_ai_bridge_step(
)
def _default_sketch(
*,
goal_query: str,
brief: PlanningSemanticBrief,
step_a: Optional[Mapping[str, Any]],
step_b: Optional[Mapping[str, Any]],
phase: str,
rationale: str = "",
) -> str:
topic = (brief.primary_topic or "Technik").strip()
from_t = (step_a or {}).get("title") or "vorherigem Schritt"
to_t = (step_b or {}).get("title") or "nächstem Schritt"
parts = [
f"Planungsziel: {goal_query}",
f"Zwischenschritt für {topic} ({phase}) zwischen „{from_t}“ und „{to_t}“.",
]
if rationale:
parts.append(rationale)
return " ".join(parts)[:1200]
def _spec_dedupe_key(spec: Mapping[str, Any]) -> Tuple[Any, ...]:
return (
spec.get("source"),
int(spec.get("insert_after_index") or 0),
str(spec.get("title_hint") or "")[:48],
)
def collect_gap_fill_specs(
*,
steps: Sequence[Mapping[str, Any]],
unfilled_gaps: Sequence[Mapping[str, Any]],
off_topic_steps: Sequence[Mapping[str, Any]],
llm_specs: Sequence[Mapping[str, Any]],
brief: PlanningSemanticBrief,
goal_query: str,
) -> List[Dict[str, Any]]:
"""Sammelt alle Lücken, für die ein KI-Anlege-Angebot sinnvoll ist."""
topic = (brief.primary_topic or "Technik").strip()
specs: List[Dict[str, Any]] = []
seen: set = set()
def add(spec: Dict[str, Any]) -> None:
key = _spec_dedupe_key(spec)
if key in seen:
return
seen.add(key)
specs.append(spec)
for gap in unfilled_gaps:
idx = find_step_pair_index(
steps,
int(gap["from_exercise_id"]),
int(gap["to_exercise_id"]),
)
if idx is None:
continue
phase = gap.get("expected_phase") or "vertiefung"
add(
{
"source": "unfilled_gap",
"insert_after_index": idx,
"gap": dict(gap),
"phase": phase,
"title_hint": f"{topic}{phase}",
"sketch": _default_sketch(
goal_query=goal_query,
brief=brief,
step_a=steps[idx],
step_b=steps[idx + 1],
phase=str(phase),
rationale="Bibliothek enthält keine passende Brücke.",
),
"rationale": "Lücke zwischen benachbarten Schritten — keine passende Bibliotheks-Übung.",
}
)
for ot in off_topic_steps:
idx = int(ot.get("step_index") or 0)
if idx <= 0 or idx >= len(steps) - 1:
continue
phase = ot.get("expected_phase") or "vertiefung"
add(
{
"source": "off_topic",
"insert_after_index": idx - 1,
"replace_step_index": idx,
"gap": {
"expected_phase": phase,
"off_topic_title": ot.get("title"),
"off_topic_exercise_id": ot.get("exercise_id"),
},
"phase": phase,
"title_hint": f"{topic}{phase} (Ersatz für themenfremden Schritt)",
"sketch": _default_sketch(
goal_query=goal_query,
brief=brief,
step_a=steps[idx - 1],
step_b=steps[idx + 1],
phase=str(phase),
rationale=f"Ersetzt themenfremden Schritt „{ot.get('title')}“.",
),
"rationale": f"Schritt „{ot.get('title')}“ passt nicht zum Pfad-Thema.",
}
)
for spec in llm_specs:
add(dict(spec))
return specs[:5]
def build_gap_fill_offer(
*,
spec: Mapping[str, Any],
steps: Sequence[Mapping[str, Any]],
proposal: Optional[Mapping[str, Any]] = None,
) -> Dict[str, Any]:
idx = int(spec.get("insert_after_index") or 0)
offer_id = f"{spec.get('source')}-{idx}-{uuid.uuid4().hex[:8]}"
offer: Dict[str, Any] = {
"offer_id": offer_id,
"source": spec.get("source"),
"insert_after_index": idx,
"replace_step_index": spec.get("replace_step_index"),
"title_hint": spec.get("title_hint"),
"sketch": spec.get("sketch"),
"phase": spec.get("phase"),
"rationale": spec.get("rationale"),
"has_ai_payload": False,
"from_title": (steps[idx].get("title") if idx < len(steps) else None),
"to_title": (steps[idx + 1].get("title") if idx + 1 < len(steps) else None),
}
if proposal:
offer["has_ai_payload"] = True
offer["proposal_key"] = proposal.get("proposal_key")
offer["ai_suggestion"] = proposal.get("ai_suggestion")
offer["proposal_title"] = proposal.get("title")
offer["proposal_summary"] = proposal.get("summary")
return offer
def apply_gap_fill_after_qa(
cur,
steps: List[Dict[str, Any]],
specs: Sequence[Mapping[str, Any]],
*,
goal_query: str,
brief: PlanningSemanticBrief,
include_ai_calls: bool = True,
max_ai_proposals: int = 3,
auto_insert_proposals: bool = False,
) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]], List[Dict[str, Any]]]:
"""
Erzeugt gap_fill_offers für die UI; optional KI-Vorschläge einfügen.
Returns: (steps, ai_proposals, gap_fill_offers)
"""
if not specs:
return steps, [], []
out = list(steps)
proposals: List[Dict[str, Any]] = []
offers: List[Dict[str, Any]] = []
for spec in specs:
idx = int(spec.get("insert_after_index") or 0)
if idx < 0 or idx >= len(out) - 1:
continue
step_a = out[idx]
step_b = out[idx + 1]
if step_a.get("is_ai_proposal") or step_b.get("is_ai_proposal"):
offer = build_gap_fill_offer(spec=spec, steps=out, proposal=None)
offers.append(offer)
continue
gap = dict(spec.get("gap") or {})
if not gap.get("expected_phase"):
gap["expected_phase"] = spec.get("phase") or "vertiefung"
proposal: Optional[Dict[str, Any]] = None
if include_ai_calls and len(proposals) < max_ai_proposals:
proposal = try_suggest_ai_bridge_step(
cur,
goal_query=goal_query,
brief=brief,
step_a=step_a,
step_b=step_b,
gap=gap,
title_hint=str(spec.get("title_hint") or ""),
sketch_hint=str(spec.get("sketch") or ""),
)
offer = build_gap_fill_offer(spec=spec, steps=out, proposal=proposal)
offers.append(offer)
if proposal and auto_insert_proposals:
out.insert(idx + 1, proposal)
proposals.append(
{
"inserted_after_index": idx,
"proposal_key": proposal.get("proposal_key"),
"proposal_title": proposal.get("title"),
"gap": gap,
"offer_id": offer.get("offer_id"),
}
)
return out, proposals, offers
def insert_ai_proposals_for_gaps(
cur,
steps: list,
@ -141,56 +365,32 @@ def insert_ai_proposals_for_gaps(
brief: PlanningSemanticBrief,
max_proposals: int = 2,
) -> tuple[list, list]:
"""Fügt KI-Vorschläge für Lücken ein, wenn Bibliotheks-Brücke fehlte."""
if not unfilled_gaps:
return steps, []
out = list(steps)
proposals: list = []
gap_by_pair = {
(int(g["from_exercise_id"]), int(g["to_exercise_id"])): g for g in unfilled_gaps
}
i = 0
while i < len(out) - 1 and len(proposals) < max_proposals:
a = out[i]
b = out[i + 1]
if a.get("is_ai_proposal") or b.get("is_ai_proposal"):
i += 1
continue
key = (int(a["exercise_id"]), int(b["exercise_id"]))
gap = gap_by_pair.get(key)
if not gap:
i += 1
continue
proposal = try_suggest_ai_bridge_step(
cur,
goal_query=goal_query,
brief=brief,
step_a=a,
step_b=b,
gap=gap,
)
if not proposal:
i += 1
continue
out.insert(i + 1, proposal)
proposals.append(
{
"inserted_after_index": i,
"proposal_key": proposal.get("proposal_key"),
"proposal_title": proposal.get("title"),
"gap": gap,
}
)
i += 2
"""Legacy: Fügt KI-Vorschläge für Lücken ein, wenn Bibliotheks-Brücke fehlte."""
specs = collect_gap_fill_specs(
steps=steps,
unfilled_gaps=unfilled_gaps,
off_topic_steps=[],
llm_specs=[],
brief=brief,
goal_query=goal_query,
)
out, proposals, _offers = apply_gap_fill_after_qa(
cur,
steps,
specs,
goal_query=goal_query,
brief=brief,
include_ai_calls=True,
max_ai_proposals=max_proposals,
auto_insert_proposals=True,
)
return out, proposals
__all__ = [
"apply_gap_fill_after_qa",
"build_gap_fill_offer",
"collect_gap_fill_specs",
"insert_ai_proposals_for_gaps",
"try_suggest_ai_bridge_step",
]

View File

@ -15,11 +15,13 @@ from planning_exercise_profiles import PlanningTargetProfile
from planning_exercise_path_qa import (
apply_llm_path_reorder,
build_path_qa_summary,
detect_off_topic_steps,
detect_path_gaps,
insert_bridge_exercises,
parse_llm_suggested_new_exercises,
try_llm_qa_progression_path,
)
from planning_exercise_path_ai_fill import insert_ai_proposals_for_gaps
from planning_exercise_path_ai_fill import apply_gap_fill_after_qa, collect_gap_fill_specs
from planning_exercise_retrieval import run_multistage_planning_retrieval
from planning_exercise_semantics import (
PlanningSemanticBrief,
@ -394,6 +396,8 @@ def suggest_progression_path(
gaps: List[Dict[str, Any]] = []
bridge_inserts: List[Dict[str, Any]] = []
ai_proposals: List[Dict[str, Any]] = []
gap_fill_offers: List[Dict[str, Any]] = []
off_topic_steps: List[Dict[str, Any]] = []
llm_qa: Optional[Dict[str, Any]] = None
llm_qa_applied = False
reorder_applied = False
@ -424,15 +428,6 @@ def suggest_progression_path(
bridge_search_fn=bridge_fn,
)
if body.include_ai_gap_fill and unfilled_gaps:
steps, ai_proposals = insert_ai_proposals_for_gaps(
cur,
steps,
unfilled_gaps,
goal_query=goal_query,
brief=semantic_brief,
)
if body.include_llm_path_qa:
llm_qa, llm_qa_applied = try_llm_qa_progression_path(
cur,
@ -452,10 +447,39 @@ def suggest_progression_path(
if llm_qa.get("overall_ok") or (q_val is not None and q_val >= 0.45):
steps, reorder_applied, reorder_notes = apply_llm_path_reorder(steps, llm_qa)
off_topic_steps = detect_off_topic_steps(cur, steps, brief=semantic_brief)
llm_gap_specs = parse_llm_suggested_new_exercises(
llm_qa,
brief=semantic_brief,
step_count=len(steps),
)
if body.include_ai_gap_fill:
gap_specs = collect_gap_fill_specs(
steps=steps,
unfilled_gaps=unfilled_gaps,
off_topic_steps=off_topic_steps,
llm_specs=llm_gap_specs,
brief=semantic_brief,
goal_query=goal_query,
)
steps, ai_proposals, gap_fill_offers = apply_gap_fill_after_qa(
cur,
steps,
gap_specs,
goal_query=goal_query,
brief=semantic_brief,
include_ai_calls=True,
max_ai_proposals=3,
auto_insert_proposals=False,
)
path_qa = build_path_qa_summary(
gaps=gaps,
bridge_inserts=bridge_inserts,
ai_proposals=ai_proposals,
gap_fill_offers=gap_fill_offers,
off_topic_steps=off_topic_steps,
llm_qa=llm_qa,
llm_applied=llm_qa_applied,
reorder_applied=reorder_applied,
@ -472,6 +496,8 @@ def suggest_progression_path(
retrieval_parts.append("path_reorder")
if ai_proposals:
retrieval_parts.append("ai_gap_fill")
if gap_fill_offers:
retrieval_parts.append("gap_fill_offers")
return {
"goal_query": goal_query,
@ -484,6 +510,7 @@ def suggest_progression_path(
"query_intent_summary": first_intent_summary,
"progression_graph_id": body.progression_graph_id,
"path_qa": path_qa,
"gap_fill_offers": gap_fill_offers,
"retrieval_phase": "+".join(retrieval_parts),
}

View File

@ -19,6 +19,7 @@ from openrouter_chat import (
from planning_exercise_semantics import (
PlanningSemanticBrief,
brief_to_summary_dict,
exercise_passes_path_semantic_gate,
score_exercise_semantic_relevance,
step_phase_for_index,
)
@ -230,6 +231,18 @@ def insert_bridge_exercises(
i += 1
continue
bridge_sem = float(bridge_hit.get("semantic_score") or 0.0)
if brief.semantic_strength >= 0.55 and not exercise_passes_path_semantic_gate(
semantic_score=bridge_sem,
title=str(bridge_hit.get("title") or ""),
summary=str(bridge_hit.get("summary") or ""),
brief=brief,
strict=True,
):
unfilled.append({**gap, "weak_bridge_rejected": True, "bridge_title": bridge_hit.get("title")})
i += 1
continue
bridge_step = {
"exercise_id": int(bridge_hit["id"]),
"variant_id": bridge_hit.get("suggested_variant_id"),
@ -351,16 +364,133 @@ def apply_llm_path_reorder(
return [steps[i] for i in indices], True, notes
_OFF_TOPIC_SEMANTIC_MAX = 0.10
def detect_off_topic_steps(
cur,
steps: Sequence[Mapping[str, Any]],
*,
brief: PlanningSemanticBrief,
) -> List[Dict[str, Any]]:
"""Schritte ohne Bezug zum Pfad-Thema (z. B. reine Kraftübungen bei Mae Geri)."""
if brief.semantic_strength < 0.55 or len(steps) < 2:
return []
off_topic: List[Dict[str, Any]] = []
total = len(steps)
for idx, step in enumerate(steps):
if step.get("is_ai_proposal") or step.get("exercise_id") is None:
continue
bundle = _load_exercise_text_bundle(cur, int(step["exercise_id"]))
phase = step_phase_for_index(brief, idx, total)
sem, sem_reasons = score_exercise_semantic_relevance(
title=bundle["title"],
summary=bundle["summary"],
goal=bundle["goal"],
variant_names=bundle["variant_names"],
brief=brief,
step_phase=phase,
)
if exercise_passes_path_semantic_gate(
semantic_score=sem,
title=bundle["title"],
summary=bundle["summary"],
goal=bundle["goal"],
brief=brief,
strict=True,
):
continue
if sem > _OFF_TOPIC_SEMANTIC_MAX:
continue
off_topic.append(
{
"step_index": idx,
"exercise_id": int(step["exercise_id"]),
"title": step.get("title") or bundle["title"],
"semantic_score": round(sem, 4),
"expected_phase": phase,
"issue": "off_topic",
"reasons": sem_reasons[:3],
}
)
return off_topic
def parse_llm_suggested_new_exercises(
llm_qa: Optional[Mapping[str, Any]],
*,
brief: PlanningSemanticBrief,
step_count: int,
) -> List[Dict[str, Any]]:
"""Strukturierte Neuanlage-Vorschläge aus LLM-Pfad-QS."""
if not llm_qa:
return []
raw = llm_qa.get("suggested_new_exercises")
if not isinstance(raw, list):
return []
topic = (brief.primary_topic or "Technik").strip()
out: List[Dict[str, Any]] = []
for item in raw[:5]:
if not isinstance(item, dict):
continue
title_hint = str(item.get("title_hint") or item.get("title") or "").strip()
if len(title_hint) < 3:
title_hint = f"{topic} — Zwischenschritt"
sketch = str(item.get("sketch") or item.get("goal_hint") or item.get("rationale") or "").strip()
phase = str(item.get("phase") or item.get("expected_phase") or "vertiefung").strip()
rationale = str(item.get("rationale") or "").strip()
insert_after = item.get("insert_after_step_index")
if insert_after is None:
insert_after = item.get("insert_after_index")
try:
insert_idx = int(insert_after) if insert_after is not None else max(0, step_count // 2 - 1)
except (TypeError, ValueError):
insert_idx = max(0, step_count // 2 - 1)
insert_idx = max(0, min(step_count - 2, insert_idx))
out.append(
{
"source": "llm_suggested",
"insert_after_index": insert_idx,
"title_hint": title_hint[:280],
"sketch": sketch[:1200],
"phase": phase,
"rationale": rationale[:500],
}
)
return out
def find_step_pair_index(
steps: Sequence[Mapping[str, Any]],
from_exercise_id: int,
to_exercise_id: int,
) -> Optional[int]:
for i in range(len(steps) - 1):
a = steps[i]
b = steps[i + 1]
if a.get("exercise_id") is None or b.get("exercise_id") is None:
continue
if int(a["exercise_id"]) == int(from_exercise_id) and int(b["exercise_id"]) == int(to_exercise_id):
return i
return None
def build_path_qa_summary(
*,
gaps: Sequence[Mapping[str, Any]],
bridge_inserts: Sequence[Mapping[str, Any]],
ai_proposals: Sequence[Mapping[str, Any]],
gap_fill_offers: Optional[Sequence[Mapping[str, Any]]] = None,
off_topic_steps: Optional[Sequence[Mapping[str, Any]]] = None,
llm_qa: Optional[Mapping[str, Any]],
llm_applied: bool,
reorder_applied: bool = False,
reorder_notes: Optional[Sequence[str]] = None,
) -> Dict[str, Any]:
offers = list(gap_fill_offers or [])
off_topic = list(off_topic_steps or [])
summary: Dict[str, Any] = {
"gap_count": len(gaps),
"large_gaps": list(gaps),
@ -368,6 +498,10 @@ def build_path_qa_summary(
"bridge_inserts": list(bridge_inserts),
"ai_proposal_count": len(ai_proposals),
"ai_proposals": list(ai_proposals),
"gap_fill_offer_count": len(offers),
"gap_fill_offers": offers,
"off_topic_count": len(off_topic),
"off_topic_steps": off_topic,
"llm_qa_applied": llm_applied,
"reorder_applied": reorder_applied,
"reorder_notes": list(reorder_notes or []),
@ -379,20 +513,29 @@ def build_path_qa_summary(
summary["sequence_notes"] = list(llm_qa.get("sequence_notes") or [])
summary["topic_coverage"] = llm_qa.get("topic_coverage")
summary["recommendations"] = list(llm_qa.get("recommendations") or [])
summary["suggested_new_exercises"] = list(llm_qa.get("suggested_new_exercises") or [])
else:
summary["overall_ok"] = len(gaps) == 0
summary["overall_ok"] = len(gaps) == 0 and len(off_topic) == 0
summary["issues"] = [
f"Lücke zwischen „{g.get('from_title')}“ und „{g.get('to_title')}“ (Score {g.get('gap_score')})"
for g in gaps
] if gaps else []
if off_topic:
summary["issues"] = list(summary["issues"]) + [
f"Schritt „{o.get('title')}“ passt nicht zum Pfad-Thema"
for o in off_topic
]
return summary
__all__ = [
"apply_llm_path_reorder",
"build_path_qa_summary",
"detect_off_topic_steps",
"detect_path_gaps",
"find_step_pair_index",
"insert_bridge_exercises",
"measure_step_transition_gap",
"parse_llm_suggested_new_exercises",
"try_llm_qa_progression_path",
]

View File

@ -0,0 +1,64 @@
"""Tests Planungs-KI Phase E3 — Lücken-Angebote und Off-Topic."""
from planning_exercise_path_ai_fill import collect_gap_fill_specs
from planning_exercise_path_qa import parse_llm_suggested_new_exercises
from planning_exercise_semantics import build_semantic_brief
def test_parse_llm_suggested_new_exercises():
brief = build_semantic_brief("Mae Geri Perfektion")
llm_qa = {
"suggested_new_exercises": [
{
"title_hint": "Mae Geri Kraft am Sandsack",
"sketch": "Kraft und Schnelligkeit",
"phase": "vertiefung",
"insert_after_step_index": 1,
"rationale": "Zwischenschritt",
}
]
}
specs = parse_llm_suggested_new_exercises(llm_qa, brief=brief, step_count=5)
assert len(specs) == 1
assert specs[0]["insert_after_index"] == 1
assert "Mae Geri" in specs[0]["title_hint"]
def test_collect_gap_fill_specs_off_topic_and_unfilled():
brief = build_semantic_brief("Mae Geri Perfektion")
steps = [
{"exercise_id": 1, "title": "Mae Geri Kihon"},
{"exercise_id": 2, "title": "Präzision"},
{"exercise_id": 3, "title": "One Leg Squat"},
{"exercise_id": 4, "title": "Gleichgewichtstritt"},
]
unfilled = [
{
"from_exercise_id": 2,
"to_exercise_id": 3,
"expected_phase": "vertiefung",
"from_title": "Präzision",
"to_title": "One Leg Squat",
}
]
off_topic = [
{
"step_index": 2,
"exercise_id": 3,
"title": "One Leg Squat",
"expected_phase": "vertiefung",
}
]
specs = collect_gap_fill_specs(
steps=steps,
unfilled_gaps=unfilled,
off_topic_steps=off_topic,
llm_specs=[],
brief=brief,
goal_query="Mae Geri Perfektion",
)
sources = {s["source"] for s in specs}
assert "unfilled_gap" in sources
assert "off_topic" in sources
off = next(s for s in specs if s["source"] == "off_topic")
assert off["replace_step_index"] == 2
assert off["insert_after_index"] == 1

View File

@ -1,8 +1,8 @@
# Shinkan Jinkendo Version Information
APP_VERSION = "0.8.189"
APP_VERSION = "0.8.190"
BUILD_DATE = "2026-05-23"
DB_SCHEMA_VERSION = "20260531074"
DB_SCHEMA_VERSION = "20260531077"
MODULE_VERSIONS = {
"legal_documents": "1.4.0", # Admin: Live-Vorschau pro Abschnitt + modale Vollvorschau (Editor + Dokumentenliste)
@ -29,7 +29,7 @@ MODULE_VERSIONS = {
"skill_profiles": "1.0.0", # Phase 3: gewichtetes Fähigkeiten-Profil + skill-discovery/suggestions
"methods": "0.1.0",
"exercises": "2.37.0", # Planungs-KI P1: Szenario-Pipeline + Query-Intent-Overlay
"planning_exercise_suggest": "0.15.2", # Pfad-Gate: soft penalty + gestufter Fallback
"planning_exercise_suggest": "0.16.0", # E3: gap_fill_offers, Off-Topic, QA→KI-Pipeline
"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
@ -44,6 +44,15 @@ MODULE_VERSIONS = {
}
CHANGELOG = [
{
"version": "0.8.190",
"date": "2026-05-23",
"changes": [
"Planungs-KI Phase E3: gap_fill_offers nach LLM-QS — Lücken, Off-Topic, QS-Neuanlage.",
"Pfad-Builder UI: „Fehlende Schritte — mit KI anlegen“ + ExerciseAiQuickCreateModal.",
"Schwache Bibliotheks-Brücken ablehnen; Migration 077 Pfad-QA suggested_new_exercises.",
],
},
{
"version": "0.8.189",
"date": "2026-05-23",

View File

@ -1,8 +1,15 @@
/**
* Planungs-KI Phase C3: Ziel Übungspfad vorschlagen in Progressionsgraph speichern.
* Planungs-KI Phase C3/E3: Ziel Übungspfad vorschlagen Lücken mit KI anlegen in Graph speichern.
*/
import React, { useCallback, useState } from 'react'
import React, { useCallback, useEffect, useState } from 'react'
import api from '../utils/api'
import ExerciseAiQuickCreateModal from './exercises/ExerciseAiQuickCreateModal'
import ExerciseAiSuggestPreviewModal from './ExerciseAiSuggestPreviewModal'
import {
aiPreviewToQuickCreateDraft,
buildQuickCreateAiPreview,
buildQuickCreateExercisePayloadFromDraft,
} from '../utils/exerciseAiQuickCreate'
function emptyPathStep() {
return { exerciseId: null, exerciseTitle: '', variantId: null, variants: [], reasons: [] }
@ -27,9 +34,32 @@ function mapApiStepToRow(step) {
isAiProposal,
aiSuggestion: step?.ai_suggestion || null,
semanticScore: step?.semantic_score,
isOffTopic: false,
}
}
function mapCreatedExerciseToRow(ex, offer) {
return {
exerciseId: Number(ex.id),
proposalKey: null,
exerciseTitle: (ex.title || offer?.title_hint || '').trim() || `Übung #${ex.id}`,
variantId: null,
variants: [],
reasons: ['Neu angelegt zur Schließung einer Pfad-Lücke'],
isBridge: true,
isAiProposal: false,
aiSuggestion: null,
semanticScore: null,
isOffTopic: false,
}
}
const OFFER_SOURCE_LABELS = {
unfilled_gap: 'Lücke',
off_topic: 'Themenfremd',
llm_suggested: 'QS-Empfehlung',
}
export default function ExerciseProgressionPathBuilder({
graphId,
disabled = false,
@ -45,6 +75,32 @@ export default function ExerciseProgressionPathBuilder({
const [semanticBrief, setSemanticBrief] = useState(null)
const [pathQa, setPathQa] = useState(null)
const [pathSteps, setPathSteps] = useState([])
const [gapFillOffers, setGapFillOffers] = useState([])
const [focusAreas, setFocusAreas] = useState([])
const [quickCreateOpen, setQuickCreateOpen] = useState(false)
const [activeOffer, setActiveOffer] = useState(null)
const [quickTitle, setQuickTitle] = useState('')
const [quickSketch, setQuickSketch] = useState('')
const [quickFocusAreaId, setQuickFocusAreaId] = useState('')
const [quickCreateDraft, setQuickCreateDraft] = useState(null)
const [quickSaving, setQuickSaving] = useState(false)
const [quickAiError, setQuickAiError] = useState('')
useEffect(() => {
let cancelled = false
api
.listFocusAreas({ status: 'active' })
.then((rows) => {
if (!cancelled) setFocusAreas(Array.isArray(rows) ? rows : [])
})
.catch(() => {
if (!cancelled) setFocusAreas([])
})
return () => {
cancelled = true
}
}, [])
const patchStep = useCallback((idx, patch) => {
setPathSteps((prev) => prev.map((row, i) => (i === idx ? { ...row, ...patch } : row)))
@ -66,6 +122,139 @@ export default function ExerciseProgressionPathBuilder({
})
}, [])
const applyOffTopicFlags = (rows, qa) => {
const off = Array.isArray(qa?.off_topic_steps) ? qa.off_topic_steps : []
const indices = new Set(off.map((o) => Number(o.step_index)).filter(Number.isFinite))
return rows.map((row, idx) => ({ ...row, isOffTopic: indices.has(idx) }))
}
const insertExerciseFromOffer = useCallback((created, offer) => {
const row = mapCreatedExerciseToRow(created, offer)
setPathSteps((prev) => {
let next = [...prev]
const afterIdx = Number(offer?.insert_after_index)
const replaceIdx =
offer?.replace_step_index != null ? Number(offer.replace_step_index) : null
if (Number.isFinite(replaceIdx) && replaceIdx >= 0 && replaceIdx < next.length) {
next.splice(replaceIdx, 1, row)
} else if (Number.isFinite(afterIdx) && afterIdx >= 0 && afterIdx < next.length) {
next.splice(afterIdx + 1, 0, row)
} else {
next.push(row)
}
return applyOffTopicFlags(next, pathQa)
})
setGapFillOffers((prev) => prev.filter((o) => o.offer_id !== offer?.offer_id))
}, [pathQa])
const closeQuickCreate = () => {
if (quickSaving) return
setQuickCreateOpen(false)
setActiveOffer(null)
setQuickCreateDraft(null)
setQuickAiError('')
}
const openOfferQuickCreate = (offer) => {
setActiveOffer(offer)
setQuickTitle((offer?.title_hint || '').trim())
setQuickSketch((offer?.sketch || '').trim())
setQuickFocusAreaId('')
setQuickCreateDraft(null)
setQuickAiError('')
if (offer?.has_ai_payload && offer?.ai_suggestion) {
const preview = buildQuickCreateAiPreview(offer.ai_suggestion, {
sketchPlain: (offer?.sketch || '').trim(),
})
if (preview.hasSummaryProposal || preview.hasSkillChoices || preview.hasInstructionChoices) {
const focusId = focusAreas[0]?.id ? String(focusAreas[0].id) : ''
setQuickFocusAreaId(focusId)
setQuickCreateDraft(
aiPreviewToQuickCreateDraft(preview, {
title: (offer?.title_hint || '').trim(),
focusAreaId: focusId ? Number(focusId) : '',
sketchPlain: (offer?.sketch || '').trim(),
}),
)
setQuickCreateOpen(false)
return
}
}
setQuickCreateOpen(true)
}
const runQuickCreateAiSuggest = async () => {
const title = (quickTitle || '').trim()
if (title.length < 3) {
alert('Titel: mindestens 3 Zeichen.')
return
}
const sketch = (quickSketch || '').trim()
const focusId = parseInt(String(quickFocusAreaId).trim(), 10)
if (!Number.isFinite(focusId) || focusId < 1) {
alert('Bitte einen Fokusbereich wählen.')
return
}
const focusRow = (focusAreas || []).find((x) => Number(x.id) === focusId)
const focusHint = (focusRow?.name || '').trim()
setQuickAiError('')
setQuickCreateDraft(null)
setQuickSaving(true)
try {
const aiRes = await api.suggestExerciseAi({
title,
goal: sketch || undefined,
execution: '',
preparation: '',
trainer_notes: '',
focus_area_hint: focusHint || undefined,
focus_areas_context: [{ focus_area_id: focusId, is_primary: true }],
include_summary: true,
include_skills: true,
include_instructions: true,
})
const preview = buildQuickCreateAiPreview(aiRes, { sketchPlain: sketch })
if (!preview.hasSummaryProposal && !preview.hasInstructionChoices && !preview.hasSkillChoices) {
throw new Error('Die KI lieferte keinen verwertbaren Vorschlag.')
}
setQuickCreateDraft(
aiPreviewToQuickCreateDraft(preview, { title, focusAreaId: focusId, sketchPlain: sketch }),
)
setQuickCreateOpen(false)
} catch (e) {
console.error(e)
const msg = e?.message || String(e)
setQuickAiError(msg)
alert(msg || 'KI-Vorschlag fehlgeschlagen')
} finally {
setQuickSaving(false)
}
}
const applyQuickCreateDraft = async () => {
if (!quickCreateDraft || !activeOffer) return
setQuickSaving(true)
setQuickAiError('')
try {
const payload = buildQuickCreateExercisePayloadFromDraft(quickCreateDraft)
const created = await api.createExercise(payload)
if (!created?.id) throw new Error('Anlegen fehlgeschlagen')
insertExerciseFromOffer(created, activeOffer)
setQuickCreateDraft(null)
setActiveOffer(null)
} catch (e) {
console.error(e)
const msg = e?.message || String(e)
setQuickAiError(msg)
alert(msg || 'Übung konnte nicht angelegt werden')
} finally {
setQuickSaving(false)
}
}
const suggestPath = async () => {
const q = (goalQuery || '').trim()
if (q.length < 3) {
@ -85,16 +274,29 @@ export default function ExerciseProgressionPathBuilder({
include_llm_intent: true,
include_path_qa: true,
include_llm_path_qa: true,
include_path_reorder: true,
include_ai_gap_fill: true,
progression_graph_id: Number(graphId),
})
const rows = (Array.isArray(res?.steps) ? res.steps : []).map(mapApiStepToRow)
const qa = res?.path_qa || null
const rows = applyOffTopicFlags(
(Array.isArray(res?.steps) ? res.steps : []).map(mapApiStepToRow),
qa,
)
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)
setPathQa(qa)
setGapFillOffers(
Array.isArray(res?.gap_fill_offers)
? res.gap_fill_offers
: Array.isArray(qa?.gap_fill_offers)
? qa.gap_fill_offers
: [],
)
if (!segmentNotes.trim() && q) setSegmentNotes(q.slice(0, 400))
} catch (e) {
console.error(e)
@ -103,6 +305,7 @@ export default function ExerciseProgressionPathBuilder({
setTargetSummary(null)
setSemanticBrief(null)
setPathQa(null)
setGapFillOffers([])
} finally {
setLoading(false)
}
@ -119,7 +322,7 @@ export default function ExerciseProgressionPathBuilder({
alert(
skippedAi > 0
? 'Mindestens zwei gespeicherte Übungen nötig. KI-Vorschläge zuerst als Übung anlegen.'
: 'Mindestens zwei Schritte mit Übung nötig.'
: 'Mindestens zwei Schritte mit Übung nötig.',
)
return
}
@ -145,6 +348,7 @@ export default function ExerciseProgressionPathBuilder({
setTargetSummary(null)
setSemanticBrief(null)
setPathQa(null)
setGapFillOffers([])
if (typeof onSaved === 'function') await onSaved()
const msg =
skippedAi > 0
@ -170,7 +374,7 @@ export default function ExerciseProgressionPathBuilder({
<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.
prüft Lücken (ggf. Brücken-Übungen) und optional per LLM-QS. Fehlende Schritte können mit KI als Übung angelegt werden.
</p>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '10px', alignItems: 'flex-end' }}>
<div className="form-row" style={{ flex: '2 1 240px', marginBottom: 0 }}>
@ -179,7 +383,7 @@ export default function ExerciseProgressionPathBuilder({
className="form-input"
value={goalQuery}
onChange={(e) => setGoalQuery(e.target.value)}
placeholder="z. B. sichere Reaktion im Partnertraining aufbauen …"
placeholder="z. B. Von Erlernen bis zur Perfektion des Fußtritts Mae Geri …"
disabled={disabled || loading || saving}
/>
</div>
@ -263,9 +467,9 @@ export default function ExerciseProgressionPathBuilder({
{pathQa.bridge_insert_count} Brücken-Übung(en) aus der Bibliothek eingefügt.
</p>
) : null}
{Number(pathQa.ai_proposal_count) > 0 ? (
<p style={{ margin: '6px 0 0', color: 'var(--accent-dark)' }}>
{pathQa.ai_proposal_count} KI-Neuanlage-Vorschlag/Vorschläge vor dem Speichern als Übung anlegen.
{Number(pathQa.off_topic_count) > 0 ? (
<p style={{ margin: '6px 0 0', color: 'var(--danger)' }}>
{pathQa.off_topic_count} Schritt(e) ohne Bezug zum Pfad-Thema siehe Lücken-Angebote unten.
</p>
) : null}
{pathQa.reorder_applied ? (
@ -276,12 +480,64 @@ export default function ExerciseProgressionPathBuilder({
: ''}
</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}
{gapFillOffers.length > 0 ? (
<div
style={{
marginTop: '12px',
padding: '12px',
borderRadius: '8px',
border: '1px solid color-mix(in srgb, var(--accent) 40%, var(--border))',
background: 'color-mix(in srgb, var(--accent) 5%, var(--surface))',
}}
>
<strong style={{ fontSize: '13px' }}>Fehlende Schritte mit KI anlegen</strong>
<p style={{ fontSize: '12px', color: 'var(--text3)', margin: '6px 0 10px', lineHeight: 1.45 }}>
Die QS hat Lücken erkannt. Vorschlag prüfen, als Übung anlegen und in den Pfad einfügen.
</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px' }}>
{gapFillOffers.map((offer) => (
<div
key={offer.offer_id}
style={{
padding: '10px 12px',
borderRadius: '8px',
background: 'var(--surface2)',
border: '1px solid var(--border)',
}}
>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px', alignItems: 'flex-start' }}>
<div style={{ flex: '1 1 200px' }}>
<span className="exercise-tag" style={{ marginBottom: '6px', display: 'inline-block' }}>
{OFFER_SOURCE_LABELS[offer.source] || offer.source || 'Lücke'}
{offer.phase ? ` · ${offer.phase}` : ''}
</span>
<div style={{ fontSize: '13px', fontWeight: 600 }}>{offer.title_hint}</div>
{offer.rationale ? (
<p style={{ fontSize: '12px', color: 'var(--text2)', margin: '4px 0 0' }}>{offer.rationale}</p>
) : null}
{offer.from_title && offer.to_title ? (
<p style={{ fontSize: '11px', color: 'var(--text3)', margin: '4px 0 0' }}>
Zwischen {offer.from_title} und {offer.to_title}
{offer.replace_step_index != null ? ' (ersetzt themenfremden Schritt)' : ''}
</p>
) : null}
</div>
<button
type="button"
className="btn btn-primary"
style={{ fontSize: '12px', flexShrink: 0 }}
disabled={quickSaving}
onClick={() => openOfferQuickCreate(offer)}
>
Mit KI anlegen
</button>
</div>
</div>
))}
</div>
</div>
) : null}
@ -290,7 +546,7 @@ export default function ExerciseProgressionPathBuilder({
<div style={{ marginTop: '14px' }}>
{pathSteps.map((step, idx) => (
<div
key={`${step.exerciseId}-${idx}`}
key={`${step.exerciseId}-${step.proposalKey || ''}-${idx}`}
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))',
@ -299,13 +555,19 @@ export default function ExerciseProgressionPathBuilder({
marginBottom: '12px',
paddingBottom: '12px',
borderBottom: idx < pathSteps.length - 1 ? '1px dashed var(--border)' : 'none',
background: step.isOffTopic
? 'color-mix(in srgb, var(--danger) 6%, transparent)'
: undefined,
borderRadius: step.isOffTopic ? '8px' : undefined,
padding: step.isOffTopic ? '8px' : undefined,
}}
>
<div className="form-row" style={{ marginBottom: 0 }}>
<label className="form-label">
Schritt {idx + 1}
{step.isOffTopic ? ' (themenfremd)' : ''}
{step.isAiProposal ? ' (KI-Neu)' : step.isBridge ? ' (Brücke)' : ''}
{!step.isAiProposal && idx === 0 ? ' (Einstieg)' : ''}
{!step.isAiProposal && !step.isOffTopic && idx === 0 ? ' (Einstieg)' : ''}
{!step.isAiProposal && idx === pathSteps.length - 1 ? ' (Zielnähe)' : ''}
</label>
<div style={{ fontSize: '13px' }}>
@ -397,6 +659,9 @@ export default function ExerciseProgressionPathBuilder({
onClick={() => {
setPathSteps([])
setTargetSummary(null)
setSemanticBrief(null)
setPathQa(null)
setGapFillOffers([])
}}
>
Vorschlag verwerfen
@ -404,6 +669,40 @@ export default function ExerciseProgressionPathBuilder({
</div>
</>
) : null}
<ExerciseAiQuickCreateModal
open={quickCreateOpen}
onClose={closeQuickCreate}
searchLabel={activeOffer?.title_hint || goalQuery}
title={quickTitle}
onTitleChange={setQuickTitle}
sketch={quickSketch}
onSketchChange={setQuickSketch}
focusAreaId={quickFocusAreaId}
onFocusAreaChange={setQuickFocusAreaId}
focusAreas={focusAreas}
catalogsReady={focusAreas.length > 0}
busy={quickSaving}
error={quickAiError}
onRunAi={runQuickCreateAiSuggest}
/>
<ExerciseAiSuggestPreviewModal
draft={quickCreateDraft}
onDraftChange={setQuickCreateDraft}
onDiscard={() => {
setQuickCreateDraft(null)
if (activeOffer) setQuickCreateOpen(true)
}}
onApply={applyQuickCreateDraft}
focusAreas={focusAreas}
skillsCatalog={[]}
dialogTitle="Pfad-Lücke — KI-Entwurf bearbeiten"
hint="Texte anpassen, dann als Übung speichern und in den Pfad einfügen."
applyLabel={quickSaving ? 'Wird angelegt …' : 'Anlegen und in Pfad einfügen'}
applyDisabled={quickSaving}
zIndex={2100}
/>
</div>
)
}