Progression optimiert Phase A #55

Merged
Lars merged 33 commits from develop into main 2026-06-11 21:26:54 +02:00
55 changed files with 11914 additions and 1298 deletions

View File

@ -493,23 +493,37 @@ Nach Pfad-Bildung:
---
## 24. Phase F — Roadmap-first Progressionsgraph (0.8.204+) 🔄
## 24. Phase F — Roadmap-first Progressionsgraph (0.8.204217) ✅
**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 ~410 %), 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)

View File

@ -2,9 +2,11 @@
**Version:** 0.1
**Datum:** 2026-06-07
**Status:** VERBINDLICHE ZIELARCHITEKTUR — Umsetzung gestartet (0.8.204+)
**Status:** VERBINDLICHE ZIELARCHITEKTUR — **F0F9 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.206209 |
| **F4** | UI Roadmap-Review + `roadmap_override` | ✅ 0.8.207 |
| **F5** | Start/Ziel strukturiert + Prompt **087** + Zwei-Schritt-UI | ✅ 0.8.210214 |
| **F6** | Gap-Prep + `planning_context` an Übungs-KI | ✅ 0.8.212214 |
| **F7** | `planning_skill_expectations` | ✅ 0.8.215216 |
| **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 F5F9 dokumentiert; Verweis auf `PLANNING_PROGRESSION_GRAPH_KI.md`.
- **2026-06-07:** Erstfassung — Roadmap-first Entscheidung, Abgrenzung Graphen vs. Planung, Workflow-lite.

View File

@ -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 |

View File

@ -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

View File

@ -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 . .

View File

@ -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.';

View File

@ -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: 24 prüfbare Kriterien an Kurzbeschreibung + Übungsziel (nicht nur Technikname im Titel)
- anti_patterns: 25 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';

View File

@ -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';

View File

@ -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",

View File

@ -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",
]

File diff suppressed because it is too large Load Diff

View File

@ -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")

View File

@ -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",

View File

@ -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",

View File

@ -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",
]

View File

@ -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",
]

View File

@ -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",
]

View File

@ -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"

View File

@ -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",
]

View File

@ -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",
]

View File

@ -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",
]

View File

@ -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)

View File

@ -3,13 +3,16 @@ Progressionsgraph zwischen Übungen (Übung → Übung), Migration 032034.
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()

View File

@ -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,

View File

@ -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",

View File

@ -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"

View File

@ -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"]

View File

@ -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"])

View File

@ -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]})

View File

@ -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 []))

View File

@ -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]

View File

@ -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)

View File

@ -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

View File

@ -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"])

View File

@ -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"

View File

@ -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 (tier13) 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",

View File

@ -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** |
| **F0F2** | 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** |
| **P0P2, AC2** | Übungssuche, Voll-Library, Graph-Bias, Varianten | ✅ bis **0.8.184** |
| **C3, EE3** | Pfad-Builder, Semantik, QA, Gap-Offers | ✅ bis **0.8.203** |
| **D** | `planning_context` an `suggestExerciseAi` | ✅ **0.8.208** |
| **F0F4** | Roadmap-Pipeline, LLM 078/079, `roadmap_first`, UI-Review | ✅ **0.8.205209** |
| **F5** | Start/Ziel strukturiert, LLM **087**, Zwei-Schritt-UI | ✅ **0.8.210214** |
| **F6** | Gap-Prep-Modal, reicher KI-Kontext (`planning_exercise_form_context`) | ✅ **0.8.212214** |
| **F7** | `planning_skill_expectations` — Retrieval + UI + Gap | ✅ **0.8.215216** |
| **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**)

View File

@ -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
| AC2 | Übungssuche | Voll-Library, Graph, Varianten | ✅ |
| C3 | Progressionsgraph | Pfad-Builder (retrieval-first) | ✅ |
| EE3 | Progressionsgraph | Semantik, QA, Lücken-Angebote | ✅ |
| **F0F1** | Progressionsgraph | Roadmap-Pipeline Scaffold + API-Preview | 🔄 **0.8.204** |
| **F2F4** | Progressionsgraph | LLM Roadmap, roadmap-first Retrieval, UI Review | 🔲 |
| **F0F4** | Progressionsgraph | Roadmap-Pipeline, LLM, roadmap-first, UI Review | ✅ **0.8.204209** |
| **F5F9** | Progressionsgraph | Start/Ziel, Gap-Prep, Skill-Expectations, Persistenz | ✅ **0.8.210217** |
| D | Übungs-Neuanlage | `planning_context` an `suggestExerciseAi` | ✅ **0.8.208** |
| **UX** | Progressionsgraph | Wizard/Stepper statt Scroll-UI | 🔲 |
| G | Trainingsplanung | Kontext-Pack Gruppe/Historie, S0S4 | 🔲 |
| 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.210214)
- [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.212214)
- [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.215216)
- [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`.

View File

@ -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 AC: 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 (210) |
| `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. **410 %** 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 |
|-------|--------|--------|---------|
| F0F2 | Roadmap-Pipeline + LLM-Prompts 078/079 | ✅ | 0.8.204205 |
| F3 | `roadmap_first` Match pro Stufe | ✅ | 0.8.206209 |
| F4 | Roadmap-Review UI + `roadmap_override` | ✅ | 0.8.207 |
| F5 | Start/Ziel strukturiert + LLM **087** + Zwei-Schritt-UI | ✅ | 0.8.210214 |
| F6 | Gap-Prep-Modal + reicher `planning_context` | ✅ | 0.8.212214 |
| F7 | `planning_skill_expectations` (Retrieval + UI + Gap) | ✅ | 0.8.215216 |
| 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 F5F9 |

View File

@ -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 FG, 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)

View File

