diff --git a/.claude/docs/working/PLANNING_EXERCISE_SUGGEST_CONTEXT.md b/.claude/docs/working/PLANNING_EXERCISE_SUGGEST_CONTEXT.md index bbce56c..a8c1ccd 100644 --- a/.claude/docs/working/PLANNING_EXERCISE_SUGGEST_CONTEXT.md +++ b/.claude/docs/working/PLANNING_EXERCISE_SUGGEST_CONTEXT.md @@ -493,23 +493,37 @@ Nach Pfad-Bildung: --- -## 24. Phase F — Roadmap-first Progressionsgraph (0.8.204+) 🔄 +## 24. Phase F — Roadmap-first Progressionsgraph (0.8.204–217) ✅ **Entscheidung:** Progressionsgraph plant **vom Ziel rückwärts** (Roadmap → Stufenspezifikation → Bibliothek/KI). **Keine Gruppenanalyse** — die gehört zur Trainingsplanung. +**Ist-Stand (vollständig):** `docs/architecture/PLANNING_PROGRESSION_GRAPH_KI.md` **Spec:** `working/PLANNING_PROGRESSION_ROADMAP_SPEC.md` · **Roadmap:** `docs/architecture/PLANNING_KI_ROADMAP.md` | Teil | Modul / API | |------|-------------| | Pipeline | `planning_progression_roadmap.py` (Workflow-lite) | -| API | `include_roadmap_preview`, `include_llm_roadmap`, `roadmap_first` auf `progression-path-suggest` | -| Prompts | Migration **078/079** — Slugs in `ai_prompts` (Admin), **kein** Template im Python-Code | -| UI | `ExerciseProgressionPathBuilder` — Roadmap-Box (Major Steps) | +| Match | `planning_exercise_path_builder.py` — `roadmap_first`, `roadmap_override` | +| Skills | `planning_skill_expectations.py` — pro Stufe + Pfad | +| Gap-KI | `planning_exercise_form_context.py`, `planning_exercise_path_ai_fill.py` | +| Persistenz | `planning_roadmap` JSONB (Migration **088**) | +| API | `progression-path-suggest`, `PUT` Graph, `POST …/edges/sequence` | +| Prompts | **078/079/087** — Slugs nur in `ai_prompts` | +| UI | `ExerciseProgressionPathBuilder`, `ExerciseGapFillPrepModal` | -**F3 (0.8.206):** `roadmap_first=true` (Default im UI) — Retrieval pro `stage_spec`/Major Step; `roadmap_unfilled` Gap-Angebote. Ohne Flag: retrieval-first wie bisher, Roadmap nur Preview. +**Graph-Bias:** `progression_graph_id` bevorzugt **bestehende Nachfolger** ab Schritt 2 (Gewicht ~4–10 %), baut aber **keinen** Pfad aus vorhandenen Knoten — siehe Ist-Doku §5. **Mitai Workflow-Engine:** bewusst **nicht** jetzt — Pipeline workflow-ready für spätere Anbindung. --- ## 25. Backlog (offen) + +Siehe priorisierte Liste in **`docs/architecture/PLANNING_PROGRESSION_GRAPH_KI.md`** §10: + +1. UI-Wizard (Progressionsgraph) — separater Chat +2. Graph-Erweiterungsmodus (Start ab Knoten) +3. Trainingsplanung Phase G (Gruppenkontext, `planning_skill_expectations`) +4. Kontext auf allen Pfad-Schritten in der UI +5. Enrichment / Prompt-Feintuning +6. Mitai Workflow-Engine (langfristig) diff --git a/.claude/docs/working/PLANNING_PROGRESSION_ROADMAP_SPEC.md b/.claude/docs/working/PLANNING_PROGRESSION_ROADMAP_SPEC.md index 6e0eadb..b6d7307 100644 --- a/.claude/docs/working/PLANNING_PROGRESSION_ROADMAP_SPEC.md +++ b/.claude/docs/working/PLANNING_PROGRESSION_ROADMAP_SPEC.md @@ -2,9 +2,11 @@ **Version:** 0.1 **Datum:** 2026-06-07 -**Status:** VERBINDLICHE ZIELARCHITEKTUR — Umsetzung gestartet (0.8.204+) +**Status:** VERBINDLICHE ZIELARCHITEKTUR — **F0–F9 umgesetzt** (0.8.217) **Geltungsbereich:** **Progressionsgraph** (`exercise_progression_graphs`) — **ohne** Gruppenanalyse +**Ist-Stand (Module, API, Graph-Verhalten, Persistenz):** `docs/architecture/PLANNING_PROGRESSION_GRAPH_KI.md` + **Bezüge:** `working/PLANNING_EXERCISE_SUGGEST_CONTEXT.md` · `working/AI_PLANNING_KI_MULTISTAGE_FORECAST.md` · `technical/AI_TRAINING_PLANNING_CONCEPT.md` · `technical/AI_PROMPT_TARGET_ARCHITECTURE.md` · `docs/architecture/PLANNING_KI_ROADMAP.md` · `docs/HANDOVER.md` @@ -152,6 +154,7 @@ Jede Phase: `(ctx) → ctx`, Zwischenergebnisse in API-Response für **Human-in- | Slug | Phase | Migration | |------|-------|-----------| +| `planning_progression_start_target` | Start/Ziel | **087** | | `planning_progression_goal_analysis` | A | **078** | | `planning_progression_roadmap` | B | **078** | | `planning_progression_stage_spec` | C | **079** | @@ -184,15 +187,23 @@ Jede Phase: `(ctx) → ctx`, Zwischenergebnisse in API-Response für **Human-in- | ID | Inhalt | Status | |----|--------|--------| -| **F0** | Spec + Doku + `planning_progression_roadmap.py` Scaffold | 🔄 0.8.204 | -| **F1** | `include_roadmap_preview` in API + deterministische A/B | 🔄 0.8.204 | -| **F2** | LLM Phase A/B/C über `ai_prompts` (078/079), `include_llm_roadmap` | 🔄 0.8.205 | -| **F3** | Retrieval aus `stage_specs` (roadmap_first) | ✅ 0.8.206 | -| **F4** | UI Roadmap-Review | ✅ 0.8.207 | -| **F5** | Trainingsplanung: eigene Pipeline + ggf. Workflow-Engine | 🔲 | +| **F0** | Spec + Doku + `planning_progression_roadmap.py` Scaffold | ✅ 0.8.204 | +| **F1** | `include_roadmap_preview` in API + deterministische A/B | ✅ 0.8.204 | +| **F2** | LLM Phase A/B/C über `ai_prompts` (078/079), `include_llm_roadmap` | ✅ 0.8.205 | +| **F3** | Retrieval aus `stage_specs` (roadmap_first) | ✅ 0.8.206–209 | +| **F4** | UI Roadmap-Review + `roadmap_override` | ✅ 0.8.207 | +| **F5** | Start/Ziel strukturiert + Prompt **087** + Zwei-Schritt-UI | ✅ 0.8.210–214 | +| **F6** | Gap-Prep + `planning_context` an Übungs-KI | ✅ 0.8.212–214 | +| **F7** | `planning_skill_expectations` | ✅ 0.8.215–216 | +| **F8** | Editierbare `stage_specs` in UI | ✅ 0.8.216 | +| **F9** | `planning_roadmap` JSONB (Migration **088**) | ✅ 0.8.217 | +| **G** | Trainingsplanung: eigene Pipeline + Workflow-Engine | 🔲 | + +Details: `docs/architecture/PLANNING_PROGRESSION_GRAPH_KI.md` --- ## 9. Changelog +- **2026-05-22:** Ist-Stand F5–F9 dokumentiert; Verweis auf `PLANNING_PROGRESSION_GRAPH_KI.md`. - **2026-06-07:** Erstfassung — Roadmap-first Entscheidung, Abgrenzung Graphen vs. Planung, Workflow-lite. diff --git a/.claude/docs/working/PROGRESSION_GRAPH_SLOT_EDITOR_SPEC.md b/.claude/docs/working/PROGRESSION_GRAPH_SLOT_EDITOR_SPEC.md new file mode 100644 index 0000000..3f5d0cf --- /dev/null +++ b/.claude/docs/working/PROGRESSION_GRAPH_SLOT_EDITOR_SPEC.md @@ -0,0 +1,81 @@ +# Progressionsgraph — Slot-Editor (Phase B) + +**Stand:** 2026-06-10 · **Status:** In Umsetzung + +## Ziel + +Ein Progressionsgraph = **ein linearer Hauptpfad** (Roadmap = strukturgebend). Jeder **Major Step** ist ein **Slot** mit: + +- **primary** — Hauptübung des Slots (Pfadknoten) +- **siblings** — 0..n Schwestern (gleiche Stufe, `edge_type: sibling`) + +KI-Entwürfe und Bibliotheksübungen leben **im selben Slot-Modell**, ohne sofortige Übungsanlage. + +## Slot-Zustände (`kind`) + +| kind | Bedeutung | +|------|-----------| +| `empty` | Noch keine Übung | +| `library` | `exercise_id` (+ optional `variant_id`) | +| `proposal` | KI-Entwurf (`ai_suggestion`, kein `exercise_id`) | + +## Kanten + +- `primary(n) → primary(n+1)` — `next_exercise` (nur befüllte Primärkette, lückenlos verbunden) +- `primary ↔ sibling` — `sibling` (pro Slot) + +Leere Slots in der Roadmap sind erlaubt; Kanten nur zwischen aufeinanderfolgenden befüllten Primär-Slots. + +## Editor-Zustand (`ProgressionGraphDraft`) + +```ts +{ + goalQuery, startSituation, targetState, roadmapNotes, maxSteps, + majorSteps: MajorStep[], + slots: Slot[], // index = major_step_index + pathSkillExpectations?, + lastFindings?, // path_qa-Snapshot + dirty: boolean, +} +``` + +**Hydration:** `planning_roadmap` + Kanten → Slots; `slot_contents[]` für Entwürfe; Primärkette aus `next_exercise`. + +**Speichern:** Batch-Delete bestehender Pfad-/Schwester-Kanten → `edges/sequence` (Primärkette) → einzelne `sibling`-Kanten → `PUT`/`sequence` mit Artefakt inkl. `slot_contents`, optional `last_findings`. + +## Findings-Panel + +Nutzt `path_qa` (`overall_ok`, `quality_score`, `issues`, `recommendations`, `gap_fill_offers`, …). + +**API:** `POST /api/planning/progression-path-suggest` mit `evaluate_only: true` und `evaluate_steps[]` — QA ohne Re-Match. + +Persistenz: `planning_roadmap.last_findings`. + +## Artefakt-Erweiterung (`GraphPlanningRoadmapArtifact`) + +Zusätzlich optional: + +- `slot_contents[]` — `{ major_step_index, primary, siblings[] }` +- `last_findings` — letzter `path_qa`-Snapshot + +## UI (konsolidiert) + +- **Eine Oberfläche:** `ExerciseProgressionGraphPanel` embeddet `ProgressionGraphEditor` (Slots + Findings) +- Kein separater Slot-Editor, kein 4-Schritt-KI-Wizard, kein `ProgressionChainEditor` im Panel +- Route `/progression-graphs/:id` → Redirect nach `/exercises` (Deep-Link wählt Graph) +- **Phase C:** Übersicht mit Kacheln (Name, Start, Ziel) + +## Ersetzt (Legacy, nicht mehr im Panel) + +- `ExerciseProgressionPathBuilder` · `ProgressionChainEditor` — Code bleibt vorerst, nicht eingebunden + +## Implementierungsreihenfolge + +| ID | Inhalt | +|----|--------| +| B.0 | Draft + Laden/Speichern Slots ↔ Kanten | +| B.1 | Slot-Karten, Bibliothek + Entwurf | +| B.2 | Findings-Panel + `evaluate_only` | +| B.3 | Entwürfe im Artefakt + „Übung anlegen“ | +| B.4 | Route + Panel vereinfachen | +| B.5 | `last_findings` + Phase-C-Vorbereitung | diff --git a/CLAUDE.md b/CLAUDE.md index f8d36f2..03e9f8a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -18,7 +18,7 @@ > | Architektur-Zielbild, Refaktor-Roadmap, verbindliche Shinkan-Regeln | **`docs/architecture/README.md`** | > | Performance-Baseline (Phase 0) | **`docs/architecture/BASELINE_SNAPSHOT.md`** | > | KI-Prompt-System — Zielarchitektur | `.claude/docs/technical/AI_PROMPT_TARGET_ARCHITECTURE.md` | -> | Planungs-KI Progressions-Roadmap (Phase F) | **`.claude/docs/working/PLANNING_PROGRESSION_ROADMAP_SPEC.md`** · **`docs/architecture/PLANNING_KI_ROADMAP.md`** | +> | Planungs-KI Progressionsgraph (Ist-Stand) | **`docs/architecture/PLANNING_PROGRESSION_GRAPH_KI.md`** · Spec **`.claude/docs/working/PLANNING_PROGRESSION_ROADMAP_SPEC.md`** · Roadmap **`docs/architecture/PLANNING_KI_ROADMAP.md`** | ## Projekt-Übersicht diff --git a/backend/Dockerfile b/backend/Dockerfile index 41de5da..eb64367 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -2,14 +2,16 @@ FROM python:3.12-slim WORKDIR /app -# Install system dependencies +# Install system dependencies (tzdata für zoneinfo/ZoneInfo unter Linux) RUN apt-get update && apt-get install -y \ postgresql-client \ + tzdata \ && rm -rf /var/lib/apt/lists/* # Copy requirements and install dependencies COPY requirements.txt . -RUN pip install --no-cache-dir -r requirements.txt +ENV PIP_DEFAULT_TIMEOUT=120 +RUN pip install --no-cache-dir --retries 5 -r requirements.txt # Copy application code COPY . . diff --git a/backend/migrations/088_exercise_progression_graph_planning_roadmap.sql b/backend/migrations/088_exercise_progression_graph_planning_roadmap.sql new file mode 100644 index 0000000..12eb27a --- /dev/null +++ b/backend/migrations/088_exercise_progression_graph_planning_roadmap.sql @@ -0,0 +1,8 @@ +-- Migration 088: Planungs-Roadmap-Artefakt am Progressionsgraph (JSONB, optional). +-- Speichert Ziel, Start/Ziel, progression_roadmap + stage_specs für Wiederaufnahme der KI-Planung. + +ALTER TABLE exercise_progression_graphs + ADD COLUMN IF NOT EXISTS planning_roadmap JSONB; + +COMMENT ON COLUMN exercise_progression_graphs.planning_roadmap IS + 'Optionales Planungs-Artefakt (goal_query, resolved_structured, progression_roadmap, stage_specs) — Schema v1 im App-Code.'; diff --git a/backend/migrations/089_ai_prompt_planning_intent_enrichment.sql b/backend/migrations/089_ai_prompt_planning_intent_enrichment.sql new file mode 100644 index 0000000..7ebcff7 --- /dev/null +++ b/backend/migrations/089_ai_prompt_planning_intent_enrichment.sql @@ -0,0 +1,67 @@ +-- Migration 089: Planungs-Intent — Zielanalyse + Stufenspecs (anti_patterns, success_criteria) + +UPDATE ai_prompts SET + description = 'Phase A: Ist-/Soll, Erfolgskriterien und explizite Ausschlüsse (ohne Gruppenkontext).', + template = $t$Du bist Assistent für Kampfsport-Trainer und analysierst eine Anfrage für einen Progressionsgraphen. + +Anfrage: {{goal_query}} +Semantic Brief: {{semantic_brief_json}} + +Wichtig: +- Keine Gruppenanalyse — nur didaktischer Pfad für die Technik/das Thema. +- Explizite Negationen aus der Anfrage (ohne/kein/nicht …) in constraints.excluded_themes übernehmen — nicht raten. +- success_criteria: messbar, für späteres Übungs-Matching (Titel + Kurzbeschreibung + Übungsziel). + +Antworte NUR mit JSON: +{ + "primary_topic": "Hauptthema", + "start_assumption": "Voraussetzungen für den Einstieg", + "target_state": "Konkreter Zielzustand der Progression", + "success_criteria": ["messbare Kriterien entlang des Pfads"], + "constraints": { + "partner_required": false, + "excluded_themes": ["wörtliche Negationen, z. B. keine Kumite-Anwendung"], + "trainer_notes": "optional: Fokus aus Ergänzungen" + } +}$t$, + default_template = template +WHERE slug = 'planning_progression_goal_analysis'; + +UPDATE ai_prompts SET + description = 'Phase C: Belastung, Übungstyp, Erfolgskriterien und anti_patterns je Major Step — für Retrieval-Matching.', + template = $t$Du bist Assistent für Kampfsport-Trainer und spezifizierst didaktische Stufen eines Progressionsgraphen. + +Anfrage: {{goal_query}} +Zielanalyse: {{goal_analysis_json}} +Major Steps: {{major_steps_json}} +Planungs-Intent (Pfadweite Regeln): {{intent_context_json}} +Semantic Brief: {{semantic_brief_json}} + +Aufgabe je Major Step — Felder für automatisches Übungs-Matching (nicht nur Titel): +- learning_goal: messbares Stufen-Lernziel (was die Übung bringen soll) +- load_profile: z. B. koordination, präzision, kraft, athletik +- exercise_type: kihon_einzel | partner_drill | kombination | kraft_auxiliary +- success_criteria: 2–4 prüfbare Kriterien an Kurzbeschreibung + Übungsziel (nicht nur Technikname im Titel) +- anti_patterns: 2–5 Dinge, die für diese Stufe unpassend sind + +Regeln: +1. Jede explicit_exclusions / excluded_themes aus intent_context und Zielanalyse MUSS in anti_patterns jeder Stufe vorkommen (umformuliert ok). +2. Keine neuen Ausschlüsse erfinden, die nicht in Anfrage/Intent/Zielanalyse stehen. +3. success_criteria Pfad-weit + stufenspezifisch kombinieren. +4. partner_drill nur wenn Partner/Kumite nicht ausgeschlossen ist. + +Antworte NUR mit JSON: +{ + "stage_specs": [ + { + "major_step_index": 0, + "learning_goal": "…", + "load_profile": ["koordination"], + "exercise_type": "kihon_einzel", + "success_criteria": ["…"], + "anti_patterns": ["…"] + } + ] +}$t$, + default_template = template +WHERE slug = 'planning_progression_stage_spec'; diff --git a/backend/migrations/090_ai_prompt_stage_transition_states.sql b/backend/migrations/090_ai_prompt_stage_transition_states.sql new file mode 100644 index 0000000..1a04b80 --- /dev/null +++ b/backend/migrations/090_ai_prompt_stage_transition_states.sql @@ -0,0 +1,43 @@ +-- Migration 090: Stufenspecs — start_state / target_state pro Major Step (Soll-Verkettung) + +UPDATE ai_prompts SET + description = 'Phase C: Stufenspezifikation inkl. Soll-Start und Stufen-Ziel je Major Step.', + template = $t$Du bist Assistent für Kampfsport-Trainer und spezifizierst didaktische Stufen eines Progressionsgraphen. + +Anfrage: {{goal_query}} +Zielanalyse: {{goal_analysis_json}} +Major Steps: {{major_steps_json}} +Planungs-Intent (Pfadweite Regeln): {{intent_context_json}} +Semantic Brief: {{semantic_brief_json}} + +Jede Stufe ist ein Übergang im Gesamtpfad: +- start_state: Soll-Zustand zu Beginn (= Ziel der vorherigen Stufe; Stufe 0 = Pfad-Start) +- target_state: Zielzustand nach dieser Stufe (= Soll für die nächste Stufe) +- learning_goal: messbares Lernziel der Übungssuche (was die Übung bringen soll) + +Felder je Major Step: +- load_profile, exercise_type, success_criteria, anti_patterns (wie bisher) + +Regeln: +1. start_state/target_state aus Zielanalyse und Major Steps ableiten — konsistente Kette. +2. explicit_exclusions aus intent_context in anti_patterns jeder Stufe. +3. success_criteria: prüfbar an Kurzbeschreibung + Übungsziel. +4. Keine erfundenen Ausschlüsse. + +Antworte NUR mit JSON: +{ + "stage_specs": [ + { + "major_step_index": 0, + "start_state": "…", + "target_state": "…", + "learning_goal": "…", + "load_profile": ["koordination"], + "exercise_type": "kihon_einzel", + "success_criteria": ["…"], + "anti_patterns": ["…"] + } + ] +}$t$, + default_template = template +WHERE slug = 'planning_progression_stage_spec'; diff --git a/backend/planning_exercise_form_context.py b/backend/planning_exercise_form_context.py index 3394261..63c9d04 100644 --- a/backend/planning_exercise_form_context.py +++ b/backend/planning_exercise_form_context.py @@ -6,7 +6,7 @@ Wird als ``planning_context_json`` in Übungs-Prompts (summary, skills, instruct from __future__ import annotations import json -from typing import Any, Dict, List, Mapping, Optional +from typing import Any, Dict, List, Mapping, Optional, Sequence _MAX_JSON_CHARS = 6000 _MAX_STRING = 800 @@ -85,6 +85,163 @@ def planning_context_prompt_variables( } +def _major_index_from_step(step: Mapping[str, Any]) -> Optional[int]: + for key in ("roadmap_major_step_index", "major_step_index"): + raw = step.get(key) + if raw is None: + continue + try: + return int(raw) + except (TypeError, ValueError): + continue + return None + + +def prior_path_steps_before_major( + steps: Sequence[Mapping[str, Any]], + major_idx: int, +) -> List[Dict[str, Any]]: + """Pfadschritte mit kleinerem roadmap_major_step_index, sortiert.""" + prior: List[Dict[str, Any]] = [] + for step in steps: + mi = _major_index_from_step(step) + if mi is not None and mi < major_idx: + prior.append(dict(step)) + prior.sort(key=lambda s: _major_index_from_step(s) or 0) + return prior + + +def _step_display_fields(step: Mapping[str, Any]) -> Dict[str, Any]: + title = _trim_str( + step.get("title") or step.get("exercise_title"), + limit=200, + ) + learning_goal = _trim_str( + step.get("roadmap_learning_goal") or step.get("learning_goal"), + limit=500, + ) + summary = _trim_str(step.get("summary"), limit=400) + start_state = _trim_str(step.get("roadmap_start_state") or step.get("start_state")) + target_state = _trim_str(step.get("roadmap_target_state") or step.get("target_state")) + phase = _trim_str(step.get("roadmap_phase") or step.get("phase")) + criteria_raw = step.get("stage_success_criteria") or step.get("success_criteria") or [] + criteria = [ + t + for x in criteria_raw + if (t := _trim_str(x, limit=200)) + ][:4] + out: Dict[str, Any] = { + "title": title, + "learning_goal": learning_goal, + "summary": summary, + "start_state": start_state, + "target_state": target_state, + "phase": phase, + "success_criteria": criteria or None, + "major_step_index": _major_index_from_step(step), + } + return {k: v for k, v in out.items() if v is not None and v != "" and v != []} + + +def build_progression_entry_state( + *, + major_step_index: Optional[int] = None, + prior_steps: Sequence[Mapping[str, Any]] = (), + start_situation: Optional[str] = None, + current_stage_start: Optional[str] = None, +) -> Dict[str, Any]: + """ + Eingangszustand für eine Roadmap-Stufe: erreichte Voraussetzungen aus Vorstufen. + """ + prior_compact = [_step_display_fields(s) for s in prior_steps] + prior_compact = [ + p + for p in prior_compact + if any(p.get(k) for k in ("title", "learning_goal", "summary", "success_criteria")) + ] + + achievements: List[str] = [] + detail_lines: List[str] = [] + for p in prior_compact: + if p.get("success_criteria"): + achievements.extend(p["success_criteria"]) + elif p.get("learning_goal"): + achievements.append(p["learning_goal"]) + + label_parts: List[str] = [] + if p.get("major_step_index") is not None: + label_parts.append(f"Stufe {int(p['major_step_index']) + 1}") + if p.get("phase"): + label_parts.append(f"({p['phase']})") + if p.get("title"): + label_parts.append(f"„{p['title']}\"") + prefix = " ".join(label_parts) if label_parts else "Vorstufe" + achieved = "" + if p.get("target_state"): + achieved = p["target_state"] + elif p.get("success_criteria"): + achieved = "; ".join(p["success_criteria"]) + elif p.get("learning_goal"): + achieved = p["learning_goal"] + elif p.get("summary"): + achieved = p["summary"] + if achieved: + detail_lines.append(f"{prefix}: erreicht — {achieved}") + + immediate_entry: Optional[str] = _trim_str(current_stage_start) + if not immediate_entry and prior_compact: + immediate = prior_compact[-1] + if immediate.get("target_state"): + immediate_entry = immediate["target_state"] + elif immediate.get("success_criteria"): + immediate_entry = "; ".join(immediate["success_criteria"]) + elif immediate.get("learning_goal"): + immediate_entry = immediate["learning_goal"] + elif immediate.get("summary"): + immediate_entry = immediate["summary"] + elif not immediate_entry and start_situation: + immediate_entry = start_situation + + entry_state = immediate_entry or start_situation + if prior_compact and start_situation and not immediate_entry: + detail_lines.insert(0, f"Ausgangsbasis Pfad: {start_situation}") + + out: Dict[str, Any] = {} + if entry_state: + out["entry_state"] = _trim_str(entry_state, limit=1200) + if detail_lines: + out["entry_state_detail"] = _trim_str("\n".join(detail_lines), limit=2000) + if prior_compact: + out["prior_steps"] = prior_compact[:6] + if achievements: + out["prior_achievements"] = list(dict.fromkeys(achievements))[:8] + return out + + +def enrich_gap_snapshot_with_entry_state( + snapshot: Mapping[str, Any], + *, + steps: Sequence[Mapping[str, Any]], + major_step_index: Optional[int], +) -> Dict[str, Any]: + snap = dict(snapshot) + if major_step_index is None: + return snap + try: + mi = int(major_step_index) + except (TypeError, ValueError): + return snap + prior = prior_path_steps_before_major(steps, mi) + entry = build_progression_entry_state( + major_step_index=mi, + prior_steps=prior, + start_situation=snap.get("start_situation"), + current_stage_start=snap.get("stage_start_state"), + ) + snap.update(entry) + return snap + + def build_progression_gap_snapshot( *, goal_analysis: Optional[Mapping[str, Any]] = None, @@ -141,6 +298,8 @@ def build_progression_gap_snapshot( "stage_learning_goal": _trim_str( spec.get("learning_goal"), limit=1200 ), + "stage_start_state": _trim_str(spec.get("start_state")), + "stage_target_state": _trim_str(spec.get("target_state")), "stage_phase": _trim_str(spec.get("phase")), "stage_exercise_type": _trim_str(spec.get("exercise_type")), "stage_load_profile": load_profile or None, @@ -160,6 +319,7 @@ def build_progression_path_gap_planning_context( offer: Optional[Mapping[str, Any]] = None, neighbor_before: Optional[Mapping[str, Any]] = None, neighbor_after: Optional[Mapping[str, Any]] = None, + prior_path_steps: Optional[Sequence[Mapping[str, Any]]] = None, path_step_count: int = 0, major_step_count: Optional[int] = None, roadmap_phase: Optional[str] = None, @@ -168,6 +328,8 @@ def build_progression_path_gap_planning_context( resolved_structured: Optional[Mapping[str, Any]] = None, stage_spec: Optional[Mapping[str, Any]] = None, semantic_brief: Optional[Mapping[str, Any]] = None, + stage_learning_goal_override: Optional[str] = None, + gap_trainer_supplements: Optional[str] = None, ) -> Dict[str, Any]: """Kontext für KI-Neuanlage aus Progressionsgraph-Pfad-Lücke.""" offer = offer or {} @@ -205,12 +367,28 @@ def build_progression_path_gap_planning_context( semantic_brief=semantic_brief, ) ctx.update(snap) + if major_idx is not None and prior_path_steps: + ctx.update( + build_progression_entry_state( + major_step_index=major_idx, + prior_steps=list(prior_path_steps), + start_situation=ctx.get("start_situation"), + ) + ) + if stage_learning_goal_override and stage_learning_goal_override.strip(): + ctx["stage_learning_goal"] = _trim_str(stage_learning_goal_override, limit=1200) + ctx["roadmap_learning_goal"] = ctx["stage_learning_goal"] + if gap_trainer_supplements and gap_trainer_supplements.strip(): + ctx["gap_trainer_supplements"] = _trim_str(gap_trainer_supplements, limit=2000) return sanitize_planning_context_for_ai(ctx) __all__ = [ + "build_progression_entry_state", "build_progression_gap_snapshot", "build_progression_path_gap_planning_context", + "enrich_gap_snapshot_with_entry_state", + "prior_path_steps_before_major", "compact_planning_context_json", "planning_context_prompt_variables", "sanitize_planning_context_for_ai", diff --git a/backend/planning_exercise_path_ai_fill.py b/backend/planning_exercise_path_ai_fill.py index 25e28da..81373bf 100644 --- a/backend/planning_exercise_path_ai_fill.py +++ b/backend/planning_exercise_path_ai_fill.py @@ -12,12 +12,174 @@ from ai_prompt_job import run_exercise_form_ai_suggestion from exercise_ai import strip_html_to_plain from planning_exercise_path_qa import find_step_pair_index -from planning_exercise_form_context import build_progression_gap_snapshot +from planning_exercise_form_context import ( + build_progression_entry_state, + build_progression_gap_snapshot, + enrich_gap_snapshot_with_entry_state, + prior_path_steps_before_major, +) from planning_exercise_semantics import PlanningSemanticBrief, brief_to_summary_dict _logger = logging.getLogger("shinkan.planning_exercise_path_ai_fill") +def _resolve_neighbor_steps_by_major_index( + steps: Sequence[Mapping[str, Any]], + major_idx: int, +) -> Tuple[Optional[Mapping[str, Any]], Optional[Mapping[str, Any]]]: + """Nachbarn im Pfad anhand roadmap_major_step_index (nicht Array-Position).""" + step_before: Optional[Mapping[str, Any]] = None + step_after: Optional[Mapping[str, Any]] = None + for step in steps: + raw = step.get("roadmap_major_step_index") + if raw is None: + continue + try: + mi = int(raw) + except (TypeError, ValueError): + continue + if mi < major_idx: + step_before = step + elif mi > major_idx and step_after is None: + step_after = step + return step_before, step_after + + +def _build_stage_ai_context( + *, + goal_query: str, + brief: PlanningSemanticBrief, + spec: Mapping[str, Any], + step_before: Optional[Mapping[str, Any]] = None, + step_after: Optional[Mapping[str, Any]] = None, + prior_steps: Optional[Sequence[Mapping[str, Any]]] = None, + start_situation: Optional[str] = None, +) -> ExerciseFormAiPromptContext: + """KI-Kontext für unbesetzte Roadmap-Stufe (keine Brücke zwischen falschen Array-Indizes).""" + gap = dict(spec.get("gap") or {}) + phase = spec.get("phase") or gap.get("expected_phase") or "vertiefung" + topic = (brief.primary_topic or "Technik").strip() + learning_goal = ( + gap.get("learning_goal") + or spec.get("title_hint") + or spec.get("sketch") + or "" + ).strip() + title = (spec.get("title_hint") or f"{topic} — {phase}").strip()[:280] + major_idx = spec.get("roadmap_major_step_index") + entry: Dict[str, Any] = {} + if prior_steps is not None and major_idx is not None: + entry = build_progression_entry_state( + major_step_index=major_idx, + prior_steps=prior_steps, + start_situation=start_situation, + ) + + goal_parts = [ + f"Planungsziel: {goal_query}", + f"Roadmap-Stufe ({phase}): {learning_goal}", + "Erstelle eine Übung, die dieses Stufen-Lernziel erfüllt — keine generische Brücken-Übung.", + ] + if entry.get("entry_state"): + goal_parts.append( + f"Eingangszustand (erreichte Voraussetzungen): {entry['entry_state']}" + ) + if entry.get("entry_state_detail") and entry.get("entry_state_detail") != entry.get("entry_state"): + goal_parts.append(f"Bisheriger Pfad:\n{entry['entry_state_detail']}") + if step_before: + goal_parts.append( + f"Vorherige Stufe im Pfad: „{(step_before.get('title') or '').strip()}“" + ) + if step_after: + goal_parts.append( + f"Nächste Stufe im Pfad: „{(step_after.get('title') or '').strip()}“" + ) + sketch = (spec.get("sketch") or "").strip() + if sketch and sketch != learning_goal: + goal_parts.extend(["", f"Kontext: {sketch}"]) + goal = "\n".join(goal_parts) + + focus_hint = topic if brief.topic_type == "technique" else None + if brief.must_phrases: + focus_hint = ", ".join(brief.must_phrases[:2]) + + return ExerciseFormAiPromptContext( + title=title[:280], + goal=goal[:8000], + execution=None, + focus_hint=focus_hint, + ) + + +def try_suggest_ai_stage_step( + cur, + *, + goal_query: str, + brief: PlanningSemanticBrief, + spec: Mapping[str, Any], + steps: Sequence[Mapping[str, Any]], +) -> Optional[Dict[str, Any]]: + """KI-Vorschlag für leere Roadmap-Stufe.""" + major_idx = spec.get("roadmap_major_step_index") + if major_idx is None: + return None + try: + mi = int(major_idx) + except (TypeError, ValueError): + return None + step_before, step_after = _resolve_neighbor_steps_by_major_index(steps, mi) + prior_steps = prior_path_steps_before_major(steps, mi) + gap = dict(spec.get("gap") or {}) + if not gap.get("expected_phase"): + gap["expected_phase"] = spec.get("phase") or "vertiefung" + gap["roadmap_major_step_index"] = mi + if not gap.get("learning_goal"): + gap["learning_goal"] = spec.get("title_hint") or spec.get("sketch") + + ctx = _build_stage_ai_context( + goal_query=goal_query, + brief=brief, + spec=spec, + step_before=step_before, + step_after=step_after, + prior_steps=prior_steps, + ) + try: + ai_payload = run_exercise_form_ai_suggestion(cur, ctx=ctx) + except Exception: + _logger.exception("roadmap_unfilled AI suggest failed") + return None + if not ai_payload: + return None + + summary_text = "" + summary_obj = ai_payload.get("summary") + if isinstance(summary_obj, dict): + summary_text = str(summary_obj.get("text") or "").strip() + elif isinstance(summary_obj, str): + summary_text = summary_obj.strip() + + proposal_key = f"ai-{uuid.uuid4().hex[:10]}" + title = (ctx.title or spec.get("title_hint") or "KI-Vorschlag").strip() + return { + "exercise_id": None, + "proposal_key": proposal_key, + "variant_id": None, + "title": title, + "summary": summary_text or None, + "score": None, + "semantic_score": None, + "reasons": ["KI-Neuanlage für Roadmap-Stufe ohne Bibliothekstreffer"], + "variants": [], + "is_bridge": False, + "is_ai_proposal": True, + "ai_suggestion": dict(ai_payload), + "roadmap_major_step_index": mi, + "roadmap_phase": gap.get("expected_phase"), + "roadmap_learning_goal": gap.get("learning_goal"), + } + + def _build_gap_ai_context( *, goal_query: str, @@ -175,6 +337,18 @@ def _spec_dedupe_key(spec: Mapping[str, Any]) -> Tuple[Any, ...]: ) +def _step_neighbors_at_index( + steps: Sequence[Mapping[str, Any]], + idx: int, +) -> Tuple[Optional[Mapping[str, Any]], Optional[Mapping[str, Any]]]: + """Vorheriger/nächster Pfadschritt ohne IndexError (Rand-Slots, leere Stufen).""" + if idx < 0 or idx >= len(steps): + return None, None + step_a = steps[idx - 1] if idx > 0 else None + step_b = steps[idx + 1] if idx + 1 < len(steps) else None + return step_a, step_b + + def collect_gap_fill_specs( *, steps: Sequence[Mapping[str, Any]], @@ -202,8 +376,10 @@ def collect_gap_fill_specs( int(gap["from_exercise_id"]), int(gap["to_exercise_id"]), ) - if idx is None: + if idx is None or idx + 1 >= len(steps): continue + step_a = steps[idx] + step_b = steps[idx + 1] phase = gap.get("expected_phase") or "vertiefung" add( { @@ -215,25 +391,46 @@ def collect_gap_fill_specs( "sketch": _default_sketch( goal_query=goal_query, brief=brief, - step_a=steps[idx], - step_b=steps[idx + 1], + step_a=step_a, + step_b=step_b, phase=str(phase), rationale="Bibliothek enthält keine passende Brücke.", ), - "rationale": "Lücke zwischen benachbarten Schritten — keine passende Bibliotheks-Übung.", + "rationale": "Lücke zwischen benachbaren Schritten — keine passende Bibliotheks-Übung.", } ) for ot in off_topic_steps: - idx = int(ot.get("step_index") or 0) - if idx <= 0 or idx >= len(steps) - 1: + major_idx = ot.get("roadmap_major_step_index") + idx: Optional[int] = None + if major_idx is not None: + try: + mi = int(major_idx) + except (TypeError, ValueError): + mi = None + if mi is not None: + idx = next( + ( + i + for i, s in enumerate(steps) + if s.get("roadmap_major_step_index") is not None + and int(s["roadmap_major_step_index"]) == mi + ), + None, + ) + if idx is None: + idx = int(ot.get("step_index") or 0) + if idx < 0 or idx >= len(steps): continue + step_a, step_b = _step_neighbors_at_index(steps, idx) phase = ot.get("expected_phase") or "vertiefung" + insert_after = max(idx - 1, -1) add( { "source": "off_topic", - "insert_after_index": idx - 1, + "insert_after_index": insert_after, "replace_step_index": idx, + "roadmap_major_step_index": major_idx, "gap": { "expected_phase": phase, "off_topic_title": ot.get("title"), @@ -244,8 +441,8 @@ def collect_gap_fill_specs( "sketch": _default_sketch( goal_query=goal_query, brief=brief, - step_a=steps[idx - 1], - step_b=steps[idx + 1], + step_a=step_a, + step_b=step_b, phase=str(phase), rationale=f"Ersetzt themenfremden Schritt „{ot.get('title')}“.", ), @@ -282,8 +479,16 @@ def build_gap_fill_goal_text( f"Planungsziel (gesamter Pfad): {goal_query}", f"Hauptthema: {snap.get('primary_topic') or topic}", ] - if snap.get("start_situation"): + if snap.get("entry_state"): + parts.append( + f"Eingangszustand (erreichte Voraussetzungen aus Vorstufen): {snap['entry_state']}" + ) + if snap.get("entry_state_detail") and snap.get("entry_state_detail") != snap.get("entry_state"): + parts.append(f"Bisheriger Pfad:\n{snap['entry_state_detail']}") + if snap.get("start_situation") and not snap.get("entry_state"): parts.append(f"Voraussetzung / Ausgangslage (Progression): {snap['start_situation']}") + elif snap.get("start_situation") and snap.get("prior_steps"): + parts.append(f"Ausgangsbasis des gesamten Pfads: {snap['start_situation']}") if snap.get("target_state"): parts.append(f"Gesamtziel der Progression: {snap['target_state']}") if snap.get("roadmap_notes"): @@ -291,13 +496,20 @@ def build_gap_fill_goal_text( stage_goal = snap.get("stage_learning_goal") or spec.get("title_hint") if stage_goal: parts.append(f"Lernziel dieser Roadmap-Stufe: {stage_goal}") - parts.extend( - [ - f"Entwicklungsphase dieser Übung: {snap.get('stage_phase') or phase}", - f"Erwarteter Entwicklungsbogen: {arc}", - f"Einordnung: didaktische Zwischenstufe zwischen „{from_title}“ und „{to_title}“.", - ] - ) + parts.append(f"Entwicklungsphase dieser Übung: {snap.get('stage_phase') or phase}") + parts.append(f"Erwarteter Entwicklungsbogen: {arc}") + if spec.get("source") == "roadmap_unfilled": + parts.append( + "Einordnung: Übung für diese Roadmap-Stufe — das Stufen-Lernziel steht im Vordergrund." + ) + if step_a: + parts.append(f"Vorherige Stufe: „{from_title}“") + if step_b: + parts.append(f"Nächste Stufe: „{to_title}“") + else: + parts.append( + f"Einordnung: didaktische Zwischenstufe zwischen „{from_title}“ und „{to_title}“." + ) if snap.get("stage_load_profile"): parts.append(f"Belastungsschwerpunkte: {', '.join(snap['stage_load_profile'])}") if snap.get("stage_success_criteria"): @@ -314,6 +526,17 @@ def build_gap_fill_goal_text( "Fähigkeiten-/Fokus-Hinweise: " + "; ".join(str(x) for x in snap["skill_hints"][:4]) ) + expected = snap.get("expected_skills") or [] + if expected: + names = [ + str(s.get("skill_name") or "").strip() + for s in expected[:5] + if str(s.get("skill_name") or "").strip() + ] + if names: + parts.append( + "Erwartete Fähigkeiten (Scoring): " + ", ".join(names) + ) if spec.get("rationale"): parts.append(f"Qualitätsprüfung: {spec['rationale']}") if spec.get("sketch"): @@ -335,10 +558,28 @@ def build_gap_fill_offer( proposal: Optional[Mapping[str, Any]] = None, roadmap_snapshot: Optional[Mapping[str, Any]] = None, ) -> Dict[str, Any]: + source = spec.get("source") idx = int(spec.get("insert_after_index") or 0) + major_idx = spec.get("roadmap_major_step_index") + if source == "roadmap_unfilled" and major_idx is not None: + try: + mi = int(major_idx) + except (TypeError, ValueError): + mi = idx + step_a, step_b = _resolve_neighbor_steps_by_major_index(steps, mi) + idx = mi + else: + step_a = steps[idx] if idx < len(steps) else None + step_b = steps[idx + 1] if idx + 1 < len(steps) else None offer_id = f"{spec.get('source')}-{idx}-{uuid.uuid4().hex[:8]}" - step_a = steps[idx] if idx < len(steps) else None - step_b = steps[idx + 1] if idx + 1 < len(steps) else None + enriched_snapshot = dict(roadmap_snapshot) if roadmap_snapshot else {} + major_raw = spec.get("roadmap_major_step_index") + if major_raw is not None: + enriched_snapshot = enrich_gap_snapshot_with_entry_state( + enriched_snapshot, + steps=steps, + major_step_index=major_raw, + ) goal_for_ai = "" if brief and goal_query: goal_for_ai = build_gap_fill_goal_text( @@ -347,9 +588,9 @@ def build_gap_fill_offer( spec=spec, step_a=step_a, step_b=step_b, - roadmap_snapshot=roadmap_snapshot, + roadmap_snapshot=enriched_snapshot or None, ) - ctx_preview = dict(roadmap_snapshot) if roadmap_snapshot else None + ctx_preview = enriched_snapshot or None offer: Dict[str, Any] = { "offer_id": offer_id, "source": spec.get("source"), @@ -400,6 +641,38 @@ def apply_gap_fill_after_qa( offers: List[Dict[str, Any]] = [] for spec in specs: + source = spec.get("source") + + if source == "roadmap_unfilled": + proposal: Optional[Dict[str, Any]] = None + if include_ai_calls and len(proposals) < max_ai_proposals: + proposal = try_suggest_ai_stage_step( + cur, + goal_query=goal_query, + brief=brief, + spec=spec, + steps=out, + ) + offer = build_gap_fill_offer( + spec=spec, + steps=out, + goal_query=goal_query, + brief=brief, + proposal=proposal, + roadmap_snapshot=roadmap_snapshot, + ) + offers.append(offer) + if proposal and auto_insert_proposals: + proposals.append( + { + "roadmap_major_step_index": spec.get("roadmap_major_step_index"), + "proposal_key": proposal.get("proposal_key"), + "proposal_title": proposal.get("title"), + "offer_id": offer.get("offer_id"), + } + ) + continue + idx = int(spec.get("insert_after_index") or 0) if idx < 0 or idx >= len(out) - 1: continue @@ -421,7 +694,7 @@ def apply_gap_fill_after_qa( if not gap.get("expected_phase"): gap["expected_phase"] = spec.get("phase") or "vertiefung" - proposal: Optional[Dict[str, Any]] = None + proposal = None if include_ai_calls and len(proposals) < max_ai_proposals: proposal = try_suggest_ai_bridge_step( cur, @@ -497,4 +770,5 @@ __all__ = [ "collect_gap_fill_specs", "insert_ai_proposals_for_gaps", "try_suggest_ai_bridge_step", + "try_suggest_ai_stage_step", ] diff --git a/backend/planning_exercise_path_builder.py b/backend/planning_exercise_path_builder.py index efef5cf..58c08fe 100644 --- a/backend/planning_exercise_path_builder.py +++ b/backend/planning_exercise_path_builder.py @@ -6,13 +6,24 @@ planning_progression_roadmap.py und PLANNING_PROGRESSION_ROADMAP_SPEC.md. """ from __future__ import annotations -from typing import Any, Callable, Dict, List, Mapping, Optional, Set, Tuple +from typing import Any, Callable, Dict, List, Mapping, Optional, Sequence, Set, Tuple from fastapi import HTTPException from pydantic import BaseModel, Field -from tenant_context import TenantContext, library_content_visibility_sql +from tenant_context import ( + TenantContext, + library_content_visibility_for_progression_graph_sql, + library_content_visibility_sql, +) 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, + prune_stripped_after_rematch, + rematch_roadmap_slots, +) +from planning_stage_context import build_contextualized_stage_goal, resolve_path_start_target from planning_exercise_path_qa import ( apply_llm_path_reorder, build_path_qa_summary, @@ -32,9 +43,14 @@ from planning_exercise_retrieval import run_multistage_planning_retrieval from planning_exercise_semantics import ( PlanningSemanticBrief, apply_path_retrieval_weights, + apply_stage_match_retrieval_weights, brief_to_summary_dict, build_semantic_brief, + build_stage_match_brief, + enrich_brief_with_path_constraints, enrich_target_with_semantic_expectations, + resolve_path_anti_patterns, + resolve_path_primary_topic, exercise_passes_path_semantic_gate, pick_best_path_hit, resolve_semantic_skill_weights, @@ -51,6 +67,12 @@ from planning_exercise_suggest import ( resolve_planning_exercise_intent, ) from planning_exercise_form_context import build_progression_gap_snapshot +from planning_skill_expectations import ( + apply_expectations_to_target, + build_planning_skill_expectations, + expectation_input_from_progression_path, + expectation_input_from_progression_stage, +) from planning_progression_roadmap import ( MajorStep, ProgressionRoadmapContext, @@ -68,11 +90,25 @@ from planning_progression_roadmap import ( from routers.training_planning import _has_planning_role +class EvaluateStepPayload(BaseModel): + exercise_id: Optional[int] = Field(default=None, ge=1) + variant_id: Optional[int] = Field(default=None, ge=1) + title: Optional[str] = Field(default=None, max_length=500) + is_ai_proposal: bool = False + ai_suggestion: Optional[Dict[str, Any]] = None + proposal_key: Optional[str] = Field(default=None, max_length=120) + roadmap_major_step_index: Optional[int] = Field(default=None, ge=0, le=20) + roadmap_phase: Optional[str] = Field(default=None, max_length=80) + roadmap_learning_goal: Optional[str] = Field(default=None, max_length=2000) + + class ProgressionPathSuggestRequest(BaseModel): query: str = Field(..., min_length=3, max_length=2000) max_steps: int = Field(default=5, ge=2, le=10) include_llm_intent: bool = True include_path_qa: bool = True + auto_rematch_after_qa: bool = True + max_rematch_rounds: int = Field(default=2, ge=0, le=3) include_llm_path_qa: bool = True include_path_reorder: bool = True include_ai_gap_fill: bool = True @@ -82,6 +118,11 @@ class ProgressionPathSuggestRequest(BaseModel): roadmap_first: bool = False roadmap_only: bool = False start_target_only: bool = False + evaluate_only: bool = False + evaluate_steps: Optional[List[EvaluateStepPayload]] = None + slot_assignments: Optional[List[EvaluateStepPayload]] = None + preserve_slot_assignments: bool = False + retrieval_boost_exercise_ids: Optional[List[int]] = None roadmap_override: Optional[RoadmapOverridePayload] = None start_situation: Optional[str] = Field(default=None, max_length=2000) target_state: Optional[str] = Field(default=None, max_length=2000) @@ -91,14 +132,17 @@ class ProgressionPathSuggestRequest(BaseModel): def _roadmap_gap_snapshot_for_spec( + cur, roadmap_ctx: Optional[ProgressionRoadmapContext], spec: Mapping[str, Any], *, + goal_query: str, semantic_brief: PlanningSemanticBrief, ) -> Dict[str, Any]: - """Roadmap-Kontext für KI-Lücken-Übung (Start, Ziel, Stufenspec).""" + """Roadmap-Kontext für KI-Lücken-Übung (Start, Ziel, Stufenspec, Fähigkeiten).""" major_idx = spec.get("roadmap_major_step_index") stage_spec_dict: Optional[Dict[str, Any]] = None + major_dict: Optional[Dict[str, Any]] = None if roadmap_ctx and major_idx is not None: for s in roadmap_ctx.stage_specs or []: if int(s.major_step_index) == int(major_idx): @@ -107,6 +151,7 @@ def _roadmap_gap_snapshot_for_spec( for m in roadmap_ctx.roadmap.major_steps: if m.index == int(major_idx): stage_spec_dict["phase"] = m.phase + major_dict = m.model_dump() break break ga = roadmap_ctx.goal_analysis.model_dump() if roadmap_ctx and roadmap_ctx.goal_analysis else None @@ -120,12 +165,25 @@ def _roadmap_gap_snapshot_for_spec( if roadmap_ctx and roadmap_ctx.semantic_brief else brief_to_summary_dict(semantic_brief) ) - return build_progression_gap_snapshot( + snap = build_progression_gap_snapshot( goal_analysis=ga, resolved_structured=rs, stage_spec=stage_spec_dict, semantic_brief=brief_summary, ) + inp = expectation_input_from_progression_stage( + goal_query=goal_query, + goal_analysis=ga, + resolved_structured=rs, + stage_spec=stage_spec_dict, + semantic_brief_summary=brief_summary, + major_step=major_dict, + ) + exp = build_planning_skill_expectations(cur, inp, semantic_brief=semantic_brief) + if exp.items: + snap["expected_skills"] = exp.to_api_dict()["expected_skills"] + snap["skill_expectation_sources"] = exp.sources + return snap def _roadmap_structured_from_body(body: ProgressionPathSuggestRequest) -> Optional[RoadmapStructuredInput]: @@ -146,8 +204,24 @@ def _pick_best_path_hit( used_exercise_ids: Set[int], *, semantic_brief: Optional[PlanningSemanticBrief] = None, + stage_learning_goal: Optional[str] = None, + stage_anti_patterns: Optional[List[str]] = None, + roadmap_stage_match: bool = False, + stage_match_brief: Optional[PlanningSemanticBrief] = None, + path_primary_topic: Optional[str] = None, + path_technique_excludes: Optional[List[str]] = None, ) -> Optional[Dict[str, Any]]: - return pick_best_path_hit(hits, used_exercise_ids, semantic_brief=semantic_brief) + return pick_best_path_hit( + hits, + used_exercise_ids, + semantic_brief=semantic_brief, + stage_learning_goal=stage_learning_goal, + stage_anti_patterns=stage_anti_patterns, + roadmap_stage_match=roadmap_stage_match, + stage_match_brief=stage_match_brief, + path_primary_topic=path_primary_topic, + path_technique_excludes=path_technique_excludes, + ) def _build_path_target_profile( @@ -196,6 +270,344 @@ def _build_path_target_profile( return target, query_intent_summary, intent +def _graph_edge_exercise_ids(cur, graph_id: Optional[int]) -> List[int]: + """Übungs-IDs aus gespeicherten Graph-Kanten (für Re-Match-Boost).""" + if not graph_id or int(graph_id) < 1: + return [] + cur.execute( + """ + SELECT from_exercise_id AS eid FROM exercise_progression_edges + WHERE graph_id = %s AND from_exercise_id IS NOT NULL + UNION + SELECT to_exercise_id AS eid FROM exercise_progression_edges + WHERE graph_id = %s AND to_exercise_id IS NOT NULL + """, + (int(graph_id), int(graph_id)), + ) + out: List[int] = [] + for row in cur.fetchall() or []: + try: + eid = int(row.get("eid") or 0) + except (TypeError, ValueError): + continue + if eid > 0: + out.append(eid) + return out + + +def _supplemental_exercise_ids_from_body( + cur, + body: ProgressionPathSuggestRequest, +) -> List[int]: + """Kandidatenpool erweitern (Graph-Kanten, Boost, Slot-Zuordnungen).""" + ids: List[int] = [] + for raw in body.evaluate_steps or []: + if raw.exercise_id is not None: + try: + eid = int(raw.exercise_id) + except (TypeError, ValueError): + continue + if eid > 0: + ids.append(eid) + for raw in body.slot_assignments or []: + if raw.exercise_id is not None: + try: + eid = int(raw.exercise_id) + except (TypeError, ValueError): + continue + if eid > 0: + ids.append(eid) + for eid in body.retrieval_boost_exercise_ids or []: + try: + val = int(eid) + except (TypeError, ValueError): + continue + if val > 0: + ids.append(val) + ids.extend(_graph_edge_exercise_ids(cur, body.progression_graph_id)) + return list(dict.fromkeys(ids)) + + +def _graph_visibility_context( + cur, + progression_graph_id: Optional[int], +) -> Tuple[str, Optional[int]]: + if not progression_graph_id or int(progression_graph_id) < 1: + return "private", None + cur.execute( + "SELECT visibility, club_id FROM exercise_progression_graphs WHERE id = %s", + (int(progression_graph_id),), + ) + row = cur.fetchone() + if not row: + return "private", None + g_club = row.get("club_id") + return ( + str(row.get("visibility") or "private"), + int(g_club) if g_club is not None else None, + ) + + +def _safe_tsquery_fragment(text: str) -> str: + import re + + cleaned = re.sub(r"[^\w\säöüßÄÖÜ]", " ", text or "", flags=re.UNICODE) + words = [w for w in cleaned.split() if len(w) >= 2][:10] + return " ".join(words) if words else (text or "")[:60].strip() + + +def _fetch_learning_goal_library_candidate_ids( + cur, + *, + tenant: TenantContext, + progression_graph_id: Optional[int], + learning_goal: str, + limit: int = 24, +) -> List[int]: + """Sichtbare Übungen, deren Titel/Volltext zum Stufen-Lernziel passt.""" + lg = (learning_goal or "").strip() + if len(lg) < 3: + return [] + vis_sql, vis_params = _planning_visibility_sql(cur, tenant, progression_graph_id) + tsq = _safe_tsquery_fragment(lg) + like_pat = f"%{lg[:100].lower()}%" + try: + cur.execute( + f""" + SELECT e.id + FROM exercises e + WHERE ({vis_sql}) + AND COALESCE(e.status, '') <> %s + AND ( + lower(trim(e.title)) = lower(trim(%s)) + OR lower(e.title) LIKE %s + OR (%s <> '' AND e.search_vector @@ plainto_tsquery('german', %s)) + ) + ORDER BY + CASE WHEN lower(trim(e.title)) = lower(trim(%s)) THEN 0 ELSE 1 END, + CASE WHEN %s <> '' THEN ts_rank_cd(e.search_vector, plainto_tsquery('german', %s)) ELSE 0 END DESC, + e.id ASC + LIMIT %s + """, + [ + *vis_params, + "archived", + lg, + like_pat, + tsq, + tsq, + lg, + tsq, + tsq, + int(limit), + ], + ) + except Exception: + cur.execute( + f""" + SELECT e.id + FROM exercises e + WHERE ({vis_sql}) + AND COALESCE(e.status, '') <> %s + AND ( + lower(trim(e.title)) = lower(trim(%s)) + OR lower(e.title) LIKE %s + ) + ORDER BY CASE WHEN lower(trim(e.title)) = lower(trim(%s)) THEN 0 ELSE 1 END, e.id ASC + LIMIT %s + """, + [*vis_params, "archived", lg, like_pat, lg, int(limit)], + ) + out: List[int] = [] + for row in cur.fetchall() or []: + try: + eid = int(row.get("id") or 0) + except (TypeError, ValueError): + continue + if eid > 0: + out.append(eid) + return out + + +def _load_supplemental_exercise_rows( + cur, + *, + tenant: TenantContext, + progression_graph_id: Optional[int], + exercise_ids: Optional[Sequence[int]], + vis_sql: str, + vis_params: Sequence[Any], +) -> List[Dict[str, Any]]: + """Supplemental-Übungen mit Graph-Sichtbarkeit, Fallback Library-vis_sql.""" + ids: List[int] = [] + for raw in exercise_ids or []: + if raw is None: + continue + try: + eid = int(raw) + except (TypeError, ValueError): + continue + if eid > 0: + ids.append(eid) + ids = list(dict.fromkeys(ids)) + if not ids: + return [] + if progression_graph_id and int(progression_graph_id) > 0: + from planning_exercise_retrieval import fetch_exercise_rows_by_ids_for_graph + + gvis, gclub = _graph_visibility_context(cur, progression_graph_id) + graph_rows = fetch_exercise_rows_by_ids_for_graph( + cur, + ids, + graph_visibility=gvis, + graph_club_id=gclub, + profile_id=tenant.profile_id, + role=tenant.global_role, + exercise_allowed_fn=_exercise_allowed_in_progression_graph, + ) + if graph_rows: + return graph_rows + from planning_exercise_retrieval import fetch_exercise_rows_by_ids + + return fetch_exercise_rows_by_ids( + cur, + ids, + vis_sql=vis_sql, + vis_params=vis_params, + ) + + +def _planning_visibility_sql( + cur, + tenant: TenantContext, + progression_graph_id: Optional[int], +) -> Tuple[str, List[Any]]: + if progression_graph_id and int(progression_graph_id) > 0: + cur.execute( + "SELECT visibility, club_id FROM exercise_progression_graphs WHERE id = %s", + (int(progression_graph_id),), + ) + grow = cur.fetchone() + if grow: + g_club = grow.get("club_id") + return library_content_visibility_for_progression_graph_sql( + alias="e", + profile_id=tenant.profile_id, + role=tenant.global_role, + effective_club_id=tenant.effective_club_id, + graph_visibility=str(grow.get("visibility") or "private"), + graph_club_id=int(g_club) if g_club is not None else None, + ) + return library_content_visibility_sql( + alias="e", + profile_id=tenant.profile_id, + role=tenant.global_role, + effective_club_id=tenant.effective_club_id, + ) + + +def _exercise_allowed_in_progression_graph( + exercise_row: Mapping[str, Any], + *, + graph_visibility: str, + graph_club_id: Optional[int], + profile_id: int, + role: str, +) -> bool: + """Prüft, ob eine Übung zur Sichtbarkeit des Progressionsgraphen passt.""" + from club_tenancy import is_platform_admin + + ex_vis = (exercise_row.get("visibility") or "private").strip().lower() + gvis = (graph_visibility or "private").strip().lower() + if gvis == "private": + if ex_vis == "official": + return True + if ex_vis == "club": + return True + if ex_vis == "private": + if is_platform_admin(role): + return True + try: + return int(exercise_row.get("created_by") or 0) == int(profile_id) + except (TypeError, ValueError): + return False + return False + if gvis == "club": + if ex_vis == "official": + return True + if ex_vis != "club": + return False + ex_club = exercise_row.get("club_id") + if ex_club is None: + return False + if graph_club_id is None: + return True + return int(ex_club) == int(graph_club_id) + return ex_vis == "official" + + +def _slot_assignments_by_major_index( + assignments: Optional[List[EvaluateStepPayload]], +) -> Dict[int, EvaluateStepPayload]: + out: Dict[int, EvaluateStepPayload] = {} + for raw in assignments or []: + if raw.exercise_id is None or raw.roadmap_major_step_index is None: + continue + out[int(raw.roadmap_major_step_index)] = raw + return out + + +def _path_step_from_slot_assignment( + cur, + *, + assignment: EvaluateStepPayload, + stage_spec: StageSpecArtifact, + major_step: Optional[MajorStep], + tenant: Optional[TenantContext] = None, + progression_graph_id: Optional[int] = None, +) -> Optional[Dict[str, Any]]: + """Bestehende Slot-Zuordnung aus dem Graph-Editor (nach KI-Anlage) übernehmen.""" + eid = int(assignment.exercise_id) + cur.execute( + "SELECT id, title, summary, visibility, club_id, created_by FROM exercises WHERE id = %s", + (eid,), + ) + row = cur.fetchone() + if not row: + return None + if tenant and progression_graph_id: + cur.execute( + "SELECT visibility, club_id FROM exercise_progression_graphs WHERE id = %s", + (int(progression_graph_id),), + ) + grow = cur.fetchone() + if grow and not _exercise_allowed_in_progression_graph( + row, + graph_visibility=str(grow.get("visibility") or "private"), + graph_club_id=int(grow["club_id"]) if grow.get("club_id") is not None else None, + profile_id=tenant.profile_id, + role=tenant.global_role, + ): + return None + title = (assignment.title or row.get("title") or "").strip() or str(row.get("title") or "") + step = { + "exercise_id": eid, + "variant_id": assignment.variant_id, + "title": title, + "summary": row.get("summary"), + "score": None, + "semantic_score": None, + "reasons": ["Bestehende Slot-Zuordnung (Graph-Editor)"], + "variants": [], + "slot_assignment": True, + } + return _annotate_roadmap_step( + step, + stage_spec=stage_spec, + major_step=major_step, + ) + + def _hit_to_path_step(hit: Dict[str, Any], *, is_bridge: bool = False) -> Dict[str, Any]: raw_vid = hit.get("suggested_variant_id") variant_id: Optional[int] = None @@ -244,6 +656,17 @@ def _run_path_step_retrieval( path_intent: Optional[str] = None, step_query_override: Optional[str] = None, step_phase_override: Optional[str] = None, + step_target_profile_override: Optional[PlanningTargetProfile] = None, + stage_learning_goal: Optional[str] = None, + stage_anti_patterns: Optional[List[str]] = None, + stage_match_brief: Optional[PlanningSemanticBrief] = None, + stage_success_criteria: Optional[List[str]] = None, + stage_load_profile: Optional[List[str]] = None, + path_context_note: Optional[str] = None, + path_primary_topic: Optional[str] = None, + path_technique_excludes: Optional[List[str]] = None, + supplemental_exercise_ids: Optional[List[int]] = None, + priority_exercise_ids: Optional[List[int]] = None, ) -> Tuple[List[Dict[str, Any]], PlanningTargetProfile, Dict[str, Any], str]: step_query = step_query_override or step_retrieval_query( semantic_brief, goal_query, step_index, max_steps @@ -279,6 +702,15 @@ def _run_path_step_retrieval( "retrieval_query": step_query, "path_step_phase": step_phase_override or step_phase_for_index(semantic_brief, step_index, max_steps), + "stage_learning_goal": (stage_learning_goal or "").strip() or None, + "stage_anti_patterns": list(stage_anti_patterns or []), + "roadmap_stage_match": bool((stage_learning_goal or "").strip()), + "stage_match_brief": stage_match_brief, + "stage_success_criteria": list(stage_success_criteria or []), + "stage_load_profile": list(stage_load_profile or []), + "path_context_note": (path_context_note or "").strip() or None, + "path_primary_topic": (path_primary_topic or "").strip() or None, + "path_technique_excludes": list(path_technique_excludes or []), } pack = apply_progression_context_to_pack( cur, @@ -311,7 +743,11 @@ def _run_path_step_retrieval( "expectation_mode": "query_only" if step_index == 0 and not planned_ids else "planning_hybrid", } - if path_target_profile is not None: + if step_target_profile_override is not None: + target_profile = step_target_profile_override + intent = path_intent or resolve_planning_exercise_intent(goal_query, "free_search") + query_intent_summary = {} + elif path_target_profile is not None: target_profile = path_target_profile intent = path_intent or resolve_planning_exercise_intent(goal_query, "free_search") query_intent_summary = {} @@ -329,17 +765,25 @@ def _run_path_step_retrieval( has_planning_reference=has_plan_ref, ) - weights = apply_path_retrieval_weights(semantic_brief) + if pack.get("roadmap_stage_match"): + weights = apply_stage_match_retrieval_weights(semantic_brief) + else: + weights = apply_path_retrieval_weights(semantic_brief) - profile_id = tenant.profile_id - role = tenant.global_role - vis_sql, vis_params = library_content_visibility_sql( - alias="e", - profile_id=profile_id, - role=role, - effective_club_id=tenant.effective_club_id, + vis_sql, vis_params = _planning_visibility_sql( + cur, + tenant, + progression_graph_id, ) + supplemental_rows = _load_supplemental_exercise_rows( + cur, + tenant=tenant, + progression_graph_id=progression_graph_id, + exercise_ids=supplemental_exercise_ids, + vis_sql=vis_sql, + vis_params=vis_params, + ) hits, _skills_by_ex, _full_lib = run_multistage_planning_retrieval( cur, vis_sql=vis_sql, @@ -350,8 +794,19 @@ def _run_path_step_retrieval( intent=intent, intent_weights=weights, pack=pack, + supplemental_rows_preloaded=supplemental_rows, ) - hits = _enrich_planning_hits_with_variant_meta(cur, hits[:32]) + from planning_exercise_retrieval import trim_hits_preserving_priority_ids + + priority_ids = list( + dict.fromkeys( + int(x) + for x in (priority_exercise_ids or supplemental_exercise_ids or []) + if int(x) > 0 + ) + ) + hits = trim_hits_preserving_priority_ids(hits, priority_ids, limit=48) + hits = _enrich_planning_hits_with_variant_meta(cur, hits) return hits, target_profile, query_intent_summary, intent @@ -368,6 +823,7 @@ def _make_bridge_search_fn( planned_ids: List[int], path_target_profile: PlanningTargetProfile, path_intent: str, + supplemental_exercise_ids: Optional[List[int]] = None, ) -> Callable[..., List[Dict[str, Any]]]: def _bridge_search( step_a: Dict[str, Any], @@ -391,6 +847,7 @@ def _make_bridge_search_fn( step_a=step_a, step_b=step_b, path_target_profile=path_target_profile, + supplemental_exercise_ids=supplemental_exercise_ids, path_intent=path_intent, ) gated = [ @@ -414,6 +871,8 @@ def _annotate_roadmap_step( *, stage_spec: StageSpecArtifact, major_step: Optional[MajorStep], + skill_expectations: Optional[Dict[str, Any]] = None, + anti_patterns_override: Optional[List[str]] = None, ) -> Dict[str, Any]: reasons = list(step.get("reasons") or []) learning_goal = (stage_spec.learning_goal or "").strip() @@ -421,14 +880,487 @@ def _annotate_roadmap_step( roadmap_reason = f"Roadmap: {learning_goal[:120]}" if roadmap_reason not in reasons: reasons.insert(0, roadmap_reason) + if skill_expectations and skill_expectations.get("expected_skills"): + names = [ + str(s.get("skill_name") or "").strip() + for s in skill_expectations["expected_skills"][:3] + if str(s.get("skill_name") or "").strip() + ] + if names: + skill_reason = f"Fähigkeiten: {', '.join(names)}" + if skill_reason not in reasons: + reasons.append(skill_reason) step["reasons"] = reasons[:4] step["roadmap_major_step_index"] = stage_spec.major_step_index step["roadmap_phase"] = major_step.phase if major_step else None step["roadmap_learning_goal"] = learning_goal or None - step["roadmap_match_source"] = "stage_spec" + anti = list(anti_patterns_override or stage_spec.anti_patterns or []) + if anti: + step["roadmap_anti_patterns"] = anti + if (stage_spec.start_state or "").strip(): + step["roadmap_start_state"] = stage_spec.start_state.strip() + if (stage_spec.target_state or "").strip(): + step["roadmap_target_state"] = stage_spec.target_state.strip() + if stage_spec.success_criteria: + step["success_criteria"] = list(stage_spec.success_criteria) + step["stage_success_criteria"] = list(stage_spec.success_criteria) + if not step.get("roadmap_match_source"): + step["roadmap_match_source"] = "stage_spec" + if step.get("exercise_id") is not None: + step["slot_status"] = step.get("slot_status") or ( + "preserved" if step.get("roadmap_match_source") == "slot_best_match" else "matched" + ) + else: + step["slot_status"] = step.get("slot_status") or "unfilled" + if skill_expectations: + step["skill_expectations"] = skill_expectations return step +def _stage_validation_context_for_spec( + cur, + *, + body: ProgressionPathSuggestRequest, + goal_query: str, + semantic_brief: PlanningSemanticBrief, + path_target_profile: PlanningTargetProfile, + roadmap_ctx: ProgressionRoadmapContext, + stage_spec: StageSpecArtifact, + step_index: int, + stage_count: int, + major: Optional[MajorStep], +) -> Dict[str, Any]: + """Gemeinsamer Kontext für Reconcile + Match eines Roadmap-Slots.""" + ga_dump = ( + roadmap_ctx.goal_analysis.model_dump() if roadmap_ctx.goal_analysis else None + ) + rs_dump = ( + roadmap_ctx.resolved_structured.model_dump() + if roadmap_ctx.resolved_structured + else None + ) + path_start, path_target = resolve_path_start_target( + structured=roadmap_ctx.resolved_structured, + goal_analysis=roadmap_ctx.goal_analysis, + ) + stage_goal = (stage_spec.learning_goal or "").strip() + stage_start = (stage_spec.start_state or "").strip() + stage_target = (stage_spec.target_state or "").strip() + contextual_goal = build_contextualized_stage_goal( + learning_goal=stage_goal, + start_state=stage_start, + target_state=stage_target, + path_target_state=path_target, + path_start_state=path_start, + stage_index=step_index, + stage_count=stage_count, + ) + path_context_note = None + if rs_dump: + ctx_parts = [ + str(rs_dump.get("start_situation") or "").strip()[:120], + str(rs_dump.get("target_state") or "").strip()[:120], + str(rs_dump.get("roadmap_notes") or "").strip()[:120], + ] + path_context_note = " ".join(p for p in ctx_parts if p)[:240] or None + path_anti = resolve_path_anti_patterns( + goal_query, + semantic_brief=semantic_brief, + extra_context=path_context_note, + ) + stage_anti = list(dict.fromkeys([*(stage_spec.anti_patterns or []), *path_anti])) + path_primary = ( + resolve_path_primary_topic( + goal_query, + semantic_brief, + stage_learning_goal=stage_goal, + extra_context=path_context_note, + ) + or "" + ).strip() + path_tech_excludes = list(semantic_brief.exclude_phrases or []) + if path_primary: + from planning_exercise_semantics import technique_sibling_excludes + + for item in technique_sibling_excludes(path_primary): + if item not in path_tech_excludes: + path_tech_excludes.append(item) + stage_match_brief = build_stage_match_brief( + learning_goal=stage_goal, + anti_patterns=stage_anti, + success_criteria=list(stage_spec.success_criteria or []), + load_profile=list(stage_spec.load_profile or []), + phase=major.phase if major else None, + path_context_note=path_context_note, + path_anti_patterns=path_anti, + path_primary_topic=path_primary or None, + path_technique_excludes=path_tech_excludes or None, + stage_start_state=stage_start or None, + stage_target_state=stage_target or None, + path_target_state=path_target or None, + contextualized_learning_goal=contextual_goal or None, + ) + return { + "stage_goal": stage_goal, + "stage_anti": stage_anti, + "path_primary": path_primary, + "path_tech_excludes": path_tech_excludes, + "stage_match_brief": stage_match_brief, + "path_context_note": path_context_note, + "path_anti": path_anti, + "path_start": path_start, + "path_target": path_target, + "ga_dump": ga_dump, + "rs_dump": rs_dump, + } + + +def _match_roadmap_slot( + 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, + planned_ids: List[int], + anchor_id: Optional[int], + anchor_variant_id: Optional[int], + used: Set[int], + slot_priority_exercise_id: Optional[int] = None, +) -> Tuple[Optional[Dict[str, Any]], Optional[StageSpecArtifact]]: + """Einzelnen Roadmap-Slot matchen (Initial-Build und Auto-Rematch).""" + major_by_index: Dict[int, MajorStep] = {} + if roadmap_ctx.roadmap: + major_by_index = {m.index: m for m in roadmap_ctx.roadmap.major_steps} + major = major_by_index.get(stage_spec.major_step_index) + + ctx = _stage_validation_context_for_spec( + cur, + body=body, + goal_query=goal_query, + semantic_brief=semantic_brief, + path_target_profile=path_target_profile, + roadmap_ctx=roadmap_ctx, + stage_spec=stage_spec, + step_index=step_index, + stage_count=stage_count, + major=major, + ) + stage_goal = ctx["stage_goal"] + stage_anti = ctx["stage_anti"] + path_primary = ctx["path_primary"] + path_tech_excludes = ctx["path_tech_excludes"] + stage_match_brief = ctx["stage_match_brief"] + path_context_note = ctx["path_context_note"] + ga_dump = ctx["ga_dump"] + rs_dump = ctx["rs_dump"] + + brief_summary = ( + roadmap_ctx.semantic_brief + if roadmap_ctx.semantic_brief + else brief_to_summary_dict(semantic_brief) + ) + + stage_spec_dict = stage_spec.model_dump() + if major: + stage_spec_dict["phase"] = major.phase + stage_inp = expectation_input_from_progression_stage( + goal_query=goal_query, + goal_analysis=ga_dump, + resolved_structured=rs_dump, + stage_spec=stage_spec_dict, + semantic_brief_summary=brief_summary, + major_step=major.model_dump() if major else None, + ) + stage_exp = build_planning_skill_expectations(cur, stage_inp, semantic_brief=semantic_brief) + step_target = apply_expectations_to_target(path_target_profile, stage_exp) + skill_exp_api = stage_exp.to_api_dict() if stage_exp.items else None + + step_query = stage_spec_retrieval_query( + semantic_brief=semantic_brief, + goal_query=goal_query, + stage_spec=stage_spec, + major_step=major, + ) + step_kind = resolve_step_exercise_kind_filter(stage_spec, body.exercise_kind_any) + + supplemental_ids = _supplemental_exercise_ids_from_body(cur, body) + lg_candidates = _fetch_learning_goal_library_candidate_ids( + cur, + tenant=tenant, + progression_graph_id=body.progression_graph_id, + learning_goal=stage_goal, + ) + supplemental_ids = list( + dict.fromkeys( + int(x) + for x in [ + *supplemental_ids, + *lg_candidates, + slot_priority_exercise_id, + ] + if x is not None and int(x) > 0 + ) + ) + priority_ids = list( + dict.fromkeys( + int(x) + for x in [ + slot_priority_exercise_id, + *(body.retrieval_boost_exercise_ids or []), + *lg_candidates[:8], + ] + if x is not None and int(x) > 0 + ) + ) + + hits, _, _, _ = _run_path_step_retrieval( + cur, + tenant=tenant, + goal_query=goal_query, + step_index=step_index, + max_steps=max_steps, + planned_ids=planned_ids, + anchor_id=anchor_id, + anchor_variant_id=anchor_variant_id, + progression_graph_id=body.progression_graph_id, + include_llm_intent=body.include_llm_intent and step_index == 0, + exercise_kind_any=step_kind, + semantic_brief=stage_match_brief, + path_target_profile=path_target_profile, + path_intent=path_intent, + step_query_override=step_query, + step_phase_override=major.phase if major else None, + step_target_profile_override=step_target, + stage_learning_goal=stage_goal or None, + stage_anti_patterns=stage_anti or None, + stage_match_brief=stage_match_brief, + stage_success_criteria=list(stage_spec.success_criteria or []), + stage_load_profile=list(stage_spec.load_profile or []), + path_context_note=path_context_note, + path_primary_topic=path_primary or None, + path_technique_excludes=path_tech_excludes or None, + supplemental_exercise_ids=supplemental_ids, + priority_exercise_ids=priority_ids, + ) + + hit = _pick_best_path_hit( + hits, + used, + semantic_brief=stage_match_brief, + stage_learning_goal=stage_goal or None, + stage_anti_patterns=stage_anti or None, + roadmap_stage_match=True, + stage_match_brief=stage_match_brief, + path_primary_topic=path_primary or None, + path_technique_excludes=path_tech_excludes or None, + ) + + if not hit: + return None, stage_spec + + step = _annotate_roadmap_step( + _hit_to_path_step(hit), + stage_spec=stage_spec, + major_step=major, + skill_expectations=skill_exp_api, + anti_patterns_override=stage_anti, + ) + if ( + slot_priority_exercise_id is not None + and int(step["exercise_id"]) == int(slot_priority_exercise_id) + ): + step["slot_status"] = "preserved" + step["roadmap_match_source"] = "slot_best_match" + step["reasons"] = ["Bester Treffer (bestehende Zuordnung)"] + list(step.get("reasons") or [])[:2] + else: + step["slot_status"] = "matched" + step["roadmap_match_source"] = "stage_spec" + return step, None + + +def _normalize_roadmap_steps_coverage( + steps: List[Dict[str, Any]], + *, + roadmap_ctx: ProgressionRoadmapContext, + max_steps: int, +) -> List[Dict[str, Any]]: + """Ein Eintrag pro Roadmap-Major-Step — fehlende Slots als leere Platzhalter.""" + stage_specs = list(roadmap_ctx.stage_specs or [])[:max_steps] + if not stage_specs: + return steps + + major_by_index: Dict[int, MajorStep] = {} + if roadmap_ctx.roadmap: + major_by_index = {m.index: m for m in roadmap_ctx.roadmap.major_steps} + + by_major: Dict[int, Dict[str, Any]] = {} + for raw in steps: + step = dict(raw) + midx = step.get("roadmap_major_step_index") + if midx is not None: + by_major[int(midx)] = step + + out: List[Dict[str, Any]] = [] + for spec in sorted(stage_specs, key=lambda s: s.major_step_index): + midx = int(spec.major_step_index) + if midx in by_major: + out.append(by_major[midx]) + continue + major = major_by_index.get(midx) + goal = (spec.learning_goal or "").strip() + out.append( + { + "exercise_id": None, + "variant_id": None, + "title": goal or f"Slot {midx + 1}", + "is_ai_proposal": False, + "roadmap_major_step_index": midx, + "roadmap_phase": major.phase if major else None, + "roadmap_learning_goal": goal or None, + "roadmap_match_source": "unfilled", + "slot_status": "unfilled", + "reasons": [], + } + ) + return out + + +def _merge_rematch_unfilled( + roadmap_unfilled: List[Tuple[int, StageSpecArtifact]], + rematch_new_unfilled: List[Tuple[int, StageSpecArtifact]], +) -> List[Tuple[int, StageSpecArtifact]]: + if not rematch_new_unfilled: + return roadmap_unfilled + remapped = {sp.major_step_index for _, sp in rematch_new_unfilled} + kept = [item for item in roadmap_unfilled if item[1].major_step_index not in remapped] + kept.extend(rematch_new_unfilled) + return kept + + +def _run_roadmap_rematch_loop( + cur, + *, + tenant: TenantContext, + body: ProgressionPathSuggestRequest, + goal_query: str, + max_steps: int, + semantic_brief: PlanningSemanticBrief, + path_target_profile: PlanningTargetProfile, + path_intent: str, + roadmap_ctx: ProgressionRoadmapContext, + steps: List[Dict[str, Any]], + stripped_off_topic: List[Dict[str, Any]], + off_topic_before_strip: List[Dict[str, Any]], + roadmap_unfilled: List[Tuple[int, StageSpecArtifact]], + gaps: List[Dict[str, Any]], +) -> Tuple[ + List[Dict[str, Any]], + List[Dict[str, Any]], + List[Dict[str, Any]], + List[Dict[str, Any]], + int, + List[Tuple[int, StageSpecArtifact]], +]: + """Phase A/B: Rematch-Schleife aus Strip, unfilled Slots und optimization_hints.""" + rematch_log: List[Dict[str, Any]] = [] + rematch_rounds = 0 + max_rounds = int(body.max_rematch_rounds or 0) + if not body.auto_rematch_after_qa or max_rounds <= 0 or not roadmap_ctx.stage_specs: + off_topic_steps = detect_off_topic_steps( + cur, + steps, + brief=semantic_brief, + goal_query=goal_query, + ) + return steps, rematch_log, stripped_off_topic, off_topic_steps, rematch_rounds, roadmap_unfilled + + current_stripped = list(stripped_off_topic or []) + use_initial_off_topic = not current_stripped + off_topic_steps: List[Dict[str, Any]] = [] + + for round_idx in range(max_rounds): + mini_qa = run_multistage_path_qa( + off_topic_steps=off_topic_steps if round_idx > 0 else [], + stripped_off_topic=current_stripped if round_idx == 0 else [], + gaps=gaps if round_idx == 0 else [], + llm_qa=None, + llm_applied=False, + roadmap_unfilled=roadmap_unfilled, + ) + optimization_hints = list(mini_qa.get("optimization_hints") or []) + + slot_indices, rematch_reasons = collect_rematch_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 [], + optimization_hints=optimization_hints, + stage_specs=roadmap_ctx.stage_specs, + roadmap_unfilled=roadmap_unfilled, + ) + if not slot_indices: + break + + steps, round_log, rematch_new_unfilled = rematch_roadmap_slots( + 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, + steps=steps, + slot_indices=slot_indices, + rematch_reasons=rematch_reasons, + match_slot_fn=_match_roadmap_slot, + ) + rematch_rounds += 1 + for entry in round_log: + tagged = dict(entry) + tagged["round"] = rematch_rounds + rematch_log.append(tagged) + + current_stripped = prune_stripped_after_rematch(current_stripped, round_log) + roadmap_unfilled = _merge_rematch_unfilled(roadmap_unfilled, rematch_new_unfilled) + use_initial_off_topic = False + + off_topic_steps = detect_off_topic_steps( + cur, + steps, + brief=semantic_brief, + goal_query=goal_query, + ) + if round_idx + 1 >= max_rounds: + break + if not off_topic_steps and not roadmap_unfilled: + break + + if not off_topic_steps: + off_topic_steps = detect_off_topic_steps( + cur, + steps, + brief=semantic_brief, + goal_query=goal_query, + ) + + return ( + steps, + rematch_log, + current_stripped, + off_topic_steps, + rematch_rounds, + roadmap_unfilled, + ) + + def _build_steps_roadmap_first( cur, *, @@ -452,77 +1384,52 @@ def _build_steps_roadmap_first( for m in roadmap_ctx.roadmap.major_steps[:max_steps] ] - major_by_index: Dict[int, MajorStep] = {} - if roadmap_ctx.roadmap: - major_by_index = {m.index: m for m in roadmap_ctx.roadmap.major_steps} - used: Set[int] = set() steps: List[Dict[str, Any]] = [] planned_ids: List[int] = [] anchor_id: Optional[int] = None anchor_variant_id: Optional[int] = None unfilled: List[Tuple[int, StageSpecArtifact]] = [] + stage_count = len(stage_specs) + assignments = _slot_assignments_by_major_index(body.slot_assignments) + majors_by_index: Dict[int, MajorStep] = {} + if roadmap_ctx.roadmap: + majors_by_index = {m.index: m for m in roadmap_ctx.roadmap.major_steps} for step_index, stage_spec in enumerate(stage_specs): - major = major_by_index.get(stage_spec.major_step_index) - step_query = stage_spec_retrieval_query( - semantic_brief=semantic_brief, - goal_query=goal_query, - stage_spec=stage_spec, - major_step=major, - ) - step_kind = resolve_step_exercise_kind_filter(stage_spec, body.exercise_kind_any) + major_idx = stage_spec.major_step_index + major = majors_by_index.get(major_idx) + slot_priority_id: Optional[int] = None - hits, _, _, _ = _run_path_step_retrieval( + if major_idx in assignments: + try: + slot_priority_id = int(assignments[major_idx].exercise_id) + except (TypeError, ValueError): + slot_priority_id = None + + step, unfilled_spec = _match_roadmap_slot( cur, tenant=tenant, + body=body, goal_query=goal_query, - step_index=step_index, max_steps=max_steps, - planned_ids=planned_ids, - anchor_id=anchor_id, - anchor_variant_id=anchor_variant_id, - progression_graph_id=body.progression_graph_id, - include_llm_intent=body.include_llm_intent and step_index == 0, - exercise_kind_any=step_kind, semantic_brief=semantic_brief, path_target_profile=path_target_profile, path_intent=path_intent, - step_query_override=step_query, - step_phase_override=major.phase if major else None, + 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, + slot_priority_exercise_id=slot_priority_id, ) - - hit = _pick_best_path_hit(hits, used, semantic_brief=semantic_brief) - if not hit and step_query != goal_query: - hits, _, _, _ = _run_path_step_retrieval( - cur, - tenant=tenant, - goal_query=goal_query, - step_index=step_index, - max_steps=max_steps, - planned_ids=planned_ids, - anchor_id=anchor_id, - anchor_variant_id=anchor_variant_id, - progression_graph_id=body.progression_graph_id, - include_llm_intent=False, - exercise_kind_any=step_kind, - semantic_brief=semantic_brief, - path_target_profile=path_target_profile, - path_intent=path_intent, - step_query_override=goal_query, - step_phase_override=major.phase if major else None, - ) - hit = _pick_best_path_hit(hits, used, semantic_brief=semantic_brief) - - if not hit: - unfilled.append((step_index, stage_spec)) + if not step: + unfilled.append((step_index, unfilled_spec or stage_spec)) continue - step = _annotate_roadmap_step( - _hit_to_path_step(hit), - stage_spec=stage_spec, - major_step=major, - ) steps.append(step) eid = int(step["exercise_id"]) used.add(eid) @@ -533,6 +1440,237 @@ def _build_steps_roadmap_first( return steps, unfilled +def _evaluate_steps_from_payload( + cur, + payloads: List[EvaluateStepPayload], +) -> List[Dict[str, Any]]: + steps: List[Dict[str, Any]] = [] + for raw in payloads: + is_proposal = bool(raw.is_ai_proposal) or raw.exercise_id is None + title = (raw.title or "").strip() or None + if is_proposal: + steps.append( + { + "exercise_id": None, + "variant_id": None, + "title": title or "KI-Vorschlag", + "is_ai_proposal": True, + "ai_suggestion": raw.ai_suggestion, + "proposal_key": raw.proposal_key, + "roadmap_major_step_index": raw.roadmap_major_step_index, + "roadmap_phase": raw.roadmap_phase, + "roadmap_learning_goal": raw.roadmap_learning_goal, + "reasons": [], + } + ) + continue + eid = int(raw.exercise_id) + cur.execute( + "SELECT id, title, summary FROM exercises WHERE id = %s", + (eid,), + ) + row = cur.fetchone() + if not row: + raise HTTPException(status_code=400, detail=f"Übung {eid} nicht gefunden") + steps.append( + { + "exercise_id": eid, + "variant_id": raw.variant_id, + "title": title or row.get("title"), + "summary": row.get("summary"), + "is_ai_proposal": False, + "roadmap_major_step_index": raw.roadmap_major_step_index, + "roadmap_phase": raw.roadmap_phase, + "roadmap_learning_goal": raw.roadmap_learning_goal, + "reasons": [], + } + ) + return steps + + +def _build_evaluate_empty_slot_gap_specs( + steps: List[Dict[str, Any]], + *, + goal_query: str, +) -> List[Dict[str, Any]]: + """Gap-Angebote für leere Roadmap-Slots im evaluate_only-Modus.""" + specs: List[Dict[str, Any]] = [] + for step in steps: + if step.get("exercise_id") is not None: + continue + major_idx = step.get("roadmap_major_step_index") + if major_idx is None: + continue + try: + roadmap_idx = int(major_idx) + except (TypeError, ValueError): + continue + phase = (step.get("roadmap_phase") or "vertiefung").strip().lower() + learning_goal = (step.get("roadmap_learning_goal") or step.get("title") or "").strip() + title_hint = learning_goal[:120] if learning_goal else f"Slot {roadmap_idx + 1}" + specs.append( + { + "source": "roadmap_unfilled", + "insert_after_index": max(roadmap_idx - 1, -1), + "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": ( + f"Slot {roadmap_idx + 1} ohne Übung — KI-Entwurf für diese Roadmap-Stufe." + ), + "roadmap_major_step_index": roadmap_idx, + } + ) + return specs[:8] + + +def _run_evaluate_only_path_qa( + cur, + *, + body: ProgressionPathSuggestRequest, + goal_query: str, + semantic_brief: PlanningSemanticBrief, + steps: List[Dict[str, Any]], + roadmap_ctx: Optional[ProgressionRoadmapContext], +) -> Dict[str, Any]: + roadmap_first = roadmap_ctx is not None + gaps: List[Dict[str, Any]] = [] + bridge_inserts: List[Dict[str, Any]] = [] + unfilled_gaps: List[Dict[str, Any]] = [] + llm_qa: Optional[Dict[str, Any]] = None + llm_qa_applied = False + off_topic_steps: List[Dict[str, Any]] = [] + stripped_off_topic: List[Dict[str, Any]] = [] + ai_proposals: List[Dict[str, Any]] = [] + gap_fill_offers: List[Dict[str, Any]] = [] + roadmap_qa_mode: Optional[str] = None + + if body.include_path_qa: + if roadmap_first: + roadmap_qa_mode = "roadmap_first_lite" + gaps = detect_path_gaps( + cur, + steps, + brief=semantic_brief, + roadmap_first=roadmap_first, + ) + if gaps and roadmap_first: + unfilled_gaps = list(gaps) + + if body.include_llm_path_qa: + llm_qa, llm_qa_applied = try_llm_qa_progression_path( + cur, + goal_query=goal_query, + brief=semantic_brief, + steps=steps, + gaps=gaps, + bridge_inserts=bridge_inserts, + ) + + off_topic_steps = detect_off_topic_steps( + cur, + steps, + brief=semantic_brief, + goal_query=goal_query, + ) + llm_gap_specs = parse_llm_suggested_new_exercises( + llm_qa, + brief=semantic_brief, + step_count=len(steps), + ) + + if body.include_ai_gap_fill: + fresh_large_gaps = [g for g in gaps if g.get("is_large_gap")] + gap_specs = collect_gap_fill_specs( + steps=steps, + unfilled_gaps=fresh_large_gaps or unfilled_gaps, + off_topic_steps=off_topic_steps, + llm_specs=llm_gap_specs, + brief=semantic_brief, + goal_query=goal_query, + ) + empty_slot_specs = _build_evaluate_empty_slot_gap_specs( + steps, + goal_query=goal_query, + ) + seen_spec_keys = { + ( + s.get("source"), + s.get("roadmap_major_step_index"), + s.get("insert_after_index"), + ) + for s in gap_specs + } + for spec in empty_slot_specs: + key = ( + spec.get("source"), + spec.get("roadmap_major_step_index"), + spec.get("insert_after_index"), + ) + if key not in seen_spec_keys: + gap_specs.append(spec) + seen_spec_keys.add(key) + path_roadmap_snapshot = None + if roadmap_ctx: + path_roadmap_snapshot = build_progression_gap_snapshot( + goal_analysis=( + roadmap_ctx.goal_analysis.model_dump() + if roadmap_ctx.goal_analysis + else None + ), + resolved_structured=( + roadmap_ctx.resolved_structured.model_dump() + if roadmap_ctx.resolved_structured + else None + ), + semantic_brief=roadmap_ctx.semantic_brief + or brief_to_summary_dict(semantic_brief), + ) + _, ai_proposals, gap_fill_offers = apply_gap_fill_after_qa( + cur, + steps, + gap_specs, + goal_query=goal_query, + brief=semantic_brief, + include_ai_calls=False, + max_ai_proposals=0, + auto_insert_proposals=False, + roadmap_snapshot=path_roadmap_snapshot, + ) + + multistage_qa = run_multistage_path_qa( + off_topic_steps=off_topic_steps, + stripped_off_topic=stripped_off_topic, + gaps=gaps, + llm_qa=llm_qa, + llm_applied=llm_qa_applied, + ) + path_qa = build_path_qa_summary( + gaps=gaps, + bridge_inserts=bridge_inserts, + ai_proposals=ai_proposals, + gap_fill_offers=gap_fill_offers, + off_topic_steps=off_topic_steps, + stripped_off_topic=stripped_off_topic, + llm_qa=llm_qa, + llm_applied=llm_qa_applied, + reorder_applied=False, + reorder_notes=[], + roadmap_qa_mode=roadmap_qa_mode, + multistage_qa=multistage_qa, + ) + return { + "path_qa": path_qa, + "gap_fill_offers": gap_fill_offers, + "steps": steps, + } + + def suggest_progression_path( cur, *, @@ -554,10 +1692,25 @@ def suggest_progression_path( semantic_brief, semantic_llm_applied = try_enrich_semantic_brief_with_llm( cur, goal_query, semantic_brief ) + extra_path_ctx = " ".join( + p + for p in ( + (body.start_situation or "").strip(), + (body.target_state or "").strip(), + (body.roadmap_notes or "").strip(), + ) + if p + ) + semantic_brief = enrich_brief_with_path_constraints( + semantic_brief, + goal_query, + extra_context=extra_path_ctx or None, + ) roadmap_first = bool(body.roadmap_first) roadmap_only = bool(body.roadmap_only) start_target_only = bool(body.start_target_only) + evaluate_only = bool(body.evaluate_only) include_roadmap = ( roadmap_first or body.include_roadmap_preview or roadmap_only or start_target_only ) @@ -646,12 +1799,68 @@ def suggest_progression_path( "retrieval_phase": "roadmap_only", } + if evaluate_only: + if not body.evaluate_steps: + raise HTTPException( + status_code=400, + detail="evaluate_only erfordert evaluate_steps", + ) + eval_steps = _evaluate_steps_from_payload(cur, body.evaluate_steps) + qa_pack = _run_evaluate_only_path_qa( + cur, + body=body, + goal_query=goal_query, + semantic_brief=semantic_brief, + steps=eval_steps, + roadmap_ctx=roadmap_ctx, + ) + return { + "goal_query": goal_query, + "max_steps_requested": max_steps, + "steps": qa_pack["steps"], + "step_count": len(qa_pack["steps"]), + "target_profile_summary": None, + "semantic_brief_summary": brief_to_summary_dict(semantic_brief), + "semantic_llm_applied": semantic_llm_applied, + "query_intent_summary": {}, + "progression_graph_id": body.progression_graph_id, + "path_qa": qa_pack["path_qa"], + "gap_fill_offers": qa_pack["gap_fill_offers"], + "progression_roadmap": progression_roadmap, + "roadmap_first": bool(roadmap_ctx), + "roadmap_only": False, + "roadmap_edited": roadmap_edited, + "roadmap_unfilled_count": 0, + "path_skill_expectations": None, + "retrieval_phase": "evaluate_only", + } + path_target_profile, first_intent_summary, path_intent = _build_path_target_profile( cur, goal_query=goal_query, semantic_brief=semantic_brief, include_llm_intent=body.include_llm_intent, ) + path_skill_expectations: Optional[Dict[str, Any]] = None + if roadmap_ctx and roadmap_ctx.goal_analysis: + path_inp = expectation_input_from_progression_path( + goal_query=goal_query, + goal_analysis=roadmap_ctx.goal_analysis.model_dump(), + resolved_structured=( + roadmap_ctx.resolved_structured.model_dump() + if roadmap_ctx.resolved_structured + else None + ), + semantic_brief_summary=( + roadmap_ctx.semantic_brief + if roadmap_ctx.semantic_brief + else brief_to_summary_dict(semantic_brief) + ), + ) + path_exp = build_planning_skill_expectations(cur, path_inp, semantic_brief=semantic_brief) + if path_exp.items: + path_target_profile = apply_expectations_to_target(path_target_profile, path_exp) + path_skill_expectations = path_exp.to_api_dict() roadmap_unfilled: List[Tuple[int, StageSpecArtifact]] = [] roadmap_gap_offers: List[Dict[str, Any]] = [] @@ -702,7 +1911,11 @@ def suggest_progression_path( brief=semantic_brief, proposal=None, roadmap_snapshot=_roadmap_gap_snapshot_for_spec( - roadmap_ctx, spec, semantic_brief=semantic_brief + cur, + roadmap_ctx, + spec, + goal_query=goal_query, + semantic_brief=semantic_brief, ), ) ) @@ -723,6 +1936,7 @@ def suggest_progression_path( semantic_brief=semantic_brief, path_target_profile=path_target_profile, path_intent=path_intent, + supplemental_exercise_ids=_supplemental_exercise_ids_from_body(cur, body), ) hit = _pick_best_path_hit(hits, used, semantic_brief=semantic_brief) @@ -737,7 +1951,10 @@ def suggest_progression_path( anchor_id = eid anchor_variant_id = step.get("variant_id") - if len(steps) < 2: + stage_spec_count = len(roadmap_ctx.stage_specs or []) if roadmap_ctx else 0 + if roadmap_first and stage_spec_count >= 2: + pass + elif len(steps) < 2: raise HTTPException( status_code=422, detail="Zu wenig passende Übungen für einen Pfad (mindestens 2 Schritte). Ziel präzisieren oder max_steps senken.", @@ -749,6 +1966,8 @@ def suggest_progression_path( gap_fill_offers: List[Dict[str, Any]] = [] off_topic_steps: List[Dict[str, Any]] = [] stripped_off_topic: List[Dict[str, Any]] = [] + rematch_log: List[Dict[str, Any]] = [] + rematch_rounds = 0 llm_qa: Optional[Dict[str, Any]] = None llm_qa_applied = False reorder_applied = False @@ -778,6 +1997,7 @@ def suggest_progression_path( planned_ids=planned_ids, path_target_profile=path_target_profile, path_intent=path_intent, + supplemental_exercise_ids=_supplemental_exercise_ids_from_body(cur, body), ) steps, bridge_inserts, unfilled_gaps = insert_bridge_exercises( cur, @@ -813,8 +2033,18 @@ def suggest_progression_path( if llm_qa.get("overall_ok") or (q_val is not None and q_val >= 0.45): steps, reorder_applied, reorder_notes = apply_llm_path_reorder(steps, llm_qa) - off_topic_steps = detect_off_topic_steps(cur, steps, brief=semantic_brief) - steps, stripped_off_topic = strip_off_topic_steps_from_path(steps, off_topic_steps) + off_topic_steps = detect_off_topic_steps( + cur, + steps, + brief=semantic_brief, + 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 stripped_off_topic: off_topic_steps = [] gaps = detect_path_gaps( @@ -824,6 +2054,39 @@ def suggest_progression_path( roadmap_first=roadmap_first, ) + if roadmap_first and roadmap_ctx is not None: + ( + steps, + rematch_log, + stripped_off_topic, + rematch_off_topic, + rematch_rounds, + roadmap_unfilled, + ) = _run_roadmap_rematch_loop( + 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, + steps=steps, + stripped_off_topic=stripped_off_topic, + off_topic_before_strip=off_topic_before_strip, + roadmap_unfilled=roadmap_unfilled, + gaps=gaps, + ) + if rematch_off_topic: + off_topic_steps = rematch_off_topic + gaps = detect_path_gaps( + cur, + steps, + brief=semantic_brief, + roadmap_first=roadmap_first, + ) + llm_gap_specs = parse_llm_suggested_new_exercises( llm_qa, brief=semantic_brief, @@ -873,6 +2136,14 @@ def suggest_progression_path( if offer.get("offer_id") not in seen_offer_ids: gap_fill_offers.append(offer) + multistage_qa = run_multistage_path_qa( + off_topic_steps=off_topic_steps, + stripped_off_topic=stripped_off_topic, + gaps=gaps, + llm_qa=llm_qa, + llm_applied=llm_qa_applied, + roadmap_unfilled=roadmap_unfilled if roadmap_first else None, + ) path_qa = build_path_qa_summary( gaps=gaps, bridge_inserts=bridge_inserts, @@ -885,7 +2156,86 @@ def suggest_progression_path( reorder_applied=reorder_applied, reorder_notes=reorder_notes, roadmap_qa_mode=roadmap_qa_mode, + multistage_qa=multistage_qa, ) + if rematch_log: + path_qa["rematch_applied"] = True + path_qa["rematch_log"] = rematch_log + path_qa["rematch_rounds"] = rematch_rounds + + if roadmap_first and roadmap_ctx is not None: + steps = _normalize_roadmap_steps_coverage( + steps, + roadmap_ctx=roadmap_ctx, + max_steps=max_steps, + ) + if body.include_ai_gap_fill: + seen_offer_ids = {o.get("offer_id") for o in gap_fill_offers if o.get("offer_id")} + for step in steps: + if step.get("exercise_id") is not None: + continue + try: + major_idx = int(step["roadmap_major_step_index"]) + except (TypeError, ValueError, KeyError): + continue + if step.get("gap_offer") and step.get("proposal_key"): + oid = step["gap_offer"].get("offer_id") + if oid and oid not in seen_offer_ids: + gap_fill_offers.append(dict(step["gap_offer"])) + seen_offer_ids.add(oid) + continue + stage_spec = next( + ( + s + for s in (roadmap_ctx.stage_specs or []) + if int(s.major_step_index) == major_idx + ), + None, + ) + learning_goal = ( + (stage_spec.learning_goal if stage_spec else None) + or step.get("roadmap_learning_goal") + or step.get("title") + or "" + ).strip() + spec = { + "source": "roadmap_unfilled", + "insert_after_index": max(major_idx - 1, -1), + "roadmap_major_step_index": major_idx, + "phase": (step.get("roadmap_phase") or "vertiefung").strip().lower(), + "title_hint": (learning_goal or f"Slot {major_idx + 1}")[:120], + "sketch": learning_goal, + "rationale": f"Slot {major_idx + 1} — keine passende Bibliotheks-Übung; KI-Entwurf für diese Stufe.", + } + offer = build_gap_fill_offer( + spec=spec, + steps=steps, + goal_query=goal_query, + brief=semantic_brief, + proposal=None, + roadmap_snapshot=_roadmap_gap_snapshot_for_spec( + cur, + roadmap_ctx, + spec, + goal_query=goal_query, + semantic_brief=semantic_brief, + ), + ) + step["gap_offer"] = offer + step["proposal_key"] = offer.get("offer_id") + step["slot_status"] = "unfilled" + if offer.get("offer_id") and offer.get("offer_id") not in seen_offer_ids: + gap_fill_offers.append(offer) + seen_offer_ids.add(offer.get("offer_id")) + + filled_library_steps = sum(1 for s in steps if s.get("exercise_id") is not None) + match_summary = { + "roadmap_first": roadmap_first, + "library_matches": filled_library_steps, + "slot_count": len(steps), + "gap_fill_offer_count": len(gap_fill_offers), + "roadmap_unfilled_count": len(roadmap_unfilled), + } target_profile_summary = path_target_profile.to_summary_dict(cur) retrieval_parts = ["profile_v1", "full_library", "path_builder", "semantics"] @@ -909,6 +2259,8 @@ def suggest_progression_path( retrieval_parts.append("roadmap_edited") if roadmap_unfilled: retrieval_parts.append("roadmap_unfilled") + if rematch_log: + retrieval_parts.append("path_rematch") return { "goal_query": goal_query, @@ -927,11 +2279,14 @@ def suggest_progression_path( "roadmap_only": False, "roadmap_edited": roadmap_edited, "roadmap_unfilled_count": len(roadmap_unfilled), + "path_skill_expectations": path_skill_expectations, + "match_summary": match_summary, "retrieval_phase": "+".join(retrieval_parts), } __all__ = [ + "EvaluateStepPayload", "ProgressionPathSuggestRequest", "suggest_progression_path", "_pick_best_path_hit", diff --git a/backend/planning_exercise_path_qa.py b/backend/planning_exercise_path_qa.py index 2f41247..d3e50a7 100644 --- a/backend/planning_exercise_path_qa.py +++ b/backend/planning_exercise_path_qa.py @@ -18,10 +18,18 @@ from openrouter_chat import ( from planning_exercise_semantics import ( PlanningSemanticBrief, + _blob_from_fields, + _blob_matches_stage_excludes, brief_to_summary_dict, exercise_passes_path_semantic_gate, + exercise_passes_stage_learning_goal_gate, + exercise_passes_technique_path_scope, + resolve_path_anti_patterns, + resolve_path_primary_topic, score_exercise_semantic_relevance, + semantic_brief_for_stage, step_phase_for_index, + technique_sibling_excludes, ) _logger = logging.getLogger("shinkan.planning_exercise_path_qa") @@ -174,6 +182,8 @@ def detect_path_gaps( for i in range(total_segments): step_a = steps[i] step_b = steps[i + 1] + if step_a.get("exercise_id") is None or step_b.get("exercise_id") is None: + continue if roadmap_first and is_roadmap_planned_neighbor_pair(step_a, step_b): continue gap = measure_step_transition_gap( @@ -391,52 +401,165 @@ def apply_llm_path_reorder( _OFF_TOPIC_SEMANTIC_MAX = 0.10 +def _with_roadmap_major_index( + step: Mapping[str, Any], + entry: Dict[str, Any], +) -> Dict[str, Any]: + midx = step.get("roadmap_major_step_index") + if midx is not None: + entry["roadmap_major_step_index"] = int(midx) + return entry + + def detect_off_topic_steps( cur, steps: Sequence[Mapping[str, Any]], *, brief: PlanningSemanticBrief, + goal_query: Optional[str] = None, ) -> List[Dict[str, Any]]: """Schritte ohne Bezug zum Pfad-Thema (z. B. reine Kraftübungen bei Mae Geri).""" - if brief.semantic_strength < 0.55 or len(steps) < 2: + if len(steps) < 2: + return [] + roadmap_stage_steps = any( + (step.get("roadmap_match_source") == "stage_spec") + or (step.get("roadmap_learning_goal") or "").strip() + for step in steps + ) + if brief.semantic_strength < 0.55 and not roadmap_stage_steps: return [] + path_anti = resolve_path_anti_patterns(goal_query or "", semantic_brief=brief) off_topic: List[Dict[str, Any]] = [] total = len(steps) for idx, step in enumerate(steps): if step.get("is_ai_proposal") or step.get("exercise_id") is None: continue bundle = _load_exercise_text_bundle(cur, int(step["exercise_id"])) - phase = step_phase_for_index(brief, idx, total) + blob = _blob_from_fields( + bundle["title"], + bundle["summary"], + bundle["goal"], + bundle["variant_names"], + ) + step_anti = list(step.get("roadmap_anti_patterns") or []) + path_anti + if step_anti and _blob_matches_stage_excludes(blob, step_anti): + off_topic.append( + _with_roadmap_major_index( + step, + { + "step_index": idx, + "exercise_id": int(step["exercise_id"]), + "title": step.get("title") or bundle["title"], + "semantic_score": 0.0, + "expected_phase": (step.get("roadmap_phase") or "").strip().lower() or None, + "issue": "path_exclude", + "reasons": ["Widerspricht Pfad-Ausschlüssen (z. B. Kumite)"], + }, + ) + ) + continue + stage_goal_pre = (step.get("roadmap_learning_goal") or "").strip() + primary = ( + resolve_path_primary_topic( + goal_query or "", + brief, + stage_learning_goal=stage_goal_pre or None, + ) + or "" + ).strip() + if primary: + siblings = technique_sibling_excludes(primary) + if not exercise_passes_technique_path_scope( + primary_topic=primary, + title=bundle["title"], + summary=bundle["summary"], + goal=bundle["goal"], + learning_goal=stage_goal_pre, + sibling_excludes=siblings, + relaxed=False, + ): + off_topic.append( + _with_roadmap_major_index( + step, + { + "step_index": idx, + "exercise_id": int(step["exercise_id"]), + "title": step.get("title") or bundle["title"], + "semantic_score": 0.0, + "expected_phase": (step.get("roadmap_phase") or "").strip().lower() or None, + "issue": "technique_scope", + "reasons": [f"Passt nicht zur Haupttechnik „{primary}“"], + }, + ) + ) + continue + stage_goal = (step.get("roadmap_learning_goal") or "").strip() + phase = (step.get("roadmap_phase") or "").strip().lower() or step_phase_for_index( + brief, idx, total + ) + step_brief = ( + semantic_brief_for_stage(brief, learning_goal=stage_goal, phase=phase or None) + if stage_goal + else brief + ) sem, sem_reasons = score_exercise_semantic_relevance( title=bundle["title"], summary=bundle["summary"], goal=bundle["goal"], variant_names=bundle["variant_names"], - brief=brief, + brief=step_brief, step_phase=phase, ) + stage_anti = list(step.get("roadmap_anti_patterns") or []) + if stage_goal and not exercise_passes_stage_learning_goal_gate( + learning_goal=stage_goal, + title=bundle["title"], + summary=bundle["summary"], + goal=bundle["goal"], + semantic_score=sem, + anti_patterns=stage_anti or None, + ): + off_topic.append( + _with_roadmap_major_index( + step, + { + "step_index": idx, + "exercise_id": int(step["exercise_id"]), + "title": step.get("title") or bundle["title"], + "semantic_score": round(sem, 4), + "expected_phase": phase, + "issue": "stage_mismatch", + "roadmap_learning_goal": stage_goal, + "reasons": sem_reasons[:3], + }, + ) + ) + continue if exercise_passes_path_semantic_gate( semantic_score=sem, title=bundle["title"], summary=bundle["summary"], goal=bundle["goal"], - brief=brief, + brief=step_brief, strict=True, ): continue if sem > _OFF_TOPIC_SEMANTIC_MAX: continue off_topic.append( - { - "step_index": idx, - "exercise_id": int(step["exercise_id"]), - "title": step.get("title") or bundle["title"], - "semantic_score": round(sem, 4), - "expected_phase": phase, - "issue": "off_topic", - "reasons": sem_reasons[:3], - } + _with_roadmap_major_index( + step, + { + "step_index": idx, + "exercise_id": int(step["exercise_id"]), + "title": step.get("title") or bundle["title"], + "semantic_score": round(sem, 4), + "expected_phase": phase, + "issue": "off_topic", + "reasons": sem_reasons[:3], + }, + ) ) return off_topic @@ -497,9 +620,10 @@ def strip_off_topic_steps_from_path( return steps, [] by_index = {int(o["step_index"]): dict(o) for o in off_topic_steps if o.get("step_index") is not None} - indices = sorted(by_index.keys(), reverse=True) - if len(steps) - len(indices) < min_remaining: + max_remove = max(0, len(steps) - min_remaining) + if max_remove <= 0: return steps, [] + indices = sorted(by_index.keys(), reverse=True)[:max_remove] out = list(steps) removed: List[Dict[str, Any]] = [] @@ -541,6 +665,7 @@ def build_path_qa_summary( reorder_applied: bool = False, reorder_notes: Optional[Sequence[str]] = None, roadmap_qa_mode: Optional[str] = None, + multistage_qa: Optional[Mapping[str, Any]] = None, ) -> Dict[str, Any]: offers = list(gap_fill_offers or []) off_topic = list(off_topic_steps or []) @@ -561,6 +686,10 @@ def build_path_qa_summary( "reorder_notes": list(reorder_notes or []), "roadmap_qa_mode": roadmap_qa_mode, } + if multistage_qa: + summary["qa_tiers"] = list(multistage_qa.get("qa_tiers") or []) + summary["optimization_hints"] = list(multistage_qa.get("optimization_hints") or []) + summary["optimization_hint_count"] = int(multistage_qa.get("optimization_hint_count") or 0) if llm_qa: summary["overall_ok"] = bool(llm_qa.get("overall_ok", True)) summary["quality_score"] = llm_qa.get("quality_score") diff --git a/backend/planning_exercise_retrieval.py b/backend/planning_exercise_retrieval.py index dcb928e..085e9e7 100644 --- a/backend/planning_exercise_retrieval.py +++ b/backend/planning_exercise_retrieval.py @@ -14,10 +14,14 @@ from planning_exercise_profiles import ( load_exercise_match_profiles_bulk, score_exercise_against_target, ) +from exercise_ai import strip_html_to_plain from planning_exercise_semantics import ( PlanningSemanticBrief, + build_stage_match_brief, exercise_passes_path_semantic_gate, + exercise_passes_stage_fit, score_exercise_semantic_relevance, + score_exercise_stage_fit, ) _MAX_LIBRARY_ROWS = 8000 @@ -54,6 +58,119 @@ def _normalize_exercise_kind_filter(exercise_kind_any: Optional[List[str]]) -> L return out +_EXERCISE_ROW_SELECT = """ + SELECT e.id, e.title, e.summary, e.method_archetype, + e.visibility, e.club_id, e.created_by, + ( + SELECT fa.name FROM exercise_focus_areas efa + JOIN focus_areas fa ON fa.id = efa.focus_area_id + WHERE efa.exercise_id = e.id + ORDER BY efa.is_primary DESC NULLS LAST, fa.name ASC + LIMIT 1 + ) AS primary_focus_name, + 0.0::float AS ft_rank + FROM exercises e +""" + + +def fetch_exercise_rows_by_ids( + cur, + exercise_ids: Sequence[int], + *, + vis_sql: str, + vis_params: Sequence[Any], +) -> List[Dict[str, Any]]: + """Lädt konkrete Übungen nach, wenn sie im Graph/Slot verankert sind (Pin-Sicherheit).""" + ids = sorted({int(x) for x in exercise_ids if int(x) > 0}) + if not ids: + return [] + ph = ",".join(["%s"] * len(ids)) + sql = f""" + {_EXERCISE_ROW_SELECT.strip()} + WHERE e.id IN ({ph}) + AND ({vis_sql}) + AND COALESCE(e.status, '') <> %s + """ + params: List[Any] = list(ids) + list(vis_params) + ["archived"] + cur.execute(sql, params) + return [dict(r) for r in cur.fetchall()] + + +def fetch_exercise_rows_by_ids_for_graph( + cur, + exercise_ids: Sequence[int], + *, + graph_visibility: str, + graph_club_id: Optional[int], + profile_id: int, + role: str, + exercise_allowed_fn, +) -> List[Dict[str, Any]]: + """ + Lädt Übungen nach ID mit Graph-Sichtbarkeitsregeln (nicht Library-vis_sql). + + Ermöglicht Re-Match für im Graph verankerte private Übungen auf Club-Graphen + (eigene private) bzw. alle graph-konformen Übungen. + """ + ids = sorted({int(x) for x in exercise_ids if int(x) > 0}) + if not ids: + return [] + ph = ",".join(["%s"] * len(ids)) + sql = f""" + {_EXERCISE_ROW_SELECT.strip()} + WHERE e.id IN ({ph}) + AND COALESCE(e.status, '') <> %s + """ + cur.execute(sql, [*ids, "archived"]) + out: List[Dict[str, Any]] = [] + for row in cur.fetchall() or []: + if exercise_allowed_fn( + row, + graph_visibility=graph_visibility, + graph_club_id=graph_club_id, + profile_id=profile_id, + role=role, + ): + out.append(dict(row)) + return out + + +def trim_hits_preserving_priority_ids( + hits: Sequence[Mapping[str, Any]], + priority_ids: Optional[Sequence[int]], + *, + limit: int = 48, +) -> List[Dict[str, Any]]: + """Behält priorisierte Graph-/Slot-Übungen im Kandidatenpool (vor pick_best_path_hit).""" + priority_set = {int(x) for x in (priority_ids or []) if int(x) > 0} + if not priority_set: + return list(hits)[:limit] + by_id: Dict[int, Dict[str, Any]] = {} + for hit in hits: + try: + by_id[int(hit["id"])] = dict(hit) + except (TypeError, ValueError, KeyError): + continue + priority_hits = [by_id[eid] for eid in sorted(priority_set) if eid in by_id] + rest = [dict(h) for h in hits if int(h.get("id") or 0) not in priority_set] + merged = priority_hits + rest + return merged[: max(limit, len(priority_hits))] + + +def merge_supplemental_exercise_rows( + rows: Sequence[Dict[str, Any]], + supplemental: Sequence[Dict[str, Any]], +) -> List[Dict[str, Any]]: + seen = {int(r["id"]) for r in rows if r.get("id") is not None} + out = list(rows) + for row in supplemental: + rid = int(row["id"]) + if rid not in seen: + seen.add(rid) + out.append(dict(row)) + return out + + def fetch_all_visible_exercise_rows( cur, *, @@ -148,7 +265,7 @@ def _load_exercise_goals_chunked(cur, exercise_ids: Sequence[int], *, batch: int ph = ",".join(["%s"] * len(chunk)) cur.execute(f"SELECT id, goal FROM exercises WHERE id IN ({ph})", chunk) for row in cur.fetchall(): - out[int(row["id"])] = str(row.get("goal") or "") + out[int(row["id"])] = strip_html_to_plain(row.get("goal"), max_len=1200) return out @@ -200,6 +317,21 @@ def rank_visible_library_hits( semantic_brief = semantic_brief_raw step_phase = pack.get("path_step_phase") path_mode = pack.get("context_mode") == "progression_path" + stage_learning_goal = (pack.get("stage_learning_goal") or "").strip() + roadmap_stage_match = bool(pack.get("roadmap_stage_match")) + stage_match_brief_raw = pack.get("stage_match_brief") + stage_match_brief: Optional[PlanningSemanticBrief] = None + if isinstance(stage_match_brief_raw, PlanningSemanticBrief): + stage_match_brief = stage_match_brief_raw + elif roadmap_stage_match and stage_learning_goal: + stage_match_brief = build_stage_match_brief( + learning_goal=stage_learning_goal, + anti_patterns=pack.get("stage_anti_patterns"), + success_criteria=pack.get("stage_success_criteria"), + load_profile=pack.get("stage_load_profile"), + phase=step_phase, + path_context_note=pack.get("path_context_note"), + ) last_planned_skills: Set[int] = set() planned_ids = pack.get("planned_exercise_ids") or [] @@ -226,7 +358,11 @@ def rank_visible_library_hits( skills_by_ex = _load_skill_sets_chunked(cur, cand_ids) goals_by_ex: Dict[int, str] = {} variants_by_ex: Dict[int, List[str]] = {} - if semantic_brief and semantic_brief.semantic_strength > 0.05: + need_exercise_semantic_text = ( + (semantic_brief and semantic_brief.semantic_strength > 0.05) + or (stage_match_brief and stage_match_brief.semantic_strength > 0.05) + ) + if need_exercise_semantic_text: goals_by_ex = _load_exercise_goals_chunked(cur, cand_ids) variants_by_ex = _load_variant_names_chunked(cur, cand_ids) @@ -267,37 +403,99 @@ def rank_visible_library_hits( emp, target, intent=intent ) + title_s = str(row.get("title") or "") + summary_s = str(row.get("summary") or "") + goal_s = goals_by_ex.get(eid, "") + semantic_score = 0.0 semantic_reasons: List[str] = [] if semantic_brief and semantic_brief.semantic_strength > 0.05: semantic_score, semantic_reasons = score_exercise_semantic_relevance( - title=str(row.get("title") or ""), - summary=str(row.get("summary") or ""), - goal=goals_by_ex.get(eid, ""), + title=title_s, + summary=summary_s, + goal=goal_s, variant_names=variants_by_ex.get(eid, []), brief=semantic_brief, step_phase=step_phase, ) + stage_semantic_score = 0.0 + stage_semantic_reasons: List[str] = [] + if stage_match_brief and stage_match_brief.semantic_strength > 0.05: + stage_semantic_score, stage_semantic_reasons = score_exercise_stage_fit( + title=title_s, + summary=summary_s, + goal=goal_s, + variant_names=variants_by_ex.get(eid, []), + stage_brief=stage_match_brief, + step_phase=step_phase, + ) + + rank_stage_sem = stage_semantic_score + stage_lg = (stage_learning_goal or "").strip() + if roadmap_stage_match and stage_lg: + raw_brief = build_stage_match_brief( + learning_goal=stage_lg, + anti_patterns=pack.get("stage_anti_patterns"), + phase=step_phase, + ) + raw_sem, raw_reasons = score_exercise_stage_fit( + title=title_s, + summary=summary_s, + goal=goal_s, + variant_names=variants_by_ex.get(eid, []), + stage_brief=raw_brief, + step_phase=step_phase, + ) + rank_stage_sem = max(stage_semantic_score, raw_sem) + if raw_sem > stage_semantic_score and raw_reasons: + for rr in raw_reasons: + if rr not in stage_semantic_reasons: + stage_semantic_reasons.append(rr) + + effective_semantic = ( + rank_stage_sem + if roadmap_stage_match and stage_match_brief + else semantic_score + ) + + score_penalty = 0.0 + stage_match_reason: Optional[str] = None if ( path_mode + and not roadmap_stage_match and semantic_brief and semantic_brief.semantic_strength >= 0.55 and not exercise_passes_path_semantic_gate( semantic_score=semantic_score, - title=str(row.get("title") or ""), - summary=str(row.get("summary") or ""), - goal=goals_by_ex.get(eid, ""), + title=title_s, + summary=summary_s, + goal=goal_s, brief=semantic_brief, strict=True, ) ): score_penalty = 0.42 - else: - score_penalty = 0.0 + if roadmap_stage_match and stage_learning_goal: + if exercise_passes_stage_fit( + learning_goal=stage_learning_goal, + title=title_s, + summary=summary_s, + goal=goal_s, + stage_brief=stage_match_brief, + stage_semantic_score=rank_stage_sem, + anti_patterns=pack.get("stage_anti_patterns"), + step_phase=step_phase, + path_primary_topic=pack.get("path_primary_topic"), + path_technique_excludes=pack.get("path_technique_excludes"), + ): + score_penalty = max(0.0, score_penalty - 0.10) + stage_match_reason = "Passt zum Stufen-Lernziel" + else: + score_penalty += 0.48 score = ( - weights.get("semantic", 0.0) * semantic_score + weights.get("semantic", 0.0) * effective_semantic + weights["fulltext"] * ft_norm + weights["progression"] * prog_hit + weights["skill"] * skill_sim @@ -309,7 +507,13 @@ def rank_visible_library_hits( ) reasons: List[str] = [] - if semantic_score >= 0.35 and semantic_reasons: + if stage_match_reason: + reasons.append(stage_match_reason) + if roadmap_stage_match and stage_semantic_score >= 0.30 and stage_semantic_reasons: + for sr in stage_semantic_reasons: + if sr not in reasons: + reasons.append(sr) + elif semantic_score >= 0.35 and semantic_reasons: for sr in semantic_reasons: if sr not in reasons: reasons.append(sr) @@ -345,6 +549,9 @@ def rank_visible_library_hits( "score": round(max(0.0, min(1.0, score)), 4), "reasons": reasons, "semantic_score": round(semantic_score, 4), + "stage_semantic_score": round(stage_semantic_score, 4), + "stage_rank_semantic": round(rank_stage_sem, 4), + "goal": goal_s, } ) succ_variants = pack.get("progression_successor_variants") or {} @@ -367,6 +574,8 @@ def run_multistage_planning_retrieval( intent: str, intent_weights: Mapping[str, float], pack: Mapping[str, Any], + supplemental_exercise_ids: Optional[Sequence[int]] = None, + supplemental_rows_preloaded: Optional[Sequence[Dict[str, Any]]] = None, ) -> Tuple[List[Dict[str, Any]], Dict[int, Set[int]], bool]: """Orchestriert S1b-0 → S1b-1 (Voll-Library-Ranking).""" rows = fetch_all_visible_exercise_rows( @@ -376,6 +585,16 @@ def run_multistage_planning_retrieval( query=pack.get("retrieval_query") or query, exercise_kind_any=exercise_kind_any, ) + if supplemental_rows_preloaded: + rows = merge_supplemental_exercise_rows(rows, supplemental_rows_preloaded) + elif supplemental_exercise_ids: + extra = fetch_exercise_rows_by_ids( + cur, + supplemental_exercise_ids, + vis_sql=vis_sql, + vis_params=vis_params, + ) + rows = merge_supplemental_exercise_rows(rows, extra) hits, skills_by_ex = rank_visible_library_hits( cur, rows, @@ -411,8 +630,10 @@ def profile_preselect_rows( __all__ = [ "fetch_all_visible_exercise_rows", + "fetch_exercise_rows_by_ids", "fetch_retrieval_candidate_rows", "hybrid_score_planning_hits", + "merge_supplemental_exercise_rows", "profile_preselect_rows", "rank_visible_library_hits", "run_multistage_planning_retrieval", diff --git a/backend/planning_exercise_semantics.py b/backend/planning_exercise_semantics.py index 7571233..501c884 100644 --- a/backend/planning_exercise_semantics.py +++ b/backend/planning_exercise_semantics.py @@ -9,6 +9,7 @@ from __future__ import annotations import json import logging import re +from dataclasses import dataclass, field from typing import Any, Dict, List, Mapping, Optional, Sequence, Tuple from pydantic import BaseModel, Field, field_validator @@ -152,6 +153,48 @@ def _normalize_phrase(text: str) -> str: return re.sub(r"\s+", " ", (text or "").strip().lower()) +_STAGE_TITLE_STOP = frozenset( + {"für", "fur", "und", "der", "die", "das", "mit", "im", "in", "am", "an", "zur", "zum", "den", "dem", "des"} +) + + +def _stage_title_tokens(text: str) -> List[str]: + return [ + tok + for tok in _normalize_phrase(text).split() + if tok not in _STAGE_TITLE_STOP and len(tok) > 1 + ] + + +def exercise_title_equivalent_to_stage_goal(title: str, learning_goal: str) -> bool: + """ + Titel entspricht dem Stufen-Lernziel (wortgleich oder nahezu identisch). + + Deckt Graph-Slots ab, bei denen die Übung gezielt zum Lernziel angelegt wurde, + ohne dass die Pfad-Haupttechnik im Übungstext vorkommt. + """ + t = _normalize_phrase(title) + lg = _normalize_phrase(learning_goal) + if len(t) < 3 or len(lg) < 3: + return False + if t == lg: + return True + shorter, longer = (t, lg) if len(t) <= len(lg) else (lg, t) + if shorter in longer and len(shorter) >= 8 and len(shorter) / max(len(longer), 1) >= 0.72: + return True + t_tok = _stage_title_tokens(title) + lg_tok = _stage_title_tokens(learning_goal) + if len(t_tok) >= 2 and t_tok == lg_tok: + return True + if len(t_tok) >= 2 and len(lg_tok) >= 2: + t_set = set(t_tok) + lg_set = set(lg_tok) + overlap = len(t_set & lg_set) + if overlap >= 2 and overlap / max(len(t_set), len(lg_set)) >= 0.85: + return True + return False + + def _normalize_query(text: str) -> str: return re.sub(r"\s+", " ", (text or "").strip()) @@ -179,6 +222,79 @@ def _find_technique_in_text(q_lower: str) -> Optional[Tuple[str, Tuple[str, ...] return None +def resolve_path_primary_topic( + goal_query: str, + semantic_brief: Optional[PlanningSemanticBrief] = None, + *, + stage_learning_goal: Optional[str] = None, + extra_context: Optional[str] = None, +) -> Optional[str]: + """ + Haupttechnik aus Anfrage, Kontext oder Stufen-Lernziel — nicht nur aus goal_query. + """ + if semantic_brief: + primary = (semantic_brief.primary_topic or "").strip() + if primary: + return primary + parts = [goal_query or "", extra_context or "", stage_learning_goal or ""] + combined = _normalize_phrase(" ".join(p for p in parts if p)) + if not combined: + return None + hit = _find_technique_in_text(combined.lower()) + return hit[0] if hit else None + + +def technique_sibling_excludes(primary_topic: str) -> List[str]: + """Andere Techniken derselben Familie (z. B. Mae/Yoko bei Mawashi) — aus Katalog.""" + topic = _normalize_phrase(primary_topic) + if not topic: + return [] + hit = _find_technique_in_text(topic) + if not hit: + return [] + out: List[str] = [] + for raw in hit[1]: + for expanded in _expand_stage_exclude_phrase(raw): + if expanded and expanded not in out: + out.append(expanded) + return out[:16] + + +def exercise_passes_technique_path_scope( + *, + primary_topic: str, + title: str, + summary: str = "", + goal: str = "", + learning_goal: str = "", + sibling_excludes: Optional[Sequence[str]] = None, + relaxed: bool = False, +) -> bool: + """ + Technik-Pfad: keine Geschwister-Technik; Haupttechnik muss im Übungstext vorkommen. + + Das Stufen-Lernziel allein reicht nicht — sonst würden themenfremde Übungen (z. B. Kumite) + nur wegen „Mawashi Geri“ im Lernziel durch das Gate rutschen. + """ + primary = _normalize_phrase(primary_topic) + if not primary: + return True + + blob = _blob_from_fields(title, summary, goal, []) + excludes = list(sibling_excludes or technique_sibling_excludes(primary)) + if excludes and _blob_matches_stage_excludes(blob, excludes): + return False + + if _phrase_in_blob(primary, blob): + return True + + if relaxed: + parts = [p for p in primary.split() if len(p) >= 4] + if parts and any(_phrase_in_blob(part, blob) for part in parts): + return True + return False + + def _detect_development_arc(q_lower: str) -> List[str]: found: List[str] = [] for phase, markers in _ARC_PHASES: @@ -245,6 +361,11 @@ def build_semantic_brief(query: Optional[str]) -> PlanningSemanticBrief: if len(q) >= 24 and not technique: strength = max(strength, 0.4) + path_constraints = parse_stage_goal_constraints(q) + for item in path_constraints.exclude_phrases: + if item not in exclude: + exclude.append(item) + return PlanningSemanticBrief( primary_topic=primary, topic_type=topic_type, @@ -462,7 +583,7 @@ def score_exercise_semantic_relevance( core_hits = sum(1 for ph in core if _phrase_in_blob(ph, blob)) must_hits = sum(1 for ph in must if _phrase_in_blob(ph, blob)) - exclude_hits = sum(1 for ph in exclude if _phrase_in_blob(ph, blob)) + exclude_hits = sum(1 for ph in exclude if _phrase_excluded_in_blob(ph, blob)) score = 0.0 if core: @@ -604,6 +725,510 @@ def apply_path_retrieval_weights(brief: PlanningSemanticBrief) -> Dict[str, floa } +_STAGE_GOAL_STOPWORDS = _QUERY_STOPWORDS | frozenset( + { + "stufe", + "phase", + "lernziel", + "grundlage", + "vertiefung", + "anwendung", + "perfektion", + "einstieg", + "sicher", + "sauber", + "korrekt", + "technik", + "training", + } +) + + +_STAGE_NEGATION_PATTERNS = ( + r"\bohne\s+([^,.;]+)", + r"\bkein(?:e|en|er|em)?\s+([^,.;]+)", + r"\bnicht\s+([^,.;]+)", +) + +# Aus „ohne Tritttechnik“ etc. — erweiterte Treffer im Übungstext +_STAGE_EXCLUDE_ALIASES: Dict[str, Tuple[str, ...]] = { + "tritttechnik": ( + "tritttechnik", + "trittpraezision", + "trittpräzision", + "tritt praesision", + "tritt-präzision", + "kicktechnik", + "tritt ausführung", + "tritt ausfuehrung", + ), + "kumite": ("kumite", "partnerkampf", "freikampf", "jiyu kumite"), + "kraftuebung": ("kraftuebung", "kraftübung", "krafttraining", "kraftübungen"), + "anwendung": ("kumite anwendung", "kampfanwendung"), +} + +_STAGE_FOCUS_TOKENS = frozenset( + { + "koordination", + "absprung", + "beinhebung", + "landung", + "sprung", + "sprungphase", + "balance", + "gleichgewicht", + "timing", + "vorbereitung", + "athletik", + "mobilitaet", + "mobilität", + "stabilisation", + "stabilisierung", + } +) + + +@dataclass +class StageGoalConstraints: + positive_tokens: List[str] = field(default_factory=list) + exclude_phrases: List[str] = field(default_factory=list) + has_negation: bool = False + strict_positive: bool = False + + +def _expand_stage_exclude_phrase(phrase: str) -> List[str]: + norm = _normalize_phrase(phrase) + if not norm: + return [] + out: List[str] = [norm] + compact = norm.replace(" ", "") + if compact and compact not in out: + out.append(compact) + for key, aliases in _STAGE_EXCLUDE_ALIASES.items(): + if key in norm or norm in key: + for alias in aliases: + a = _normalize_phrase(alias) + if a and a not in out: + out.append(a) + return out[:12] + + +def _significant_stage_tokens(learning_goal: str, *, strip_negated: bool = True) -> List[str]: + """Wörter aus Stufen-Lernziel für Text-Match (ohne Füllwörter, ohne Negationssegmente).""" + text = _normalize_phrase(learning_goal) + if strip_negated: + for pat in _STAGE_NEGATION_PATTERNS: + text = re.sub(pat, " ", text) + raw = re.findall(r"[a-zäöüß]{4,}", text, flags=re.IGNORECASE) + out: List[str] = [] + for w in raw: + low = w.lower().replace("ä", "ae").replace("ö", "oe").replace("ü", "ue") + if low in _STAGE_GOAL_STOPWORDS: + continue + if low not in out: + out.append(low) + return out[:10] + + +def parse_stage_goal_constraints( + learning_goal: str, + anti_patterns: Optional[Sequence[str]] = None, +) -> StageGoalConstraints: + """Positiv/Negativ aus Stufen-Lernziel + anti_patterns (Roadmap-Stufe).""" + lg = (learning_goal or "").strip() + if len(lg) < 3: + return StageGoalConstraints() + + norm = _normalize_phrase(lg) + exclude: List[str] = [] + has_negation = False + for pat in _STAGE_NEGATION_PATTERNS: + for m in re.finditer(pat, norm): + has_negation = True + chunk = (m.group(1) or "").strip() + if chunk: + exclude.extend(_expand_stage_exclude_phrase(chunk)) + + for raw in anti_patterns or []: + s = _normalize_phrase(str(raw or "")) + if s: + exclude.extend(_expand_stage_exclude_phrase(s)) + + positive = _significant_stage_tokens(lg, strip_negated=True) + focus_hits = [t for t in positive if t in _STAGE_FOCUS_TOKENS] + strict_positive = bool(focus_hits) or has_negation + + dedup_exclude: List[str] = [] + for item in exclude: + if item and item not in dedup_exclude: + dedup_exclude.append(item) + + return StageGoalConstraints( + positive_tokens=positive, + exclude_phrases=dedup_exclude[:16], + has_negation=has_negation, + strict_positive=strict_positive, + ) + + +def _phrase_excluded_in_blob(phrase: str, blob: str) -> bool: + """Treffer nur wenn das Ausschluss-Thema nicht selbst negiert beschrieben ist.""" + if not phrase or not blob: + return False + if not _phrase_in_blob(phrase, blob): + return False + norm = _normalize_phrase(phrase) + for pat in _STAGE_NEGATION_PATTERNS: + for m in re.finditer(pat, blob): + chunk = _normalize_phrase(m.group(1) or "") + if not chunk: + continue + if norm in chunk or chunk in norm or _phrase_in_blob(norm, chunk): + return False + return True + + +def _blob_matches_stage_excludes(blob: str, exclude_phrases: Sequence[str]) -> bool: + for phrase in exclude_phrases: + if _phrase_excluded_in_blob(phrase, blob): + return True + return False + + +def resolve_path_anti_patterns( + goal_query: str, + *, + semantic_brief: Optional[PlanningSemanticBrief] = None, + extra_context: Optional[str] = None, +) -> List[str]: + """ + Pfadweite Ausschlüsse — nur aus expliziten Quellen, kein Themen-Raten. + + Quellen (in dieser Reihenfolge): + 1. Negationen in Anfrage/Kontext (ohne/kein/nicht …) via parse_stage_goal_constraints + 2. exclude_phrases im Semantic Brief (inkl. LLM/Technik-Regeln) + 3. stage_specs.anti_patterns (Roadmap-Stufe, vom Trainer oder LLM) + + Keine stillen Ausschlüsse aus dem Hauptthema (z. B. „Mawashi“ → kein Kumite). + """ + parts = [str(goal_query or "").strip(), str(extra_context or "").strip()] + combined = " ".join(p for p in parts if p) + if not combined and not semantic_brief: + return [] + + constraints = parse_stage_goal_constraints(combined) if combined else StageGoalConstraints() + out: List[str] = [] + for item in constraints.exclude_phrases: + if item and item not in out: + out.append(item) + + if semantic_brief: + for raw in semantic_brief.exclude_phrases or []: + for expanded in _expand_stage_exclude_phrase(str(raw or "")): + if expanded and expanded not in out: + out.append(expanded) + + return out[:24] + + +def enrich_brief_with_path_constraints( + brief: PlanningSemanticBrief, + goal_query: str, + *, + extra_context: Optional[str] = None, +) -> PlanningSemanticBrief: + """Negationen/Ausschlüsse aus der Gesamtanfrage in den Semantic Brief übernehmen.""" + anti = resolve_path_anti_patterns( + goal_query, + semantic_brief=brief, + extra_context=extra_context, + ) + if not anti: + return brief + exclude = list(brief.exclude_phrases or []) + for item in anti: + if item not in exclude: + exclude.append(item) + return brief.model_copy(update={"exclude_phrases": exclude[:16]}) + + +_MIN_STAGE_FIT_SEMANTIC = 0.30 +_MIN_STAGE_FIT_RELAXED = 0.20 +_MIN_TITLE_EQUIV_SEMANTIC = 0.15 +_MIN_ROADMAP_FALLBACK_RANK = 0.15 + + +def build_stage_match_brief( + *, + learning_goal: str, + anti_patterns: Optional[Sequence[str]] = None, + success_criteria: Optional[Sequence[str]] = None, + load_profile: Optional[Sequence[str]] = None, + phase: Optional[str] = None, + path_context_note: Optional[str] = None, + path_anti_patterns: Optional[Sequence[str]] = None, + path_primary_topic: Optional[str] = None, + path_technique_excludes: Optional[Sequence[str]] = None, + stage_start_state: Optional[str] = None, + stage_target_state: Optional[str] = None, + path_target_state: Optional[str] = None, + contextualized_learning_goal: Optional[str] = None, +) -> PlanningSemanticBrief: + """ + Stufen-zentrierter Semantik-Brief — unabhängig vom Gesamt-Pfad-Thema. + + Primär für Roadmap-Match: Bewertung gegen Titel + Kurzbeschreibung + Übungsziel. + """ + lg = (contextualized_learning_goal or learning_goal or "").strip() + if len(lg) < 3: + return PlanningSemanticBrief(semantic_strength=0.0) + + merged_anti: List[str] = [] + for raw in list(anti_patterns or []) + list(path_anti_patterns or []): + s = str(raw or "").strip() + if s and s not in merged_anti: + merged_anti.append(s) + primary_path = _normalize_phrase(path_primary_topic or "") + if primary_path: + for item in technique_sibling_excludes(primary_path): + if item not in merged_anti: + merged_anti.append(item) + for raw in path_technique_excludes or []: + for expanded in _expand_stage_exclude_phrase(str(raw or "")): + if expanded and expanded not in merged_anti: + merged_anti.append(expanded) + constraints = parse_stage_goal_constraints(lg, merged_anti) + must: List[str] = [] + norm_lg = _normalize_phrase(lg) + if primary_path and primary_path not in must: + must.insert(0, primary_path[:120]) + for token in constraints.positive_tokens: + if token not in must: + must.append(token) + if norm_lg and norm_lg not in must: + must.append(norm_lg[:120]) + for raw in success_criteria or []: + s = _normalize_phrase(str(raw or "")) + if s and s not in must: + must.append(s[:100]) + for raw in load_profile or []: + s = _normalize_phrase(str(raw or "")) + if s and s not in must: + must.append(s[:60]) + + retrieval_parts = [norm_lg] + for raw in (stage_start_state, stage_target_state, path_target_state): + s = _normalize_phrase(str(raw or ""))[:200] + if s and s not in retrieval_parts: + retrieval_parts.append(s) + if path_context_note: + note = _normalize_phrase(path_context_note)[:200] + if note: + retrieval_parts.append(note) + + arc: List[str] = [] + ph = (phase or "").strip().lower() + if ph: + arc.append(ph) + + return PlanningSemanticBrief( + primary_topic="", + topic_type="focus", + must_phrases=must[:12], + exclude_phrases=list(constraints.exclude_phrases)[:12], + development_arc=arc[:4], + retrieval_query=" ".join(p for p in retrieval_parts if p)[:500], + semantic_strength=0.78, + rationale="stage_match_brief", + ) + + +def score_exercise_stage_fit( + *, + title: str, + summary: str, + goal: str, + stage_brief: PlanningSemanticBrief, + variant_names: Optional[Sequence[str]] = None, + step_phase: Optional[str] = None, +) -> Tuple[float, List[str]]: + """Semantik-Score Übung ↔ Stufen-Lernziel (Titel + Summary + Goal).""" + score, reasons = score_exercise_semantic_relevance( + title=title, + summary=summary, + goal=goal, + variant_names=variant_names or [], + brief=stage_brief, + step_phase=step_phase, + ) + blob = _blob_from_fields(title, summary, goal, variant_names or []) + focus_tokens = [ + t + for t in (stage_brief.must_phrases or []) + if t and " " not in t and len(t) >= 4 + ][:6] + if focus_tokens: + hits = sum(1 for t in focus_tokens if _phrase_in_blob(t, blob)) + ratio = hits / len(focus_tokens) + bonus = 0.28 * ratio + if bonus > 0: + score = min(1.0, score + bonus) + if hits >= max(1, len(focus_tokens) // 2): + reasons = ["Stufen-Schwerpunkte im Übungstext", *reasons] + return max(0.0, min(1.0, round(score, 4))), reasons[:4] + + +def exercise_passes_stage_fit( + *, + learning_goal: str, + title: str, + summary: str = "", + goal: str = "", + stage_brief: Optional[PlanningSemanticBrief] = None, + stage_semantic_score: Optional[float] = None, + anti_patterns: Optional[Sequence[str]] = None, + step_phase: Optional[str] = None, + path_primary_topic: Optional[str] = None, + path_technique_excludes: Optional[Sequence[str]] = None, + min_stage_semantic: float = _MIN_STAGE_FIT_SEMANTIC, + relaxed: bool = False, +) -> bool: + """Allgemeines Stufen-Fit-Gate: voller Übungstext vs. Stufen-Brief.""" + lg = (learning_goal or "").strip() + if len(lg) < 3 and not (path_primary_topic or "").strip(): + return True + + blob = _blob_from_fields(title, summary, goal, []) + constraints = parse_stage_goal_constraints(lg, anti_patterns) + if constraints.exclude_phrases and _blob_matches_stage_excludes(blob, constraints.exclude_phrases): + return False + + title_equiv = exercise_title_equivalent_to_stage_goal(title, learning_goal or lg) + + primary_path = (path_primary_topic or "").strip() + if not primary_path and lg: + hit = _find_technique_in_text(_normalize_phrase(lg)) + if hit: + primary_path = hit[0] + tech_excludes = list(path_technique_excludes or []) + if primary_path: + for item in technique_sibling_excludes(primary_path): + if item not in tech_excludes: + tech_excludes.append(item) + if primary_path and not title_equiv and not exercise_passes_technique_path_scope( + primary_topic=primary_path, + title=title, + summary=summary, + goal=goal, + learning_goal=lg, + sibling_excludes=tech_excludes, + relaxed=relaxed, + ): + return False + + brief = stage_brief or build_stage_match_brief( + learning_goal=lg, + anti_patterns=anti_patterns, + ) + stage_sem = stage_semantic_score + if stage_sem is None: + stage_sem, _ = score_exercise_stage_fit( + title=title, + summary=summary, + goal=goal, + stage_brief=brief, + step_phase=step_phase, + ) + + if relaxed: + threshold = _MIN_STAGE_FIT_RELAXED + elif title_equiv: + threshold = _MIN_TITLE_EQUIV_SEMANTIC + else: + threshold = min_stage_semantic + return float(stage_sem or 0.0) >= threshold + + +def apply_stage_match_retrieval_weights(brief: PlanningSemanticBrief) -> Dict[str, float]: + """Roadmap-Stufe: Stufen-Semantik (Ziel/Summary/Goal) dominiert.""" + return { + "semantic": 0.58, + "fulltext": 0.14, + "profile": 0.18, + "progression": 0.04, + "skill": 0.04, + "plan": 0.02, + "repeat_unit": -0.40, + "repeat_group": -0.15, + } + + +def semantic_brief_for_stage( + brief: PlanningSemanticBrief, + *, + learning_goal: str, + phase: Optional[str] = None, + anti_patterns: Optional[Sequence[str]] = None, +) -> PlanningSemanticBrief: + """Legacy: globalen Brief anreichern — bevorzugt build_stage_match_brief für Roadmap-Match.""" + lg = _normalize_phrase(learning_goal) + if not lg: + return brief + constraints = parse_stage_goal_constraints(learning_goal, anti_patterns) + must = list(brief.must_phrases or []) + for token in constraints.positive_tokens[:4]: + if token not in must: + must.append(token) + if lg not in must: + must.insert(0, lg[:120]) + exclude = list(brief.exclude_phrases or []) + for item in constraints.exclude_phrases: + if item not in exclude: + exclude.append(item) + arc = list(brief.development_arc or []) + ph = (phase or "").strip().lower() + if ph and ph not in arc: + arc = [ph, *arc] + strength = max(float(brief.semantic_strength or 0.0), 0.58) + return brief.model_copy( + update={ + "must_phrases": must[:12], + "exclude_phrases": exclude[:12], + "development_arc": arc[:8], + "semantic_strength": min(1.0, strength), + } + ) + + +def exercise_passes_stage_learning_goal_gate( + *, + learning_goal: str, + title: str, + summary: str = "", + goal: str = "", + semantic_score: float = 0.0, + min_semantic: float = 0.20, + relaxed: bool = False, + anti_patterns: Optional[Sequence[str]] = None, + stage_brief: Optional[PlanningSemanticBrief] = None, + stage_semantic_score: Optional[float] = None, + step_phase: Optional[str] = None, +) -> bool: + """Roadmap-Stufe: delegiert an exercise_passes_stage_fit (Titel + Summary + Goal).""" + del semantic_score, min_semantic + return exercise_passes_stage_fit( + learning_goal=learning_goal, + title=title, + summary=summary, + goal=goal, + stage_brief=stage_brief, + stage_semantic_score=stage_semantic_score, + anti_patterns=anti_patterns, + step_phase=step_phase, + relaxed=relaxed, + ) + + def exercise_passes_path_semantic_gate( *, semantic_score: float, @@ -636,16 +1261,101 @@ def exercise_passes_path_semantic_gate( return False +def _pick_roadmap_rank_fallback( + hits: List[Dict[str, Any]], + used_exercise_ids: Set[int], + *, + stage_learning_goal: str, + stage_anti_patterns: Optional[Sequence[str]] = None, + path_primary_topic: Optional[str] = None, + path_technique_excludes: Optional[Sequence[str]] = None, +) -> Optional[Dict[str, Any]]: + """ + Roadmap-Notfall: bester Treffer nach Stufen-Ranking, wenn striktes Gate leer läuft. + + Filtert weiterhin Ausschlüsse und Technik-Scope (Kumite etc.), aber ohne + Mindest-Semantik-Schwelle — so finden auch wortnahe Bibliotheks-Übungen den Slot. + """ + stage_goal = (stage_learning_goal or "").strip() + if not stage_goal or not hits: + return None + + best: Optional[Dict[str, Any]] = None + best_key: Tuple[float, float] = (-1.0, -1.0) + for hit in hits: + try: + eid = int(hit["id"]) + except (TypeError, ValueError, KeyError): + continue + if eid in used_exercise_ids: + continue + title = str(hit.get("title") or "") + summary = str(hit.get("summary") or "") + goal_text = str(hit.get("goal") or hit.get("exercise_goal") or "") + blob = _blob_from_fields(title, summary, goal_text, []) + constraints = parse_stage_goal_constraints(stage_goal, stage_anti_patterns) + if constraints.exclude_phrases and _blob_matches_stage_excludes( + blob, constraints.exclude_phrases + ): + continue + title_equiv = exercise_title_equivalent_to_stage_goal(title, stage_goal) + primary = (path_primary_topic or "").strip() + if primary and not title_equiv: + tech_excludes = list(path_technique_excludes or []) + for item in technique_sibling_excludes(primary): + if item not in tech_excludes: + tech_excludes.append(item) + if not exercise_passes_technique_path_scope( + primary_topic=primary, + title=title, + summary=summary, + goal=goal_text, + learning_goal=stage_goal, + sibling_excludes=tech_excludes, + relaxed=True, + ): + continue + rank_sem = float( + hit.get("stage_rank_semantic") + or hit.get("stage_semantic_score") + or hit.get("semantic_score") + or 0.0 + ) + score = float(hit.get("score") or 0.0) + key = (rank_sem, score) + if key > best_key: + best_key = key + best = hit + if best is None or best_key[0] < _MIN_ROADMAP_FALLBACK_RANK: + return None + return best + + def pick_best_path_hit( hits: List[Dict[str, Any]], used_exercise_ids: Set[int], *, semantic_brief: Optional[PlanningSemanticBrief] = None, + stage_learning_goal: Optional[str] = None, + stage_anti_patterns: Optional[Sequence[str]] = None, + roadmap_stage_match: bool = False, + stage_match_brief: Optional[PlanningSemanticBrief] = None, + path_primary_topic: Optional[str] = None, + path_technique_excludes: Optional[Sequence[str]] = None, ) -> Optional[Dict[str, Any]]: - """Gestufte Auswahl: strikt → relaxed → bester Semantik-Score.""" + """Gestufte Auswahl: strikt → relaxed → optional Notfall-Fallback.""" if not hits: return None + stage_goal = (stage_learning_goal or "").strip() + + stage_brief: Optional[PlanningSemanticBrief] = stage_match_brief + if roadmap_stage_match and stage_goal and stage_brief is None: + stage_brief = build_stage_match_brief( + learning_goal=stage_goal, + anti_patterns=stage_anti_patterns, + ) + def _scan(*, strict: bool) -> Optional[Dict[str, Any]]: best: Optional[Dict[str, Any]] = None best_key: Tuple[float, float] = (-1.0, -1.0) @@ -653,18 +1363,44 @@ def pick_best_path_hit( eid = int(hit["id"]) if eid in used_exercise_ids: continue + title = str(hit.get("title") or "") + summary = str(hit.get("summary") or "") + goal_text = str(hit.get("goal") or hit.get("exercise_goal") or "") sem = float(hit.get("semantic_score") or 0.0) - if semantic_brief and not exercise_passes_path_semantic_gate( - semantic_score=sem, - title=str(hit.get("title") or ""), - summary=str(hit.get("summary") or ""), - goal="", - brief=semantic_brief, - strict=strict, - ): - continue + stage_sem = float( + hit.get("stage_rank_semantic") + or hit.get("stage_semantic_score") + or sem + ) + + if roadmap_stage_match and stage_goal: + if not exercise_passes_stage_fit( + learning_goal=stage_goal, + title=title, + summary=summary, + goal=goal_text, + stage_brief=stage_brief, + stage_semantic_score=stage_sem, + anti_patterns=stage_anti_patterns, + path_primary_topic=path_primary_topic, + path_technique_excludes=path_technique_excludes, + relaxed=not strict, + ): + continue + else: + if semantic_brief and not exercise_passes_path_semantic_gate( + semantic_score=sem, + title=title, + summary=summary, + goal=goal_text, + brief=semantic_brief, + strict=strict, + ): + continue + score = float(hit.get("score") or 0.0) - key = (sem, score) + rank_sem = stage_sem if roadmap_stage_match and stage_goal else sem + key = (rank_sem, score) if key > best_key: best_key = key best = hit @@ -673,11 +1409,25 @@ def pick_best_path_hit( chosen = _scan(strict=True) if chosen: return chosen + + if roadmap_stage_match: + chosen = _scan(strict=False) + if chosen: + return chosen + return _pick_roadmap_rank_fallback( + hits, + used_exercise_ids, + stage_learning_goal=stage_goal, + stage_anti_patterns=stage_anti_patterns, + path_primary_topic=path_primary_topic, + path_technique_excludes=path_technique_excludes, + ) + chosen = _scan(strict=False) if chosen: return chosen - # Notfall: bester verbleibender Treffer mit Semantik > 0 (Thema trotzdem priorisieren) + # Notfall (nur retrieval-first / Brücken): bester verbleibender Treffer fallback: Optional[Dict[str, Any]] = None fallback_key: Tuple[float, float] = (-1.0, -1.0) for hit in hits: @@ -706,8 +1456,22 @@ __all__ = [ "build_semantic_brief", "enrich_target_with_semantic_expectations", "exercise_passes_path_semantic_gate", + "StageGoalConstraints", + "apply_stage_match_retrieval_weights", + "build_stage_match_brief", + "enrich_brief_with_path_constraints", + "exercise_passes_stage_fit", + "exercise_title_equivalent_to_stage_goal", + "resolve_path_primary_topic", + "resolve_path_anti_patterns", + "exercise_passes_stage_learning_goal_gate", "merge_semantic_brief_llm", + "parse_stage_goal_constraints", "pick_best_path_hit", + "exercise_passes_technique_path_scope", + "score_exercise_stage_fit", + "semantic_brief_for_stage", + "technique_sibling_excludes", "resolve_semantic_skill_weights", "score_exercise_semantic_relevance", "semantic_core_phrases", diff --git a/backend/planning_intent_context.py b/backend/planning_intent_context.py new file mode 100644 index 0000000..ce6e774 --- /dev/null +++ b/backend/planning_intent_context.py @@ -0,0 +1,248 @@ +""" +Gemeinsame Intent-Anreicherung für Planungs-Retrieval. + +Progressionsgraph (Roadmap stage_specs) und später Trainingsplanung +(Abschnitt/Slot) nutzen dieselben Bausteine: + Intent-Kontext bauen → Specs finalisieren → Matching-Gates. +""" +from __future__ import annotations + +import re +from dataclasses import dataclass, field +from typing import Any, Dict, List, Mapping, Optional, Sequence + +from planning_exercise_semantics import ( + PlanningSemanticBrief, + resolve_path_anti_patterns, + technique_sibling_excludes, +) + +_NEGATION_CLAUSE_RE = re.compile( + r"\b(?:ohne|kein(?:e|en|er|em)?|nicht)\s+[^,.;\n]+", + flags=re.IGNORECASE, +) + + +def extract_explicit_exclusions(*texts: Optional[str]) -> List[str]: + """Lesbare Negationsklauseln aus Freitext (ohne Themen-Raten).""" + out: List[str] = [] + for raw in texts: + s = (raw or "").strip() + if not s: + continue + for m in _NEGATION_CLAUSE_RE.finditer(s): + clause = m.group(0).strip().rstrip(".,;") + if clause and clause.lower() not in {x.lower() for x in out}: + out.append(clause[:220]) + return out[:12] + + +@dataclass +class PlanningIntentContext: + """Pfad-/Abschnittsweiter Planungs-Intent — domänenneutral.""" + + source_query: str = "" + primary_topic: str = "" + path_anti_patterns: List[str] = field(default_factory=list) + path_success_criteria: List[str] = field(default_factory=list) + explicit_exclusions: List[str] = field(default_factory=list) + context_notes: str = "" + topic_type: str = "general" + technique_sibling_excludes: List[str] = field(default_factory=list) + + def to_api_dict(self) -> Dict[str, Any]: + return { + "source_query": self.source_query, + "primary_topic": self.primary_topic, + "topic_type": self.topic_type, + "path_anti_patterns": self.path_anti_patterns[:16], + "path_success_criteria": self.path_success_criteria[:10], + "explicit_exclusions": self.explicit_exclusions[:10], + "technique_sibling_excludes": self.technique_sibling_excludes[:16], + "context_notes": self.context_notes[:1200] or None, + } + + +def build_planning_intent_context( + goal_query: str, + *, + semantic_brief: Optional[PlanningSemanticBrief] = None, + goal_analysis: Optional[Mapping[str, Any]] = None, + extra_context: Optional[str] = None, + primary_topic: Optional[str] = None, +) -> PlanningIntentContext: + """Intent aus Anfrage, Zielanalyse und optionalem Kontext — ohne Sonderregeln pro Thema.""" + ga = dict(goal_analysis or {}) + notes_parts = [extra_context or ""] + constraints = ga.get("constraints") if isinstance(ga.get("constraints"), dict) else {} + if isinstance(constraints, dict): + trainer_notes = str(constraints.get("trainer_notes") or "").strip() + if trainer_notes: + notes_parts.append(trainer_notes) + + combined_notes = " ".join(p.strip() for p in notes_parts if p and p.strip()) + explicit = extract_explicit_exclusions(goal_query, combined_notes or None) + ga_excluded = constraints.get("excluded_themes") if isinstance(constraints, dict) else None + if isinstance(ga_excluded, list): + for item in ga_excluded: + s = str(item or "").strip() + if s and s.lower() not in {x.lower() for x in explicit}: + explicit.append(s[:220]) + + path_anti = resolve_path_anti_patterns( + goal_query, + semantic_brief=semantic_brief, + extra_context=combined_notes or None, + ) + path_success: List[str] = [] + for item in ga.get("success_criteria") or []: + s = str(item or "").strip() + if s and s not in path_success: + path_success.append(s[:240]) + target = str(ga.get("target_state") or "").strip() + if target and len(target) >= 8: + line = f"Zielzustand erreichbar: {target[:200]}" + if line not in path_success: + path_success.append(line) + + topic = (primary_topic or ga.get("primary_topic") or "").strip() + topic_type = "general" + siblings: List[str] = [] + if semantic_brief: + if not topic: + topic = (semantic_brief.primary_topic or "").strip() + topic_type = (semantic_brief.topic_type or "general").strip().lower() + if topic_type == "technique" and topic: + siblings = technique_sibling_excludes(topic) + for raw in semantic_brief.exclude_phrases or []: + s = str(raw or "").strip() + if s and s.lower() not in {x.lower() for x in siblings}: + siblings.append(s[:120]) + + if topic_type == "technique" and topic: + line = f"Haupttechnik {topic} in Kurzbeschreibung oder Übungsziel erkennbar" + if line not in path_success: + path_success.insert(0, line) + + return PlanningIntentContext( + source_query=(goal_query or "").strip(), + primary_topic=topic, + topic_type=topic_type, + path_anti_patterns=path_anti, + path_success_criteria=path_success, + explicit_exclusions=explicit, + technique_sibling_excludes=siblings[:16], + context_notes=combined_notes[:1200], + ) + + +def _dedupe_preserve(items: Sequence[str], *, limit: int = 14) -> List[str]: + out: List[str] = [] + seen: set[str] = set() + for raw in items: + s = str(raw or "").strip() + if not s: + continue + key = s.lower() + if key in seen: + continue + seen.add(key) + out.append(s[:240]) + if len(out) >= limit: + break + return out + + +def finalize_stage_spec_artifact( + spec: "StageSpecArtifact", + *, + major_step: Optional["MajorStep"] = None, + intent: PlanningIntentContext, +) -> "StageSpecArtifact": + """Pfad-Intent in eine Stufenspezifikation mergen (LLM oder heuristisch).""" + from planning_progression_roadmap import MajorStep, StageSpecArtifact + + learning_goal = (spec.learning_goal or (major_step.learning_goal if major_step else "")).strip() + phase = (major_step.phase if major_step else "").strip().lower() + + anti = _dedupe_preserve( + [ + *(spec.anti_patterns or []), + *intent.explicit_exclusions, + *intent.path_anti_patterns, + *intent.technique_sibling_excludes, + ( + f"andere Technik als {intent.primary_topic}" + if intent.topic_type == "technique" and intent.primary_topic + else "" + ), + ], + limit=14, + ) + stage_start = (spec.start_state or "").strip() + stage_target = (spec.target_state or "").strip() + success = _dedupe_preserve( + [ + *(spec.success_criteria or []), + *intent.path_success_criteria, + (f"Soll-Start der Stufe erreichbar: {stage_start[:180]}" if stage_start else ""), + (f"Stufen-Ziel erreichbar: {stage_target[:180]}" if stage_target else ""), + ( + f"Übung liefert messbar: {learning_goal[:160]}" + if learning_goal + else "" + ), + ( + f"Kurzbeschreibung und Übungsziel passen zur Phase {phase}" + if phase + else "Kurzbeschreibung und Übungsziel passen zum Stufen-Lernziel" + ), + ], + limit=8, + ) + + idx = spec.major_step_index + if major_step is not None: + idx = major_step.index + + return StageSpecArtifact( + major_step_index=idx, + learning_goal=learning_goal, + load_profile=list(spec.load_profile or []), + exercise_type=(spec.exercise_type or "").strip(), + success_criteria=success, + anti_patterns=anti, + ) + + +def finalize_stage_specs_with_intent( + specs: Sequence["StageSpecArtifact"], + major_steps: Sequence["MajorStep"], + *, + intent: PlanningIntentContext, + fallback_specs: Optional[Sequence["StageSpecArtifact"]] = None, +) -> List["StageSpecArtifact"]: + """Alle Stufen mit gleichem Pfad-Intent anreichern; fehlende Indizes aus Fallback.""" + from planning_progression_roadmap import MajorStep, StageSpecArtifact + + by_idx = {int(s.major_step_index): s for s in specs} + fallback_by_idx = {int(s.major_step_index): s for s in (fallback_specs or [])} + out: List[StageSpecArtifact] = [] + for major in major_steps: + raw = by_idx.get(major.index) or fallback_by_idx.get(major.index) + if raw is None: + raw = StageSpecArtifact( + major_step_index=major.index, + learning_goal=major.learning_goal, + ) + out.append(finalize_stage_spec_artifact(raw, major_step=major, intent=intent)) + return out + + +__all__ = [ + "PlanningIntentContext", + "build_planning_intent_context", + "extract_explicit_exclusions", + "finalize_stage_spec_artifact", + "finalize_stage_specs_with_intent", +] diff --git a/backend/planning_path_qa_pipeline.py b/backend/planning_path_qa_pipeline.py new file mode 100644 index 0000000..2073880 --- /dev/null +++ b/backend/planning_path_qa_pipeline.py @@ -0,0 +1,176 @@ +""" +Mehrstufige Pfad-QS — Findings pro Stufe, daraus Optimierungspotenziale ableiten. + +Stufen (allgemein, domänenneutral): + 1. deterministische Gates (Technik-Scope, Ausschlüsse, Stufen-Fit) + 2. Übergangs-Kohärenz (Lücken zwischen Schritten) + 3. LLM-Ganzpfad-Bewertung (Empfehlungen, keine Auto-Patches) +""" +from __future__ import annotations + +from typing import Any, Dict, List, Mapping, Optional, Sequence + + +_ACTION_BY_ISSUE: Dict[str, str] = { + "technique_scope": "rematch_slot", + "path_exclude": "rematch_slot", + "stage_mismatch": "refine_stage_spec", + "off_topic": "rematch_slot", + "gap": "bridge_or_gap_fill", + "large_gap": "bridge_or_gap_fill", + "roadmap_unfilled": "rematch_slot", +} + + +def _action_for_finding(finding: Mapping[str, Any]) -> str: + issue = str(finding.get("issue") or finding.get("type") or "").strip().lower() + if finding.get("is_large_gap"): + return "bridge_or_gap_fill" + return _ACTION_BY_ISSUE.get(issue, "review") + + +def _hint_from_finding(finding: Mapping[str, Any], *, tier: str) -> Dict[str, Any]: + step_index = finding.get("step_index") + if step_index is None: + step_index = finding.get("major_step_index") + issue = str(finding.get("issue") or finding.get("type") or tier) + action = _action_for_finding(finding) + title = str(finding.get("title") or finding.get("removed_title") or "").strip() + reasons = finding.get("reasons") or [] + reason = reasons[0] if reasons else str(finding.get("rationale") or finding.get("detail") or "") + + hint: Dict[str, Any] = { + "tier": tier, + "action": action, + "issue": issue, + "step_index": step_index, + "title": title or None, + "reason": (reason or "")[:400] or None, + } + if finding.get("roadmap_learning_goal"): + hint["roadmap_learning_goal"] = finding.get("roadmap_learning_goal") + if finding.get("roadmap_major_step_index") is not None: + hint["roadmap_major_step_index"] = finding.get("roadmap_major_step_index") + return {k: v for k, v in hint.items() if v is not None and v != ""} + + +def derive_optimization_hints( + tiers: Sequence[Mapping[str, Any]], +) -> List[Dict[str, Any]]: + """Aus QS-Stufen konkrete Optimierungsaktionen (ohne anfrage-spezifische Heuristiken).""" + hints: List[Dict[str, Any]] = [] + seen: set[tuple] = set() + for tier in tiers: + tier_id = str(tier.get("id") or "") + for finding in tier.get("findings") or []: + if not isinstance(finding, dict): + continue + hint = _hint_from_finding(finding, tier=tier_id) + key = ( + hint.get("tier"), + hint.get("action"), + hint.get("step_index"), + hint.get("issue"), + ) + if key in seen: + continue + seen.add(key) + hints.append(hint) + return hints[:24] + + +def run_multistage_path_qa( + *, + off_topic_steps: Sequence[Mapping[str, Any]], + stripped_off_topic: Sequence[Mapping[str, Any]], + gaps: Sequence[Mapping[str, Any]], + llm_qa: Optional[Mapping[str, Any]] = None, + llm_applied: bool = False, + roadmap_unfilled: Optional[Sequence[Mapping[str, Any]]] = None, +) -> Dict[str, Any]: + """Orchestriert QS-Stufen und leitet Optimierungspotenziale ab.""" + tier1_findings: List[Dict[str, Any]] = [] + for item in stripped_off_topic or off_topic_steps or []: + if isinstance(item, dict): + tier1_findings.append(dict(item)) + + tier2_findings: List[Dict[str, Any]] = [dict(g) for g in gaps if isinstance(g, dict)] + + tier3_findings: List[Dict[str, Any]] = [] + llm_recommendations: List[str] = [] + if llm_applied and llm_qa: + q_score = llm_qa.get("quality_score") + tier3_findings.append( + { + "issue": "llm_assessment", + "quality_score": q_score, + "overall_ok": llm_qa.get("overall_ok"), + "detail": llm_qa.get("summary") or llm_qa.get("assessment"), + } + ) + for raw in llm_qa.get("recommendations") or llm_qa.get("suggestions") or []: + s = str(raw or "").strip() + if s: + llm_recommendations.append(s[:500]) + + unfilled = list(roadmap_unfilled or []) + if unfilled: + for item in unfilled: + if isinstance(item, (list, tuple)) and len(item) >= 2: + idx, spec = item[0], item[1] + tier1_findings.append( + { + "issue": "roadmap_unfilled", + "step_index": int(idx), + "roadmap_major_step_index": getattr(spec, "major_step_index", idx), + "roadmap_learning_goal": getattr(spec, "learning_goal", None), + "reasons": ["Keine passende Übung für Roadmap-Stufe"], + } + ) + elif isinstance(item, dict): + tier1_findings.append({**item, "issue": item.get("issue") or "roadmap_unfilled"}) + + tiers: List[Dict[str, Any]] = [ + { + "id": "tier1_deterministic", + "label": "Deterministische Gates", + "finding_count": len(tier1_findings), + "findings": tier1_findings[:16], + }, + { + "id": "tier2_transitions", + "label": "Übergangs-Kohärenz", + "finding_count": len(tier2_findings), + "findings": tier2_findings[:12], + }, + { + "id": "tier3_llm_holistic", + "label": "LLM-Ganzpfad", + "finding_count": len(tier3_findings), + "findings": tier3_findings, + "recommendations": llm_recommendations[:8], + "applied": bool(llm_applied), + }, + ] + optimization_hints = derive_optimization_hints(tiers) + for rec in llm_recommendations[:5]: + optimization_hints.append( + { + "tier": "tier3_llm_holistic", + "action": "review_roadmap", + "issue": "llm_recommendation", + "reason": rec, + } + ) + + return { + "qa_tiers": tiers, + "optimization_hints": optimization_hints[:28], + "optimization_hint_count": len(optimization_hints), + } + + +__all__ = [ + "derive_optimization_hints", + "run_multistage_path_qa", +] diff --git a/backend/planning_path_rematch.py b/backend/planning_path_rematch.py new file mode 100644 index 0000000..4bc19dd --- /dev/null +++ b/backend/planning_path_rematch.py @@ -0,0 +1,245 @@ +""" +Auto-Rematch nach Pfad-QS — betroffene Roadmap-Slots erneut matchen (Phase A/B). +""" +from __future__ import annotations + +from typing import Any, Dict, List, Mapping, Optional, Sequence, Set, Tuple + +from planning_progression_roadmap import ProgressionRoadmapContext, StageSpecArtifact + + +def collect_rematch_slot_indices( + *, + stripped_off_topic: Sequence[Mapping[str, Any]], + off_topic_steps: Sequence[Mapping[str, Any]], + optimization_hints: Sequence[Mapping[str, Any]], + stage_specs: Sequence[StageSpecArtifact], + roadmap_unfilled: Optional[Sequence[Any]] = None, +) -> Tuple[Set[int], Dict[int, str]]: + """Major-Step-Indizes für rematch_slot + Begründung pro Slot.""" + spec_by_pos = list(stage_specs) + indices: Set[int] = set() + reasons: Dict[int, str] = {} + + def _register(midx: int, reason: str) -> None: + indices.add(int(midx)) + if midx not in reasons and reason: + reasons[int(midx)] = reason[:400] + + def _resolve_major(item: Mapping[str, Any]) -> Optional[int]: + raw = item.get("roadmap_major_step_index") + if raw is not None: + return int(raw) + si = item.get("step_index") + if si is not None: + pos = int(si) + if 0 <= pos < len(spec_by_pos): + return int(spec_by_pos[pos].major_step_index) + return None + + for item in stripped_off_topic or []: + if not isinstance(item, dict): + continue + midx = _resolve_major(item) + if midx is not None: + issue = str(item.get("issue") or "stripped_off_topic") + r = (item.get("reasons") or [issue])[0] if item.get("reasons") else issue + _register(midx, str(r)) + + for item in off_topic_steps or []: + if not isinstance(item, dict): + continue + midx = _resolve_major(item) + if midx is None: + continue + issue = str(item.get("issue") or "off_topic") + r = (item.get("reasons") or [issue])[0] if item.get("reasons") else issue + _register(midx, str(r)) + + for hint in optimization_hints or []: + if not isinstance(hint, dict): + continue + if str(hint.get("action") or "") != "rematch_slot": + continue + midx = _resolve_major(hint) + if midx is not None: + _register(midx, str(hint.get("reason") or hint.get("issue") or "rematch_slot")) + + for item in roadmap_unfilled or []: + if isinstance(item, (list, tuple)) and len(item) >= 2: + idx, spec = item[0], item[1] + midx = getattr(spec, "major_step_index", idx) + _register(int(midx), "Keine passende Übung für Roadmap-Stufe") + elif isinstance(item, dict): + midx = _resolve_major(item) + if midx is not None: + issue = str(item.get("issue") or "roadmap_unfilled") + r = (item.get("reasons") or [issue])[0] if item.get("reasons") else issue + _register(midx, str(r)) + + return indices, reasons + + +def _context_before_major( + steps_by_major: Mapping[int, Mapping[str, Any]], + target_major: int, +) -> Tuple[List[int], Optional[int], Optional[int]]: + planned: List[int] = [] + anchor: Optional[int] = None + anchor_vid: Optional[int] = None + for midx in sorted(steps_by_major): + if midx >= target_major: + break + step = steps_by_major[midx] + eid = step.get("exercise_id") + if eid is not None: + planned.append(int(eid)) + anchor = int(eid) + vid = step.get("variant_id") + anchor_vid = int(vid) if vid is not None else None + return planned, anchor, anchor_vid + + +def rematch_roadmap_slots( + cur, + *, + tenant, + body, + goal_query: str, + max_steps: int, + semantic_brief, + path_target_profile, + path_intent: str, + roadmap_ctx: ProgressionRoadmapContext, + steps: Sequence[Mapping[str, Any]], + slot_indices: Set[int], + rematch_reasons: Mapping[int, str], + match_slot_fn, +) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]], List[Tuple[int, StageSpecArtifact]]]: + """ + Ersetzt nur betroffene Slots; andere Schritte und used-Set bleiben konsistent. + + match_slot_fn: _match_roadmap_slot aus path_builder (Injection gegen Zirkularität). + """ + stage_specs = list(roadmap_ctx.stage_specs or [])[:max_steps] + if not stage_specs or not slot_indices: + return list(steps), [], [] + + spec_by_major = {int(s.major_step_index): s for s in stage_specs} + steps_by_major: Dict[int, Dict[str, Any]] = {} + for raw in steps: + step = dict(raw) + midx = step.get("roadmap_major_step_index") + if midx is not None: + steps_by_major[int(midx)] = step + + rematch_log: List[Dict[str, Any]] = [] + new_unfilled: List[Tuple[int, StageSpecArtifact]] = [] + + for major_idx in sorted(slot_indices): + stage_spec = spec_by_major.get(int(major_idx)) + if stage_spec is None: + continue + step_index = next( + (i for i, sp in enumerate(stage_specs) if int(sp.major_step_index) == int(major_idx)), + major_idx, + ) + old = steps_by_major.pop(int(major_idx), None) + used = { + int(s["exercise_id"]) + for m, s in steps_by_major.items() + if s.get("exercise_id") is not None + } + if old and old.get("exercise_id") is not None: + used.add(int(old["exercise_id"])) + planned_ids, anchor_id, anchor_variant_id = _context_before_major( + steps_by_major, int(major_idx) + ) + + new_step, unfilled_spec = match_slot_fn( + 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=len(stage_specs), + planned_ids=planned_ids, + anchor_id=anchor_id, + anchor_variant_id=anchor_variant_id, + used=used, + ) + + reason = str(rematch_reasons.get(int(major_idx)) or "rematch_slot") + if new_step: + steps_by_major[int(major_idx)] = new_step + rematch_log.append( + { + "roadmap_major_step_index": int(major_idx), + "action": "replaced", + "reason": reason, + "replaced_exercise_id": old.get("exercise_id") if old else None, + "replaced_title": old.get("title") if old else None, + "new_exercise_id": new_step.get("exercise_id"), + "new_title": new_step.get("title"), + } + ) + else: + if unfilled_spec is not None: + new_unfilled.append((step_index, unfilled_spec)) + rematch_log.append( + { + "roadmap_major_step_index": int(major_idx), + "action": "rematch_unfilled", + "reason": reason, + "replaced_exercise_id": old.get("exercise_id") if old else None, + "replaced_title": old.get("title") if old else None, + } + ) + + ordered: List[Dict[str, Any]] = [] + for spec in sorted(stage_specs, key=lambda s: s.major_step_index): + midx = int(spec.major_step_index) + if midx in steps_by_major: + ordered.append(steps_by_major[midx]) + + return ordered, rematch_log, new_unfilled + + +def prune_stripped_after_rematch( + stripped_off_topic: Sequence[Mapping[str, Any]], + rematch_log: Sequence[Mapping[str, Any]], +) -> List[Dict[str, Any]]: + """Entfernt aus stripped_off_topic Slots, die per Rematch ersetzt wurden.""" + replaced: Set[int] = set() + for entry in rematch_log or []: + if not isinstance(entry, dict): + continue + if str(entry.get("action") or "") != "replaced": + continue + midx = entry.get("roadmap_major_step_index") + if midx is not None: + replaced.add(int(midx)) + if not replaced: + return list(stripped_off_topic or []) + out: List[Dict[str, Any]] = [] + for item in stripped_off_topic or []: + if not isinstance(item, dict): + continue + midx = item.get("roadmap_major_step_index") + if midx is not None and int(midx) in replaced: + continue + out.append(dict(item)) + return out + + +__all__ = [ + "collect_rematch_slot_indices", + "prune_stripped_after_rematch", + "rematch_roadmap_slots", +] diff --git a/backend/planning_progression_roadmap.py b/backend/planning_progression_roadmap.py index e1e0a6c..dfab0c2 100644 --- a/backend/planning_progression_roadmap.py +++ b/backend/planning_progression_roadmap.py @@ -104,6 +104,10 @@ class RoadmapArtifact(BaseModel): class StageSpecArtifact(BaseModel): major_step_index: int = Field(ge=0) learning_goal: str = "" + """Soll-Start dieser Stufe (= Zielzustand der vorherigen Stufe / Pfad-Start).""" + start_state: str = "" + """Zielzustand dieser Stufe (= Soll für den nächsten Schritt).""" + target_state: str = "" load_profile: List[str] = Field(default_factory=list) exercise_type: str = "" success_criteria: List[str] = Field(default_factory=list) @@ -298,6 +302,8 @@ def try_llm_stage_specs( goal_query: str, goal_analysis: GoalAnalysisArtifact, major_steps: Sequence[MajorStep], + intent_context: Optional[Mapping[str, Any]] = None, + semantic_brief: Optional[PlanningSemanticBrief] = None, ) -> Tuple[Optional[List[StageSpecArtifact]], bool]: obj = _run_prompt_json( cur, @@ -306,6 +312,11 @@ def try_llm_stage_specs( "goal_query": goal_query or "", "goal_analysis_json": json.dumps(goal_analysis.model_dump(), ensure_ascii=False), "major_steps_json": json.dumps([m.model_dump() for m in major_steps], ensure_ascii=False), + "intent_context_json": json.dumps(dict(intent_context or {}), ensure_ascii=False), + "semantic_brief_json": json.dumps( + brief_to_summary_dict(semantic_brief) if semantic_brief else {}, + ensure_ascii=False, + ), }, ) if not obj: @@ -522,9 +533,14 @@ def build_goal_analysis( if notes.strip(): criteria.append(f"Berücksichtigung: {notes.strip()[:200]}") + from planning_intent_context import extract_explicit_exclusions + constraints: Dict[str, Any] = {"partner_required": False, "group_analysis": False} if notes.strip(): constraints["trainer_notes"] = notes.strip()[:500] + excluded = extract_explicit_exclusions(goal_query, notes or None) + if excluded: + constraints["excluded_themes"] = excluded return GoalAnalysisArtifact( primary_topic=topic, @@ -840,19 +856,31 @@ def build_roadmap_unfilled_gap_specs( "roadmap_major_step_index": stage_spec.major_step_index, } ) - return specs[:5] + return specs[:12] def build_stage_specs( major_steps: Sequence[MajorStep], *, goal_analysis: GoalAnalysisArtifact, + goal_query: str = "", + semantic_brief: Optional[PlanningSemanticBrief] = None, ) -> List[StageSpecArtifact]: """Phase C — Stufenspezifikation je Major Step (heuristisch).""" + from planning_exercise_semantics import resolve_path_anti_patterns + topic = goal_analysis.primary_topic or "Technik" + path_anti = resolve_path_anti_patterns(goal_query, semantic_brief=semantic_brief) specs: List[StageSpecArtifact] = [] for step in major_steps: phase = (step.phase or "vertiefung").lower() + anti = [ + "reine Kraftübung ohne Technikbezug", + f"andere Technik als {topic}" if topic else "themenfremde Übung", + ] + for item in path_anti: + if item not in anti: + anti.append(item) specs.append( StageSpecArtifact( major_step_index=step.index, @@ -863,10 +891,7 @@ def build_stage_specs( f"Bezug zu {topic}", f"Phase {phase} erkennbar im Übungsziel", ], - anti_patterns=[ - "reine Kraftübung ohne Technikbezug", - f"andere Technik als {topic}" if topic else "themenfremde Übung", - ], + anti_patterns=anti[:14], ) ) return specs @@ -920,6 +945,8 @@ def roadmap_context_from_override( StageSpecArtifact( major_step_index=i, learning_goal=(spec.learning_goal or majors[i].learning_goal).strip(), + start_state=(spec.start_state or "").strip(), + target_state=(spec.target_state or "").strip(), load_profile=list(spec.load_profile or []), exercise_type=(spec.exercise_type or "").strip(), success_criteria=list(spec.success_criteria or []), @@ -927,19 +954,81 @@ def roadmap_context_from_override( ) ) if not all(s.exercise_type for s in stage_specs): - rebuilt = build_stage_specs(majors, goal_analysis=goal_analysis) + rebuilt = build_stage_specs( + majors, + goal_analysis=goal_analysis, + goal_query=goal_query.strip(), + semantic_brief=semantic_brief, + ) for i, spec in enumerate(stage_specs): if not spec.exercise_type: spec.exercise_type = rebuilt[i].exercise_type if not spec.load_profile: spec.load_profile = list(rebuilt[i].load_profile) else: - stage_specs = build_stage_specs(majors, goal_analysis=goal_analysis) + stage_specs = build_stage_specs( + majors, + goal_analysis=goal_analysis, + goal_query=goal_query.strip(), + semantic_brief=semantic_brief, + ) + + from planning_exercise_semantics import enrich_brief_with_path_constraints + from planning_intent_context import ( + build_planning_intent_context, + finalize_stage_specs_with_intent, + ) + + enriched_brief = enrich_brief_with_path_constraints( + semantic_brief, + goal_query.strip(), + extra_context=_merge_roadmap_notes( + structured.roadmap_notes if structured else None, + structured.start_situation if structured else None, + structured.target_state if structured else None, + ), + ) + intent = build_planning_intent_context( + goal_query.strip(), + semantic_brief=enriched_brief, + goal_analysis=goal_analysis.model_dump(), + extra_context=_merge_roadmap_notes( + structured.roadmap_notes if structured else None, + structured.start_situation if structured else None, + structured.target_state if structured else None, + ), + primary_topic=goal_analysis.primary_topic, + ) + stage_specs = finalize_stage_specs_with_intent( + stage_specs, + majors, + intent=intent, + fallback_specs=build_stage_specs( + majors, + goal_analysis=goal_analysis, + goal_query=goal_query.strip(), + semantic_brief=enriched_brief, + ), + ) + from planning_stage_context import derive_stage_specs_transition_states, resolve_path_start_target + + path_start, path_target = resolve_path_start_target( + structured=structured, + goal_analysis=goal_analysis, + ) + stage_specs = derive_stage_specs_transition_states( + stage_specs, + majors, + path_start=path_start, + path_target=path_target, + goal_analysis=goal_analysis, + ) return ProgressionRoadmapContext( goal_query=goal_query.strip(), max_steps=effective_max, semantic_brief=brief_to_summary_dict(semantic_brief), + resolved_structured=structured, goal_analysis=goal_analysis, roadmap=RoadmapArtifact(major_steps=majors), stage_specs=stage_specs, @@ -1103,19 +1192,72 @@ def run_progression_roadmap_pipeline( ) ctx.roadmap = roadmap - stage_specs = build_stage_specs(roadmap.major_steps, goal_analysis=goal_analysis) + from planning_exercise_semantics import enrich_brief_with_path_constraints + from planning_intent_context import ( + build_planning_intent_context, + finalize_stage_specs_with_intent, + ) + + brief = enrich_brief_with_path_constraints( + brief, + goal_query, + extra_context=_merge_roadmap_notes( + resolved.roadmap_notes, + resolved.start_situation, + resolved.target_state, + ), + ) + intent = build_planning_intent_context( + goal_query, + semantic_brief=brief, + goal_analysis=goal_analysis.model_dump(), + extra_context=_merge_roadmap_notes( + resolved.roadmap_notes, + resolved.start_situation, + resolved.target_state, + ), + primary_topic=goal_analysis.primary_topic, + ) + + heuristic_specs = build_stage_specs( + roadmap.major_steps, + goal_analysis=goal_analysis, + goal_query=goal_query, + semantic_brief=brief, + ) + stage_specs = list(heuristic_specs) if include_llm_roadmap and cur is not None: llm_specs, spec_ok = try_llm_stage_specs( cur, goal_query=llm_goal_query, goal_analysis=goal_analysis, major_steps=roadmap.major_steps, + intent_context=intent.to_api_dict(), + semantic_brief=brief, ) if spec_ok and llm_specs: - stage_specs = llm_specs + stage_specs = list(llm_specs) ctx.llm_stage_spec_applied = True ctx.prompt_slugs.append(PROMPT_SLUG_STAGE_SPEC) - ctx.stage_specs = stage_specs + ctx.stage_specs = finalize_stage_specs_with_intent( + stage_specs, + roadmap.major_steps, + intent=intent, + fallback_specs=heuristic_specs, + ) + from planning_stage_context import derive_stage_specs_transition_states, resolve_path_start_target + + path_start, path_target = resolve_path_start_target( + structured=resolved, + goal_analysis=goal_analysis, + ) + ctx.stage_specs = derive_stage_specs_transition_states( + ctx.stage_specs, + roadmap.major_steps, + path_start=path_start, + path_target=path_target, + goal_analysis=goal_analysis, + ) if ctx.llm_goal_analysis_applied or ctx.llm_roadmap_applied or ctx.llm_stage_spec_applied: ctx.pipeline_phase = "roadmap_v1_llm" diff --git a/backend/planning_skill_expectations.py b/backend/planning_skill_expectations.py new file mode 100644 index 0000000..e996349 --- /dev/null +++ b/backend/planning_skill_expectations.py @@ -0,0 +1,334 @@ +""" +Wiederverwendbare Fähigkeiten-Erwartungen für Planungs-KI. + +Domänen-Scopes (gleiches Input-/Output-Modell): +- ``progression_stage`` — ein Major Step / stage_spec im Progressionsgraphen +- ``progression_path`` — gesamter Pfad (Ziel + Start/Ziel) +- ``training_section`` — Abschnitt einer Trainingseinheit (Phase G, später) +- ``framework_slot`` — Rahmen-Session-Slot (Phase G, später) + +Konsumenten mergen ``skill_weights`` in ``PlanningTargetProfile`` (Retrieval) +oder liefern ``expected_skills`` an Übungs-KI (planning_context). +""" +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any, Dict, List, Mapping, Optional, Sequence, Tuple + +from planning_exercise_profiles import _merge_weight_maps, _normalize_weight_map +from planning_exercise_semantics import PlanningSemanticBrief, resolve_semantic_skill_weights +from planning_exercise_text_signals import _load_skills_for_text_match, _match_skills_in_text + +# Scope-Strings — stabil für API/UI und spätere Trainingsplanung +SCOPE_PROGRESSION_STAGE = "progression_stage" +SCOPE_PROGRESSION_PATH = "progression_path" +SCOPE_TRAINING_SECTION = "training_section" +SCOPE_FRAMEWORK_SLOT = "framework_slot" + +_LOAD_PROFILE_SKILL_TERMS: Dict[str, Tuple[str, ...]] = { + "koordination": ("Koordination",), + "präzision": ("Präzision",), + "praezision": ("Präzision",), + "kraft": ("Kraft", "Kime"), + "geschwindigkeit": ("Geschwindigkeit", "Schnelligkeit"), + "schnelligkeit": ("Schnelligkeit", "Geschwindigkeit"), + "timing": ("Timing", "Reaktion"), + "reaktion": ("Reaktion", "Timing"), + "distanz": ("Distanz",), + "raum": ("Raum", "Distanz"), + "gleichgewicht": ("Gleichgewicht",), + "kime": ("Kime",), + "ausdauer": ("Ausdauer",), + "beweglichkeit": ("Beweglichkeit",), +} + + +@dataclass +class PlanningSkillExpectationInput: + scope: str = SCOPE_PROGRESSION_STAGE + primary_topic: str = "" + goal_query: str = "" + start_situation: str = "" + target_state: str = "" + stage_learning_goal: str = "" + roadmap_notes: str = "" + load_profile: List[str] = field(default_factory=list) + phase: str = "" + skill_hints: List[str] = field(default_factory=list) + + +@dataclass +class PlanningSkillExpectationItem: + skill_id: int + skill_name: str + weight: float + source: str + + +@dataclass +class PlanningSkillExpectations: + scope: str + skill_weights: Dict[int, float] + items: List[PlanningSkillExpectationItem] + sources: List[str] + + def to_api_dict(self) -> Dict[str, Any]: + return { + "scope": self.scope, + "sources": list(self.sources), + "expected_skills": [ + { + "skill_id": it.skill_id, + "skill_name": it.skill_name, + "weight": round(it.weight, 4), + "source": it.source, + } + for it in self.items + ], + } + + +def _norm_load_key(s: str) -> str: + return (s or "").strip().lower().replace("ä", "ae").replace("ö", "oe").replace("ü", "ue") + + +def _text_blob(inp: PlanningSkillExpectationInput) -> str: + parts = [ + inp.primary_topic, + inp.goal_query, + inp.start_situation, + inp.target_state, + inp.stage_learning_goal, + inp.roadmap_notes, + inp.phase, + " ".join(inp.load_profile or []), + " ".join(inp.skill_hints or []), + ] + return "\n".join(p for p in parts if (p or "").strip()).lower() + + +def _resolve_skills_by_name_terms( + cur, + terms: Sequence[str], + *, + weight: float = 0.9, + source: str, + weights: Dict[int, float], + items: Dict[int, PlanningSkillExpectationItem], +) -> bool: + found = False + for name in terms: + if not name: + continue + cur.execute( + """ + SELECT id, name FROM skills + WHERE (status IS NULL OR status = 'active') + AND LOWER(name) LIKE %s + ORDER BY CASE WHEN LOWER(name) = %s THEN 0 WHEN LOWER(name) LIKE %s THEN 1 ELSE 2 END, + LENGTH(name) ASC + LIMIT 1 + """, + (f"%{name.lower()}%", name.lower(), f"{name.lower()}%"), + ) + row = cur.fetchone() + if not row: + continue + sid = int(row["id"]) + w = max(weights.get(sid, 0.0), weight) + weights[sid] = w + items[sid] = PlanningSkillExpectationItem( + skill_id=sid, + skill_name=str(row.get("name") or "").strip(), + weight=w, + source=source, + ) + found = True + return found + + +def _merge_weights_into( + weights: Dict[int, float], + items: Dict[int, PlanningSkillExpectationItem], + incoming: Dict[int, float], + *, + source: str, + skill_rows: Sequence[Tuple[int, str, int]], +) -> None: + name_by_id = {sid: name for sid, name, _ in skill_rows} + for sid, w in incoming.items(): + if w <= 0: + continue + merged = max(weights.get(sid, 0.0), float(w)) + weights[sid] = merged + items[sid] = PlanningSkillExpectationItem( + skill_id=sid, + skill_name=name_by_id.get(sid, f"Skill #{sid}"), + weight=merged, + source=source, + ) + + +def build_planning_skill_expectations( + cur, + inp: PlanningSkillExpectationInput, + *, + semantic_brief: Optional[PlanningSemanticBrief] = None, +) -> PlanningSkillExpectations: + """Deterministisch: Thema + Text + load_profile → skill_weights.""" + weights: Dict[int, float] = {} + items: Dict[int, PlanningSkillExpectationItem] = {} + sources: List[str] = [] + + skill_rows = _load_skills_for_text_match(cur) + + if semantic_brief is not None: + topic_weights = resolve_semantic_skill_weights(cur, semantic_brief) + if topic_weights: + sources.append("semantic_topic") + _merge_weights_into( + weights, items, topic_weights, source="semantic_topic", skill_rows=skill_rows + ) + + blob = _text_blob(inp) + if blob: + text_weights = _match_skills_in_text(blob, skill_rows) + if text_weights: + sources.append("text_match") + _merge_weights_into( + weights, items, text_weights, source="text_match", skill_rows=skill_rows + ) + + load_found = False + for raw in inp.load_profile or []: + key = _norm_load_key(str(raw)) + terms = _LOAD_PROFILE_SKILL_TERMS.get(key, (str(raw).strip(),) if key else ()) + if _resolve_skills_by_name_terms( + cur, terms, weight=0.88, source="load_profile", weights=weights, items=items + ): + load_found = True + if load_found and "load_profile" not in sources: + sources.append("load_profile") + + normalized = _normalize_weight_map(weights) + out_items = sorted( + [ + PlanningSkillExpectationItem( + skill_id=sid, + skill_name=items[sid].skill_name, + weight=normalized[sid], + source=items[sid].source, + ) + for sid in normalized + ], + key=lambda x: (-x.weight, x.skill_name.lower()), + )[:8] + + return PlanningSkillExpectations( + scope=inp.scope or SCOPE_PROGRESSION_STAGE, + skill_weights=normalized, + items=out_items, + sources=sources, + ) + + +def expectation_input_from_progression_stage( + *, + goal_query: str, + goal_analysis: Optional[Mapping[str, Any]] = None, + resolved_structured: Optional[Mapping[str, Any]] = None, + stage_spec: Optional[Mapping[str, Any]] = None, + semantic_brief_summary: Optional[Mapping[str, Any]] = None, + major_step: Optional[Mapping[str, Any]] = None, +) -> PlanningSkillExpectationInput: + """Roadmap-Stufe → PlanningSkillExpectationInput (Progressionsgraph).""" + ga = dict(goal_analysis or {}) + rs = dict(resolved_structured or {}) + spec = dict(stage_spec or {}) + brief = dict(semantic_brief_summary or {}) + major = dict(major_step or {}) + + skill_hints: List[str] = [] + for item in (brief.get("must_phrases") or [])[:4]: + s = str(item or "").strip() + if s: + skill_hints.append(s) + + return PlanningSkillExpectationInput( + scope=SCOPE_PROGRESSION_STAGE, + primary_topic=str(ga.get("primary_topic") or brief.get("primary_topic") or "").strip(), + goal_query=(goal_query or "").strip(), + start_situation=str(rs.get("start_situation") or ga.get("start_assumption") or "").strip(), + target_state=str(rs.get("target_state") or ga.get("target_state") or "").strip(), + stage_learning_goal=str( + spec.get("learning_goal") or major.get("learning_goal") or "" + ).strip(), + roadmap_notes=str(rs.get("roadmap_notes") or "").strip(), + load_profile=[str(x).strip() for x in (spec.get("load_profile") or []) if str(x).strip()], + phase=str(spec.get("phase") or major.get("phase") or "").strip(), + skill_hints=skill_hints, + ) + + +def expectation_input_from_progression_path( + *, + goal_query: str, + goal_analysis: Optional[Mapping[str, Any]] = None, + resolved_structured: Optional[Mapping[str, Any]] = None, + semantic_brief_summary: Optional[Mapping[str, Any]] = None, +) -> PlanningSkillExpectationInput: + """Gesamtpfad-Kontext (z. B. einmaliges Pfad-Profil).""" + ga = dict(goal_analysis or {}) + rs = dict(resolved_structured or {}) + brief = dict(semantic_brief_summary or {}) + skill_hints: List[str] = [] + for item in (brief.get("must_phrases") or [])[:4]: + s = str(item or "").strip() + if s: + skill_hints.append(s) + return PlanningSkillExpectationInput( + scope=SCOPE_PROGRESSION_PATH, + primary_topic=str(ga.get("primary_topic") or brief.get("primary_topic") or "").strip(), + goal_query=(goal_query or "").strip(), + start_situation=str(rs.get("start_situation") or ga.get("start_assumption") or "").strip(), + target_state=str(rs.get("target_state") or ga.get("target_state") or "").strip(), + roadmap_notes=str(rs.get("roadmap_notes") or "").strip(), + skill_hints=skill_hints, + ) + + +def apply_expectations_to_target(target, expectations: PlanningSkillExpectations): + """Merge Erwartungs-Skills in PlanningTargetProfile (Retrieval).""" + from planning_exercise_semantics import enrich_target_with_semantic_expectations + + if not expectations.skill_weights: + return target + return enrich_target_with_semantic_expectations( + target, skill_weights=dict(expectations.skill_weights) + ) + + +def merge_expectation_skill_weights( + base: Dict[int, float], + extra: Dict[int, float], + *, + extra_scale: float = 1.0, +) -> Dict[int, float]: + merged = _merge_weight_maps(base, extra, scale=extra_scale) + return _normalize_weight_map(merged) + + +__all__ = [ + "SCOPE_FRAMEWORK_SLOT", + "SCOPE_PROGRESSION_PATH", + "SCOPE_PROGRESSION_STAGE", + "SCOPE_TRAINING_SECTION", + "PlanningSkillExpectationInput", + "PlanningSkillExpectationItem", + "PlanningSkillExpectations", + "apply_expectations_to_target", + "build_planning_skill_expectations", + "expectation_input_from_progression_path", + "expectation_input_from_progression_stage", + "merge_expectation_skill_weights", +] diff --git a/backend/planning_stage_context.py b/backend/planning_stage_context.py new file mode 100644 index 0000000..0ed2aa8 --- /dev/null +++ b/backend/planning_stage_context.py @@ -0,0 +1,140 @@ +""" +Stufen-Kontext im Gesamtziel — Start/Ziel pro Roadmap-Stufe für Matching und QS. + +Übertragbar auf Trainingsplanung: Abschnitt-Soll (= Ende Vorabschnitt), Abschnitt-Ziel. +""" +from __future__ import annotations + +from typing import List, Optional, Sequence + +from planning_progression_roadmap import ( + GoalAnalysisArtifact, + MajorStep, + RoadmapStructuredInput, + StageSpecArtifact, +) + + +def build_contextualized_stage_goal( + *, + learning_goal: str, + start_state: str = "", + target_state: str = "", + path_target_state: str = "", + path_start_state: str = "", + stage_index: int = 0, + stage_count: int = 1, +) -> str: + """Stufen-Lernziel eingebettet in Übergang und Gesamtziel (für Brief/Retrieval).""" + lg = (learning_goal or "").strip() + if not lg: + return "" + + parts: List[str] = [] + start = (start_state or "").strip() + target = (target_state or "").strip() + path_end = (path_target_state or "").strip() + path_begin = (path_start_state or "").strip() + + if start: + parts.append(f"Soll-Start: {start[:220]}") + elif path_begin and stage_index == 0: + parts.append(f"Pfad-Start: {path_begin[:220]}") + if target: + parts.append(f"Stufen-Ziel: {target[:220]}") + parts.append(f"Lernziel: {lg[:280]}") + if path_end: + if stage_index >= max(0, stage_count - 1): + parts.append(f"Gesamtziel: {path_end[:220]}") + else: + parts.append(f"Gesamtziel (Kontext): {path_end[:180]}") + + return " | ".join(parts)[:900] + + +def derive_stage_specs_transition_states( + stage_specs: Sequence[StageSpecArtifact], + major_steps: Sequence[MajorStep], + *, + path_start: str = "", + path_target: str = "", + goal_analysis: Optional[GoalAnalysisArtifact] = None, +) -> List[StageSpecArtifact]: + """ + Verkettete Soll-/Zielzustände je Stufe. + + - Stufe 0 start = Pfad-Start + - Stufe n start = Zielzustand Stufe n-1 (Ziel des vorherigen Schritts) + - Letzte Stufe target = Pfad-Gesamtziel (falls gesetzt) + """ + start_path = (path_start or "").strip() + end_path = (path_target or "").strip() + if goal_analysis: + if not start_path: + start_path = (goal_analysis.start_assumption or "").strip() + if not end_path: + end_path = (goal_analysis.target_state or "").strip() + + by_idx = {int(s.major_step_index): s for s in stage_specs} + majors = sorted(major_steps, key=lambda m: m.index) + if not majors: + return list(stage_specs) + + out: List[StageSpecArtifact] = [] + prev_target = start_path + last_idx = majors[-1].index + + for major in majors: + spec = by_idx.get(major.index) + if spec is None: + spec = StageSpecArtifact( + major_step_index=major.index, + learning_goal=major.learning_goal, + ) + + explicit_start = (spec.start_state or "").strip() + explicit_target = (spec.target_state or "").strip() + stage_start = explicit_start or prev_target or start_path + if explicit_target: + stage_target = explicit_target + elif major.index == last_idx and end_path: + stage_target = end_path + else: + stage_target = (major.learning_goal or spec.learning_goal or "").strip() + + prev_target = stage_target + out.append( + spec.model_copy( + update={ + "start_state": (stage_start or "")[:500], + "target_state": (stage_target or "")[:500], + } + ) + ) + return out + + +def resolve_path_start_target( + *, + structured: Optional[RoadmapStructuredInput] = None, + goal_analysis: Optional[GoalAnalysisArtifact] = None, +) -> tuple[str, str]: + """Pfadweiter Start- und Zielzustand für Stufen-Verkettung.""" + start = "" + target = "" + if structured: + start = (structured.start_situation or "").strip() + target = (structured.target_state or "").strip() + if goal_analysis: + if not start: + start = (goal_analysis.start_assumption or "").strip() + if not target: + target = (goal_analysis.target_state or "").strip() + return start, target + + +__all__ = [ + "build_contextualized_stage_goal", + "derive_stage_specs_transition_states", + "resolve_path_start_target", +] diff --git a/backend/progression_graph_planning_artifact.py b/backend/progression_graph_planning_artifact.py new file mode 100644 index 0000000..0a75480 --- /dev/null +++ b/backend/progression_graph_planning_artifact.py @@ -0,0 +1,76 @@ +"""Validierung und Normalisierung des Planungs-Artefakts am Progressionsgraph.""" +from __future__ import annotations + +import json +from typing import Any, Dict, List, Optional + +from pydantic import BaseModel, Field, field_validator + +ARTIFACT_SCHEMA_VERSION = 1 +_MAX_JSON_BYTES = 64_000 + + +class SlotExerciseContent(BaseModel): + kind: str = Field(default="empty", pattern=r"^(empty|library|proposal)$") + exercise_id: Optional[int] = Field(default=None, ge=1) + variant_id: Optional[int] = Field(default=None, ge=1) + title: Optional[str] = Field(default=None, max_length=500) + variant_name: Optional[str] = Field(default=None, max_length=200) + proposal_key: Optional[str] = Field(default=None, max_length=120) + ai_suggestion: Optional[Dict[str, Any]] = None + + +class SlotContentEntry(BaseModel): + major_step_index: int = Field(ge=0, le=20) + primary: SlotExerciseContent = Field(default_factory=SlotExerciseContent) + siblings: List[SlotExerciseContent] = Field(default_factory=list) + + +class GraphPlanningRoadmapArtifact(BaseModel): + schema_version: int = Field(default=ARTIFACT_SCHEMA_VERSION, ge=1, le=1) + goal_query: str = Field(default="", max_length=2000) + start_situation: Optional[str] = Field(default=None, max_length=2000) + target_state: Optional[str] = Field(default=None, max_length=2000) + roadmap_notes: Optional[str] = Field(default=None, max_length=2000) + max_steps: int = Field(default=5, ge=2, le=10) + progression_roadmap: Optional[Dict[str, Any]] = None + path_skill_expectations: Optional[Dict[str, Any]] = None + slot_contents: Optional[List[SlotContentEntry]] = None + last_findings: Optional[Dict[str, Any]] = None + + @field_validator("progression_roadmap", "path_skill_expectations", "last_findings", mode="before") + @classmethod + def _empty_dict_to_none(cls, v): + if v == {}: + return None + return v + + @field_validator("slot_contents", mode="before") + @classmethod + def _empty_slot_list_to_none(cls, v): + if v == []: + return None + return v + + +def normalize_planning_roadmap_payload(raw: Any) -> Optional[Dict[str, Any]]: + """None erlaubt (löschen); sonst validiertes Dict.""" + if raw is None: + return None + if not isinstance(raw, dict): + raise ValueError("planning_roadmap muss ein JSON-Objekt sein") + artifact = GraphPlanningRoadmapArtifact.model_validate(raw) + out = artifact.model_dump(exclude_none=True) + blob = json.dumps(out, ensure_ascii=False) + if len(blob.encode("utf-8")) > _MAX_JSON_BYTES: + raise ValueError("planning_roadmap ist zu groß (max. 64 KB)") + return out + + +__all__ = [ + "ARTIFACT_SCHEMA_VERSION", + "GraphPlanningRoadmapArtifact", + "SlotContentEntry", + "SlotExerciseContent", + "normalize_planning_roadmap_payload", +] diff --git a/backend/requirements.txt b/backend/requirements.txt index ebc6416..a7f455a 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -10,5 +10,5 @@ bcrypt==4.1.3 slowapi==0.1.9 psycopg2-binary==2.9.9 python-dateutil==2.9.0 -tzdata>=2024.1 # ZoneInfo (Europe/Berlin) auch unter Windows +tzdata>=2024.1; sys_platform == "win32" # ZoneInfo lokal; Linux/Docker: apt tzdata sqlparse>=0.5.0 # Migrationen: Statements splitten (Fallback ohne psql) diff --git a/backend/routers/exercise_progression_graphs.py b/backend/routers/exercise_progression_graphs.py index 212359f..9e4edaa 100644 --- a/backend/routers/exercise_progression_graphs.py +++ b/backend/routers/exercise_progression_graphs.py @@ -3,13 +3,16 @@ Progressionsgraph zwischen Übungen (Übung → Übung), Migration 032–034. Optional Übungsvarianten als Knoten-Endpunkte; Sequenz-Bulk-Anlage. AuthZ analog training_plan_templates: Bearbeiten/Löschen wie Übungen; Governance-Übergänge zentral. """ -from typing import Any, List, Optional +import json +from typing import Any, Dict, List, Optional from fastapi import APIRouter, Depends, HTTPException, Query from pydantic import BaseModel, Field, model_validator from psycopg2 import IntegrityError +from psycopg2.extras import Json from db import get_db, get_cursor, r2d +from progression_graph_planning_artifact import normalize_planning_roadmap_payload from tenant_context import TenantContext, get_tenant_context, library_content_visibility_sql from club_tenancy import ( assert_library_content_deletable, @@ -36,6 +39,7 @@ class ProgressionGraphUpdate(BaseModel): description: Optional[str] = None visibility: Optional[str] = Field(None, pattern="^(private|club|official)$") club_id: Optional[int] = None + planning_roadmap: Optional[Dict[str, Any]] = None class ProgressionEdgeCreate(BaseModel): @@ -59,6 +63,7 @@ class SequenceStep(BaseModel): class ProgressionSequenceCreate(BaseModel): steps: List[SequenceStep] = Field(..., min_length=2) segment_notes: Optional[List[Optional[str]]] = None + planning_roadmap: Optional[Dict[str, Any]] = None """Länge muss len(steps)-1 sein, wenn gesetzt; Notiz pro Kante Zwischen je zwei Schritten.""" @model_validator(mode="after") @@ -116,6 +121,17 @@ def _assert_graph_writable(cur, row: dict, profile_id: int, role: str) -> None: assert_library_content_editable(cur, profile_id, role, row) +def _persist_graph_planning_roadmap(cur, graph_id: int, raw: Optional[Dict[str, Any]]) -> None: + try: + normalized = normalize_planning_roadmap_payload(raw) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + cur.execute( + "UPDATE exercise_progression_graphs SET planning_roadmap = %s WHERE id = %s", + (Json(normalized) if normalized is not None else None, graph_id), + ) + + def _require_graph_read(cur, graph_id: int, profile_id: int, role: str) -> dict: row = _graph_row(cur, graph_id) _assert_graph_readable(cur, row, profile_id, role) @@ -241,6 +257,127 @@ def get_progression_graph( return row +def _exercise_ids_from_planning_roadmap(artifact: Optional[Dict[str, Any]]) -> set[int]: + ids: set[int] = set() + if not artifact or not isinstance(artifact, dict): + return ids + for slot in artifact.get("slot_contents") or []: + if not isinstance(slot, dict): + continue + primary = slot.get("primary") if isinstance(slot.get("primary"), dict) else {} + if primary.get("kind") == "library" and primary.get("exercise_id") is not None: + try: + ids.add(int(primary["exercise_id"])) + except (TypeError, ValueError): + pass + for sib in slot.get("siblings") or []: + if not isinstance(sib, dict): + continue + if sib.get("kind") == "library" and sib.get("exercise_id") is not None: + try: + ids.add(int(sib["exercise_id"])) + except (TypeError, ValueError): + pass + return ids + + +def _collect_graph_referenced_exercise_ids(cur, graph_id: int) -> set[int]: + ids: set[int] = set() + cur.execute( + """ + SELECT from_exercise_id, to_exercise_id + FROM exercise_progression_edges + WHERE graph_id = %s + """, + (graph_id,), + ) + for row in cur.fetchall(): + for key in ("from_exercise_id", "to_exercise_id"): + raw = row.get(key) + if raw is not None: + ids.add(int(raw)) + cur.execute( + "SELECT planning_roadmap FROM exercise_progression_graphs WHERE id = %s", + (graph_id,), + ) + prow = cur.fetchone() + if prow and prow.get("planning_roadmap"): + art = prow["planning_roadmap"] + if isinstance(art, str): + try: + art = json.loads(art) + except json.JSONDecodeError: + art = None + ids |= _exercise_ids_from_planning_roadmap(art if isinstance(art, dict) else None) + return ids + + +@router.get("/exercise-progression-graphs/{graph_id}/visibility-promotion-candidates") +def list_visibility_promotion_candidates( + graph_id: int, + target_visibility: str = Query(default="club", pattern="^(club|official)$"), + tenant: TenantContext = Depends(get_tenant_context), +): + """ + Private Übungen im Graph, die bei Promotion des Graphen mit angehoben werden müssten. + """ + profile_id = tenant.profile_id + role = tenant.global_role + with get_db() as conn: + cur = get_cursor(conn) + row = _require_graph_read(cur, graph_id, profile_id, role) + graph_vis = (row.get("visibility") or "private").strip().lower() + if graph_vis != "private" or target_visibility != "club": + return { + "graph_id": graph_id, + "graph_visibility": graph_vis, + "target_visibility": target_visibility, + "exercises": [], + } + ref_ids = _collect_graph_referenced_exercise_ids(cur, graph_id) + if not ref_ids: + return { + "graph_id": graph_id, + "graph_visibility": graph_vis, + "target_visibility": target_visibility, + "exercises": [], + } + ph = ",".join(["%s"] * len(ref_ids)) + cur.execute( + f""" + SELECT id, title, visibility, club_id, created_by + FROM exercises + WHERE id IN ({ph}) + AND LOWER(TRIM(COALESCE(visibility, ''))) = 'private' + ORDER BY title + """, + list(ref_ids), + ) + exercises = [] + for ex in cur.fetchall(): + exd = r2d(ex) + if not library_content_visible_to_profile( + cur, + profile_id, + role, + exd, + ): + continue + exercises.append( + { + "id": exd["id"], + "title": exd.get("title"), + "visibility": exd.get("visibility"), + } + ) + return { + "graph_id": graph_id, + "graph_visibility": graph_vis, + "target_visibility": target_visibility, + "exercises": exercises, + } + + @router.post("/exercise-progression-graphs", status_code=201) def create_progression_graph( body: ProgressionGraphCreate, @@ -353,15 +490,24 @@ def update_progression_graph( fields.append("club_id = %s") params.append(next_club if next_vis == "club" else None) - if not fields: + if "planning_roadmap" in original: + _persist_graph_planning_roadmap(cur, graph_id, original.get("planning_roadmap")) + + if not fields and "planning_roadmap" not in original: return get_progression_graph(graph_id, include_edges=False, tenant=tenant) - fields.append("updated_at = NOW()") - params.append(graph_id) - cur.execute( - f"UPDATE exercise_progression_graphs SET {', '.join(fields)} WHERE id = %s", - tuple(params), - ) + if fields: + fields.append("updated_at = NOW()") + params.append(graph_id) + cur.execute( + f"UPDATE exercise_progression_graphs SET {', '.join(fields)} WHERE id = %s", + tuple(params), + ) + elif "planning_roadmap" in original: + cur.execute( + "UPDATE exercise_progression_graphs SET updated_at = NOW() WHERE id = %s", + (graph_id,), + ) conn.commit() return get_progression_graph(graph_id, include_edges=False, tenant=tenant) @@ -488,6 +634,12 @@ def create_progression_sequence( note, ) created.append(row) + if body.planning_roadmap is not None: + _persist_graph_planning_roadmap(cur, graph_id, body.planning_roadmap) + cur.execute( + "UPDATE exercise_progression_graphs SET updated_at = NOW() WHERE id = %s", + (graph_id,), + ) conn.commit() except IntegrityError as e: conn.rollback() diff --git a/backend/tenant_context.py b/backend/tenant_context.py index 175221e..d3ab1ae 100644 --- a/backend/tenant_context.py +++ b/backend/tenant_context.py @@ -108,6 +108,57 @@ def library_content_visibility_sql( return "(" + " OR ".join(parts) + ")", params +def library_content_visibility_for_progression_graph_sql( + *, + alias: str, + profile_id: int, + role: str, + effective_club_id: Optional[int], + graph_visibility: str, + graph_club_id: Optional[int] = None, +) -> tuple[str, List[Any]]: + """ + Übungs-Sichtbarkeit für Progressionsgraph-Match/Planung. + + - private Graph: private (eigene) + Verein + offiziell — volle Nutzer-Bibliothek + - club Graph: nur Verein (aktiver Graph-Verein) + offiziell + - official Graph: nur offiziell + """ + gvis = (graph_visibility or "private").strip().lower() + if gvis == "private": + return library_content_visibility_sql( + alias=alias, + profile_id=profile_id, + role=role, + effective_club_id=effective_club_id, + ) + if gvis == "club": + parts: List[str] = [f"{alias}.visibility = 'official'"] + params: List[Any] = [] + club_id = graph_club_id if graph_club_id is not None else effective_club_id + if club_id is not None: + plat = is_platform_admin(role) + if plat: + parts.append(f"({alias}.visibility = 'club' AND {alias}.club_id = %s)") + params.append(int(club_id)) + else: + parts.append( + f"""( + {alias}.visibility = 'club' + AND {alias}.club_id = %s + AND EXISTS ( + SELECT 1 FROM club_members cm + WHERE cm.profile_id = %s + AND cm.club_id = {alias}.club_id + AND cm.status = 'active' + ) + )""" + ) + params.extend([int(club_id), profile_id]) + return "(" + " OR ".join(parts) + ")", params + return f"({alias}.visibility = 'official')", [] + + def club_library_visibility_sql( *, alias: str, diff --git a/backend/tests/test_access_layer.py b/backend/tests/test_access_layer.py index 915c0ee..8169dc7 100644 --- a/backend/tests/test_access_layer.py +++ b/backend/tests/test_access_layer.py @@ -2,7 +2,12 @@ import pytest from fastapi import HTTPException -from tenant_context import library_content_visibility_sql, parse_active_club_header, resolve_tenant_context +from tenant_context import ( + library_content_visibility_for_progression_graph_sql, + library_content_visibility_sql, + parse_active_club_header, + resolve_tenant_context, +) def test_library_visibility_sql_platform_admin_restricts_club_by_membership(): @@ -41,6 +46,37 @@ def test_library_visibility_sql_trainer_without_active_club_no_shared_club_branc assert params == [42] +def test_progression_graph_visibility_sql_private_matches_library(): + base_sql, base_params = library_content_visibility_sql( + alias="e", profile_id=5, role="trainer", effective_club_id=12 + ) + graph_sql, graph_params = library_content_visibility_for_progression_graph_sql( + alias="e", + profile_id=5, + role="trainer", + effective_club_id=12, + graph_visibility="private", + graph_club_id=None, + ) + assert graph_sql == base_sql + assert graph_params == base_params + + +def test_progression_graph_visibility_sql_club_excludes_private(): + sql, params = library_content_visibility_for_progression_graph_sql( + alias="e", + profile_id=5, + role="trainer", + effective_club_id=12, + graph_visibility="club", + graph_club_id=12, + ) + assert "official" in sql + assert "visibility = 'club'" in sql + assert "visibility = 'private'" not in sql + assert 12 in params + + def test_library_visibility_sql_user_with_active_club_includes_club_branch(): sql, params = library_content_visibility_sql( alias="t", diff --git a/backend/tests/test_planning_exercise_form_context.py b/backend/tests/test_planning_exercise_form_context.py index 772e2ff..5e0e2ba 100644 --- a/backend/tests/test_planning_exercise_form_context.py +++ b/backend/tests/test_planning_exercise_form_context.py @@ -1,7 +1,9 @@ """Tests Planungs-KI Phase D — planning_context für suggestExerciseAi.""" from planning_exercise_form_context import ( + build_progression_entry_state, build_progression_gap_snapshot, build_progression_path_gap_planning_context, + enrich_gap_snapshot_with_entry_state, planning_context_prompt_variables, sanitize_planning_context_for_ai, ) @@ -78,3 +80,55 @@ def test_gap_planning_context_carries_snapshot_fields(): ) assert ctx["start_situation"] == "Start" assert ctx["stage_learning_goal"] == "Stufenziel" + + +def test_build_progression_entry_state_from_prior_steps(): + entry = build_progression_entry_state( + major_step_index=2, + prior_steps=[ + { + "roadmap_major_step_index": 0, + "title": "Schritt-Stand", + "roadmap_phase": "einstieg", + "success_criteria": ["stabile Grundstellung"], + }, + { + "roadmap_major_step_index": 1, + "title": "Mawashi Vorbereitung", + "roadmap_target_state": "Hüfte dreht vor dem Knie", + "roadmap_phase": "grundlage", + }, + ], + start_situation="Anfänger ohne Kumite-Erfahrung", + current_stage_start="Hüfte dreht vor dem Knie, sicherer Stand", + ) + assert entry["entry_state"] == "Hüfte dreht vor dem Knie, sicherer Stand" + assert "Mawashi Vorbereitung" in entry["entry_state_detail"] + assert "stabile Grundstellung" in entry["prior_achievements"][0] + + +def test_enrich_gap_snapshot_with_entry_state(): + snap = enrich_gap_snapshot_with_entry_state( + {"start_situation": "Basis", "stage_learning_goal": "Rhythmen"}, + steps=[ + { + "roadmap_major_step_index": 0, + "title": "A", + "success_criteria": ["Timing erkannt"], + } + ], + major_step_index=1, + ) + assert snap["entry_state"] == "Timing erkannt" + assert snap["prior_steps"][0]["title"] == "A" + + +def test_gap_planning_context_trainer_supplements_and_stage_override(): + ctx = build_progression_path_gap_planning_context( + goal_query="Kumite", + stage_spec={"learning_goal": "Original"}, + stage_learning_goal_override="Angepasstes Stufenziel", + gap_trainer_supplements="Nur Partnerübung, Kindergruppe", + ) + assert ctx["stage_learning_goal"] == "Angepasstes Stufenziel" + assert ctx["gap_trainer_supplements"] == "Nur Partnerübung, Kindergruppe" diff --git a/backend/tests/test_planning_exercise_path_ai_fill.py b/backend/tests/test_planning_exercise_path_ai_fill.py index 54b317a..f39b3bf 100644 --- a/backend/tests/test_planning_exercise_path_ai_fill.py +++ b/backend/tests/test_planning_exercise_path_ai_fill.py @@ -119,6 +119,86 @@ def test_build_gap_fill_goal_text_includes_roadmap_snapshot(): assert "timing" in text +def test_build_gap_fill_goal_text_includes_expected_skills(): + brief = build_semantic_brief("Kumite Beinarbeit") + text = build_gap_fill_goal_text( + goal_query="Kumite Beinarbeit", + brief=brief, + spec={"phase": "vertiefung", "title_hint": "Rhythmen"}, + roadmap_snapshot={ + "expected_skills": [ + {"skill_name": "Timing", "weight": 0.9}, + {"skill_name": "Distanz", "weight": 0.8}, + ], + }, + ) + assert "Erwartete Fähigkeiten" in text + assert "Timing" in text + + +def test_build_gap_fill_offer_roadmap_unfilled_uses_major_step_neighbors(): + """Leere Stufe 2 zwischen Stufe 1 und 3 — Nachbarn per roadmap_major_step_index.""" + brief = build_semantic_brief("Kumite Beinarbeit") + steps = [ + { + "title": "Explosive Angriffe", + "exercise_id": 10, + "roadmap_major_step_index": 0, + }, + { + "title": "Kumite-Anwendung", + "exercise_id": 30, + "roadmap_major_step_index": 2, + }, + ] + offer = build_gap_fill_offer( + spec={ + "source": "roadmap_unfilled", + "roadmap_major_step_index": 1, + "phase": "grundlage", + "title_hint": "Grundlegende Kumite-Steppbewegungen", + "gap": {"learning_goal": "Grundlegende Kumite-Steppbewegungen", "expected_phase": "grundlage"}, + }, + steps=steps, + goal_query="Kumite Beinarbeit", + brief=brief, + ) + assert offer["roadmap_major_step_index"] == 1 + assert "Explosive Angriffe" in offer["from_title"] + assert "Kumite-Anwendung" in offer["to_title"] + assert "Stufen-Lernziel" in offer["goal_for_ai"] or "Roadmap-Stufe" in offer["goal_for_ai"] + + +def test_build_gap_fill_offer_includes_entry_state_from_prior_steps(): + brief = build_semantic_brief("Kumite Beinarbeit") + steps = [ + { + "roadmap_major_step_index": 0, + "title": "Schritt A", + "roadmap_target_state": "gleichmäßige Distanz", + "success_criteria": ["Partnerabstand stabil"], + }, + {"roadmap_major_step_index": 2, "title": "Schritt C"}, + ] + offer = build_gap_fill_offer( + spec={ + "source": "roadmap_unfilled", + "phase": "vertiefung", + "title_hint": "Rhythmen", + "roadmap_major_step_index": 1, + }, + steps=steps, + goal_query="Kumite Beinarbeit", + brief=brief, + roadmap_snapshot={ + "start_situation": "Steppbewegung", + "stage_learning_goal": "variable Rhythmen", + }, + ) + assert offer["context_preview"]["entry_state"] == "gleichmäßige Distanz" + assert "Eingangszustand" in offer["goal_for_ai"] + + def test_build_gap_fill_offer_exposes_context_preview(): brief = build_semantic_brief("Kumite Beinarbeit") offer = build_gap_fill_offer( @@ -134,3 +214,55 @@ def test_build_gap_fill_offer_exposes_context_preview(): ) assert offer["context_preview"]["start_situation"] == "Steppbewegung" assert "variable Rhythmen" in offer["goal_for_ai"] + + +def test_collect_gap_fill_specs_off_topic_last_step_no_crash(): + """Rand-Slot: off_topic am letzten Schritt darf keinen IndexError auslösen (500).""" + brief = build_semantic_brief("Mawashi Geri Kumite") + steps = [ + {"exercise_id": 1, "title": "Stand", "roadmap_major_step_index": 0}, + {"exercise_id": 2, "title": "Yoko Geri", "roadmap_major_step_index": 1}, + ] + specs = collect_gap_fill_specs( + steps=steps, + unfilled_gaps=[], + off_topic_steps=[ + { + "step_index": 1, + "roadmap_major_step_index": 1, + "title": "Yoko Geri", + "expected_phase": "anwendung", + } + ], + llm_specs=[], + brief=brief, + goal_query="Mawashi Geri Kumite", + ) + assert len(specs) == 1 + assert specs[0]["source"] == "off_topic" + assert "Stand" in specs[0]["sketch"] + + +def test_collect_gap_fill_specs_off_topic_first_step_uses_safe_neighbors(): + brief = build_semantic_brief("Mawashi Geri") + steps = [ + {"exercise_id": 1, "title": "Yoko Geri", "roadmap_major_step_index": 0}, + {"exercise_id": 2, "title": "Mawashi", "roadmap_major_step_index": 1}, + ] + specs = collect_gap_fill_specs( + steps=steps, + unfilled_gaps=[], + off_topic_steps=[ + { + "step_index": 0, + "roadmap_major_step_index": 0, + "title": "Yoko Geri", + } + ], + llm_specs=[], + brief=brief, + goal_query="Mawashi Geri", + ) + assert len(specs) == 1 + assert "Mawashi" in specs[0]["sketch"] + assert "vorherigem Schritt" in specs[0]["sketch"] diff --git a/backend/tests/test_planning_exercise_path_builder.py b/backend/tests/test_planning_exercise_path_builder.py index dc9d598..ef20664 100644 --- a/backend/tests/test_planning_exercise_path_builder.py +++ b/backend/tests/test_planning_exercise_path_builder.py @@ -1,12 +1,37 @@ """Tests Planungs-KI Phase C3/E/F — Pfad-Vorschläge.""" from planning_exercise_path_builder import ( + EvaluateStepPayload, + ProgressionPathSuggestRequest, _annotate_roadmap_step, _hit_to_path_step, _pick_best_path_hit, + _supplemental_exercise_ids_from_body, ) from planning_progression_roadmap import MajorStep, StageSpecArtifact +class _FakeCur: + def execute(self, *_args, **_kwargs): + return None + + def fetchall(self): + return [] + + +def test_supplemental_boost_includes_slot_assignments_and_retrieval_boost(): + body = ProgressionPathSuggestRequest( + query="Mawashi Geri Progression", + slot_assignments=[ + EvaluateStepPayload(exercise_id=99, roadmap_major_step_index=0), + ], + retrieval_boost_exercise_ids=[42, 7], + ) + ids = _supplemental_exercise_ids_from_body(_FakeCur(), body) + assert 99 in ids + assert 42 in ids + assert 7 in ids + + def test_pick_next_path_hit_skips_used(): hits = [{"id": 1, "title": "A", "semantic_score": 0.2}, {"id": 2, "title": "B", "semantic_score": 0.2}, {"id": 3, "title": "C", "semantic_score": 0.2}] assert _pick_best_path_hit(hits, {1})["id"] == 2 @@ -42,3 +67,21 @@ def test_annotate_roadmap_step_adds_metadata(): assert step["roadmap_phase"] == "grundlage" assert step["roadmap_match_source"] == "stage_spec" assert any("Roadmap:" in r for r in step["reasons"]) + + +def test_annotate_roadmap_step_adds_skill_expectations(): + spec = StageSpecArtifact(major_step_index=0, learning_goal="Timing und Distanz") + step = _annotate_roadmap_step( + {"exercise_id": 5, "title": "Test", "reasons": []}, + stage_spec=spec, + major_step=None, + skill_expectations={ + "scope": "progression_stage", + "expected_skills": [ + {"skill_id": 2, "skill_name": "Timing", "weight": 0.9}, + {"skill_id": 3, "skill_name": "Distanz", "weight": 0.8}, + ], + }, + ) + assert step["skill_expectations"]["expected_skills"][0]["skill_name"] == "Timing" + assert any("Fähigkeiten:" in r for r in step["reasons"]) diff --git a/backend/tests/test_planning_exercise_path_qa.py b/backend/tests/test_planning_exercise_path_qa.py index 929e187..f6a8d8a 100644 --- a/backend/tests/test_planning_exercise_path_qa.py +++ b/backend/tests/test_planning_exercise_path_qa.py @@ -106,6 +106,42 @@ def test_detect_path_gaps_skips_roadmap_neighbors(): assert gaps == [] +def test_detect_path_gaps_skips_empty_slots(): + """Graph-Bewertung: leere Slots dürfen keinen 500er durch Übergangs-Lücken auslösen.""" + brief = build_semantic_brief("Mawashi Geri Kumite") + steps = [ + { + "exercise_id": 10, + "title": "Stand", + "roadmap_major_step_index": 0, + }, + { + "exercise_id": None, + "title": "(leer: Slot 2)", + "is_ai_proposal": True, + "roadmap_major_step_index": 1, + }, + { + "exercise_id": 11, + "title": "Anwendung", + "roadmap_major_step_index": 2, + }, + ] + + class _FakeCur: + def execute(self, *args, **kwargs): + return None + + def fetchall(self): + return [] + + def fetchone(self): + return {"title": "X", "summary": "", "goal": ""} + + gaps = detect_path_gaps(_FakeCur(), steps, brief=brief, roadmap_first=True) + assert isinstance(gaps, list) + + def test_apply_llm_path_reorder_invalid_ignored(): steps = [{"exercise_id": 1}, {"exercise_id": 2}] reordered, applied, _ = apply_llm_path_reorder(steps, {"ordered_step_indices": [0, 0]}) diff --git a/backend/tests/test_planning_intent_context.py b/backend/tests/test_planning_intent_context.py new file mode 100644 index 0000000..e85d5a8 --- /dev/null +++ b/backend/tests/test_planning_intent_context.py @@ -0,0 +1,58 @@ +"""Tests gemeinsames Planungs-Intent-Modul (Progressionsgraph → Trainingsplanung).""" +from planning_intent_context import ( + build_planning_intent_context, + extract_explicit_exclusions, + finalize_stage_specs_with_intent, +) +from planning_progression_roadmap import ( + MajorStep, + StageSpecArtifact, + build_goal_analysis, + build_stage_specs, + run_progression_roadmap_pipeline, +) +from planning_exercise_semantics import build_semantic_brief, enrich_brief_with_path_constraints + + +def test_extract_explicit_exclusions_parses_negations(): + q = "gesprungener Mawashi Geri, keine Kumite-Anwendung gewünscht" + out = extract_explicit_exclusions(q) + assert any("kumite" in x.lower() for x in out) + + +def test_build_planning_intent_context_includes_path_anti(): + q = "Mawashi Geri Sprungkraft, keine Kumite-Anwendung" + brief = enrich_brief_with_path_constraints(build_semantic_brief(q), q) + ga = build_goal_analysis(q, brief) + intent = build_planning_intent_context(q, semantic_brief=brief, goal_analysis=ga.model_dump()) + assert intent.explicit_exclusions + assert any("kumite" in a for a in intent.path_anti_patterns) + + +def test_finalize_stage_specs_merges_intent_into_each_stage(): + q = "gesprungener Mawashi Geri, keine Kumite-Anwendung" + brief = enrich_brief_with_path_constraints(build_semantic_brief(q), q) + ga = build_goal_analysis(q, brief) + intent = build_planning_intent_context(q, semantic_brief=brief, goal_analysis=ga.model_dump()) + majors = [ + MajorStep(index=0, phase="grundlage", learning_goal="Grundtechnik Mawashi", consolidates=["m1"]), + MajorStep(index=1, phase="vertiefung", learning_goal="Sprungkoordination", consolidates=["m2"]), + ] + raw_specs = [ + StageSpecArtifact(major_step_index=0, learning_goal=majors[0].learning_goal), + StageSpecArtifact(major_step_index=1, learning_goal=majors[1].learning_goal), + ] + finalized = finalize_stage_specs_with_intent(raw_specs, majors, intent=intent) + assert len(finalized) == 2 + for spec in finalized: + assert spec.success_criteria + assert any("kumite" in a.lower() for a in spec.anti_patterns) + assert any("messbar" in c.lower() or "übungsziel" in c.lower() for c in spec.success_criteria) + + +def test_pipeline_stage_specs_carry_exclusions_without_llm(): + q = "gesprungener Mawashi Geri Sprungphase, keine Kumite-Anwendung" + ctx = run_progression_roadmap_pipeline(q, max_steps=3, include_llm_roadmap=False) + assert len(ctx.stage_specs) == 3 + for spec in ctx.stage_specs: + assert any("kumite" in a.lower() for a in (spec.anti_patterns or [])) diff --git a/backend/tests/test_planning_path_rematch.py b/backend/tests/test_planning_path_rematch.py new file mode 100644 index 0000000..78cc99f --- /dev/null +++ b/backend/tests/test_planning_path_rematch.py @@ -0,0 +1,185 @@ +"""Tests Auto-Rematch nach Pfad-QS (Phase A).""" +from planning_path_rematch import collect_rematch_slot_indices, rematch_roadmap_slots +from planning_progression_roadmap import ProgressionRoadmapContext, StageSpecArtifact + + +def _stage_specs(): + return [ + StageSpecArtifact(major_step_index=0, learning_goal="Grundlage"), + StageSpecArtifact(major_step_index=1, learning_goal="Vertiefung"), + StageSpecArtifact(major_step_index=2, learning_goal="Anwendung"), + ] + + +def test_collect_rematch_slot_indices_from_stripped_with_major_index(): + specs = _stage_specs() + stripped = [ + { + "step_index": 1, + "roadmap_major_step_index": 1, + "issue": "technique_scope", + "reasons": ["Passt nicht zur Haupttechnik"], + } + ] + indices, reasons = collect_rematch_slot_indices( + stripped_off_topic=stripped, + off_topic_steps=[], + optimization_hints=[], + stage_specs=specs, + ) + assert indices == {1} + assert "Haupttechnik" in reasons[1] + + +def test_collect_rematch_slot_indices_resolves_step_index_to_major(): + specs = _stage_specs() + off_topic = [ + { + "step_index": 2, + "issue": "stage_mismatch", + "reasons": ["Ziel passt nicht"], + } + ] + indices, reasons = collect_rematch_slot_indices( + stripped_off_topic=[], + off_topic_steps=off_topic, + optimization_hints=[], + stage_specs=specs, + ) + assert indices == {2} + assert reasons[2] == "Ziel passt nicht" + + +def test_collect_rematch_slot_indices_from_optimization_hints(): + specs = _stage_specs() + hints = [ + { + "action": "rematch_slot", + "roadmap_major_step_index": 0, + "reason": "QS-Tier-1", + } + ] + indices, _ = collect_rematch_slot_indices( + stripped_off_topic=[], + off_topic_steps=[], + optimization_hints=hints, + stage_specs=specs, + ) + assert indices == {0} + + +def test_collect_rematch_slot_indices_from_roadmap_unfilled(): + specs = _stage_specs() + indices, reasons = collect_rematch_slot_indices( + stripped_off_topic=[], + off_topic_steps=[], + optimization_hints=[], + stage_specs=specs, + roadmap_unfilled=[(1, specs[1])], + ) + assert indices == {1} + assert "Roadmap-Stufe" in reasons[1] + + +def test_rematch_roadmap_slots_replaces_only_target_slot(): + specs = _stage_specs() + ctx = ProgressionRoadmapContext( + goal_query="Mawashi Geri", + max_steps=3, + stage_specs=specs, + ) + steps = [ + { + "exercise_id": 10, + "title": "Slot 0 OK", + "roadmap_major_step_index": 0, + }, + { + "exercise_id": 20, + "title": "Mae Geri falsch", + "roadmap_major_step_index": 1, + }, + { + "exercise_id": 30, + "title": "Slot 2 OK", + "roadmap_major_step_index": 2, + }, + ] + + def _fake_match(cur, *, stage_spec, used, **kwargs): + assert stage_spec.major_step_index == 1 + assert 20 in used + assert 10 in used + assert 30 in used + return ( + { + "exercise_id": 21, + "title": "Sprungkraft Mawashi", + "roadmap_major_step_index": 1, + }, + None, + ) + + ordered, log, unfilled = rematch_roadmap_slots( + None, + tenant=None, + body=None, + goal_query="Mawashi Geri", + max_steps=3, + semantic_brief=None, + path_target_profile=None, + path_intent="", + roadmap_ctx=ctx, + steps=steps, + slot_indices={1}, + rematch_reasons={1: "technique_scope"}, + match_slot_fn=_fake_match, + ) + + assert len(ordered) == 3 + assert ordered[0]["exercise_id"] == 10 + assert ordered[1]["exercise_id"] == 21 + assert ordered[2]["exercise_id"] == 30 + assert len(log) == 1 + assert log[0]["action"] == "replaced" + assert log[0]["replaced_exercise_id"] == 20 + assert log[0]["new_exercise_id"] == 21 + assert not unfilled + + +def test_rematch_excludes_replaced_exercise_from_used(): + specs = _stage_specs() + ctx = ProgressionRoadmapContext( + goal_query="Mawashi Geri", + max_steps=3, + stage_specs=specs, + ) + steps = [ + {"exercise_id": 10, "title": "OK", "roadmap_major_step_index": 0}, + {"exercise_id": 99, "title": "Mae Geri", "roadmap_major_step_index": 1}, + ] + seen_used = [] + + def _fake_match(cur, *, used, stage_spec, **kwargs): + seen_used.append(set(used)) + return ( + {"exercise_id": 42, "title": "Neu", "roadmap_major_step_index": stage_spec.major_step_index}, + None, + ) + + rematch_roadmap_slots( + None, + tenant=None, + body=None, + goal_query="Mawashi", + max_steps=3, + semantic_brief=None, + path_target_profile=None, + path_intent="", + roadmap_ctx=ctx, + steps=steps, + slot_indices={1}, + rematch_reasons={1: "technique_scope"}, + match_slot_fn=_fake_match, + ) + assert 99 in seen_used[0] diff --git a/backend/tests/test_planning_roadmap_stage_match.py b/backend/tests/test_planning_roadmap_stage_match.py new file mode 100644 index 0000000..21ae1c2 --- /dev/null +++ b/backend/tests/test_planning_roadmap_stage_match.py @@ -0,0 +1,572 @@ +"""Tests Roadmap-Stufen-Match — Gate gegen themenfremde Übungen.""" +from planning_exercise_semantics import ( + build_semantic_brief, + build_stage_match_brief, + enrich_brief_with_path_constraints, + exercise_passes_stage_learning_goal_gate, + exercise_passes_stage_fit, + exercise_passes_technique_path_scope, + pick_best_path_hit, + resolve_path_anti_patterns, + resolve_path_primary_topic, + score_exercise_stage_fit, + semantic_brief_for_stage, + technique_sibling_excludes, +) +from planning_exercise_path_qa import strip_off_topic_steps_from_path + + +def test_stage_gate_accepts_learning_goal_in_title(): + assert exercise_passes_stage_learning_goal_gate( + learning_goal="variable Rhythmen und Hüftmobilität für Mae Geri", + title="Mae Geri — variable Rhythmen", + summary="", + semantic_score=0.1, + ) + + +def test_stage_gate_rejects_unrelated_kumite(): + assert not exercise_passes_stage_learning_goal_gate( + learning_goal="variable Rhythmen und Hüftmobilität für Mae Geri", + title="Kumite Grundstellungen", + summary="Partnerarbeit Distanz", + semantic_score=0.05, + ) + + +def test_semantic_brief_for_stage_adds_learning_goal(): + brief = build_semantic_brief("Mae Geri Perfektion") + stage = semantic_brief_for_stage( + brief, + learning_goal="Hüftmobilität und Kammerhaltung", + phase="grundlage", + ) + assert "hüftmobilität und kammerhaltung" in stage.must_phrases[0] + + +def test_build_stage_match_brief_uses_stage_tokens_not_global_topic(): + brief = build_stage_match_brief( + learning_goal="Koordination von Absprung und Beinhebung ohne Tritttechnik", + phase="vertiefung", + ) + must_blob = " ".join(brief.must_phrases or []).lower() + assert "mawashi" not in must_blob + assert "absprung" in must_blob + assert not (brief.primary_topic or "").strip() + + +def test_stage_fit_prefers_goal_over_misleading_title(): + stage_goal = "Koordination von Absprung und Beinhebung ohne Tritttechnik" + stage_brief = build_stage_match_brief(learning_goal=stage_goal) + kick_score, _ = score_exercise_stage_fit( + title="Mawashi Geri Trittpräzision", + summary="Kicktechnik", + goal="Präzision im Tritt und Hüftarbeit", + stage_brief=stage_brief, + ) + coord_score, _ = score_exercise_stage_fit( + title="Allgemeines Sprungtraining", + summary="Athletik", + goal="Absprung, Beinhebung und Landung koordinieren — ohne Trittausführung", + stage_brief=stage_brief, + ) + assert coord_score > kick_score + + +def test_pick_best_path_hit_roadmap_stage_no_weak_fallback(): + stage_brief = build_stage_match_brief( + learning_goal="Hüftmobilität für Mae Geri", + phase="grundlage", + ) + hits = [ + { + "id": 1, + "title": "Kumite Stellungen", + "summary": "Partner Distanz", + "score": 0.92, + "semantic_score": 0.08, + }, + { + "id": 2, + "title": "Kraft-Ausdauer Zirkel", + "summary": "allgemeine Fitness", + "score": 0.88, + "semantic_score": 0.02, + }, + ] + chosen = pick_best_path_hit( + hits, + set(), + semantic_brief=stage_brief, + stage_learning_goal="Hüftmobilität für Mae Geri", + roadmap_stage_match=True, + ) + assert chosen is None + + +def test_pick_best_path_hit_roadmap_stage_picks_relevant(): + stage_brief = build_stage_match_brief( + learning_goal="Hüftmobilität für Mae Geri", + phase="grundlage", + ) + hits = [ + {"id": 1, "title": "Kumite", "score": 0.9, "semantic_score": 0.1}, + { + "id": 2, + "title": "Mae Geri Hüftmobilität", + "summary": "Kammerhaltung und Hüfte", + "score": 0.7, + "semantic_score": 0.55, + }, + ] + chosen = pick_best_path_hit( + hits, + set(), + semantic_brief=stage_brief, + stage_learning_goal="Hüftmobilität für Mae Geri", + roadmap_stage_match=True, + ) + assert chosen is not None + assert int(chosen["id"]) == 2 + + +def test_stage_gate_rejects_tritt_when_goal_says_ohne_tritttechnik(): + """Regression: gesprungener Mawashi — Slot Koordination ohne Tritttechnik.""" + goal = "Koordination von Absprung und Beinhebung ohne Tritttechnik" + assert not exercise_passes_stage_learning_goal_gate( + learning_goal=goal, + title="Verbesserung der Trittpräzision des Mawashi Geri und der Hüftbewegung", + summary="Präzision und Hüftarbeit im Stand", + semantic_score=0.72, + ) + + +def test_stage_gate_accepts_absprung_drill_not_kick_focus(): + goal = "Koordination von Absprung und Beinhebung ohne Tritttechnik" + assert exercise_passes_stage_learning_goal_gate( + learning_goal=goal, + title="Sprungkoordination — Absprung und Beinhebung", + summary="Ohne Trittausführung, Fokus Gleichgewicht und Timing", + semantic_score=0.35, + ) + + +def test_pick_best_rejects_mawashi_tritt_precision_for_coordination_slot(): + stage_goal = "Koordination von Absprung und Beinhebung ohne Tritttechnik" + stage_brief = build_stage_match_brief(learning_goal=stage_goal, phase="vertiefung") + hits = [ + { + "id": 99, + "title": "Verbesserung der Trittpräzision des Mawashi Geri und der Hüftbewegung", + "summary": "Tritttechnik und Hüfte im Stand", + "score": 0.91, + "semantic_score": 0.68, + }, + { + "id": 100, + "title": "Absprung und Beinhebung — Koordination ohne Kick", + "summary": "Sprungvorbereitung, kein Tritt", + "score": 0.62, + "semantic_score": 0.41, + }, + ] + chosen = pick_best_path_hit( + hits, + set(), + semantic_brief=stage_brief, + stage_learning_goal=stage_goal, + roadmap_stage_match=True, + ) + assert chosen is not None + assert int(chosen["id"]) == 100 + + +def test_path_anti_patterns_from_keine_kumite_anwendung(): + q = "gesprungener Mawashi Geri Sprungphase, keine Kumite-Anwendung gewünscht" + brief = enrich_brief_with_path_constraints(build_semantic_brief(q), q) + anti = resolve_path_anti_patterns(q, semantic_brief=brief) + assert any("kumite" in a for a in anti) + + +def test_stage_fit_rejects_kumite_when_path_excludes_kumite(): + q = "gesprungener Mawashi Geri, keine Kumite-Anwendung" + brief = enrich_brief_with_path_constraints(build_semantic_brief(q), q) + path_anti = resolve_path_anti_patterns(q, semantic_brief=brief) + stage_goal = "Sprungkraft und Koordination für gesprungenen Mawashi Geri" + stage_brief = build_stage_match_brief( + learning_goal=stage_goal, + anti_patterns=path_anti, + path_anti_patterns=path_anti, + ) + assert not exercise_passes_stage_fit( + learning_goal=stage_goal, + title="Kumite Distanztraining Mawashi", + summary="Partner-Kumite mit Trittanwendung", + goal="Anwendung im freien Kampf", + stage_brief=stage_brief, + anti_patterns=path_anti, + ) + + +def test_pick_best_skips_kumite_for_mawashi_athletic_path(): + q = "gesprungener Mawashi Geri Sprungkraft, keine Kumite-Anwendung" + brief = enrich_brief_with_path_constraints(build_semantic_brief(q), q) + path_anti = resolve_path_anti_patterns(q, semantic_brief=brief) + stage_goal = "Athletisches Sprungtraining für Mawashi Geri" + stage_brief = build_stage_match_brief( + learning_goal=stage_goal, + anti_patterns=path_anti, + path_anti_patterns=path_anti, + ) + hits = [ + { + "id": 1, + "title": "Kumite Mawashi Anwendung", + "summary": "Partner Kumite", + "goal": "Kampfanwendung", + "score": 0.95, + "semantic_score": 0.55, + "stage_semantic_score": 0.55, + }, + { + "id": 2, + "title": "Sprungkraft Plyometrie", + "summary": "Absprung und Landung", + "goal": "Sprungkraft für Mawashi Geri Vorbereitung", + "score": 0.62, + "semantic_score": 0.38, + "stage_semantic_score": 0.38, + }, + ] + primary = resolve_path_primary_topic(q, brief, stage_learning_goal=stage_goal) + chosen = pick_best_path_hit( + hits, + set(), + stage_learning_goal=stage_goal, + stage_anti_patterns=path_anti, + roadmap_stage_match=True, + stage_match_brief=stage_brief, + path_primary_topic=primary, + path_technique_excludes=technique_sibling_excludes(primary or "mawashi geri"), + ) + assert chosen is not None + assert int(chosen["id"]) == 2 + + +def test_resolve_path_primary_topic_from_stage_learning_goal(): + brief = build_semantic_brief("Trainingsprogression gesprungener Tritt") + primary = resolve_path_primary_topic( + "Trainingsprogression gesprungener Tritt", + brief, + stage_learning_goal="Perfektionierung der statischen Mawashi Geri Technik", + ) + assert primary and "mawashi" in primary + + +def test_pick_roadmap_relaxed_for_non_technique_stage(): + stage_goal = "Progression Hüftflexibilität und Adduktoren dehnen" + stage_brief = build_stage_match_brief(learning_goal=stage_goal) + hits = [ + { + "id": 11, + "title": "Adduktoren Dehnung am Boden", + "summary": "Flexibilität Hüfte", + "goal": "Mobilität", + "score": 0.68, + "semantic_score": 0.22, + "stage_semantic_score": 0.22, + }, + ] + chosen = pick_best_path_hit( + hits, + set(), + stage_learning_goal=stage_goal, + roadmap_stage_match=True, + stage_match_brief=stage_brief, + path_primary_topic=None, + ) + assert chosen is not None + assert int(chosen["id"]) == 11 + + +def test_pick_rejects_kumite_when_primary_only_in_stage_goal(): + brief = build_semantic_brief("Trainingsprogression") + stage_goal = "Perfektionierung der statischen Mawashi Geri Technik" + stage_brief = build_stage_match_brief(learning_goal=stage_goal) + primary = resolve_path_primary_topic("Trainingsprogression", brief, stage_learning_goal=stage_goal) + hits = [ + { + "id": 4, + "title": "4 Kumite Reaktions Übungen", + "summary": "Partner", + "goal": "Kumite", + "score": 0.95, + "semantic_score": 0.4, + "stage_semantic_score": 0.35, + }, + { + "id": 2, + "title": "Mawashi Geri Standtechnik", + "summary": "Rundtritt", + "goal": "Mawashi Geri Basis", + "score": 0.7, + "semantic_score": 0.5, + "stage_semantic_score": 0.48, + }, + ] + chosen = pick_best_path_hit( + hits, + set(), + stage_learning_goal=stage_goal, + roadmap_stage_match=True, + stage_match_brief=stage_brief, + path_primary_topic=primary, + path_technique_excludes=technique_sibling_excludes(primary or "mawashi geri"), + ) + assert chosen is not None + assert int(chosen["id"]) == 2 + + +def test_technique_scope_rejects_kumite_when_only_stage_goal_mentions_mawashi(): + siblings = technique_sibling_excludes("mawashi geri") + assert not exercise_passes_technique_path_scope( + primary_topic="mawashi geri", + title="Kumite Grundstellungen", + summary="Partner-Distanz und freier Kampf", + goal="Kumite-Technik", + learning_goal="Sprungkraft und Koordination für Mawashi Geri", + sibling_excludes=siblings, + relaxed=True, + ) + + +def test_title_equivalent_to_stage_goal(): + from planning_exercise_semantics import exercise_title_equivalent_to_stage_goal + + assert exercise_title_equivalent_to_stage_goal( + "Hüftmobilität für Mae Geri", + "Hüftmobilität für Mae Geri", + ) + assert exercise_title_equivalent_to_stage_goal( + "Hüftmobilität Mae Geri", + "Hüftmobilität für Mae Geri", + ) + assert not exercise_title_equivalent_to_stage_goal("Kumite", "Hüftmobilität für Mae Geri") + + +def test_stage_fit_passes_for_title_equivalent_with_sufficient_semantic_score(): + stage_goal = "Koordination Absprung ohne Kick" + assert exercise_passes_stage_fit( + learning_goal=stage_goal, + title=stage_goal, + summary="Absprung und Landung koordinieren", + goal="", + path_primary_topic="mawashi geri", + path_technique_excludes=["kumite"], + stage_semantic_score=0.42, + ) + + +def test_roadmap_rank_fallback_picks_best_stage_semantic(): + from planning_exercise_semantics import _pick_roadmap_rank_fallback + + stage_goal = "Hüftmobilität für Mawashi Geri" + hits = [ + { + "id": 1, + "title": "Hüftmobilität für Mawashi Geri", + "summary": "Aufwärmen", + "goal": "", + "score": 0.9, + "stage_rank_semantic": 0.32, + }, + { + "id": 2, + "title": "Mawashi Hüftdehnung", + "summary": "Adduktoren und Hüfte", + "goal": "Mobilität für Mawashi Geri", + "score": 0.7, + "stage_rank_semantic": 0.58, + }, + ] + chosen = _pick_roadmap_rank_fallback( + hits, + set(), + stage_learning_goal=stage_goal, + path_primary_topic="mawashi geri", + path_technique_excludes=technique_sibling_excludes("mawashi geri"), + ) + assert chosen is not None + assert int(chosen["id"]) == 2 + + +def test_pick_best_prefers_semantic_fit_over_coincidental_title(): + stage_goal = "Hüftmobilität für Mawashi Geri" + stage_brief = build_stage_match_brief(learning_goal=stage_goal) + hits = [ + { + "id": 1, + "title": "Hüftmobilität für Mawashi Geri", + "summary": "allgemeine Aufwärmung", + "goal": "", + "score": 0.9, + "semantic_score": 0.12, + "stage_semantic_score": 0.12, + "stage_rank_semantic": 0.35, + }, + { + "id": 2, + "title": "Mawashi Hüftmobilität und Adduktoren", + "summary": "Dehnung Hüfte für Rundtritt", + "goal": "Mawashi Geri Hüftbeweglichkeit", + "score": 0.72, + "semantic_score": 0.58, + "stage_semantic_score": 0.58, + "stage_rank_semantic": 0.62, + }, + ] + chosen = pick_best_path_hit( + hits, + set(), + stage_learning_goal=stage_goal, + roadmap_stage_match=True, + stage_match_brief=stage_brief, + path_primary_topic="mawashi geri", + path_technique_excludes=technique_sibling_excludes("mawashi geri"), + ) + assert chosen is not None + assert int(chosen["id"]) == 2 + + +def test_pick_roadmap_relaxed_with_path_primary_when_strict_fails(): + """Bestehende Graph-Übungen: relaxed Gate auch bei gesetztem path_primary_topic.""" + stage_goal = "Hüftmobilität für Mawashi Geri" + primary = "mawashi geri" + stage_brief = build_stage_match_brief( + learning_goal=stage_goal, + path_primary_topic=primary, + path_technique_excludes=technique_sibling_excludes(primary), + ) + hits = [ + { + "id": 42, + "title": "Mawashi Geri Hüftmobilität — Vereinsübung", + "summary": "Dehnung und Hüfte für Rundtritt", + "goal": "Mobilität Mawashi Geri", + "score": 0.55, + "semantic_score": 0.25, + "stage_semantic_score": 0.25, + }, + ] + chosen = pick_best_path_hit( + hits, + set(), + stage_learning_goal=stage_goal, + roadmap_stage_match=True, + stage_match_brief=stage_brief, + path_primary_topic=primary, + path_technique_excludes=technique_sibling_excludes(primary), + ) + assert chosen is not None + assert int(chosen["id"]) == 42 + + +def test_pick_best_roadmap_rejects_kumite_with_path_primary_topic(): + q = "gesprungener Mawashi Geri Sprungphase" + brief = enrich_brief_with_path_constraints(build_semantic_brief(q), q) + primary = (brief.primary_topic or "mawashi geri").strip() + stage_goal = "Sprungvorbereitung für Mawashi Geri" + stage_brief = build_stage_match_brief( + learning_goal=stage_goal, + path_primary_topic=primary, + path_technique_excludes=technique_sibling_excludes(primary), + ) + hits = [ + { + "id": 1, + "title": "Kumite Distanztraining", + "summary": "Partnerarbeit", + "goal": "Kampfvorbereitung", + "score": 0.95, + "semantic_score": 0.4, + "stage_semantic_score": 0.35, + }, + { + "id": 2, + "title": "Sprungkraft Plyometrie", + "summary": "Absprung für Tritttechnik", + "goal": "Sprungkraft Mawashi Geri Vorbereitung", + "score": 0.7, + "semantic_score": 0.45, + "stage_semantic_score": 0.42, + }, + ] + chosen = pick_best_path_hit( + hits, + set(), + stage_learning_goal=stage_goal, + roadmap_stage_match=True, + stage_match_brief=stage_brief, + path_primary_topic=primary, + path_technique_excludes=technique_sibling_excludes(primary), + ) + assert chosen is not None + assert int(chosen["id"]) == 2 + + +def test_technique_scope_rejects_sibling_geri_for_mawashi_path(): + siblings = technique_sibling_excludes("mawashi geri") + assert any("mae" in s for s in siblings) + assert not exercise_passes_technique_path_scope( + primary_topic="mawashi geri", + title="Mae Geri Grundtechnik", + summary="Front kick", + goal="Präzision Mae Geri", + learning_goal="Sprungvorbereitung für Mawashi Geri", + sibling_excludes=siblings, + ) + assert exercise_passes_technique_path_scope( + primary_topic="mawashi geri", + title="Sprungkraft Plyometrie", + summary="Absprung", + goal="Vorbereitung gesprungener Mawashi Geri", + learning_goal="Sprungvorbereitung für Mawashi Geri", + sibling_excludes=siblings, + ) + + +def test_stage_fit_rejects_yoko_geri_on_mawashi_roadmap_stage(): + brief = build_semantic_brief("gesprungener Mawashi Geri Sprungphase") + primary = brief.primary_topic or "mawashi geri" + stage_goal = "Koordination Sprungphase Mawashi Geri" + stage_brief = build_stage_match_brief( + learning_goal=stage_goal, + path_primary_topic=primary, + path_technique_excludes=technique_sibling_excludes(primary), + ) + assert not exercise_passes_stage_fit( + learning_goal=stage_goal, + title="Yoko Geri seitlicher Tritt", + summary="Seitwärtskick", + goal="Yoko Geri Technik", + stage_brief=stage_brief, + path_primary_topic=primary, + path_technique_excludes=technique_sibling_excludes(primary), + ) + + +def test_strip_off_topic_removes_partial_when_most_steps_bad(): + steps = [{"exercise_id": i, "title": f"E{i}"} for i in range(1, 8)] + off_topic = [{"step_index": i, "issue": "path_exclude"} for i in range(5)] + out, removed = strip_off_topic_steps_from_path(steps, off_topic, min_remaining=2) + assert len(out) == 2 + assert len(removed) == 5 + + +def test_parse_stage_goal_constraints_extracts_ohne_tritttechnik(): + from planning_exercise_semantics import parse_stage_goal_constraints + + c = parse_stage_goal_constraints("Koordination von Absprung und Beinhebung ohne Tritttechnik") + assert c.has_negation + assert "absprung" in c.positive_tokens + assert any("tritt" in ex for ex in c.exclude_phrases) diff --git a/backend/tests/test_planning_skill_expectations.py b/backend/tests/test_planning_skill_expectations.py new file mode 100644 index 0000000..f35be9a --- /dev/null +++ b/backend/tests/test_planning_skill_expectations.py @@ -0,0 +1,121 @@ +"""Tests wiederverwendbare Fähigkeiten-Erwartungen (Progressionsgraph + später Planung).""" +from unittest.mock import MagicMock + +from planning_skill_expectations import ( + SCOPE_PROGRESSION_PATH, + SCOPE_PROGRESSION_STAGE, + PlanningSkillExpectationInput, + apply_expectations_to_target, + build_planning_skill_expectations, + expectation_input_from_progression_path, + expectation_input_from_progression_stage, +) + + +class _FakeCursor: + def __init__(self, skills): + self._skills = skills + + def execute(self, query, params=None): + del query + if params and len(params) >= 2: + needle = str(params[0]).strip("%").lower() + exact = str(params[1]).lower() + matches = [ + s + for s in self._skills + if needle in s["name"].lower() or s["name"].lower() == exact + ] + self._row = matches[0] if matches else None + else: + self._row = None + + def fetchone(self): + return self._row + + +def _fake_skill_rows(): + return [ + (1, "Koordination", 0), + (2, "Timing", 0), + (3, "Distanz", 0), + (4, "Kime", 0), + ] + + +def test_expectation_input_from_progression_stage_merges_sources(monkeypatch): + monkeypatch.setattr( + "planning_skill_expectations._load_skills_for_text_match", + lambda cur: _fake_skill_rows(), + ) + inp = expectation_input_from_progression_stage( + goal_query="Kumite Beinarbeit", + goal_analysis={ + "primary_topic": "Kumite", + "start_assumption": "gleichförmige Steppbewegung", + "target_state": "explosiver Angriff", + }, + resolved_structured={"roadmap_notes": "Kindergruppe"}, + stage_spec={ + "learning_goal": "variable Rhythmen", + "load_profile": ["timing", "distanz"], + "phase": "vertiefung", + }, + semantic_brief_summary={"must_phrases": ["Beinarbeit"], "primary_topic": "Kumite"}, + major_step={"phase": "vertiefung", "learning_goal": "Major-Ziel"}, + ) + assert inp.scope == SCOPE_PROGRESSION_STAGE + assert inp.primary_topic == "Kumite" + assert inp.start_situation == "gleichförmige Steppbewegung" + assert inp.load_profile == ["timing", "distanz"] + assert "Beinarbeit" in inp.skill_hints + + +def test_build_planning_skill_expectations_load_profile_and_text(monkeypatch): + monkeypatch.setattr( + "planning_skill_expectations._load_skills_for_text_match", + lambda cur: _fake_skill_rows(), + ) + cur = _FakeCursor( + [ + {"id": 2, "name": "Timing"}, + {"id": 3, "name": "Distanz"}, + ] + ) + inp = PlanningSkillExpectationInput( + scope=SCOPE_PROGRESSION_STAGE, + primary_topic="Kumite", + goal_query="Kumite Beinarbeit mit Timing", + load_profile=["timing", "distanz"], + ) + exp = build_planning_skill_expectations(cur, inp) + assert exp.scope == SCOPE_PROGRESSION_STAGE + assert "load_profile" in exp.sources or "text_match" in exp.sources + names = {it.skill_name for it in exp.items} + assert "Timing" in names or "Distanz" in names + assert exp.skill_weights + + +def test_expectation_input_from_progression_path_scope(): + inp = expectation_input_from_progression_path( + goal_query="Mae Geri Perfektion", + goal_analysis={"primary_topic": "Mae Geri"}, + resolved_structured={"start_situation": "Grundstellung", "target_state": "freier Kick"}, + ) + assert inp.scope == SCOPE_PROGRESSION_PATH + assert inp.start_situation == "Grundstellung" + assert inp.target_state == "freier Kick" + + +def test_apply_expectations_to_target_noop_without_weights(): + class _Target: + skill_weights = {} + + def to_summary_dict(self, cur): + return {} + + target = _Target() + from planning_skill_expectations import PlanningSkillExpectations + + empty = PlanningSkillExpectations(scope=SCOPE_PROGRESSION_STAGE, skill_weights={}, items=[], sources=[]) + assert apply_expectations_to_target(target, empty) is target diff --git a/backend/tests/test_planning_stage_context.py b/backend/tests/test_planning_stage_context.py new file mode 100644 index 0000000..dae3fa7 --- /dev/null +++ b/backend/tests/test_planning_stage_context.py @@ -0,0 +1,65 @@ +"""Tests Stufen-Kontext (Start/Ziel-Verkettung) und mehrstufige QS.""" +from planning_path_qa_pipeline import derive_optimization_hints, run_multistage_path_qa +from planning_progression_roadmap import MajorStep, StageSpecArtifact +from planning_stage_context import ( + build_contextualized_stage_goal, + derive_stage_specs_transition_states, +) + + +def test_derive_stage_transition_chain(): + majors = [ + MajorStep(index=0, phase="grundlage", learning_goal="Stand-Mawashi", consolidates=["m1"]), + MajorStep(index=1, phase="vertiefung", learning_goal="Sprungvorbereitung", consolidates=["m2"]), + MajorStep(index=2, phase="perfektion", learning_goal="Gesprungener Mawashi", consolidates=["m3"]), + ] + specs = [ + StageSpecArtifact(major_step_index=0, learning_goal=majors[0].learning_goal), + StageSpecArtifact(major_step_index=1, learning_goal=majors[1].learning_goal), + StageSpecArtifact(major_step_index=2, learning_goal=majors[2].learning_goal), + ] + out = derive_stage_specs_transition_states( + specs, + majors, + path_start="Anfänger mit Grundstellung", + path_target="Sauberer gesprungener Mawashi Geri", + ) + assert out[0].start_state == "Anfänger mit Grundstellung" + assert out[1].start_state == out[0].target_state + assert out[2].target_state == "Sauberer gesprungener Mawashi Geri" + + +def test_contextualized_stage_goal_includes_path_target(): + text = build_contextualized_stage_goal( + learning_goal="Sprungkoordination", + start_state="Stand-Mawashi sicher", + target_state="Explosiver Absprung", + path_target_state="Gesprungener Mawashi Geri", + stage_index=1, + stage_count=3, + ) + assert "Sprungkoordination" in text + assert "Gesamtziel" in text + assert "Soll-Start" in text + + +def test_multistage_qa_emits_optimization_hints(): + result = run_multistage_path_qa( + off_topic_steps=[], + stripped_off_topic=[ + { + "step_index": 2, + "issue": "technique_scope", + "title": "Yoko Geri", + "reasons": ["Passt nicht zur Haupttechnik"], + } + ], + gaps=[{"from_title": "A", "to_title": "B", "gap_score": 0.6, "is_large_gap": True}], + llm_qa={"quality_score": 0.25, "recommendations": ["Athletisches Training ergänzen"]}, + llm_applied=True, + ) + assert len(result["qa_tiers"]) == 3 + hints = result["optimization_hints"] + assert any(h.get("action") == "rematch_slot" for h in hints) + assert any(h.get("action") == "bridge_or_gap_fill" for h in hints) + assert derive_optimization_hints(result["qa_tiers"]) diff --git a/backend/tests/test_progression_graph_planning_artifact.py b/backend/tests/test_progression_graph_planning_artifact.py new file mode 100644 index 0000000..1ae1932 --- /dev/null +++ b/backend/tests/test_progression_graph_planning_artifact.py @@ -0,0 +1,59 @@ +"""Tests Planungs-Artefakt am Progressionsgraph.""" +import pytest + +from progression_graph_planning_artifact import ( + ARTIFACT_SCHEMA_VERSION, + normalize_planning_roadmap_payload, +) + + +def test_normalize_planning_roadmap_minimal(): + out = normalize_planning_roadmap_payload( + { + "schema_version": ARTIFACT_SCHEMA_VERSION, + "goal_query": "Mae Geri Perfektion", + "max_steps": 5, + } + ) + assert out["goal_query"] == "Mae Geri Perfektion" + assert out["max_steps"] == 5 + + +def test_normalize_planning_roadmap_with_progression_roadmap(): + out = normalize_planning_roadmap_payload( + { + "goal_query": "Kumite Beinarbeit", + "progression_roadmap": { + "stage_specs": [{"major_step_index": 0, "learning_goal": "Grundstellung"}], + }, + } + ) + assert out["progression_roadmap"]["stage_specs"][0]["learning_goal"] == "Grundstellung" + + +def test_normalize_rejects_invalid_type(): + with pytest.raises(ValueError, match="JSON-Objekt"): + normalize_planning_roadmap_payload("not-json") + + +def test_normalize_slot_contents(): + out = normalize_planning_roadmap_payload( + { + "goal_query": "Gerade-Tritt", + "max_steps": 3, + "slot_contents": [ + { + "major_step_index": 0, + "primary": {"kind": "library", "exercise_id": 12, "title": "Grundstellung"}, + "siblings": [], + }, + { + "major_step_index": 1, + "primary": {"kind": "proposal", "title": "KI-Entwurf", "proposal_key": "p1"}, + "siblings": [], + }, + ], + } + ) + assert len(out["slot_contents"]) == 2 + assert out["slot_contents"][1]["primary"]["kind"] == "proposal" diff --git a/backend/version.py b/backend/version.py index f3eec53..b09870e 100644 --- a/backend/version.py +++ b/backend/version.py @@ -1,8 +1,8 @@ # Shinkan Jinkendo Version Information -APP_VERSION = "0.8.213" -BUILD_DATE = "2026-06-07" -DB_SCHEMA_VERSION = "20260607087" +APP_VERSION = "0.8.226" +BUILD_DATE = "2026-05-22" +DB_SCHEMA_VERSION = "20260607090" MODULE_VERSIONS = { "legal_documents": "1.4.0", # Admin: Live-Vorschau pro Abschnitt + modale Vollvorschau (Editor + Dokumentenliste) @@ -38,7 +38,7 @@ MODULE_VERSIONS = { "skill_profiles": "1.0.0", # Phase 3: gewichtetes Fähigkeiten-Profil + skill-discovery/suggestions "methods": "0.1.0", "exercises": "2.37.1", # KI-Endpoints: feature_usage nach ai_calls consume - "planning_exercise_suggest": "0.21.1", # start_target_only + reicher gap-fill planning_context + "planning_exercise_suggest": "0.23.1", # Phase B: Rematch-Schleife mit optimization_hints + roadmap_unfilled "training_units": "0.4.0", # POST .../publish-to-framework: Ablauf aus geplanter Einheit → Rahmen-Slot-Blueprint "training_programs": "0.1.0", "planning": "0.15.0", # Vorlagen: Strukturvorschau, Bearbeiten inkl. Split-Sessions + Beschreibung @@ -53,6 +53,120 @@ MODULE_VERSIONS = { } CHANGELOG = [ + { + "version": "0.8.226", + "date": "2026-05-22", + "changes": [ + "Progressionsgraph Phase B: Rematch-Schleife (max_rematch_rounds) mit optimization_hints und roadmap_unfilled.", + "Fix: Graph-Bewertung/Match 500 bei off-topic am Rand-Slot (collect_gap_fill_specs IndexError).", + ], + }, + { + "version": "0.8.225", + "date": "2026-06-07", + "changes": [ + "Stufen start_state/target_state (Soll-Verkettung) + kontextualisiertes Matching.", + "Mehrstufige Pfad-QS (tier1–3) mit optimization_hints; Migration 090 Prompt.", + ], + }, + { + "version": "0.8.224", + "date": "2026-06-07", + "changes": [ + "Technik-Pfad-Scope: Geschwister-Techniken (Mae/Yoko bei Mawashi) als hartes Gate in Match/QS.", + "path_primary_topic in build_stage_match_brief; Intent technique_sibling_excludes.", + ], + }, + { + "version": "0.8.223", + "date": "2026-06-07", + "changes": [ + "planning_intent_context: gemeinsamer Intent für anti_patterns/success_criteria (Phase G-ready).", + "Migration 089: LLM-Prompts Zielanalyse + stage_spec mit Intent-Kontext; finalize nach Pipeline.", + ], + }, + { + "version": "0.8.222", + "date": "2026-06-07", + "changes": [ + "Pfad-Ausschlüsse: athletic→Kumite-Heuristik entfernt — nur explizite Negationen und anti_patterns.", + ], + }, + { + "version": "0.8.221", + "date": "2026-06-07", + "changes": [ + "Pfad-Ausschlüsse (keine Kumite etc.) aus Anfrage in Brief, stage_specs und Matching-Gates.", + "QS entfernt path_exclude-Schritte; partielles Strippen wenn die meisten Slots falsch sind.", + ], + }, + { + "version": "0.8.220", + "date": "2026-06-07", + "changes": [ + "Roadmap-Stufen-Match: build_stage_match_brief + stage_semantic_score über Titel, Summary und Goal.", + "Retriever lädt Übungsziele immer bei Stufen-Match; Ranking nach Stufen-Fit statt Gesamtthema.", + ], + }, + { + "version": "0.8.219", + "date": "2026-06-07", + "changes": [ + "Roadmap-Stufen-Gate: Negationen (ohne Tritttechnik) + Pflicht-Treffer Absprung/Beinhebung.", + "anti_patterns in Stufen-Match; Gesamt-Thema allein reicht bei strict_positive nicht mehr.", + ], + }, + { + "version": "0.8.218", + "date": "2026-06-07", + "changes": [ + "Roadmap-Match: Stufen-Lernziel-Gate (semantic_brief_for_stage, stage_learning_goal).", + "Kein Fallback auf globale goal_query bei roadmap_first — Lücke statt falscher Übung.", + "Retrieval-Strafe/Bonus für Stufen-Passung; QS erkennt stage_mismatch.", + ], + }, + { + "version": "0.8.217", + "date": "2026-06-07", + "changes": [ + "F9: planning_roadmap JSONB am Progressionsgraph (Migration 088).", + "PUT Graph + POST edges/sequence speichern Planungs-Artefakt; Laden beim Graph-Wechsel.", + "Roadmap-Vorschlag: include_llm_intent=true für reicheren Semantic Brief.", + "Doku: docs/architecture/PLANNING_PROGRESSION_GRAPH_KI.md als zentrale Ist-Referenz.", + ], + }, + { + "version": "0.8.216", + "date": "2026-06-07", + "changes": [ + "F8: Editierbare stage_specs in UI (Belastung, Erfolgskriterien) → roadmap_override.", + "Erwartete Fähigkeiten als Tags pro Pfadschritt und auf Pfad-Ebene.", + ], + }, + { + "version": "0.8.215", + "date": "2026-06-07", + "changes": [ + "F7: planning_skill_expectations — pro Stufe Retrieval, path_skill_expectations, Gap-Kontext.", + "Wiederverwendbare Scopes für spätere Trainingsplanung (training_section, framework_slot).", + ], + }, + { + "version": "0.8.214", + "date": "2026-06-07", + "changes": [ + "F6: ExerciseGapFillPrepModal — Trainer-Ergänzungen vor KI-Entwurf.", + "context_preview und reicher goal_for_ai aus Roadmap-Snapshot.", + ], + }, + { + "version": "0.8.210", + "date": "2026-06-07", + "changes": [ + "F5: Strukturierte Start/Ziel-Felder, Prompt 087, Zwei-Schritt-UI (start_target_only).", + "Priorität Trainer-Eingabe > KI > Regex; heuristische Start→Ziel-Roadmap.", + ], + }, { "version": "0.8.209", "date": "2026-06-07", diff --git a/docs/HANDOVER.md b/docs/HANDOVER.md index 869ba42..06e5daf 100644 --- a/docs/HANDOVER.md +++ b/docs/HANDOVER.md @@ -89,38 +89,39 @@ 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 Übungssuche (Stand **0.8.183**) +### 2.8 KI Assistenz Übungen & Planungs-KI (Stand **0.8.217**) -**Spec / Pipeline:** `.claude/docs/working/PLANNING_EXERCISE_SUGGEST_CONTEXT.md` +**Zentrale Ist-Doku (Progressionsgraph-KI):** **`docs/architecture/PLANNING_PROGRESSION_GRAPH_KI.md`** — bei Drift zuerst dort pflegen. + +**Retrieval / Scoring:** `.claude/docs/working/PLANNING_EXERCISE_SUGGEST_CONTEXT.md` +**Zielarchitektur Roadmap:** `.claude/docs/working/PLANNING_PROGRESSION_ROADMAP_SPEC.md` +**Produkt-Roadmap Phase G+:** `docs/architecture/PLANNING_KI_ROADMAP.md` | Phase | Inhalt | Status | |-------|--------|--------| -| **P0** | Kontext-Pack, Hybrid-Score, Planungs-Picker | ✅ | -| **P0.1** | `ExerciseMatchProfile` / `PlanningTargetProfile`, `profile_v1` | ✅ | -| **P1** | Szenario-Pipeline + LLM Intent (`073`) + Erwartungsprofil (`074`) | ✅ | -| **P2 / B2** | LLM-Rerank (`072`) bei engem Top-Feld, max. 2 LLM-Calls | ✅ **0.8.182** | -| **A** | Voll-Library deterministisch ranken (kein OR-Profil-Pool) | ✅ **0.8.177** | -| **B** | Text-Signale aus guidance/Rahmen-Zielen (`planning_text_signals`) | ✅ **0.8.181** | -| **C1** | Progressionsgraph auto-match + variantenbewusste Nachfolger | ✅ **0.8.183** | -| **C2** | Varianten in Trefferliste / Picker-Auswahl | ✅ **0.8.184** | -| **C3** | Graph-Builder: Ziel → Pfad vorschlagen → in Graph speichern | ✅ **0.8.185** | -| **E** | Semantik-Schicht (Brief, Phrasen-Score) + Pfad-QA (Lücken, Brücken, LLM-QS) | ✅ **0.8.186** | -| **E2** | Pfad-Neuordnung (LLM) + KI-Neuanlage bei unüberbrückbaren Lücken | ✅ **0.8.187** | -| **E3** | `gap_fill_offers`, Off-Topic-Strip, voller KI-Call bei Lücken | ✅ **0.8.203** | -| **F0–F2** | Roadmap-Pipeline + LLM-Prompts (078/079) | ✅ **0.8.205** | -| **F3** | `roadmap_first` — Retrieval + QA lite (keine Brücken/Reorder) | ✅ **0.8.209** | -| **F4** | Roadmap-Review UI + `roadmap_override` | ✅ **0.8.207** | -| **D** | `planning_context` an `suggestExerciseAi` (Neu-Anlage) | ✅ **0.8.208** | +| **P0–P2, A–C2** | Übungssuche, Voll-Library, Graph-Bias, Varianten | ✅ bis **0.8.184** | +| **C3, E–E3** | Pfad-Builder, Semantik, QA, Gap-Offers | ✅ bis **0.8.203** | +| **D** | `planning_context` an `suggestExerciseAi` | ✅ **0.8.208** | +| **F0–F4** | Roadmap-Pipeline, LLM 078/079, `roadmap_first`, UI-Review | ✅ **0.8.205–209** | +| **F5** | Start/Ziel strukturiert, LLM **087**, Zwei-Schritt-UI | ✅ **0.8.210–214** | +| **F6** | Gap-Prep-Modal, reicher KI-Kontext (`planning_exercise_form_context`) | ✅ **0.8.212–214** | +| **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** | -**Architektur-Entscheidung (2026-06-07):** Progressionsgraph = **Roadmap-first** (Ziel → Major Steps → Übungs-Match). **Keine Gruppenanalyse** im Graphen. Mitai Workflow-Engine **später** — jetzt `planning_progression_roadmap.py`. Spec: **`.claude/docs/working/PLANNING_PROGRESSION_ROADMAP_SPEC.md`** · Roadmap: **`docs/architecture/PLANNING_KI_ROADMAP.md`** +**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`. -**Backend:** `planning_exercise_suggest.py`, `planning_exercise_retrieval.py`, `planning_exercise_path_builder.py`, **`planning_progression_roadmap.py`** · Router `POST /api/planning/exercise-suggest`, `POST /api/planning/progression-path-suggest` (`roadmap_first`, `include_roadmap_preview`) +**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` -**Frontend:** `ExerciseProgressionPathBuilder` — Roadmap-Box + Pfad je Major Step (`roadmap_first`) · `ExercisePickerModal` (Planung) +**API:** `POST /api/planning/progression-path-suggest` · `PUT /api/exercise-progression-graphs/:id` (`planning_roadmap`) · `POST …/edges/sequence` -**Superadmin:** Übungs-Anreicherung (Skills) — `exercise_enrichment_admin` (**0.8.178+**), separater Admin-Flow +**Frontend:** `ExerciseProgressionPathBuilder`, `ExerciseGapFillPrepModal`, `planningContextForExerciseAi.js` -**Offen (F4+):** Roadmap-UI editierbar; Trainingsplanung eigene Pipeline (Gruppenkontext); Enrichment +**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 #### Übungs-KI Formular / Schnellanlage (Stand **0.8.171**) diff --git a/docs/architecture/PLANNING_KI_ROADMAP.md b/docs/architecture/PLANNING_KI_ROADMAP.md index 8f0cda3..ee16d23 100644 --- a/docs/architecture/PLANNING_KI_ROADMAP.md +++ b/docs/architecture/PLANNING_KI_ROADMAP.md @@ -1,10 +1,11 @@ # Planungs-KI — Produkt-Roadmap -**Stand:** 2026-06-07 -**App-Version:** ab **0.8.204** — maßgeblich `backend/version.py` +**Stand:** 2026-05-22 +**App-Version:** **0.8.217** — 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**. +**Ist-Stand Progressionsgraph (detailliert):** `PLANNING_PROGRESSION_GRAPH_KI.md` **Leit-Spec:** `.claude/docs/working/PLANNING_PROGRESSION_ROADMAP_SPEC.md` --- @@ -26,9 +27,10 @@ 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–F1** | Progressionsgraph | Roadmap-Pipeline Scaffold + API-Preview | 🔄 **0.8.204** | -| **F2–F4** | Progressionsgraph | LLM Roadmap, roadmap-first Retrieval, UI Review | 🔲 | +| **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** | | 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 | @@ -70,6 +72,40 @@ Diese Roadmap ergänzt die **Architektur-Refaktor-Roadmap** (`UMSETZUNGSPLAN_ROA - [x] Major Steps editierbar (Phase, Lernziel, Reihenfolge) vor Übungs-Match - [x] API `roadmap_only` + `roadmap_override` +### F5 — Start/Ziel (0.8.210–214) + +- [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“ + +### F6 — Gap-KI-Kontext (0.8.212–214) + +- [x] `ExerciseGapFillPrepModal` vor KI-Call +- [x] `planning_exercise_form_context.py` — Gap-Snapshot, `context_preview` +- [x] Migration **085** — `planning_context` in Übungs-Prompts + +### F7 — Fähigkeiten-Scoring (0.8.215–216) + +- [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 + +### F8 — Stufen-Details UI (0.8.216) + +- [x] Editierbare `stage_specs` in `roadmap_override` (Belastung, Erfolgskriterien, Vermeiden) + +### F9 — Persistenz (0.8.217) + +- [x] Migration **088** — `planning_roadmap` JSONB am Graph +- [x] Laden/Speichern über `GET/PUT` Graph + Sequenz-Endpoint + +### UX — UI-Überarbeitung (offen) + +- [ ] Wizard mit 4 Schritten (Ziel → Roadmap → Match → Lücken) +- [ ] Progressive disclosure — Details in Panels, nicht alles gleichzeitig +- [ ] Briefing: `PLANNING_PROGRESSION_GRAPH_KI.md` §10 + --- ## Abhängigkeiten @@ -85,4 +121,4 @@ Diese Roadmap ergänzt die **Architektur-Refaktor-Roadmap** (`UMSETZUNGSPLAN_ROA ## Pflege -Bei Abschluss einer Teilphase: diese Datei, `HANDOVER.md` §2.8, `PLANNING_EXERCISE_SUGGEST_CONTEXT.md` §24, Changelog in `version.py`. +Bei Abschluss einer Teilphase: **`PLANNING_PROGRESSION_GRAPH_KI.md`** (Ist-Stand), diese Datei, `HANDOVER.md` §2.8, `PLANNING_EXERCISE_SUGGEST_CONTEXT.md` §24, Changelog in `version.py`. diff --git a/docs/architecture/PLANNING_PROGRESSION_GRAPH_KI.md b/docs/architecture/PLANNING_PROGRESSION_GRAPH_KI.md new file mode 100644 index 0000000..5b2df2d --- /dev/null +++ b/docs/architecture/PLANNING_PROGRESSION_GRAPH_KI.md @@ -0,0 +1,368 @@ +# 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`) + +> **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. + +**Bezüge:** +`.claude/docs/working/PLANNING_PROGRESSION_ROADMAP_SPEC.md` (Zielarchitektur) · +`.claude/docs/working/PLANNING_EXERCISE_SUGGEST_CONTEXT.md` (Retrieval/Scoring) · +`.claude/docs/technical/SKILL_SCORING_SPEC.md` (Fähigkeiten-Scoring) · +`docs/architecture/PLANNING_KI_ROADMAP.md` (Produkt-Roadmap Phase G+) + +--- + +## 1. Fachliche Abgrenzung + +| Thema | Progressionsgraph (dieses Feature) | Trainingsplanung (Phase G, später) | +|--------|-------------------------------------|-------------------------------------| +| Ziel | Curriculum / Technikpfad über Übungen | Konkrete Trainingseinheit für Gruppe | +| Kontext | Zieltext, Start/Ziel, Roadmap, optional Graph-Kanten | Gruppe, Historie, Termin, Rahmen | +| Pipeline | `planning_progression_roadmap.py` → Path-Builder | Eigene Pipeline (noch offen) | +| Gruppenanalyse | **Nein** | **Ja** | +| Wiederverwendung | `planning_skill_expectations.py`, `planning_exercise_form_context.py` | Gleiche Bausteine, andere Scopes | + +**Shinkan-Regel:** Der Progressionsgraph ist **keine** persönliche Tracking-App und plant **nicht** für einzelne Sportler. + +--- + +## 2. Trainer-Workflow (UI) + +Aktuell in `ExerciseProgressionPathBuilder.jsx` (eingebettet in `ExerciseProgressionGraphPanel.jsx`): + +``` +① Ziel eingeben (+ optional Start/Ziel-Felder manuell) +② „Start/Ziel analysieren“ (optional, start_target_only) +③ „Roadmap vorschlagen“ (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) +``` + +**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. + +--- + +## 3. Architektur (Module) + +```mermaid +flowchart TB + subgraph ui [Frontend] + EPB[ExerciseProgressionPathBuilder] + GFM[ExerciseGapFillPrepModal] + PCtx[planningContextForExerciseAi.js] + end + + subgraph api [API] + PPS[POST /api/planning/progression-path-suggest] + EAI[POST /api/exercises/ai/suggest] + SEQ[POST /api/exercise-progression-graphs/:id/edges/sequence] + PUT[PUT /api/exercise-progression-graphs/:id] + end + + subgraph roadmap [Roadmap-Pipeline] + PR[planning_progression_roadmap.py] + ST087[Prompt 087 start_target] + ST078[Prompts 078/079 roadmap + stage_spec] + end + + subgraph match [Match + QA] + PB[planning_exercise_path_builder.py] + RET[planning_exercise_retrieval.py] + PG[planning_exercise_progression.py] + SEM[planning_exercise_semantics.py] + end + + subgraph skills [Fähigkeiten — wiederverwendbar] + PSE[planning_skill_expectations.py] + PEFC[planning_exercise_form_context.py] + AIF[planning_exercise_path_ai_fill.py] + end + + subgraph persist [Persistenz Graph] + G088[(planning_roadmap JSONB — Migration 088)] + EDGES[(exercise_progression_edges)] + end + + EPB --> PPS + EPB --> SEQ + EPB --> PUT + GFM --> EAI + PPS --> PR + PPS --> PB + PB --> RET + PB --> PG + PB --> PSE + AIF --> PEFC + PSE --> RET + SEQ --> EDGES + PUT --> G088 + SEQ --> G088 +``` + +### 3.1 Verantwortlichkeiten + +| 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_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) | + +--- + +## 4. API — `POST /api/planning/progression-path-suggest` + +### 4.1 Wichtige Request-Flags + +| Feld | Typ | Bedeutung | +|------|-----|-----------| +| `query` | string | Ziel / Entwicklungsrichtung (min. 3 Zeichen) | +| `max_steps` | int | Anzahl Major Steps (2–10) | +| `progression_graph_id` | int? | Gewählter Graph — siehe §5 | +| `roadmap_first` | bool | Match pro `stage_spec` statt iterativem Pfad | +| `roadmap_only` | bool | Nur Roadmap, keine Übungen | +| `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) | +| `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 | + +### 4.2 Wichtige Response-Felder + +| Feld | Bedeutung | +|------|-----------| +| `progression_roadmap` | Artefakte A/B/C inkl. `resolved_structured`, `stage_specs`, `prompt_slugs` | +| `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` | + +### 4.3 Prompt-Slugs (nur in `ai_prompts`, nie Hardcoding) + +| Slug | Migration | Phase | +|------|-----------|-------| +| `planning_progression_start_target` | **087** | Start/Ziel-Extraktion | +| `planning_progression_goal_analysis` | **078** | Zielanalyse | +| `planning_progression_roadmap` | **078** | Roadmap (micro → major) | +| `planning_progression_stage_spec` | **079** | Stufenspezifikation | + +--- + +## 5. Roadmap-Match — Stufen-Qualität (0.8.218) + +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`. + +Tests: `test_planning_roadmap_stage_match.py` + +--- + +## 6. Rolle des bestehenden Graphs + +**Wichtig — häufiges Missverständnis:** + +| Aspekt | Verhalten | +|--------|-----------| +| **KI-Pfadvorschlag** | Baut einen **neuen** Übungspfad aus der **Bibliothek** (Roadmap-first), nicht aus vorhandenen Graph-Knoten als Start | +| **Schritt 1** | Kein Anker → **kein** Graph-Bias | +| **Ab Schritt 2** | Anker = vorherige Übung im Vorschlag → ausgehende Kanten im Graph werden geladen | +| **Scoring** | Nachfolger im Graph: Bonus `progression` (ca. **4–10 %** Gewicht, Semantik/Profil dominieren) | +| **Speichern** | `POST …/edges/sequence` **fügt Kanten hinzu** — ersetzt den Graph nicht | +| **Nicht implementiert** | „Ab letztem Graph-Knoten erweitern“, bestehende Sequenz als Pfad-Start | + +Code: `planning_exercise_progression.py` → `apply_progression_context_to_pack` → `planning_exercise_retrieval.py` (`prog_hit`). + +--- + +## 7. Persistenz + +### 6.1 Kanten (`exercise_progression_edges`) + +- Übungsfolge als gerichtete Kanten `next_exercise` +- Varianten-aware (Migration **034**) +- Kanten-Notizen aus Pfad-Begründungen oder Fallback-`segment_notes` + +### 6.2 Planungs-Artefakt (`planning_roadmap` JSONB, Migration **088**) + +Gespeichert am **Graph-Container** (`exercise_progression_graphs`), Schema v1: + +```json +{ + "schema_version": 1, + "goal_query": "…", + "start_situation": "…", + "target_state": "…", + "roadmap_notes": "…", + "max_steps": 5, + "progression_roadmap": { }, + "path_skill_expectations": { } +} +``` + +| Aktion | API | +|--------|-----| +| Speichern (Roadmap/Match) | `PUT /api/exercise-progression-graphs/:id` | +| Speichern (mit Pfad) | `POST …/edges/sequence` (optional `planning_roadmap` im Body) | +| Laden | `GET /api/exercise-progression-graphs/:id` → Frontend stellt Workflow-Felder wieder her | + +Validierung: `progression_graph_planning_artifact.py` · Tests: `test_progression_graph_planning_artifact.py` + +**`planning_roadmap` ≠ Graph-Kanten:** Metadaten für Wiederaufnahme der KI-Planung, nicht der Übungsgraph selbst. + +--- + +## 8. Planungs-Intent (gemeinsam mit Trainingsplanung) + +Modul: **`planning_intent_context.py`** — domänenneutral, wiederverwendbar in Phase G. + +| Baustein | Progressionsgraph heute | Trainingsplanung (Phase G) | +|----------|-------------------------|----------------------------| +| `build_planning_intent_context` | Aus `goal_query`, Zielanalyse, Notizen | Aus `section_guidance`, Slot, Einheit | +| `explicit_exclusions` | Negationen (`ohne/kein/nicht …`) | gleich | +| `path_anti_patterns` | → jede `stage_spec.anti_patterns` | → Abschnitts-/Slot-Brief | +| `path_success_criteria` | → `success_criteria` + Matching | → Slot-Erwartung | +| `finalize_stage_specs_with_intent` | Nach heuristischer/LLM-Roadmap | Analog für Sektionen | + +LLM: Migration **089** — Prompts `planning_progression_goal_analysis` + `planning_progression_stage_spec` erhalten `intent_context_json`; Ausschlüsse nicht erfinden, nur aus Anfrage übernehmen. + +Matching: `anti_patterns` + `success_criteria` → `build_stage_match_brief` → Retrieval-Gate (Titel + Summary + Goal). + +### Stufen im Gesamtziel (`planning_stage_context.py`) + +| Feld | Bedeutung | +|------|-----------| +| `start_state` | Soll-Start der Stufe (= `target_state` der Vorstufe / Pfad-Start) | +| `target_state` | Ziel nach dieser Stufe (= Soll für den nächsten Schritt) | +| `build_contextualized_stage_goal()` | Lernziel + Start + Stufen-Ziel + Gesamtziel → Brief/Retrieval | + +Deterministisch: `derive_stage_specs_transition_states()` nach Roadmap-Pipeline; LLM kann Felder überschreiben (Prompt **090**). + +### Mehrstufige Pfad-QS (`planning_path_qa_pipeline.py`) + +| Stufe | Inhalt | Ableitung | +|-------|--------|-----------| +| **tier1** | Deterministische Gates (Technik-Scope, Ausschlüsse, unfilled) | `optimization_hints` → `rematch_slot`, `refine_stage_spec` | +| **tier2** | Übergangs-Lücken zwischen Schritten | `bridge_or_gap_fill` | +| **tier3** | LLM-Ganzpfad + Empfehlungen | `review_roadmap` | + +API: `path_qa.qa_tiers`, `path_qa.optimization_hints` — **kein** anfrage-spezifischer Patch, sondern strukturierte Rückkopplung. Auto-Rematch-Schleife: Backlog (QS → Aktion → erneutes Match). + +Phase G: gleiche Tiers für Abschnitts-QS nach Einheits-Match. + +## 9. Fähigkeiten-Scoring-Anbindung + +Modul: `planning_skill_expectations.py` + +| Scope | Verwendung heute | Phase G (vorbereitet) | +|-------|------------------|------------------------| +| `progression_path` | Einmaliges Pfad-Profil (`path_skill_expectations`) | — | +| `progression_stage` | Pro Roadmap-Stufe vor Retrieval + in Gap-Kontext | — | +| `training_section` | — | Trainingsabschnitt | +| `framework_slot` | — | Rahmen-Slot | + +Quellen: `semantic_topic`, `text_match`, `load_profile` (aus `stage_spec`). + +Integration: +- Retrieval: `apply_expectations_to_target` → Hybrid-Score +- UI: Tags pro Pfadschritt + Pfad-Header; Gap-Kontext „Erwartete Fähigkeiten“ +- KI-Neuanlage: `expected_skills` in `context_preview` / `goal_for_ai` + +--- + +## 10. KI-Lücken (Gap-Fill) + +Flow: +1. `roadmap_unfilled` / QA-Lücken → `gap_fill_offers` +2. Trainer: **„Vorbereiten & KI anlegen“** → `ExerciseGapFillPrepModal` (Titel, Stufen-Lernziel, Ergänzungen) +3. `POST /api/exercises/ai/suggest` mit `planning_context` +4. Vorschau → Übung anlegen → in Pfad einfügen + +Kontext-Helfer: `frontend/src/utils/planningContextForExerciseAi.js` + +--- + +## 11. Implementierungsstände (Phasen) + +| Phase | Inhalt | Status | Version | +|-------|--------|--------|---------| +| F0–F2 | Roadmap-Pipeline + LLM-Prompts 078/079 | ✅ | 0.8.204–205 | +| F3 | `roadmap_first` Match pro Stufe | ✅ | 0.8.206–209 | +| F4 | Roadmap-Review UI + `roadmap_override` | ✅ | 0.8.207 | +| F5 | Start/Ziel strukturiert + LLM **087** + Zwei-Schritt-UI | ✅ | 0.8.210–214 | +| F6 | Gap-Prep-Modal + reicher `planning_context` | ✅ | 0.8.212–214 | +| 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 | + +--- + +## 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) + +### Briefing-Vorlage UI-Chat (Copy-Paste) + +Siehe Nutzer-Chat 2026-05-22 oder `HANDOVER.md` §2.8 — Abschnitt „UI-Überarbeitung“. + +Kern: Wizard ① Ziel & Start/Ziel → ② Roadmap → ③ Match → ④ Lücken & Speichern; Backend-Pipeline unverändert lassen. + +--- + +## 13. Tests + +| Datei | Abdeckung | +|-------|-----------| +| `test_planning_progression_roadmap.py` | Roadmap-Pipeline, Override, Start/Ziel | +| `test_planning_exercise_path_builder.py` | Pfad-Annotierung, Skill-Expectations auf Steps | +| `test_planning_skill_expectations.py` | Skill-Erwartungen, Scopes | +| `test_planning_intent_context.py` | Intent-Kontext, finalize stage_specs | +| `test_planning_exercise_path_ai_fill.py` | Gap-Fill, `expected_skills` in goal text | +| `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 | + +--- + +## 14. Dokumenten-Index (Drift vermeiden) + +| Frage | Primäre Quelle | +|-------|----------------| +| Was ist der aktuelle Ist-Stand? | **Diese Datei** | +| Zielarchitektur / JSON-Artefakte | `PLANNING_PROGRESSION_ROADMAP_SPEC.md` | +| Retrieval, Hybrid-Score, Intents | `PLANNING_EXERCISE_SUGGEST_CONTEXT.md` | +| Produkt-Roadmap Phase G+ | `PLANNING_KI_ROADMAP.md` | +| Session-Handover / nächste Schritte | `docs/HANDOVER.md` §2.8 | +| Fähigkeiten-Scoring allgemein | `SKILL_SCORING_SPEC.md` | +| Versionen / Changelog | `backend/version.py` | + +**Pflege-Regel:** Bei jeder abgeschlossenen Teilphase diese Datei + `HANDOVER.md` §2.8 + `version.py` CHANGELOG aktualisieren. + +--- + +## 15. Changelog (Dokument) + +| Datum | Änderung | +|-------|----------| +| 2026-05-22 | Erstfassung Ist-Stand 0.8.217 — zentrale Referenz nach F5–F9 | diff --git a/docs/architecture/README.md b/docs/architecture/README.md index 40061f6..a3a4c40 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -14,7 +14,8 @@ Dieses Bündel ist die **Leitlinie für die große Refaktorierung** nach dem MVP | [`frontend/src/api/planning.js`](../../frontend/src/api/planning.js) | Phase 4: Trainingsplanung (Einheiten, Vorlagen, Module, Rahmen, KPIs) | | [BASELINE_SNAPSHOT.md](./BASELINE_SNAPSHOT.md) | Phase 0: Bundle-, API- und Last-Baseline (Messvorlagen, Vergleich nach Phase 2) | | [KI-Prompt-Zielarchitektur](../../.claude/docs/technical/AI_PROMPT_TARGET_ARCHITECTURE.md) | Roadmap: Kontext-Arten, Composition, Planung/Rahmen, Phasenplan (verbindliche Zielrichtung) | -| [PLANNING_KI_ROADMAP.md](./PLANNING_KI_ROADMAP.md) | **Planungs-KI Produkt-Roadmap** (Phase F Roadmap-first, Abgrenzung Trainingsplanung) | +| [PLANNING_PROGRESSION_GRAPH_KI.md](./PLANNING_PROGRESSION_GRAPH_KI.md) | **Progressionsgraph-KI Ist-Stand** (Module, API, Graph-Verhalten, Persistenz — zentrale Referenz) | +| [PLANNING_KI_ROADMAP.md](./PLANNING_KI_ROADMAP.md) | **Planungs-KI Produkt-Roadmap** (Phase F–G, Abgrenzung Trainingsplanung) | | [Progressions-Roadmap Spec](../../.claude/docs/working/PLANNING_PROGRESSION_ROADMAP_SPEC.md) | Phase F: Artefakte A→B→C, API, Workflow-lite | ## Tests (E2E / Refaktor-Budget) diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index acb42d4..1f695a6 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -31,6 +31,7 @@ const SettingsSystemInfoPage = lazy(() => import('./pages/SettingsSystemInfoPage const ExercisesListPage = lazy(() => import('./pages/ExercisesListPage')) const ExerciseDetailPage = lazy(() => import('./pages/ExerciseDetailPage')) const ExerciseFormPage = lazy(() => import('./pages/ExerciseFormPage')) +const ProgressionGraphEditPage = lazy(() => import('./pages/ProgressionGraphEditPage')) const ClubsPage = lazy(() => import('./pages/ClubsPage')) const InboxPage = lazy(() => import('./pages/InboxPage')) const SkillsPage = lazy(() => import('./pages/SkillsPage')) @@ -244,6 +245,7 @@ const appRouter = createBrowserRouter([ { path: 'settings/system', element: }, { path: 'settings/legal', element: }, { path: 'media', element: }, + { path: 'progression-graphs/:id', element: }, { path: 'exercises', children: [ diff --git a/frontend/src/api/exercises.js b/frontend/src/api/exercises.js index 4eee53d..4e72274 100644 --- a/frontend/src/api/exercises.js +++ b/frontend/src/api/exercises.js @@ -521,6 +521,16 @@ export async function getExerciseProgressionGraph(id, { includeEdges = false } = return request(`/api/exercise-progression-graphs/${id}${q}`) } +export async function getProgressionGraphVisibilityPromotionCandidates( + graphId, + { targetVisibility = 'club' } = {}, +) { + const q = new URLSearchParams({ target_visibility: targetVisibility }) + return request( + `/api/exercise-progression-graphs/${graphId}/visibility-promotion-candidates?${q}`, + ) +} + export async function createExerciseProgressionGraph(data) { return request('/api/exercise-progression-graphs', { method: 'POST', diff --git a/frontend/src/components/ExerciseProgressionGraphPanel.jsx b/frontend/src/components/ExerciseProgressionGraphPanel.jsx index cd92620..2d02149 100644 --- a/frontend/src/components/ExerciseProgressionGraphPanel.jsx +++ b/frontend/src/components/ExerciseProgressionGraphPanel.jsx @@ -1,15 +1,21 @@ /** - * Progressionsgraphen: Sequenz-Editor (mehrere Nachfolger auf einmal), Ketten-Ansicht, - * Varianten als eigene Knoten-Endpunkte, Schwester-Kanten gesondert, Tabelle als Fallback. + * Progressionsgraphen — Kachel-Übersicht (wie Übungen) + Editor-Detailansicht. */ -import React, { useCallback, useEffect, useMemo, useState } from 'react' -import { Link } from 'react-router-dom' +import React, { + forwardRef, + useCallback, + useEffect, + useImperativeHandle, + useMemo, + useState, +} from 'react' +import { Link, useLocation } from 'react-router-dom' import api from '../utils/api' import SkillProfilePanel from './skills/SkillProfilePanel' import { useAuth } from '../context/AuthContext' -import { getTenantClubDependencyKey } from '../utils/activeClub' -import ExercisePickerModal from './ExercisePickerModal' -import ExerciseProgressionPathBuilder from './ExerciseProgressionPathBuilder' +import { activeClubMemberships, getTenantClubDependencyKey } from '../utils/activeClub' +import ProgressionGraphEditor from './ProgressionGraphEditor' +import ProgressionGraphListCard from './ProgressionGraphListCard' import { EXERCISE_VISIBILITY_FIELD_LABEL } from '../constants/exerciseGovernanceLabels' const VIS_OPTIONS = [ @@ -24,87 +30,16 @@ function edgeTypeLabel(type) { return type || '—' } -/** Maximale lineare Segmente aus next_exercise-Kanten (jedes Segment deckt zusammenhängende „Pfade“ ab). */ -function maximalLinearChains(nextEdges) { - if (!nextEdges?.length) return [] - const outMap = new Map() - const inMap = new Map() - const nodeKey = (ex, v) => `${ex}:${v ?? ''}` - - for (const e of nextEdges) { - const f = nodeKey(e.from_exercise_id, e.from_exercise_variant_id) - const t = nodeKey(e.to_exercise_id, e.to_exercise_variant_id) - if (!outMap.has(f)) outMap.set(f, []) - outMap.get(f).push(e) - if (!inMap.has(t)) inMap.set(t, []) - inMap.get(t).push(e) - } - - const used = new Set() - const chains = [] - - for (const startEdge of nextEdges) { - if (used.has(startEdge.id)) continue - - const edgesSeq = [startEdge] - - let fk = nodeKey(startEdge.from_exercise_id, startEdge.from_exercise_variant_id) - while (true) { - const preds = inMap.get(fk) - if (!preds || preds.length !== 1) break - const pred = preds[0] - if (used.has(pred.id)) break - edgesSeq.unshift(pred) - fk = nodeKey(pred.from_exercise_id, pred.from_exercise_variant_id) - } - - let tk = nodeKey(startEdge.to_exercise_id, startEdge.to_exercise_variant_id) - while (true) { - const outs = outMap.get(tk) - if (!outs || outs.length !== 1) break - const nx = outs[0] - if (used.has(nx.id)) break - edgesSeq.push(nx) - tk = nodeKey(nx.to_exercise_id, nx.to_exercise_variant_id) - } - - edgesSeq.forEach((ed) => used.add(ed.id)) - - const first = edgesSeq[0] - const nodes = [ - { - exercise_id: first.from_exercise_id, - variant_id: first.from_exercise_variant_id ?? null, - title: first.from_exercise_title, - variant_name: first.from_variant_name ?? null, - }, - ] - for (const ed of edgesSeq) { - nodes.push({ - exercise_id: ed.to_exercise_id, - variant_id: ed.to_exercise_variant_id ?? null, - title: ed.to_exercise_title, - variant_name: ed.to_variant_name ?? null, - }) - } - chains.push({ nodes, edges: edgesSeq }) - } - return chains -} - -function emptySeqStep() { - return { exerciseId: null, exerciseTitle: '', variantId: null, variants: [] } -} - -function emptyEndpoint() { - return { exerciseId: null, exerciseTitle: '', variantId: null, variants: [] } -} - -export default function ExerciseProgressionGraphPanel({ - anchorExerciseId = null, - anchorTitle = null, -}) { +function ExerciseProgressionGraphPanel( + { + anchorExerciseId = null, + anchorTitle = null, + initialGraphId = null, + }, + ref, +) { const { user } = useAuth() + const location = useLocation() const isSuperadmin = user?.role === 'superadmin' const tenantClubDepKey = useMemo(() => getTenantClubDependencyKey(user), [user]) @@ -119,6 +54,7 @@ export default function ExerciseProgressionGraphPanel({ const [busy, setBusy] = useState(false) const [loadErr, setLoadErr] = useState(null) + const [createModalOpen, setCreateModalOpen] = useState(false) const [newGraphName, setNewGraphName] = useState('') const [newGraphVisibility, setNewGraphVisibility] = useState('private') @@ -126,26 +62,35 @@ export default function ExerciseProgressionGraphPanel({ const [metaDescription, setMetaDescription] = useState('') const [metaVisibility, setMetaVisibility] = useState('private') - const [sequenceSteps, setSequenceSteps] = useState([emptySeqStep(), emptySeqStep()]) - const [sequenceBulkNotes, setSequenceBulkNotes] = useState('') - const [pickContext, setPickContext] = useState(null) - - const [relationKind, setRelationKind] = useState('progression') - const [firstEp, setFirstEp] = useState(emptyEndpoint) - const [secondEp, setSecondEp] = useState(emptyEndpoint) - const [edgeNotes, setEdgeNotes] = useState('') - const [filterAnchorOnly, setFilterAnchorOnly] = useState(!!anchorExerciseId) const [editingEdgeNotes, setEditingEdgeNotes] = useState(null) const [notesDraft, setNotesDraft] = useState('') - const [uiTab, setUiTab] = useState('overview') const [skillProfileData, setSkillProfileData] = useState(null) const [skillProfileLoading, setSkillProfileLoading] = useState(false) const [skillProfileError, setSkillProfileError] = useState('') + useImperativeHandle(ref, () => ({ + openCreateDialog: () => { + setNewGraphName('') + setNewGraphVisibility('private') + setCreateModalOpen(true) + }, + })) + useEffect(() => { - setSelectedGraphId(null) - }, [tenantClubDepKey]) + const gid = + initialGraphId ?? + location.state?.progressionGraphId + if (gid != null && Number.isFinite(Number(gid))) { + setSelectedGraphId(Number(gid)) + } + }, [location.state?.progressionGraphId, initialGraphId]) + + useEffect(() => { + if (!initialGraphId && !location.state?.progressionGraphId) { + setSelectedGraphId(null) + } + }, [tenantClubDepKey, initialGraphId, location.state?.progressionGraphId]) const refreshGraphs = useCallback(async () => { const list = await api.listExerciseProgressionGraphs() @@ -162,12 +107,6 @@ export default function ExerciseProgressionGraphPanel({ setEdges(Array.isArray(list) ? list : []) }, []) - const loadVariantsForExercise = useCallback(async (exerciseId) => { - if (!exerciseId) return [] - const ex = await api.getExercise(exerciseId) - return Array.isArray(ex?.variants) ? ex.variants : [] - }, []) - useEffect(() => { let cancelled = false ;(async () => { @@ -239,30 +178,6 @@ export default function ExerciseProgressionGraphPanel({ } }, [selectedGraphId, graphs, refreshEdges]) - useEffect(() => { - let cancelled = false - ;(async () => { - if (!firstEp.exerciseId) return - const vars = await loadVariantsForExercise(firstEp.exerciseId) - if (!cancelled) setFirstEp((p) => ({ ...p, variants: vars })) - })() - return () => { - cancelled = true - } - }, [firstEp.exerciseId, loadVariantsForExercise]) - - useEffect(() => { - let cancelled = false - ;(async () => { - if (!secondEp.exerciseId) return - const vars = await loadVariantsForExercise(secondEp.exerciseId) - if (!cancelled) setSecondEp((p) => ({ ...p, variants: vars })) - })() - return () => { - cancelled = true - } - }, [secondEp.exerciseId, loadVariantsForExercise]) - const filteredEdges = useMemo(() => { if (!filterAnchorOnly || anchorExerciseId == null) return edges return edges.filter( @@ -271,17 +186,6 @@ export default function ExerciseProgressionGraphPanel({ ) }, [edges, filterAnchorOnly, anchorExerciseId]) - const nextEdgesFiltered = useMemo( - () => filteredEdges.filter((e) => e.edge_type === 'next_exercise'), - [filteredEdges], - ) - const siblingEdgesFiltered = useMemo( - () => filteredEdges.filter((e) => e.edge_type === 'sibling'), - [filteredEdges], - ) - - const flowChains = useMemo(() => maximalLinearChains(nextEdgesFiltered), [nextEdgesFiltered]) - const handleCreateGraph = async (e) => { e.preventDefault() const name = newGraphName.trim() @@ -295,6 +199,7 @@ export default function ExerciseProgressionGraphPanel({ name, visibility: newGraphVisibility, }) + setCreateModalOpen(false) setNewGraphName('') await refreshGraphs() if (created?.id != null) setSelectedGraphId(created.id) @@ -305,6 +210,30 @@ export default function ExerciseProgressionGraphPanel({ } } + const handleDeleteGraph = async (graph) => { + const gid = graph?.id ?? selectedGraphId + if (!gid) return + if (!window.confirm(`Progressionsgraph „${graph?.name || gid}" wirklich löschen?`)) return + setBusy(true) + try { + await api.deleteExerciseProgressionGraph(gid) + if (selectedGraphId === gid) setSelectedGraphId(null) + await refreshGraphs() + } catch (err) { + alert(err.message || String(err)) + } finally { + setBusy(false) + } + } + + const resolvePromoteClubId = () => { + const g = graphs.find((x) => x.id === selectedGraphId) + if (g?.club_id != null) return Number(g.club_id) + const memberships = activeClubMemberships(user?.clubs) + const active = memberships.find((c) => c.is_active) || memberships[0] + return active?.club_id != null ? Number(active.club_id) : null + } + const handleSaveMeta = async () => { if (!selectedGraphId) return const name = metaName.trim() @@ -312,145 +241,57 @@ export default function ExerciseProgressionGraphPanel({ alert('Name ist Pflicht') return } + const prevGraph = graphs.find((x) => x.id === selectedGraphId) + const prevVis = (prevGraph?.visibility || 'private').trim().toLowerCase() + const nextVis = (metaVisibility || 'private').trim().toLowerCase() + setBusy(true) try { + if (prevVis === 'private' && nextVis === 'club') { + const preview = await api.getProgressionGraphVisibilityPromotionCandidates( + selectedGraphId, + { targetVisibility: 'club' }, + ) + const privateExercises = Array.isArray(preview?.exercises) ? preview.exercises : [] + if (privateExercises.length > 0) { + const titles = privateExercises + .slice(0, 8) + .map((ex) => `• ${ex.title || `Übung #${ex.id}`}`) + .join('\n') + const more = + privateExercises.length > 8 + ? `\n… und ${privateExercises.length - 8} weitere` + : '' + const promote = window.confirm( + `Der Graph wird auf „Verein“ gestellt. Im Graph sind noch ${privateExercises.length} private Übung(en):\n\n${titles}${more}\n\nDiese Übungen ebenfalls auf Vereins-Sichtbarkeit anheben?`, + ) + if (promote) { + const clubId = resolvePromoteClubId() + if (!clubId) { + alert('Kein aktiver Verein — Übungen können nicht auf Verein promoted werden.') + } else { + const ids = privateExercises.map((ex) => ex.id).filter((id) => id != null) + const res = await api.bulkPatchExercisesMetadata({ + exercise_ids: ids, + visibility: 'club', + club_id: clubId, + }) + if ((res?.failed || []).length) { + const f = res.failed[0] + throw new Error(f?.detail || 'Übungs-Promotion fehlgeschlagen') + } + } + } + } + } + await api.updateExerciseProgressionGraph(selectedGraphId, { name, description: metaDescription.trim() || null, visibility: metaVisibility, }) await refreshGraphs() - alert('Graph gespeichert.') - } catch (err) { - alert(err.message || String(err)) - } finally { - setBusy(false) - } - } - - const handleDeleteGraph = async () => { - if (!selectedGraphId) return - if (!confirm('Diesen Progressionsgraphen und alle Kanten wirklich löschen?')) return - setBusy(true) - try { - await api.deleteExerciseProgressionGraph(selectedGraphId) - setSelectedGraphId(null) - await refreshGraphs() - } catch (err) { - alert(err.message || String(err)) - } finally { - setBusy(false) - } - } - - const patchSeqStep = (idx, patch) => { - setSequenceSteps((prev) => prev.map((s, i) => (i === idx ? { ...s, ...patch } : s))) - } - - const addSeqStep = () => setSequenceSteps((prev) => [...prev, emptySeqStep()]) - - const removeSeqStep = (idx) => { - setSequenceSteps((prev) => { - if (prev.length <= 2) return prev - return prev.filter((_, i) => i !== idx) - }) - } - - const moveSeqStep = (idx, dir) => { - setSequenceSteps((prev) => { - const j = idx + dir - if (j < 0 || j >= prev.length) return prev - const next = [...prev] - const t = next[idx] - next[idx] = next[j] - next[j] = t - return next - }) - } - - const submitSequence = async () => { - if (!selectedGraphId) { - alert('Zuerst einen Graphen wählen.') - return - } - const steps = sequenceSteps.filter((s) => s.exerciseId != null) - if (steps.length < 2) { - alert('Mindestens zwei Schritte mit gewählter Übung.') - return - } - const n = steps.length - 1 - const noteRaw = sequenceBulkNotes.trim() - const segment_notes = Array.from({ length: n }, () => (noteRaw ? noteRaw : null)) - - setBusy(true) - try { - await api.createExerciseProgressionSequence(selectedGraphId, { - steps: steps.map((s) => ({ - exercise_id: s.exerciseId, - variant_id: s.variantId || null, - })), - segment_notes, - }) - setSequenceBulkNotes('') - await refreshEdges(selectedGraphId) - alert(`${n} Nachfolger-Kante(n) angelegt.`) - } catch (err) { - alert(err.message || String(err)) - } finally { - setBusy(false) - } - } - - const deleteChain = async (edgeObjs) => { - if (!selectedGraphId || !edgeObjs?.length) return - if (!confirm(`${edgeObjs.length} Kante(n) dieser Reihe löschen?`)) return - setBusy(true) - try { - await api.deleteExerciseProgressionEdgesBatch( - selectedGraphId, - edgeObjs.map((e) => e.id), - ) - await refreshEdges(selectedGraphId) - } catch (err) { - alert(err.message || String(err)) - } finally { - setBusy(false) - } - } - - const handleAddEdge = async () => { - if (!selectedGraphId) { - alert('Zuerst einen Graphen wählen.') - return - } - if (!firstEp.exerciseId || !secondEp.exerciseId) { - alert('Beide Enden müssen eine Übung haben.') - return - } - if ( - firstEp.exerciseId === secondEp.exerciseId && - (firstEp.variantId == null || - secondEp.variantId == null || - firstEp.variantId === secondEp.variantId) - ) { - alert('Bei derselben Übung bitte zwei verschiedene Varianten wählen (oder unterschiedliche Übungen).') - return - } - const edge_type = relationKind === 'sibling' ? 'sibling' : 'next_exercise' - const notes = edgeNotes.trim() || null - const body = { - from_exercise_id: firstEp.exerciseId, - to_exercise_id: secondEp.exerciseId, - from_exercise_variant_id: firstEp.variantId || null, - to_exercise_variant_id: secondEp.variantId || null, - edge_type, - notes, - } - setBusy(true) - try { - await api.createExerciseProgressionEdge(selectedGraphId, body) - setEdgeNotes('') - await refreshEdges(selectedGraphId) + alert('Graph-Metadaten gespeichert.') } catch (err) { alert(err.message || String(err)) } finally { @@ -460,7 +301,6 @@ export default function ExerciseProgressionGraphPanel({ const handleDeleteEdge = async (edgeId) => { if (!selectedGraphId) return - if (!confirm('Kante löschen?')) return setBusy(true) try { await api.deleteExerciseProgressionEdge(selectedGraphId, edgeId) @@ -493,58 +333,164 @@ export default function ExerciseProgressionGraphPanel({ } } - const swapEnds = () => { - const a = firstEp - setFirstEp(secondEp) - setSecondEp(a) - } + const handleEditorSaved = useCallback(async () => { + if (!selectedGraphId) return + await Promise.all([refreshEdges(selectedGraphId), refreshGraphs()]) + }, [selectedGraphId, refreshEdges, refreshGraphs]) - const applyPickedExercise = async (ex) => { - const title = ex.title || `Übung #${ex.id}` - const variants = Array.isArray(ex.variants) && ex.variants.length - ? ex.variants - : await loadVariantsForExercise(ex.id) - const variantId = - ex.exercise_variant_id ?? ex.suggested_variant_id ?? null + const selectedGraph = graphs.find((g) => g.id === selectedGraphId) - if (pickContext?.kind === 'sequence') { - patchSeqStep(pickContext.index, { - exerciseId: ex.id, - exerciseTitle: title, - variantId: variantId != null ? Number(variantId) : null, - variants, - }) - setPickContext(null) - return - } - if (pickContext?.kind === 'single') { - const patch = { - exerciseId: ex.id, - exerciseTitle: title, - variantId: variantId != null ? Number(variantId) : null, - variants, - } - if (pickContext.slot === 'first') setFirstEp(patch) - else setSecondEp(patch) - setPickContext(null) - } - } - - function formatNodeLine(n) { + if (loadErr) { return ( - <> - {n.title} - {n.variant_name ? ( - {` · ${n.variant_name}`} - ) : null} - +
+

{loadErr}

+
) } - const pickerOpen = pickContext != null + if (!selectedGraphId) { + return ( +
+ {anchorExerciseId != null && ( +

+ Kontext:{' '} + {anchorTitle?.trim() || `Übung #${anchorExerciseId}`} + {' · '} + Ansehen +

+ )} + +

+ Progressionsgraphen planen didaktische Entwicklungspfade mit Roadmap-Slots, KI-Match und + Bewertung — analog zur Übungsbibliothek als eigene Sammlung. +

+ + {busy && graphs.length === 0 ? ( +
+
+

+ Lade Progressionsgraphen… +

+
+ ) : graphs.length === 0 ? ( +
+

+ Noch keine Progressionsgraphen — lege den ersten an. +

+ +
+ ) : ( +
+ {graphs.map((g) => ( + setSelectedGraphId(row.id)} + onDelete={handleDeleteGraph} + /> + ))} +
+ )} + + {createModalOpen ? ( +
{ + if (e.target === e.currentTarget && !busy) setCreateModalOpen(false) + }} + > +
e.stopPropagation()} + style={{ maxWidth: '480px' }} + > +
+

+ Neuer Progressionsgraph +

+ +
+
+
+
+ + setNewGraphName(e.target.value)} + placeholder="z. B. Kumite-Einstieg Verein Nord" + autoFocus + /> +
+
+ + +
+
+
+ + +
+
+
+
+ ) : null} +
+ ) + } return (
+
+ +

+ {selectedGraph?.name || `Graph #${selectedGraphId}`} +

+
+ {anchorExerciseId != null && (

Kontext:{' '} @@ -554,86 +500,27 @@ export default function ExerciseProgressionGraphPanel({

)} -

- Pro Graph mehrere Reihen und Alternativen: eine{' '} - Sequenz legt automatisch alle Schritte Übung1 → Übung2 → … als Nachfolger-Kanten an. - Optional pro Schritt eine Variante — sie wirkt wie ein eigener Knoten. Verzweigungen und - Schwestern trennst du weiterhin mit Einzelkanten oder mehreren Sequenzen aus dem gleichen Knoten. -

- - {loadErr && ( -
-

{loadErr}

-
+ {selectedGraphId && anchorExerciseId != null && ( + )} -
-

Graph auswählen

-
-
- - -
- - -
+ -
-

Neuen Graphen anlegen

-
-
- - setNewGraphName(e.target.value)} - placeholder="z. B. Kumite-Einstieg Verein Nord" - /> -
-
- - -
- -
-
-
- - {selectedGraphId && ( -
-

Graph bearbeiten

+
+ Graph-Einstellungen (Name, Sichtbarkeit) +
setMetaName(e.target.value)} /> @@ -649,7 +536,11 @@ export default function ExerciseProgressionGraphPanel({
- setMetaVisibility(e.target.value)} + > {filteredGraphVisOptions.map((o) => (
- -
- )} - - {selectedGraphId && ( -
- - -
- )} - - {selectedGraphId && uiTab === 'overview' && ( - <> - - { - await refreshEdges(selectedGraphId) - }} - /> -
-

Sequenz / Reihe anlegen

-

- Reihenfolge von links nach rechts: jede Zeile ein Schritt. Es werden automatisch alle Nachfolger-Kanten - zwischen benachbarten Schritten erzeugt (ein API-Vorgang). -

- {sequenceSteps.map((step, idx) => ( -
-
- -
- - {step.exerciseId ? ( - <> - {step.exerciseTitle} - (#{step.exerciseId}) - - ) : ( - Übung wählen - )} - - - {anchorExerciseId != null && ( - - )} -
-
-
- - -
-
- - - -
-
- ))} - -
- -