From f3710ac0a11ef7512e4ab2475818f3e0f87a48a4 Mon Sep 17 00:00:00 2001 From: Lars Date: Fri, 12 Jun 2026 12:25:52 +0200 Subject: [PATCH 01/20] Enhance Planning Catalog Context Integration in Progression Path - Updated `PROJECT_STATUS.md` to reflect the addition of the Planning AI Progression Graph and its context in the roadmap. - Enhanced `DOMAIN_MODEL.md` with details on the new `planning_catalog_context` features, allowing trainers to manage curriculum stages and context. - Added tests in `test_planning_catalog_context.py` to validate the separation of LLM highlights from fix hints during QA processes. - Updated `HANDOVER.md` and `PLANNING_KI_ROADMAP.md` to reflect the latest app version and improvements in the planning context. - Enhanced frontend components to support the new planning catalog context, including updates to `ExerciseProgressionPathBuilder` and `ProgressionGraphEditor`. - Bumped version to 0.8.233 to reflect the new features and improvements. --- .claude/docs/PROJECT_STATUS.md | 2 +- .claude/docs/functional/DOMAIN_MODEL.md | 2 + .../tests/test_planning_catalog_context.py | 31 +++ docs/HANDOVER.md | 46 +++-- docs/architecture/PLANNING_KI_ROADMAP.md | 173 ++++++++++------ .../PLANNING_PROGRESSION_GRAPH_KI.md | 188 ++++++++++++++---- .../ExerciseProgressionPathBuilder.jsx | 114 ++++++++++- .../PlanningCatalogContextFields.jsx | 99 +++++++++ .../components/ProgressionFindingsPanel.jsx | 59 +++++- .../src/components/ProgressionGraphEditor.jsx | 89 +-------- frontend/src/utils/progressionGraphDraft.js | 38 ++++ 11 files changed, 643 insertions(+), 198 deletions(-) create mode 100644 frontend/src/components/PlanningCatalogContextFields.jsx diff --git a/.claude/docs/PROJECT_STATUS.md b/.claude/docs/PROJECT_STATUS.md index 3ab8915..209c8fd 100644 --- a/.claude/docs/PROJECT_STATUS.md +++ b/.claude/docs/PROJECT_STATUS.md @@ -15,7 +15,7 @@ **Plattform-Rechtstexte (P-01, 0.8.95–0.8.96):** Admin-Editor mit **Abschnitts- und Vollvorschau** (Markdown); fortlaufende Abschnittsnummerierung in der Anzeige/PDF (Darstellung, nicht DB-persistent). -**Parallel weiter relevant:** **Trainingsplan Phasen & Streams** (Migration **063**, Coach + Planung **0.8.137–0.8.140**; Handover **`docs/HANDOVER.md`** §3); **Trainingsrahmenprogramm** (036–037), **Progressionsgraph** (032–034) — siehe **`TRAINING_FRAMEWORK_SPEC.md`**. +**Parallel weiter relevant:** **Trainingsplan Phasen & Streams** (Migration **063**, Coach + Planung **0.8.137–0.8.140**; Handover **`docs/HANDOVER.md`** §3); **Trainingsrahmenprogramm** (036–037), **Progressionsgraph** (032–034) — siehe **`TRAINING_FRAMEWORK_SPEC.md`**. **Planungs-KI Progressionsgraph** (Roadmap-first, Auto-Optimierung, Katalog-Kontext **0.8.233**): Ist-Doku **`docs/architecture/PLANNING_PROGRESSION_GRAPH_KI.md`**, Handover **`docs/HANDOVER.md`** §2.8. **Referenz:** [`library/FEATURES_DELIVERED_2026-Q2.md`](library/FEATURES_DELIVERED_2026-Q2.md) Abschnitt 12 · Medien-Norm: [`technical/MEDIA_ASSETS_AND_ARCHIVE_SPEC.md`](technical/MEDIA_ASSETS_AND_ARCHIVE_SPEC.md) (inkl. **Abschnitt 11 Inline-Medien**, umgesetzt) · **Fachlicher Nutzerüberblick:** [`../../docs/FACHLICHE_NUTZERFUNKTIONEN.md`](../../docs/FACHLICHE_NUTZERFUNKTIONEN.md) diff --git a/.claude/docs/functional/DOMAIN_MODEL.md b/.claude/docs/functional/DOMAIN_MODEL.md index 158d784..b240724 100644 --- a/.claude/docs/functional/DOMAIN_MODEL.md +++ b/.claude/docs/functional/DOMAIN_MODEL.md @@ -465,6 +465,8 @@ skill_level_definitions ( **Fachliche Grenze aktuell:** Mehrere gleichwertige „Pakete“ paralleler Alternativen sind **modellierbar** (mehrere ausgehende Kanten), aber noch **nicht** über eine dedizierte „Alternativgruppe“ in der UI trivial pflegbar; siehe `technical/TRAINING_FRAMEWORK_SPEC.md` §4. +**KI-Planung (Workbench, Stand 0.8.233):** Am Graph können Trainer neben Kanten ein **`planning_roadmap`**-Artefakt (Curriculum-Stufen) und **`planning_catalog_context`** (Primärfokus, Stilrichtung, Trainingsstil, Zielgruppe aus den Katalog-Dimensionen §1) pflegen. Die Roadmap-first-Pipeline matcht Übungen pro Stufe; Didaktik und Reihenfolge kommen aus Roadmap + QS, nicht aus Technik-Hardcoding. Technische Details: **`docs/architecture/PLANNING_PROGRESSION_GRAPH_KI.md`**. Für **Trainingsplanung** (Einheit, Abschnitt, Rahmen-Slot) gelten dieselben Katalog- und Retrieval-Bausteine mit anderen Scopes — Phase G, siehe Roadmap **`PLANNING_KI_ROADMAP.md`**. + ### Trainingsrahmen‑Vorlage (Rahmenprogramm, CURR‑002 Stufe 2 / CURR‑009) **Abgrenzung:** Eine **einzeilige** Trainingsplan‑Mikrovorlage (`training_plan_template`) strukturiert **eine** Einheit; das **Rahmenprogramm** ist eine **eigene Bibliotheksentität** mit **sortierten Session‑Slots**, **mindestens einem** formulierten **Entwicklungsziel** (Zielliste, **CURR‑011**) und einem **vollständigen Ablauf** pro Slot (**`training_unit_sections` + `training_unit_section_items`** wie bei geplanten Einheiten — **CURR‑010** inhaltlich, technisch seit **037** identisch zur Planungsstruktur). Der persistierte **Progressionsgraph** zwischen Übungen bleibt **optional** (**CURR‑013**). diff --git a/backend/tests/test_planning_catalog_context.py b/backend/tests/test_planning_catalog_context.py index f525be6..21d6330 100644 --- a/backend/tests/test_planning_catalog_context.py +++ b/backend/tests/test_planning_catalog_context.py @@ -45,3 +45,34 @@ def test_normalize_planning_roadmap_with_catalog_context(): } ) assert out["planning_catalog_context"]["focus_areas"][0]["id"] == 4 + + +def test_multistage_qa_splits_llm_highlights_from_fix_hints(): + from planning_path_qa_pipeline import run_multistage_path_qa + + result = run_multistage_path_qa( + off_topic_steps=[], + stripped_off_topic=[ + { + "issue": "roadmap_unfilled", + "step_index": 1, + "reasons": ["Keine passende Übung"], + } + ], + gaps=[], + llm_qa={ + "overall_ok": True, + "quality_score": 0.88, + "recommendations": [ + "Gute didaktische Progression", + "Optional: Vertiefung Koordination", + ], + }, + llm_applied=True, + ) + hints = result["optimization_hints"] + llm_hints = [h for h in hints if h.get("issue") == "llm_recommendation"] + fix_hints = [h for h in hints if h.get("issue") != "llm_recommendation"] + assert len(llm_hints) >= 2 + assert any(h.get("issue") == "roadmap_unfilled" for h in fix_hints) + assert result["qa_tiers"][2]["recommendations"][0].startswith("Gute didaktische") diff --git a/docs/HANDOVER.md b/docs/HANDOVER.md index 06e5daf..b672d48 100644 --- a/docs/HANDOVER.md +++ b/docs/HANDOVER.md @@ -1,7 +1,7 @@ # Shinkan Jinkendo – Entwicklungsstand & Handover -**Stand:** 2026-06-07 -**App-Version / DB-Schema:** App **`0.8.208`** (Planungs-KI Phase D); DB **`20260606086`** — maßgeblich **`backend/version.py`**. +**Stand:** 2026-05-22 +**App-Version / DB-Schema:** App **`0.8.233`** (Planungs-KI F11–F14, Katalog-Kontext); DB siehe **`backend/version.py`** (`DB_SCHEMA_VERSION`, Migration **088**). 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**. @@ -89,7 +89,7 @@ Das Schema ist gegenüber dem Code zurück: Migration **`022_skills_schema_compl - **Varianten:** Speichern in der **Aktionsleiste** persistiert zuerst geänderte Varianten (`persistPendingVariantChanges`), dann Übungs-Stammdaten; „Variante anlegen“ als `type="button"` ohne verschachteltes Formular (`createVariantFromDraft`) - **Governance (Übungen):** Owner = `created_by`; Bearbeiten = Ersteller, Plattform-Admin oder `can_plan_in_club` bei `visibility=club`; Löschen `club` = nur `club_admin`; Details **`FEATURES_DELIVERED_2026-Q2.md`** §16, **`EXERCISES_API_SPEC.md`** Permissions -### 2.8 KI Assistenz Übungen & Planungs-KI (Stand **0.8.217**) +### 2.8 KI Assistenz Übungen & Planungs-KI (Stand **0.8.233**) **Zentrale Ist-Doku (Progressionsgraph-KI):** **`docs/architecture/PLANNING_PROGRESSION_GRAPH_KI.md`** — bei Drift zuerst dort pflegen. @@ -108,20 +108,31 @@ Das Schema ist gegenüber dem Code zurück: Migration **`022_skills_schema_compl | **F7** | `planning_skill_expectations` — Retrieval + UI + Gap | ✅ **0.8.215–216** | | **F8** | Editierbare `stage_specs` (Belastung, Erfolgskriterien) | ✅ **0.8.216** | | **F9** | `planning_roadmap` JSONB am Graph (Migration **088**) | ✅ **0.8.217** | +| **F10** | Stufen-Lernziel-Gate, kein blindes Rank-Fallback | ✅ **0.8.218** | +| **F11** | Auto-Rematch + Stufen-Spec-Refine + mehrstufige QS | ✅ **0.8.225–0.8.230** | +| **F12** | Post-Match-Gate, LLM-QA nach Rematch, Gap-Timing, `roadmap_unfilled`-Sync | ✅ **0.8.231–0.8.232** | +| **F13** | **`planning_catalog_context`** (Fokus/Stil/TT/ZG) im Match + Graph-Artefakt | ✅ **0.8.233** | +| **F14** | **`ProgressionGraphEditor`** — Slot-UI + Planungskontext-Dropdowns | ✅ **0.8.233** | -**Architektur (verbindlich):** Progressionsgraph = **Roadmap-first**, **keine Gruppenanalyse**. Bestehender Graph = **leichter Nachfolger-Bias** ab Schritt 2, **kein** automatisches Erweitern ab letztem Knoten (siehe Ist-Doku §5). Trainingsplanung = **eigene Pipeline** (Phase G), wiederverwendet `planning_skill_expectations`. +**Architektur (verbindlich):** Drei Schichten — (1) **Katalog-Dimensionen** (DB, jetzt im Match verdrahtet), (2) **Technik-Disambiguierung** (Code, nur bei `topic_type=technique`), (3) **Didaktik** (Roadmap + LLM-QS, nicht im Vokabular). Progressionsgraph = **Roadmap-first**, **keine Gruppenanalyse**. Bestehender Graph = **leichter Nachfolger-Bias** ab Schritt 2. Trainingsplanung = **eigene Pipeline** (Phase G) — Wiederverwendung der Bausteine, siehe Ist-Doku §16. -**Backend-Kern:** `planning_progression_roadmap.py`, `planning_exercise_path_builder.py`, `planning_skill_expectations.py`, `planning_exercise_form_context.py`, `planning_exercise_path_ai_fill.py`, `progression_graph_planning_artifact.py` +**Validierung (Mae Geri, Härtetest):** Pfad-QS vor Optimierung ~65 % → nach Trainer-Roadmap + KI-Gap-Fill **~88 % OK**. Workbench ist **universell** gedacht; Mae Geri war Referenzfall, kein Sonder-Patch. -**API:** `POST /api/planning/progression-path-suggest` · `PUT /api/exercise-progression-graphs/:id` (`planning_roadmap`) · `POST …/edges/sequence` +**Backend-Kern:** `planning_progression_roadmap.py`, `planning_exercise_path_builder.py`, `planning_catalog_context.py`, `planning_path_rematch.py`, `planning_path_refine_stage.py`, `planning_path_qa_pipeline.py`, `planning_skill_expectations.py`, `planning_exercise_form_context.py`, `planning_exercise_path_ai_fill.py`, `progression_graph_planning_artifact.py` -**Frontend:** `ExerciseProgressionPathBuilder`, `ExerciseGapFillPrepModal`, `planningContextForExerciseAi.js` +**API:** `POST /api/planning/progression-path-suggest` · `PUT /api/exercise-progression-graphs/:id` (`planning_roadmap`, `planning_catalog_context`) · `POST …/edges/sequence` + +**Frontend:** **`ProgressionGraphEditor`** (primäre Workbench), `ExerciseProgressionPathBuilder`, `ExerciseGapFillPrepModal`, `progressionGraphDraft.js`, `planningContextForExerciseAi.js` **Offen (priorisiert):** -1. UI-Wizard (Scroll-Monolith → 4 Schritte) — **separater UI-Chat** -2. Graph-Erweiterungsmodus (Start ab Knoten) -3. Trainingsplanung Phase G (Gruppenkontext) -4. Kontext-Anzeige auf allen Pfad-Schritten +1. Dev-Regression: Gewaltschutz / Breitensport / Kinder (nicht nur Mae Geri) +2. **PathBuilder-Parität** — gleiche Katalog-Dropdowns wie GraphEditor +3. QS-UI — positive LLM-Hinweise als Highlights +4. UI-Wizard (4 Schritte: Ziel → Roadmap → Match → Lücken) +5. Graph-Erweiterungsmodus (Start ab Knoten) +6. Phase D′ — Auto KI-Gap-Fill bei persistent leeren Slots +7. **Trainingsplanung Phase G** — Gruppenkontext-Pack, Scopes `training_section` / `framework_slot` (Ist-Doku §16) +8. Technik-Katalog konfigurierbar (Backlog) #### Übungs-KI Formular / Schnellanlage (Stand **0.8.171**) @@ -256,11 +267,14 @@ Das Schema ist gegenüber dem Code zurück: Migration **`022_skills_schema_compl ### Planungs-KI (priorisiert) -1. **Phase F2:** LLM für Roadmap (Prompts **078**) + `roadmap_first` Retrieval aus `stage_specs`. -2. **Phase F4:** Roadmap-Review UI (Major Steps editierbar vor Übungs-Match). -3. **Enrichment:** Skills/Tags pro Technik (Feinauflösung statt nur Geri Waza). -4. **D — Neu-Anlage:** `planning_context_json` an `POST /api/exercises/ai/suggest`. -5. **Trainingsplanung G:** Kontext-Pack Gruppe/Historie — eigene Pipeline (`AI_PLANNING_KI_MULTISTAGE_FORECAST`); Mitai Workflow-Engine erst danach. +1. **Dev-Regression:** Katalog-Match für Gewaltschutz, Breitensport, Kinder — nicht nur Mae-Geri-Härtetest. +2. **PathBuilder-Parität:** `planning_catalog_context`-Dropdowns auch in `ExerciseProgressionPathBuilder`. +3. **QS-UI:** positive LLM-Empfehlungen als Highlights statt nur Optimierungspotenziale. +4. **UI-Wizard:** 4 Schritte (Ziel & Katalog → Roadmap → Match → Lücken); Backend-Pipeline unverändert. +5. **Phase D′:** automatisches KI-Gap-Fill bei persistent `roadmap_unfilled`. +6. **Trainingsplanung G0–G4:** Katalog in Einheits-Editor, Scopes `training_section`/`framework_slot`, Abschnitts-QS, Gruppenkontext-Pack — Details **`PLANNING_PROGRESSION_GRAPH_KI.md`** §16, **`PLANNING_KI_ROADMAP.md`**. +7. **Technik-Katalog externalisieren** (Backlog): `concept_groups` konfigurierbar statt Code-Tuples. +8. **Mitai Workflow-Engine** erst nach stabiler Phase G. ### Allgemein diff --git a/docs/architecture/PLANNING_KI_ROADMAP.md b/docs/architecture/PLANNING_KI_ROADMAP.md index ee16d23..de73bbf 100644 --- a/docs/architecture/PLANNING_KI_ROADMAP.md +++ b/docs/architecture/PLANNING_KI_ROADMAP.md @@ -1,7 +1,7 @@ # Planungs-KI — Produkt-Roadmap **Stand:** 2026-05-22 -**App-Version:** **0.8.217** — maßgeblich `backend/version.py` +**App-Version:** **0.8.233** — maßgeblich `backend/version.py` Diese Roadmap ergänzt die **Architektur-Refaktor-Roadmap** (`UMSETZUNGSPLAN_ROADMAP.md`) und gilt **nur für KI-gestützte Trainingsplanungsunterstützung**. @@ -13,9 +13,10 @@ Diese Roadmap ergänzt die **Architektur-Refaktor-Roadmap** (`UMSETZUNGSPLAN_ROA ## Strategische Entscheidung (verbindlich) 1. **Progressionsgraph:** Planung **vom Ziel rückwärts** (Roadmap-first), nicht Bibliothek-first. -2. **Keine Gruppenanalyse** im Graphen — Kontext = Zieltext, Thema, Schrittanzahl, optional Graph-Kanten. -3. **Trainingsplanung** (Einheit, Rahmen, Abschnitt): eigene Pipeline später, **mit** Gruppenkontext — siehe `AI_PLANNING_KI_MULTISTAGE_FORECAST.md` S0–S4. -4. **Orchestrierung:** Workflow-**lite** jetzt (`planning_progression_roadmap.py`); Mitai Workflow-Engine **später**, wenn 2–3 Pipelines stabil sind. +2. **Keine Gruppenanalyse** im Graphen — Kontext = Zieltext, Katalog-Dimensionen, Start/Ziel, Roadmap, optional Graph-Kanten. +3. **Drei Schichten statt monolithischem Vokabular:** Katalog (DB) · Technik-Disambiguierung (Code, nur bei Technik-Themen) · Didaktik (Roadmap + LLM-QS). +4. **Trainingsplanung** (Einheit, Rahmen, Abschnitt): eigene Pipeline (Phase G), **mit** Gruppenkontext — siehe `AI_PLANNING_KI_MULTISTAGE_FORECAST.md` S0–S4 und Ist-Doku §16. +5. **Orchestrierung:** Workflow-**lite** jetzt (`planning_progression_roadmap.py`, `planning_exercise_path_builder.py`); Mitai Workflow-Engine **später**, wenn Phase G stabil ist. --- @@ -27,84 +28,138 @@ Diese Roadmap ergänzt die **Architektur-Refaktor-Roadmap** (`UMSETZUNGSPLAN_ROA | A–C2 | Übungssuche | Voll-Library, Graph, Varianten | ✅ | | C3 | Progressionsgraph | Pfad-Builder (retrieval-first) | ✅ | | E–E3 | Progressionsgraph | Semantik, QA, Lücken-Angebote | ✅ | -| **F0–F4** | Progressionsgraph | Roadmap-Pipeline, LLM, roadmap-first, UI Review | ✅ **0.8.204–209** | +| **F0–F4** | Progressionsgraph | Roadmap-Pipeline, LLM, roadmap_first, UI Review | ✅ **0.8.204–209** | | **F5–F9** | Progressionsgraph | Start/Ziel, Gap-Prep, Skill-Expectations, Persistenz | ✅ **0.8.210–217** | +| **F10** | Progressionsgraph | Stufen-Lernziel-Gate, kein blindes Rank-Fallback | ✅ **0.8.218** | +| **F11–F12** | Progressionsgraph | Auto-Rematch, Spec-Refine, QS-Pipeline-Timing | ✅ **0.8.225–0.8.232** | +| **F13–F14** | Progressionsgraph | Katalog-Kontext + GraphEditor-Workbench | ✅ **0.8.233** | | D | Übungs-Neuanlage | `planning_context` an `suggestExerciseAi` | ✅ **0.8.208** | -| **UX** | Progressionsgraph | Wizard/Stepper statt Scroll-UI | 🔲 | -| G | Trainingsplanung | Kontext-Pack Gruppe/Historie, S0–S4 | 🔲 | -| H | Plattform | Mitai-Workflow-Engine (optional) | 🔲 Backlog | +| **UX** | Progressionsgraph | Wizard/Stepper; PathBuilder-Parität Katalog | 🔲 | +| **D′** | Progressionsgraph | Auto KI-Gap-Fill bei persistent leeren Slots | 🔲 Backlog | +| **G** | Trainingsplanung | Kontext-Pack Gruppe/Historie, G0–G4 | 🔲 | +| **H** | Plattform | Technik-Katalog konfigurierbar; Mitai-Workflow | 🔲 Backlog | --- -## Phase F — Progressions-Roadmap (aktiver Fokus) +## Phase F — Progressions-Roadmap (abgeschlossen bis F14) -### F0 — Foundation (0.8.204) +Details und Module: **`PLANNING_PROGRESSION_GRAPH_KI.md`**. -- [x] Spec `PLANNING_PROGRESSION_ROADMAP_SPEC.md` -- [x] Modul `planning_progression_roadmap.py` (Pydantic, Pipeline-Skeleton) -- [x] Migration **078** Prompt-Slugs (Zielanalyse, Roadmap) -- [x] API: `include_roadmap_preview` auf `progression-path-suggest` -- [x] Doku: HANDOVER, PLANNING_EXERCISE_SUGGEST_CONTEXT, MULTISTAGE_FORECAST +### F0–F9 — (Kurz, siehe Ist-Doku) -### F1 — Deterministische Roadmap +- [x] F0 Foundation (0.8.204) — Spec, Pipeline-Skeleton, Prompts 078 +- [x] F1 Deterministische Roadmap — Phase A/B/C heuristisch +- [x] F2 LLM Roadmap (0.8.205) — Prompts 078/079 +- [x] F3 roadmap-first (0.8.206) — Match pro `stage_spec`, `roadmap_unfilled` +- [x] F4 UI Review (0.8.207) — `roadmap_override`, Major Steps editierbar +- [x] F5 Start/Ziel (0.8.210–214) — Prompt **087**, Zwei-Schritt-UI +- [x] F6 Gap-KI-Kontext (0.8.212–214) — `ExerciseGapFillPrepModal` +- [x] F7 Fähigkeiten-Scoring (0.8.215–216) — `planning_skill_expectations` +- [x] F8 Stufen-Details UI (0.8.216) — editierbare `stage_specs` +- [x] F9 Persistenz (0.8.217) — Migration **088** `planning_roadmap` JSONB -- [x] Phase A aus Semantic Brief -- [x] Phase B: `micro_objectives` aus `development_arc` + Konsolidierung auf N -- [x] Phase C: heuristische `stage_specs` -- [ ] pytest für Konsolidierung +### F10 — Stufen-Qualität (0.8.218) -### F2 — LLM Roadmap (0.8.205) +- [x] Stufen-Lernziel-Gate — kein Rank-Fallback ohne Pass +- [x] Anti-Pattern-Sanitizer, `stage_mismatch` → leerer Slot + Gap -- [x] Prompts **078/079** in `ai_prompts` — Code nur Slugs (`PROMPT_SLUG_*`) -- [x] `include_llm_roadmap` + `load_and_render_ai_prompt` + JSON-Validierung -- [x] Deterministischer Fallback wenn Prompt/OpenRouter fehlt -- [ ] Response/UI: genutzte `prompt_slugs` sichtbar machen (Admin-Hinweis) +### F11 — Auto-Optimierung (0.8.225–0.8.230) -### F3 — roadmap-first (0.8.206) +- [x] `planning_path_rematch.py` — Rematch-Schleife für `rematch_slot` / `roadmap_unfilled` +- [x] `planning_path_refine_stage.py` — Spec-Schärfung aus QS +- [x] `planning_path_qa_pipeline.py` — mehrstufige QS -- [x] Retrieval pro `major_step` + `stage_spec` statt iterativem Pfad-Bau -- [x] Gap-Angebote für unbesetzte Roadmap-Stufen (`roadmap_unfilled`) -- [x] QA/Lücken an Roadmap gekoppelt (`roadmap_first_lite`: keine Brücken/Reorder zwischen Major Steps) +### F12 — Pipeline-Timing & Sync (0.8.231–0.8.232) -### F4 — UI (0.8.207) +- [x] Post-Match-Gate vor Rematch-Akzeptanz +- [x] LLM Pfad-QS **nach** Rematch +- [x] Gap-Offers vor `path_qa`-Summary +- [x] Frontend: `applyMatchStepsToSlots` sync per `majorStepIndex` -- [x] Roadmap-Review im `ExerciseProgressionPathBuilder` -- [x] Major Steps editierbar (Phase, Lernziel, Reihenfolge) vor Übungs-Match -- [x] API `roadmap_only` + `roadmap_override` +### F13 — Katalog-Kontext (0.8.233) -### F5 — Start/Ziel (0.8.210–214) +- [x] `planning_catalog_context.py` — Fokus, Stil, Trainingsstil, Zielgruppe +- [x] Merge in `PlanningTargetProfile` + Text-Signale +- [x] Persistenz im Graph-Artefakt +- [x] Technik-Gates nur bei `topic_type == "technique"` -- [x] Strukturierte Felder `start_situation`, `target_state`, `roadmap_notes` -- [x] Prompt **087** `planning_progression_start_target` -- [x] Priorität: Trainer > KI > Regex (`resolve_roadmap_structured_input`) -- [x] Zwei-Schritt-UI: „Start/Ziel analysieren“ / „Roadmap vorschlagen“ +### F14 — GraphEditor Workbench (0.8.233) -### F6 — Gap-KI-Kontext (0.8.212–214) +- [x] `ProgressionGraphEditor` — primäre UI für Roadmap + Match + Lücken +- [x] Vier Planungskontext-Dropdowns im Editor +- [x] `progressionGraphDraft.js` — Artefakt + API-Payload -- [x] `ExerciseGapFillPrepModal` vor KI-Call -- [x] `planning_exercise_form_context.py` — Gap-Snapshot, `context_preview` -- [x] Migration **085** — `planning_context` in Übungs-Prompts +### Validierung (Referenz Mae Geri, 2026-05) -### F7 — Fähigkeiten-Scoring (0.8.215–216) +| Phase | Pfad-QS | Ergebnis | +|-------|---------|----------| +| Vor Roadmap/KI | ~65 % | Lücken, falsche Reihenfolge, Off-Topic | +| Nach Trainer-Roadmap + KI-Gap-Fill | **~88 % OK** | Vollständige Abdeckung; positive LLM-Hinweise | -- [x] `planning_skill_expectations.py` (Scopes: `progression_stage`, `progression_path`) -- [x] Pro-Stufe-Retrieval + `path_skill_expectations` + UI-Tags -- [x] `expected_skills` in Gap-Fill +**Fazit:** Workbench + Katalog + Roadmap sind universell; Technik-Hardcoding allein reicht für Didaktik nicht. -### F8 — Stufen-Details UI (0.8.216) +--- -- [x] Editierbare `stage_specs` in `roadmap_override` (Belastung, Erfolgskriterien, Vermeiden) +## UX — UI-Überarbeitung (offen) -### F9 — Persistenz (0.8.217) +- [ ] Wizard mit 4 Schritten (Ziel & Katalog → Roadmap → Match → Lücken) +- [ ] Progressive disclosure — Details in Panels +- [ ] PathBuilder-Parität: gleiche Katalog-Dropdowns wie GraphEditor +- [ ] QS-UI: positive LLM-Hinweise als Highlights +- Briefing: `PLANNING_PROGRESSION_GRAPH_KI.md` §12 -- [x] Migration **088** — `planning_roadmap` JSONB am Graph -- [x] Laden/Speichern über `GET/PUT` Graph + Sequenz-Endpoint +--- -### UX — UI-Überarbeitung (offen) +## Phase D′ — Auto Gap-Fill (Backlog) -- [ ] Wizard mit 4 Schritten (Ziel → Roadmap → Match → Lücken) -- [ ] Progressive disclosure — Details in Panels, nicht alles gleichzeitig -- [ ] Briefing: `PLANNING_PROGRESSION_GRAPH_KI.md` §10 +- [ ] Bei persistent `roadmap_unfilled` automatisch KI-Vorschlag vorbereiten (ohne manuelles Modal) +- [ ] Governance: Trainer bestätigt vor Persistenz + +--- + +## Phase G — Trainingsplanung (komplexere Domäne) + +**Ziel:** Einheiten, Rahmen-Slots, Abschnitte und parallele Streams KI-gestützt planen — **ohne** zweite Retrieval-Welt. + +### Wiederverwendung aus Progressionsgraph + +| Baustein | Progressionsgraph | Trainingsplanung | +|----------|-------------------|------------------| +| `PlanningTargetProfile` | Curriculum-Query + Katalog | Einheit + Abschnitt + Slot + Katalog + Historie | +| `planning_catalog_context` | Am Graph gespeichert | Pro Einheit / Slot / Voreinstellung | +| `planning_skill_expectations` | `progression_stage`, `progression_path` | **`training_section`**, **`framework_slot`** | +| `planning_exercise_retrieval` | Roadmap-Stufen-Match | `suggest_planning_exercises` — **bereits produktiv** | +| `planning_path_qa_pipeline` | Curriculum-QS | Abschnitts-QS (Kohärenz, Streams) | +| `planning_exercise_form_context` | Pfad-Lücken | Abschnitts-/Slot-Lücken | +| Roadmap-Pipeline | Major Steps über Wochen | **Nicht 1:1** — Phasen/Streams + Vorlagen | + +### Was Phase G neu braucht + +- Gruppen-/Historie-Kontext-Pack (`AI_PLANNING_KI_MULTISTAGE_FORECAST` S0–S4) +- Abschnitts-Didaktik — Dauer, Parallel-Streams, Coaching +- Rahmen-Blueprint-Anbindung (`training_framework_programs`, Slot-Blueprints) +- Eigene Orchestrierung pro Einheit (kein Curriculum über N Wochen) + +### Integrations-Reihenfolge G0–G4 + +| Schritt | Inhalt | Abhängigkeit | +|---------|--------|--------------| +| **G0** | Katalog in Einheits-Editor → bestehende Suggest-Pipeline | F13 ✅ | +| **G1** | Scope `training_section` + Skill-Erwartungen aktiv | F7 ✅ | +| **G2** | Abschnitts-QS (Hint-Struktur wie Graph) | F11–F12 ✅ | +| **G3** | Framework-Slot + Gap-Fill | G0, G1 | +| **G4** | Gruppenkontext-Pack | G0–G3 | + +**Nicht:** Roadmap-first-Loop 1:1 auf Trainingseinheit mappen. + +Details: **`PLANNING_PROGRESSION_GRAPH_KI.md`** §16 · Domäne **`DOMAIN_MODEL.md`** §1–2. + +--- + +## Phase H — Plattform (Backlog) + +- [ ] Technik-Disambiguierung konfigurierbar (DB statt `_GERI_TECHNIQUES` in Code) +- [ ] Mitai Workflow-Engine — erst wenn G0–G4 stabil --- @@ -112,10 +167,10 @@ Diese Roadmap ergänzt die **Architektur-Refaktor-Roadmap** (`UMSETZUNGSPLAN_ROA | Von | Nach | Hinweis | |-----|------|---------| -| F2 | Enrichment / Skills | Bessere Roadmap bei technikspezifischen Skills | -| F3 | F2 | LLM-Roadmap oder stabile heuristische B | -| G | F4 | Trainingsplanung kann Roadmap aus Graph referenzieren | -| H | G + F4 | Workflow-Engine lohnt bei verzweigten Planungsflows | +| F13 | G0 | Katalog-Kontext in Einheitsplanung | +| F7, F11 | G1, G2 | Skill-Expectations + QS-Muster | +| F4, F9 | G3 | Graph-Roadmap kann Rahmen referenzieren | +| G | H | Workflow-Engine lohnt bei verzweigten Planungsflows | --- diff --git a/docs/architecture/PLANNING_PROGRESSION_GRAPH_KI.md b/docs/architecture/PLANNING_PROGRESSION_GRAPH_KI.md index 5b2df2d..55d2c9b 100644 --- a/docs/architecture/PLANNING_PROGRESSION_GRAPH_KI.md +++ b/docs/architecture/PLANNING_PROGRESSION_GRAPH_KI.md @@ -1,7 +1,7 @@ # Progressionsgraph — KI-Planung (Ist-Stand) -**Stand:** 2026-05-22 (Dokumentation) · **App-Version:** **0.8.218** · **DB:** Migration **088** -**Maßgeblich für Code:** `backend/version.py` (`APP_VERSION`, `DB_SCHEMA_VERSION`) +**Stand:** 2026-05-22 (Dokumentation) · **App-Version:** **0.8.233** · **DB:** Migration **088** +**Maßgeblich für Code:** `backend/version.py` (`APP_VERSION`, `DB_SCHEMA_VERSION`, `MODULE_VERSIONS.planning_exercise_suggest`) > **Diese Datei ist die zentrale Referenz** für die KI-gestützte Planung im Progressionsgraph. > Ältere Abschnitte in `HANDOVER.md` §2.8 und `PLANNING_KI_ROADMAP.md` verweisen hierher. @@ -30,19 +30,20 @@ ## 2. Trainer-Workflow (UI) -Aktuell in `ExerciseProgressionPathBuilder.jsx` (eingebettet in `ExerciseProgressionGraphPanel.jsx`): +**Primär:** `ProgressionGraphEditor.jsx` (integrierter Slot-Editor, Phase B). +**Legacy/Parallel:** `ExerciseProgressionPathBuilder.jsx` (Scroll-Monolith — gleiche API, Katalog-Kontext-Dropdowns dort noch nachziehen). ``` -① Ziel eingeben (+ optional Start/Ziel-Felder manuell) -② „Start/Ziel analysieren“ (optional, start_target_only) -③ „Roadmap vorschlagen“ (roadmap_only, LLM-Roadmap) +① Ziel eingeben (+ Planungskontext: Primärfokus, Stil, Trainingsstil, Zielgruppe) +② Optional: Start/Ziel-Felder manuell oder „Start/Ziel analysieren“ +③ „Roadmap generieren“ (roadmap_only, LLM-Roadmap) ④ Roadmap bearbeiten (Major Steps + Stufen-Details) -⑤ „Übungen matchen“ (roadmap_first + roadmap_override) -⑥ Lücken mit KI schließen (gap_fill_offers + Vorbereitungs-Dialog) -⑦ „Pfad in Graph speichern“ (Sequenz-Kanten) +⑤ „Übungen matchen“ (roadmap_first + roadmap_override + Auto-QS/Rematch) +⑥ Lücken: KI-Angebote → „KI anlegen“ (Gap-Prep-Modal) → in Slot +⑦ „Graph speichern“ (planning_roadmap + optional Kanten-Sequenz) ``` -**Bekannte UX-Schuld:** Alle Schritte liegen auf **einer langen Scroll-Seite** — Überarbeitung als Wizard/Stepper ist geplant (separater UI-Chat). Briefing-Vorlage siehe unten §10. +**Bekannte UX-Schuld:** PathBuilder = lange Scroll-Seite; GraphEditor = kompakter, aber noch kein Wizard. Stepper geplant (separater UI-Chat). Briefing §12. --- @@ -51,6 +52,7 @@ Aktuell in `ExerciseProgressionPathBuilder.jsx` (eingebettet in `ExerciseProgres ```mermaid flowchart TB subgraph ui [Frontend] + PGE[ProgressionGraphEditor] EPB[ExerciseProgressionPathBuilder] GFM[ExerciseGapFillPrepModal] PCtx[planningContextForExerciseAi.js] @@ -71,6 +73,10 @@ flowchart TB subgraph match [Match + QA] PB[planning_exercise_path_builder.py] + PCC[planning_catalog_context.py] + REM[planning_path_rematch.py] + REF[planning_path_refine_stage.py] + QAP[planning_path_qa_pipeline.py] RET[planning_exercise_retrieval.py] PG[planning_exercise_progression.py] SEM[planning_exercise_semantics.py] @@ -88,11 +94,16 @@ flowchart TB end EPB --> PPS - EPB --> SEQ - EPB --> PUT + PGE --> PPS + PGE --> SEQ + PGE --> PUT GFM --> EAI PPS --> PR PPS --> PB + PB --> PCC + PB --> REM + PB --> REF + PB --> QAP PB --> RET PB --> PG PB --> PSE @@ -108,12 +119,18 @@ flowchart TB | Modul | Aufgabe | |--------|---------| | `planning_progression_roadmap.py` | Phasen A–C: Zielanalyse, Roadmap, `stage_specs`; Start/Ziel-Auflösung (Trainer > KI > Regex) | -| `planning_exercise_path_builder.py` | `suggest_progression_path`: roadmap_first Match, QA, Gap-Offers | +| `planning_exercise_path_builder.py` | `suggest_progression_path`: roadmap_first Match, Auto-QS, Rematch, Gap-Offers | +| `planning_catalog_context.py` | **Expliziter Katalog-Kontext** (Fokus, Stil, Trainingsstil, Zielgruppe) → `PlanningTargetProfile` | +| `planning_path_rematch.py` | Auto-Rematch betroffener Slots (`max_rematch_rounds`) | +| `planning_path_refine_stage.py` | Stufen-Spec-Verfeinerung bei `stage_mismatch` (Phase C) | +| `planning_path_qa_pipeline.py` | Mehrstufige QS → `optimization_hints` | | `planning_exercise_progression.py` | Graph auflösen, Nachfolger-Kanten für Retrieval-Bias | | `planning_skill_expectations.py` | Skill-Erwartungen pro Scope (`progression_stage`, `progression_path`, später `training_section`) | | `planning_exercise_form_context.py` | `planning_context` / Gap-Snapshot für Übungs-KI | | `planning_exercise_path_ai_fill.py` | Gap-Fill-Angebote, `goal_for_ai`, `context_preview` | | `progression_graph_planning_artifact.py` | Validierung `planning_roadmap` JSON (Schema v1, max. 64 KB) | +| `planning_exercise_profiles.py` | **Katalog-Scoring** (Fokus/Stil/TT/ZG/Skills) — gemeinsam mit Einheitsplanung | +| `planning_exercise_target_pipeline.py` | Query-Intent-Pipeline — Progressionsgraph nutzt `query_only`-Modus + Katalog-Overlay | --- @@ -131,10 +148,14 @@ flowchart TB | `start_target_only` | bool | Nur Start/Ziel-Analyse | | `roadmap_override` | object | Trainer-bearbeitete `major_steps` + `stage_specs` | | `start_situation`, `target_state`, `roadmap_notes` | string? | Strukturierte Eingabe (Priorität vor KI) | +| `planning_catalog_context` | object? | Primärfokus, Stilrichtung, Trainingsstil, Zielgruppe (IDs + `is_primary`) | | `include_llm_start_target` | bool | LLM-Extraktion Start/Ziel (Prompt **087**) | | `include_llm_roadmap` | bool | LLM Roadmap (Prompts **078/079**) | -| `include_llm_intent` | bool | LLM Intent für Semantic Brief (Roadmap-Vorschlag: **true** seit 0.8.217) | -| `include_path_qa`, `include_ai_gap_fill` | bool | QS, Lücken-Angebote | +| `include_llm_intent` | bool | LLM Intent für Semantic Brief | +| `auto_rematch_after_qa` | bool | Auto-Rematch nach QS (Default **true**) | +| `auto_refine_stage_spec` | bool | Stufen-Spec bei `stage_mismatch` schärfen (Default **true**) | +| `max_rematch_rounds` | int | Rematch-Runden 0–4 (Default **3**) | +| `include_path_qa`, `include_llm_path_qa`, `include_ai_gap_fill` | bool | QS, LLM-Ganzpfad, Lücken-Angebote | ### 4.2 Wichtige Response-Felder @@ -144,7 +165,27 @@ flowchart TB | `steps[]` | Gematchte Übungen; pro Schritt u. a. `roadmap_*`, `skill_expectations` | | `path_skill_expectations` | Pfadweite Skill-Erwartungen | | `gap_fill_offers[]` | Lücken mit `context_preview`, `goal_for_ai` | -| `path_qa` | QS inkl. `roadmap_qa_mode: roadmap_first_lite` | +| `path_qa` | QS inkl. `qa_tiers`, `optimization_hints`, `rematch_log`, `refine_log` | +| `target_profile_summary` | Erwartungsprofil inkl. Katalog-Dimensionen (nach Match) | +| `match_summary` | `library_matches`, `gap_fill_offer_count`, `roadmap_unfilled_count` | + +--- + +## 4.4 Planungskontext — Katalog vs. Technik-Vokabular + +Shinkan unterscheidet **drei Schichten** (kein monolithisches „Vokabular“): + +| Schicht | Was | Wo | Beispiel | +|---------|-----|-----|----------| +| **Katalog-Dimensionen** | Was für Training? | DB: `focus_areas`, `style_directions`, `training_types`, `target_groups`, `skills` | Gewaltschutz, Breitensport, Shotokan | +| **Disambiguierung (Technik)** | Verwechslungs-Nachbarn | Code: `planning_exercise_semantics.py` (`_GERI_TECHNIQUES`, …) | Mae Geri ≠ Mawashi Geri | +| **Didaktik / Kausalität** | Reihenfolge, Lernphasen | Roadmap + LLM Pfad-QS | Grundlagen vor Geschwindigkeit | + +**Seit 0.8.233:** `planning_catalog_context` im Request und im Graph-Artefakt (`planning_catalog_context` JSON). Fließt in `PlanningTargetProfile` → Hybrid-Retrieval (`score_exercise_against_target`: „Fokusbereich passend“, …). Zusätzlich additive Text-Signale aus Anfrage + Start/Ziel + Notizen (`planning_exercise_text_signals`). + +**Technik-Gates** (`technique_scope`, Geschwister-Ausschluss) nur bei `topic_type == "technique"` — Fokus-Pfade (Gewaltschutz, Fitness, …) werden nicht wie Mae-Geri-Pfade behandelt. + +Fallback: fehlt `planning_catalog_context` im Request, wird aus gespeichertem `planning_roadmap` am Graph geladen. ### 4.3 Prompt-Slugs (nur in `ai_prompts`, nie Hardcoding) @@ -157,17 +198,32 @@ flowchart TB --- -## 5. Roadmap-Match — Stufen-Qualität (0.8.218) +## 5. Roadmap-Match — Stufen-Qualität (0.8.218–0.8.233) Pro Major Step gilt: -1. **Stufen-Brief** — `semantic_brief_for_stage()` ergänzt `must_phrases` um das Stufen-Lernziel. -2. **Stufen-Gate** — `exercise_passes_stage_learning_goal_gate()` prüft Lernziel-Text in Titel/Summary/Ziel oder Mindest-`semantic_score`. -3. **Kein Fallback** — Bei `roadmap_first` wird **nicht** auf die globale `goal_query` zurückgefallen; passt keine Übung → **Lücke** (`roadmap_unfilled`) statt themenfremder Übung. -4. **Retrieval** — Bonus/Strafe im Hybrid-Score je nach Stufen-Passung. -5. **QS** — `detect_off_topic_steps` erkennt `stage_mismatch` anhand `roadmap_learning_goal`. +1. **Stufen-Brief** — `build_stage_match_brief()` aus Lernziel, `anti_patterns`, Erfolgskriterien, Pfad-Kontext. +2. **Stufen-Gate** — `exercise_passes_stage_fit()` / `exercise_passes_stage_learning_goal_gate()` auf vollem Übungstext. +3. **Kein blindes Rank-Fallback** — ohne Gate-Passung → `roadmap_unfilled`, nicht themenfremde Übung. +4. **Post-Match-Gate** — `_roadmap_step_passes_post_match_gate()` = gleiche QS wie `detect_off_topic_steps` (kein Rematch-Treffer, der sofort wieder `stage_mismatch` wäre). +5. **Retrieval** — Hybrid-Score: Volltext + Semantik + **Profil/Katalog** + Skill-Erwartungen + optional Graph-Bias. +6. **Auto-Optimierung (ein Match-Lauf):** + - **Phase B:** Rematch-Schleife (`planning_path_rematch.py`) für `rematch_slot` / `roadmap_unfilled` + - **Phase C:** `planning_path_refine_stage.py` — `anti_patterns` / Erfolgskriterien aus QS + - Purge persistent `stage_mismatch` → Slot leeren + KI-Gap + - LLM Pfad-QS **nach** Rematch auf finalem Pfad + - Gap-Offers für alle leeren Slots **vor** `path_qa`-Summary -Tests: `test_planning_roadmap_stage_match.py` +Tests: `test_planning_roadmap_stage_match.py`, `test_planning_path_rematch.py`, `test_planning_path_refine_stage.py`, `test_planning_catalog_context.py` + +### Referenz-Validierung (Mae Geri, 2026-05) + +| Phase | Pfad-QS | Ergebnis | +|-------|---------|----------| +| Vor Roadmap/KI-Anpassung | ~65 % | Strukturelle Lücken (Grundlagen, Reihenfolge, Zielgenauigkeit) | +| Nach Trainer-Roadmap + KI-Angebote in leeren Slots | **~88 % OK** | Vollständige Curriculum-Abdeckung; positive LLM-Empfehlungen | + +**Lesson:** Workbench + Katalog-Kontext + Roadmap sind der Hebel; Technik-Hardcoding allein reicht nicht für Didaktik. --- @@ -209,7 +265,15 @@ Gespeichert am **Graph-Container** (`exercise_progression_graphs`), Schema v1: "roadmap_notes": "…", "max_steps": 5, "progression_roadmap": { }, - "path_skill_expectations": { } + "path_skill_expectations": { }, + "planning_catalog_context": { + "focus_areas": [{ "id": 1, "is_primary": true }], + "style_directions": [], + "training_types": [{ "id": 2, "is_primary": true }], + "target_groups": [] + }, + "slot_contents": [ ], + "last_findings": { } } ``` @@ -307,26 +371,73 @@ Kontext-Helfer: `frontend/src/utils/planningContextForExerciseAi.js` | F7 | `planning_skill_expectations` (Retrieval + UI + Gap) | ✅ | 0.8.215–216 | | F8 | Editierbare `stage_specs` in UI | ✅ | 0.8.216 | | F9 | `planning_roadmap` Persistenz (Migration **088**) | ✅ | 0.8.217 | -| F10 | Stufen-Lernziel-Gate beim Match (kein goal_query-Fallback) | ✅ | 0.8.218 | -| **G** | Trainingsplanung eigene Pipeline + Graph-Referenz | 🔲 | — | -| **UX** | Wizard/Stepper statt Scroll-Monolith | 🔲 | separater Chat | +| F10 | Stufen-Lernziel-Gate + kein goal_query-Fallback | ✅ | 0.8.218 | +| **F11** | Auto-Rematch + Stufen-Spec-Refine + mehrstufige QS | ✅ | 0.8.225–0.8.230 | +| **F12** | Post-Match-Gate, LLM-QA nach Rematch, Gap-Timing, `roadmap_unfilled`-Sync | ✅ | 0.8.231–0.8.232 | +| **F13** | **Katalog-Kontext** (`planning_catalog_context`) im Match + Graph-Artefakt | ✅ | **0.8.233** | +| **F14** | `ProgressionGraphEditor` Slot-UI + Planungskontext-Dropdowns | ✅ | 0.8.233 | +| **G** | Trainingsplanung: eigene Pipeline + Wiederverwendung Bausteine (§16) | 🔲 | — | +| **UX** | Wizard/Stepper; PathBuilder-Parität Katalog | 🔲 | — | +| **H** | Technik-Disambiguierung konfigurierbar (DB statt Code-Tuples) | 🔲 | Backlog | +| **D′** | Auto Gap-Fill (KI generiert bei persistent `roadmap_unfilled`) | 🔲 | Backlog | --- ## 12. Offenes Backlog (priorisiert) -1. **UI-Überarbeitung** — Wizard mit 4 Schritten, progressive disclosure (Briefing unten) -2. **Graph-Erweiterungsmodus** — Start ab gewähltem Knoten / letzter Sequenz -3. **Kontext auf allen Pfad-Schritten** in UI (nicht nur Lücken) -4. **Trainingsplanung Phase G** — Pipeline mit Gruppenkontext, Wiederverwendung `planning_skill_expectations` -5. Enrichment / Prompt-Feintuning -6. Mitai Workflow-Engine (langfristig) +1. **Dev-Regression:** Gewaltschutz + Breitensport + Kinder (ohne Mae Geri) — Katalog-Match verifizieren +2. **PathBuilder-Parität** — gleiche `planning_catalog_context`-Dropdowns in `ExerciseProgressionPathBuilder` +3. **QS-UI** — positive LLM-Hinweise als „Highlights“, nicht als „Optimierungspotenziale“ +4. **UI-Wizard** — 4 Schritte (Ziel → Roadmap → Match → Lücken); Backend unverändert +5. **Graph-Erweiterungsmodus** — Start ab gewähltem Knoten / letzter Sequenz +6. **Phase D′** — automatisches KI-Gap-Fill bei persistent leeren Slots +7. **Trainingsplanung Phase G** — siehe §16 +8. **Technik-Katalog externalisieren** — konfigurierbare `concept_groups` (Backlog) +9. Graph-Metadaten: Primärfokus/Stil als Spalten (Reporting) +10. Mitai Workflow-Engine (langfristig) -### Briefing-Vorlage UI-Chat (Copy-Paste) +### Briefing-Vorlage UI-Chat -Siehe Nutzer-Chat 2026-05-22 oder `HANDOVER.md` §2.8 — Abschnitt „UI-Überarbeitung“. +Kern: Wizard ① Ziel & Planungskontext → ② Roadmap → ③ Match → ④ Lücken & Speichern; Backend-Pipeline unverändert lassen. -Kern: Wizard ① Ziel & Start/Ziel → ② Roadmap → ③ Match → ④ Lücken & Speichern; Backend-Pipeline unverändert lassen. +--- + +## 16. Wiederverwendung in der Trainingsplanung (Phase G) + +Die **komplexere Trainingsplanung** (Einheit, Rahmen-Slot, Abschnitt, parallele Streams) soll **keine zweite Retrieval-Welt** bauen, sondern bestehende Module mit **anderem Kontext-Pack** nutzen. + +### 16.1 Was Progressionsgraph liefert (Workbench-Muster) + +| Baustein | Progressionsgraph | Trainingsplanung (Ziel) | +|----------|-------------------|-------------------------| +| `PlanningTargetProfile` | Query + Katalog + Skills | Einheit + Abschnitt + Slot + Katalog + Historie | +| `planning_catalog_context` | Am Graph gespeichert | Pro Einheit / Slot / Trainer-Voreinstellung | +| `planning_skill_expectations` | `progression_stage` / `progression_path` | **`training_section`**, **`framework_slot`** | +| `planning_exercise_retrieval` | Roadmap-Stufen-Match | Abschnitts-Suche (`suggest_planning_exercises`) — **produktiv** | +| `planning_path_qa_pipeline` | Curriculum-QS | Abschnitts-QS (Kohärenz, Streams) | +| `planning_intent_context` | Pfad-Ausschlüsse → Stufen | Abschnitts-Guidance → Brief | +| `planning_exercise_form_context` | Pfad-Lücken | Abschnitts-/Slot-Lücken | +| Roadmap-Pipeline | Curriculum Major Steps | **Nicht 1:1** — Phasen/Streams + Vorlagen | +| Technik-Disambiguierung | bei `topic_type=technique` | nur bei explizitem Technik-Abschnitt | + +### 16.2 Was Phase G neu braucht + +- **Gruppen-/Historie-Kontext-Pack** (`AI_PLANNING_KI_MULTISTAGE_FORECAST` S0–S4) +- **Abschnitts-Didaktik** — Dauer, Parallel-Streams, Coaching (`training_unit_phases`) +- **Rahmen-Blueprint** — bereits `training_framework_programs` / Slot-Blueprints +- **Eigene Orchestrierung** pro Einheit — kein Curriculum über N Wochen + +### 16.3 Integrations-Reihenfolge (Phase G) + +1. **G0** — Katalog in Einheits-Editor → bestehende Suggest-Pipeline +2. **G1** — Scope `training_section` + Skill-Erwartungen aktiv +3. **G2** — Abschnitts-QS (Hint-Struktur wie Graph) +4. **G3** — Framework-Slot + Gap-Fill +5. **G4** — Gruppenkontext-Pack + +**Nicht:** Roadmap-first-Loop 1:1 auf Trainingseinheit mappen. + +Domänenbezug: **`DOMAIN_MODEL.md`** §1–2 (Katalog-Dimensionen). --- @@ -342,6 +453,10 @@ Kern: Wizard ① Ziel & Start/Ziel → ② Roadmap → ③ Match → ④ Lücken | `test_planning_exercise_form_context.py` | `planning_context`, Gap-Snapshot | | `test_progression_graph_planning_artifact.py` | JSONB-Artefakt-Validierung | | `test_planning_exercise_progression.py` | Graph-Auflösung, Nachfolger | +| `test_planning_path_rematch.py` | Auto-Rematch, unfilled-Platzhalter | +| `test_planning_path_refine_stage.py` | Stufen-Spec-Refine | +| `test_planning_stage_anti_patterns.py` | Anti-Pattern-Sanitizer, Stufen-Gate | +| `test_planning_catalog_context.py` | Katalog-Kontext → Target-Profil | --- @@ -366,3 +481,4 @@ Kern: Wizard ① Ziel & Start/Ziel → ② Roadmap → ③ Match → ④ Lücken | Datum | Änderung | |-------|----------| | 2026-05-22 | Erstfassung Ist-Stand 0.8.217 — zentrale Referenz nach F5–F9 | +| 2026-05-22 | F11–F14: Auto-Optimierung, Katalog-Kontext, GraphEditor, Mae-Geri-Validierung, Phase-G-Wiederverwendung §16 | diff --git a/frontend/src/components/ExerciseProgressionPathBuilder.jsx b/frontend/src/components/ExerciseProgressionPathBuilder.jsx index 9c83c04..f06c61d 100644 --- a/frontend/src/components/ExerciseProgressionPathBuilder.jsx +++ b/frontend/src/components/ExerciseProgressionPathBuilder.jsx @@ -7,6 +7,16 @@ import api from '../utils/api' import ExerciseAiQuickCreateModal from './exercises/ExerciseAiQuickCreateModal' import ExerciseGapFillPrepModal from './exercises/ExerciseGapFillPrepModal' import ExerciseAiSuggestPreviewModal from './ExerciseAiSuggestPreviewModal' +import PlanningCatalogContextFields from './PlanningCatalogContextFields' +import { + EMPTY_PLANNING_CATALOG_CONTEXT, + parsePlanningCatalogContextFromArtifact, + planningCatalogContextToApi, + pathQaQualityPercent, + pathQaShowsStrongResult, + setCatalogSelectItems, + splitPathQaHints, +} from '../utils/progressionGraphDraft' import { aiPreviewToQuickCreateDraft, buildQuickCreateAiPreview, @@ -449,9 +459,13 @@ function buildPlanningRoadmapArtifactSnapshot({ maxSteps, progressionRoadmap, pathSkillExpectations, + planningCatalogContext, }) { const q = (goalQuery || '').trim() if (!q && !progressionRoadmap) return null + const catalogPayload = planningCatalogContextToApi( + planningCatalogContext || EMPTY_PLANNING_CATALOG_CONTEXT, + ) return { schema_version: PLANNING_ARTIFACT_SCHEMA, goal_query: q, @@ -461,6 +475,9 @@ function buildPlanningRoadmapArtifactSnapshot({ max_steps: Number(maxSteps) || 5, progression_roadmap: progressionRoadmap || null, path_skill_expectations: pathSkillExpectations || null, + ...(catalogPayload.planning_catalog_context + ? { planning_catalog_context: catalogPayload.planning_catalog_context } + : {}), } } @@ -544,6 +561,12 @@ export default function ExerciseProgressionPathBuilder({ const [startTargetAnalyzed, setStartTargetAnalyzed] = useState(false) const loading = loadingRoadmap || loadingStartTarget || loadingMatch const [focusAreas, setFocusAreas] = useState([]) + const [styleDirections, setStyleDirections] = useState([]) + const [trainingTypes, setTrainingTypes] = useState([]) + const [targetGroups, setTargetGroups] = useState([]) + const [planningCatalogContext, setPlanningCatalogContext] = useState(() => ({ + ...EMPTY_PLANNING_CATALOG_CONTEXT, + })) const [skillsCatalog, setSkillsCatalog] = useState([]) const [generatingOfferId, setGeneratingOfferId] = useState(null) @@ -571,6 +594,22 @@ export default function ExerciseProgressionPathBuilder({ [editableMajorSteps, pathSteps], ) + const catalogApiPayload = useMemo( + () => planningCatalogContextToApi(planningCatalogContext), + [planningCatalogContext], + ) + + const pathQaSplit = useMemo(() => splitPathQaHints(pathQa), [pathQa]) + const pathQaHighlights = pathQaSplit.highlightTexts + const pathQaFixHints = pathQaSplit.fixHints + + const patchCatalogDimension = useCallback((key, value) => { + setPlanningCatalogContext((prev) => ({ + ...prev, + [key]: setCatalogSelectItems(prev?.[key], value), + })) + }, []) + const buildPlanningArtifact = useCallback( () => buildPlanningRoadmapArtifactSnapshot({ @@ -581,6 +620,7 @@ export default function ExerciseProgressionPathBuilder({ maxSteps, progressionRoadmap, pathSkillExpectations, + planningCatalogContext, }), [ goalQuery, @@ -590,6 +630,7 @@ export default function ExerciseProgressionPathBuilder({ maxSteps, progressionRoadmap, pathSkillExpectations, + planningCatalogContext, ], ) @@ -634,6 +675,9 @@ export default function ExerciseProgressionPathBuilder({ if (art.roadmap_notes) setRoadmapNotes(String(art.roadmap_notes)) if (art.max_steps) setMaxSteps(Number(art.max_steps)) if (art.path_skill_expectations) setPathSkillExpectations(art.path_skill_expectations) + if (art.planning_catalog_context) { + setPlanningCatalogContext(parsePlanningCatalogContextFromArtifact(art)) + } if (art.progression_roadmap) { setProgressionRoadmap(art.progression_roadmap) const majors = mapMajorStepsFromApi(art.progression_roadmap) @@ -670,16 +714,25 @@ export default function ExerciseProgressionPathBuilder({ let cancelled = false Promise.all([ api.listFocusAreas({ status: 'active' }), + api.listStyleDirections({ status: 'active' }), + api.listTrainingTypes({ status: 'active' }), + api.listTargetGroups({ status: 'active' }), api.listSkillsCatalog({ status: 'active' }), ]) - .then(([fa, sk]) => { + .then(([fa, sd, tt, tg, sk]) => { if (cancelled) return setFocusAreas(Array.isArray(fa) ? fa : []) + setStyleDirections(Array.isArray(sd) ? sd : []) + setTrainingTypes(Array.isArray(tt) ? tt : []) + setTargetGroups(Array.isArray(tg) ? tg : []) setSkillsCatalog(Array.isArray(sk) ? sk : []) }) .catch(() => { if (!cancelled) { setFocusAreas([]) + setStyleDirections([]) + setTrainingTypes([]) + setTargetGroups([]) setSkillsCatalog([]) } }) @@ -1095,6 +1148,7 @@ export default function ExerciseProgressionPathBuilder({ start_target_only: true, progression_graph_id: Number(graphId), ...roadmapStructuredPayload(startSituation, targetState, roadmapNotes), + ...catalogApiPayload, }) applyStartTargetResponse(res) } catch (e) { @@ -1133,6 +1187,7 @@ export default function ExerciseProgressionPathBuilder({ roadmap_only: true, progression_graph_id: Number(graphId), ...roadmapStructuredPayload(startSituation, targetState, roadmapNotes), + ...catalogApiPayload, }) const majors = mapMajorStepsFromApi(res?.progression_roadmap) if (majors.length < 2) { @@ -1204,6 +1259,7 @@ export default function ExerciseProgressionPathBuilder({ roadmap_override: override, progression_graph_id: Number(graphId), ...roadmapStructuredPayload(startSituation, targetState, roadmapNotes), + ...catalogApiPayload, }) applyPathMatchResponse(res, q) setMaxSteps(validSteps.length) @@ -1406,6 +1462,16 @@ export default function ExerciseProgressionPathBuilder({ /> +

Optional zuerst „Start/Ziel analysieren“, anpassen, dann Roadmap-Stufen. Sind Start und Ziel leer, geschieht die Analyse beim Roadmap-Vorschlag automatisch mit. Manuelle Eingaben haben immer Vorrang. @@ -1826,11 +1892,40 @@ export default function ExerciseProgressionPathBuilder({ > Pfad-QS: {pathQa.overall_ok ? 'OK' : 'Hinweise'} - {pathQa.quality_score != null ? ` (${Math.round(Number(pathQa.quality_score) * 100)} %)` : ''} + {pathQaQualityPercent(pathQa) != null ? ` (${pathQaQualityPercent(pathQa)} %)` : ''} + {pathQaShowsStrongResult(pathQa) ? ( +

+ Starker Pfad — KI-Highlights können Feinschliff oder optionale Vertiefung sein. +

+ ) : null} {pathQa.topic_coverage ? (

{pathQa.topic_coverage}

) : null} + {pathQaHighlights.length > 0 ? ( + <> +

+ KI-Highlights ({pathQaHighlights.length}) +

+ + + ) : null} {Array.isArray(pathQa.issues) && pathQa.issues.length > 0 ? ( ) : null} + {pathQaFixHints.length > 0 ? ( + <> +

+ Handlungsbedarf ({pathQaFixHints.length}) +

+ + + ) : null} {Number(pathQa.bridge_insert_count) > 0 ? (

{pathQa.bridge_insert_count} Brücken-Übung(en) aus der Bibliothek eingefügt. diff --git a/frontend/src/components/PlanningCatalogContextFields.jsx b/frontend/src/components/PlanningCatalogContextFields.jsx new file mode 100644 index 0000000..7877eb5 --- /dev/null +++ b/frontend/src/components/PlanningCatalogContextFields.jsx @@ -0,0 +1,99 @@ +/** + * Planungskontext — Katalog-Dimensionen für Progressionsgraph-Matching. + */ +import React from 'react' +import { getCatalogSelectId } from '../utils/progressionGraphDraft' + +export default function PlanningCatalogContextFields({ + catalogCtx, + onPatchDimension, + focusAreas = [], + styleDirections = [], + trainingTypes = [], + targetGroups = [], + disabled = false, + helperText = 'Planungskontext steuert Bibliotheks-Matching (Fokusbereich, Stil, Trainingsstil, Zielgruppe) — unabhängig von Technik-Pfaden.', +}) { + return ( + <> +

+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ {helperText ? ( +

+ {helperText} +

+ ) : null} + + ) +} diff --git a/frontend/src/components/ProgressionFindingsPanel.jsx b/frontend/src/components/ProgressionFindingsPanel.jsx index e733693..7483353 100644 --- a/frontend/src/components/ProgressionFindingsPanel.jsx +++ b/frontend/src/components/ProgressionFindingsPanel.jsx @@ -10,8 +10,11 @@ import { formatRematchLogEntry, formatRefineLogEntry, hasRematchSlotHints, + pathQaQualityPercent, + pathQaShowsStrongResult, resolveHintSlotIndex, resolveOfferSlotIndex, + splitPathQaHints, } from '../utils/progressionGraphDraft' function severityStyle(pathQa) { @@ -164,10 +167,15 @@ export default function ProgressionFindingsPanel({ aiBusy = false, evaluateDisabled = false, }) { - const optimizationHints = Array.isArray(pathQa?.optimization_hints) ? pathQa.optimization_hints : [] + const { fixHints: optimizationHints, highlightTexts } = useMemo( + () => splitPathQaHints(pathQa), + [pathQa], + ) const rematchLog = Array.isArray(pathQa?.rematch_log) ? pathQa.rematch_log : [] const refineLog = Array.isArray(pathQa?.refine_log) ? pathQa.refine_log : [] const showRematchAction = hasRematchSlotHints(pathQa) && typeof onRematchSlots === 'function' + const qualityPct = pathQaQualityPercent(pathQa) + const strongResult = pathQaShowsStrongResult(pathQa) return (
@@ -204,13 +212,50 @@ export default function ProgressionFindingsPanel({ > Pfad-QS: {pathQa.overall_ok ? 'OK' : 'Hinweise'} - {pathQa.quality_score != null - ? ` (${Math.round(Number(pathQa.quality_score) * 100)} %)` - : ''} + {qualityPct != null ? ` (${qualityPct} %)` : ''} + {strongResult ? ( +

+ Starker Pfad — KI-Empfehlungen unten können Feinschliff oder optionale Vertiefung sein. +

+ ) : null} {pathQa.topic_coverage ? (

{pathQa.topic_coverage}

) : null} + {highlightTexts.length > 0 ? ( + <> +

+ KI-Highlights ({highlightTexts.length}) +

+
    + {highlightTexts.map((item, i) => ( +
  • + {item.text} +
  • + ))} +
+ + ) : null} {Array.isArray(pathQa.issues) && pathQa.issues.length > 0 ? (
    {pathQa.issues.map((issue) => ( @@ -218,7 +263,9 @@ export default function ProgressionFindingsPanel({ ))}
) : null} - {Array.isArray(pathQa.recommendations) && pathQa.recommendations.length > 0 ? ( + {Array.isArray(pathQa.recommendations) && + pathQa.recommendations.length > 0 && + highlightTexts.length === 0 ? ( <>

Empfehlungen

    @@ -265,7 +312,7 @@ export default function ProgressionFindingsPanel({ {optimizationHints.length > 0 ? ( <>

    - Optimierungspotenziale ({optimizationHints.length}) + Handlungsbedarf ({optimizationHints.length})

-
-
- - -
-
- - -
-
- - -
-
- - -
-
-

- Planungskontext steuert Bibliotheks-Matching (Fokusbereich, Stil, Trainingsstil, Zielgruppe) — unabhängig - von Technik-Pfaden wie Mae Geri. Wird mit dem Graph gespeichert. -

+

Optional zuerst „Start/Ziel analysieren“, anpassen, dann Roadmap-Stufen. Sind Start und Ziel leer, geschieht die Analyse beim Roadmap-Vorschlag automatisch. Manuelle Eingaben haben Vorrang. diff --git a/frontend/src/utils/progressionGraphDraft.js b/frontend/src/utils/progressionGraphDraft.js index c654b17..5f1fe1e 100644 --- a/frontend/src/utils/progressionGraphDraft.js +++ b/frontend/src/utils/progressionGraphDraft.js @@ -141,6 +141,44 @@ export function optimizationHintActionLabel(action) { return OPTIMIZATION_ACTION_LABELS[action] || action || 'Hinweis' } +/** LLM-Empfehlungen von technischen Fix-Hinweisen trennen (QS-UI). */ +export function splitPathQaHints(pathQa) { + const hints = Array.isArray(pathQa?.optimization_hints) ? pathQa.optimization_hints : [] + const fixHints = hints.filter((h) => String(h?.issue || '') !== 'llm_recommendation') + const highlightHints = hints.filter((h) => String(h?.issue || '') === 'llm_recommendation') + const recommendations = Array.isArray(pathQa?.recommendations) ? pathQa.recommendations : [] + const highlightTexts = [] + const seen = new Set() + for (const rec of recommendations) { + const text = String(rec || '').trim() + const key = text.toLowerCase() + if (text && !seen.has(key)) { + seen.add(key) + highlightTexts.push({ text, source: 'recommendation' }) + } + } + for (const hint of highlightHints) { + const text = String(hint.reason || hint.title || '').trim() + const key = text.toLowerCase() + if (text && !seen.has(key)) { + seen.add(key) + highlightTexts.push({ text, source: 'hint', hint }) + } + } + return { fixHints, highlightTexts } +} + +export function pathQaQualityPercent(pathQa) { + if (pathQa?.quality_score == null || !Number.isFinite(Number(pathQa.quality_score))) return null + return Math.round(Number(pathQa.quality_score) * 100) +} + +export function pathQaShowsStrongResult(pathQa) { + const pct = pathQaQualityPercent(pathQa) + if (pathQa?.overall_ok && pct != null && pct >= 85) return true + return Boolean(pathQa?.overall_ok && pct != null && pct >= 80 && !(pathQa?.issues || []).length) +} + /** Slot-Index aus optimization_hint (roadmap_major_step_index oder step_index). */ export function resolveHintSlotIndex(hint, draft = null) { if (!hint || typeof hint !== 'object') return null -- 2.43.0 From b8f65e04c5e58079a631d1dd7d8f637090000fa7 Mon Sep 17 00:00:00 2001 From: Lars Date: Fri, 12 Jun 2026 12:33:00 +0200 Subject: [PATCH 02/20] Enhance Rematch Logic and Slot Filtering in Planning Path - Introduced `filter_rematch_slot_indices` to exclude preserved slots from rematching, improving the accuracy of slot assignments. - Added `_slot_priority_for_rematch` to prioritize existing slot assignments during rematching, enhancing the robustness of the rematch process. - Updated `_run_roadmap_rematch_loop` to utilize the new filtering and prioritization logic, ensuring better handling of rematch scenarios. - Enhanced tests in `test_planning_path_rematch.py` to validate the new filtering behavior and ensure correct exercise restoration when not rejected. - Bumped version to reflect the new features and improvements. --- backend/planning_exercise_path_builder.py | 7 ++ backend/planning_path_rematch.py | 106 +++++++++++++++++++- backend/tests/test_planning_path_rematch.py | 101 +++++++++++++++++++ frontend/src/utils/progressionGraphDraft.js | 15 ++- 4 files changed, 223 insertions(+), 6 deletions(-) diff --git a/backend/planning_exercise_path_builder.py b/backend/planning_exercise_path_builder.py index 6d3fcc7..f1bf496 100644 --- a/backend/planning_exercise_path_builder.py +++ b/backend/planning_exercise_path_builder.py @@ -27,6 +27,7 @@ from planning_exercise_profiles import PlanningTargetProfile from planning_path_qa_pipeline import run_multistage_path_qa from planning_path_rematch import ( collect_rematch_slot_indices, + filter_rematch_slot_indices, prune_stripped_after_rematch, rematch_roadmap_slots, ) @@ -1709,6 +1710,12 @@ def _run_roadmap_rematch_loop( slot_indices.add(int(midx)) if int(midx) not in rematch_reasons: rematch_reasons[int(midx)] = "refine_stage_spec" + slot_indices = filter_rematch_slot_indices( + steps, + slot_indices, + stripped_off_topic=current_stripped if round_idx == 0 else [], + off_topic_steps=off_topic_before_strip if round_idx == 0 and use_initial_off_topic else [], + ) if not slot_indices: break diff --git a/backend/planning_path_rematch.py b/backend/planning_path_rematch.py index 200c3c7..4f47dfd 100644 --- a/backend/planning_path_rematch.py +++ b/backend/planning_path_rematch.py @@ -8,6 +8,40 @@ from typing import Any, Dict, List, Mapping, Optional, Sequence, Set, Tuple from planning_progression_roadmap import ProgressionRoadmapContext, StageSpecArtifact +def _slot_priority_for_rematch( + body, + *, + major_idx: int, + old: Optional[Mapping[str, Any]], + rejected_by_major: Optional[Mapping[int, Set[int]]], +) -> Optional[int]: + """Bestehende Slot-Zuordnung beim Rematch bevorzugen — außer explizit abgelehnt.""" + priority_id: Optional[int] = None + if body is not None: + for raw in getattr(body, "slot_assignments", None) or []: + midx = getattr(raw, "roadmap_major_step_index", None) + if midx is None or int(midx) != int(major_idx): + continue + eid = getattr(raw, "exercise_id", None) + if eid is not None: + try: + priority_id = int(eid) + except (TypeError, ValueError): + priority_id = None + break + if priority_id is None and old and old.get("exercise_id") is not None: + try: + priority_id = int(old["exercise_id"]) + except (TypeError, ValueError): + priority_id = None + if priority_id is None or priority_id < 1: + return None + rejected = rejected_by_major.get(int(major_idx), set()) if rejected_by_major else set() + if priority_id in rejected: + return None + return priority_id + + def collect_rematch_slot_indices( *, stripped_off_topic: Sequence[Mapping[str, Any]], @@ -80,6 +114,43 @@ def collect_rematch_slot_indices( return indices, reasons +def filter_rematch_slot_indices( + steps: Sequence[Mapping[str, Any]], + slot_indices: Set[int], + *, + stripped_off_topic: Sequence[Mapping[str, Any]], + off_topic_steps: Sequence[Mapping[str, Any]], +) -> Set[int]: + """Trainer-Zuordnungen (slot_best_match) nicht rematchen, außer Slot ist explizit beanstandet.""" + flagged: Set[int] = set() + for item in list(stripped_off_topic or []) + list(off_topic_steps or []): + if not isinstance(item, dict): + continue + midx = item.get("roadmap_major_step_index") + if midx is not None: + try: + flagged.add(int(midx)) + except (TypeError, ValueError): + pass + + preserved: Set[int] = set() + for raw in steps or []: + if not isinstance(raw, dict): + continue + midx = raw.get("roadmap_major_step_index") + if midx is None: + continue + try: + major_idx = int(midx) + except (TypeError, ValueError): + continue + if raw.get("roadmap_match_source") == "slot_best_match" or raw.get("slot_status") == "preserved": + if major_idx not in flagged: + preserved.add(major_idx) + + return {idx for idx in slot_indices if idx not in preserved} + + def _context_before_major( steps_by_major: Mapping[int, Mapping[str, Any]], target_major: int, @@ -178,6 +249,12 @@ def rematch_roadmap_slots( anchor_id=anchor_id, anchor_variant_id=anchor_variant_id, used=used, + slot_priority_exercise_id=_slot_priority_for_rematch( + body, + major_idx=major_idx, + old=old, + rejected_by_major=rejected_by_major, + ), ) reason = str(rematch_reasons.get(int(major_idx)) or "rematch_slot") @@ -186,12 +263,10 @@ def rematch_roadmap_slots( new_eid = int(new_step.get("exercise_id") or 0) except (TypeError, ValueError): new_eid = 0 - hist = ( - slot_assignment_history.get(int(major_idx), set()) - if slot_assignment_history - else set() + rejected = ( + rejected_by_major.get(int(major_idx), set()) if rejected_by_major else set() ) - if new_eid > 0 and new_eid in hist: + if new_eid > 0 and new_eid in rejected: new_step = None if new_step: steps_by_major[int(major_idx)] = new_step @@ -207,6 +282,26 @@ def rematch_roadmap_slots( } ) else: + if old and old.get("exercise_id") is not None: + try: + old_eid = int(old["exercise_id"]) + except (TypeError, ValueError): + old_eid = 0 + rejected = ( + rejected_by_major.get(int(major_idx), set()) if rejected_by_major else set() + ) + if old_eid > 0 and old_eid not in rejected: + steps_by_major[int(major_idx)] = dict(old) + rematch_log.append( + { + "roadmap_major_step_index": int(major_idx), + "action": "restored", + "reason": reason, + "restored_exercise_id": old_eid, + "restored_title": old.get("title"), + } + ) + continue goal = (stage_spec.learning_goal or "").strip() major = None if roadmap_ctx.roadmap: @@ -278,6 +373,7 @@ def prune_stripped_after_rematch( __all__ = [ "collect_rematch_slot_indices", + "filter_rematch_slot_indices", "prune_stripped_after_rematch", "rematch_roadmap_slots", ] diff --git a/backend/tests/test_planning_path_rematch.py b/backend/tests/test_planning_path_rematch.py index 9337c9e..2ee19f5 100644 --- a/backend/tests/test_planning_path_rematch.py +++ b/backend/tests/test_planning_path_rematch.py @@ -214,6 +214,7 @@ def test_rematch_unfilled_leaves_placeholder_step(): slot_indices={1}, rematch_reasons={1: "stage_mismatch"}, match_slot_fn=_no_match, + rejected_by_major={1: {99}}, ) assert len(ordered) == 2 @@ -235,3 +236,103 @@ def test_prune_filled_from_roadmap_unfilled(): unfilled_steps = [{"exercise_id": None, "roadmap_major_step_index": 5}] kept2 = _prune_filled_from_roadmap_unfilled(unfilled_steps, [(4, spec)]) assert len(kept2) == 1 + + +def test_rematch_keeps_same_exercise_when_not_rejected(): + """Regression: slot_assignment_history blockierte gültige Wiederzuordnung → leere Slots.""" + specs = _stage_specs() + ctx = ProgressionRoadmapContext( + goal_query="Mae Geri", + max_steps=3, + stage_specs=specs, + ) + steps = [ + {"exercise_id": 10, "title": "OK", "roadmap_major_step_index": 0}, + {"exercise_id": 42, "title": "Gut", "roadmap_major_step_index": 1}, + ] + + def _same_match(cur, *, stage_spec, slot_priority_exercise_id=None, **kwargs): + assert slot_priority_exercise_id == 42 + return ( + {"exercise_id": 42, "title": "Gut", "roadmap_major_step_index": stage_spec.major_step_index}, + None, + ) + + ordered, log, unfilled = rematch_roadmap_slots( + None, + tenant=None, + body=None, + goal_query="Mae Geri", + max_steps=3, + semantic_brief=None, + path_target_profile=None, + path_intent="", + roadmap_ctx=ctx, + steps=steps, + slot_indices={1}, + rematch_reasons={1: "refine_stage_spec"}, + match_slot_fn=_same_match, + rejected_by_major={}, + ) + + assert ordered[1]["exercise_id"] == 42 + assert log[0]["action"] == "replaced" + assert not unfilled + + +def test_rematch_restores_when_match_fails_and_not_rejected(): + specs = _stage_specs() + ctx = ProgressionRoadmapContext( + goal_query="Mae Geri", + max_steps=3, + stage_specs=specs, + ) + steps = [ + {"exercise_id": 10, "title": "OK", "roadmap_major_step_index": 0}, + {"exercise_id": 42, "title": "Gut", "roadmap_major_step_index": 1}, + ] + + def _no_match(cur, *, stage_spec, **kwargs): + return None, stage_spec + + ordered, log, unfilled = rematch_roadmap_slots( + None, + tenant=None, + body=None, + goal_query="Mae Geri", + max_steps=3, + semantic_brief=None, + path_target_profile=None, + path_intent="", + roadmap_ctx=ctx, + steps=steps, + slot_indices={1}, + rematch_reasons={1: "refine_stage_spec"}, + match_slot_fn=_no_match, + rejected_by_major={}, + ) + + assert ordered[1]["exercise_id"] == 42 + assert log[0]["action"] == "restored" + assert not unfilled + + +def test_filter_rematch_skips_preserved_slots(): + from planning_path_rematch import filter_rematch_slot_indices + + steps = [ + { + "exercise_id": 10, + "roadmap_major_step_index": 0, + "roadmap_match_source": "slot_best_match", + "slot_status": "preserved", + }, + {"exercise_id": 20, "roadmap_major_step_index": 1}, + ] + filtered = filter_rematch_slot_indices( + steps, + {0, 1}, + stripped_off_topic=[], + off_topic_steps=[], + ) + assert filtered == {1} diff --git a/frontend/src/utils/progressionGraphDraft.js b/frontend/src/utils/progressionGraphDraft.js index 5f1fe1e..21d88e8 100644 --- a/frontend/src/utils/progressionGraphDraft.js +++ b/frontend/src/utils/progressionGraphDraft.js @@ -1024,9 +1024,22 @@ export function applyMatchStepsToSlots(draft, apiSteps) { if (!step) { return base } + const mappedPrimary = mapStepToPrimary(step, slot) + const apiUnfilled = + step.exercise_id == null && + (step.slot_status === 'unfilled' || + step.roadmap_match_source === 'unfilled' || + mappedPrimary.kind === 'empty') + if ( + apiUnfilled && + slot.primary?.kind === 'library' && + slot.primary.exerciseId != null + ) { + return base + } return { ...base, - primary: mapStepToPrimary(step, slot), + primary: mappedPrimary, } }) -- 2.43.0 From 5ed06002d90c2eaf43da77238ef01cda3829b3be Mon Sep 17 00:00:00 2001 From: Lars Date: Fri, 12 Jun 2026 13:22:04 +0200 Subject: [PATCH 03/20] Implement Comparison Logic for Progression Path Suggestions - Added `compare_with_assignments` flag to `ProgressionPathSuggestRequest` to enable comparison of proposed paths with existing slot assignments. - Introduced `_assignment_preservation_active` function to determine if existing assignments should be preserved during path suggestions. - Enhanced `suggest_progression_path` to handle comparison logic, including validation for minimum slot assignments required for comparison. - Implemented `_build_progression_compare_response` to structure the response for comparison results, including slot differences and quality scores. - Updated frontend components to support new comparison features, including handling of slot assignments and optimization comparisons. - Bumped version to reflect the new features and improvements. --- backend/planning_exercise_path_builder.py | 160 ++++++++++++- .../test_planning_assignment_preservation.py | 31 +++ .../ExerciseProgressionPathBuilder.jsx | 34 +++ .../components/ProgressionFindingsPanel.jsx | 25 +- .../src/components/ProgressionGraphEditor.jsx | 220 ++++++++++++++++-- .../ProgressionOptimizeCompareModal.jsx | 202 ++++++++++++++++ frontend/src/utils/progressionGraphDraft.js | 49 ++++ 7 files changed, 695 insertions(+), 26 deletions(-) create mode 100644 backend/tests/test_planning_assignment_preservation.py create mode 100644 frontend/src/components/ProgressionOptimizeCompareModal.jsx diff --git a/backend/planning_exercise_path_builder.py b/backend/planning_exercise_path_builder.py index f1bf496..b034dea 100644 --- a/backend/planning_exercise_path_builder.py +++ b/backend/planning_exercise_path_builder.py @@ -141,6 +141,7 @@ class ProgressionPathSuggestRequest(BaseModel): roadmap_notes: Optional[str] = Field(default=None, max_length=2000) progression_graph_id: Optional[int] = Field(default=None, ge=1) exercise_kind_any: Optional[List[str]] = None + compare_with_assignments: bool = False planning_catalog_context: Optional[ProgressionPlanningCatalogContext] = None @@ -676,6 +677,11 @@ def _slot_assignments_by_major_index( return out +def _assignment_preservation_active(body: ProgressionPathSuggestRequest) -> bool: + """Trainer-Pfad schützen — nur bei explizitem Flag (Frontend entscheidet pro Aktion).""" + return bool(body.preserve_slot_assignments) + + def _path_step_from_slot_assignment( cur, *, @@ -1848,11 +1854,33 @@ def _build_steps_roadmap_first( if roadmap_ctx.roadmap: majors_by_index = {m.index: m for m in roadmap_ctx.roadmap.major_steps} + preserve_assignments = _assignment_preservation_active(body) + for step_index, stage_spec in enumerate(stage_specs): major_idx = stage_spec.major_step_index major = majors_by_index.get(major_idx) slot_priority_id: Optional[int] = None + if preserve_assignments and major_idx in assignments: + direct = _path_step_from_slot_assignment( + cur, + assignment=assignments[major_idx], + stage_spec=stage_spec, + major_step=major, + tenant=tenant, + progression_graph_id=body.progression_graph_id, + ) + if direct: + direct["slot_status"] = "preserved" + direct["roadmap_match_source"] = "slot_best_match" + steps.append(direct) + eid = int(direct["exercise_id"]) + used.add(eid) + planned_ids.append(eid) + anchor_id = eid + anchor_variant_id = direct.get("variant_id") + continue + if major_idx in assignments: try: slot_priority_id = int(assignments[major_idx].exercise_id) @@ -2123,6 +2151,88 @@ def _run_evaluate_only_path_qa( } +def _path_qa_quality_score(path_qa: Optional[Mapping[str, Any]]) -> Optional[float]: + if not path_qa: + return None + raw = path_qa.get("quality_score") + try: + return float(raw) if raw is not None else None + except (TypeError, ValueError): + return None + + +def _steps_by_major_index(steps: Sequence[Mapping[str, Any]]) -> Dict[int, Dict[str, Any]]: + out: Dict[int, Dict[str, Any]] = {} + for raw in steps or []: + if not isinstance(raw, dict): + continue + midx = raw.get("roadmap_major_step_index") + if midx is None: + continue + try: + out[int(midx)] = dict(raw) + except (TypeError, ValueError): + continue + return out + + +def _build_progression_slot_diffs( + baseline_steps: Sequence[Mapping[str, Any]], + proposed_steps: Sequence[Mapping[str, Any]], +) -> List[Dict[str, Any]]: + """Gegenüberstellung je Roadmap-Slot — nur geänderte oder neu leere Slots.""" + base_by = _steps_by_major_index(baseline_steps) + prop_by = _steps_by_major_index(proposed_steps) + diffs: List[Dict[str, Any]] = [] + for midx in sorted(set(base_by.keys()) | set(prop_by.keys())): + base = base_by.get(midx, {}) + prop = prop_by.get(midx, {}) + base_id = base.get("exercise_id") + prop_id = prop.get("exercise_id") + base_title = (base.get("title") or "").strip() or None + prop_title = (prop.get("title") or "").strip() or None + if base_id is not None and prop_id is not None and int(base_id) == int(prop_id): + continue + diffs.append( + { + "roadmap_major_step_index": midx, + "baseline_exercise_id": int(base_id) if base_id is not None else None, + "baseline_title": base_title, + "proposed_exercise_id": int(prop_id) if prop_id is not None else None, + "proposed_title": prop_title, + "baseline_slot_status": base.get("slot_status"), + "proposed_slot_status": prop.get("slot_status"), + "changed": base_id != prop_id or base_title != prop_title, + } + ) + return diffs + + +def _build_progression_compare_response( + baseline: Mapping[str, Any], + proposed: Mapping[str, Any], +) -> Dict[str, Any]: + baseline_steps = list(baseline.get("steps") or []) + proposed_steps = list(proposed.get("steps") or []) + baseline_qa = baseline.get("path_qa") if isinstance(baseline.get("path_qa"), dict) else {} + proposed_qa = proposed.get("path_qa") if isinstance(proposed.get("path_qa"), dict) else {} + slot_diffs = _build_progression_slot_diffs(baseline_steps, proposed_steps) + return { + **dict(proposed), + "comparison_mode": True, + "baseline_steps": baseline_steps, + "baseline_path_qa": baseline_qa, + "proposed_steps": proposed_steps, + "proposed_path_qa": proposed_qa, + "slot_diffs": slot_diffs, + "slot_diff_count": len(slot_diffs), + "baseline_quality_score": _path_qa_quality_score(baseline_qa), + "proposed_quality_score": _path_qa_quality_score(proposed_qa), + "path_qa": proposed_qa, + "steps": proposed_steps, + } + + def suggest_progression_path( cur, *, @@ -2133,6 +2243,32 @@ def suggest_progression_path( if not _has_planning_role(role): raise HTTPException(status_code=403, detail="Nur Trainer dürfen Pfad-Vorschläge abrufen") + if body.compare_with_assignments: + assignments = _slot_assignments_by_major_index(body.slot_assignments) + if len(assignments) < 1: + raise HTTPException( + status_code=400, + detail="compare_with_assignments erfordert mindestens ein slot_assignment", + ) + baseline_body = body.model_copy( + update={ + "evaluate_only": True, + "evaluate_steps": list(body.slot_assignments or []), + "compare_with_assignments": False, + "preserve_slot_assignments": True, + } + ) + baseline = suggest_progression_path(cur, tenant=tenant, body=baseline_body) + proposed_body = body.model_copy( + update={ + "compare_with_assignments": False, + "preserve_slot_assignments": False, + "evaluate_only": False, + } + ) + proposed = suggest_progression_path(cur, tenant=tenant, body=proposed_body) + return _build_progression_compare_response(baseline, proposed) + goal_query = _normalize_query(body.query) if len(goal_query) < 3: raise HTTPException(status_code=400, detail="Ziel-Anfrage: mindestens 3 Zeichen") @@ -2431,6 +2567,7 @@ def suggest_progression_path( reorder_notes: List[str] = [] roadmap_qa_mode: Optional[str] = None + preserve_assignments = _assignment_preservation_active(body) if body.include_path_qa: if roadmap_first: roadmap_qa_mode = "roadmap_first_lite" @@ -2466,7 +2603,9 @@ def suggest_progression_path( elif gaps and roadmap_first: unfilled_gaps = list(gaps) - if body.include_llm_path_qa and not roadmap_first: + if body.include_llm_path_qa and ( + not roadmap_first or preserve_assignments + ): llm_qa, llm_qa_applied = try_llm_qa_progression_path( cur, goal_query=goal_query, @@ -2497,11 +2636,14 @@ def suggest_progression_path( goal_query=goal_query, ) off_topic_before_strip = list(off_topic_steps) - steps, stripped_off_topic = strip_off_topic_steps_from_path( - steps, - off_topic_steps, - min_remaining=0 if roadmap_first else 2, - ) + if preserve_assignments: + stripped_off_topic = [] + else: + steps, stripped_off_topic = strip_off_topic_steps_from_path( + steps, + off_topic_steps, + min_remaining=0 if roadmap_first else 2, + ) if stripped_off_topic: off_topic_steps = [] gaps = detect_path_gaps( @@ -2511,7 +2653,7 @@ def suggest_progression_path( roadmap_first=roadmap_first, ) - if roadmap_first and roadmap_ctx is not None: + if roadmap_first and roadmap_ctx is not None and not preserve_assignments: ( steps, rematch_log, @@ -2545,7 +2687,7 @@ def suggest_progression_path( roadmap_first=roadmap_first, ) - if body.include_llm_path_qa and roadmap_first: + if body.include_llm_path_qa and roadmap_first and not preserve_assignments: gaps = detect_path_gaps( cur, steps, @@ -2656,6 +2798,8 @@ def suggest_progression_path( path_qa["refine_applied"] = True path_qa["refine_log"] = refine_log path_qa["refine_count"] = len(refine_log) + if preserve_assignments: + path_qa["assignments_preserved"] = True filled_library_steps = sum(1 for s in steps if s.get("exercise_id") is not None) match_summary = { diff --git a/backend/tests/test_planning_assignment_preservation.py b/backend/tests/test_planning_assignment_preservation.py new file mode 100644 index 0000000..9b93741 --- /dev/null +++ b/backend/tests/test_planning_assignment_preservation.py @@ -0,0 +1,31 @@ +"""Tests Trainer-Pfad-Schutz (preserve_slot_assignments).""" +from planning_exercise_path_builder import ( + EvaluateStepPayload, + ProgressionPathSuggestRequest, + _assignment_preservation_active, +) + + +def test_assignment_preservation_explicit_flag(): + body = ProgressionPathSuggestRequest( + query="Kumite Beinarbeit Progression", + preserve_slot_assignments=True, + ) + assert _assignment_preservation_active(body) + + +def test_assignment_preservation_not_auto_from_slot_assignments(): + """Nur explizites preserve_slot_assignments — sonst wäre compare/match blockiert.""" + body = ProgressionPathSuggestRequest( + query="Kumite Beinarbeit Progression", + slot_assignments=[ + EvaluateStepPayload(exercise_id=10, roadmap_major_step_index=0), + EvaluateStepPayload(exercise_id=11, roadmap_major_step_index=1), + ], + ) + assert not _assignment_preservation_active(body) + + +def test_assignment_preservation_inactive_without_assignments(): + body = ProgressionPathSuggestRequest(query="Kumite Beinarbeit Progression") + assert not _assignment_preservation_active(body) diff --git a/frontend/src/components/ExerciseProgressionPathBuilder.jsx b/frontend/src/components/ExerciseProgressionPathBuilder.jsx index f06c61d..7b2efc1 100644 --- a/frontend/src/components/ExerciseProgressionPathBuilder.jsx +++ b/frontend/src/components/ExerciseProgressionPathBuilder.jsx @@ -16,6 +16,9 @@ import { pathQaShowsStrongResult, setCatalogSelectItems, splitPathQaHints, + draftHasLibrarySlotAssignments, + slotsToSlotAssignments, + draftRetrievalBoostExerciseIds, } from '../utils/progressionGraphDraft' import { aiPreviewToQuickCreateDraft, @@ -1245,6 +1248,22 @@ export default function ExerciseProgressionPathBuilder({ setError('') try { const override = majorStepsToOverridePayload(validSteps) + const preserveAssignments = draftHasLibrarySlotAssignments({ + slots: validSteps.map((s, i) => ({ + majorStepIndex: i, + phase: s.phase, + learning_goal: s.learning_goal, + primary: + pathSteps[i]?.exerciseId != null + ? { + kind: 'library', + exerciseId: pathSteps[i].exerciseId, + exerciseTitle: pathSteps[i].exerciseTitle, + variantId: pathSteps[i].variantId, + } + : { kind: 'empty' }, + })), + }) const res = await api.suggestProgressionPath({ query: q, max_steps: validSteps.length, @@ -1257,6 +1276,21 @@ export default function ExerciseProgressionPathBuilder({ include_llm_roadmap: false, roadmap_first: true, roadmap_override: override, + preserve_slot_assignments: preserveAssignments, + slot_assignments: pathSteps + .map((row, i) => { + if (row.exerciseId == null) return null + return { + exercise_id: row.exerciseId, + variant_id: row.variantId || null, + title: row.exerciseTitle || null, + is_ai_proposal: false, + roadmap_major_step_index: i, + roadmap_phase: validSteps[i]?.phase || null, + roadmap_learning_goal: validSteps[i]?.learning_goal || null, + } + }) + .filter(Boolean), progression_graph_id: Number(graphId), ...roadmapStructuredPayload(startSituation, targetState, roadmapNotes), ...catalogApiPayload, diff --git a/frontend/src/components/ProgressionFindingsPanel.jsx b/frontend/src/components/ProgressionFindingsPanel.jsx index 7483353..91ea61c 100644 --- a/frontend/src/components/ProgressionFindingsPanel.jsx +++ b/frontend/src/components/ProgressionFindingsPanel.jsx @@ -162,6 +162,9 @@ export default function ProgressionFindingsPanel({ onInsertGapSlot, onGenerateGapAi, onRematchSlots = null, + onOptimizeCompare = null, + canOptimizeCompare = false, + optimizeCompareBusy = false, rematchBusy = false, generatingOfferId = null, aiBusy = false, @@ -174,6 +177,9 @@ export default function ProgressionFindingsPanel({ const rematchLog = Array.isArray(pathQa?.rematch_log) ? pathQa.rematch_log : [] const refineLog = Array.isArray(pathQa?.refine_log) ? pathQa.refine_log : [] const showRematchAction = hasRematchSlotHints(pathQa) && typeof onRematchSlots === 'function' + const showOptimizeCompare = + typeof onOptimizeCompare === 'function' + && (canOptimizeCompare || pathQa?.assignments_preserved || showRematchAction) const qualityPct = pathQaQualityPercent(pathQa) const strongResult = pathQaShowsStrongResult(pathQa) @@ -214,6 +220,12 @@ export default function ProgressionFindingsPanel({ Pfad-QS: {pathQa.overall_ok ? 'OK' : 'Hinweise'} {qualityPct != null ? ` (${qualityPct} %)` : ''} + {pathQa.assignments_preserved ? ( +

+ Bestehende Slot-Zuordnungen beibehalten — QS wie „Graph bewerten“, ohne Auto-Rematch. + {showOptimizeCompare ? ' „Übungen matchen“ oder „Optimierung vergleichen“ prüft Alternativen.' : ''} +

+ ) : null} {strongResult ? (

Starker Pfad — KI-Empfehlungen unten können Feinschliff oder optionale Vertiefung sein. @@ -350,7 +362,18 @@ export default function ProgressionFindingsPanel({ ) })} - {showRematchAction ? ( + {showOptimizeCompare ? ( + + ) : null} + {showRematchAction && !showOptimizeCompare ? ( + {draftHasLibrarySlotAssignments(draft) ? ( + + ) : null} @@ -1015,6 +1186,9 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa onInsertGapSlot={handleInsertGapSlot} onGenerateGapAi={openGapFillPrep} onRematchSlots={runMatch} + onOptimizeCompare={runOptimizeCompare} + canOptimizeCompare={draftHasLibrarySlotAssignments(draft)} + optimizeCompareBusy={comparing} rematchBusy={matching} generatingOfferId={generatingOfferId} aiBusy={gapAiBusy} @@ -1054,6 +1228,18 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa zIndex={2100} /> + { + if (compareApplying) return + setCompareOpen(false) + setComparePayload(null) + }} + onApplySelected={applyOptimizeCompare} + applying={compareApplying} + /> + new Set()) + + const allKeys = useMemo( + () => slotDiffs.map((d) => Number(d.roadmap_major_step_index)), + [slotDiffs], + ) + + React.useEffect(() => { + if (!open) return + setSelected(new Set(allKeys)) + }, [open, allKeys]) + + if (!open || !comparison) return null + + const baselineQa = comparison.baseline_path_qa + const proposedQa = comparison.proposed_path_qa || comparison.path_qa + const baselinePct = pathQaQualityPercent(baselineQa) + const proposedPct = pathQaQualityPercent(proposedQa) + + const toggle = (midx) => { + setSelected((prev) => { + const next = new Set(prev) + if (next.has(midx)) next.delete(midx) + else next.add(midx) + return next + }) + } + + const toggleAll = (on) => { + setSelected(on ? new Set(allKeys) : new Set()) + } + + return ( +

{ + if (e.target === e.currentTarget && !applying) onClose() + }} + > +
e.stopPropagation()} + > +

+ Optimierung vergleichen +

+

+ Links dein aktueller Pfad, rechts der Vorschlag nach vollem Match inkl. Auto-Optimierung. + Wähle die Slots, die du übernehmen möchtest. +

+ +
+
+ Aktuell +
{qaLabel(baselineQa)}
+ {baselineQa?.topic_coverage ? ( +

{baselineQa.topic_coverage}

+ ) : null} +
+
+ Vorschlag (Match + Optimierung) +
{qaLabel(proposedQa)}
+ {proposedPct != null && baselinePct != null && proposedPct !== baselinePct ? ( +

+ Δ {proposedPct - baselinePct > 0 ? '+' : ''} + {proposedPct - baselinePct} Prozentpunkte +

+ ) : null} + {proposedQa?.topic_coverage ? ( +

{proposedQa.topic_coverage}

+ ) : null} +
+
+ + {slotDiffs.length === 0 ? ( +

+ Keine abweichenden Slot-Zuordnungen — der optimierte Lauf liefert denselben Pfad. +

+ ) : ( + <> +
+ + +
+
    + {slotDiffs.map((diff) => { + const midx = Number(diff.roadmap_major_step_index) + const checked = selected.has(midx) + return ( +
  • + +
  • + ) + })} +
+ + )} + +
+ + +
+
+
+ ) +} diff --git a/frontend/src/utils/progressionGraphDraft.js b/frontend/src/utils/progressionGraphDraft.js index 21d88e8..8eb117e 100644 --- a/frontend/src/utils/progressionGraphDraft.js +++ b/frontend/src/utils/progressionGraphDraft.js @@ -906,6 +906,23 @@ export function slotsToSlotAssignments(draft) { })) } +/** Mindestens ein Bibliotheks-Slot belegt → kuratierter Stand, Match prüft Abweichungen. */ +export function draftHasLibrarySlotAssignments(draft) { + return slotsToSlotAssignments(draft).length >= 1 +} + +/** Diff-Einträge nur für Slots, die vorher schon eine Bibliotheks-Übung hatten. */ +export function curatedSlotDiffs(comparison) { + const diffs = comparison?.slot_diffs + if (!Array.isArray(diffs)) return [] + return diffs.filter((d) => d?.baseline_exercise_id != null) +} + +/** Vergleich würde eine bestehende Zuordnung ändern (Dialog bei Match). */ +export function compareResponseHasCuratedSlotChanges(res) { + return curatedSlotDiffs(res).length > 0 +} + /** Alle Graph-Übungs-IDs für Retriever-Boost (Slots + Geschwister + gespeichertes Artefakt). */ export function draftRetrievalBoostExerciseIds(draft) { const ids = new Set() @@ -1046,6 +1063,38 @@ export function applyMatchStepsToSlots(draft, apiSteps) { return syncProgressionRoadmapFromSlots({ ...draft, slots: nextSlots, dirty: true }) } +/** Vergleichs-Antwort: mindestens ein Slot mit anderer Übung als im Ist-Stand. */ +export function compareResponseHasSlotChanges(res) { + const count = res?.slot_diff_count ?? res?.slot_diffs?.length ?? 0 + return Number(count) > 0 +} + +/** Nur ausgewählte Slots aus Optimierungs-Vorschlag übernehmen. */ +export function applySelectedCompareSteps(draft, proposedSteps, selectedMajorIndices) { + const selected = new Set( + (selectedMajorIndices || []) + .map((x) => Number(x)) + .filter((x) => Number.isFinite(x)), + ) + if (!selected.size) return draft + const stepByMajor = new Map() + for (const step of proposedSteps || []) { + if (step?.roadmap_major_step_index == null) continue + stepByMajor.set(Number(step.roadmap_major_step_index), step) + } + const nextSlots = (draft.slots || []).map((slot) => { + const midx = Number(slot.majorStepIndex) + if (!selected.has(midx)) { + return { ...slot, primary: { ...slot.primary }, siblings: [...(slot.siblings || [])] } + } + const step = stepByMajor.get(midx) + if (!step) return slot + const patched = applyMatchStepsToSlots({ ...draft, slots: [slot] }, [step]) + return patched.slots[0] + }) + return syncProgressionRoadmapFromSlots({ ...draft, slots: nextSlots, dirty: true }) +} + /** Lücken-Angebote in leere Slots legen; Panel nur für verbleibende Lücken. */ export function applyGapOffersFromResponse(draft, res, { replaceOffTopicSlots = false } = {}) { let next = draft -- 2.43.0 From 5bca5ef9eb3e62d1a7d502ae6a04ca9bc8b42730 Mon Sep 17 00:00:00 2001 From: Lars Date: Fri, 12 Jun 2026 13:33:36 +0200 Subject: [PATCH 04/20] Enhance Progression Path Evaluation and Optimization Features - Updated `suggest_progression_path` to include additional evaluation parameters, allowing for more comprehensive path assessments. - Introduced `PathQaPipelineDetails` component to display detailed quality assessment metrics, including rematch and refine logs, in the frontend. - Enhanced `ProgressionGraphEditor` to manage proposed path evaluations and integrate quality assessment results into the draft workflow. - Improved `ProgressionOptimizeCompareModal` to present optimization hints and quality tier information for proposed paths. - Bumped version to reflect the new features and improvements. --- backend/planning_exercise_path_builder.py | 10 +- .../components/ProgressionFindingsPanel.jsx | 134 +++++++++++++++++- .../src/components/ProgressionGraphEditor.jsx | 107 +++++++++----- .../ProgressionOptimizeCompareModal.jsx | 18 +++ 4 files changed, 228 insertions(+), 41 deletions(-) diff --git a/backend/planning_exercise_path_builder.py b/backend/planning_exercise_path_builder.py index b034dea..795360c 100644 --- a/backend/planning_exercise_path_builder.py +++ b/backend/planning_exercise_path_builder.py @@ -2253,9 +2253,15 @@ def suggest_progression_path( baseline_body = body.model_copy( update={ "evaluate_only": True, - "evaluate_steps": list(body.slot_assignments or []), + "evaluate_steps": list( + body.evaluate_steps or body.slot_assignments or [] + ), "compare_with_assignments": False, - "preserve_slot_assignments": True, + "preserve_slot_assignments": False, + # Gleiche QS-Pipeline wie „Graph bewerten“ (kein Match/Rematch-Schönung) + "include_llm_intent": False, + "auto_rematch_after_qa": False, + "include_roadmap_preview": False, } ) baseline = suggest_progression_path(cur, tenant=tenant, body=baseline_body) diff --git a/frontend/src/components/ProgressionFindingsPanel.jsx b/frontend/src/components/ProgressionFindingsPanel.jsx index 91ea61c..24dad48 100644 --- a/frontend/src/components/ProgressionFindingsPanel.jsx +++ b/frontend/src/components/ProgressionFindingsPanel.jsx @@ -26,6 +26,124 @@ function severityStyle(pathQa) { } } +function PathQaPipelineDetails({ pathQa, draft, title, compact = false }) { + const { fixHints: optimizationHints } = useMemo( + () => splitPathQaHints(pathQa), + [pathQa], + ) + const rematchLog = Array.isArray(pathQa?.rematch_log) ? pathQa.rematch_log : [] + const refineLog = Array.isArray(pathQa?.refine_log) ? pathQa.refine_log : [] + const qaTiers = Array.isArray(pathQa?.qa_tiers) ? pathQa.qa_tiers : [] + const qualityPct = pathQaQualityPercent(pathQa) + const hasContent = + qaTiers.length > 0 + || (pathQa?.rematch_applied && rematchLog.length > 0) + || (pathQa?.refine_applied && refineLog.length > 0) + || optimizationHints.length > 0 + + if (!pathQa || !hasContent) return null + + return ( +
+ + {title} + {qualityPct != null ? ` · ${pathQa.overall_ok ? 'OK' : 'Hinweise'} (${qualityPct} %)` : ''} + + {qaTiers.length > 0 ? ( +
    + {qaTiers.map((tier) => ( +
  • + {tier.label || tier.id} + {tier.finding_count != null ? ` (${tier.finding_count})` : ''} + {tier.applied === false ? ' · LLM aus' : ''} +
  • + ))} +
+ ) : null} + {pathQa.rematch_applied && rematchLog.length > 0 ? ( + <> +

+ Auto-Rematch + {pathQa.rematch_rounds != null ? ` (${pathQa.rematch_rounds} Runde(n))` : ''} +

+
    + {rematchLog.map((entry, i) => ( +
  • + {formatRematchLogEntry(entry)} +
  • + ))} +
+ + ) : null} + {pathQa.refine_applied && refineLog.length > 0 ? ( + <> +

+ Stufen-Spec verfeinert ({refineLog.length}) +

+
    + {refineLog.map((entry, i) => ( +
  • + {formatRefineLogEntry(entry)} +
  • + ))} +
+ + ) : null} + {optimizationHints.length > 0 ? ( + <> +

+ Handlungsbedarf ({optimizationHints.length}) +

+
    + {optimizationHints.slice(0, compact ? 4 : 8).map((hint, i) => { + const slotIdx = resolveHintSlotIndex(hint, draft) + return ( +
  • + + {optimizationHintActionLabel(hint.action)} + {slotIdx != null ? ` · Slot ${slotIdx + 1}` : ''} + + {hint.title ? ( +
    {hint.title}
    + ) : null} + {hint.reason ?

    {hint.reason}

    : null} +
  • + ) + })} +
+ + ) : null} +
+ ) +} + function GapOfferCard({ offer, slotCount, @@ -163,6 +281,7 @@ export default function ProgressionFindingsPanel({ onGenerateGapAi, onRematchSlots = null, onOptimizeCompare = null, + optimizationPreviewQa = null, canOptimizeCompare = false, optimizeCompareBusy = false, rematchBusy = false, @@ -187,7 +306,8 @@ export default function ProgressionFindingsPanel({

Graph-Bewertung

- Prüft den Slot-Stand und listet KI-Angebote für leere Stufen und Lücken. + Bewertet den aktuellen Slot-Stand (3-Stufen-QS, ohne Auto-Rematch) — dieselbe Logik nach Match, + solange keine Zuordnung geändert wurde.

) : (

diff --git a/frontend/src/components/ProgressionGraphEditor.jsx b/frontend/src/components/ProgressionGraphEditor.jsx index 6c12253..40caac1 100644 --- a/frontend/src/components/ProgressionGraphEditor.jsx +++ b/frontend/src/components/ProgressionGraphEditor.jsx @@ -122,6 +122,7 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa const [comparePayload, setComparePayload] = useState(null) const [comparing, setComparing] = useState(false) const [compareApplying, setCompareApplying] = useState(false) + const [proposedPathQa, setProposedPathQa] = useState(null) const loadGraph = useCallback(async () => { if (!graphId) return @@ -435,6 +436,35 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa } } + const buildEvaluateRequest = (synced) => { + const override = majorStepsToOverridePayload(synced.slots) + return { + query: (synced.goalQuery || '').trim(), + max_steps: synced.slots.length || draft?.maxSteps || 5, + include_path_qa: true, + include_llm_path_qa: true, + include_ai_gap_fill: true, + include_path_reorder: false, + include_llm_intent: false, + evaluate_only: true, + evaluate_steps: slotsToEvaluateSteps(synced), + roadmap_override: override, + slot_assignments: slotsToSlotAssignments(synced), + progression_graph_id: Number(graphId), + ...roadmapStructuredPayload(synced.startSituation, synced.targetState, synced.roadmapNotes), + ...catalogApiPayload, + } + } + + const fetchPathEvaluate = async (synced) => api.suggestProgressionPath(buildEvaluateRequest(synced)) + + const applyEvaluateResult = (synced, res) => { + setSemanticBrief(res?.semantic_brief_summary || null) + setPathQa(res?.path_qa || null) + const { draft: evaluated, remainingOffers } = applyEvaluateResponseToDraft(synced, res) + return { draft: { ...evaluated, lastFindings: res?.path_qa || null }, remainingOffers } + } + const buildMatchRequestBase = (synced) => { const override = majorStepsToOverridePayload(synced.slots) return { @@ -460,6 +490,7 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa const fetchOptimizeCompare = async (synced) => { const res = await api.suggestProgressionPath({ ...buildMatchRequestBase(synced), + evaluate_steps: slotsToEvaluateSteps(synced), preserve_slot_assignments: false, compare_with_assignments: true, }) @@ -475,6 +506,7 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa const baselineQa = res?.baseline_path_qa || null const proposedQa = res?.proposed_path_qa || res?.path_qa || null setPathQa(baselineQa) + setProposedPathQa(proposedQa) const openCompareDialog = (diffCount, noticePrefix) => { setComparePayload(res) @@ -521,11 +553,16 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa try { const synced = syncProgressionRoadmapFromSlots(draft) const hasAssignments = draftHasLibrarySlotAssignments(synced) + setProposedPathQa(null) if (hasAssignments) { const res = await fetchOptimizeCompare(synced) const { opened, res: compareRes } = presentOptimizeCompare(res, { source: 'match' }) - if (opened) return + if (opened) { + const evalRes = await fetchPathEvaluate(synced) + setPathQa(evalRes?.path_qa || compareRes?.baseline_path_qa || null) + return + } const { draft: matched, remainingOffers } = applyMatchResponseToDraft( { @@ -536,19 +573,27 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa }, compareRes, ) - setDraft(matched) - setPathQa(compareRes?.proposed_path_qa || compareRes?.path_qa || null) - setGapFillOffers(remainingOffers) + const syncedMatched = syncProgressionRoadmapFromSlots(matched) + const evalRes = await fetchPathEvaluate(syncedMatched) + const { draft: evaluated, remainingOffers: evalOffers } = applyEvaluateResult( + syncedMatched, + evalRes, + ) + setDraft(evaluated) + setGapFillOffers(evalOffers.length ? evalOffers : remainingOffers) + const evalPct = pathQaQualityPercent(evalRes?.path_qa) const ms = compareRes?.match_summary if (ms) { - setMatchNotice( - `Match: ${ms.library_matches ?? 0}/${ms.slot_count ?? '?'} Slots aus Bibliothek, ${ms.gap_fill_offer_count ?? 0} KI-Angebote. Bestehende Zuordnungen unverändert.`, - ) + let notice = `Match: ${ms.library_matches ?? 0}/${ms.slot_count ?? '?'} Slots aus Bibliothek, ${ms.gap_fill_offer_count ?? 0} KI-Angebote.` + if (evalPct != null) { + notice += ` Pfad-QS (Bewertung): ${evalPct} %.` + } + setMatchNotice(notice) } try { await saveProgressionGraphDraft(api, graphId, { - ...matched, - lastFindings: compareRes?.proposed_path_qa || compareRes?.path_qa || null, + ...evaluated, + lastFindings: evalRes?.path_qa || null, }) setDraft((prev) => (prev ? { ...prev, dirty: false } : prev)) } catch (saveErr) { @@ -625,6 +670,8 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa const synced = syncProgressionRoadmapFromSlots(draft) const res = await fetchOptimizeCompare(synced) presentOptimizeCompare(res, { source: 'manual' }) + const evalRes = await fetchPathEvaluate(synced) + setPathQa(evalRes?.path_qa || res?.baseline_path_qa || null) } catch (e) { setActionErr(e.message || 'Optimierungs-Vergleich fehlgeschlagen') } finally { @@ -642,15 +689,18 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa comparePayload.proposed_steps || comparePayload.steps, selectedMajorIndices, ) - const proposedQa = comparePayload.proposed_path_qa || comparePayload.path_qa - setDraft({ ...nextDraft, lastFindings: proposedQa || null }) - setPathQa(proposedQa || null) + const syncedNext = syncProgressionRoadmapFromSlots(nextDraft) + const evalRes = await fetchPathEvaluate(syncedNext) + const { draft: evaluated, remainingOffers } = applyEvaluateResult(syncedNext, evalRes) + setDraft({ ...evaluated, dirty: true }) + setGapFillOffers(remainingOffers) + setProposedPathQa(null) setCompareOpen(false) setComparePayload(null) - setMatchNotice('Ausgewählte Optimierungen übernommen.') + setMatchNotice('Ausgewählte Optimierungen übernommen — Pfad-QS neu bewertet.') await saveProgressionGraphDraft(api, graphId, { - ...nextDraft, - lastFindings: proposedQa || null, + ...evaluated, + lastFindings: evalRes?.path_qa || null, }) setDraft((prev) => (prev ? { ...prev, dirty: false } : prev)) } catch (e) { @@ -668,29 +718,12 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa } setEvaluating(true) setActionErr('') + setProposedPathQa(null) try { const synced = syncProgressionRoadmapFromSlots(draft) - const override = - validMajorSteps.length >= 2 ? majorStepsToOverridePayload(synced.slots) : undefined - const res = await api.suggestProgressionPath({ - query: q, - max_steps: synced.slots.length || draft.maxSteps || 5, - include_path_qa: true, - include_llm_path_qa: true, - include_ai_gap_fill: true, - include_path_reorder: false, - include_llm_intent: false, - evaluate_only: true, - evaluate_steps: slotsToEvaluateSteps(synced), - roadmap_override: override, - progression_graph_id: Number(graphId), - ...roadmapStructuredPayload(draft.startSituation, draft.targetState, draft.roadmapNotes), - ...catalogApiPayload, - }) - setSemanticBrief(res?.semantic_brief_summary || null) - setPathQa(res?.path_qa || null) - const { draft: evaluated, remainingOffers } = applyEvaluateResponseToDraft(synced, res) - setDraft({ ...evaluated, lastFindings: res?.path_qa || null }) + const res = await fetchPathEvaluate(synced) + const { draft: evaluated, remainingOffers } = applyEvaluateResult(synced, res) + setDraft(evaluated) setGapFillOffers(remainingOffers) } catch (e) { setActionErr(e.message || 'Bewertung fehlgeschlagen') @@ -1187,6 +1220,7 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa onGenerateGapAi={openGapFillPrep} onRematchSlots={runMatch} onOptimizeCompare={runOptimizeCompare} + optimizationPreviewQa={proposedPathQa} canOptimizeCompare={draftHasLibrarySlotAssignments(draft)} optimizeCompareBusy={comparing} rematchBusy={matching} @@ -1235,6 +1269,7 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa if (compareApplying) return setCompareOpen(false) setComparePayload(null) + setProposedPathQa(null) }} onApplySelected={applyOptimizeCompare} applying={compareApplying} diff --git a/frontend/src/components/ProgressionOptimizeCompareModal.jsx b/frontend/src/components/ProgressionOptimizeCompareModal.jsx index ca66e10..864ebdf 100644 --- a/frontend/src/components/ProgressionOptimizeCompareModal.jsx +++ b/frontend/src/components/ProgressionOptimizeCompareModal.jsx @@ -37,6 +37,11 @@ export default function ProgressionOptimizeCompareModal({ const proposedQa = comparison.proposed_path_qa || comparison.path_qa const baselinePct = pathQaQualityPercent(baselineQa) const proposedPct = pathQaQualityPercent(proposedQa) + const rematchRounds = proposedQa?.rematch_rounds + const rematchCount = Array.isArray(proposedQa?.rematch_log) ? proposedQa.rematch_log.length : 0 + const refineCount = Array.isArray(proposedQa?.refine_log) ? proposedQa.refine_log.length : 0 + const hintCount = Number(proposedQa?.optimization_hint_count || 0) + const tierCount = Array.isArray(proposedQa?.qa_tiers) ? proposedQa.qa_tiers.length : 0 const toggle = (midx) => { setSelected((prev) => { @@ -120,6 +125,19 @@ export default function ProgressionOptimizeCompareModal({ + {tierCount > 0 || rematchCount > 0 || refineCount > 0 || hintCount > 0 ? ( +

+ 3-Stufen-Optimierung im Vorschlag + {tierCount > 0 ? ` · ${tierCount} QS-Stufen` : ''} + {rematchCount > 0 + ? ` · Auto-Rematch ${rematchRounds != null ? `(${rematchRounds} Runde(n))` : ''}: ${rematchCount} Anpassung(en)` + : ''} + {refineCount > 0 ? ` · ${refineCount} Stufen-Spec verfeinert` : ''} + {hintCount > 0 ? ` · ${hintCount} Handlungshinweis(e)` : ''} + . Details im Panel „Graph-Bewertung“. +

+ ) : null} + {slotDiffs.length === 0 ? (

Keine abweichenden Slot-Zuordnungen — der optimierte Lauf liefert denselben Pfad. -- 2.43.0 From e828a5da32e180757b53bc4c9a5119ffa38f02ff Mon Sep 17 00:00:00 2001 From: Lars Date: Sat, 13 Jun 2026 07:44:01 +0200 Subject: [PATCH 05/20] Enhance Progression Path Evaluation and Comparison Logic - Introduced `_steps_to_evaluate_payloads` to convert path steps into evaluation payloads for improved quality assessments. - Updated `_build_progression_compare_response` to include a new `proposed_eval` parameter, allowing for fair quality assessment comparisons. - Enhanced `ProgressionGraphEditor` to utilize the new pipeline quality assessment data. - Modified `ProgressionOptimizeCompareModal` to display detailed comparison results, including handling of trivial slot differences and optimization hints. - Bumped version to reflect the new features and improvements. --- backend/planning_exercise_path_builder.py | 83 +++++++++++++++++-- .../tests/test_planning_compare_slot_diffs.py | 47 +++++++++++ .../src/components/ProgressionGraphEditor.jsx | 3 +- .../ProgressionOptimizeCompareModal.jsx | 58 +++++++++++-- 4 files changed, 175 insertions(+), 16 deletions(-) create mode 100644 backend/tests/test_planning_compare_slot_diffs.py diff --git a/backend/planning_exercise_path_builder.py b/backend/planning_exercise_path_builder.py index 795360c..8084c99 100644 --- a/backend/planning_exercise_path_builder.py +++ b/backend/planning_exercise_path_builder.py @@ -2176,6 +2176,52 @@ def _steps_by_major_index(steps: Sequence[Mapping[str, Any]]) -> Dict[int, Dict[ return out +def _steps_to_evaluate_payloads(steps: Sequence[Mapping[str, Any]]) -> List[EvaluateStepPayload]: + """Pfad-Schritte → evaluate_steps (für faire QS auf dem End-Stand).""" + payloads: List[EvaluateStepPayload] = [] + for step in steps or []: + if not isinstance(step, dict): + continue + midx = step.get("roadmap_major_step_index") + if midx is None: + continue + eid = step.get("exercise_id") + is_proposal = bool(step.get("is_ai_proposal")) or eid is None + payloads.append( + EvaluateStepPayload( + exercise_id=int(eid) if eid is not None and not is_proposal else None, + variant_id=step.get("variant_id"), + title=step.get("title"), + is_ai_proposal=is_proposal, + ai_suggestion=step.get("ai_suggestion") if isinstance(step.get("ai_suggestion"), dict) else None, + proposal_key=step.get("proposal_key"), + roadmap_major_step_index=int(midx), + roadmap_phase=step.get("roadmap_phase"), + roadmap_learning_goal=step.get("roadmap_learning_goal"), + ) + ) + payloads.sort(key=lambda p: int(p.roadmap_major_step_index or 0)) + return payloads + + +def _normalize_slot_title(title: Optional[str]) -> str: + return (title or "").strip().casefold() + + +def _filter_trivial_slot_diffs(diffs: Sequence[Mapping[str, Any]]) -> List[Dict[str, Any]]: + """Gleicher sichtbarer Titel = kein inhaltlicher Wechsel (nur ID-Doppel in der Bibliothek).""" + out: List[Dict[str, Any]] = [] + for raw in diffs or []: + if not isinstance(raw, dict): + continue + bt = _normalize_slot_title(raw.get("baseline_title")) + pt = _normalize_slot_title(raw.get("proposed_title")) + if bt and pt and bt == pt: + continue + out.append(dict(raw)) + return out + + def _build_progression_slot_diffs( baseline_steps: Sequence[Mapping[str, Any]], proposed_steps: Sequence[Mapping[str, Any]], @@ -2211,24 +2257,35 @@ def _build_progression_slot_diffs( def _build_progression_compare_response( baseline: Mapping[str, Any], proposed: Mapping[str, Any], + *, + proposed_eval: Optional[Mapping[str, Any]] = None, ) -> Dict[str, Any]: baseline_steps = list(baseline.get("steps") or []) proposed_steps = list(proposed.get("steps") or []) baseline_qa = baseline.get("path_qa") if isinstance(baseline.get("path_qa"), dict) else {} - proposed_qa = proposed.get("path_qa") if isinstance(proposed.get("path_qa"), dict) else {} - slot_diffs = _build_progression_slot_diffs(baseline_steps, proposed_steps) + pipeline_qa = proposed.get("path_qa") if isinstance(proposed.get("path_qa"), dict) else {} + fair_qa = ( + proposed_eval.get("path_qa") + if isinstance(proposed_eval, dict) and isinstance(proposed_eval.get("path_qa"), dict) + else pipeline_qa + ) + slot_diffs = _filter_trivial_slot_diffs( + _build_progression_slot_diffs(baseline_steps, proposed_steps), + ) return { **dict(proposed), "comparison_mode": True, "baseline_steps": baseline_steps, "baseline_path_qa": baseline_qa, "proposed_steps": proposed_steps, - "proposed_path_qa": proposed_qa, + "proposed_path_qa": fair_qa, + "proposed_path_qa_pipeline": pipeline_qa, "slot_diffs": slot_diffs, "slot_diff_count": len(slot_diffs), "baseline_quality_score": _path_qa_quality_score(baseline_qa), - "proposed_quality_score": _path_qa_quality_score(proposed_qa), - "path_qa": proposed_qa, + "proposed_quality_score": _path_qa_quality_score(fair_qa), + "proposed_pipeline_quality_score": _path_qa_quality_score(pipeline_qa), + "path_qa": fair_qa, "steps": proposed_steps, } @@ -2273,7 +2330,21 @@ def suggest_progression_path( } ) proposed = suggest_progression_path(cur, tenant=tenant, body=proposed_body) - return _build_progression_compare_response(baseline, proposed) + proposed_eval_payloads = _steps_to_evaluate_payloads(proposed.get("steps") or []) + proposed_eval: Optional[Dict[str, Any]] = None + if proposed_eval_payloads: + proposed_eval_body = body.model_copy( + update={ + "evaluate_only": True, + "evaluate_steps": proposed_eval_payloads, + "compare_with_assignments": False, + "include_llm_intent": False, + "auto_rematch_after_qa": False, + "include_roadmap_preview": False, + } + ) + proposed_eval = suggest_progression_path(cur, tenant=tenant, body=proposed_eval_body) + return _build_progression_compare_response(baseline, proposed, proposed_eval=proposed_eval) goal_query = _normalize_query(body.query) if len(goal_query) < 3: diff --git a/backend/tests/test_planning_compare_slot_diffs.py b/backend/tests/test_planning_compare_slot_diffs.py new file mode 100644 index 0000000..cef4d85 --- /dev/null +++ b/backend/tests/test_planning_compare_slot_diffs.py @@ -0,0 +1,47 @@ +"""Tests Vergleichs-Diffs (triviale ID-Tausche ausfiltern).""" +from planning_exercise_path_builder import ( + _build_progression_slot_diffs, + _filter_trivial_slot_diffs, +) + + +def test_filter_trivial_slot_diffs_same_title_different_id(): + diffs = [ + { + "roadmap_major_step_index": 1, + "baseline_exercise_id": 10, + "baseline_title": "Rhythmuswechsel in der Kumite-Beinarbeit", + "proposed_exercise_id": 99, + "proposed_title": "Rhythmuswechsel in der Kumite-Beinarbeit", + } + ] + assert _filter_trivial_slot_diffs(diffs) == [] + + +def test_filter_trivial_slot_diffs_keeps_real_title_change(): + diffs = [ + { + "roadmap_major_step_index": 1, + "baseline_exercise_id": 10, + "baseline_title": "Alt", + "proposed_exercise_id": 99, + "proposed_title": "Neu", + } + ] + filtered = _filter_trivial_slot_diffs(diffs) + assert len(filtered) == 1 + assert filtered[0]["proposed_title"] == "Neu" + + +def test_build_slot_diffs_then_filter(): + baseline = [ + {"roadmap_major_step_index": 0, "exercise_id": 1, "title": "A"}, + {"roadmap_major_step_index": 1, "exercise_id": 10, "title": "Gleich"}, + ] + proposed = [ + {"roadmap_major_step_index": 0, "exercise_id": 1, "title": "A"}, + {"roadmap_major_step_index": 1, "exercise_id": 77, "title": "Gleich"}, + ] + raw = _build_progression_slot_diffs(baseline, proposed) + assert len(raw) == 1 + assert _filter_trivial_slot_diffs(raw) == [] diff --git a/frontend/src/components/ProgressionGraphEditor.jsx b/frontend/src/components/ProgressionGraphEditor.jsx index 40caac1..785cccd 100644 --- a/frontend/src/components/ProgressionGraphEditor.jsx +++ b/frontend/src/components/ProgressionGraphEditor.jsx @@ -505,8 +505,9 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa setTargetSummary(res?.target_profile_summary || null) const baselineQa = res?.baseline_path_qa || null const proposedQa = res?.proposed_path_qa || res?.path_qa || null + const pipelineQa = res?.proposed_path_qa_pipeline || null setPathQa(baselineQa) - setProposedPathQa(proposedQa) + setProposedPathQa(pipelineQa) const openCompareDialog = (diffCount, noticePrefix) => { setComparePayload(res) diff --git a/frontend/src/components/ProgressionOptimizeCompareModal.jsx b/frontend/src/components/ProgressionOptimizeCompareModal.jsx index 864ebdf..648dae7 100644 --- a/frontend/src/components/ProgressionOptimizeCompareModal.jsx +++ b/frontend/src/components/ProgressionOptimizeCompareModal.jsx @@ -37,11 +37,17 @@ export default function ProgressionOptimizeCompareModal({ const proposedQa = comparison.proposed_path_qa || comparison.path_qa const baselinePct = pathQaQualityPercent(baselineQa) const proposedPct = pathQaQualityPercent(proposedQa) - const rematchRounds = proposedQa?.rematch_rounds - const rematchCount = Array.isArray(proposedQa?.rematch_log) ? proposedQa.rematch_log.length : 0 - const refineCount = Array.isArray(proposedQa?.refine_log) ? proposedQa.refine_log.length : 0 - const hintCount = Number(proposedQa?.optimization_hint_count || 0) - const tierCount = Array.isArray(proposedQa?.qa_tiers) ? proposedQa.qa_tiers.length : 0 + const pipelinePct = pathQaQualityPercent(comparison?.proposed_path_qa_pipeline) + const rematchRounds = comparison?.proposed_path_qa_pipeline?.rematch_rounds + ?? proposedQa?.rematch_rounds + const pipelineQa = comparison?.proposed_path_qa_pipeline + const rematchCount = Array.isArray(pipelineQa?.rematch_log) ? pipelineQa.rematch_log.length : 0 + const refineCount = Array.isArray(pipelineQa?.refine_log) ? pipelineQa.refine_log.length : 0 + const hintCount = Number(pipelineQa?.optimization_hint_count || 0) + const tierCount = Array.isArray(pipelineQa?.qa_tiers) ? pipelineQa.qa_tiers.length : 0 + const noMeaningfulDiffs = slotDiffs.length === 0 + const proposedNotBetter = + proposedPct != null && baselinePct != null && proposedPct <= baselinePct const toggle = (midx) => { setSelected((prev) => { @@ -75,10 +81,42 @@ export default function ProgressionOptimizeCompareModal({ Optimierung vergleichen

- Links dein aktueller Pfad, rechts der Vorschlag nach vollem Match inkl. Auto-Optimierung. - Wähle die Slots, die du übernehmen möchtest. + Vergleicht deinen Pfad mit dem End-Stand nach Match — beide Seiten mit derselben Bewertungslogik + wie „Graph bewerten“. Auto-Rematch-Details stehen im Panel, nicht in der Prozentzahl.

+ {noMeaningfulDiffs || proposedNotBetter ? ( +
+ {noMeaningfulDiffs ? ( + Keine inhaltlichen Slot-Änderungen + ) : ( + Vorschlag nicht besser als dein Pfad + )} + {noMeaningfulDiffs ? ( +

+ Rematch hat höchstens dieselben Übungen unter anderen IDs getroffen — kein Grund zur + Übernahme. Bitte abbrechen. +

+ ) : ( +

+ Fair bewertet liefert der Vorschlag keinen höheren Pfad-QS-Wert. Die frühere niedrigere + Pipeline-Zahl{pipelinePct != null ? ` (${pipelinePct} %)` : ''} stammte aus dem + Rematch-Lauf, nicht aus dem sichtbaren End-Pfad. +

+ )} +
+ ) : null} +
- Keine abweichenden Slot-Zuordnungen — der optimierte Lauf liefert denselben Pfad. + Keine inhaltlichen Abweichungen — der End-Stand entspricht deinem Pfad.

) : ( <> @@ -187,9 +225,11 @@ export default function ProgressionOptimizeCompareModal({ > Bisher: {diff.baseline_title || '— leer —'} + {diff.baseline_exercise_id != null ? ` (#${diff.baseline_exercise_id})` : ''} Neu: {diff.proposed_title || '— leer —'} + {diff.proposed_exercise_id != null ? ` (#${diff.proposed_exercise_id})` : ''}
@@ -208,7 +248,7 @@ export default function ProgressionOptimizeCompareModal({ - - -
    - {slotDiffs.map((diff) => { - const midx = Number(diff.roadmap_major_step_index) - const checked = selected.has(midx) - return ( -
  • 0 ? ( + <> +

    + Lücken füllen (empfohlen) +

    +
    +
  • - ) - })} -
+ Alle Lücken wählen + + + +
    + {recommended.map((diff) => ( + + ))} +
+ + ) : null} + + {optionalReplace.length > 0 ? ( + <> +

+ Bestehende Slots ersetzen (optional — oft Verschlechterung) +

+

+ Standard: abgewählt. Nur aktivieren, wenn du die konkrete Übung bewusst tauschen + willst. +

+
    + {optionalReplace.map((diff) => ( + + ))} +
+ + ) : null} )} -
+ {selectedReplaceCount > 0 ? ( +

+ {selectedReplaceCount} Ersetzung(en) gewählt — kann Pfad-QS senken. Lückenfüllungen + sind unkritischer. +

+ ) : null} + +
- -
-
    - {recommended.map((diff) => ( - - ))} -
- - ) : null} - - {optionalReplace.length > 0 ? ( - <> -

- Bestehende Slots ersetzen (optional — oft Verschlechterung) -

-

- Standard: abgewählt. Nur aktivieren, wenn du die konkrete Übung bewusst tauschen - willst. -

-
    - {optionalReplace.map((diff) => ( - - ))} -
- - ) : null} +
+ + +
+
    + {dialogDiffs.map((diff) => ( + + ))} +
)} - {selectedReplaceCount > 0 ? ( -

- {selectedReplaceCount} Ersetzung(en) gewählt — kann Pfad-QS senken. Lückenfüllungen - sind unkritischer. -

- ) : null} -
({ ...d, @@ -1004,20 +1010,57 @@ export function annotateCompareDiffKinds(diffs) { })) } -/** Nur übernehmbare Bibliotheks-Diffs (kein reines Titel-/Gap-Geplänkel). */ +/** Nur übernehmbare Verbesserungsvorschläge (Bibliothek oder KI-Angebot). */ export function compareDiffsForDialog(comparison) { + const fromSuggestions = (comparison?.slot_suggestions || []).filter((s) => s?.improves_path) + if (fromSuggestions.length > 0) { + return fromSuggestions + .map((s) => ({ ...s, diff_kind: suggestionDiffKind(s) })) + .filter( + (d) => + d.proposed_exercise_id != null + || (d.suggestion_type === 'ai_gap' && d.gap_offer), + ) + } + if (Array.isArray(comparison?.slot_diffs_improving)) { + return comparison.slot_diffs_improving.filter( + (d) => d?.proposed_exercise_id != null && !d?.trivial_id_swap, + ) + } const diffs = annotateCompareDiffKinds( compareSlotDiffs(comparison, { actionableOnly: true }), ) - return diffs.filter((d) => d.diff_kind === 'fill' || d.diff_kind === 'replace') + return diffs.filter( + (d) => + (d.diff_kind === 'fill' || d.diff_kind === 'replace') + && d.proposed_exercise_id != null, + ) +} + +export function suggestionDiffKind(suggestion) { + if (!suggestion) return 'skip' + if (suggestion.suggestion_type === 'ai_gap') return 'ai_gap' + if (suggestion.baseline_exercise_id == null && suggestion.proposed_exercise_id != null) { + return 'fill' + } + if (suggestion.baseline_exercise_id != null && suggestion.proposed_exercise_id != null) { + return 'replace' + } + return 'skip' } export function recommendedCompareDiffs(comparison) { - return compareDiffsForDialog(comparison).filter((d) => d.diff_kind === 'fill') + return compareDiffsForDialog(comparison) } export function optionalReplaceCompareDiffs(comparison) { - return compareDiffsForDialog(comparison).filter((d) => d.diff_kind === 'replace') + return [] +} + +export function rejectedCompareDiffs(comparison) { + return Array.isArray(comparison?.slot_diffs_rejected) + ? comparison.slot_diffs_rejected + : [] } export function gapOnlyCompareDiffs(comparison) { @@ -1027,7 +1070,7 @@ export function gapOnlyCompareDiffs(comparison) { } export function defaultSelectedCompareDiffs(comparison) { - return recommendedCompareDiffs(comparison).map((d) => Number(d.roadmap_major_step_index)) + return compareDiffsForDialog(comparison).map((d) => Number(d.roadmap_major_step_index)) } function mergeGapFillOffersFromSteps(steps, offers) { @@ -1044,29 +1087,43 @@ function mergeGapFillOffersFromSteps(steps, offers) { } /** - * Vergleich aus zwei kaskadierten Antworten (Evaluate → Match) — spiegelt Backend-Compare. + * Vergleich aus unified_slot_review oder kaskadierten Antworten (Evaluate → Match). */ export function buildProgressionComparePayload(baselineRes, proposedRes) { + if (proposedRes?.unified_slot_review) { + return buildUnifiedSlotReviewComparePayload(proposedRes) + } + const baselineSteps = Array.isArray(baselineRes?.steps) ? baselineRes.steps : [] const proposedSteps = Array.isArray(proposedRes?.steps) ? proposedRes.steps : [] const baselineQa = baselineRes?.path_qa || null const pipelineQa = proposedRes?.path_qa || null - const slotDiffs = annotateCompareDiffKinds( + const scoring = proposedRes?.slot_diff_scoring + const rawDiffs = annotateCompareDiffKinds( annotateCompareSlotDiffs( buildProgressionSlotDiffs(baselineSteps, proposedSteps), ), ) - const actionableDiffs = actionableCompareSlotDiffs(slotDiffs) - const dialogDiffs = actionableDiffs.filter( - (d) => d.diff_kind === 'fill' || d.diff_kind === 'replace', + const improvingDiffs = annotateCompareDiffKinds( + (scoring?.improvement_diffs || []).filter((d) => d?.proposed_exercise_id != null), ) - const recommendedDiffs = dialogDiffs.filter((d) => d.diff_kind === 'fill') + const rejectedDiffs = annotateCompareDiffKinds(scoring?.rejected_diffs || []) + const dialogDiffs = improvingDiffs.length > 0 + ? improvingDiffs + : rawDiffs.filter( + (d) => + !d.trivial_id_swap + && (d.diff_kind === 'fill' || d.diff_kind === 'replace') + && d.proposed_exercise_id != null + && d.improves_path !== false, + ) + const actionableDiffs = dialogDiffs const gapFillOffers = mergeGapFillOffersFromSteps( proposedSteps, proposedRes?.gap_fill_offers || [], ) - const proposedQa = - actionableDiffs.length === 0 && baselineQa ? baselineQa : pipelineQa + const baselineScore = scoring?.baseline_quality_score ?? baselineQa?.quality_score + const proposedQa = baselineQa return { ...proposedRes, @@ -1078,19 +1135,110 @@ export function buildProgressionComparePayload(baselineRes, proposedRes) { proposed_path_qa: proposedQa, proposed_path_qa_pipeline: pipelineQa, gap_fill_offers: gapFillOffers, - slot_diffs: slotDiffs, + slot_diffs: rawDiffs, slot_diffs_actionable: actionableDiffs, + slot_diffs_improving: improvingDiffs, + slot_diffs_rejected: rejectedDiffs, slot_diffs_dialog: dialogDiffs, - slot_diffs_recommended: recommendedDiffs, + slot_diffs_recommended: dialogDiffs, slot_diff_count: dialogDiffs.length, - slot_diff_count_recommended: recommendedDiffs.length, - slot_diff_count_including_trivial: slotDiffs.length, - slot_diffs_source: 'steps', + slot_diff_count_recommended: dialogDiffs.length, + slot_diff_count_rejected: rejectedDiffs.length, + slot_diff_count_including_trivial: rawDiffs.length, + slot_diffs_source: scoring ? 'incremental_scoring' : 'steps', + slot_diff_scoring: scoring, + baseline_quality_score: baselineScore, path_qa: proposedQa, steps: proposedSteps, } } +/** Einheitlicher Match-Review (Bewertung + Slot-Vorschläge in einem Lauf). */ +export function buildUnifiedSlotReviewComparePayload(res) { + const baselineSteps = Array.isArray(res?.baseline_steps) ? res.baseline_steps : (res?.steps || []) + const baselineQa = res?.baseline_path_qa || res?.path_qa || null + const scoring = res?.slot_diff_scoring + const suggestions = Array.isArray(res?.slot_suggestions) ? res.slot_suggestions : [] + const improving = suggestions.filter((s) => s?.improves_path) + const rejected = Array.isArray(scoring?.rejected_diffs) ? scoring.rejected_diffs : [] + const proposedSteps = improving.map(suggestionToApplyStep).filter(Boolean) + const gapFillOffers = Array.isArray(res?.gap_fill_offers) ? res.gap_fill_offers : [] + + return { + ...res, + comparison_mode: true, + unified_slot_review: true, + baseline_steps: baselineSteps, + baseline_path_qa: baselineQa, + proposed_steps: proposedSteps, + proposed_steps_pipeline: proposedSteps, + proposed_path_qa: baselineQa, + proposed_path_qa_pipeline: null, + gap_fill_offers: gapFillOffers, + slot_suggestions: suggestions, + slot_diffs: improving, + slot_diffs_improving: improving, + slot_diffs_rejected: rejected, + slot_diffs_dialog: improving, + slot_diffs_recommended: improving, + slot_diff_count: improving.length, + slot_diff_count_recommended: improving.length, + slot_diff_count_rejected: rejected.length, + slot_diffs_source: 'unified_slot_review', + slot_diff_scoring: scoring, + baseline_quality_score: scoring?.baseline_quality_score ?? baselineQa?.quality_score, + path_qa: baselineQa, + steps: baselineSteps, + } +} + +function suggestionToApplyStep(suggestion) { + if (!suggestion || suggestion.roadmap_major_step_index == null) return null + const midx = Number(suggestion.roadmap_major_step_index) + if (suggestion.suggestion_type === 'ai_gap' && suggestion.gap_offer) { + const offer = suggestion.gap_offer + return { + roadmap_major_step_index: midx, + exercise_id: null, + title: offer.title_hint || suggestion.proposed_title || `Slot ${midx + 1}`, + is_ai_proposal: true, + proposal_key: offer.offer_id || `roadmap-unfilled-${midx}`, + gap_offer: offer, + slot_status: 'ai_proposal', + } + } + if (suggestion.proposed_exercise_id == null) return null + return { + roadmap_major_step_index: midx, + exercise_id: suggestion.proposed_exercise_id, + title: suggestion.proposed_title, + slot_status: suggestion.proposed_slot_status || 'matched', + is_ai_proposal: false, + } +} + +/** Ausgewählte Slot-Vorschläge aus unified review übernehmen. */ +export function applySelectedSlotSuggestions(draft, comparison, selectedMajorIndices) { + const selected = new Set( + (selectedMajorIndices || []) + .map((x) => Number(x)) + .filter((x) => Number.isFinite(x)), + ) + if (!selected.size) return draft + const steps = (comparison?.slot_suggestions || []) + .filter((s) => selected.has(Number(s.roadmap_major_step_index))) + .map(suggestionToApplyStep) + .filter(Boolean) + if (!steps.length) { + return applySelectedCompareSteps( + draft, + comparison?.proposed_steps || comparison?.steps, + selectedMajorIndices, + ) + } + return applyMatchStepsToSlots(draft, steps) +} + /** Alle Slot-Diffs inkl. reiner ID-Tausche (gleicher Titel). */ export function compareSlotDiffs(comparison, { actionableOnly = false } = {}) { if (actionableOnly && Array.isArray(comparison?.slot_diffs_actionable)) { -- 2.43.0 From a1e4ad66df30261df47c3abcccfb8396065fb9c9 Mon Sep 17 00:00:00 2001 From: Lars Date: Sat, 13 Jun 2026 10:27:07 +0200 Subject: [PATCH 14/20] Implement Quick Evaluation and Quality Scoring for Path QA - Added `_quick_evaluate_steps_qa` function to streamline path quality assessment without recursive API calls, enhancing performance for slot comparisons. - Introduced `compute_deterministic_path_quality_score` to provide a heuristic quality score based on gaps and off-topic steps, improving evaluation accuracy. - Updated `_run_unified_slot_improvement_review` to utilize the new quick evaluation method, optimizing the review process and integrating quality scoring. - Enhanced `build_path_qa_summary` to include quality score calculations, ensuring comprehensive feedback on path evaluations. - Refactored related functions for improved clarity and efficiency in handling path quality assessments. --- backend/planning_exercise_path_builder.py | 176 ++++++++++++++---- backend/planning_exercise_path_qa.py | 32 ++++ ...st_planning_deterministic_quality_score.py | 21 +++ .../src/components/ProgressionGraphEditor.jsx | 5 +- 4 files changed, 199 insertions(+), 35 deletions(-) create mode 100644 backend/tests/test_planning_deterministic_quality_score.py diff --git a/backend/planning_exercise_path_builder.py b/backend/planning_exercise_path_builder.py index 7114ced..9778606 100644 --- a/backend/planning_exercise_path_builder.py +++ b/backend/planning_exercise_path_builder.py @@ -36,6 +36,7 @@ from planning_stage_context import build_contextualized_stage_goal, resolve_path from planning_exercise_path_qa import ( apply_llm_path_reorder, build_path_qa_summary, + compute_deterministic_path_quality_score, detect_off_topic_steps, detect_path_gaps, insert_bridge_exercises, @@ -2399,6 +2400,110 @@ def _evaluate_steps_for_compare_qa( return suggest_progression_path(cur, tenant=tenant, body=eval_body) +def _quick_evaluate_steps_qa( + cur, + *, + goal_query: str, + semantic_brief: PlanningSemanticBrief, + steps: Sequence[Mapping[str, Any]], + roadmap_ctx: Optional[ProgressionRoadmapContext], +) -> Dict[str, Any]: + """Schnelle Pfad-QS ohne rekursiven API-Lauf — für Slot-Vergleiche.""" + roadmap_first = roadmap_ctx is not None + steps_list = list(steps or []) + gaps = detect_path_gaps( + cur, + steps_list, + brief=semantic_brief, + roadmap_first=roadmap_first, + ) + off_topic_steps = detect_off_topic_steps( + cur, + steps_list, + brief=semantic_brief, + goal_query=goal_query, + ) + multistage_qa = run_multistage_path_qa( + off_topic_steps=off_topic_steps, + stripped_off_topic=[], + gaps=gaps, + llm_qa=None, + llm_applied=False, + ) + path_qa = build_path_qa_summary( + gaps=gaps, + bridge_inserts=[], + ai_proposals=[], + gap_fill_offers=[], + off_topic_steps=off_topic_steps, + stripped_off_topic=[], + llm_qa=None, + llm_applied=False, + roadmap_qa_mode="roadmap_first_lite" if roadmap_first else None, + multistage_qa=multistage_qa, + ) + if path_qa.get("quality_score") is None: + path_qa["quality_score"] = compute_deterministic_path_quality_score( + gaps=gaps, + off_topic_steps=off_topic_steps, + steps=steps_list, + multistage_qa=multistage_qa, + ) + return path_qa + + +def _off_topic_slot_indices(path_qa: Optional[Mapping[str, Any]]) -> Set[int]: + return set(_off_topic_reasons_by_slot((path_qa or {}).get("off_topic_steps") or []).keys()) + + +def _slot_suggestion_accepted( + *, + baseline_qa: Optional[Mapping[str, Any]], + projected_qa: Optional[Mapping[str, Any]], + baseline_score: Optional[float], + projected_score: Optional[float], + diff: Mapping[str, Any], + off_topic: bool, + major_idx: int, +) -> bool: + """Entscheidet, ob ein Slot-Vorschlag in die Liste kommt.""" + base_id = diff.get("baseline_exercise_id") + prop_id = diff.get("proposed_exercise_id") + base_off = _off_topic_slot_indices(baseline_qa) + proj_off = _off_topic_slot_indices(projected_qa) + + if off_topic and base_id is not None: + if major_idx in base_off and major_idx not in proj_off: + return True + if major_idx in base_off and prop_id is not None: + return _slot_diff_improves_path(diff, _quality_delta(baseline_score, projected_score), off_topic=True) + + if base_id is None and prop_id is not None: + return _slot_diff_improves_path(diff, _quality_delta(baseline_score, projected_score), off_topic=False) + + if base_id is not None and prop_id is not None: + if int(base_id) == int(prop_id): + return False + return _slot_diff_improves_path(diff, _quality_delta(baseline_score, projected_score), off_topic=False) + + if base_id is None and prop_id is None and diff.get("proposed_is_ai_proposal"): + return _slot_diff_improves_path( + diff, + _quality_delta(baseline_score, projected_score), + off_topic=off_topic or major_idx in base_off, + ) + return False + + +def _quality_delta( + baseline_score: Optional[float], + projected_score: Optional[float], +) -> Optional[float]: + if baseline_score is None or projected_score is None: + return None + return round(float(projected_score) - float(baseline_score), 4) + + def _apply_slot_diff_to_steps( baseline_steps: Sequence[Mapping[str, Any]], diff: Mapping[str, Any], @@ -2784,6 +2889,14 @@ def _run_unified_slot_improvement_review( ) baseline_steps = list(qa_pack.get("steps") or baseline_steps) baseline_qa = qa_pack.get("path_qa") if isinstance(qa_pack.get("path_qa"), dict) else {} + if baseline_qa.get("quality_score") is None: + baseline_qa = dict(baseline_qa) + baseline_qa["quality_score"] = compute_deterministic_path_quality_score( + gaps=baseline_qa.get("large_gaps") or [], + off_topic_steps=baseline_qa.get("off_topic_steps") or [], + steps=baseline_steps, + multistage_qa=baseline_qa, + ) baseline_score = _path_qa_quality_score(baseline_qa) gap_fill_offers = list(qa_pack.get("gap_fill_offers") or []) off_topic_map = _off_topic_reasons_by_slot(baseline_qa.get("off_topic_steps") or []) @@ -2794,14 +2907,6 @@ def _run_unified_slot_improvement_review( suggestions: List[Dict[str, Any]] = [] rejected: List[Dict[str, Any]] = [] - scored_eval_body = body.model_copy( - update={ - "include_llm_path_qa": False, - "include_ai_gap_fill": False, - "auto_rematch_after_qa": False, - "include_roadmap_preview": False, - } - ) for step_index, stage_spec in enumerate(roadmap_ctx.stage_specs): major_idx = int(stage_spec.major_step_index) @@ -2856,6 +2961,7 @@ def _run_unified_slot_improvement_review( anchor_variant_id=anchor_variant_id, used=used_other, exclude_exercise_id=exclude_id if not off_topic else int(current_id) if current_id else None, + max_candidates=3, ) accepted_for_slot = False @@ -2882,22 +2988,25 @@ def _run_unified_slot_improvement_review( if int(raw.get("roadmap_major_step_index", -1)) == major_idx: merged_steps[i] = {**raw, **candidate, "roadmap_major_step_index": major_idx} break - eval_res = _evaluate_steps_for_compare_qa( + eval_res = _quick_evaluate_steps_qa( cur, - tenant=tenant, - body=scored_eval_body, + goal_query=goal_query, + semantic_brief=semantic_brief, steps=merged_steps, + roadmap_ctx=roadmap_ctx, ) - projected_qa = ( - eval_res.get("path_qa") - if isinstance(eval_res, dict) and isinstance(eval_res.get("path_qa"), dict) - else None - ) + projected_qa = eval_res if isinstance(eval_res, dict) else None projected_score = _path_qa_quality_score(projected_qa) - delta: Optional[float] = None - if baseline_score is not None and projected_score is not None: - delta = round(projected_score - baseline_score, 4) - improves = _slot_diff_improves_path(diff_stub, delta, off_topic=off_topic) + delta = _quality_delta(baseline_score, projected_score) + improves = _slot_suggestion_accepted( + baseline_qa=baseline_qa, + projected_qa=projected_qa, + baseline_score=baseline_score, + projected_score=projected_score, + diff=diff_stub, + off_topic=off_topic, + major_idx=major_idx, + ) suggestion_type = ( "remove_and_replace" if off_topic and current_id is not None @@ -2990,24 +3099,25 @@ def _run_unified_slot_improvement_review( "proposed_title": ai_step.get("title"), } merged_steps = _apply_slot_diff_to_steps(baseline_steps, diff_stub, [ai_step]) - eval_res = _evaluate_steps_for_compare_qa( + eval_res = _quick_evaluate_steps_qa( cur, - tenant=tenant, - body=scored_eval_body, + goal_query=goal_query, + semantic_brief=semantic_brief, steps=merged_steps, + roadmap_ctx=roadmap_ctx, ) - projected_qa = ( - eval_res.get("path_qa") - if isinstance(eval_res, dict) and isinstance(eval_res.get("path_qa"), dict) - else None - ) + projected_qa = eval_res if isinstance(eval_res, dict) else None projected_score = _path_qa_quality_score(projected_qa) - delta = ( - round(projected_score - baseline_score, 4) - if baseline_score is not None and projected_score is not None - else None + delta = _quality_delta(baseline_score, projected_score) + improves = _slot_suggestion_accepted( + baseline_qa=baseline_qa, + projected_qa=projected_qa, + baseline_score=baseline_score, + projected_score=projected_score, + diff=diff_stub, + off_topic=off_topic or major_idx in _off_topic_slot_indices(baseline_qa), + major_idx=major_idx, ) - improves = _slot_diff_improves_path(diff_stub, delta, off_topic=off_topic or current_id is None) entry = { **diff_stub, "baseline_slot_status": current.get("slot_status"), diff --git a/backend/planning_exercise_path_qa.py b/backend/planning_exercise_path_qa.py index 472ec35..48770a1 100644 --- a/backend/planning_exercise_path_qa.py +++ b/backend/planning_exercise_path_qa.py @@ -745,12 +745,44 @@ def build_path_qa_summary( f"Schritt „{o.get('title')}“ passt nicht zum Pfad-Thema" for o in off_topic ] + summary["quality_score"] = compute_deterministic_path_quality_score( + gaps=gaps, + off_topic_steps=off_topic, + steps=steps, + multistage_qa=multistage_qa, + ) return summary +def compute_deterministic_path_quality_score( + *, + gaps: Sequence[Mapping[str, Any]], + off_topic_steps: Sequence[Mapping[str, Any]], + steps: Optional[Sequence[Mapping[str, Any]]] = None, + multistage_qa: Optional[Mapping[str, Any]] = None, +) -> float: + """Heuristische Pfad-QS ohne LLM — Basis für Slot-Vergleiche.""" + score = 0.92 + score -= 0.08 * len(off_topic_steps or []) + score -= 0.05 * len(gaps or []) + if steps: + empty = sum( + 1 + for s in steps + if isinstance(s, dict) + and s.get("exercise_id") is None + and not s.get("is_ai_proposal") + ) + score -= 0.06 * empty + hint_count = int((multistage_qa or {}).get("optimization_hint_count") or 0) + score -= min(0.14, 0.02 * hint_count) + return max(0.35, min(0.98, round(score, 4))) + + __all__ = [ "apply_llm_path_reorder", "build_path_qa_summary", + "compute_deterministic_path_quality_score", "detect_off_topic_steps", "detect_path_gaps", "is_roadmap_planned_neighbor_pair", diff --git a/backend/tests/test_planning_deterministic_quality_score.py b/backend/tests/test_planning_deterministic_quality_score.py new file mode 100644 index 0000000..f5a0975 --- /dev/null +++ b/backend/tests/test_planning_deterministic_quality_score.py @@ -0,0 +1,21 @@ +"""Deterministische Pfad-QS ohne LLM.""" +from planning_exercise_path_qa import compute_deterministic_path_quality_score + + +def test_deterministic_quality_score_penalizes_off_topic(): + base = compute_deterministic_path_quality_score(gaps=[], off_topic_steps=[]) + with_off = compute_deterministic_path_quality_score( + gaps=[], + off_topic_steps=[{"roadmap_major_step_index": 1}], + ) + assert with_off < base + + +def test_deterministic_quality_score_penalizes_empty_slots(): + base = compute_deterministic_path_quality_score(gaps=[], off_topic_steps=[], steps=[]) + with_empty = compute_deterministic_path_quality_score( + gaps=[], + off_topic_steps=[], + steps=[{"exercise_id": None}, {"exercise_id": 1}], + ) + assert with_empty < base diff --git a/frontend/src/components/ProgressionGraphEditor.jsx b/frontend/src/components/ProgressionGraphEditor.jsx index 4d87c5b..43eddbe 100644 --- a/frontend/src/components/ProgressionGraphEditor.jsx +++ b/frontend/src/components/ProgressionGraphEditor.jsx @@ -500,10 +500,11 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa unified_slot_review: true, baseline_evaluate_steps: slotsToEvaluateSteps(synced), include_llm_intent: false, - include_llm_path_qa: false, auto_rematch_after_qa: false, }) - setPathQa(reviewRes?.path_qa || null) + const qa = reviewRes?.path_qa || null + setPathQa(qa) + setDraft((prev) => (prev ? { ...prev, lastFindings: qa } : prev)) const compareRes = buildProgressionComparePayload(null, reviewRes) setGapFillOffers(mergeGapOffersForDraft(synced, reviewRes, reviewRes)) -- 2.43.0 From 3468b2066ebb34587b8f84bb2c76161b7c5ed720 Mon Sep 17 00:00:00 2001 From: Lars Date: Sat, 13 Jun 2026 10:39:52 +0200 Subject: [PATCH 15/20] Enhance Path QA and Progression Review Logic - Introduced `_resolve_hint_major_index` to accurately map hints to major step indices, improving the handling of optimization hints in path evaluations. - Added `_problematic_slots_from_path_qa` to identify and categorize problematic slots based on baseline QA, enhancing the quality assessment process. - Updated `_slot_suggestion_accepted` to incorporate new parameters for slot problems and stage specifications, refining the decision-making process for slot suggestions. - Enhanced `ProgressionGraphEditor` to improve user notifications regarding identified issues and suggestions, ensuring clearer communication of path evaluation results. - Modified `buildProgressionComparePayload` and `buildUnifiedSlotReviewComparePayload` to support baseline evaluations, streamlining the comparison process for proposed paths. --- backend/planning_exercise_path_builder.py | 184 ++++++++++++++++-- .../tests/test_planning_problematic_slots.py | 53 +++++ .../src/components/ProgressionGraphEditor.jsx | 33 +++- frontend/src/utils/progressionGraphDraft.js | 10 +- 4 files changed, 251 insertions(+), 29 deletions(-) create mode 100644 backend/tests/test_planning_problematic_slots.py diff --git a/backend/planning_exercise_path_builder.py b/backend/planning_exercise_path_builder.py index 9778606..649848b 100644 --- a/backend/planning_exercise_path_builder.py +++ b/backend/planning_exercise_path_builder.py @@ -2456,6 +2456,107 @@ def _off_topic_slot_indices(path_qa: Optional[Mapping[str, Any]]) -> Set[int]: return set(_off_topic_reasons_by_slot((path_qa or {}).get("off_topic_steps") or []).keys()) +def _resolve_hint_major_index( + hint: Mapping[str, Any], + stage_specs: Sequence[StageSpecArtifact], +) -> Optional[int]: + raw = hint.get("roadmap_major_step_index") + if raw is not None: + try: + return int(raw) + except (TypeError, ValueError): + return None + step_index = hint.get("step_index") + if step_index is None: + return None + try: + pos = int(step_index) + except (TypeError, ValueError): + return None + if 0 <= pos < len(stage_specs): + return int(stage_specs[pos].major_step_index) + return pos if pos >= 0 else None + + +def _problematic_slots_from_path_qa( + baseline_qa: Optional[Mapping[str, Any]], + baseline_steps: Sequence[Mapping[str, Any]], + stage_specs: Sequence[StageSpecArtifact], +) -> Dict[int, List[str]]: + """Schachstellen aus derselben QS wie „Graph bewerten“ — Basis für Match-Vorschläge.""" + problems: Dict[int, List[str]] = {} + + def _add(midx: int, reason: str) -> None: + text = (reason or "").strip() + if not text: + return + bucket = problems.setdefault(int(midx), []) + if text not in bucket: + bucket.append(text[:400]) + + for midx, reasons in _off_topic_reasons_by_slot( + (baseline_qa or {}).get("off_topic_steps") or [], + ).items(): + for reason in reasons: + _add(midx, reason) + + for hint in (baseline_qa or {}).get("optimization_hints") or []: + if not isinstance(hint, dict): + continue + action = str(hint.get("action") or "").strip().lower() + if action in ("review_roadmap", "refine_stage_spec"): + continue + midx = _resolve_hint_major_index(hint, stage_specs) + if midx is None: + continue + _add( + midx, + str( + hint.get("reason") + or hint.get("issue") + or hint.get("title") + or action + ), + ) + + for raw in (baseline_qa or {}).get("issues") or []: + text = str(raw or "").strip() + if not text: + continue + for step in baseline_steps or []: + if not isinstance(step, dict): + continue + midx = step.get("roadmap_major_step_index") + if midx is None: + continue + try: + slot_no = int(midx) + 1 + except (TypeError, ValueError): + continue + title = str(step.get("title") or "").strip() + if ( + f"slot {slot_no}" in text.lower() + or f"stufe {slot_no}" in text.lower() + or (title and title.lower() in text.lower()) + ): + _add(int(midx), text) + + for step in baseline_steps or []: + if not isinstance(step, dict): + continue + midx = step.get("roadmap_major_step_index") + if midx is None: + continue + try: + major_idx = int(midx) + except (TypeError, ValueError): + continue + if step.get("exercise_id") is None and not step.get("is_ai_proposal"): + _add(major_idx, "Leerer Slot ohne Bibliotheks-Übung") + + return problems + + def _slot_suggestion_accepted( *, baseline_qa: Optional[Mapping[str, Any]], @@ -2465,32 +2566,53 @@ def _slot_suggestion_accepted( diff: Mapping[str, Any], off_topic: bool, major_idx: int, + slot_problem: bool = False, + stage_specs: Optional[Sequence[StageSpecArtifact]] = None, + baseline_steps: Optional[Sequence[Mapping[str, Any]]] = None, + projected_steps: Optional[Sequence[Mapping[str, Any]]] = None, ) -> bool: """Entscheidet, ob ein Slot-Vorschlag in die Liste kommt.""" base_id = diff.get("baseline_exercise_id") prop_id = diff.get("proposed_exercise_id") base_off = _off_topic_slot_indices(baseline_qa) proj_off = _off_topic_slot_indices(projected_qa) + delta = _quality_delta(baseline_score, projected_score) + + if prop_id is not None and base_id is not None and int(base_id) == int(prop_id): + return False + + if slot_problem and prop_id is not None: + if major_idx in base_off and major_idx not in proj_off: + return True + if delta is not None and delta >= -0.001: + return True + if stage_specs is not None: + proj_problems = _problematic_slots_from_path_qa( + projected_qa, + projected_steps or baseline_steps or [], + stage_specs, + ) + if major_idx not in proj_problems: + return True + return True if off_topic and base_id is not None: if major_idx in base_off and major_idx not in proj_off: return True - if major_idx in base_off and prop_id is not None: - return _slot_diff_improves_path(diff, _quality_delta(baseline_score, projected_score), off_topic=True) + if prop_id is not None: + return _slot_diff_improves_path(diff, delta, off_topic=True) if base_id is None and prop_id is not None: - return _slot_diff_improves_path(diff, _quality_delta(baseline_score, projected_score), off_topic=False) + return _slot_diff_improves_path(diff, delta, off_topic=False) if base_id is not None and prop_id is not None: - if int(base_id) == int(prop_id): - return False - return _slot_diff_improves_path(diff, _quality_delta(baseline_score, projected_score), off_topic=False) + return _slot_diff_improves_path(diff, delta, off_topic=False) if base_id is None and prop_id is None and diff.get("proposed_is_ai_proposal"): return _slot_diff_improves_path( diff, - _quality_delta(baseline_score, projected_score), - off_topic=off_topic or major_idx in base_off, + delta, + off_topic=off_topic or major_idx in base_off or slot_problem, ) return False @@ -2900,6 +3022,11 @@ def _run_unified_slot_improvement_review( baseline_score = _path_qa_quality_score(baseline_qa) gap_fill_offers = list(qa_pack.get("gap_fill_offers") or []) off_topic_map = _off_topic_reasons_by_slot(baseline_qa.get("off_topic_steps") or []) + problem_slots = _problematic_slots_from_path_qa( + baseline_qa, + baseline_steps, + roadmap_ctx.stage_specs, + ) steps_by_major = _steps_by_major_index(baseline_steps) spec_by_major = {int(s.major_step_index): s for s in roadmap_ctx.stage_specs} @@ -2913,10 +3040,11 @@ def _run_unified_slot_improvement_review( current = dict(steps_by_major.get(major_idx, {})) current.setdefault("roadmap_major_step_index", major_idx) current_id = current.get("exercise_id") - off_topic = major_idx in off_topic_map or bool( + slot_problem = major_idx in problem_slots + off_topic = slot_problem or major_idx in off_topic_map or bool( current.get("slot_status") in {"off_topic", "stripped"} ) - off_reasons = off_topic_map.get(major_idx, []) + off_reasons = list(problem_slots.get(major_idx, [])) + off_topic_map.get(major_idx, []) planned_ids = [ int(s["exercise_id"]) @@ -2937,7 +3065,12 @@ def _run_unified_slot_improvement_review( anchor_variant_id = int(vid) if vid is not None else None exclude_id: Optional[int] = None - if current_id is not None and not off_topic: + if current_id is not None and not (off_topic or slot_problem): + try: + exclude_id = int(current_id) + except (TypeError, ValueError): + exclude_id = None + elif current_id is not None and (off_topic or slot_problem): try: exclude_id = int(current_id) except (TypeError, ValueError): @@ -2972,7 +3105,7 @@ def _run_unified_slot_improvement_review( continue if ( current_id is not None - and not off_topic + and not (off_topic or slot_problem) and int(current_id) == cand_id ): continue @@ -3006,10 +3139,14 @@ def _run_unified_slot_improvement_review( diff=diff_stub, off_topic=off_topic, major_idx=major_idx, + slot_problem=slot_problem, + stage_specs=roadmap_ctx.stage_specs, + baseline_steps=baseline_steps, + projected_steps=merged_steps, ) suggestion_type = ( "remove_and_replace" - if off_topic and current_id is not None + if (off_topic or slot_problem) and current_id is not None else ("library_fill" if current_id is None else "library_improvement") ) entry = { @@ -3023,6 +3160,8 @@ def _run_unified_slot_improvement_review( "projected_path_qa": projected_qa, "improves_path": improves, "off_topic": off_topic, + "slot_problem": slot_problem, + "problem_reasons": off_reasons[:6], "proposed_is_ai_proposal": False, "pro_contra": _build_slot_pro_contra( current_step=current, @@ -3048,6 +3187,7 @@ def _run_unified_slot_improvement_review( needs_ai = ( current_id is None or off_topic + or slot_problem or bool(current.get("is_ai_proposal")) ) if not needs_ai or not body.include_ai_gap_fill: @@ -3115,8 +3255,12 @@ def _run_unified_slot_improvement_review( baseline_score=baseline_score, projected_score=projected_score, diff=diff_stub, - off_topic=off_topic or major_idx in _off_topic_slot_indices(baseline_qa), + off_topic=off_topic, major_idx=major_idx, + slot_problem=slot_problem, + stage_specs=roadmap_ctx.stage_specs, + baseline_steps=baseline_steps, + projected_steps=merged_steps, ) entry = { **diff_stub, @@ -3127,8 +3271,10 @@ def _run_unified_slot_improvement_review( "projected_quality_score": projected_score, "baseline_quality_score": baseline_score, "projected_path_qa": projected_qa, - "improves_path": improves, + "improves_path": improves or slot_problem, "off_topic": off_topic, + "slot_problem": slot_problem, + "problem_reasons": off_reasons[:6], "proposed_is_ai_proposal": True, "gap_offer": slot_offer, "pro_contra": _build_slot_pro_contra( @@ -3143,12 +3289,16 @@ def _run_unified_slot_improvement_review( gap_offer=slot_offer, ), } - if improves: + if improves or slot_problem: + entry["improves_path"] = True suggestions.append(entry) else: rejected.append(entry) improvement_diffs = [_suggestion_as_slot_diff(s) for s in suggestions] + problem_slot_payload = { + str(k): v for k, v in sorted(problem_slots.items(), key=lambda x: x[0]) + } slot_diff_scoring = { "baseline_quality_score": baseline_score, "scored_diffs": improvement_diffs + [_suggestion_as_slot_diff(r) for r in rejected], @@ -3182,9 +3332,11 @@ def _run_unified_slot_improvement_review( "unified_slot_review": True, "suggestion_count": len(suggestions), "rejected_count": len(rejected), + "problem_slot_count": len(problem_slots), }, "retrieval_phase": "unified_slot_review", "unified_slot_review": True, + "problem_slots": problem_slot_payload, "slot_suggestions": suggestions, "slot_diff_scoring": slot_diff_scoring, "comparison_mode": True, diff --git a/backend/tests/test_planning_problematic_slots.py b/backend/tests/test_planning_problematic_slots.py new file mode 100644 index 0000000..00039f7 --- /dev/null +++ b/backend/tests/test_planning_problematic_slots.py @@ -0,0 +1,53 @@ +"""Schachstellen-Erkennung für unified Slot-Review.""" +from planning_exercise_path_builder import ( + _problematic_slots_from_path_qa, + _slot_suggestion_accepted, +) +from planning_progression_roadmap import StageSpecArtifact + + +def _spec(midx: int) -> StageSpecArtifact: + return StageSpecArtifact( + major_step_index=midx, + learning_goal=f"Lernziel Slot {midx + 1}", + load_profile=[], + exercise_type="", + success_criteria=[], + anti_patterns=[], + ) + + +def test_problematic_slots_from_optimization_hints(): + qa = { + "optimization_hints": [ + { + "action": "rematch_slot", + "step_index": 1, + "issue": "stage_mismatch", + "reason": "Übung passt nicht zur Stufe", + } + ], + "off_topic_steps": [], + } + steps = [ + {"roadmap_major_step_index": 0, "exercise_id": 1, "title": "A"}, + {"roadmap_major_step_index": 1, "exercise_id": 2, "title": "B"}, + ] + specs = [_spec(0), _spec(1)] + problems = _problematic_slots_from_path_qa(qa, steps, specs) + assert 1 in problems + assert any("Stufe" in r or "passt" in r for r in problems[1]) + + +def test_slot_suggestion_accepted_for_problem_slot(): + diff = {"baseline_exercise_id": 10, "proposed_exercise_id": 99} + assert _slot_suggestion_accepted( + baseline_qa={"optimization_hints": [{"action": "rematch_slot", "roadmap_major_step_index": 1}]}, + projected_qa={"optimization_hints": []}, + baseline_score=0.7, + projected_score=0.7, + diff=diff, + off_topic=False, + major_idx=1, + slot_problem=True, + ) diff --git a/frontend/src/components/ProgressionGraphEditor.jsx b/frontend/src/components/ProgressionGraphEditor.jsx index 43eddbe..a60b80a 100644 --- a/frontend/src/components/ProgressionGraphEditor.jsx +++ b/frontend/src/components/ProgressionGraphEditor.jsx @@ -494,20 +494,31 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa } const runMatchCompareFlow = async (synced, { source = 'match' } = {}) => { - setMatchNotice('Pfad bewerten und je Slot passende Verbesserungen prüfen…') + setMatchNotice('Schritt 1/2: Pfad bewerten (wie „Graph bewerten“)…') + const baselineRes = await fetchPathEvaluate(synced) + const { draft: evaluated, remainingOffers } = applyEvaluateResult(synced, baselineRes) + setDraft(evaluated) + const mergedAfterEval = mergeGapOffersForDraft(evaluated, baselineRes) + setGapFillOffers(mergedAfterEval.length > 0 ? mergedAfterEval : remainingOffers) + + setMatchNotice('Schritt 2/2: Verbesserungsvorschläge für gemeldete Schachstellen…') const reviewRes = await api.suggestProgressionPath({ - ...buildMatchRequestBase(synced), + ...buildEvaluateRequest(synced), + evaluate_only: false, unified_slot_review: true, baseline_evaluate_steps: slotsToEvaluateSteps(synced), include_llm_intent: false, auto_rematch_after_qa: false, }) - const qa = reviewRes?.path_qa || null - setPathQa(qa) - setDraft((prev) => (prev ? { ...prev, lastFindings: qa } : prev)) - const compareRes = buildProgressionComparePayload(null, reviewRes) - setGapFillOffers(mergeGapOffersForDraft(synced, reviewRes, reviewRes)) + if (!reviewRes?.unified_slot_review) { + throw new Error( + 'Match-Review nicht verfügbar — Backend-Stand prüfen (unified_slot_review fehlt in der Antwort).', + ) + } + + const compareRes = buildProgressionComparePayload(baselineRes, reviewRes) + setGapFillOffers(mergeGapOffersForDraft(evaluated, baselineRes, reviewRes)) presentMatchCompare(compareRes, { source }) return compareRes } @@ -523,11 +534,15 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa const baselineQa = res?.baseline_path_qa || null const diffCount = res?.slot_diff_count ?? compareDiffsForDialog(res).length const rejectedCount = res?.slot_diff_count_rejected ?? rejectedCompareDiffs(res).length + const problemCount = res?.match_summary?.problem_slot_count + ?? (res?.problem_slots ? Object.keys(res.problem_slots).length : 0) const bPct = pathQaQualityPercent(baselineQa) let notice = diffCount > 0 - ? `Match: ${diffCount} Verbesserung(en) — je Slot gegen deinen Pfad (${bPct != null ? `${bPct} %` : 'QS'}) geprüft.` - : 'Match: Keine messbare Verbesserung gegenüber deinem Pfad.' + ? `Match: ${diffCount} Verbesserung(en) für gemeldete Schachstellen.` + : problemCount > 0 + ? `Match: ${problemCount} Schachstelle(n) erkannt, aber kein Bibliotheks-Ersatz mit Gewinn — KI-Angebote im Panel prüfen.` + : 'Match: Keine Schachstellen — Pfad wirkt konsistent.' if (rejectedCount > 0) { notice += ` ${rejectedCount} Vorschlag/Vorschläge verworfen (Verschlechterung oder neutral).` } diff --git a/frontend/src/utils/progressionGraphDraft.js b/frontend/src/utils/progressionGraphDraft.js index 1377ae7..c25c295 100644 --- a/frontend/src/utils/progressionGraphDraft.js +++ b/frontend/src/utils/progressionGraphDraft.js @@ -1091,7 +1091,7 @@ function mergeGapFillOffersFromSteps(steps, offers) { */ export function buildProgressionComparePayload(baselineRes, proposedRes) { if (proposedRes?.unified_slot_review) { - return buildUnifiedSlotReviewComparePayload(proposedRes) + return buildUnifiedSlotReviewComparePayload(proposedRes, baselineRes) } const baselineSteps = Array.isArray(baselineRes?.steps) ? baselineRes.steps : [] @@ -1154,9 +1154,11 @@ export function buildProgressionComparePayload(baselineRes, proposedRes) { } /** Einheitlicher Match-Review (Bewertung + Slot-Vorschläge in einem Lauf). */ -export function buildUnifiedSlotReviewComparePayload(res) { - const baselineSteps = Array.isArray(res?.baseline_steps) ? res.baseline_steps : (res?.steps || []) - const baselineQa = res?.baseline_path_qa || res?.path_qa || null +export function buildUnifiedSlotReviewComparePayload(res, baselineRes = null) { + const baselineSteps = Array.isArray(baselineRes?.steps) + ? baselineRes.steps + : (Array.isArray(res?.baseline_steps) ? res.baseline_steps : (res?.steps || [])) + const baselineQa = baselineRes?.path_qa || res?.baseline_path_qa || res?.path_qa || null const scoring = res?.slot_diff_scoring const suggestions = Array.isArray(res?.slot_suggestions) ? res.slot_suggestions : [] const improving = suggestions.filter((s) => s?.improves_path) -- 2.43.0 From e9bf5bd1a5987629aa7d857224d94d3c1905c074 Mon Sep 17 00:00:00 2001 From: Lars Date: Sat, 13 Jun 2026 12:17:58 +0200 Subject: [PATCH 16/20] Enhance Path Evaluation and Slot Management Features - Introduced `_parse_slot_refs_from_text` to extract and convert slot references from text, improving the handling of user input in path evaluations. - Updated `_problematic_slots_from_path_qa` to utilize the new parsing function, enhancing the identification of problematic slots based on various hints and issues. - Enhanced `ProgressionGraphEditor` and `ProgressionOptimizeCompareModal` to better display identified problem slots and their associated reasons, improving user feedback during evaluations. - Added tests for new parsing functionality and its integration with existing slot management processes, ensuring robustness in slot reference handling. --- backend/planning_exercise_path_builder.py | 149 ++++++++++++++---- .../tests/test_planning_problematic_slots.py | 44 ++++++ .../src/components/ProgressionGraphEditor.jsx | 5 + .../ProgressionOptimizeCompareModal.jsx | 48 +++++- 4 files changed, 207 insertions(+), 39 deletions(-) diff --git a/backend/planning_exercise_path_builder.py b/backend/planning_exercise_path_builder.py index 649848b..87db549 100644 --- a/backend/planning_exercise_path_builder.py +++ b/backend/planning_exercise_path_builder.py @@ -6,6 +6,7 @@ planning_progression_roadmap.py und PLANNING_PROGRESSION_ROADMAP_SPEC.md. """ from __future__ import annotations +import re from typing import Any, Callable, Dict, List, Mapping, Optional, Sequence, Set, Tuple from fastapi import HTTPException @@ -149,6 +150,7 @@ class ProgressionPathSuggestRequest(BaseModel): baseline_quality_score: Optional[float] = Field(default=None, ge=0.0, le=1.0) include_incremental_diff_scoring: bool = False unified_slot_review: bool = False + baseline_path_qa_snapshot: Optional[Dict[str, Any]] = None def _resolve_planning_catalog_context( @@ -1165,6 +1167,7 @@ def _match_roadmap_slot( anchor_variant_id: Optional[int], used: Set[int], slot_priority_exercise_id: Optional[int] = None, + skip_post_match_gate: bool = False, ) -> Tuple[Optional[Dict[str, Any]], Optional[StageSpecArtifact]]: """Einzelnen Roadmap-Slot matchen (Initial-Build und Auto-Rematch).""" major_by_index: Dict[int, MajorStep] = {} @@ -1331,11 +1334,15 @@ def _match_roadmap_slot( else: step["slot_status"] = "matched" step["roadmap_match_source"] = "stage_spec" - if step.get("roadmap_match_source") != "slot_best_match" and not _roadmap_step_passes_post_match_gate( - cur, - step, - goal_query=goal_query, - semantic_brief=semantic_brief, + if ( + not skip_post_match_gate + and step.get("roadmap_match_source") != "slot_best_match" + and not _roadmap_step_passes_post_match_gate( + cur, + step, + goal_query=goal_query, + semantic_brief=semantic_brief, + ) ): return None, stage_spec return step, None @@ -2478,6 +2485,21 @@ def _resolve_hint_major_index( return pos if pos >= 0 else None +def _parse_slot_refs_from_text(text: str) -> Set[int]: + """„Schritt 8“ / „Slot 8“ / „Stufe 8“ → 0-basierter major_step_index (7).""" + found: Set[int] = set() + if not text: + return found + for match in re.finditer(r"(?:schritt|slot|stufe)\s*(\d+)", text.lower()): + try: + n = int(match.group(1)) + except (TypeError, ValueError): + continue + if n >= 1: + found.add(n - 1) + return found + + def _problematic_slots_from_path_qa( baseline_qa: Optional[Mapping[str, Any]], baseline_steps: Sequence[Mapping[str, Any]], @@ -2504,13 +2526,32 @@ def _problematic_slots_from_path_qa( if not isinstance(hint, dict): continue action = str(hint.get("action") or "").strip().lower() - if action in ("review_roadmap", "refine_stage_spec"): + if action == "review_roadmap": continue midx = _resolve_hint_major_index(hint, stage_specs) + if midx is None: + title = str(hint.get("title") or "") + for ref in _parse_slot_refs_from_text( + " ".join( + str(hint.get(k) or "") + for k in ("reason", "issue", "title", "roadmap_learning_goal") + ) + ): + midx = ref + break + if title: + for step in baseline_steps or []: + if not isinstance(step, dict): + continue + st = str(step.get("title") or "").strip() + smidx = step.get("roadmap_major_step_index") + if st and title.lower() in st.lower() and smidx is not None: + midx = int(smidx) + break if midx is None: continue _add( - midx, + int(midx), str( hint.get("reason") or hint.get("issue") @@ -2519,6 +2560,18 @@ def _problematic_slots_from_path_qa( ), ) + llm_text_parts: List[str] = [] + for key in ("topic_coverage",): + raw = (baseline_qa or {}).get(key) + if raw: + llm_text_parts.append(str(raw)) + for key in ("issues", "recommendations", "sequence_notes"): + for raw in (baseline_qa or {}).get(key) or []: + llm_text_parts.append(str(raw or "")) + combined = "\n".join(llm_text_parts) + for midx in _parse_slot_refs_from_text(combined): + _add(midx, "In Pfad-Bewertung als Schachstelle genannt") + for raw in (baseline_qa or {}).get("issues") or []: text = str(raw or "").strip() if not text: @@ -2535,7 +2588,8 @@ def _problematic_slots_from_path_qa( continue title = str(step.get("title") or "").strip() if ( - f"slot {slot_no}" in text.lower() + f"schritt {slot_no}" in text.lower() + or f"slot {slot_no}" in text.lower() or f"stufe {slot_no}" in text.lower() or (title and title.lower() in text.lower()) ): @@ -2896,6 +2950,7 @@ def _roadmap_slot_library_candidates( used: Set[int], exclude_exercise_id: Optional[int] = None, max_candidates: int = 5, + skip_post_match_gate: bool = False, ) -> List[Dict[str, Any]]: """Mehrere Bibliotheks-Kandidaten je Slot (beste zuerst, aktuelle optional ausgeschlossen).""" pick_used = set(used) @@ -2925,6 +2980,7 @@ def _roadmap_slot_library_candidates( anchor_variant_id=anchor_variant_id, used=pick_used, slot_priority_exercise_id=None, + skip_post_match_gate=skip_post_match_gate, ) if not step or step.get("exercise_id") is None: break @@ -2993,34 +3049,55 @@ def _run_unified_slot_improvement_review( detail="unified_slot_review erfordert Roadmap (roadmap_override / roadmap_first)", ) - eval_body = body.model_copy( - update={ - "include_llm_path_qa": body.include_llm_path_qa, - "include_ai_gap_fill": body.include_ai_gap_fill, - "auto_rematch_after_qa": False, - } - ) baseline_steps = _evaluate_steps_from_payload(cur, body.baseline_evaluate_steps) - qa_pack = _run_evaluate_only_path_qa( - cur, - body=eval_body, - goal_query=goal_query, - semantic_brief=semantic_brief, - steps=list(baseline_steps), - roadmap_ctx=roadmap_ctx, + snapshot = ( + dict(body.baseline_path_qa_snapshot) + if isinstance(body.baseline_path_qa_snapshot, dict) + else None ) - baseline_steps = list(qa_pack.get("steps") or baseline_steps) - baseline_qa = qa_pack.get("path_qa") if isinstance(qa_pack.get("path_qa"), dict) else {} - if baseline_qa.get("quality_score") is None: - baseline_qa = dict(baseline_qa) - baseline_qa["quality_score"] = compute_deterministic_path_quality_score( - gaps=baseline_qa.get("large_gaps") or [], - off_topic_steps=baseline_qa.get("off_topic_steps") or [], - steps=baseline_steps, - multistage_qa=baseline_qa, + if snapshot: + baseline_qa = snapshot + if baseline_qa.get("quality_score") is None: + baseline_qa["quality_score"] = compute_deterministic_path_quality_score( + gaps=baseline_qa.get("large_gaps") or [], + off_topic_steps=baseline_qa.get("off_topic_steps") or [], + steps=baseline_steps, + multistage_qa=baseline_qa, + ) + baseline_score = ( + float(body.baseline_quality_score) + if body.baseline_quality_score is not None + else _path_qa_quality_score(baseline_qa) ) - baseline_score = _path_qa_quality_score(baseline_qa) - gap_fill_offers = list(qa_pack.get("gap_fill_offers") or []) + gap_fill_offers: List[Dict[str, Any]] = [] + else: + eval_body = body.model_copy( + update={ + "include_llm_path_qa": body.include_llm_path_qa, + "include_ai_gap_fill": body.include_ai_gap_fill, + "auto_rematch_after_qa": False, + } + ) + qa_pack = _run_evaluate_only_path_qa( + cur, + body=eval_body, + goal_query=goal_query, + semantic_brief=semantic_brief, + steps=list(baseline_steps), + roadmap_ctx=roadmap_ctx, + ) + baseline_steps = list(qa_pack.get("steps") or baseline_steps) + baseline_qa = qa_pack.get("path_qa") if isinstance(qa_pack.get("path_qa"), dict) else {} + if baseline_qa.get("quality_score") is None: + baseline_qa = dict(baseline_qa) + baseline_qa["quality_score"] = compute_deterministic_path_quality_score( + gaps=baseline_qa.get("large_gaps") or [], + off_topic_steps=baseline_qa.get("off_topic_steps") or [], + steps=baseline_steps, + multistage_qa=baseline_qa, + ) + baseline_score = _path_qa_quality_score(baseline_qa) + gap_fill_offers = list(qa_pack.get("gap_fill_offers") or []) off_topic_map = _off_topic_reasons_by_slot(baseline_qa.get("off_topic_steps") or []) problem_slots = _problematic_slots_from_path_qa( baseline_qa, @@ -3076,6 +3153,7 @@ def _run_unified_slot_improvement_review( except (TypeError, ValueError): exclude_id = None + relax_match_gate = bool(off_topic or slot_problem) candidates = _roadmap_slot_library_candidates( cur, tenant=tenant, @@ -3093,8 +3171,9 @@ def _run_unified_slot_improvement_review( anchor_id=anchor_id, anchor_variant_id=anchor_variant_id, used=used_other, - exclude_exercise_id=exclude_id if not off_topic else int(current_id) if current_id else None, - max_candidates=3, + exclude_exercise_id=int(current_id) if current_id is not None else exclude_id, + max_candidates=5 if relax_match_gate else 3, + skip_post_match_gate=relax_match_gate, ) accepted_for_slot = False diff --git a/backend/tests/test_planning_problematic_slots.py b/backend/tests/test_planning_problematic_slots.py index 00039f7..4052139 100644 --- a/backend/tests/test_planning_problematic_slots.py +++ b/backend/tests/test_planning_problematic_slots.py @@ -1,5 +1,6 @@ """Schachstellen-Erkennung für unified Slot-Review.""" from planning_exercise_path_builder import ( + _parse_slot_refs_from_text, _problematic_slots_from_path_qa, _slot_suggestion_accepted, ) @@ -51,3 +52,46 @@ def test_slot_suggestion_accepted_for_problem_slot(): major_idx=1, slot_problem=True, ) + + +def test_parse_slot_refs_schritt_is_one_based(): + assert _parse_slot_refs_from_text("Schritt 8 (Ukemi Vorwärts) entfernen") == {7} + assert _parse_slot_refs_from_text("slot 3 und Stufe 5") == {2, 4} + + +def test_problematic_slots_from_refine_stage_spec_hint(): + qa = { + "optimization_hints": [ + { + "action": "refine_stage_spec", + "step_index": 7, + "issue": "stage_mismatch", + "reason": "Stufen-Fit zu schwach (0.00) für „Integration von Täuschung“", + } + ], + "off_topic_steps": [], + } + steps = [ + {"roadmap_major_step_index": i, "exercise_id": i + 1, "title": f"Übung {i + 1}"} + for i in range(8) + ] + steps[7]["title"] = "Ukemi Vorwärts" + specs = [_spec(i) for i in range(8)] + problems = _problematic_slots_from_path_qa(qa, steps, specs) + assert 7 in problems + + +def test_problematic_slots_from_llm_schritt_text(): + qa = { + "optimization_hints": [], + "off_topic_steps": [], + "issues": [ + "Schritt 8 (Ukemi Vorwärts) hat keinen Bezug zur Kumite-Beinarbeit", + ], + } + steps = [ + {"roadmap_major_step_index": 7, "exercise_id": 99, "title": "Ukemi Vorwärts"}, + ] + specs = [_spec(7)] + problems = _problematic_slots_from_path_qa(qa, steps, specs) + assert 7 in problems diff --git a/frontend/src/components/ProgressionGraphEditor.jsx b/frontend/src/components/ProgressionGraphEditor.jsx index a60b80a..80a4187 100644 --- a/frontend/src/components/ProgressionGraphEditor.jsx +++ b/frontend/src/components/ProgressionGraphEditor.jsx @@ -507,6 +507,11 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa evaluate_only: false, unified_slot_review: true, baseline_evaluate_steps: slotsToEvaluateSteps(synced), + baseline_path_qa_snapshot: baselineRes?.path_qa || null, + baseline_quality_score: + baselineRes?.path_qa?.quality_score != null + ? Number(baselineRes.path_qa.quality_score) + : null, include_llm_intent: false, auto_rematch_after_qa: false, }) diff --git a/frontend/src/components/ProgressionOptimizeCompareModal.jsx b/frontend/src/components/ProgressionOptimizeCompareModal.jsx index 2e28f81..8e4147d 100644 --- a/frontend/src/components/ProgressionOptimizeCompareModal.jsx +++ b/frontend/src/components/ProgressionOptimizeCompareModal.jsx @@ -222,10 +222,50 @@ export default function ProgressionOptimizeCompareModal({ ) : null} {dialogDiffs.length === 0 ? ( -

- Keine Verbesserung gefunden — dein Pfad ist für alle Slots bereits optimal bewertet - oder es fehlen passende Bibliotheks-Treffer (KI-Angebote im Bewertungs-Panel). -

+ <> +

+ Keine Bibliotheks-Verbesserung mit messbarem Gewinn — Schachstellen siehe unten. + KI-Angebote im Bewertungs-Panel oder „Brücke / KI-Angebot“ nutzen. +

+ {comparison?.problem_slots && Object.keys(comparison.problem_slots).length > 0 ? ( +
    + {Object.entries(comparison.problem_slots).map(([midxRaw, reasons]) => { + const midx = Number(midxRaw) + const reasonList = Array.isArray(reasons) ? reasons : [reasons] + return ( +
  • + + Schachstelle Slot {midx + 1} + +
      + {reasonList.filter(Boolean).map((text, i) => ( +
    • {text}
    • + ))} +
    +
  • + ) + })} +
+ ) : null} + ) : ( <>
-- 2.43.0 From cd457e3ea06b6ad71fa7fcbc1ea6dd6d368b1b85 Mon Sep 17 00:00:00 2001 From: Lars Date: Sat, 13 Jun 2026 12:33:16 +0200 Subject: [PATCH 17/20] Enhance Slot Evaluation and Scoring Mechanisms - Introduced new functions `_off_topic_semantic_scores_by_slot` and `_score_exercise_stage_fit_for_spec` to improve the evaluation of off-topic steps and exercise stage fit, enhancing the quality assessment process. - Updated `_run_unified_slot_improvement_review` to incorporate off-topic scores and exercise stage fit scoring, refining the decision-making process for slot suggestions. - Enhanced existing logic to streamline the handling of slot scores and improve the overall robustness of slot management in path evaluations. --- backend/planning_exercise_path_builder.py | 483 ++++++++++-------- .../tests/test_planning_problematic_slots.py | 16 + .../src/components/ProgressionGraphEditor.jsx | 84 +-- .../ProgressionOptimizeCompareModal.jsx | 404 ++++++++------- frontend/src/utils/progressionGraphDraft.js | 104 +++- 5 files changed, 654 insertions(+), 437 deletions(-) diff --git a/backend/planning_exercise_path_builder.py b/backend/planning_exercise_path_builder.py index 87db549..868e706 100644 --- a/backend/planning_exercise_path_builder.py +++ b/backend/planning_exercise_path_builder.py @@ -35,6 +35,7 @@ from planning_path_rematch import ( from planning_path_refine_stage import apply_stage_spec_refinements, collect_refine_stage_targets from planning_stage_context import build_contextualized_stage_goal, resolve_path_start_target from planning_exercise_path_qa import ( + _load_exercise_text_bundle, apply_llm_path_reorder, build_path_qa_summary, compute_deterministic_path_quality_score, @@ -66,6 +67,7 @@ from planning_exercise_semantics import ( exercise_passes_stage_fit, exercise_title_matches_peer_stage_goal, pick_best_path_hit, + score_exercise_stage_fit, resolve_semantic_skill_weights, step_phase_for_index, step_retrieval_query, @@ -3019,6 +3021,92 @@ def _suggestion_as_slot_diff(entry: Mapping[str, Any]) -> Dict[str, Any]: } +_SLOT_FIT_POOR_THRESHOLD = 0.30 + + +def _off_topic_semantic_scores_by_slot( + off_topic_steps: Sequence[Mapping[str, Any]], +) -> Dict[int, float]: + scores: Dict[int, float] = {} + for item in off_topic_steps or []: + if not isinstance(item, dict): + continue + midx = item.get("roadmap_major_step_index") + if midx is None: + continue + try: + key = int(midx) + raw = item.get("semantic_score") + if raw is not None: + scores[key] = round(float(raw), 4) + except (TypeError, ValueError): + continue + return scores + + +def _score_exercise_stage_fit_for_spec( + cur, + *, + exercise_id: int, + step: Mapping[str, Any], + stage_spec: StageSpecArtifact, + semantic_brief: PlanningSemanticBrief, + step_index: int, + stage_count: int, +) -> Optional[float]: + try: + eid = int(exercise_id) + except (TypeError, ValueError): + return None + if eid < 1: + return None + bundle = _load_exercise_text_bundle(cur, eid) + stage_goal = (stage_spec.learning_goal or step.get("roadmap_learning_goal") or "").strip() + phase = ( + (step.get("roadmap_phase") or "").strip().lower() + or step_phase_for_index(semantic_brief, step_index, stage_count) + ) + stage_anti = list(stage_spec.anti_patterns or step.get("roadmap_anti_patterns") or []) + stage_match_brief = ( + build_stage_match_brief( + learning_goal=stage_goal, + anti_patterns=stage_anti or None, + phase=phase or None, + ) + if stage_goal + else None + ) + if not stage_match_brief: + return None + score, _ = score_exercise_stage_fit( + title=bundle["title"], + summary=bundle["summary"], + goal=bundle["goal"], + variant_names=bundle["variant_names"], + stage_brief=stage_match_brief, + step_phase=phase, + ) + return round(float(score), 4) + + +def _slot_auto_select_library( + *, + baseline_slot_score: Optional[float], + proposed_slot_score: Optional[float], + baseline_exercise_id: Optional[int], + proposed_exercise_id: Optional[int], +) -> bool: + if proposed_exercise_id is None: + return False + if baseline_exercise_id is not None and int(baseline_exercise_id) == int(proposed_exercise_id): + return False + if proposed_slot_score is None: + return False + if baseline_slot_score is None: + return True + return float(proposed_slot_score) > float(baseline_slot_score) + 0.001 + + def _run_unified_slot_improvement_review( cur, *, @@ -3109,6 +3197,10 @@ def _run_unified_slot_improvement_review( spec_by_major = {int(s.major_step_index): s for s in roadmap_ctx.stage_specs} stage_count = len(roadmap_ctx.stage_specs) + off_topic_scores = _off_topic_semantic_scores_by_slot( + baseline_qa.get("off_topic_steps") or [], + ) + slot_reviews: List[Dict[str, Any]] = [] suggestions: List[Dict[str, Any]] = [] rejected: List[Dict[str, Any]] = [] @@ -3116,6 +3208,7 @@ def _run_unified_slot_improvement_review( major_idx = int(stage_spec.major_step_index) current = dict(steps_by_major.get(major_idx, {})) current.setdefault("roadmap_major_step_index", major_idx) + current.setdefault("roadmap_learning_goal", stage_spec.learning_goal) current_id = current.get("exercise_id") slot_problem = major_idx in problem_slots off_topic = slot_problem or major_idx in off_topic_map or bool( @@ -3123,6 +3216,18 @@ def _run_unified_slot_improvement_review( ) off_reasons = list(problem_slots.get(major_idx, [])) + off_topic_map.get(major_idx, []) + baseline_slot_score: Optional[float] = off_topic_scores.get(major_idx) + if baseline_slot_score is None and current_id is not None and not current.get("is_ai_proposal"): + baseline_slot_score = _score_exercise_stage_fit_for_spec( + cur, + exercise_id=int(current_id), + step=current, + stage_spec=stage_spec, + semantic_brief=semantic_brief, + step_index=step_index, + stage_count=stage_count, + ) + planned_ids = [ int(s["exercise_id"]) for midx, s in sorted(steps_by_major.items()) @@ -3141,19 +3246,6 @@ def _run_unified_slot_improvement_review( vid = step.get("variant_id") anchor_variant_id = int(vid) if vid is not None else None - exclude_id: Optional[int] = None - if current_id is not None and not (off_topic or slot_problem): - try: - exclude_id = int(current_id) - except (TypeError, ValueError): - exclude_id = None - elif current_id is not None and (off_topic or slot_problem): - try: - exclude_id = int(current_id) - except (TypeError, ValueError): - exclude_id = None - - relax_match_gate = bool(off_topic or slot_problem) candidates = _roadmap_slot_library_candidates( cur, tenant=tenant, @@ -3171,208 +3263,199 @@ def _run_unified_slot_improvement_review( anchor_id=anchor_id, anchor_variant_id=anchor_variant_id, used=used_other, - exclude_exercise_id=int(current_id) if current_id is not None else exclude_id, - max_candidates=5 if relax_match_gate else 3, - skip_post_match_gate=relax_match_gate, + exclude_exercise_id=int(current_id) if current_id is not None else None, + max_candidates=5, + skip_post_match_gate=True, ) - accepted_for_slot = False + best_candidate: Optional[Dict[str, Any]] = None for candidate in candidates: try: cand_id = int(candidate.get("exercise_id")) except (TypeError, ValueError): continue - if ( - current_id is not None - and not (off_topic or slot_problem) - and int(current_id) == cand_id - ): + if current_id is not None and int(current_id) == cand_id: continue - diff_stub = { + best_candidate = candidate + break + + proposed_slot_score: Optional[float] = None + quality_delta: Optional[float] = None + projected_qa: Optional[Dict[str, Any]] = None + library_alt: Optional[Dict[str, Any]] = None + if best_candidate is not None: + try: + cand_id = int(best_candidate.get("exercise_id")) + except (TypeError, ValueError): + cand_id = None + if cand_id is not None: + proposed_slot_score = _score_exercise_stage_fit_for_spec( + cur, + exercise_id=cand_id, + step={**current, **best_candidate, "roadmap_major_step_index": major_idx}, + stage_spec=stage_spec, + semantic_brief=semantic_brief, + step_index=step_index, + stage_count=stage_count, + ) + diff_stub = { + "roadmap_major_step_index": major_idx, + "baseline_exercise_id": int(current_id) if current_id is not None else None, + "baseline_title": (current.get("title") or "").strip() or None, + "proposed_exercise_id": cand_id, + "proposed_title": (best_candidate.get("title") or "").strip() or None, + } + merged_steps = _apply_slot_diff_to_steps(baseline_steps, diff_stub, baseline_steps) + for i, raw in enumerate(merged_steps): + if int(raw.get("roadmap_major_step_index", -1)) == major_idx: + merged_steps[i] = { + **raw, + **best_candidate, + "roadmap_major_step_index": major_idx, + } + break + projected_qa = _quick_evaluate_steps_qa( + cur, + goal_query=goal_query, + semantic_brief=semantic_brief, + steps=merged_steps, + roadmap_ctx=roadmap_ctx, + ) + quality_delta = _quality_delta( + baseline_score, + _path_qa_quality_score(projected_qa), + ) + suggestion_type = ( + "remove_and_replace" + if (off_topic or slot_problem) and current_id is not None + else ("library_fill" if current_id is None else "library_improvement") + ) + auto_select = _slot_auto_select_library( + baseline_slot_score=baseline_slot_score, + proposed_slot_score=proposed_slot_score, + baseline_exercise_id=int(current_id) if current_id is not None else None, + proposed_exercise_id=cand_id, + ) + library_alt = { + "exercise_id": cand_id, + "title": (best_candidate.get("title") or "").strip() or None, + "slot_score": proposed_slot_score, + "slot_score_delta": ( + round(float(proposed_slot_score) - float(baseline_slot_score), 4) + if proposed_slot_score is not None and baseline_slot_score is not None + else None + ), + "quality_delta": quality_delta, + "auto_select": auto_select, + "suggestion_type": suggestion_type, + "reasons": list(best_candidate.get("reasons") or [])[:4], + "pro_contra": _build_slot_pro_contra( + current_step=current, + proposed_step=best_candidate, + suggestion_type=suggestion_type, + baseline_qa=baseline_qa, + projected_qa=projected_qa, + quality_delta=quality_delta, + off_topic_reasons=off_reasons, + candidate_reasons=best_candidate.get("reasons") or [], + ), + } + lib_entry = { + "roadmap_major_step_index": major_idx, + "baseline_exercise_id": int(current_id) if current_id is not None else None, + "baseline_title": (current.get("title") or "").strip() or None, + "proposed_exercise_id": cand_id, + "proposed_title": library_alt["title"], + "baseline_slot_status": current.get("slot_status"), + "proposed_slot_status": best_candidate.get("slot_status") or "matched", + "suggestion_type": suggestion_type, + "quality_delta": quality_delta, + "baseline_slot_score": baseline_slot_score, + "proposed_slot_score": proposed_slot_score, + "slot_score_delta": library_alt["slot_score_delta"], + "auto_select": auto_select, + "baseline_quality_score": baseline_score, + "projected_quality_score": _path_qa_quality_score(projected_qa), + "projected_path_qa": projected_qa, + "improves_path": auto_select, + "off_topic": off_topic, + "slot_problem": slot_problem, + "problem_reasons": off_reasons[:6], + "proposed_is_ai_proposal": False, + "pro_contra": library_alt["pro_contra"], + } + if auto_select: + suggestions.append(lib_entry) + elif cand_id is not None: + rejected.append(lib_entry) + + show_ai_option = bool( + body.include_ai_gap_fill + and ( + current_id is None + or off_topic + or slot_problem + or bool(current.get("is_ai_proposal")) + or ( + baseline_slot_score is not None + and baseline_slot_score < _SLOT_FIT_POOR_THRESHOLD + ) + ) + ) + ai_alt: Optional[Dict[str, Any]] = None + if show_ai_option: + slot_offer = next( + ( + o + for o in gap_fill_offers + if isinstance(o, dict) + and int(o.get("roadmap_major_step_index", -1)) == major_idx + ), + None, + ) + if not slot_offer: + empty_specs = _build_evaluate_empty_slot_gap_specs( + [current], + goal_query=goal_query, + ) + if empty_specs: + slot_offer = build_gap_fill_offer( + spec=empty_specs[0], + steps=baseline_steps, + goal_query=goal_query, + brief=semantic_brief, + proposal=None, + roadmap_snapshot=_roadmap_gap_snapshot_for_spec( + cur, + roadmap_ctx, + empty_specs[0], + goal_query=goal_query, + semantic_brief=semantic_brief, + ), + ) + gap_fill_offers.append(slot_offer) + if slot_offer: + ai_alt = { + "title_hint": slot_offer.get("title_hint") or f"Slot {major_idx + 1}", + "gap_offer": slot_offer, + "auto_select": False, + } + + slot_reviews.append( + { "roadmap_major_step_index": major_idx, + "roadmap_learning_goal": (stage_spec.learning_goal or "").strip() or None, "baseline_exercise_id": int(current_id) if current_id is not None else None, "baseline_title": (current.get("title") or "").strip() or None, - "proposed_exercise_id": cand_id, - "proposed_title": (candidate.get("title") or "").strip() or None, - } - merged_steps = _apply_slot_diff_to_steps(baseline_steps, diff_stub, baseline_steps) - for i, raw in enumerate(merged_steps): - if int(raw.get("roadmap_major_step_index", -1)) == major_idx: - merged_steps[i] = {**raw, **candidate, "roadmap_major_step_index": major_idx} - break - eval_res = _quick_evaluate_steps_qa( - cur, - goal_query=goal_query, - semantic_brief=semantic_brief, - steps=merged_steps, - roadmap_ctx=roadmap_ctx, - ) - projected_qa = eval_res if isinstance(eval_res, dict) else None - projected_score = _path_qa_quality_score(projected_qa) - delta = _quality_delta(baseline_score, projected_score) - improves = _slot_suggestion_accepted( - baseline_qa=baseline_qa, - projected_qa=projected_qa, - baseline_score=baseline_score, - projected_score=projected_score, - diff=diff_stub, - off_topic=off_topic, - major_idx=major_idx, - slot_problem=slot_problem, - stage_specs=roadmap_ctx.stage_specs, - baseline_steps=baseline_steps, - projected_steps=merged_steps, - ) - suggestion_type = ( - "remove_and_replace" - if (off_topic or slot_problem) and current_id is not None - else ("library_fill" if current_id is None else "library_improvement") - ) - entry = { - **diff_stub, + "baseline_slot_score": baseline_slot_score, "baseline_slot_status": current.get("slot_status"), - "proposed_slot_status": candidate.get("slot_status") or "matched", - "suggestion_type": suggestion_type, - "quality_delta": delta, - "projected_quality_score": projected_score, - "baseline_quality_score": baseline_score, - "projected_path_qa": projected_qa, - "improves_path": improves, - "off_topic": off_topic, "slot_problem": slot_problem, + "off_topic": off_topic, "problem_reasons": off_reasons[:6], - "proposed_is_ai_proposal": False, - "pro_contra": _build_slot_pro_contra( - current_step=current, - proposed_step=candidate, - suggestion_type=suggestion_type, - baseline_qa=baseline_qa, - projected_qa=projected_qa, - quality_delta=delta, - off_topic_reasons=off_reasons, - candidate_reasons=candidate.get("reasons") or [], - ), + "library_alternative": library_alt, + "ai_alternative": ai_alt, } - if improves: - suggestions.append(entry) - accepted_for_slot = True - break - rejected.append(entry) - - if accepted_for_slot: - continue - - # Kein Bibliotheks-Treffer oder keine Verbesserung → KI-Angebot wenn Slot leer/off-topic/KI - needs_ai = ( - current_id is None - or off_topic - or slot_problem - or bool(current.get("is_ai_proposal")) ) - if not needs_ai or not body.include_ai_gap_fill: - continue - slot_offer = next( - ( - o - for o in gap_fill_offers - if isinstance(o, dict) - and int(o.get("roadmap_major_step_index", -1)) == major_idx - ), - None, - ) - if not slot_offer: - empty_specs = _build_evaluate_empty_slot_gap_specs( - [current], - goal_query=goal_query, - ) - if empty_specs: - slot_offer = build_gap_fill_offer( - spec=empty_specs[0], - steps=baseline_steps, - goal_query=goal_query, - brief=semantic_brief, - proposal=None, - roadmap_snapshot=_roadmap_gap_snapshot_for_spec( - cur, - roadmap_ctx, - empty_specs[0], - goal_query=goal_query, - semantic_brief=semantic_brief, - ), - ) - gap_fill_offers.append(slot_offer) - - ai_step = { - **current, - "exercise_id": None, - "is_ai_proposal": True, - "title": slot_offer.get("title_hint") or current.get("title") or f"Slot {major_idx + 1}", - "roadmap_major_step_index": major_idx, - "gap_offer": slot_offer, - } - diff_stub = { - "roadmap_major_step_index": major_idx, - "baseline_exercise_id": int(current_id) if current_id is not None else None, - "baseline_title": (current.get("title") or "").strip() or None, - "proposed_exercise_id": None, - "proposed_title": ai_step.get("title"), - } - merged_steps = _apply_slot_diff_to_steps(baseline_steps, diff_stub, [ai_step]) - eval_res = _quick_evaluate_steps_qa( - cur, - goal_query=goal_query, - semantic_brief=semantic_brief, - steps=merged_steps, - roadmap_ctx=roadmap_ctx, - ) - projected_qa = eval_res if isinstance(eval_res, dict) else None - projected_score = _path_qa_quality_score(projected_qa) - delta = _quality_delta(baseline_score, projected_score) - improves = _slot_suggestion_accepted( - baseline_qa=baseline_qa, - projected_qa=projected_qa, - baseline_score=baseline_score, - projected_score=projected_score, - diff=diff_stub, - off_topic=off_topic, - major_idx=major_idx, - slot_problem=slot_problem, - stage_specs=roadmap_ctx.stage_specs, - baseline_steps=baseline_steps, - projected_steps=merged_steps, - ) - entry = { - **diff_stub, - "baseline_slot_status": current.get("slot_status"), - "proposed_slot_status": "ai_proposal", - "suggestion_type": "ai_gap", - "quality_delta": delta, - "projected_quality_score": projected_score, - "baseline_quality_score": baseline_score, - "projected_path_qa": projected_qa, - "improves_path": improves or slot_problem, - "off_topic": off_topic, - "slot_problem": slot_problem, - "problem_reasons": off_reasons[:6], - "proposed_is_ai_proposal": True, - "gap_offer": slot_offer, - "pro_contra": _build_slot_pro_contra( - current_step=current, - proposed_step=None, - suggestion_type="ai_gap", - baseline_qa=baseline_qa, - projected_qa=projected_qa, - quality_delta=delta, - off_topic_reasons=off_reasons, - candidate_reasons=[], - gap_offer=slot_offer, - ), - } - if improves or slot_problem: - entry["improves_path"] = True - suggestions.append(entry) - else: - rejected.append(entry) improvement_diffs = [_suggestion_as_slot_diff(s) for s in suggestions] problem_slot_payload = { @@ -3412,9 +3495,11 @@ def _run_unified_slot_improvement_review( "suggestion_count": len(suggestions), "rejected_count": len(rejected), "problem_slot_count": len(problem_slots), + "slot_review_count": len(slot_reviews), }, "retrieval_phase": "unified_slot_review", "unified_slot_review": True, + "slot_reviews": slot_reviews, "problem_slots": problem_slot_payload, "slot_suggestions": suggestions, "slot_diff_scoring": slot_diff_scoring, diff --git a/backend/tests/test_planning_problematic_slots.py b/backend/tests/test_planning_problematic_slots.py index 4052139..b4fa3d9 100644 --- a/backend/tests/test_planning_problematic_slots.py +++ b/backend/tests/test_planning_problematic_slots.py @@ -2,6 +2,7 @@ from planning_exercise_path_builder import ( _parse_slot_refs_from_text, _problematic_slots_from_path_qa, + _slot_auto_select_library, _slot_suggestion_accepted, ) from planning_progression_roadmap import StageSpecArtifact @@ -95,3 +96,18 @@ def test_problematic_slots_from_llm_schritt_text(): specs = [_spec(7)] problems = _problematic_slots_from_path_qa(qa, steps, specs) assert 7 in problems + + +def test_slot_auto_select_requires_higher_score(): + assert _slot_auto_select_library( + baseline_slot_score=0.5, + proposed_slot_score=0.51, + baseline_exercise_id=1, + proposed_exercise_id=2, + ) + assert not _slot_auto_select_library( + baseline_slot_score=0.5, + proposed_slot_score=0.5, + baseline_exercise_id=1, + proposed_exercise_id=2, + ) diff --git a/frontend/src/components/ProgressionGraphEditor.jsx b/frontend/src/components/ProgressionGraphEditor.jsx index 80a4187..b980d2d 100644 --- a/frontend/src/components/ProgressionGraphEditor.jsx +++ b/frontend/src/components/ProgressionGraphEditor.jsx @@ -501,53 +501,75 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa const mergedAfterEval = mergeGapOffersForDraft(evaluated, baselineRes) setGapFillOffers(mergedAfterEval.length > 0 ? mergedAfterEval : remainingOffers) - setMatchNotice('Schritt 2/2: Verbesserungsvorschläge für gemeldete Schachstellen…') - const reviewRes = await api.suggestProgressionPath({ - ...buildEvaluateRequest(synced), - evaluate_only: false, - unified_slot_review: true, - baseline_evaluate_steps: slotsToEvaluateSteps(synced), - baseline_path_qa_snapshot: baselineRes?.path_qa || null, - baseline_quality_score: - baselineRes?.path_qa?.quality_score != null - ? Number(baselineRes.path_qa.quality_score) - : null, - include_llm_intent: false, - auto_rematch_after_qa: false, - }) - - if (!reviewRes?.unified_slot_review) { - throw new Error( - 'Match-Review nicht verfügbar — Backend-Stand prüfen (unified_slot_review fehlt in der Antwort).', - ) + setMatchNotice('Schritt 2/2: Slot-Alternativen prüfen…') + let compareRes + let reviewError = null + try { + const reviewRes = await api.suggestProgressionPath({ + ...buildEvaluateRequest(synced), + evaluate_only: false, + unified_slot_review: true, + baseline_evaluate_steps: slotsToEvaluateSteps(synced), + baseline_path_qa_snapshot: baselineRes?.path_qa || null, + baseline_quality_score: + baselineRes?.path_qa?.quality_score != null + ? Number(baselineRes.path_qa.quality_score) + : null, + include_llm_intent: false, + auto_rematch_after_qa: false, + }) + if (!reviewRes?.unified_slot_review) { + reviewError = + 'Slot-Review nicht verfügbar — Backend neu starten/deployen (unified_slot_review fehlt).' + compareRes = buildProgressionComparePayload(baselineRes, { + ...reviewRes, + unified_slot_review: true, + slot_reviews: [], + review_error: reviewError, + }) + } else { + compareRes = buildProgressionComparePayload(baselineRes, reviewRes) + } + setGapFillOffers(mergeGapOffersForDraft(evaluated, baselineRes, reviewRes)) + } catch (e) { + reviewError = e.message || 'Slot-Review fehlgeschlagen' + compareRes = buildProgressionComparePayload(baselineRes, { + unified_slot_review: true, + slot_reviews: [], + review_error: reviewError, + path_qa: baselineRes?.path_qa, + }) } - const compareRes = buildProgressionComparePayload(baselineRes, reviewRes) - setGapFillOffers(mergeGapOffersForDraft(evaluated, baselineRes, reviewRes)) - presentMatchCompare(compareRes, { source }) + presentMatchCompare(compareRes, { source, reviewError }) return compareRes } - const presentMatchCompare = (res, { source = 'manual' } = {}) => { + const presentMatchCompare = (res, { source = 'manual', reviewError = null } = {}) => { setSemanticBrief(res?.semantic_brief_summary || null) setTargetSummary(res?.target_profile_summary || null) - setComparePayload(res) + setComparePayload(reviewError ? { ...res, review_error: reviewError } : res) setCompareSource(source) setProposedPathQa(res?.proposed_path_qa_pipeline || null) setCompareOpen(true) const baselineQa = res?.baseline_path_qa || null - const diffCount = res?.slot_diff_count ?? compareDiffsForDialog(res).length + const slotReviews = res?.slot_reviews || [] + const autoCount = slotReviews.filter((r) => r?.library_alternative?.auto_select).length + const diffCount = autoCount || res?.slot_diff_count || 0 const rejectedCount = res?.slot_diff_count_rejected ?? rejectedCompareDiffs(res).length const problemCount = res?.match_summary?.problem_slot_count ?? (res?.problem_slots ? Object.keys(res.problem_slots).length : 0) const bPct = pathQaQualityPercent(baselineQa) - let notice = - diffCount > 0 - ? `Match: ${diffCount} Verbesserung(en) für gemeldete Schachstellen.` - : problemCount > 0 - ? `Match: ${problemCount} Schachstelle(n) erkannt, aber kein Bibliotheks-Ersatz mit Gewinn — KI-Angebote im Panel prüfen.` - : 'Match: Keine Schachstellen — Pfad wirkt konsistent.' + let notice = reviewError + ? `Match: Dialog geöffnet — ${reviewError}` + : slotReviews.length > 0 + ? `Match: ${slotReviews.length} Slot(s) geprüft, ${autoCount} Empfehlung(en) vorausgewählt.` + : diffCount > 0 + ? `Match: ${diffCount} Verbesserung(en).` + : problemCount > 0 + ? `Match: ${problemCount} Schachstelle(n), keine bessere Bibliotheks-Alternative.` + : 'Match: Pfad geprüft — siehe Dialog.' if (rejectedCount > 0) { notice += ` ${rejectedCount} Vorschlag/Vorschläge verworfen (Verschlechterung oder neutral).` } diff --git a/frontend/src/components/ProgressionOptimizeCompareModal.jsx b/frontend/src/components/ProgressionOptimizeCompareModal.jsx index 8e4147d..098e58c 100644 --- a/frontend/src/components/ProgressionOptimizeCompareModal.jsx +++ b/frontend/src/components/ProgressionOptimizeCompareModal.jsx @@ -1,13 +1,16 @@ /** - * Gegenüberstellung: Verbesserungsvorschläge mit Slot-Bewertung (Pro/Contra). + * Slot-Match-Dialog: je Slot Bewertung, Bibliotheks-Alternative, optional KI. */ import React, { useMemo, useState } from 'react' +import FormModalOverlay from './FormModalOverlay' import { - compareDiffsForDialog, + compareSlotReviews, defaultSelectedCompareDiffs, pathQaQualityPercent, qualityDeltaPercent, rejectedCompareDiffs, + slotFitScorePercent, + slotReviewSelectionKey, } from '../utils/progressionGraphDraft' function qaLabel(pathQa) { @@ -17,12 +20,10 @@ function qaLabel(pathQa) { return ok ? 'OK' : 'Hinweise' } -function deltaLabel(diff) { - const pct = qualityDeltaPercent(diff) - if (pct == null) return null - if (pct > 0) return `+${pct} % Pfad-QS` - if (pct === 0) return '±0 % Pfad-QS' - return `${pct} % Pfad-QS` +function slotScoreLabel(score) { + const pct = slotFitScorePercent(score) + if (pct == null) return '—' + return `${pct} % Stufen-Fit` } function ProContraList({ title, items, tone = 'neutral' }) { @@ -41,89 +42,157 @@ function ProContraList({ title, items, tone = 'neutral' }) { ) } -function DiffRow({ diff, checked, onToggle, applying }) { - const midx = Number(diff.roadmap_major_step_index) - const delta = deltaLabel(diff) - const pc = diff.pro_contra || {} - const isAi = diff.suggestion_type === 'ai_gap' || diff.proposed_is_ai_proposal - const isFill = diff.baseline_exercise_id == null && !isAi +function SlotReviewRow({ review, selected, onToggle, applying }) { + const midx = Number(review.roadmap_major_step_index) + const lib = review.library_alternative + const ai = review.ai_alternative + const libKey = slotReviewSelectionKey(midx, 'library') + const aiKey = slotReviewSelectionKey(midx, 'ai') + const pc = lib?.pro_contra || {} + const pathDelta = qualityDeltaPercent({ quality_delta: lib?.quality_delta }) + const slotDelta = lib?.slot_score_delta return (
  • -
  • ) } @@ -136,10 +205,10 @@ export default function ProgressionOptimizeCompareModal({ onApplySelected, applying = false, }) { - const dialogDiffs = useMemo(() => compareDiffsForDialog(comparison), [comparison]) + const slotReviews = useMemo(() => compareSlotReviews(comparison), [comparison]) const rejected = useMemo(() => rejectedCompareDiffs(comparison), [comparison]) const defaultSelected = useMemo( - () => defaultSelectedCompareDiffs(comparison), + () => new Set(defaultSelectedCompareDiffs(comparison)), [comparison], ) @@ -155,48 +224,59 @@ export default function ProgressionOptimizeCompareModal({ const baselineQa = comparison.baseline_path_qa const baselinePct = pathQaQualityPercent(baselineQa) const rejectedCount = rejected.length + const reviewError = comparison.review_error || null - const toggle = (midx) => { + const toggle = (key, kind) => { setSelected((prev) => { const next = new Set(prev) - if (next.has(midx)) next.delete(midx) - else next.add(midx) + const parsed = String(key) + const midx = Number(parsed.split(':')[0]) + const libKey = slotReviewSelectionKey(midx, 'library') + const aiKey = slotReviewSelectionKey(midx, 'ai') + if (next.has(parsed)) { + next.delete(parsed) + return next + } + if (kind === 'library') next.delete(aiKey) + if (kind === 'ai') next.delete(libKey) + next.add(parsed) return next }) } - const toggleAll = (on) => { - setSelected(on ? new Set(dialogDiffs.map((d) => Number(d.roadmap_major_step_index))) : new Set()) - } - const title = - mode === 'match' ? 'Übungs-Match — Verbesserungen' : 'Optimierung vergleichen' + mode === 'match' ? 'Übungs-Match — Slot-Bewertung' : 'Optimierung vergleichen' return ( -
    { - if (e.target === e.currentTarget && !applying) onClose() - }} - > +
    e.stopPropagation()} >

    {title}

    - Bewertung und Vorschläge in einem Durchlauf: je Slot wird geprüft, ob eine passendere - Übung (Bibliothek oder KI) den Pfad verbessert. Nur messbare Verbesserungen erscheinen - hier — mit Pro- und Contra-Punkten auf Slot-Ebene. + Je Slot: aktuelle Bewertung, beste Bibliotheks-Alternative und optional KI. Haken nur + vorausgewählt, wenn die Alternative einen höheren Stufen-Fit hat.

    + {reviewError ? ( +

    + {reviewError} +

    + ) : null} +
    0 ? (

    - {rejectedCount} Alternative(n) verworfen — kein QS-Gewinn gegenüber deinem Pfad - {baselinePct != null ? ` (${baselinePct} %)` : ''}. + {rejectedCount} Alternative(n) ohne Pfad-Gewinn + {baselinePct != null ? ` (Basis ${baselinePct} %)` : ''}.

    ) : null} - {dialogDiffs.length === 0 ? ( - <> -

    - Keine Bibliotheks-Verbesserung mit messbarem Gewinn — Schachstellen siehe unten. - KI-Angebote im Bewertungs-Panel oder „Brücke / KI-Angebot“ nutzen. -

    - {comparison?.problem_slots && Object.keys(comparison.problem_slots).length > 0 ? ( -
      - {Object.entries(comparison.problem_slots).map(([midxRaw, reasons]) => { - const midx = Number(midxRaw) - const reasonList = Array.isArray(reasons) ? reasons : [reasons] - return ( -
    • - - Schachstelle Slot {midx + 1} - -
        - {reasonList.filter(Boolean).map((text, i) => ( -
      • {text}
      • - ))} -
      -
    • - ) - })} -
    - ) : null} - + {slotReviews.length === 0 ? ( +

    + Keine Slot-Daten — Backend-Stand prüfen oder erneut „Graph bewerten“ und „Übungen + matchen“. +

    ) : ( - <> -
    - - -
    -
      - {dialogDiffs.map((diff) => ( - - ))} -
    - +
      + {slotReviews.map((review) => ( + + ))} +
    )}
    onApplySelected([...selected])} > {applying ? 'Übernehmen …' : `Auswahl übernehmen (${selected.size})`}
    -
    +
    ) } diff --git a/frontend/src/utils/progressionGraphDraft.js b/frontend/src/utils/progressionGraphDraft.js index c25c295..7360c89 100644 --- a/frontend/src/utils/progressionGraphDraft.js +++ b/frontend/src/utils/progressionGraphDraft.js @@ -1010,8 +1010,39 @@ export function annotateCompareDiffKinds(diffs) { })) } +export function slotFitScorePercent(score) { + if (score == null || !Number.isFinite(Number(score))) return null + return Math.round(Number(score) * 100) +} + +export function slotReviewSelectionKey(midx, kind = 'library') { + return `${Number(midx)}:${kind}` +} + +export function parseSlotReviewSelection(raw) { + if (raw == null) return null + const text = String(raw) + if (text.includes(':')) { + const [midxRaw, kind] = text.split(':') + const midx = Number(midxRaw) + if (!Number.isFinite(midx)) return null + return { midx, kind: kind === 'ai' ? 'ai' : 'library' } + } + const midx = Number(text) + if (!Number.isFinite(midx)) return null + return { midx, kind: 'library' } +} + +/** Alle Slot-Reviews aus Match-Antwort (je Slot eine Zeile). */ +export function compareSlotReviews(comparison) { + return Array.isArray(comparison?.slot_reviews) ? comparison.slot_reviews : [] +} + /** Nur übernehmbare Verbesserungsvorschläge (Bibliothek oder KI-Angebot). */ export function compareDiffsForDialog(comparison) { + const reviews = compareSlotReviews(comparison) + if (reviews.length > 0) return reviews + const fromSuggestions = (comparison?.slot_suggestions || []).filter((s) => s?.improves_path) if (fromSuggestions.length > 0) { return fromSuggestions @@ -1037,6 +1068,16 @@ export function compareDiffsForDialog(comparison) { ) } +export function defaultSelectedCompareDiffs(comparison) { + const reviews = compareSlotReviews(comparison) + if (reviews.length > 0) { + return reviews + .filter((review) => review?.library_alternative?.auto_select) + .map((review) => slotReviewSelectionKey(review.roadmap_major_step_index, 'library')) + } + return compareDiffsForDialog(comparison).map((d) => Number(d.roadmap_major_step_index)) +} + export function suggestionDiffKind(suggestion) { if (!suggestion) return 'skip' if (suggestion.suggestion_type === 'ai_gap') return 'ai_gap' @@ -1069,10 +1110,6 @@ export function gapOnlyCompareDiffs(comparison) { ).filter((d) => d.diff_kind === 'gap_only') } -export function defaultSelectedCompareDiffs(comparison) { - return compareDiffsForDialog(comparison).map((d) => Number(d.roadmap_major_step_index)) -} - function mergeGapFillOffersFromSteps(steps, offers) { const merged = (offers || []).map((o) => ({ ...o })) const seen = new Set(merged.map((o) => o.offer_id).filter(Boolean)) @@ -1160,11 +1197,13 @@ export function buildUnifiedSlotReviewComparePayload(res, baselineRes = null) { : (Array.isArray(res?.baseline_steps) ? res.baseline_steps : (res?.steps || [])) const baselineQa = baselineRes?.path_qa || res?.baseline_path_qa || res?.path_qa || null const scoring = res?.slot_diff_scoring + const slotReviews = compareSlotReviews(res) const suggestions = Array.isArray(res?.slot_suggestions) ? res.slot_suggestions : [] - const improving = suggestions.filter((s) => s?.improves_path) + const improving = suggestions.filter((s) => s?.improves_path || s?.auto_select) const rejected = Array.isArray(scoring?.rejected_diffs) ? scoring.rejected_diffs : [] const proposedSteps = improving.map(suggestionToApplyStep).filter(Boolean) const gapFillOffers = Array.isArray(res?.gap_fill_offers) ? res.gap_fill_offers : [] + const autoSelectCount = slotReviews.filter((r) => r?.library_alternative?.auto_select).length return { ...res, @@ -1177,14 +1216,15 @@ export function buildUnifiedSlotReviewComparePayload(res, baselineRes = null) { proposed_path_qa: baselineQa, proposed_path_qa_pipeline: null, gap_fill_offers: gapFillOffers, + slot_reviews: slotReviews, slot_suggestions: suggestions, slot_diffs: improving, slot_diffs_improving: improving, slot_diffs_rejected: rejected, - slot_diffs_dialog: improving, + slot_diffs_dialog: slotReviews.length > 0 ? slotReviews : improving, slot_diffs_recommended: improving, - slot_diff_count: improving.length, - slot_diff_count_recommended: improving.length, + slot_diff_count: autoSelectCount || improving.length, + slot_diff_count_recommended: autoSelectCount || improving.length, slot_diff_count_rejected: rejected.length, slot_diffs_source: 'unified_slot_review', slot_diff_scoring: scoring, @@ -1220,25 +1260,59 @@ function suggestionToApplyStep(suggestion) { } /** Ausgewählte Slot-Vorschläge aus unified review übernehmen. */ -export function applySelectedSlotSuggestions(draft, comparison, selectedMajorIndices) { +export function applySelectedSlotSuggestions(draft, comparison, selectedKeys) { + const reviews = compareSlotReviews(comparison) + if (reviews.length > 0) { + const selected = new Set((selectedKeys || []).map((x) => String(x))) + const steps = [] + for (const review of reviews) { + const midx = Number(review.roadmap_major_step_index) + const libKey = slotReviewSelectionKey(midx, 'library') + const aiKey = slotReviewSelectionKey(midx, 'ai') + if (selected.has(aiKey) && review.ai_alternative?.gap_offer) { + const offer = review.ai_alternative.gap_offer + steps.push({ + roadmap_major_step_index: midx, + exercise_id: null, + title: offer.title_hint || review.ai_alternative.title_hint || `Slot ${midx + 1}`, + is_ai_proposal: true, + proposal_key: offer.offer_id || `roadmap-unfilled-${midx}`, + gap_offer: offer, + slot_status: 'ai_proposal', + }) + continue + } + if (selected.has(libKey) && review.library_alternative?.exercise_id != null) { + steps.push({ + roadmap_major_step_index: midx, + exercise_id: review.library_alternative.exercise_id, + title: review.library_alternative.title, + slot_status: 'matched', + is_ai_proposal: false, + }) + } + } + if (steps.length) return applyMatchStepsToSlots(draft, steps) + } + const selected = new Set( - (selectedMajorIndices || []) - .map((x) => Number(x)) + (selectedKeys || []) + .map((x) => parseSlotReviewSelection(x)?.midx ?? Number(x)) .filter((x) => Number.isFinite(x)), ) if (!selected.size) return draft - const steps = (comparison?.slot_suggestions || []) + const legacySteps = (comparison?.slot_suggestions || []) .filter((s) => selected.has(Number(s.roadmap_major_step_index))) .map(suggestionToApplyStep) .filter(Boolean) - if (!steps.length) { + if (!legacySteps.length) { return applySelectedCompareSteps( draft, comparison?.proposed_steps || comparison?.steps, - selectedMajorIndices, + selectedKeys, ) } - return applyMatchStepsToSlots(draft, steps) + return applyMatchStepsToSlots(draft, legacySteps) } /** Alle Slot-Diffs inkl. reiner ID-Tausche (gleicher Titel). */ -- 2.43.0 From f0e581a9f5b237b92405afc040865e839c3f12f7 Mon Sep 17 00:00:00 2001 From: Lars Date: Sat, 13 Jun 2026 12:43:59 +0200 Subject: [PATCH 18/20] Implement Off-Topic Slot Gap Specification and Unified Slot Review Enhancements - Introduced `_build_off_topic_slot_gap_spec` to generate specifications for off-topic slots, improving the handling of filled but thematically inappropriate slots. - Added `_build_unified_slot_review_entry` to streamline the review process for slots, incorporating various parameters for better evaluation and suggestions. - Enhanced existing logic in slot management to improve the robustness of path evaluations and user feedback. - Added tests for the new off-topic slot gap specification to ensure functionality and correctness. --- backend/planning_exercise_path_builder.py | 637 +++++++++++------- .../tests/test_planning_problematic_slots.py | 17 + .../src/components/ProgressionGraphEditor.jsx | 1 + 3 files changed, 411 insertions(+), 244 deletions(-) diff --git a/backend/planning_exercise_path_builder.py b/backend/planning_exercise_path_builder.py index 868e706..618a467 100644 --- a/backend/planning_exercise_path_builder.py +++ b/backend/planning_exercise_path_builder.py @@ -2024,6 +2024,56 @@ def _build_evaluate_empty_slot_gap_specs( return specs[:8] +def _build_off_topic_slot_gap_spec( + step: Mapping[str, Any], + *, + goal_query: str = "", +) -> Optional[Dict[str, Any]]: + """KI-Angebot für belegten, aber themenfremden Slot (Ersatz statt Leerstelle).""" + del goal_query + major_idx = step.get("roadmap_major_step_index") + if major_idx is None: + return None + try: + roadmap_idx = int(major_idx) + except (TypeError, ValueError): + return None + phase = (step.get("roadmap_phase") or "vertiefung").strip().lower() + learning_goal = (step.get("roadmap_learning_goal") or step.get("title") or "").strip() + rejected_title = (step.get("title") or "").strip() + title_hint = learning_goal[:120] if learning_goal else f"Slot {roadmap_idx + 1}" + rationale = ( + f"Slot {roadmap_idx + 1}: Ersatz für „{rejected_title}“ — passende Übung per KI." + if rejected_title + else f"Slot {roadmap_idx + 1} — KI-Entwurf für diese Roadmap-Stufe." + ) + return { + "source": "off_topic", + "insert_after_index": max(roadmap_idx - 1, -1), + "replace_step_index": roadmap_idx, + "gap": { + "expected_phase": phase, + "roadmap_major_step_index": roadmap_idx, + "learning_goal": learning_goal, + }, + "phase": phase, + "title_hint": title_hint, + "sketch": learning_goal or title_hint, + "rationale": rationale[:400], + "roadmap_major_step_index": roadmap_idx, + } + + +def _gap_offer_major_index(offer: Mapping[str, Any]) -> Optional[int]: + raw = offer.get("roadmap_major_step_index") + if raw is None: + return None + try: + return int(raw) + except (TypeError, ValueError): + return None + + def _run_evaluate_only_path_qa( cur, *, @@ -3107,6 +3157,261 @@ def _slot_auto_select_library( return float(proposed_slot_score) > float(baseline_slot_score) + 0.001 +def _build_unified_slot_review_entry( + cur, + *, + tenant: TenantContext, + body: ProgressionPathSuggestRequest, + goal_query: str, + max_steps: int, + semantic_brief: PlanningSemanticBrief, + path_target_profile: PlanningTargetProfile, + path_intent: str, + roadmap_ctx: ProgressionRoadmapContext, + stage_spec: StageSpecArtifact, + step_index: int, + stage_count: int, + major_idx: int, + current: Mapping[str, Any], + baseline_steps: Sequence[Mapping[str, Any]], + baseline_qa: Mapping[str, Any], + baseline_score: Optional[float], + steps_by_major: Mapping[int, Mapping[str, Any]], + problem_slots: Mapping[int, Sequence[str]], + off_topic_map: Mapping[int, Sequence[str]], + off_topic_scores: Mapping[int, float], + gap_fill_offers: List[Dict[str, Any]], + suggestions: List[Dict[str, Any]], + rejected: List[Dict[str, Any]], +) -> Dict[str, Any]: + current = dict(current or {}) + current.setdefault("roadmap_major_step_index", major_idx) + current.setdefault("roadmap_learning_goal", stage_spec.learning_goal) + current_id = current.get("exercise_id") + slot_problem = major_idx in problem_slots + off_topic = slot_problem or major_idx in off_topic_map or bool( + current.get("slot_status") in {"off_topic", "stripped"} + ) + off_reasons = list(problem_slots.get(major_idx, [])) + list(off_topic_map.get(major_idx, [])) + + baseline_slot_score: Optional[float] = off_topic_scores.get(major_idx) + if baseline_slot_score is None and current_id is not None and not current.get("is_ai_proposal"): + baseline_slot_score = _score_exercise_stage_fit_for_spec( + cur, + exercise_id=int(current_id), + step=current, + stage_spec=stage_spec, + semantic_brief=semantic_brief, + step_index=step_index, + stage_count=stage_count, + ) + + planned_ids = [ + int(s["exercise_id"]) + for midx, s in sorted(steps_by_major.items()) + if midx != major_idx and s.get("exercise_id") is not None + ] + anchor_id: Optional[int] = None + anchor_variant_id: Optional[int] = None + used_other: Set[int] = set(planned_ids) + for midx in sorted(steps_by_major): + if midx >= major_idx: + break + step = steps_by_major[midx] + eid = step.get("exercise_id") + if eid is not None: + anchor_id = int(eid) + vid = step.get("variant_id") + anchor_variant_id = int(vid) if vid is not None else None + + candidates = _roadmap_slot_library_candidates( + cur, + tenant=tenant, + body=body, + goal_query=goal_query, + max_steps=max_steps, + semantic_brief=semantic_brief, + path_target_profile=path_target_profile, + path_intent=path_intent, + roadmap_ctx=roadmap_ctx, + stage_spec=stage_spec, + step_index=step_index, + stage_count=stage_count, + planned_ids=planned_ids, + anchor_id=anchor_id, + anchor_variant_id=anchor_variant_id, + used=used_other, + exclude_exercise_id=int(current_id) if current_id is not None else None, + max_candidates=5, + skip_post_match_gate=True, + ) + + best_candidate: Optional[Dict[str, Any]] = None + for candidate in candidates: + try: + cand_id = int(candidate.get("exercise_id")) + except (TypeError, ValueError): + continue + if current_id is not None and int(current_id) == cand_id: + continue + best_candidate = candidate + break + + library_alt: Optional[Dict[str, Any]] = None + if best_candidate is not None: + try: + cand_id = int(best_candidate.get("exercise_id")) + except (TypeError, ValueError): + cand_id = None + if cand_id is not None: + proposed_slot_score = _score_exercise_stage_fit_for_spec( + cur, + exercise_id=cand_id, + step={**current, **best_candidate, "roadmap_major_step_index": major_idx}, + stage_spec=stage_spec, + semantic_brief=semantic_brief, + step_index=step_index, + stage_count=stage_count, + ) + suggestion_type = ( + "remove_and_replace" + if (off_topic or slot_problem) and current_id is not None + else ("library_fill" if current_id is None else "library_improvement") + ) + auto_select = _slot_auto_select_library( + baseline_slot_score=baseline_slot_score, + proposed_slot_score=proposed_slot_score, + baseline_exercise_id=int(current_id) if current_id is not None else None, + proposed_exercise_id=cand_id, + ) + slot_score_delta = ( + round(float(proposed_slot_score) - float(baseline_slot_score), 4) + if proposed_slot_score is not None and baseline_slot_score is not None + else None + ) + pro_contra = _build_slot_pro_contra( + current_step=current, + proposed_step=best_candidate, + suggestion_type=suggestion_type, + baseline_qa=baseline_qa, + projected_qa=None, + quality_delta=None, + off_topic_reasons=off_reasons, + candidate_reasons=best_candidate.get("reasons") or [], + ) + if slot_score_delta is not None and slot_score_delta > 0: + fit_msg = f"Stufen-Fit +{round(slot_score_delta * 100)} Prozentpunkte" + if fit_msg not in pro_contra["proposed_pro"]: + pro_contra["proposed_pro"].insert(0, fit_msg) + library_alt = { + "exercise_id": cand_id, + "title": (best_candidate.get("title") or "").strip() or None, + "slot_score": proposed_slot_score, + "slot_score_delta": slot_score_delta, + "quality_delta": None, + "auto_select": auto_select, + "suggestion_type": suggestion_type, + "reasons": list(best_candidate.get("reasons") or [])[:4], + "pro_contra": pro_contra, + } + lib_entry = { + "roadmap_major_step_index": major_idx, + "baseline_exercise_id": int(current_id) if current_id is not None else None, + "baseline_title": (current.get("title") or "").strip() or None, + "proposed_exercise_id": cand_id, + "proposed_title": library_alt["title"], + "baseline_slot_status": current.get("slot_status"), + "proposed_slot_status": best_candidate.get("slot_status") or "matched", + "suggestion_type": suggestion_type, + "quality_delta": None, + "baseline_slot_score": baseline_slot_score, + "proposed_slot_score": proposed_slot_score, + "slot_score_delta": slot_score_delta, + "auto_select": auto_select, + "baseline_quality_score": baseline_score, + "improves_path": auto_select, + "off_topic": off_topic, + "slot_problem": slot_problem, + "problem_reasons": off_reasons[:6], + "proposed_is_ai_proposal": False, + "pro_contra": pro_contra, + } + if auto_select: + suggestions.append(lib_entry) + else: + rejected.append(lib_entry) + + show_ai_option = bool( + body.include_ai_gap_fill + and ( + current_id is None + or off_topic + or slot_problem + or bool(current.get("is_ai_proposal")) + or ( + baseline_slot_score is not None + and baseline_slot_score < _SLOT_FIT_POOR_THRESHOLD + ) + ) + ) + ai_alt: Optional[Dict[str, Any]] = None + if show_ai_option: + slot_offer = next( + ( + o + for o in gap_fill_offers + if isinstance(o, dict) and _gap_offer_major_index(o) == major_idx + ), + None, + ) + if not slot_offer: + gap_spec: Optional[Dict[str, Any]] = None + if current_id is None: + empty_specs = _build_evaluate_empty_slot_gap_specs( + [current], + goal_query=goal_query, + ) + gap_spec = empty_specs[0] if empty_specs else None + elif off_topic or slot_problem: + gap_spec = _build_off_topic_slot_gap_spec(current, goal_query=goal_query) + if gap_spec: + slot_offer = build_gap_fill_offer( + spec=gap_spec, + steps=baseline_steps, + goal_query=goal_query, + brief=semantic_brief, + proposal=None, + roadmap_snapshot=_roadmap_gap_snapshot_for_spec( + cur, + roadmap_ctx, + gap_spec, + goal_query=goal_query, + semantic_brief=semantic_brief, + ), + ) + gap_fill_offers.append(slot_offer) + if slot_offer: + ai_alt = { + "title_hint": slot_offer.get("title_hint") or f"Slot {major_idx + 1}", + "gap_offer": slot_offer, + "auto_select": False, + } + + return { + "roadmap_major_step_index": major_idx, + "roadmap_learning_goal": (stage_spec.learning_goal or "").strip() or None, + "baseline_exercise_id": int(current_id) if current_id is not None else None, + "baseline_title": (current.get("title") or "").strip() or None, + "baseline_slot_score": baseline_slot_score, + "baseline_slot_status": current.get("slot_status"), + "slot_problem": slot_problem, + "off_topic": off_topic, + "problem_reasons": off_reasons[:6], + "library_alternative": library_alt, + "ai_alternative": ai_alt, + } + + def _run_unified_slot_improvement_review( cur, *, @@ -3124,7 +3429,7 @@ def _run_unified_slot_improvement_review( roadmap_edited: bool, ) -> Dict[str, Any]: """ - Ein Workflow: Pfad bewerten → je Slot Alternativen suchen → Einzel-QS → nur Verbesserungen. + Ein Workflow: Pfad bewerten → je Slot Alternativen suchen → Stufen-Fit vergleichen. """ if not body.baseline_evaluate_steps: raise HTTPException( @@ -3137,6 +3442,52 @@ def _run_unified_slot_improvement_review( detail="unified_slot_review erfordert Roadmap (roadmap_override / roadmap_first)", ) + try: + return _run_unified_slot_improvement_review_core( + cur, + tenant=tenant, + body=body, + goal_query=goal_query, + max_steps=max_steps, + semantic_brief=semantic_brief, + semantic_llm_applied=semantic_llm_applied, + path_target_profile=path_target_profile, + path_intent=path_intent, + first_intent_summary=first_intent_summary, + roadmap_ctx=roadmap_ctx, + progression_roadmap=progression_roadmap, + roadmap_edited=roadmap_edited, + ) + except HTTPException: + raise + except Exception as exc: + raise HTTPException( + status_code=500, + detail=f"unified_slot_review fehlgeschlagen: {exc}", + ) from exc + + +def _run_unified_slot_improvement_review_core( + cur, + *, + tenant: TenantContext, + body: ProgressionPathSuggestRequest, + goal_query: str, + max_steps: int, + semantic_brief: PlanningSemanticBrief, + semantic_llm_applied: bool, + path_target_profile: PlanningTargetProfile, + path_intent: str, + first_intent_summary: Mapping[str, Any], + roadmap_ctx: ProgressionRoadmapContext, + progression_roadmap: Optional[Dict[str, Any]], + roadmap_edited: bool, +) -> Dict[str, Any]: + if not body.baseline_evaluate_steps: + raise HTTPException(status_code=400, detail="baseline_evaluate_steps fehlt") + if not roadmap_ctx.stage_specs: + raise HTTPException(status_code=400, detail="roadmap stage_specs fehlt") + baseline_steps = _evaluate_steps_from_payload(cur, body.baseline_evaluate_steps) snapshot = ( dict(body.baseline_path_qa_snapshot) @@ -3206,256 +3557,49 @@ def _run_unified_slot_improvement_review( for step_index, stage_spec in enumerate(roadmap_ctx.stage_specs): major_idx = int(stage_spec.major_step_index) - current = dict(steps_by_major.get(major_idx, {})) - current.setdefault("roadmap_major_step_index", major_idx) - current.setdefault("roadmap_learning_goal", stage_spec.learning_goal) - current_id = current.get("exercise_id") - slot_problem = major_idx in problem_slots - off_topic = slot_problem or major_idx in off_topic_map or bool( - current.get("slot_status") in {"off_topic", "stripped"} - ) - off_reasons = list(problem_slots.get(major_idx, [])) + off_topic_map.get(major_idx, []) - - baseline_slot_score: Optional[float] = off_topic_scores.get(major_idx) - if baseline_slot_score is None and current_id is not None and not current.get("is_ai_proposal"): - baseline_slot_score = _score_exercise_stage_fit_for_spec( + try: + slot_review = _build_unified_slot_review_entry( cur, - exercise_id=int(current_id), - step=current, - stage_spec=stage_spec, + tenant=tenant, + body=body, + goal_query=goal_query, + max_steps=max_steps, semantic_brief=semantic_brief, + path_target_profile=path_target_profile, + path_intent=path_intent, + roadmap_ctx=roadmap_ctx, + stage_spec=stage_spec, step_index=step_index, stage_count=stage_count, + major_idx=major_idx, + current=steps_by_major.get(major_idx, {}), + baseline_steps=baseline_steps, + baseline_qa=baseline_qa, + baseline_score=baseline_score, + steps_by_major=steps_by_major, + problem_slots=problem_slots, + off_topic_map=off_topic_map, + off_topic_scores=off_topic_scores, + gap_fill_offers=gap_fill_offers, + suggestions=suggestions, + rejected=rejected, ) - - planned_ids = [ - int(s["exercise_id"]) - for midx, s in sorted(steps_by_major.items()) - if midx != major_idx and s.get("exercise_id") is not None - ] - anchor_id: Optional[int] = None - anchor_variant_id: Optional[int] = None - used_other: Set[int] = set(planned_ids) - for midx in sorted(steps_by_major): - if midx >= major_idx: - break - step = steps_by_major[midx] - eid = step.get("exercise_id") - if eid is not None: - anchor_id = int(eid) - vid = step.get("variant_id") - anchor_variant_id = int(vid) if vid is not None else None - - candidates = _roadmap_slot_library_candidates( - cur, - tenant=tenant, - body=body, - goal_query=goal_query, - max_steps=max_steps, - semantic_brief=semantic_brief, - path_target_profile=path_target_profile, - path_intent=path_intent, - roadmap_ctx=roadmap_ctx, - stage_spec=stage_spec, - step_index=step_index, - stage_count=stage_count, - planned_ids=planned_ids, - anchor_id=anchor_id, - anchor_variant_id=anchor_variant_id, - used=used_other, - exclude_exercise_id=int(current_id) if current_id is not None else None, - max_candidates=5, - skip_post_match_gate=True, - ) - - best_candidate: Optional[Dict[str, Any]] = None - for candidate in candidates: - try: - cand_id = int(candidate.get("exercise_id")) - except (TypeError, ValueError): - continue - if current_id is not None and int(current_id) == cand_id: - continue - best_candidate = candidate - break - - proposed_slot_score: Optional[float] = None - quality_delta: Optional[float] = None - projected_qa: Optional[Dict[str, Any]] = None - library_alt: Optional[Dict[str, Any]] = None - if best_candidate is not None: - try: - cand_id = int(best_candidate.get("exercise_id")) - except (TypeError, ValueError): - cand_id = None - if cand_id is not None: - proposed_slot_score = _score_exercise_stage_fit_for_spec( - cur, - exercise_id=cand_id, - step={**current, **best_candidate, "roadmap_major_step_index": major_idx}, - stage_spec=stage_spec, - semantic_brief=semantic_brief, - step_index=step_index, - stage_count=stage_count, - ) - diff_stub = { - "roadmap_major_step_index": major_idx, - "baseline_exercise_id": int(current_id) if current_id is not None else None, - "baseline_title": (current.get("title") or "").strip() or None, - "proposed_exercise_id": cand_id, - "proposed_title": (best_candidate.get("title") or "").strip() or None, - } - merged_steps = _apply_slot_diff_to_steps(baseline_steps, diff_stub, baseline_steps) - for i, raw in enumerate(merged_steps): - if int(raw.get("roadmap_major_step_index", -1)) == major_idx: - merged_steps[i] = { - **raw, - **best_candidate, - "roadmap_major_step_index": major_idx, - } - break - projected_qa = _quick_evaluate_steps_qa( - cur, - goal_query=goal_query, - semantic_brief=semantic_brief, - steps=merged_steps, - roadmap_ctx=roadmap_ctx, - ) - quality_delta = _quality_delta( - baseline_score, - _path_qa_quality_score(projected_qa), - ) - suggestion_type = ( - "remove_and_replace" - if (off_topic or slot_problem) and current_id is not None - else ("library_fill" if current_id is None else "library_improvement") - ) - auto_select = _slot_auto_select_library( - baseline_slot_score=baseline_slot_score, - proposed_slot_score=proposed_slot_score, - baseline_exercise_id=int(current_id) if current_id is not None else None, - proposed_exercise_id=cand_id, - ) - library_alt = { - "exercise_id": cand_id, - "title": (best_candidate.get("title") or "").strip() or None, - "slot_score": proposed_slot_score, - "slot_score_delta": ( - round(float(proposed_slot_score) - float(baseline_slot_score), 4) - if proposed_slot_score is not None and baseline_slot_score is not None - else None - ), - "quality_delta": quality_delta, - "auto_select": auto_select, - "suggestion_type": suggestion_type, - "reasons": list(best_candidate.get("reasons") or [])[:4], - "pro_contra": _build_slot_pro_contra( - current_step=current, - proposed_step=best_candidate, - suggestion_type=suggestion_type, - baseline_qa=baseline_qa, - projected_qa=projected_qa, - quality_delta=quality_delta, - off_topic_reasons=off_reasons, - candidate_reasons=best_candidate.get("reasons") or [], - ), - } - lib_entry = { - "roadmap_major_step_index": major_idx, - "baseline_exercise_id": int(current_id) if current_id is not None else None, - "baseline_title": (current.get("title") or "").strip() or None, - "proposed_exercise_id": cand_id, - "proposed_title": library_alt["title"], - "baseline_slot_status": current.get("slot_status"), - "proposed_slot_status": best_candidate.get("slot_status") or "matched", - "suggestion_type": suggestion_type, - "quality_delta": quality_delta, - "baseline_slot_score": baseline_slot_score, - "proposed_slot_score": proposed_slot_score, - "slot_score_delta": library_alt["slot_score_delta"], - "auto_select": auto_select, - "baseline_quality_score": baseline_score, - "projected_quality_score": _path_qa_quality_score(projected_qa), - "projected_path_qa": projected_qa, - "improves_path": auto_select, - "off_topic": off_topic, - "slot_problem": slot_problem, - "problem_reasons": off_reasons[:6], - "proposed_is_ai_proposal": False, - "pro_contra": library_alt["pro_contra"], - } - if auto_select: - suggestions.append(lib_entry) - elif cand_id is not None: - rejected.append(lib_entry) - - show_ai_option = bool( - body.include_ai_gap_fill - and ( - current_id is None - or off_topic - or slot_problem - or bool(current.get("is_ai_proposal")) - or ( - baseline_slot_score is not None - and baseline_slot_score < _SLOT_FIT_POOR_THRESHOLD - ) - ) - ) - ai_alt: Optional[Dict[str, Any]] = None - if show_ai_option: - slot_offer = next( - ( - o - for o in gap_fill_offers - if isinstance(o, dict) - and int(o.get("roadmap_major_step_index", -1)) == major_idx - ), - None, - ) - if not slot_offer: - empty_specs = _build_evaluate_empty_slot_gap_specs( - [current], - goal_query=goal_query, - ) - if empty_specs: - slot_offer = build_gap_fill_offer( - spec=empty_specs[0], - steps=baseline_steps, - goal_query=goal_query, - brief=semantic_brief, - proposal=None, - roadmap_snapshot=_roadmap_gap_snapshot_for_spec( - cur, - roadmap_ctx, - empty_specs[0], - goal_query=goal_query, - semantic_brief=semantic_brief, - ), - ) - gap_fill_offers.append(slot_offer) - if slot_offer: - ai_alt = { - "title_hint": slot_offer.get("title_hint") or f"Slot {major_idx + 1}", - "gap_offer": slot_offer, - "auto_select": False, - } - - slot_reviews.append( - { + except Exception as exc: + slot_review = { "roadmap_major_step_index": major_idx, "roadmap_learning_goal": (stage_spec.learning_goal or "").strip() or None, - "baseline_exercise_id": int(current_id) if current_id is not None else None, - "baseline_title": (current.get("title") or "").strip() or None, - "baseline_slot_score": baseline_slot_score, - "baseline_slot_status": current.get("slot_status"), - "slot_problem": slot_problem, - "off_topic": off_topic, - "problem_reasons": off_reasons[:6], - "library_alternative": library_alt, - "ai_alternative": ai_alt, + "baseline_exercise_id": None, + "baseline_title": None, + "baseline_slot_score": None, + "baseline_slot_status": None, + "slot_problem": major_idx in problem_slots, + "off_topic": major_idx in off_topic_map, + "problem_reasons": [f"Slot-Review fehlgeschlagen: {exc}"[:300]], + "library_alternative": None, + "ai_alternative": None, + "review_error": str(exc)[:300], } - ) + slot_reviews.append(slot_review) improvement_diffs = [_suggestion_as_slot_diff(s) for s in suggestions] problem_slot_payload = { @@ -3470,12 +3614,17 @@ def _run_unified_slot_improvement_review( "rejected_count": len(rejected), } + try: + target_summary = path_target_profile.to_summary_dict(cur) + except Exception: + target_summary = {} + return { "goal_query": goal_query, "max_steps_requested": max_steps, "steps": baseline_steps, "step_count": len(baseline_steps), - "target_profile_summary": path_target_profile.to_summary_dict(cur), + "target_profile_summary": target_summary, "semantic_brief_summary": brief_to_summary_dict(semantic_brief), "semantic_llm_applied": semantic_llm_applied, "query_intent_summary": first_intent_summary, diff --git a/backend/tests/test_planning_problematic_slots.py b/backend/tests/test_planning_problematic_slots.py index b4fa3d9..8276d70 100644 --- a/backend/tests/test_planning_problematic_slots.py +++ b/backend/tests/test_planning_problematic_slots.py @@ -111,3 +111,20 @@ def test_slot_auto_select_requires_higher_score(): baseline_exercise_id=1, proposed_exercise_id=2, ) + + +def test_off_topic_slot_gap_spec_for_filled_slot(): + from planning_exercise_path_builder import _build_off_topic_slot_gap_spec + + spec = _build_off_topic_slot_gap_spec( + { + "roadmap_major_step_index": 7, + "exercise_id": 99, + "title": "Ukemi Vorwärts", + "roadmap_learning_goal": "Integration Täuschung", + } + ) + assert spec is not None + assert spec["source"] == "off_topic" + assert spec["roadmap_major_step_index"] == 7 + assert "Ukemi" in spec["rationale"] diff --git a/frontend/src/components/ProgressionGraphEditor.jsx b/frontend/src/components/ProgressionGraphEditor.jsx index b980d2d..59ce505 100644 --- a/frontend/src/components/ProgressionGraphEditor.jsx +++ b/frontend/src/components/ProgressionGraphEditor.jsx @@ -515,6 +515,7 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa baselineRes?.path_qa?.quality_score != null ? Number(baselineRes.path_qa.quality_score) : null, + include_llm_path_qa: false, include_llm_intent: false, auto_rematch_after_qa: false, }) -- 2.43.0 From 5e5f4ca8d4bbeb934946fd5293271e576bf670cf Mon Sep 17 00:00:00 2001 From: Lars Date: Sat, 13 Jun 2026 16:23:04 +0200 Subject: [PATCH 19/20] Enhance Progression Findings and Graph Editor with Evaluation Staleness Handling - Added `evaluationStale` state to `ProgressionGraphEditor` and `ProgressionFindingsPanel` to track the freshness of evaluations. - Updated UI to display a warning when evaluations are stale, prompting users to re-evaluate the graph. - Modified loading and evaluation functions to manage the `evaluationStale` state effectively, ensuring accurate user feedback during the evaluation process. - Improved user notifications regarding the need for re-evaluation after changes to the graph. --- .../components/ProgressionFindingsPanel.jsx | 23 +++++++++- .../src/components/ProgressionGraphEditor.jsx | 46 +++++++++++-------- 2 files changed, 49 insertions(+), 20 deletions(-) diff --git a/frontend/src/components/ProgressionFindingsPanel.jsx b/frontend/src/components/ProgressionFindingsPanel.jsx index 162b04e..0b4135e 100644 --- a/frontend/src/components/ProgressionFindingsPanel.jsx +++ b/frontend/src/components/ProgressionFindingsPanel.jsx @@ -296,6 +296,7 @@ export default function ProgressionFindingsPanel({ generatingOfferId = null, aiBusy = false, evaluateDisabled = false, + evaluationStale = false, }) { const { fixHints: optimizationHints, highlightTexts } = useMemo( () => splitPathQaHints(pathQa), @@ -314,8 +315,8 @@ export default function ProgressionFindingsPanel({

    Graph-Bewertung

    - Bewertet den aktuellen Slot-Stand (3-Stufen-QS, ohne Auto-Rematch) — dieselbe Logik nach Match, - solange keine Zuordnung geändert wurde. + Bewertet den aktuellen Slot-Stand (3-Stufen-QS, ohne Auto-Rematch). Nach Änderungen am Graphen + erscheint ein Hinweis — dann erneut „Graph bewerten“.