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
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:
parent
8d1dd59c3c
commit
3450a9296a
|
|
@ -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';
|
||||
|
|
@ -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",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
]
|
||||
|
|
|
|||
64
backend/tests/test_planning_exercise_path_ai_fill.py
Normal file
64
backend/tests/test_planning_exercise_path_ai_fill.py
Normal 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
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user