Implement Phase E2 Enhancements for Planning Exercise Suggestion
All checks were successful
Deploy Development / deploy (push) Successful in 40s
Test Suite / pytest-backend (push) Successful in 51s
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 1m20s
All checks were successful
Deploy Development / deploy (push) Successful in 40s
Test Suite / pytest-backend (push) Successful in 51s
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 1m20s
- Introduced path reordering functionality using LLM with `ordered_step_indices`, allowing for dynamic adjustment of exercise progression paths. - Added AI gap filling capabilities, enabling the system to propose new exercises when unbridgeable gaps are detected. - Updated the backend to support new request parameters for path reordering and AI gap filling. - Enhanced frontend components to reflect these new features, including alerts for AI proposals and adjustments in exercise display. - Incremented version to 0.8.187 and updated changelog to document these significant enhancements in planning AI functionality.
This commit is contained in:
parent
c6b8c396ad
commit
c2c736dafc
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
196
backend/planning_exercise_path_ai_fill.py
Normal file
196
backend/planning_exercise_path_ai_fill.py
Normal file
|
|
@ -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",
|
||||
]
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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 ? (
|
||||
<p style={{ margin: '6px 0 0', color: 'var(--accent-dark)' }}>
|
||||
{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.
|
||||
</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.
|
||||
</p>
|
||||
) : null}
|
||||
{pathQa.reorder_applied ? (
|
||||
<p style={{ margin: '6px 0 0', color: 'var(--text2)' }}>
|
||||
Reihenfolge nach QS angepasst.
|
||||
{Array.isArray(pathQa.reorder_notes) && pathQa.reorder_notes[0]
|
||||
? ` ${pathQa.reorder_notes[0]}`
|
||||
: ''}
|
||||
</p>
|
||||
) : null}
|
||||
{Array.isArray(targetSummary?.top_skills) &&
|
||||
|
|
@ -276,12 +304,17 @@ export default function ExerciseProgressionPathBuilder({
|
|||
<div className="form-row" style={{ marginBottom: 0 }}>
|
||||
<label className="form-label">
|
||||
Schritt {idx + 1}
|
||||
{step.isBridge ? ' (Brücke)' : ''}
|
||||
{idx === 0 ? ' (Einstieg)' : idx === pathSteps.length - 1 ? ' (Zielnähe)' : ''}
|
||||
{step.isAiProposal ? ' (KI-Neu)' : step.isBridge ? ' (Brücke)' : ''}
|
||||
{!step.isAiProposal && idx === 0 ? ' (Einstieg)' : ''}
|
||||
{!step.isAiProposal && idx === pathSteps.length - 1 ? ' (Zielnähe)' : ''}
|
||||
</label>
|
||||
<div style={{ fontSize: '13px' }}>
|
||||
<strong>{step.exerciseTitle}</strong>
|
||||
<span style={{ color: 'var(--text3)' }}> (#{step.exerciseId})</span>
|
||||
{step.exerciseId ? (
|
||||
<span style={{ color: 'var(--text3)' }}> (#{step.exerciseId})</span>
|
||||
) : (
|
||||
<span style={{ color: 'var(--text3)' }}> — noch nicht in Bibliothek</span>
|
||||
)}
|
||||
</div>
|
||||
{step.reasons?.length ? (
|
||||
<ul
|
||||
|
|
@ -300,23 +333,29 @@ export default function ExerciseProgressionPathBuilder({
|
|||
</div>
|
||||
<div className="form-row" style={{ marginBottom: 0 }}>
|
||||
<label className="form-label">Variante</label>
|
||||
<select
|
||||
className="form-input"
|
||||
value={step.variantId ?? ''}
|
||||
onChange={(e) =>
|
||||
patchStep(idx, {
|
||||
variantId: e.target.value === '' ? null : parseInt(e.target.value, 10),
|
||||
})
|
||||
}
|
||||
disabled={!step.exerciseId}
|
||||
>
|
||||
<option value="">Gesamte Übung</option>
|
||||
{(step.variants || []).map((v) => (
|
||||
<option key={v.id} value={v.id}>
|
||||
{v.variant_name || `Variante #${v.id}`}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{step.isAiProposal ? (
|
||||
<p style={{ fontSize: '12px', color: 'var(--text3)', margin: 0 }}>
|
||||
Nach Anlage der Übung im Graph wählbar.
|
||||
</p>
|
||||
) : (
|
||||
<select
|
||||
className="form-input"
|
||||
value={step.variantId ?? ''}
|
||||
onChange={(e) =>
|
||||
patchStep(idx, {
|
||||
variantId: e.target.value === '' ? null : parseInt(e.target.value, 10),
|
||||
})
|
||||
}
|
||||
disabled={!step.exerciseId}
|
||||
>
|
||||
<option value="">Gesamte Übung</option>
|
||||
{(step.variants || []).map((v) => (
|
||||
<option key={v.id} value={v.id}>
|
||||
{v.variant_name || `Variante #${v.id}`}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '6px', flexWrap: 'wrap' }}>
|
||||
<button type="button" className="btn" style={{ fontSize: '12px', padding: '4px 8px' }} onClick={() => moveStep(idx, -1)}>
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user