diff --git a/.claude/docs/working/PLANNING_EXERCISE_SUGGEST_CONTEXT.md b/.claude/docs/working/PLANNING_EXERCISE_SUGGEST_CONTEXT.md index 3c97f27..e2d8e9c 100644 --- a/.claude/docs/working/PLANNING_EXERCISE_SUGGEST_CONTEXT.md +++ b/.claude/docs/working/PLANNING_EXERCISE_SUGGEST_CONTEXT.md @@ -192,6 +192,7 @@ Wenn `hits` leer oder Trainer wählt „Mit KI anlegen“: | **C2** | Varianten in Trefferliste / Picker | ✅ **0.8.184** | | **C3** | Graph-Builder (Ziel → Pfad → speichern) | ✅ **0.8.185** | | **E** | Semantik-Schicht + Pfad-QA (Lücken/Brücken/LLM-QS) | ✅ **0.8.186** | +| **E2** | Pfad-Neuordnung + KI-Lückenfüller | ✅ **0.8.187** | | **D** | Neu-Anlage: Pack an `suggestExerciseAi` | 🔲 | --- @@ -477,6 +478,12 @@ Nach Pfad-Bildung: **Pfad-Schritte:** Semantic Brief + Entwicklungsphase in **allen** Schritten (nicht nur Schritt 1). +### Phase E2 (0.8.187) + +- **LLM-QS → Neuordnung:** `ordered_step_indices` im Prompt `planning_exercise_path_qa` (Migration **076**) +- **KI-Lückenfüller:** `planning_exercise_path_ai_fill.py` — `is_ai_proposal` wenn Bibliothek keine Brücke liefert +- Request: `include_path_reorder`, `include_ai_gap_fill` + --- ## 23. Backlog (offen) diff --git a/backend/migrations/076_ai_prompt_planning_path_qa_reorder.sql b/backend/migrations/076_ai_prompt_planning_path_qa_reorder.sql new file mode 100644 index 0000000..99616b4 --- /dev/null +++ b/backend/migrations/076_ai_prompt_planning_path_qa_reorder.sql @@ -0,0 +1,60 @@ +-- Migration 076: Planungs-Pfad-QA — Neuordnung + KI-Lückenfüller (Phase E2) + +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? + +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. + +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": ["…"] +}$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? + +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. + +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": ["…"] +}$t$ +WHERE slug = 'planning_exercise_path_qa'; diff --git a/backend/planning_exercise_path_ai_fill.py b/backend/planning_exercise_path_ai_fill.py new file mode 100644 index 0000000..32775fd --- /dev/null +++ b/backend/planning_exercise_path_ai_fill.py @@ -0,0 +1,196 @@ +""" +Planungs-KI Phase E2: KI-Neuanlage-Vorschläge für unüberbrückbare Pfad-Lücken. +""" +from __future__ import annotations + +import logging +from typing import Any, Dict, Mapping, Optional +import uuid + +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_semantics import PlanningSemanticBrief + +_logger = logging.getLogger("shinkan.planning_exercise_path_ai_fill") + + +def _build_gap_ai_context( + *, + goal_query: str, + brief: PlanningSemanticBrief, + step_a: Mapping[str, Any], + step_b: Mapping[str, Any], + gap: Mapping[str, Any], +) -> 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." + ) + focus_hint = topic if brief.topic_type == "technique" else None + if brief.must_phrases: + focus_hint = ", ".join(brief.must_phrases[:2]) + + return ExerciseFormAiPromptContext( + title=title[:280], + goal=goal[:8000], + execution=None, + focus_hint=focus_hint, + ) + + +def ai_proposal_to_path_step( + *, + ai_payload: Mapping[str, Any], + ctx_title: str, + gap: Mapping[str, Any], + step_a: Mapping[str, Any], + step_b: Mapping[str, Any], +) -> Dict[str, Any]: + summary_text = "" + summary_obj = ai_payload.get("summary") + if isinstance(summary_obj, dict): + summary_text = str(summary_obj.get("text") or "").strip() + elif isinstance(summary_obj, str): + summary_text = summary_obj.strip() + + proposal_key = f"ai-{uuid.uuid4().hex[:10]}" + title = (ctx_title or "").strip() or "KI-Vorschlag (Brücke)" + reasons = ["KI-Neuanlage-Vorschlag — Lücke ohne passende Bibliotheks-Übung"] + + return { + "exercise_id": None, + "proposal_key": proposal_key, + "variant_id": None, + "title": title, + "summary": summary_text or None, + "score": None, + "semantic_score": None, + "reasons": reasons, + "variants": [], + "is_bridge": True, + "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"]), + "gap_score": gap.get("gap_score"), + "expected_phase": gap.get("expected_phase"), + }, + } + + +def try_suggest_ai_bridge_step( + cur, + *, + goal_query: str, + brief: PlanningSemanticBrief, + step_a: Mapping[str, Any], + step_b: Mapping[str, Any], + gap: Mapping[str, Any], +) -> Optional[Dict[str, Any]]: + """Ruft exercise AI suggest auf — kein Speichern in DB.""" + ctx = _build_gap_ai_context( + goal_query=goal_query, + brief=brief, + step_a=step_a, + step_b=step_b, + gap=gap, + ) + g_plain = strip_html_to_plain(ctx.goal) + if not g_plain.strip() and not (ctx.title or "").strip(): + return None + try: + payload = run_exercise_form_ai_suggestion( + cur, + ctx, + want_summary=True, + want_skills=True, + want_instructions=False, + ) + except Exception as exc: + _logger.warning("KI-Lückenfüller fehlgeschlagen: %s", exc) + return None + + if not payload: + return None + return ai_proposal_to_path_step( + ai_payload=payload, + ctx_title=ctx.title or "", + gap=gap, + step_a=step_a, + step_b=step_b, + ) + + +def insert_ai_proposals_for_gaps( + cur, + steps: list, + unfilled_gaps: list, + *, + goal_query: str, + 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 + + return out, proposals + + +__all__ = [ + "insert_ai_proposals_for_gaps", + "try_suggest_ai_bridge_step", +] diff --git a/backend/planning_exercise_path_builder.py b/backend/planning_exercise_path_builder.py index dfe44f9..2b47239 100644 --- a/backend/planning_exercise_path_builder.py +++ b/backend/planning_exercise_path_builder.py @@ -13,11 +13,13 @@ from pydantic import BaseModel, Field from tenant_context import TenantContext, library_content_visibility_sql from planning_exercise_profiles import PlanningTargetProfile from planning_exercise_path_qa import ( + apply_llm_path_reorder, build_path_qa_summary, detect_path_gaps, insert_bridge_exercises, try_llm_qa_progression_path, ) +from planning_exercise_path_ai_fill import insert_ai_proposals_for_gaps from planning_exercise_retrieval import run_multistage_planning_retrieval from planning_exercise_semantics import ( PlanningSemanticBrief, @@ -47,6 +49,8 @@ class ProgressionPathSuggestRequest(BaseModel): include_llm_intent: bool = True include_path_qa: bool = True include_llm_path_qa: bool = True + include_path_reorder: bool = True + include_ai_gap_fill: bool = True progression_graph_id: Optional[int] = Field(default=None, ge=1) exercise_kind_any: Optional[List[str]] = None @@ -325,11 +329,15 @@ def suggest_progression_path( gaps: List[Dict[str, Any]] = [] bridge_inserts: List[Dict[str, Any]] = [] + ai_proposals: List[Dict[str, Any]] = [] llm_qa: Optional[Dict[str, Any]] = None llm_qa_applied = False + reorder_applied = False + reorder_notes: List[str] = [] if body.include_path_qa: gaps = detect_path_gaps(cur, steps, brief=semantic_brief) + unfilled_gaps: List[Dict[str, Any]] = [] if gaps: bridge_fn = _make_bridge_search_fn( cur, @@ -342,7 +350,7 @@ def suggest_progression_path( semantic_brief=semantic_brief, planned_ids=planned_ids, ) - steps, bridge_inserts = insert_bridge_exercises( + steps, bridge_inserts, unfilled_gaps = insert_bridge_exercises( cur, steps, gaps, @@ -350,6 +358,15 @@ 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, @@ -360,11 +377,17 @@ def suggest_progression_path( bridge_inserts=bridge_inserts, ) + if body.include_path_reorder and llm_qa_applied and llm_qa: + steps, reorder_applied, reorder_notes = apply_llm_path_reorder(steps, llm_qa) + path_qa = build_path_qa_summary( gaps=gaps, bridge_inserts=bridge_inserts, + ai_proposals=ai_proposals, llm_qa=llm_qa, llm_applied=llm_qa_applied, + reorder_applied=reorder_applied, + reorder_notes=reorder_notes, ) target_profile_summary = target_profile.to_summary_dict(cur) if target_profile else None @@ -373,6 +396,10 @@ def suggest_progression_path( retrieval_parts.append("path_qa") if llm_qa_applied: retrieval_parts.append("llm_path_qa") + if reorder_applied: + retrieval_parts.append("path_reorder") + if ai_proposals: + retrieval_parts.append("ai_gap_fill") return { "goal_query": goal_query, diff --git a/backend/planning_exercise_path_qa.py b/backend/planning_exercise_path_qa.py index b690cd1..8df530f 100644 --- a/backend/planning_exercise_path_qa.py +++ b/backend/planning_exercise_path_qa.py @@ -187,16 +187,18 @@ def insert_bridge_exercises( brief: PlanningSemanticBrief, bridge_search_fn: Callable[..., List[Dict[str, Any]]], max_inserts: int = _MAX_BRIDGE_INSERTS, -) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]]]: +) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]], List[Dict[str, Any]]]: """ Fügt zwischen großen Lücken Brücken-Übungen ein. bridge_search_fn(from_step, to_step, gap) -> hits + Returns: (steps, bridge_inserts, unfilled_gaps) """ if not gaps: - return steps, [] + return steps, [], [] - used_ids = {int(s["exercise_id"]) for s in steps} + used_ids = {int(s["exercise_id"]) for s in steps if s.get("exercise_id") is not None} inserts: List[Dict[str, Any]] = [] + unfilled: List[Dict[str, Any]] = [] out = list(steps) gap_by_pair = { @@ -207,6 +209,9 @@ def insert_bridge_exercises( while i < len(out) - 1 and len(inserts) < max_inserts: a = out[i] b = out[i + 1] + if a.get("exercise_id") is None or b.get("exercise_id") is None: + i += 1 + continue key = (int(a["exercise_id"]), int(b["exercise_id"])) gap = gap_by_pair.get(key) if not gap: @@ -221,6 +226,7 @@ def insert_bridge_exercises( step_b_id=int(b["exercise_id"]), ) if not bridge_hit: + unfilled.append(gap) i += 1 continue @@ -253,7 +259,7 @@ def insert_bridge_exercises( ) i += 2 - return out, inserts + return out, inserts, unfilled def try_llm_qa_progression_path( @@ -271,14 +277,29 @@ def try_llm_qa_progression_path( step_payload = [] for idx, step in enumerate(steps): + if step.get("is_ai_proposal") or step.get("exercise_id") is None: + step_payload.append( + { + "index": idx + 1, + "proposal_key": step.get("proposal_key"), + "title": step.get("title"), + "summary": strip_html_to_plain(step.get("summary"), max_len=400), + "is_bridge": bool(step.get("is_bridge")), + "is_ai_proposal": True, + "reasons": list(step.get("reasons") or [])[:3], + } + ) + continue bundle = _load_exercise_text_bundle(cur, int(step["exercise_id"])) step_payload.append( { "index": idx + 1, "exercise_id": int(step["exercise_id"]), + "proposal_key": step.get("proposal_key"), "title": step.get("title") or bundle["title"], "goal": strip_html_to_plain(bundle["goal"], max_len=400), "is_bridge": bool(step.get("is_bridge")), + "is_ai_proposal": False, "reasons": list(step.get("reasons") or [])[:3], } ) @@ -304,19 +325,52 @@ def try_llm_qa_progression_path( return None, False +def apply_llm_path_reorder( + steps: List[Dict[str, Any]], + llm_qa: Mapping[str, Any], +) -> Tuple[List[Dict[str, Any]], bool, List[str]]: + """ + Wendet LLM-Neuordnung an (ordered_step_indices = Permutation der aktuellen Indizes). + """ + raw = llm_qa.get("ordered_step_indices") + if not isinstance(raw, list) or len(raw) != len(steps): + return steps, False, [] + + try: + indices = [int(x) for x in raw] + except (TypeError, ValueError): + return steps, False, ["Neuordnung: ungültige Indizes"] + + if sorted(indices) != list(range(len(steps))): + return steps, False, ["Neuordnung: keine gültige Permutation — ignoriert"] + + if indices == list(range(len(steps))): + return steps, False, [] + + notes = [str(n) for n in (llm_qa.get("sequence_notes") or []) if str(n).strip()] + return [steps[i] for i in indices], True, notes + + def build_path_qa_summary( *, gaps: Sequence[Mapping[str, Any]], bridge_inserts: Sequence[Mapping[str, Any]], + ai_proposals: Sequence[Mapping[str, Any]], llm_qa: Optional[Mapping[str, Any]], llm_applied: bool, + reorder_applied: bool = False, + reorder_notes: Optional[Sequence[str]] = None, ) -> Dict[str, Any]: summary: Dict[str, Any] = { "gap_count": len(gaps), "large_gaps": list(gaps), "bridge_insert_count": len(bridge_inserts), "bridge_inserts": list(bridge_inserts), + "ai_proposal_count": len(ai_proposals), + "ai_proposals": list(ai_proposals), "llm_qa_applied": llm_applied, + "reorder_applied": reorder_applied, + "reorder_notes": list(reorder_notes or []), } if llm_qa: summary["overall_ok"] = bool(llm_qa.get("overall_ok", True)) @@ -335,6 +389,7 @@ def build_path_qa_summary( __all__ = [ + "apply_llm_path_reorder", "build_path_qa_summary", "detect_path_gaps", "insert_bridge_exercises", diff --git a/backend/tests/test_planning_exercise_path_qa.py b/backend/tests/test_planning_exercise_path_qa.py index 1133db8..5cf9824 100644 --- a/backend/tests/test_planning_exercise_path_qa.py +++ b/backend/tests/test_planning_exercise_path_qa.py @@ -1,5 +1,6 @@ """Tests Planungs-KI Phase E — Pfad-QA.""" from planning_exercise_path_builder import _pick_best_path_hit +from planning_exercise_path_qa import apply_llm_path_reorder def test_pick_best_path_hit_prefers_semantic_score(): @@ -14,3 +15,21 @@ def test_pick_best_path_hit_prefers_semantic_score(): def test_pick_best_path_hit_skips_used(): hits = [{"id": 1, "title": "A", "score": 0.5, "semantic_score": 0.5}] assert _pick_best_path_hit(hits, {1}) is None + + +def test_apply_llm_path_reorder_permutation(): + steps = [{"exercise_id": 1}, {"exercise_id": 2}, {"exercise_id": 3}] + reordered, applied, notes = apply_llm_path_reorder( + steps, + {"ordered_step_indices": [0, 2, 1], "sequence_notes": ["Vertiefung vor Anwendung"]}, + ) + assert applied is True + assert [s["exercise_id"] for s in reordered] == [1, 3, 2] + assert notes + + +def test_apply_llm_path_reorder_invalid_ignored(): + steps = [{"exercise_id": 1}, {"exercise_id": 2}] + reordered, applied, _ = apply_llm_path_reorder(steps, {"ordered_step_indices": [0, 0]}) + assert applied is False + assert reordered == steps diff --git a/backend/version.py b/backend/version.py index e826e46..2a2a977 100644 --- a/backend/version.py +++ b/backend/version.py @@ -1,6 +1,6 @@ # Shinkan Jinkendo Version Information -APP_VERSION = "0.8.186" +APP_VERSION = "0.8.187" BUILD_DATE = "2026-05-23" DB_SCHEMA_VERSION = "20260531074" @@ -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.14.0", # Phase E: Semantik-Schicht + Pfad-QA + "planning_exercise_suggest": "0.15.0", # Phase E2: Pfad-Neuordnung + KI-Lückenfüller "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,14 @@ MODULE_VERSIONS = { } CHANGELOG = [ + { + "version": "0.8.187", + "date": "2026-05-23", + "changes": [ + "Planungs-KI Phase E2: LLM-Pfad-QS kann Reihenfolge per ordered_step_indices anpassen.", + "Unüberbrückbare Lücken: KI-Neuanlage-Vorschläge (is_ai_proposal) statt nur Bibliotheks-Brücken.", + ], + }, { "version": "0.8.186", "date": "2026-05-23", diff --git a/docs/HANDOVER.md b/docs/HANDOVER.md index d308727..e81096b 100644 --- a/docs/HANDOVER.md +++ b/docs/HANDOVER.md @@ -1,7 +1,7 @@ # Shinkan Jinkendo – Entwicklungsstand & Handover **Stand:** 2026-05-23 -**App-Version / DB-Schema:** App **`0.8.186`** (Planungs-KI Phase E Semantik + Pfad-QA); DB **`20260531074`** — maßgeblich **`backend/version.py`**. +**App-Version / DB-Schema:** App **`0.8.187`** (Planungs-KI Phase E2); DB **`20260531074`** — maßgeblich **`backend/version.py`**. 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**. @@ -105,6 +105,7 @@ Das Schema ist gegenüber dem Code zurück: Migration **`022_skills_schema_compl | **C2** | Varianten in Trefferliste / Picker-Auswahl | ✅ **0.8.184** | | **C3** | Graph-Builder: Ziel → Pfad vorschlagen → in Graph speichern | ✅ **0.8.185** | | **E** | Semantik-Schicht (Brief, Phrasen-Score) + Pfad-QA (Lücken, Brücken, LLM-QS) | ✅ **0.8.186** | +| **E2** | Pfad-Neuordnung (LLM) + KI-Neuanlage bei unüberbrückbaren Lücken | ✅ **0.8.187** | | **D** | `planning_context` an `suggestExerciseAi` (Neu-Anlage) | 🔲 | **Backend:** `planning_exercise_suggest.py`, `planning_exercise_retrieval.py`, `planning_exercise_profiles.py`, `planning_exercise_target_pipeline.py`, `planning_exercise_progression.py` · Router `POST /api/planning/exercise-suggest` @@ -251,7 +252,7 @@ Das Schema ist gegenüber dem Code zurück: Migration **`022_skills_schema_compl 1. **Graph-Auswahl UI:** Dropdown neben Auto-Match; Rahmen-Slot mit Default-Graph verknüpfen. 2. **Enrichment:** Skills/Tags pro Technik (Feinauflösung statt nur Geri Waza). 3. **D — Neu-Anlage:** `planning_context_json` an `POST /api/exercises/ai/suggest`. -4. **E Feinschliff:** Pfad-QA → automatische Neuordnung; fehlende Schritte als KI-Neuanlage vorschlagen. +4. **E3:** KI-Vorschlag im UI direkt anlegen (Modal) · Embeddings für Freitext. ### Allgemein diff --git a/frontend/src/components/ExerciseProgressionPathBuilder.jsx b/frontend/src/components/ExerciseProgressionPathBuilder.jsx index e8548a5..37f11e1 100644 --- a/frontend/src/components/ExerciseProgressionPathBuilder.jsx +++ b/frontend/src/components/ExerciseProgressionPathBuilder.jsx @@ -13,13 +13,19 @@ function mapApiStepToRow(step) { const rawVid = step?.variant_id ?? step?.suggested_variant_id ?? null const variantId = rawVid != null && Number.isFinite(Number(rawVid)) && Number(rawVid) > 0 ? Number(rawVid) : null + const isAiProposal = Boolean(step?.is_ai_proposal) || step?.exercise_id == 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, + proposalKey: step?.proposal_key || null, + exerciseTitle: + (step?.title || '').trim() || + (step?.exercise_id ? `Übung #${step.exercise_id}` : 'KI-Vorschlag'), + variantId: isAiProposal ? null : variantId, + variants: isAiProposal ? [] : variants, reasons: Array.isArray(step?.reasons) ? step.reasons : [], isBridge: Boolean(step?.is_bridge), + isAiProposal, + aiSuggestion: step?.ai_suggestion || null, semanticScore: step?.semantic_score, } } @@ -108,8 +114,13 @@ export default function ExerciseProgressionPathBuilder({ return } const steps = pathSteps.filter((s) => s.exerciseId != null) + const skippedAi = pathSteps.filter((s) => s.isAiProposal).length if (steps.length < 2) { - alert('Mindestens zwei Schritte mit Übung nötig.') + alert( + skippedAi > 0 + ? 'Mindestens zwei gespeicherte Übungen nötig. KI-Vorschläge zuerst als Übung anlegen.' + : 'Mindestens zwei Schritte mit Übung nötig.' + ) return } const n = steps.length - 1 @@ -135,7 +146,11 @@ export default function ExerciseProgressionPathBuilder({ setSemanticBrief(null) setPathQa(null) if (typeof onSaved === 'function') await onSaved() - alert(`${n} Nachfolger-Kante(n) aus KI-Pfad gespeichert.`) + const msg = + skippedAi > 0 + ? `${n} Kante(n) gespeichert. ${skippedAi} KI-Vorschlag/Vorschläge nicht im Graph (noch nicht angelegt).` + : `${n} Nachfolger-Kante(n) aus KI-Pfad gespeichert.` + alert(msg) } catch (e) { console.error(e) setError(e.message || 'Speichern fehlgeschlagen') @@ -245,7 +260,20 @@ export default function ExerciseProgressionPathBuilder({ ) : null} {Number(pathQa.bridge_insert_count) > 0 ? (
- {pathQa.bridge_insert_count} Brücken-Übung(en) eingefügt (Lückenfüller). + {pathQa.bridge_insert_count} Brücken-Übung(en) aus der Bibliothek eingefügt. +
+ ) : null} + {Number(pathQa.ai_proposal_count) > 0 ? ( ++ {pathQa.ai_proposal_count} KI-Neuanlage-Vorschlag/Vorschläge — vor dem Speichern als Übung anlegen. +
+ ) : null} + {pathQa.reorder_applied ? ( ++ Reihenfolge nach QS angepasst. + {Array.isArray(pathQa.reorder_notes) && pathQa.reorder_notes[0] + ? ` ${pathQa.reorder_notes[0]}` + : ''}
) : null} {Array.isArray(targetSummary?.top_skills) && @@ -276,12 +304,17 @@ export default function ExerciseProgressionPathBuilder({+ Nach Anlage der Übung im Graph wählbar. +
+ ) : ( + + )}