@ -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: <SettingsSystemInfoPage /> },
{ path: 'settings/legal', element: <SettingsLegalPage /> },
{ path: 'media', element: <MediaLibraryPage /> },
{ path: 'progression-graphs/:id', element: <ProgressionGraphEditPage /> },
{
path: 'exercises',
children: [

View File

@ -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',

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,516 @@
/**
* Bearbeitbare Darstellung linearer Progressions-Reihen im Graphen.
*/
import React, { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useState } from 'react'
import { Link } from 'react-router-dom'
import api from '../utils/api'
function emptyNode() {
return {
exerciseId: null,
exerciseTitle: '',
variantId: null,
variantName: null,
variants: [],
}
}
function chainToDraft(chain) {
return {
key: `chain-${chain.edges[0]?.id ?? 'x'}`,
edgeIds: chain.edges.map((e) => e.id),
segmentNotes: chain.edges.map((e) => e.notes || ''),
nodes: chain.nodes.map((n) => ({
exerciseId: n.exercise_id,
exerciseTitle: n.title || `Übung #${n.exercise_id}`,
variantId: n.variant_id ?? null,
variantName: n.variant_name ?? null,
variants: [],
})),
dirty: false,
isNew: false,
}
}
function newChainDraft() {
const key = `new-${Date.now()}`
return {
key,
edgeIds: [],
segmentNotes: [],
nodes: [emptyNode(), emptyNode()],
dirty: true,
isNew: true,
}
}
function formatNodeLabel(node) {
if (!node.exerciseId) return '— Übung wählen —'
return (
<>
<Link to={`/exercises/${node.exerciseId}`}>{node.exerciseTitle}</Link>
{node.variantName ? (
<span style={{ color: 'var(--text3)', fontWeight: 400 }}>{` · ${node.variantName}`}</span>
) : null}
</>
)
}
const ProgressionChainEditor = forwardRef(function ProgressionChainEditor(
{
graphId,
chains = [],
busy = false,
anchorExerciseId = null,
anchorTitle = null,
onRefresh,
onPickExercise,
loadVariantsForExercise,
singlePathMode = false,
},
ref,
) {
const [drafts, setDrafts] = useState([])
const [savingKey, setSavingKey] = useState(null)
const chainSignature = useMemo(
() =>
chains
.map((c) => c.edges.map((e) => e.id).join(','))
.join('|'),
[chains],
)
useEffect(() => {
setDrafts(chains.map(chainToDraft))
}, [chainSignature, chains])
const patchDraft = useCallback((key, patchFn) => {
setDrafts((prev) =>
prev.map((d) => {
if (d.key !== key) return d
const next = patchFn(d)
return { ...next, dirty: true }
}),
)
}, [])
const moveNode = (key, idx, dir) => {
patchDraft(key, (d) => {
const j = idx + dir
if (j < 0 || j >= d.nodes.length) return d
const nodes = [...d.nodes]
const t = nodes[idx]
nodes[idx] = nodes[j]
nodes[j] = t
return { ...d, nodes }
})
}
const removeNode = (key, idx) => {
patchDraft(key, (d) => {
if (d.nodes.length <= 2) return d
const nodes = d.nodes.filter((_, i) => i !== idx)
const segmentNotes = d.segmentNotes.slice(0, Math.max(0, nodes.length - 1))
return { ...d, nodes, segmentNotes }
})
}
const setVariant = (key, idx, variantId) => {
patchDraft(key, (d) => ({
...d,
nodes: d.nodes.map((n, i) => {
if (i !== idx) return n
const v = (n.variants || []).find((x) => Number(x.id) === Number(variantId))
return {
...n,
variantId: variantId === '' || variantId == null ? null : Number(variantId),
variantName: v?.variant_name || null,
}
}),
}))
}
const applyExerciseToNode = useCallback(async (key, idx, 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
patchDraft(key, (d) => ({
...d,
nodes: d.nodes.map((n, i) =>
i === idx
? {
exerciseId: ex.id,
exerciseTitle: title,
variantId: variantId != null ? Number(variantId) : null,
variantName:
variantId != null
? variants.find((v) => Number(v.id) === Number(variantId))?.variant_name || null
: null,
variants,
}
: n,
),
}))
}, [patchDraft, loadVariantsForExercise])
const insertNodeAfter = (key, idx) => {
patchDraft(key, (d) => {
const nodes = [...d.nodes]
nodes.splice(idx + 1, 0, emptyNode())
return { ...d, nodes }
})
onPickExercise({ kind: 'chain', draftKey: key, nodeIndex: idx + 1 })
}
const addNewChain = () => {
setDrafts((prev) => [...prev, newChainDraft()])
}
const discardDraft = (key) => {
setDrafts((prev) => {
const draft = prev.find((d) => d.key === key)
if (!draft) return prev
if (draft.isNew) return prev.filter((d) => d.key !== key)
const original = chains.find((c) => `chain-${c.edges[0]?.id}` === key)
if (!original) return prev.filter((d) => d.key !== key)
return prev.map((d) => (d.key === key ? chainToDraft(original) : d))
})
}
const deleteChain = async (draft) => {
if (!graphId) return
if (draft.isNew) {
setDrafts((prev) => prev.filter((d) => d.key !== draft.key))
return
}
if (!draft.edgeIds.length) return
if (!window.confirm(`Reihe mit ${draft.nodes.length} Schritten löschen?`)) return
setSavingKey(draft.key)
try {
await api.deleteExerciseProgressionEdgesBatch(graphId, draft.edgeIds)
await onRefresh()
} catch (e) {
alert(e.message || String(e))
} finally {
setSavingKey(null)
}
}
const saveDraft = async (draft) => {
if (!graphId) return
const steps = draft.nodes.filter((n) => n.exerciseId != null)
if (steps.length < 2) {
alert('Mindestens zwei Schritte mit gewählter Übung.')
return
}
const n = steps.length - 1
let segment_notes = draft.segmentNotes.slice(0, n)
while (segment_notes.length < n) segment_notes.push(null)
segment_notes = segment_notes.slice(0, n)
setSavingKey(draft.key)
try {
if (!draft.isNew && draft.edgeIds.length) {
await api.deleteExerciseProgressionEdgesBatch(graphId, draft.edgeIds)
}
await api.createExerciseProgressionSequence(graphId, {
steps: steps.map((s) => ({
exercise_id: s.exerciseId,
variant_id: s.variantId || null,
})),
segment_notes,
})
await onRefresh()
} catch (e) {
alert(e.message || String(e))
} finally {
setSavingKey(null)
}
}
const ensureVariantsLoaded = async (key, idx) => {
const draft = drafts.find((d) => d.key === key)
const node = draft?.nodes[idx]
if (!node?.exerciseId || (node.variants || []).length) return
const variants = await loadVariantsForExercise(node.exerciseId)
setDrafts((prev) =>
prev.map((d) => {
if (d.key !== key) return d
return {
...d,
nodes: d.nodes.map((n, i) => (i === idx ? { ...n, variants } : n)),
}
}),
)
}
useImperativeHandle(
ref,
() => ({
applyExercise: applyExerciseToNode,
}),
[applyExerciseToNode],
)
if (!graphId) return null
return (
<div style={{ marginBottom: '16px' }}>
<div
style={{
display: 'flex',
flexWrap: 'wrap',
gap: '10px',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: '12px',
}}
>
<div>
<h4 style={{ margin: 0, fontSize: '0.95rem' }}>
{singlePathMode ? 'Manuell bearbeiten' : 'Reihen im Graph'}
</h4>
<p style={{ fontSize: '12px', color: 'var(--text3)', margin: '4px 0 0', lineHeight: 1.45 }}>
Schritt 1 2 : Reihenfolge ändern, Übungen tauschen oder dazwischen einfügen, dann speichern.
</p>
</div>
{!singlePathMode || drafts.length === 0 ? (
<button type="button" className="btn btn-secondary" disabled={busy} onClick={addNewChain}>
{singlePathMode ? '+ Pfad anlegen' : '+ Neue Reihe'}
</button>
) : null}
</div>
{drafts.length === 0 ? (
<p style={{ color: 'var(--text2)', margin: 0, fontSize: '13px' }}>
{singlePathMode
? 'Noch kein gespeicherter Pfad — manuell anlegen oder mit dem KI-Planer unten.'
: 'Noch keine Reihen in diesem Graph.'}
</p>
) : (
drafts.map((draft, chainIdx) => (
<div
key={draft.key}
style={{
marginBottom: '16px',
padding: '14px',
borderRadius: '10px',
border: `1px solid ${
draft.dirty
? 'color-mix(in srgb, var(--accent) 50%, var(--border))'
: 'var(--border)'
}`,
background: 'var(--surface2)',
}}
>
<div
style={{
display: 'flex',
flexWrap: 'wrap',
gap: '8px',
alignItems: 'center',
marginBottom: '12px',
}}
>
<strong style={{ fontSize: '13px' }}>
{singlePathMode ? 'Gespeicherter Pfad' : `Reihe ${chainIdx + 1}`}
</strong>
{draft.dirty ? (
<span className="exercise-tag" style={{ borderColor: 'var(--accent)' }}>
Ungespeichert
</span>
) : null}
{draft.isNew ? (
<span className="exercise-tag">Neu</span>
) : (
<span className="exercise-tag">{draft.nodes.length} Schritte</span>
)}
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0' }}>
{draft.nodes.map((node, idx) => (
<React.Fragment key={`${draft.key}-node-${idx}`}>
{idx > 0 ? (
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
padding: '4px 0 4px 12px',
color: 'var(--accent)',
fontSize: '12px',
fontWeight: 700,
}}
aria-hidden
>
Nachfolger
</div>
) : null}
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))',
gap: '10px',
alignItems: 'end',
padding: '10px 12px',
borderRadius: '8px',
border: '1px solid var(--border)',
background: 'var(--surface)',
}}
>
<div className="form-row" style={{ marginBottom: 0 }}>
<label className="form-label">Schritt {idx + 1}</label>
<div
style={{
display: 'flex',
flexWrap: 'wrap',
gap: '8px',
alignItems: 'center',
}}
>
<span style={{ fontSize: '13px', flex: '1 1 140px' }}>
{formatNodeLabel(node)}
</span>
<button
type="button"
className="btn btn-secondary"
style={{ fontSize: '12px', padding: '4px 10px' }}
disabled={busy || savingKey === draft.key}
onClick={() =>
onPickExercise({ kind: 'chain', draftKey: draft.key, nodeIndex: idx })
}
>
{node.exerciseId ? 'Tauschen…' : 'Übung…'}
</button>
{anchorExerciseId != null ? (
<button
type="button"
className="btn"
style={{ fontSize: '12px', padding: '4px 10px' }}
disabled={busy || savingKey === draft.key}
onClick={async () => {
const variants = await loadVariantsForExercise(anchorExerciseId)
await applyExerciseToNode(draft.key, idx, {
id: anchorExerciseId,
title: anchorTitle?.trim() || `Übung #${anchorExerciseId}`,
variants,
})
}}
>
Kontext-Übung
</button>
) : null}
</div>
</div>
<div className="form-row" style={{ marginBottom: 0 }}>
<label className="form-label">Variante</label>
<select
className="form-input"
disabled={!node.exerciseId || busy || savingKey === draft.key}
value={node.variantId ?? ''}
onFocus={() => ensureVariantsLoaded(draft.key, idx)}
onChange={(e) =>
setVariant(
draft.key,
idx,
e.target.value === '' ? null : parseInt(e.target.value, 10),
)
}
>
<option value="">Gesamte Übung</option>
{(node.variants || []).map((v) => (
<option key={v.id} value={v.id}>
{v.variant_name || `Variante #${v.id}`}
</option>
))}
</select>
</div>
<div style={{ display: 'flex', gap: '6px', flexWrap: 'wrap' }}>
<button
type="button"
className="btn"
style={{ fontSize: '12px', padding: '4px 8px' }}
disabled={busy || savingKey === draft.key || idx === 0}
onClick={() => moveNode(draft.key, idx, -1)}
>
</button>
<button
type="button"
className="btn"
style={{ fontSize: '12px', padding: '4px 8px' }}
disabled={busy || savingKey === draft.key || idx >= draft.nodes.length - 1}
onClick={() => moveNode(draft.key, idx, 1)}
>
</button>
<button
type="button"
className="btn"
style={{ fontSize: '12px', padding: '4px 8px' }}
disabled={busy || savingKey === draft.key}
onClick={() => insertNodeAfter(draft.key, idx)}
>
+ Einfügen
</button>
<button
type="button"
className="btn"
style={{ fontSize: '12px', padding: '4px 8px' }}
disabled={busy || savingKey === draft.key || draft.nodes.length <= 2}
onClick={() => removeNode(draft.key, idx)}
>
Entfernen
</button>
</div>
</div>
</React.Fragment>
))}
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px', marginTop: '12px' }}>
<button
type="button"
className="btn btn-primary"
disabled={busy || savingKey === draft.key || !draft.dirty}
onClick={() => saveDraft(draft)}
>
{savingKey === draft.key ? 'Speichern …' : singlePathMode ? 'Pfad speichern' : 'Reihe speichern'}
</button>
{draft.dirty ? (
<button
type="button"
className="btn btn-secondary"
disabled={busy || savingKey === draft.key}
onClick={() => discardDraft(draft.key)}
>
Verwerfen
</button>
) : null}
<button
type="button"
className="btn"
style={{
fontSize: '12px',
background: 'var(--danger)',
color: '#fff',
border: 'none',
}}
disabled={busy || savingKey === draft.key}
onClick={() => deleteChain(draft)}
>
{singlePathMode ? 'Pfad löschen' : 'Reihe löschen'}
</button>
</div>
</div>
))
)}
</div>
)
})
export default ProgressionChainEditor

View File

@ -0,0 +1,259 @@
/**
* Zentrales Findings-Panel für Progressionsgraph-QA (Phase B.2).
*/
import React, { useMemo, useState } from 'react'
import {
offerCanExpandSlots,
offerNeedsNewSlot,
offerSourceLabel,
resolveOfferSlotIndex,
} from '../utils/progressionGraphDraft'
function severityStyle(pathQa) {
if (!pathQa) return {}
return {
background: pathQa.overall_ok
? 'color-mix(in srgb, var(--accent) 8%, var(--surface2))'
: 'color-mix(in srgb, var(--danger) 8%, var(--surface2))',
}
}
function GapOfferCard({
offer,
slotCount,
draft,
onApplyDraft,
onInsertSlot,
onGenerateAi,
generatingOfferId,
aiBusy,
}) {
const defaultSlot = resolveOfferSlotIndex(draft, offer)
const [slotPick, setSlotPick] = useState(
defaultSlot != null && Number.isFinite(defaultSlot) ? String(defaultSlot) : '',
)
const needsInsert = offerNeedsNewSlot(offer)
const canInsert = offerCanExpandSlots(draft, offer)
const slotOptions = useMemo(() => {
const rows = []
for (let i = 0; i < slotCount; i += 1) {
rows.push({ value: String(i), label: `Slot ${i + 1}` })
}
return rows
}, [slotCount])
const applyToSlot = () => {
const idx = slotPick !== '' ? Number(slotPick) : defaultSlot
if (!Number.isFinite(idx)) {
alert('Bitte einen Slot wählen.')
return
}
onApplyDraft(offer, idx)
}
return (
<li
style={{
padding: '10px 12px',
borderRadius: '8px',
border: '1px solid var(--border)',
background: 'var(--surface2)',
fontSize: '12px',
}}
>
<span className="exercise-tag" style={{ marginBottom: '6px', display: 'inline-block' }}>
{offerSourceLabel(offer.source)}
{offer.phase ? ` · ${offer.phase}` : ''}
{offer.has_ai_payload ? ' · KI-Entwurf bereit' : ''}
</span>
<div style={{ fontWeight: 600 }}>{offer.title_hint || offer.proposal_title || 'Übungsvorschlag'}</div>
{offer.rationale ? (
<p style={{ margin: '4px 0 0', color: 'var(--text2)' }}>{offer.rationale}</p>
) : null}
{offer.from_title && offer.to_title ? (
<p style={{ margin: '4px 0 0', color: 'var(--text3)', fontSize: '11px' }}>
Zwischen {offer.from_title} und {offer.to_title}
</p>
) : null}
<div style={{ marginTop: '8px', display: 'flex', flexWrap: 'wrap', gap: '6px', alignItems: 'center' }}>
<label style={{ fontSize: '11px', color: 'var(--text3)' }}>
Ziel-Slot
<select
className="form-input"
style={{ marginLeft: '6px', padding: '4px 8px', fontSize: '12px', minWidth: '100px' }}
value={slotPick}
onChange={(e) => setSlotPick(e.target.value)}
>
<option value=""> wählen </option>
{slotOptions.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
</label>
</div>
<div style={{ marginTop: '8px', display: 'flex', flexWrap: 'wrap', gap: '6px' }}>
{offer.has_ai_payload ? (
<button
type="button"
className="btn btn-primary"
style={{ fontSize: '11px', padding: '4px 8px' }}
onClick={() => applyToSlot()}
>
Entwurf in Slot
</button>
) : (
<button
type="button"
className="btn btn-primary"
style={{ fontSize: '11px', padding: '4px 8px' }}
disabled={aiBusy}
onClick={() => onGenerateAi(offer, slotPick !== '' ? Number(slotPick) : defaultSlot)}
>
{generatingOfferId === offer.offer_id ? 'KI erstellt…' : 'KI anlegen'}
</button>
)}
<button
type="button"
className="btn btn-secondary"
style={{ fontSize: '11px', padding: '4px 8px' }}
onClick={() => applyToSlot()}
>
Platzhalter in Slot
</button>
{needsInsert ? (
<button
type="button"
className="btn btn-secondary"
style={{ fontSize: '11px', padding: '4px 8px' }}
disabled={!canInsert}
title={!canInsert ? `Maximal ${slotCount} Slots` : 'Neuen Slot zwischen zwei Stufen einfügen'}
onClick={() => onInsertSlot(offer)}
>
Neuen Slot einfügen
</button>
) : null}
</div>
</li>
)
}
export default function ProgressionFindingsPanel({
pathQa = null,
gapFillOffers = [],
draft = null,
slotCount = 0,
loading = false,
error = '',
onEvaluate,
onApplyGapOffer,
onInsertGapSlot,
onGenerateGapAi,
generatingOfferId = null,
aiBusy = false,
evaluateDisabled = false,
}) {
return (
<div className="card" style={{ position: 'sticky', top: '12px' }}>
<h3 style={{ marginTop: 0, fontSize: '1rem' }}>Graph-Bewertung</h3>
<p style={{ fontSize: '12px', color: 'var(--text3)', marginTop: 0, lineHeight: 1.45 }}>
Prüft den Slot-Stand und listet KI-Angebote für leere Stufen und Lücken.
</p>
<button
type="button"
className="btn btn-primary btn-full"
disabled={loading || evaluateDisabled}
onClick={onEvaluate}
style={{ marginBottom: '12px' }}
>
{loading ? 'Bewertung läuft…' : 'Graph bewerten'}
</button>
{error ? (
<p className="form-error" style={{ marginTop: 0 }}>
{error}
</p>
) : null}
{pathQa ? (
<div
style={{
padding: '10px 12px',
borderRadius: '8px',
fontSize: '12px',
lineHeight: 1.45,
...severityStyle(pathQa),
}}
>
<strong>
Pfad-QS: {pathQa.overall_ok ? 'OK' : 'Hinweise'}
{pathQa.quality_score != null
? ` (${Math.round(Number(pathQa.quality_score) * 100)} %)`
: ''}
</strong>
{pathQa.topic_coverage ? (
<p style={{ margin: '6px 0 0', color: 'var(--text2)' }}>{pathQa.topic_coverage}</p>
) : null}
{Array.isArray(pathQa.issues) && pathQa.issues.length > 0 ? (
<ul style={{ margin: '6px 0 0', paddingLeft: '16px', color: 'var(--text2)' }}>
{pathQa.issues.map((issue) => (
<li key={issue}>{issue}</li>
))}
</ul>
) : null}
{Array.isArray(pathQa.recommendations) && pathQa.recommendations.length > 0 ? (
<>
<p style={{ margin: '8px 0 4px', fontWeight: 600, color: 'var(--text2)' }}>Empfehlungen</p>
<ul style={{ margin: 0, paddingLeft: '16px', color: 'var(--text2)' }}>
{pathQa.recommendations.map((rec) => (
<li key={rec}>{rec}</li>
))}
</ul>
</>
) : null}
{Number(pathQa.off_topic_count) > 0 ? (
<p style={{ margin: '6px 0 0', color: 'var(--danger)' }}>
{pathQa.off_topic_count} Schritt(e) ohne Bezug zum Pfad-Thema.
</p>
) : null}
</div>
) : (
<p style={{ fontSize: '12px', color: 'var(--text3)', margin: 0 }}>
Noch keine Bewertung. Roadmap anlegen, dann Graph bewerten oder Übungen matchen.
</p>
)}
<div style={{ marginTop: '14px' }}>
<h4 style={{ margin: '0 0 8px', fontSize: '0.9rem' }}>
KI-Angebote {gapFillOffers.length > 0 ? `(${gapFillOffers.length})` : ''}
</h4>
{gapFillOffers.length === 0 ? (
<p style={{ fontSize: '12px', color: 'var(--text3)', margin: 0 }}>
Keine offenen Angebote. Nach Match oder Bewertung erscheinen Vorschläge für leere Slots und Lücken.
</p>
) : (
<ul style={{ listStyle: 'none', padding: 0, margin: 0, display: 'flex', flexDirection: 'column', gap: '8px' }}>
{gapFillOffers.map((offer) => (
<GapOfferCard
key={offer.offer_id || `${offer.source}-${offer.title_hint}`}
offer={offer}
slotCount={slotCount}
draft={draft}
generatingOfferId={generatingOfferId}
aiBusy={aiBusy}
onApplyDraft={(o, idx) => onApplyGapOffer(o, idx)}
onInsertSlot={onInsertGapSlot}
onGenerateAi={onGenerateGapAi}
/>
))}
</ul>
)}
</div>
</div>
)
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,126 @@
import React from 'react'
import { GitBranch, Lock, Users, Globe, Pencil, Trash2 } from 'lucide-react'
import { graphGoalQueryFromRow, graphSlotCountFromRow } from '../utils/progressionGraphDraft'
import { EXERCISE_VISIBILITY_FIELD_LABEL } from '../constants/exerciseGovernanceLabels'
const VIS_LABELS = { official: 'Global', club: 'Verein', private: 'Privat' }
function visibilityLabel(v) {
return VIS_LABELS[v] || v || '—'
}
function cardClassName(graph, userId) {
const vis = graph.visibility || 'private'
const visKey = vis === 'official' || vis === 'club' || vis === 'private' ? vis : 'private'
const mine = userId != null && Number(graph.created_by) === Number(userId)
return ['card', 'exercise-card', 'progression-graph-card', `exercise-card--scope-${visKey}`, mine ? 'exercise-card--mine' : '']
.filter(Boolean)
.join(' ')
}
function VisIcon({ visibility }) {
if (visibility === 'official') return <Globe size={14} aria-hidden="true" />
if (visibility === 'club') return <Users size={14} aria-hidden="true" />
return <Lock size={14} aria-hidden="true" />
}
export default function ProgressionGraphListCard({
graph,
userId = null,
onOpen,
onDelete,
disabled = false,
}) {
const goalQuery = graphGoalQueryFromRow(graph)
const slotCount = graphSlotCountFromRow(graph)
const edgesCount = Number(graph.edges_count) || 0
const description = (graph.description || '').trim()
return (
<article className={cardClassName(graph, userId)}>
<div className="exercise-card__body">
<div className="exercise-card-title" style={{ display: 'flex', alignItems: 'flex-start', gap: '8px' }}>
<GitBranch size={18} style={{ flexShrink: 0, marginTop: '2px', color: 'var(--accent)' }} aria-hidden="true" />
<button
type="button"
className="exercise-card__body--clickable"
style={{
border: 'none',
background: 'none',
padding: 0,
textAlign: 'left',
font: 'inherit',
color: 'inherit',
cursor: disabled ? 'default' : 'pointer',
width: '100%',
}}
disabled={disabled}
onClick={() => onOpen?.(graph)}
>
{graph.name || `Graph #${graph.id}`}
</button>
</div>
<div className="exercise-card-tags" style={{ marginTop: '8px' }}>
<span className="exercise-tag" title={EXERCISE_VISIBILITY_FIELD_LABEL}>
<VisIcon visibility={graph.visibility} />
{visibilityLabel(graph.visibility)}
</span>
{slotCount != null ? (
<span className="exercise-tag">{slotCount} Stufen</span>
) : null}
<span className="exercise-tag">
{edgesCount === 1 ? '1 Kante' : `${edgesCount} Kanten`}
</span>
</div>
{goalQuery ? (
<p className="exercise-card-summary" style={{ marginTop: '10px' }}>
<strong style={{ fontWeight: 600, color: 'var(--text2)' }}>Ziel: </strong>
{goalQuery.length > 160 ? `${goalQuery.slice(0, 160)}` : goalQuery}
</p>
) : description ? (
<p className="exercise-card-summary" style={{ marginTop: '10px' }}>
{description.length > 160 ? `${description.slice(0, 160)}` : description}
</p>
) : (
<p className="exercise-card-summary muted" style={{ marginTop: '10px' }}>
Noch kein Planungsziel hinterlegt öffnen und Roadmap anlegen.
</p>
)}
</div>
<div
className="exercise-card-layout"
style={{
padding: '10px 14px 14px',
borderTop: '1px solid var(--border)',
display: 'flex',
gap: '8px',
flexWrap: 'wrap',
}}
>
<button
type="button"
className="btn btn-primary"
style={{ fontSize: '12px', padding: '6px 12px' }}
disabled={disabled}
onClick={() => onOpen?.(graph)}
>
<Pencil size={14} style={{ marginRight: '4px', verticalAlign: '-2px' }} aria-hidden="true" />
Bearbeiten
</button>
<button
type="button"
className="btn"
style={{ fontSize: '12px', padding: '6px 12px' }}
disabled={disabled}
onClick={() => onDelete?.(graph)}
>
<Trash2 size={14} style={{ marginRight: '4px', verticalAlign: '-2px' }} aria-hidden="true" />
Löschen
</button>
</div>
</article>
)
}

View File

@ -0,0 +1,242 @@
/**
* Einzelner Roadmap-Slot im Progressionsgraph-Editor.
*/
import React from 'react'
import { Link } from 'react-router-dom'
import { ROADMAP_PHASES } from '../utils/progressionGraphDraft'
function exerciseLabel(entry) {
if (!entry || entry.kind === 'empty') return '— noch leer —'
if (entry.kind === 'proposal') return entry.exerciseTitle || 'KI-Entwurf'
return entry.exerciseTitle || `Übung #${entry.exerciseId}`
}
export default function ProgressionSlotCard({
slot,
slotIndex,
slotCount = 1,
onPickPrimary,
onPickSibling,
onClearPrimary,
onRemoveSibling,
onPatchLearningGoal,
onPatchPhase,
onMoveUp,
onMoveDown,
onRemoveSlot,
onInsertAfter,
onCreateFromProposal,
disabled = false,
}) {
const { primary, siblings = [], phase, learning_goal: learningGoal } = slot
return (
<div
className="card"
style={{
marginBottom: '10px',
borderColor: primary.kind === 'empty'
? 'var(--border)'
: 'color-mix(in srgb, var(--accent) 25%, var(--border))',
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: '8px', flexWrap: 'wrap' }}>
<div>
<h4 style={{ margin: 0, fontSize: '0.95rem' }}>Slot {slotIndex + 1}</h4>
</div>
<div style={{ display: 'flex', gap: '4px', flexWrap: 'wrap', alignItems: 'center' }}>
<span
className="exercise-tag"
style={{
fontSize: '11px',
borderColor: primary.kind === 'proposal' ? 'var(--danger)' : undefined,
}}
>
{primary.kind === 'empty' ? 'leer' : primary.kind === 'proposal' ? 'KI-Entwurf' : 'Bibliothek'}
</span>
<button
type="button"
className="btn"
style={{ fontSize: '11px', padding: '2px 6px' }}
disabled={disabled || slotIndex === 0}
onClick={() => onMoveUp(slotIndex)}
title="Nach oben"
>
</button>
<button
type="button"
className="btn"
style={{ fontSize: '11px', padding: '2px 6px' }}
disabled={disabled || slotIndex >= slotCount - 1}
onClick={() => onMoveDown(slotIndex)}
title="Nach unten"
>
</button>
<button
type="button"
className="btn"
style={{ fontSize: '11px', padding: '2px 6px' }}
disabled={disabled || slotCount <= 2}
onClick={() => onRemoveSlot(slotIndex)}
title="Slot entfernen"
>
</button>
</div>
</div>
<div className="form-row" style={{ marginTop: '10px', marginBottom: '8px', display: 'grid', gridTemplateColumns: '1fr 140px', gap: '8px' }}>
<div>
<label className="form-label">Lernziel (Major Step)</label>
<input
className="form-input"
value={learningGoal || ''}
disabled={disabled}
onChange={(e) => onPatchLearningGoal(slotIndex, e.target.value)}
placeholder="Was soll in dieser Stufe erreicht werden?"
/>
</div>
<div>
<label className="form-label">Phase</label>
<select
className="form-input"
value={phase || 'vertiefung'}
disabled={disabled}
onChange={(e) => onPatchPhase(slotIndex, e.target.value)}
>
{ROADMAP_PHASES.map((p) => (
<option key={p} value={p}>
{p}
</option>
))}
</select>
</div>
</div>
<div
style={{
padding: '10px 12px',
borderRadius: '8px',
background: 'var(--surface2)',
border: '1px solid var(--border)',
}}
>
<div style={{ fontSize: '11px', color: 'var(--text3)', marginBottom: '4px' }}>Hauptpfad (primary)</div>
<div style={{ fontSize: '13px', fontWeight: 600 }}>
{primary.kind === 'library' && primary.exerciseId ? (
<Link to={`/exercises/${primary.exerciseId}`}>{exerciseLabel(primary)}</Link>
) : (
exerciseLabel(primary)
)}
{primary.variantName ? (
<span style={{ color: 'var(--text3)', fontWeight: 400 }}>{` · ${primary.variantName}`}</span>
) : null}
</div>
{primary.kind === 'proposal' && primary.aiSuggestion?.summary?.text ? (
<p style={{ margin: '6px 0 0', fontSize: '12px', color: 'var(--text2)', lineHeight: 1.4 }}>
{primary.aiSuggestion.summary.text.slice(0, 220)}
{primary.aiSuggestion.summary.text.length > 220 ? '…' : ''}
</p>
) : null}
<div style={{ display: 'flex', gap: '6px', flexWrap: 'wrap', marginTop: '8px' }}>
<button
type="button"
className="btn btn-secondary"
style={{ fontSize: '12px', padding: '4px 10px' }}
disabled={disabled}
onClick={() => onPickPrimary(slotIndex)}
>
{primary.kind === 'empty' ? 'Übung wählen' : 'Übung ändern'}
</button>
{primary.kind === 'proposal' && typeof onCreateFromProposal === 'function' ? (
<button
type="button"
className="btn btn-primary"
style={{ fontSize: '12px', padding: '4px 10px' }}
disabled={disabled}
onClick={() => onCreateFromProposal(slotIndex)}
>
{primary.aiSuggestion ? 'Als Übung anlegen' : 'Mit KI anlegen'}
</button>
) : null}
{primary.kind !== 'empty' ? (
<button
type="button"
className="btn"
style={{ fontSize: '12px', padding: '4px 10px' }}
disabled={disabled}
onClick={() => onClearPrimary(slotIndex)}
>
Leeren
</button>
) : null}
</div>
</div>
<div style={{ marginTop: '10px' }}>
<div style={{ fontSize: '11px', color: 'var(--text3)', marginBottom: '6px' }}>Schwestern (Alternativen)</div>
{siblings.length === 0 ? (
<p style={{ margin: 0, fontSize: '12px', color: 'var(--text2)' }}>Keine Schwestern.</p>
) : (
<ul style={{ listStyle: 'none', padding: 0, margin: '0 0 8px', display: 'flex', flexDirection: 'column', gap: '6px' }}>
{siblings.map((sib, sibIdx) => (
<li
key={`${sib.exerciseId || sib.proposalKey}-${sibIdx}`}
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
gap: '8px',
padding: '6px 8px',
borderRadius: '6px',
background: 'var(--surface2)',
fontSize: '12px',
}}
>
<span>
{sib.kind === 'library' && sib.exerciseId ? (
<Link to={`/exercises/${sib.exerciseId}`}>{exerciseLabel(sib)}</Link>
) : (
exerciseLabel(sib)
)}
</span>
<button
type="button"
className="btn"
style={{ fontSize: '11px', padding: '2px 8px' }}
disabled={disabled}
onClick={() => onRemoveSibling(slotIndex, sibIdx)}
>
Entfernen
</button>
</li>
))}
</ul>
)}
<div style={{ display: 'flex', gap: '6px', flexWrap: 'wrap' }}>
<button
type="button"
className="btn btn-secondary"
style={{ fontSize: '12px', padding: '4px 10px' }}
disabled={disabled || primary.kind !== 'library'}
onClick={() => onPickSibling(slotIndex)}
title={primary.kind !== 'library' ? 'Zuerst eine Hauptübung wählen' : undefined}
>
Schwester hinzufügen
</button>
<button
type="button"
className="btn"
style={{ fontSize: '12px', padding: '4px 10px' }}
disabled={disabled || slotCount >= 10}
onClick={() => onInsertAfter(slotIndex)}
>
Slot darunter einfügen
</button>
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,186 @@
import React, { useEffect } from 'react'
/**
* Vorbereitung vor KI-Übungsanlage aus Pfad-Lücke: Kontext prüfen, Ergänzungen mitgeben.
*/
export default function ExerciseGapFillPrepModal({
open,
onClose,
offer = null,
contextLines = [],
title = '',
onTitleChange,
stageLearningGoal = '',
onStageLearningGoalChange,
supplements = '',
onSupplementsChange,
focusAreaId = '',
onFocusAreaChange,
focusAreas = [],
busy = false,
error = '',
onSubmit,
}) {
useEffect(() => {
if (!open) return undefined
const onKey = (e) => {
if (e.key === 'Escape' && !busy) {
e.preventDefault()
onClose()
}
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [open, busy, onClose])
if (!open || !offer) return null
const readOnlyLines = (contextLines || []).filter(
(line) => line.label !== 'Stufen-Lernziel',
)
return (
<div
className="admin-modal-backdrop"
role="presentation"
onClick={(e) => {
if (e.target === e.currentTarget && !busy) onClose()
}}
>
<div
className="admin-modal-sheet"
role="dialog"
aria-modal="true"
aria-labelledby="gap-fill-prep-title"
onClick={(e) => e.stopPropagation()}
style={{ maxWidth: '680px' }}
>
<div className="admin-modal-sheet__header">
<h3 id="gap-fill-prep-title" className="admin-modal-sheet__title">
Übung mit KI vorbereiten
</h3>
<button
type="button"
className="btn btn-secondary admin-modal-sheet__close"
disabled={busy}
onClick={onClose}
>
Schließen
</button>
</div>
<div className="admin-modal-sheet__body">
<p className="muted" style={{ marginTop: 0, marginBottom: '12px', lineHeight: 1.45 }}>
Prüfen und anpassen, was an die KI geht. Ergänzungen fließen in Ziel, Trainerhinweise und
Planungskontext ein erst danach wird der Entwurf erzeugt.
</p>
{readOnlyLines.length > 0 ? (
<div
style={{
marginBottom: '14px',
padding: '10px 12px',
borderRadius: '8px',
border: '1px solid var(--border)',
background: 'var(--surface2)',
fontSize: '12px',
}}
>
<strong style={{ display: 'block', marginBottom: '6px' }}>Pfad-Kontext (aus Roadmap)</strong>
<dl style={{ margin: 0, display: 'grid', gap: '6px' }}>
{readOnlyLines.map(({ label, value }) => (
<div key={label}>
<dt style={{ margin: 0, fontSize: '11px', color: 'var(--text3)' }}>{label}</dt>
<dd style={{ margin: '2px 0 0', lineHeight: 1.45, color: 'var(--text2)' }}>{value}</dd>
</div>
))}
</dl>
</div>
) : null}
<div style={{ display: 'grid', gap: '12px' }}>
<div>
<label className="form-label" htmlFor="gap-prep-title">
Titel-Vorschlag *
</label>
<input
id="gap-prep-title"
className="form-input"
value={title}
onChange={(e) => onTitleChange(e.target.value)}
disabled={busy}
maxLength={280}
/>
</div>
<div>
<label className="form-label" htmlFor="gap-prep-stage-goal">
Stufen-Lernziel (anpassbar)
</label>
<textarea
id="gap-prep-stage-goal"
className="form-input"
rows={2}
value={stageLearningGoal}
onChange={(e) => onStageLearningGoalChange(e.target.value)}
disabled={busy}
placeholder="Was soll diese Übung in der Roadmap-Stufe leisten?"
/>
</div>
<div>
<label className="form-label" htmlFor="gap-prep-supplements">
Ergänzungen / Anpassungen für die KI
</label>
<textarea
id="gap-prep-supplements"
className="form-input"
rows={3}
value={supplements}
onChange={(e) => onSupplementsChange(e.target.value)}
disabled={busy}
placeholder="z. B. nur Partnerübung, 1012 Jahre, Fokus auf Reaktion unter Druck, keine Sprünge …"
/>
</div>
<div>
<label className="form-label" htmlFor="gap-prep-focus">
Fokusbereich *
</label>
<select
id="gap-prep-focus"
className="form-input"
value={focusAreaId}
onChange={(e) => onFocusAreaChange(e.target.value)}
disabled={busy}
>
<option value="">Bitte wählen </option>
{(focusAreas || []).map((fa) => (
<option key={fa.id} value={fa.id}>
{fa.name}
</option>
))}
</select>
</div>
</div>
{offer.from_title && offer.to_title ? (
<p style={{ fontSize: '11px', color: 'var(--text3)', margin: '12px 0 0' }}>
Einordnung: zwischen {offer.from_title} und {offer.to_title}
</p>
) : null}
{error ? (
<p className="form-error" style={{ marginTop: '12px' }}>
{error}
</p>
) : null}
</div>
<div className="admin-modal-sheet__footer" style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
<button type="button" className="btn btn-secondary" disabled={busy} onClick={onClose}>
Abbrechen
</button>
<button type="button" className="btn btn-primary" disabled={busy} onClick={onSubmit}>
{busy ? 'KI erstellt Entwurf …' : 'KI-Entwurf erstellen'}
</button>
</div>
</div>
</div>
)
}

View File

@ -95,6 +95,7 @@ function ExercisesListPageRoot() {
const [quickSaving, setQuickSaving] = useState(false)
const [quickAiError, setQuickAiError] = useState('')
const [quickCreateDraft, setQuickCreateDraft] = useState(null)
const progressionPanelRef = useRef(null)
const planningKi = usePlanningExerciseSuggestSearch({
enabled: pageTab === 'list' && aiQuickCreateEnabled,
@ -653,7 +654,13 @@ function ExercisesListPageRoot() {
)}
</div>
) : (
<span aria-hidden="true" />
<button
type="button"
className="btn btn-primary"
onClick={() => progressionPanelRef.current?.openCreateDialog?.()}
>
+ Neu
</button>
)}
</div>
@ -676,7 +683,7 @@ function ExercisesListPageRoot() {
</div>
}
>
<ExerciseProgressionGraphPanel />
<ExerciseProgressionGraphPanel ref={progressionPanelRef} />
</Suspense>
) : (
<>

View File

@ -0,0 +1,15 @@
import React from 'react'
import { Navigate, useParams } from 'react-router-dom'
/** Alte Deep-Links → Übungen-Liste mit Graph-Auswahl. */
export default function ProgressionGraphEditPage() {
const { id } = useParams()
const graphId = Number(id)
return (
<Navigate
to="/exercises"
replace
state={Number.isFinite(graphId) && graphId > 0 ? { progressionGraphId: graphId } : undefined}
/>
)
}

View File

@ -166,6 +166,24 @@ export function describeAiSkillRowForPreview(row, skillsCatalog) {
}
/** KI-Vorschau → bearbeitbarer Entwurf (Rich-Text-Felder). */
/** Rohes API-ai_suggestion oder bereits bearbeiteter Entwurf → Preview-Entwurf. */
export function ensureQuickCreateDraftFromAiSuggestion(
aiSuggestion,
{ title = '', focusAreaId = '', sketchPlain = '' } = {},
) {
if (!aiSuggestion || typeof aiSuggestion !== 'object') return null
if (aiSuggestion.instructionFields) return aiSuggestion
const preview = buildQuickCreateAiPreview(aiSuggestion, { sketchPlain })
if (
!preview.hasSummaryProposal &&
!preview.hasInstructionChoices &&
!preview.hasSkillChoices
) {
return null
}
return aiPreviewToQuickCreateDraft(preview, { title, focusAreaId, sketchPlain })
}
export function aiPreviewToQuickCreateDraft(preview, { title, focusAreaId, sketchPlain }) {
const sketchHtml = aiPlainTextToMinimalHtml(sketchPlain)
const instructionFields = {

View File

@ -2,6 +2,8 @@
* Planungs-KI Phase D: strukturierter Kontext für suggestExerciseAi.
*/
import { slotsAsPathStepRows } from './progressionGraphDraft.js'
export function buildPickerPlanningContextForAi({
planningContextSummary = null,
planningContext = null,
@ -30,6 +32,109 @@ export function buildPickerPlanningContextForAi({
return Object.fromEntries(Object.entries(ctx).filter(([, v]) => v != null && v !== ''))
}
function majorIndexFromStep(step) {
const raw = step?.roadmap_major_step_index ?? step?.roadmapMajorStepIndex
if (raw == null || !Number.isFinite(Number(raw))) return null
return Number(raw)
}
function priorPathStepsBeforeMajor(pathSteps, majorIdx) {
if (majorIdx == null || !Number.isFinite(Number(majorIdx))) return []
const mi = Number(majorIdx)
return (pathSteps || [])
.filter((s) => {
const idx = majorIndexFromStep(s)
return idx != null && idx < mi
})
.sort((a, b) => (majorIndexFromStep(a) || 0) - (majorIndexFromStep(b) || 0))
}
function stepDisplayFields(step) {
if (!step) return null
const title = String(step.title || step.exerciseTitle || '').trim()
const learningGoal = String(
step.roadmap_learning_goal || step.roadmapLearningGoal || step.learning_goal || '',
).trim()
const phase = String(step.roadmap_phase || step.roadmapPhase || step.phase || '').trim()
const startState = String(step.roadmap_start_state || step.start_state || '').trim()
const targetState = String(step.roadmap_target_state || step.target_state || '').trim()
const criteria = Array.isArray(step.success_criteria)
? step.success_criteria.map((x) => String(x || '').trim()).filter(Boolean).slice(0, 4)
: []
const majorStepIndex = majorIndexFromStep(step)
const out = {
title: title || null,
learning_goal: learningGoal || null,
start_state: startState || null,
target_state: targetState || null,
phase: phase || null,
success_criteria: criteria.length ? criteria : null,
major_step_index: majorStepIndex,
}
const hasData = Object.values(out).some((v) => v != null && v !== '')
return hasData ? out : null
}
export function buildProgressionEntryState({
majorStepIndex = null,
priorSteps = [],
startSituation = '',
currentStageStart = '',
} = {}) {
const priorCompact = (priorSteps || [])
.map(stepDisplayFields)
.filter(Boolean)
const achievements = []
const detailLines = []
for (const p of priorCompact) {
if (Array.isArray(p.success_criteria) && p.success_criteria.length) {
achievements.push(...p.success_criteria)
} else if (p.learning_goal) {
achievements.push(p.learning_goal)
}
const labelParts = []
if (p.major_step_index != null) labelParts.push(`Stufe ${p.major_step_index + 1}`)
if (p.phase) labelParts.push(`(${p.phase})`)
if (p.title) labelParts.push(`${p.title}"`)
const prefix = labelParts.length ? labelParts.join(' ') : 'Vorstufe'
const achieved =
p.target_state ||
(Array.isArray(p.success_criteria) && p.success_criteria.length
? p.success_criteria.join('; ')
: '') ||
p.learning_goal ||
''
if (achieved) detailLines.push(`${prefix}: erreicht — ${achieved}`)
}
let entryState = (currentStageStart || '').trim()
if (!entryState && priorCompact.length) {
const immediate = priorCompact[priorCompact.length - 1]
entryState =
immediate.target_state ||
(Array.isArray(immediate.success_criteria) && immediate.success_criteria.length
? immediate.success_criteria.join('; ')
: '') ||
immediate.learning_goal ||
''
} else if (!entryState && (startSituation || '').trim()) {
entryState = startSituation.trim()
}
if (priorCompact.length && (startSituation || '').trim() && !entryState) {
detailLines.unshift(`Ausgangsbasis Pfad: ${startSituation.trim()}`)
}
const out = {}
if (entryState) out.entry_state = entryState
if (detailLines.length) out.entry_state_detail = detailLines.join('\n')
if (priorCompact.length) out.prior_steps = priorCompact.slice(0, 6)
if (achievements.length) out.prior_achievements = [...new Set(achievements)].slice(0, 8)
return out
}
function stageSpecForMajorIndex(progressionRoadmap, majorIdx) {
if (majorIdx == null || !progressionRoadmap) return null
const specs = progressionRoadmap?.stage_specs
@ -53,15 +158,28 @@ export function buildPathGapPlanningContextForAi({
startSituation = '',
targetState = '',
roadmapNotes = '',
stageLearningGoalOverride = '',
gapTrainerSupplements = '',
} = {}) {
const afterIdx = Number(offer?.insert_after_index)
const stepA = Number.isFinite(afterIdx) && afterIdx >= 0 ? pathSteps[afterIdx] : null
const stepB =
Number.isFinite(afterIdx) && afterIdx >= 0 ? pathSteps[afterIdx + 1] : null
const majorIdxRaw =
offer?.roadmap_major_step_index ?? offer?.gap?.roadmap_major_step_index
const majorIdx =
majorIdxRaw != null && Number.isFinite(Number(majorIdxRaw)) ? Number(majorIdxRaw) : null
const priorSteps = majorIdx != null ? priorPathStepsBeforeMajor(pathSteps, majorIdx) : []
const afterIdx = Number(offer?.insert_after_index)
const stepA =
priorSteps.length > 0
? priorSteps[priorSteps.length - 1]
: Number.isFinite(afterIdx) && afterIdx >= 0
? pathSteps[afterIdx]
: null
const stepB =
majorIdx != null
? (pathSteps || []).find((s) => majorIndexFromStep(s) === majorIdx + 1) ||
(Number.isFinite(afterIdx) && afterIdx >= 0 ? pathSteps[afterIdx + 1] : null)
: Number.isFinite(afterIdx) && afterIdx >= 0
? pathSteps[afterIdx + 1]
: null
const majorStep =
majorIdx != null && editableMajorSteps[majorIdx] ? editableMajorSteps[majorIdx] : null
const stageSpec = stageSpecForMajorIndex(progressionRoadmap, majorIdx)
@ -90,6 +208,13 @@ export function buildPathGapPlanningContextForAi({
)
}
const entryState = buildProgressionEntryState({
majorStepIndex: majorIdx,
priorSteps,
startSituation: start,
currentStageStart: stageSpec?.start_state || '',
})
const ctx = {
source: 'progression_path_gap_fill',
goal_query: (goalQuery || '').trim() || null,
@ -105,7 +230,11 @@ export function buildPathGapPlanningContextForAi({
start_situation: start,
target_state: target,
roadmap_notes: notes,
stage_learning_goal: stageSpec?.learning_goal || null,
stage_learning_goal:
(stageLearningGoalOverride || '').trim() || stageSpec?.learning_goal || null,
stage_start_state: stageSpec?.start_state || null,
stage_target_state: stageSpec?.target_state || null,
gap_trainer_supplements: (gapTrainerSupplements || '').trim() || null,
stage_phase: stageSpec?.phase || majorStep?.phase || null,
stage_exercise_type: stageSpec?.exercise_type || null,
stage_load_profile: Array.isArray(stageSpec?.load_profile)
@ -121,8 +250,9 @@ export function buildPathGapPlanningContextForAi({
? ga.success_criteria.slice(0, 4)
: null,
skill_hints: skillHints.length ? skillHints : null,
neighbor_before_title: stepA?.exerciseTitle || offer?.from_title || null,
neighbor_after_title: stepB?.exerciseTitle || offer?.to_title || null,
neighbor_before_title: stepA?.exerciseTitle || stepA?.title || offer?.from_title || null,
neighbor_after_title: stepB?.exerciseTitle || stepB?.title || offer?.to_title || null,
...entryState,
path_step_count: Array.isArray(pathSteps) ? pathSteps.length : 0,
major_step_count:
editableMajorSteps?.length ||
@ -144,7 +274,14 @@ export function gapOfferContextDisplayLines(offer, fallbackParams = null) {
const v = String(value || '').trim()
if (v) lines.push({ label, value: v })
}
push('Ausgangslage (Pfad)', raw.start_situation)
push('Eingangszustand (Vorstufen)', raw.entry_state)
if (raw.entry_state_detail && raw.entry_state_detail !== raw.entry_state) {
push('Bisheriger Pfad', raw.entry_state_detail)
}
if (Array.isArray(raw.prior_achievements) && raw.prior_achievements.length) {
push('Erreichte Voraussetzungen', raw.prior_achievements.slice(0, 6).join(' · '))
}
push('Ausgangslage (gesamter Pfad)', raw.start_situation)
push('Gesamtziel (Pfad)', raw.target_state)
push('Ergänzungen', raw.roadmap_notes)
push('Stufen-Lernziel', raw.stage_learning_goal || raw.roadmap_learning_goal)
@ -158,5 +295,56 @@ export function gapOfferContextDisplayLines(offer, fallbackParams = null) {
if (Array.isArray(raw.skill_hints) && raw.skill_hints.length) {
push('Fähigkeiten-Fokus', raw.skill_hints.slice(0, 3).join(' · '))
}
if (Array.isArray(raw.expected_skills) && raw.expected_skills.length) {
const names = raw.expected_skills
.map((s) => String(s?.skill_name || '').trim())
.filter(Boolean)
.slice(0, 5)
if (names.length) push('Erwartete Fähigkeiten', names.join(' · '))
}
push('Trainer-Ergänzungen', raw.gap_trainer_supplements)
return lines
}
/** Zieltext für KI aus Slot-Kontext (Graph-Editor ohne API-Offer). */
export function buildSlotGapGoalForAi(draft, slotIndex, { goalQuery = '' } = {}) {
const slot = draft?.slots?.[slotIndex]
if (!slot) return ''
const pathSteps = slotsAsPathStepRows(draft)
const majorIdx = slot.majorStepIndex
const priorSteps = priorPathStepsBeforeMajor(pathSteps, majorIdx)
const start = (draft.startSituation || '').trim()
const stageSpec =
majorIdx != null && draft.progressionRoadmap
? stageSpecForMajorIndex(draft.progressionRoadmap, majorIdx)
: null
const entry = buildProgressionEntryState({
majorStepIndex: majorIdx,
priorSteps,
startSituation: start,
currentStageStart: stageSpec?.start_state || '',
})
const parts = [
goalQuery ? `Planungsziel (gesamter Pfad): ${goalQuery}` : '',
entry.entry_state
? `Eingangszustand (erreichte Voraussetzungen): ${entry.entry_state}`
: start
? `Ausgangslage (Pfad): ${start}`
: '',
entry.entry_state_detail && entry.entry_state_detail !== entry.entry_state
? `Bisheriger Pfad:\n${entry.entry_state_detail}`
: '',
(slot.learning_goal || '').trim()
? `Lernziel dieser Roadmap-Stufe: ${(slot.learning_goal || '').trim()}`
: '',
(slot.phase || '').trim() ? `Entwicklungsphase: ${slot.phase}` : '',
'Die Übung baut didaktisch auf den Vorstufen auf — Voraussetzungen explizit benennen, messbares Stufenziel.',
].filter(Boolean)
return parts.join('\n\n').trim()
}
export function initialStageLearningGoalFromOffer(offer, fallbackParams = null) {
const lines = gapOfferContextDisplayLines(offer, fallbackParams)
const hit = lines.find((l) => l.label === 'Stufen-Lernziel')
return hit?.value || (offer?.title_hint || '').trim()
}

File diff suppressed because it is too large Load Diff