Progression optimiert Phase A #55
|
|
@ -493,23 +493,37 @@ Nach Pfad-Bildung:
|
|||
|
||||
---
|
||||
|
||||
## 24. Phase F — Roadmap-first Progressionsgraph (0.8.204+) 🔄
|
||||
## 24. Phase F — Roadmap-first Progressionsgraph (0.8.204–217) ✅
|
||||
|
||||
**Entscheidung:** Progressionsgraph plant **vom Ziel rückwärts** (Roadmap → Stufenspezifikation → Bibliothek/KI). **Keine Gruppenanalyse** — die gehört zur Trainingsplanung.
|
||||
|
||||
**Ist-Stand (vollständig):** `docs/architecture/PLANNING_PROGRESSION_GRAPH_KI.md`
|
||||
**Spec:** `working/PLANNING_PROGRESSION_ROADMAP_SPEC.md` · **Roadmap:** `docs/architecture/PLANNING_KI_ROADMAP.md`
|
||||
|
||||
| Teil | Modul / API |
|
||||
|------|-------------|
|
||||
| Pipeline | `planning_progression_roadmap.py` (Workflow-lite) |
|
||||
| API | `include_roadmap_preview`, `include_llm_roadmap`, `roadmap_first` auf `progression-path-suggest` |
|
||||
| Prompts | Migration **078/079** — Slugs in `ai_prompts` (Admin), **kein** Template im Python-Code |
|
||||
| UI | `ExerciseProgressionPathBuilder` — Roadmap-Box (Major Steps) |
|
||||
| Match | `planning_exercise_path_builder.py` — `roadmap_first`, `roadmap_override` |
|
||||
| Skills | `planning_skill_expectations.py` — pro Stufe + Pfad |
|
||||
| Gap-KI | `planning_exercise_form_context.py`, `planning_exercise_path_ai_fill.py` |
|
||||
| Persistenz | `planning_roadmap` JSONB (Migration **088**) |
|
||||
| API | `progression-path-suggest`, `PUT` Graph, `POST …/edges/sequence` |
|
||||
| Prompts | **078/079/087** — Slugs nur in `ai_prompts` |
|
||||
| UI | `ExerciseProgressionPathBuilder`, `ExerciseGapFillPrepModal` |
|
||||
|
||||
**F3 (0.8.206):** `roadmap_first=true` (Default im UI) — Retrieval pro `stage_spec`/Major Step; `roadmap_unfilled` Gap-Angebote. Ohne Flag: retrieval-first wie bisher, Roadmap nur Preview.
|
||||
**Graph-Bias:** `progression_graph_id` bevorzugt **bestehende Nachfolger** ab Schritt 2 (Gewicht ~4–10 %), baut aber **keinen** Pfad aus vorhandenen Knoten — siehe Ist-Doku §5.
|
||||
|
||||
**Mitai Workflow-Engine:** bewusst **nicht** jetzt — Pipeline workflow-ready für spätere Anbindung.
|
||||
|
||||
---
|
||||
|
||||
## 25. Backlog (offen)
|
||||
|
||||
Siehe priorisierte Liste in **`docs/architecture/PLANNING_PROGRESSION_GRAPH_KI.md`** §10:
|
||||
|
||||
1. UI-Wizard (Progressionsgraph) — separater Chat
|
||||
2. Graph-Erweiterungsmodus (Start ab Knoten)
|
||||
3. Trainingsplanung Phase G (Gruppenkontext, `planning_skill_expectations`)
|
||||
4. Kontext auf allen Pfad-Schritten in der UI
|
||||
5. Enrichment / Prompt-Feintuning
|
||||
6. Mitai Workflow-Engine (langfristig)
|
||||
|
|
|
|||
|
|
@ -2,9 +2,11 @@
|
|||
|
||||
**Version:** 0.1
|
||||
**Datum:** 2026-06-07
|
||||
**Status:** VERBINDLICHE ZIELARCHITEKTUR — Umsetzung gestartet (0.8.204+)
|
||||
**Status:** VERBINDLICHE ZIELARCHITEKTUR — **F0–F9 umgesetzt** (0.8.217)
|
||||
**Geltungsbereich:** **Progressionsgraph** (`exercise_progression_graphs`) — **ohne** Gruppenanalyse
|
||||
|
||||
**Ist-Stand (Module, API, Graph-Verhalten, Persistenz):** `docs/architecture/PLANNING_PROGRESSION_GRAPH_KI.md`
|
||||
|
||||
**Bezüge:**
|
||||
`working/PLANNING_EXERCISE_SUGGEST_CONTEXT.md` · `working/AI_PLANNING_KI_MULTISTAGE_FORECAST.md` · `technical/AI_TRAINING_PLANNING_CONCEPT.md` · `technical/AI_PROMPT_TARGET_ARCHITECTURE.md` · `docs/architecture/PLANNING_KI_ROADMAP.md` · `docs/HANDOVER.md`
|
||||
|
||||
|
|
@ -152,6 +154,7 @@ Jede Phase: `(ctx) → ctx`, Zwischenergebnisse in API-Response für **Human-in-
|
|||
|
||||
| Slug | Phase | Migration |
|
||||
|------|-------|-----------|
|
||||
| `planning_progression_start_target` | Start/Ziel | **087** |
|
||||
| `planning_progression_goal_analysis` | A | **078** |
|
||||
| `planning_progression_roadmap` | B | **078** |
|
||||
| `planning_progression_stage_spec` | C | **079** |
|
||||
|
|
@ -184,15 +187,23 @@ Jede Phase: `(ctx) → ctx`, Zwischenergebnisse in API-Response für **Human-in-
|
|||
|
||||
| ID | Inhalt | Status |
|
||||
|----|--------|--------|
|
||||
| **F0** | Spec + Doku + `planning_progression_roadmap.py` Scaffold | 🔄 0.8.204 |
|
||||
| **F1** | `include_roadmap_preview` in API + deterministische A/B | 🔄 0.8.204 |
|
||||
| **F2** | LLM Phase A/B/C über `ai_prompts` (078/079), `include_llm_roadmap` | 🔄 0.8.205 |
|
||||
| **F3** | Retrieval aus `stage_specs` (roadmap_first) | ✅ 0.8.206 |
|
||||
| **F4** | UI Roadmap-Review | ✅ 0.8.207 |
|
||||
| **F5** | Trainingsplanung: eigene Pipeline + ggf. Workflow-Engine | 🔲 |
|
||||
| **F0** | Spec + Doku + `planning_progression_roadmap.py` Scaffold | ✅ 0.8.204 |
|
||||
| **F1** | `include_roadmap_preview` in API + deterministische A/B | ✅ 0.8.204 |
|
||||
| **F2** | LLM Phase A/B/C über `ai_prompts` (078/079), `include_llm_roadmap` | ✅ 0.8.205 |
|
||||
| **F3** | Retrieval aus `stage_specs` (roadmap_first) | ✅ 0.8.206–209 |
|
||||
| **F4** | UI Roadmap-Review + `roadmap_override` | ✅ 0.8.207 |
|
||||
| **F5** | Start/Ziel strukturiert + Prompt **087** + Zwei-Schritt-UI | ✅ 0.8.210–214 |
|
||||
| **F6** | Gap-Prep + `planning_context` an Übungs-KI | ✅ 0.8.212–214 |
|
||||
| **F7** | `planning_skill_expectations` | ✅ 0.8.215–216 |
|
||||
| **F8** | Editierbare `stage_specs` in UI | ✅ 0.8.216 |
|
||||
| **F9** | `planning_roadmap` JSONB (Migration **088**) | ✅ 0.8.217 |
|
||||
| **G** | Trainingsplanung: eigene Pipeline + Workflow-Engine | 🔲 |
|
||||
|
||||
Details: `docs/architecture/PLANNING_PROGRESSION_GRAPH_KI.md`
|
||||
|
||||
---
|
||||
|
||||
## 9. Changelog
|
||||
|
||||
- **2026-05-22:** Ist-Stand F5–F9 dokumentiert; Verweis auf `PLANNING_PROGRESSION_GRAPH_KI.md`.
|
||||
- **2026-06-07:** Erstfassung — Roadmap-first Entscheidung, Abgrenzung Graphen vs. Planung, Workflow-lite.
|
||||
|
|
|
|||
81
.claude/docs/working/PROGRESSION_GRAPH_SLOT_EDITOR_SPEC.md
Normal file
81
.claude/docs/working/PROGRESSION_GRAPH_SLOT_EDITOR_SPEC.md
Normal 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 |
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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 . .
|
||||
|
|
|
|||
|
|
@ -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.';
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
-- Migration 089: Planungs-Intent — Zielanalyse + Stufenspecs (anti_patterns, success_criteria)
|
||||
|
||||
UPDATE ai_prompts SET
|
||||
description = 'Phase A: Ist-/Soll, Erfolgskriterien und explizite Ausschlüsse (ohne Gruppenkontext).',
|
||||
template = $t$Du bist Assistent für Kampfsport-Trainer und analysierst eine Anfrage für einen Progressionsgraphen.
|
||||
|
||||
Anfrage: {{goal_query}}
|
||||
Semantic Brief: {{semantic_brief_json}}
|
||||
|
||||
Wichtig:
|
||||
- Keine Gruppenanalyse — nur didaktischer Pfad für die Technik/das Thema.
|
||||
- Explizite Negationen aus der Anfrage (ohne/kein/nicht …) in constraints.excluded_themes übernehmen — nicht raten.
|
||||
- success_criteria: messbar, für späteres Übungs-Matching (Titel + Kurzbeschreibung + Übungsziel).
|
||||
|
||||
Antworte NUR mit JSON:
|
||||
{
|
||||
"primary_topic": "Hauptthema",
|
||||
"start_assumption": "Voraussetzungen für den Einstieg",
|
||||
"target_state": "Konkreter Zielzustand der Progression",
|
||||
"success_criteria": ["messbare Kriterien entlang des Pfads"],
|
||||
"constraints": {
|
||||
"partner_required": false,
|
||||
"excluded_themes": ["wörtliche Negationen, z. B. keine Kumite-Anwendung"],
|
||||
"trainer_notes": "optional: Fokus aus Ergänzungen"
|
||||
}
|
||||
}$t$,
|
||||
default_template = template
|
||||
WHERE slug = 'planning_progression_goal_analysis';
|
||||
|
||||
UPDATE ai_prompts SET
|
||||
description = 'Phase C: Belastung, Übungstyp, Erfolgskriterien und anti_patterns je Major Step — für Retrieval-Matching.',
|
||||
template = $t$Du bist Assistent für Kampfsport-Trainer und spezifizierst didaktische Stufen eines Progressionsgraphen.
|
||||
|
||||
Anfrage: {{goal_query}}
|
||||
Zielanalyse: {{goal_analysis_json}}
|
||||
Major Steps: {{major_steps_json}}
|
||||
Planungs-Intent (Pfadweite Regeln): {{intent_context_json}}
|
||||
Semantic Brief: {{semantic_brief_json}}
|
||||
|
||||
Aufgabe je Major Step — Felder für automatisches Übungs-Matching (nicht nur Titel):
|
||||
- learning_goal: messbares Stufen-Lernziel (was die Übung bringen soll)
|
||||
- load_profile: z. B. koordination, präzision, kraft, athletik
|
||||
- exercise_type: kihon_einzel | partner_drill | kombination | kraft_auxiliary
|
||||
- success_criteria: 2–4 prüfbare Kriterien an Kurzbeschreibung + Übungsziel (nicht nur Technikname im Titel)
|
||||
- anti_patterns: 2–5 Dinge, die für diese Stufe unpassend sind
|
||||
|
||||
Regeln:
|
||||
1. Jede explicit_exclusions / excluded_themes aus intent_context und Zielanalyse MUSS in anti_patterns jeder Stufe vorkommen (umformuliert ok).
|
||||
2. Keine neuen Ausschlüsse erfinden, die nicht in Anfrage/Intent/Zielanalyse stehen.
|
||||
3. success_criteria Pfad-weit + stufenspezifisch kombinieren.
|
||||
4. partner_drill nur wenn Partner/Kumite nicht ausgeschlossen ist.
|
||||
|
||||
Antworte NUR mit JSON:
|
||||
{
|
||||
"stage_specs": [
|
||||
{
|
||||
"major_step_index": 0,
|
||||
"learning_goal": "…",
|
||||
"load_profile": ["koordination"],
|
||||
"exercise_type": "kihon_einzel",
|
||||
"success_criteria": ["…"],
|
||||
"anti_patterns": ["…"]
|
||||
}
|
||||
]
|
||||
}$t$,
|
||||
default_template = template
|
||||
WHERE slug = 'planning_progression_stage_spec';
|
||||
43
backend/migrations/090_ai_prompt_stage_transition_states.sql
Normal file
43
backend/migrations/090_ai_prompt_stage_transition_states.sql
Normal 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';
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
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) - 1:
|
||||
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,12 +496,19 @@ 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'])}")
|
||||
|
|
@ -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)
|
||||
offer_id = f"{spec.get('source')}-{idx}-{uuid.uuid4().hex[:8]}"
|
||||
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]}"
|
||||
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
|
|
@ -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,43 +401,155 @@ 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(
|
||||
_with_roadmap_major_index(
|
||||
step,
|
||||
{
|
||||
"step_index": idx,
|
||||
"exercise_id": int(step["exercise_id"]),
|
||||
|
|
@ -436,7 +558,8 @@ def detect_off_topic_steps(
|
|||
"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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
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.0
|
||||
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",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
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=str(hit.get("title") or ""),
|
||||
summary=str(hit.get("summary") or ""),
|
||||
goal="",
|
||||
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",
|
||||
|
|
|
|||
248
backend/planning_intent_context.py
Normal file
248
backend/planning_intent_context.py
Normal 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",
|
||||
]
|
||||
176
backend/planning_path_qa_pipeline.py
Normal file
176
backend/planning_path_qa_pipeline.py
Normal 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",
|
||||
]
|
||||
245
backend/planning_path_rematch.py
Normal file
245
backend/planning_path_rematch.py
Normal 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",
|
||||
]
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
334
backend/planning_skill_expectations.py
Normal file
334
backend/planning_skill_expectations.py
Normal 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",
|
||||
]
|
||||
140
backend/planning_stage_context.py
Normal file
140
backend/planning_stage_context.py
Normal 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",
|
||||
]
|
||||
76
backend/progression_graph_planning_artifact.py
Normal file
76
backend/progression_graph_planning_artifact.py
Normal 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",
|
||||
]
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -3,13 +3,16 @@ Progressionsgraph zwischen Übungen (Übung → Übung), Migration 032–034.
|
|||
Optional Übungsvarianten als Knoten-Endpunkte; Sequenz-Bulk-Anlage.
|
||||
AuthZ analog training_plan_templates: Bearbeiten/Löschen wie Übungen; Governance-Übergänge zentral.
|
||||
"""
|
||||
from typing import Any, List, Optional
|
||||
import json
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from pydantic import BaseModel, Field, model_validator
|
||||
from psycopg2 import IntegrityError
|
||||
from psycopg2.extras import Json
|
||||
|
||||
from db import get_db, get_cursor, r2d
|
||||
from progression_graph_planning_artifact import normalize_planning_roadmap_payload
|
||||
from tenant_context import TenantContext, get_tenant_context, library_content_visibility_sql
|
||||
from club_tenancy import (
|
||||
assert_library_content_deletable,
|
||||
|
|
@ -36,6 +39,7 @@ class ProgressionGraphUpdate(BaseModel):
|
|||
description: Optional[str] = None
|
||||
visibility: Optional[str] = Field(None, pattern="^(private|club|official)$")
|
||||
club_id: Optional[int] = None
|
||||
planning_roadmap: Optional[Dict[str, Any]] = None
|
||||
|
||||
|
||||
class ProgressionEdgeCreate(BaseModel):
|
||||
|
|
@ -59,6 +63,7 @@ class SequenceStep(BaseModel):
|
|||
class ProgressionSequenceCreate(BaseModel):
|
||||
steps: List[SequenceStep] = Field(..., min_length=2)
|
||||
segment_notes: Optional[List[Optional[str]]] = None
|
||||
planning_roadmap: Optional[Dict[str, Any]] = None
|
||||
"""Länge muss len(steps)-1 sein, wenn gesetzt; Notiz pro Kante Zwischen je zwei Schritten."""
|
||||
|
||||
@model_validator(mode="after")
|
||||
|
|
@ -116,6 +121,17 @@ def _assert_graph_writable(cur, row: dict, profile_id: int, role: str) -> None:
|
|||
assert_library_content_editable(cur, profile_id, role, row)
|
||||
|
||||
|
||||
def _persist_graph_planning_roadmap(cur, graph_id: int, raw: Optional[Dict[str, Any]]) -> None:
|
||||
try:
|
||||
normalized = normalize_planning_roadmap_payload(raw)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
cur.execute(
|
||||
"UPDATE exercise_progression_graphs SET planning_roadmap = %s WHERE id = %s",
|
||||
(Json(normalized) if normalized is not None else None, graph_id),
|
||||
)
|
||||
|
||||
|
||||
def _require_graph_read(cur, graph_id: int, profile_id: int, role: str) -> dict:
|
||||
row = _graph_row(cur, graph_id)
|
||||
_assert_graph_readable(cur, row, profile_id, role)
|
||||
|
|
@ -241,6 +257,127 @@ def get_progression_graph(
|
|||
return row
|
||||
|
||||
|
||||
def _exercise_ids_from_planning_roadmap(artifact: Optional[Dict[str, Any]]) -> set[int]:
|
||||
ids: set[int] = set()
|
||||
if not artifact or not isinstance(artifact, dict):
|
||||
return ids
|
||||
for slot in artifact.get("slot_contents") or []:
|
||||
if not isinstance(slot, dict):
|
||||
continue
|
||||
primary = slot.get("primary") if isinstance(slot.get("primary"), dict) else {}
|
||||
if primary.get("kind") == "library" and primary.get("exercise_id") is not None:
|
||||
try:
|
||||
ids.add(int(primary["exercise_id"]))
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
for sib in slot.get("siblings") or []:
|
||||
if not isinstance(sib, dict):
|
||||
continue
|
||||
if sib.get("kind") == "library" and sib.get("exercise_id") is not None:
|
||||
try:
|
||||
ids.add(int(sib["exercise_id"]))
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
return ids
|
||||
|
||||
|
||||
def _collect_graph_referenced_exercise_ids(cur, graph_id: int) -> set[int]:
|
||||
ids: set[int] = set()
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT from_exercise_id, to_exercise_id
|
||||
FROM exercise_progression_edges
|
||||
WHERE graph_id = %s
|
||||
""",
|
||||
(graph_id,),
|
||||
)
|
||||
for row in cur.fetchall():
|
||||
for key in ("from_exercise_id", "to_exercise_id"):
|
||||
raw = row.get(key)
|
||||
if raw is not None:
|
||||
ids.add(int(raw))
|
||||
cur.execute(
|
||||
"SELECT planning_roadmap FROM exercise_progression_graphs WHERE id = %s",
|
||||
(graph_id,),
|
||||
)
|
||||
prow = cur.fetchone()
|
||||
if prow and prow.get("planning_roadmap"):
|
||||
art = prow["planning_roadmap"]
|
||||
if isinstance(art, str):
|
||||
try:
|
||||
art = json.loads(art)
|
||||
except json.JSONDecodeError:
|
||||
art = None
|
||||
ids |= _exercise_ids_from_planning_roadmap(art if isinstance(art, dict) else None)
|
||||
return ids
|
||||
|
||||
|
||||
@router.get("/exercise-progression-graphs/{graph_id}/visibility-promotion-candidates")
|
||||
def list_visibility_promotion_candidates(
|
||||
graph_id: int,
|
||||
target_visibility: str = Query(default="club", pattern="^(club|official)$"),
|
||||
tenant: TenantContext = Depends(get_tenant_context),
|
||||
):
|
||||
"""
|
||||
Private Übungen im Graph, die bei Promotion des Graphen mit angehoben werden müssten.
|
||||
"""
|
||||
profile_id = tenant.profile_id
|
||||
role = tenant.global_role
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
row = _require_graph_read(cur, graph_id, profile_id, role)
|
||||
graph_vis = (row.get("visibility") or "private").strip().lower()
|
||||
if graph_vis != "private" or target_visibility != "club":
|
||||
return {
|
||||
"graph_id": graph_id,
|
||||
"graph_visibility": graph_vis,
|
||||
"target_visibility": target_visibility,
|
||||
"exercises": [],
|
||||
}
|
||||
ref_ids = _collect_graph_referenced_exercise_ids(cur, graph_id)
|
||||
if not ref_ids:
|
||||
return {
|
||||
"graph_id": graph_id,
|
||||
"graph_visibility": graph_vis,
|
||||
"target_visibility": target_visibility,
|
||||
"exercises": [],
|
||||
}
|
||||
ph = ",".join(["%s"] * len(ref_ids))
|
||||
cur.execute(
|
||||
f"""
|
||||
SELECT id, title, visibility, club_id, created_by
|
||||
FROM exercises
|
||||
WHERE id IN ({ph})
|
||||
AND LOWER(TRIM(COALESCE(visibility, ''))) = 'private'
|
||||
ORDER BY title
|
||||
""",
|
||||
list(ref_ids),
|
||||
)
|
||||
exercises = []
|
||||
for ex in cur.fetchall():
|
||||
exd = r2d(ex)
|
||||
if not library_content_visible_to_profile(
|
||||
cur,
|
||||
profile_id,
|
||||
role,
|
||||
exd,
|
||||
):
|
||||
continue
|
||||
exercises.append(
|
||||
{
|
||||
"id": exd["id"],
|
||||
"title": exd.get("title"),
|
||||
"visibility": exd.get("visibility"),
|
||||
}
|
||||
)
|
||||
return {
|
||||
"graph_id": graph_id,
|
||||
"graph_visibility": graph_vis,
|
||||
"target_visibility": target_visibility,
|
||||
"exercises": exercises,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/exercise-progression-graphs", status_code=201)
|
||||
def create_progression_graph(
|
||||
body: ProgressionGraphCreate,
|
||||
|
|
@ -353,15 +490,24 @@ def update_progression_graph(
|
|||
fields.append("club_id = %s")
|
||||
params.append(next_club if next_vis == "club" else None)
|
||||
|
||||
if not fields:
|
||||
if "planning_roadmap" in original:
|
||||
_persist_graph_planning_roadmap(cur, graph_id, original.get("planning_roadmap"))
|
||||
|
||||
if not fields and "planning_roadmap" not in original:
|
||||
return get_progression_graph(graph_id, include_edges=False, tenant=tenant)
|
||||
|
||||
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()
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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"])
|
||||
|
|
|
|||
|
|
@ -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]})
|
||||
|
|
|
|||
58
backend/tests/test_planning_intent_context.py
Normal file
58
backend/tests/test_planning_intent_context.py
Normal 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 []))
|
||||
185
backend/tests/test_planning_path_rematch.py
Normal file
185
backend/tests/test_planning_path_rematch.py
Normal 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]
|
||||
572
backend/tests/test_planning_roadmap_stage_match.py
Normal file
572
backend/tests/test_planning_roadmap_stage_match.py
Normal 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)
|
||||
121
backend/tests/test_planning_skill_expectations.py
Normal file
121
backend/tests/test_planning_skill_expectations.py
Normal 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
|
||||
65
backend/tests/test_planning_stage_context.py
Normal file
65
backend/tests/test_planning_stage_context.py
Normal 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"])
|
||||
59
backend/tests/test_progression_graph_planning_artifact.py
Normal file
59
backend/tests/test_progression_graph_planning_artifact.py
Normal 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"
|
||||
|
|
@ -1,8 +1,8 @@
|
|||
# Shinkan Jinkendo Version Information
|
||||
|
||||
APP_VERSION = "0.8.213"
|
||||
BUILD_DATE = "2026-06-07"
|
||||
DB_SCHEMA_VERSION = "20260607087"
|
||||
APP_VERSION = "0.8.226"
|
||||
BUILD_DATE = "2026-05-22"
|
||||
DB_SCHEMA_VERSION = "20260607090"
|
||||
|
||||
MODULE_VERSIONS = {
|
||||
"legal_documents": "1.4.0", # Admin: Live-Vorschau pro Abschnitt + modale Vollvorschau (Editor + Dokumentenliste)
|
||||
|
|
@ -38,7 +38,7 @@ MODULE_VERSIONS = {
|
|||
"skill_profiles": "1.0.0", # Phase 3: gewichtetes Fähigkeiten-Profil + skill-discovery/suggestions
|
||||
"methods": "0.1.0",
|
||||
"exercises": "2.37.1", # KI-Endpoints: feature_usage nach ai_calls consume
|
||||
"planning_exercise_suggest": "0.21.1", # start_target_only + reicher gap-fill planning_context
|
||||
"planning_exercise_suggest": "0.23.1", # Phase B: Rematch-Schleife mit optimization_hints + roadmap_unfilled
|
||||
"training_units": "0.4.0", # POST .../publish-to-framework: Ablauf aus geplanter Einheit → Rahmen-Slot-Blueprint
|
||||
"training_programs": "0.1.0",
|
||||
"planning": "0.15.0", # Vorlagen: Strukturvorschau, Bearbeiten inkl. Split-Sessions + Beschreibung
|
||||
|
|
@ -53,6 +53,120 @@ MODULE_VERSIONS = {
|
|||
}
|
||||
|
||||
CHANGELOG = [
|
||||
{
|
||||
"version": "0.8.226",
|
||||
"date": "2026-05-22",
|
||||
"changes": [
|
||||
"Progressionsgraph Phase B: Rematch-Schleife (max_rematch_rounds) mit optimization_hints und roadmap_unfilled.",
|
||||
"Fix: Graph-Bewertung/Match 500 bei off-topic am Rand-Slot (collect_gap_fill_specs IndexError).",
|
||||
],
|
||||
},
|
||||
{
|
||||
"version": "0.8.225",
|
||||
"date": "2026-06-07",
|
||||
"changes": [
|
||||
"Stufen start_state/target_state (Soll-Verkettung) + kontextualisiertes Matching.",
|
||||
"Mehrstufige Pfad-QS (tier1–3) mit optimization_hints; Migration 090 Prompt.",
|
||||
],
|
||||
},
|
||||
{
|
||||
"version": "0.8.224",
|
||||
"date": "2026-06-07",
|
||||
"changes": [
|
||||
"Technik-Pfad-Scope: Geschwister-Techniken (Mae/Yoko bei Mawashi) als hartes Gate in Match/QS.",
|
||||
"path_primary_topic in build_stage_match_brief; Intent technique_sibling_excludes.",
|
||||
],
|
||||
},
|
||||
{
|
||||
"version": "0.8.223",
|
||||
"date": "2026-06-07",
|
||||
"changes": [
|
||||
"planning_intent_context: gemeinsamer Intent für anti_patterns/success_criteria (Phase G-ready).",
|
||||
"Migration 089: LLM-Prompts Zielanalyse + stage_spec mit Intent-Kontext; finalize nach Pipeline.",
|
||||
],
|
||||
},
|
||||
{
|
||||
"version": "0.8.222",
|
||||
"date": "2026-06-07",
|
||||
"changes": [
|
||||
"Pfad-Ausschlüsse: athletic→Kumite-Heuristik entfernt — nur explizite Negationen und anti_patterns.",
|
||||
],
|
||||
},
|
||||
{
|
||||
"version": "0.8.221",
|
||||
"date": "2026-06-07",
|
||||
"changes": [
|
||||
"Pfad-Ausschlüsse (keine Kumite etc.) aus Anfrage in Brief, stage_specs und Matching-Gates.",
|
||||
"QS entfernt path_exclude-Schritte; partielles Strippen wenn die meisten Slots falsch sind.",
|
||||
],
|
||||
},
|
||||
{
|
||||
"version": "0.8.220",
|
||||
"date": "2026-06-07",
|
||||
"changes": [
|
||||
"Roadmap-Stufen-Match: build_stage_match_brief + stage_semantic_score über Titel, Summary und Goal.",
|
||||
"Retriever lädt Übungsziele immer bei Stufen-Match; Ranking nach Stufen-Fit statt Gesamtthema.",
|
||||
],
|
||||
},
|
||||
{
|
||||
"version": "0.8.219",
|
||||
"date": "2026-06-07",
|
||||
"changes": [
|
||||
"Roadmap-Stufen-Gate: Negationen (ohne Tritttechnik) + Pflicht-Treffer Absprung/Beinhebung.",
|
||||
"anti_patterns in Stufen-Match; Gesamt-Thema allein reicht bei strict_positive nicht mehr.",
|
||||
],
|
||||
},
|
||||
{
|
||||
"version": "0.8.218",
|
||||
"date": "2026-06-07",
|
||||
"changes": [
|
||||
"Roadmap-Match: Stufen-Lernziel-Gate (semantic_brief_for_stage, stage_learning_goal).",
|
||||
"Kein Fallback auf globale goal_query bei roadmap_first — Lücke statt falscher Übung.",
|
||||
"Retrieval-Strafe/Bonus für Stufen-Passung; QS erkennt stage_mismatch.",
|
||||
],
|
||||
},
|
||||
{
|
||||
"version": "0.8.217",
|
||||
"date": "2026-06-07",
|
||||
"changes": [
|
||||
"F9: planning_roadmap JSONB am Progressionsgraph (Migration 088).",
|
||||
"PUT Graph + POST edges/sequence speichern Planungs-Artefakt; Laden beim Graph-Wechsel.",
|
||||
"Roadmap-Vorschlag: include_llm_intent=true für reicheren Semantic Brief.",
|
||||
"Doku: docs/architecture/PLANNING_PROGRESSION_GRAPH_KI.md als zentrale Ist-Referenz.",
|
||||
],
|
||||
},
|
||||
{
|
||||
"version": "0.8.216",
|
||||
"date": "2026-06-07",
|
||||
"changes": [
|
||||
"F8: Editierbare stage_specs in UI (Belastung, Erfolgskriterien) → roadmap_override.",
|
||||
"Erwartete Fähigkeiten als Tags pro Pfadschritt und auf Pfad-Ebene.",
|
||||
],
|
||||
},
|
||||
{
|
||||
"version": "0.8.215",
|
||||
"date": "2026-06-07",
|
||||
"changes": [
|
||||
"F7: planning_skill_expectations — pro Stufe Retrieval, path_skill_expectations, Gap-Kontext.",
|
||||
"Wiederverwendbare Scopes für spätere Trainingsplanung (training_section, framework_slot).",
|
||||
],
|
||||
},
|
||||
{
|
||||
"version": "0.8.214",
|
||||
"date": "2026-06-07",
|
||||
"changes": [
|
||||
"F6: ExerciseGapFillPrepModal — Trainer-Ergänzungen vor KI-Entwurf.",
|
||||
"context_preview und reicher goal_for_ai aus Roadmap-Snapshot.",
|
||||
],
|
||||
},
|
||||
{
|
||||
"version": "0.8.210",
|
||||
"date": "2026-06-07",
|
||||
"changes": [
|
||||
"F5: Strukturierte Start/Ziel-Felder, Prompt 087, Zwei-Schritt-UI (start_target_only).",
|
||||
"Priorität Trainer-Eingabe > KI > Regex; heuristische Start→Ziel-Roadmap.",
|
||||
],
|
||||
},
|
||||
{
|
||||
"version": "0.8.209",
|
||||
"date": "2026-06-07",
|
||||
|
|
|
|||
|
|
@ -89,38 +89,39 @@ Das Schema ist gegenüber dem Code zurück: Migration **`022_skills_schema_compl
|
|||
- **Varianten:** Speichern in der **Aktionsleiste** persistiert zuerst geänderte Varianten (`persistPendingVariantChanges`), dann Übungs-Stammdaten; „Variante anlegen“ als `type="button"` ohne verschachteltes Formular (`createVariantFromDraft`)
|
||||
- **Governance (Übungen):** Owner = `created_by`; Bearbeiten = Ersteller, Plattform-Admin oder `can_plan_in_club` bei `visibility=club`; Löschen `club` = nur `club_admin`; Details **`FEATURES_DELIVERED_2026-Q2.md`** §16, **`EXERCISES_API_SPEC.md`** Permissions
|
||||
|
||||
### 2.8 KI Assistenz Übungen & Planungs-KI Übungssuche (Stand **0.8.183**)
|
||||
### 2.8 KI Assistenz Übungen & Planungs-KI (Stand **0.8.217**)
|
||||
|
||||
**Spec / Pipeline:** `.claude/docs/working/PLANNING_EXERCISE_SUGGEST_CONTEXT.md`
|
||||
**Zentrale Ist-Doku (Progressionsgraph-KI):** **`docs/architecture/PLANNING_PROGRESSION_GRAPH_KI.md`** — bei Drift zuerst dort pflegen.
|
||||
|
||||
**Retrieval / Scoring:** `.claude/docs/working/PLANNING_EXERCISE_SUGGEST_CONTEXT.md`
|
||||
**Zielarchitektur Roadmap:** `.claude/docs/working/PLANNING_PROGRESSION_ROADMAP_SPEC.md`
|
||||
**Produkt-Roadmap Phase G+:** `docs/architecture/PLANNING_KI_ROADMAP.md`
|
||||
|
||||
| Phase | Inhalt | Status |
|
||||
|-------|--------|--------|
|
||||
| **P0** | Kontext-Pack, Hybrid-Score, Planungs-Picker | ✅ |
|
||||
| **P0.1** | `ExerciseMatchProfile` / `PlanningTargetProfile`, `profile_v1` | ✅ |
|
||||
| **P1** | Szenario-Pipeline + LLM Intent (`073`) + Erwartungsprofil (`074`) | ✅ |
|
||||
| **P2 / B2** | LLM-Rerank (`072`) bei engem Top-Feld, max. 2 LLM-Calls | ✅ **0.8.182** |
|
||||
| **A** | Voll-Library deterministisch ranken (kein OR-Profil-Pool) | ✅ **0.8.177** |
|
||||
| **B** | Text-Signale aus guidance/Rahmen-Zielen (`planning_text_signals`) | ✅ **0.8.181** |
|
||||
| **C1** | Progressionsgraph auto-match + variantenbewusste Nachfolger | ✅ **0.8.183** |
|
||||
| **C2** | Varianten in Trefferliste / Picker-Auswahl | ✅ **0.8.184** |
|
||||
| **C3** | Graph-Builder: Ziel → Pfad vorschlagen → in Graph speichern | ✅ **0.8.185** |
|
||||
| **E** | Semantik-Schicht (Brief, Phrasen-Score) + Pfad-QA (Lücken, Brücken, LLM-QS) | ✅ **0.8.186** |
|
||||
| **E2** | Pfad-Neuordnung (LLM) + KI-Neuanlage bei unüberbrückbaren Lücken | ✅ **0.8.187** |
|
||||
| **E3** | `gap_fill_offers`, Off-Topic-Strip, voller KI-Call bei Lücken | ✅ **0.8.203** |
|
||||
| **F0–F2** | Roadmap-Pipeline + LLM-Prompts (078/079) | ✅ **0.8.205** |
|
||||
| **F3** | `roadmap_first` — Retrieval + QA lite (keine Brücken/Reorder) | ✅ **0.8.209** |
|
||||
| **F4** | Roadmap-Review UI + `roadmap_override` | ✅ **0.8.207** |
|
||||
| **D** | `planning_context` an `suggestExerciseAi` (Neu-Anlage) | ✅ **0.8.208** |
|
||||
| **P0–P2, A–C2** | Übungssuche, Voll-Library, Graph-Bias, Varianten | ✅ bis **0.8.184** |
|
||||
| **C3, E–E3** | Pfad-Builder, Semantik, QA, Gap-Offers | ✅ bis **0.8.203** |
|
||||
| **D** | `planning_context` an `suggestExerciseAi` | ✅ **0.8.208** |
|
||||
| **F0–F4** | Roadmap-Pipeline, LLM 078/079, `roadmap_first`, UI-Review | ✅ **0.8.205–209** |
|
||||
| **F5** | Start/Ziel strukturiert, LLM **087**, Zwei-Schritt-UI | ✅ **0.8.210–214** |
|
||||
| **F6** | Gap-Prep-Modal, reicher KI-Kontext (`planning_exercise_form_context`) | ✅ **0.8.212–214** |
|
||||
| **F7** | `planning_skill_expectations` — Retrieval + UI + Gap | ✅ **0.8.215–216** |
|
||||
| **F8** | Editierbare `stage_specs` (Belastung, Erfolgskriterien) | ✅ **0.8.216** |
|
||||
| **F9** | `planning_roadmap` JSONB am Graph (Migration **088**) | ✅ **0.8.217** |
|
||||
|
||||
**Architektur-Entscheidung (2026-06-07):** Progressionsgraph = **Roadmap-first** (Ziel → Major Steps → Übungs-Match). **Keine Gruppenanalyse** im Graphen. Mitai Workflow-Engine **später** — jetzt `planning_progression_roadmap.py`. Spec: **`.claude/docs/working/PLANNING_PROGRESSION_ROADMAP_SPEC.md`** · Roadmap: **`docs/architecture/PLANNING_KI_ROADMAP.md`**
|
||||
**Architektur (verbindlich):** Progressionsgraph = **Roadmap-first**, **keine Gruppenanalyse**. Bestehender Graph = **leichter Nachfolger-Bias** ab Schritt 2, **kein** automatisches Erweitern ab letztem Knoten (siehe Ist-Doku §5). Trainingsplanung = **eigene Pipeline** (Phase G), wiederverwendet `planning_skill_expectations`.
|
||||
|
||||
**Backend:** `planning_exercise_suggest.py`, `planning_exercise_retrieval.py`, `planning_exercise_path_builder.py`, **`planning_progression_roadmap.py`** · Router `POST /api/planning/exercise-suggest`, `POST /api/planning/progression-path-suggest` (`roadmap_first`, `include_roadmap_preview`)
|
||||
**Backend-Kern:** `planning_progression_roadmap.py`, `planning_exercise_path_builder.py`, `planning_skill_expectations.py`, `planning_exercise_form_context.py`, `planning_exercise_path_ai_fill.py`, `progression_graph_planning_artifact.py`
|
||||
|
||||
**Frontend:** `ExerciseProgressionPathBuilder` — Roadmap-Box + Pfad je Major Step (`roadmap_first`) · `ExercisePickerModal` (Planung)
|
||||
**API:** `POST /api/planning/progression-path-suggest` · `PUT /api/exercise-progression-graphs/:id` (`planning_roadmap`) · `POST …/edges/sequence`
|
||||
|
||||
**Superadmin:** Übungs-Anreicherung (Skills) — `exercise_enrichment_admin` (**0.8.178+**), separater Admin-Flow
|
||||
**Frontend:** `ExerciseProgressionPathBuilder`, `ExerciseGapFillPrepModal`, `planningContextForExerciseAi.js`
|
||||
|
||||
**Offen (F4+):** Roadmap-UI editierbar; Trainingsplanung eigene Pipeline (Gruppenkontext); Enrichment
|
||||
**Offen (priorisiert):**
|
||||
1. UI-Wizard (Scroll-Monolith → 4 Schritte) — **separater UI-Chat**
|
||||
2. Graph-Erweiterungsmodus (Start ab Knoten)
|
||||
3. Trainingsplanung Phase G (Gruppenkontext)
|
||||
4. Kontext-Anzeige auf allen Pfad-Schritten
|
||||
|
||||
#### Übungs-KI Formular / Schnellanlage (Stand **0.8.171**)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
# Planungs-KI — Produkt-Roadmap
|
||||
|
||||
**Stand:** 2026-06-07
|
||||
**App-Version:** ab **0.8.204** — maßgeblich `backend/version.py`
|
||||
**Stand:** 2026-05-22
|
||||
**App-Version:** **0.8.217** — maßgeblich `backend/version.py`
|
||||
|
||||
Diese Roadmap ergänzt die **Architektur-Refaktor-Roadmap** (`UMSETZUNGSPLAN_ROADMAP.md`) und gilt **nur für KI-gestützte Trainingsplanungsunterstützung**.
|
||||
|
||||
**Ist-Stand Progressionsgraph (detailliert):** `PLANNING_PROGRESSION_GRAPH_KI.md`
|
||||
**Leit-Spec:** `.claude/docs/working/PLANNING_PROGRESSION_ROADMAP_SPEC.md`
|
||||
|
||||
---
|
||||
|
|
@ -26,9 +27,10 @@ Diese Roadmap ergänzt die **Architektur-Refaktor-Roadmap** (`UMSETZUNGSPLAN_ROA
|
|||
| A–C2 | Übungssuche | Voll-Library, Graph, Varianten | ✅ |
|
||||
| C3 | Progressionsgraph | Pfad-Builder (retrieval-first) | ✅ |
|
||||
| E–E3 | Progressionsgraph | Semantik, QA, Lücken-Angebote | ✅ |
|
||||
| **F0–F1** | Progressionsgraph | Roadmap-Pipeline Scaffold + API-Preview | 🔄 **0.8.204** |
|
||||
| **F2–F4** | Progressionsgraph | LLM Roadmap, roadmap-first Retrieval, UI Review | 🔲 |
|
||||
| **F0–F4** | Progressionsgraph | Roadmap-Pipeline, LLM, roadmap-first, UI Review | ✅ **0.8.204–209** |
|
||||
| **F5–F9** | Progressionsgraph | Start/Ziel, Gap-Prep, Skill-Expectations, Persistenz | ✅ **0.8.210–217** |
|
||||
| D | Übungs-Neuanlage | `planning_context` an `suggestExerciseAi` | ✅ **0.8.208** |
|
||||
| **UX** | Progressionsgraph | Wizard/Stepper statt Scroll-UI | 🔲 |
|
||||
| G | Trainingsplanung | Kontext-Pack Gruppe/Historie, S0–S4 | 🔲 |
|
||||
| H | Plattform | Mitai-Workflow-Engine (optional) | 🔲 Backlog |
|
||||
|
||||
|
|
@ -70,6 +72,40 @@ Diese Roadmap ergänzt die **Architektur-Refaktor-Roadmap** (`UMSETZUNGSPLAN_ROA
|
|||
- [x] Major Steps editierbar (Phase, Lernziel, Reihenfolge) vor Übungs-Match
|
||||
- [x] API `roadmap_only` + `roadmap_override`
|
||||
|
||||
### F5 — Start/Ziel (0.8.210–214)
|
||||
|
||||
- [x] Strukturierte Felder `start_situation`, `target_state`, `roadmap_notes`
|
||||
- [x] Prompt **087** `planning_progression_start_target`
|
||||
- [x] Priorität: Trainer > KI > Regex (`resolve_roadmap_structured_input`)
|
||||
- [x] Zwei-Schritt-UI: „Start/Ziel analysieren“ / „Roadmap vorschlagen“
|
||||
|
||||
### F6 — Gap-KI-Kontext (0.8.212–214)
|
||||
|
||||
- [x] `ExerciseGapFillPrepModal` vor KI-Call
|
||||
- [x] `planning_exercise_form_context.py` — Gap-Snapshot, `context_preview`
|
||||
- [x] Migration **085** — `planning_context` in Übungs-Prompts
|
||||
|
||||
### F7 — Fähigkeiten-Scoring (0.8.215–216)
|
||||
|
||||
- [x] `planning_skill_expectations.py` (Scopes: `progression_stage`, `progression_path`)
|
||||
- [x] Pro-Stufe-Retrieval + `path_skill_expectations` + UI-Tags
|
||||
- [x] `expected_skills` in Gap-Fill
|
||||
|
||||
### F8 — Stufen-Details UI (0.8.216)
|
||||
|
||||
- [x] Editierbare `stage_specs` in `roadmap_override` (Belastung, Erfolgskriterien, Vermeiden)
|
||||
|
||||
### F9 — Persistenz (0.8.217)
|
||||
|
||||
- [x] Migration **088** — `planning_roadmap` JSONB am Graph
|
||||
- [x] Laden/Speichern über `GET/PUT` Graph + Sequenz-Endpoint
|
||||
|
||||
### UX — UI-Überarbeitung (offen)
|
||||
|
||||
- [ ] Wizard mit 4 Schritten (Ziel → Roadmap → Match → Lücken)
|
||||
- [ ] Progressive disclosure — Details in Panels, nicht alles gleichzeitig
|
||||
- [ ] Briefing: `PLANNING_PROGRESSION_GRAPH_KI.md` §10
|
||||
|
||||
---
|
||||
|
||||
## Abhängigkeiten
|
||||
|
|
@ -85,4 +121,4 @@ Diese Roadmap ergänzt die **Architektur-Refaktor-Roadmap** (`UMSETZUNGSPLAN_ROA
|
|||
|
||||
## Pflege
|
||||
|
||||
Bei Abschluss einer Teilphase: diese Datei, `HANDOVER.md` §2.8, `PLANNING_EXERCISE_SUGGEST_CONTEXT.md` §24, Changelog in `version.py`.
|
||||
Bei Abschluss einer Teilphase: **`PLANNING_PROGRESSION_GRAPH_KI.md`** (Ist-Stand), diese Datei, `HANDOVER.md` §2.8, `PLANNING_EXERCISE_SUGGEST_CONTEXT.md` §24, Changelog in `version.py`.
|
||||
|
|
|
|||
368
docs/architecture/PLANNING_PROGRESSION_GRAPH_KI.md
Normal file
368
docs/architecture/PLANNING_PROGRESSION_GRAPH_KI.md
Normal 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 A–C: Zielanalyse, Roadmap, `stage_specs`; Start/Ziel-Auflösung (Trainer > KI > Regex) |
|
||||
| `planning_exercise_path_builder.py` | `suggest_progression_path`: roadmap_first Match, QA, Gap-Offers |
|
||||
| `planning_exercise_progression.py` | Graph auflösen, Nachfolger-Kanten für Retrieval-Bias |
|
||||
| `planning_skill_expectations.py` | Skill-Erwartungen pro Scope (`progression_stage`, `progression_path`, später `training_section`) |
|
||||
| `planning_exercise_form_context.py` | `planning_context` / Gap-Snapshot für Übungs-KI |
|
||||
| `planning_exercise_path_ai_fill.py` | Gap-Fill-Angebote, `goal_for_ai`, `context_preview` |
|
||||
| `progression_graph_planning_artifact.py` | Validierung `planning_roadmap` JSON (Schema v1, max. 64 KB) |
|
||||
|
||||
---
|
||||
|
||||
## 4. API — `POST /api/planning/progression-path-suggest`
|
||||
|
||||
### 4.1 Wichtige Request-Flags
|
||||
|
||||
| Feld | Typ | Bedeutung |
|
||||
|------|-----|-----------|
|
||||
| `query` | string | Ziel / Entwicklungsrichtung (min. 3 Zeichen) |
|
||||
| `max_steps` | int | Anzahl Major Steps (2–10) |
|
||||
| `progression_graph_id` | int? | Gewählter Graph — siehe §5 |
|
||||
| `roadmap_first` | bool | Match pro `stage_spec` statt iterativem Pfad |
|
||||
| `roadmap_only` | bool | Nur Roadmap, keine Übungen |
|
||||
| `start_target_only` | bool | Nur Start/Ziel-Analyse |
|
||||
| `roadmap_override` | object | Trainer-bearbeitete `major_steps` + `stage_specs` |
|
||||
| `start_situation`, `target_state`, `roadmap_notes` | string? | Strukturierte Eingabe (Priorität vor KI) |
|
||||
| `include_llm_start_target` | bool | LLM-Extraktion Start/Ziel (Prompt **087**) |
|
||||
| `include_llm_roadmap` | bool | LLM Roadmap (Prompts **078/079**) |
|
||||
| `include_llm_intent` | bool | LLM Intent für Semantic Brief (Roadmap-Vorschlag: **true** seit 0.8.217) |
|
||||
| `include_path_qa`, `include_ai_gap_fill` | bool | QS, Lücken-Angebote |
|
||||
|
||||
### 4.2 Wichtige Response-Felder
|
||||
|
||||
| Feld | Bedeutung |
|
||||
|------|-----------|
|
||||
| `progression_roadmap` | Artefakte A/B/C inkl. `resolved_structured`, `stage_specs`, `prompt_slugs` |
|
||||
| `steps[]` | Gematchte Übungen; pro Schritt u. a. `roadmap_*`, `skill_expectations` |
|
||||
| `path_skill_expectations` | Pfadweite Skill-Erwartungen |
|
||||
| `gap_fill_offers[]` | Lücken mit `context_preview`, `goal_for_ai` |
|
||||
| `path_qa` | QS inkl. `roadmap_qa_mode: roadmap_first_lite` |
|
||||
|
||||
### 4.3 Prompt-Slugs (nur in `ai_prompts`, nie Hardcoding)
|
||||
|
||||
| Slug | Migration | Phase |
|
||||
|------|-----------|-------|
|
||||
| `planning_progression_start_target` | **087** | Start/Ziel-Extraktion |
|
||||
| `planning_progression_goal_analysis` | **078** | Zielanalyse |
|
||||
| `planning_progression_roadmap` | **078** | Roadmap (micro → major) |
|
||||
| `planning_progression_stage_spec` | **079** | Stufenspezifikation |
|
||||
|
||||
---
|
||||
|
||||
## 5. Roadmap-Match — Stufen-Qualität (0.8.218)
|
||||
|
||||
Pro Major Step gilt:
|
||||
|
||||
1. **Stufen-Brief** — `semantic_brief_for_stage()` ergänzt `must_phrases` um das Stufen-Lernziel.
|
||||
2. **Stufen-Gate** — `exercise_passes_stage_learning_goal_gate()` prüft Lernziel-Text in Titel/Summary/Ziel oder Mindest-`semantic_score`.
|
||||
3. **Kein Fallback** — Bei `roadmap_first` wird **nicht** auf die globale `goal_query` zurückgefallen; passt keine Übung → **Lücke** (`roadmap_unfilled`) statt themenfremder Übung.
|
||||
4. **Retrieval** — Bonus/Strafe im Hybrid-Score je nach Stufen-Passung.
|
||||
5. **QS** — `detect_off_topic_steps` erkennt `stage_mismatch` anhand `roadmap_learning_goal`.
|
||||
|
||||
Tests: `test_planning_roadmap_stage_match.py`
|
||||
|
||||
---
|
||||
|
||||
## 6. Rolle des bestehenden Graphs
|
||||
|
||||
**Wichtig — häufiges Missverständnis:**
|
||||
|
||||
| Aspekt | Verhalten |
|
||||
|--------|-----------|
|
||||
| **KI-Pfadvorschlag** | Baut einen **neuen** Übungspfad aus der **Bibliothek** (Roadmap-first), nicht aus vorhandenen Graph-Knoten als Start |
|
||||
| **Schritt 1** | Kein Anker → **kein** Graph-Bias |
|
||||
| **Ab Schritt 2** | Anker = vorherige Übung im Vorschlag → ausgehende Kanten im Graph werden geladen |
|
||||
| **Scoring** | Nachfolger im Graph: Bonus `progression` (ca. **4–10 %** Gewicht, Semantik/Profil dominieren) |
|
||||
| **Speichern** | `POST …/edges/sequence` **fügt Kanten hinzu** — ersetzt den Graph nicht |
|
||||
| **Nicht implementiert** | „Ab letztem Graph-Knoten erweitern“, bestehende Sequenz als Pfad-Start |
|
||||
|
||||
Code: `planning_exercise_progression.py` → `apply_progression_context_to_pack` → `planning_exercise_retrieval.py` (`prog_hit`).
|
||||
|
||||
---
|
||||
|
||||
## 7. Persistenz
|
||||
|
||||
### 6.1 Kanten (`exercise_progression_edges`)
|
||||
|
||||
- Übungsfolge als gerichtete Kanten `next_exercise`
|
||||
- Varianten-aware (Migration **034**)
|
||||
- Kanten-Notizen aus Pfad-Begründungen oder Fallback-`segment_notes`
|
||||
|
||||
### 6.2 Planungs-Artefakt (`planning_roadmap` JSONB, Migration **088**)
|
||||
|
||||
Gespeichert am **Graph-Container** (`exercise_progression_graphs`), Schema v1:
|
||||
|
||||
```json
|
||||
{
|
||||
"schema_version": 1,
|
||||
"goal_query": "…",
|
||||
"start_situation": "…",
|
||||
"target_state": "…",
|
||||
"roadmap_notes": "…",
|
||||
"max_steps": 5,
|
||||
"progression_roadmap": { },
|
||||
"path_skill_expectations": { }
|
||||
}
|
||||
```
|
||||
|
||||
| Aktion | API |
|
||||
|--------|-----|
|
||||
| Speichern (Roadmap/Match) | `PUT /api/exercise-progression-graphs/:id` |
|
||||
| Speichern (mit Pfad) | `POST …/edges/sequence` (optional `planning_roadmap` im Body) |
|
||||
| Laden | `GET /api/exercise-progression-graphs/:id` → Frontend stellt Workflow-Felder wieder her |
|
||||
|
||||
Validierung: `progression_graph_planning_artifact.py` · Tests: `test_progression_graph_planning_artifact.py`
|
||||
|
||||
**`planning_roadmap` ≠ Graph-Kanten:** Metadaten für Wiederaufnahme der KI-Planung, nicht der Übungsgraph selbst.
|
||||
|
||||
---
|
||||
|
||||
## 8. Planungs-Intent (gemeinsam mit Trainingsplanung)
|
||||
|
||||
Modul: **`planning_intent_context.py`** — domänenneutral, wiederverwendbar in Phase G.
|
||||
|
||||
| Baustein | Progressionsgraph heute | Trainingsplanung (Phase G) |
|
||||
|----------|-------------------------|----------------------------|
|
||||
| `build_planning_intent_context` | Aus `goal_query`, Zielanalyse, Notizen | Aus `section_guidance`, Slot, Einheit |
|
||||
| `explicit_exclusions` | Negationen (`ohne/kein/nicht …`) | gleich |
|
||||
| `path_anti_patterns` | → jede `stage_spec.anti_patterns` | → Abschnitts-/Slot-Brief |
|
||||
| `path_success_criteria` | → `success_criteria` + Matching | → Slot-Erwartung |
|
||||
| `finalize_stage_specs_with_intent` | Nach heuristischer/LLM-Roadmap | Analog für Sektionen |
|
||||
|
||||
LLM: Migration **089** — Prompts `planning_progression_goal_analysis` + `planning_progression_stage_spec` erhalten `intent_context_json`; Ausschlüsse nicht erfinden, nur aus Anfrage übernehmen.
|
||||
|
||||
Matching: `anti_patterns` + `success_criteria` → `build_stage_match_brief` → Retrieval-Gate (Titel + Summary + Goal).
|
||||
|
||||
### Stufen im Gesamtziel (`planning_stage_context.py`)
|
||||
|
||||
| Feld | Bedeutung |
|
||||
|------|-----------|
|
||||
| `start_state` | Soll-Start der Stufe (= `target_state` der Vorstufe / Pfad-Start) |
|
||||
| `target_state` | Ziel nach dieser Stufe (= Soll für den nächsten Schritt) |
|
||||
| `build_contextualized_stage_goal()` | Lernziel + Start + Stufen-Ziel + Gesamtziel → Brief/Retrieval |
|
||||
|
||||
Deterministisch: `derive_stage_specs_transition_states()` nach Roadmap-Pipeline; LLM kann Felder überschreiben (Prompt **090**).
|
||||
|
||||
### Mehrstufige Pfad-QS (`planning_path_qa_pipeline.py`)
|
||||
|
||||
| Stufe | Inhalt | Ableitung |
|
||||
|-------|--------|-----------|
|
||||
| **tier1** | Deterministische Gates (Technik-Scope, Ausschlüsse, unfilled) | `optimization_hints` → `rematch_slot`, `refine_stage_spec` |
|
||||
| **tier2** | Übergangs-Lücken zwischen Schritten | `bridge_or_gap_fill` |
|
||||
| **tier3** | LLM-Ganzpfad + Empfehlungen | `review_roadmap` |
|
||||
|
||||
API: `path_qa.qa_tiers`, `path_qa.optimization_hints` — **kein** anfrage-spezifischer Patch, sondern strukturierte Rückkopplung. Auto-Rematch-Schleife: Backlog (QS → Aktion → erneutes Match).
|
||||
|
||||
Phase G: gleiche Tiers für Abschnitts-QS nach Einheits-Match.
|
||||
|
||||
## 9. Fähigkeiten-Scoring-Anbindung
|
||||
|
||||
Modul: `planning_skill_expectations.py`
|
||||
|
||||
| Scope | Verwendung heute | Phase G (vorbereitet) |
|
||||
|-------|------------------|------------------------|
|
||||
| `progression_path` | Einmaliges Pfad-Profil (`path_skill_expectations`) | — |
|
||||
| `progression_stage` | Pro Roadmap-Stufe vor Retrieval + in Gap-Kontext | — |
|
||||
| `training_section` | — | Trainingsabschnitt |
|
||||
| `framework_slot` | — | Rahmen-Slot |
|
||||
|
||||
Quellen: `semantic_topic`, `text_match`, `load_profile` (aus `stage_spec`).
|
||||
|
||||
Integration:
|
||||
- Retrieval: `apply_expectations_to_target` → Hybrid-Score
|
||||
- UI: Tags pro Pfadschritt + Pfad-Header; Gap-Kontext „Erwartete Fähigkeiten“
|
||||
- KI-Neuanlage: `expected_skills` in `context_preview` / `goal_for_ai`
|
||||
|
||||
---
|
||||
|
||||
## 10. KI-Lücken (Gap-Fill)
|
||||
|
||||
Flow:
|
||||
1. `roadmap_unfilled` / QA-Lücken → `gap_fill_offers`
|
||||
2. Trainer: **„Vorbereiten & KI anlegen“** → `ExerciseGapFillPrepModal` (Titel, Stufen-Lernziel, Ergänzungen)
|
||||
3. `POST /api/exercises/ai/suggest` mit `planning_context`
|
||||
4. Vorschau → Übung anlegen → in Pfad einfügen
|
||||
|
||||
Kontext-Helfer: `frontend/src/utils/planningContextForExerciseAi.js`
|
||||
|
||||
---
|
||||
|
||||
## 11. Implementierungsstände (Phasen)
|
||||
|
||||
| Phase | Inhalt | Status | Version |
|
||||
|-------|--------|--------|---------|
|
||||
| F0–F2 | Roadmap-Pipeline + LLM-Prompts 078/079 | ✅ | 0.8.204–205 |
|
||||
| F3 | `roadmap_first` Match pro Stufe | ✅ | 0.8.206–209 |
|
||||
| F4 | Roadmap-Review UI + `roadmap_override` | ✅ | 0.8.207 |
|
||||
| F5 | Start/Ziel strukturiert + LLM **087** + Zwei-Schritt-UI | ✅ | 0.8.210–214 |
|
||||
| F6 | Gap-Prep-Modal + reicher `planning_context` | ✅ | 0.8.212–214 |
|
||||
| F7 | `planning_skill_expectations` (Retrieval + UI + Gap) | ✅ | 0.8.215–216 |
|
||||
| F8 | Editierbare `stage_specs` in UI | ✅ | 0.8.216 |
|
||||
| F9 | `planning_roadmap` Persistenz (Migration **088**) | ✅ | 0.8.217 |
|
||||
| F10 | Stufen-Lernziel-Gate beim Match (kein goal_query-Fallback) | ✅ | 0.8.218 |
|
||||
| **G** | Trainingsplanung eigene Pipeline + Graph-Referenz | 🔲 | — |
|
||||
| **UX** | Wizard/Stepper statt Scroll-Monolith | 🔲 | separater Chat |
|
||||
|
||||
---
|
||||
|
||||
## 12. Offenes Backlog (priorisiert)
|
||||
|
||||
1. **UI-Überarbeitung** — Wizard mit 4 Schritten, progressive disclosure (Briefing unten)
|
||||
2. **Graph-Erweiterungsmodus** — Start ab gewähltem Knoten / letzter Sequenz
|
||||
3. **Kontext auf allen Pfad-Schritten** in UI (nicht nur Lücken)
|
||||
4. **Trainingsplanung Phase G** — Pipeline mit Gruppenkontext, Wiederverwendung `planning_skill_expectations`
|
||||
5. Enrichment / Prompt-Feintuning
|
||||
6. Mitai Workflow-Engine (langfristig)
|
||||
|
||||
### Briefing-Vorlage UI-Chat (Copy-Paste)
|
||||
|
||||
Siehe Nutzer-Chat 2026-05-22 oder `HANDOVER.md` §2.8 — Abschnitt „UI-Überarbeitung“.
|
||||
|
||||
Kern: Wizard ① Ziel & Start/Ziel → ② Roadmap → ③ Match → ④ Lücken & Speichern; Backend-Pipeline unverändert lassen.
|
||||
|
||||
---
|
||||
|
||||
## 13. Tests
|
||||
|
||||
| Datei | Abdeckung |
|
||||
|-------|-----------|
|
||||
| `test_planning_progression_roadmap.py` | Roadmap-Pipeline, Override, Start/Ziel |
|
||||
| `test_planning_exercise_path_builder.py` | Pfad-Annotierung, Skill-Expectations auf Steps |
|
||||
| `test_planning_skill_expectations.py` | Skill-Erwartungen, Scopes |
|
||||
| `test_planning_intent_context.py` | Intent-Kontext, finalize stage_specs |
|
||||
| `test_planning_exercise_path_ai_fill.py` | Gap-Fill, `expected_skills` in goal text |
|
||||
| `test_planning_exercise_form_context.py` | `planning_context`, Gap-Snapshot |
|
||||
| `test_progression_graph_planning_artifact.py` | JSONB-Artefakt-Validierung |
|
||||
| `test_planning_exercise_progression.py` | Graph-Auflösung, Nachfolger |
|
||||
|
||||
---
|
||||
|
||||
## 14. Dokumenten-Index (Drift vermeiden)
|
||||
|
||||
| Frage | Primäre Quelle |
|
||||
|-------|----------------|
|
||||
| Was ist der aktuelle Ist-Stand? | **Diese Datei** |
|
||||
| Zielarchitektur / JSON-Artefakte | `PLANNING_PROGRESSION_ROADMAP_SPEC.md` |
|
||||
| Retrieval, Hybrid-Score, Intents | `PLANNING_EXERCISE_SUGGEST_CONTEXT.md` |
|
||||
| Produkt-Roadmap Phase G+ | `PLANNING_KI_ROADMAP.md` |
|
||||
| Session-Handover / nächste Schritte | `docs/HANDOVER.md` §2.8 |
|
||||
| Fähigkeiten-Scoring allgemein | `SKILL_SCORING_SPEC.md` |
|
||||
| Versionen / Changelog | `backend/version.py` |
|
||||
|
||||
**Pflege-Regel:** Bei jeder abgeschlossenen Teilphase diese Datei + `HANDOVER.md` §2.8 + `version.py` CHANGELOG aktualisieren.
|
||||
|
||||
---
|
||||
|
||||
## 15. Changelog (Dokument)
|
||||
|
||||
| Datum | Änderung |
|
||||
|-------|----------|
|
||||
| 2026-05-22 | Erstfassung Ist-Stand 0.8.217 — zentrale Referenz nach F5–F9 |
|
||||
|
|
@ -14,7 +14,8 @@ Dieses Bündel ist die **Leitlinie für die große Refaktorierung** nach dem MVP
|
|||
| [`frontend/src/api/planning.js`](../../frontend/src/api/planning.js) | Phase 4: Trainingsplanung (Einheiten, Vorlagen, Module, Rahmen, KPIs) |
|
||||
| [BASELINE_SNAPSHOT.md](./BASELINE_SNAPSHOT.md) | Phase 0: Bundle-, API- und Last-Baseline (Messvorlagen, Vergleich nach Phase 2) |
|
||||
| [KI-Prompt-Zielarchitektur](../../.claude/docs/technical/AI_PROMPT_TARGET_ARCHITECTURE.md) | Roadmap: Kontext-Arten, Composition, Planung/Rahmen, Phasenplan (verbindliche Zielrichtung) |
|
||||
| [PLANNING_KI_ROADMAP.md](./PLANNING_KI_ROADMAP.md) | **Planungs-KI Produkt-Roadmap** (Phase F Roadmap-first, Abgrenzung Trainingsplanung) |
|
||||
| [PLANNING_PROGRESSION_GRAPH_KI.md](./PLANNING_PROGRESSION_GRAPH_KI.md) | **Progressionsgraph-KI Ist-Stand** (Module, API, Graph-Verhalten, Persistenz — zentrale Referenz) |
|
||||
| [PLANNING_KI_ROADMAP.md](./PLANNING_KI_ROADMAP.md) | **Planungs-KI Produkt-Roadmap** (Phase F–G, Abgrenzung Trainingsplanung) |
|
||||
| [Progressions-Roadmap Spec](../../.claude/docs/working/PLANNING_PROGRESSION_ROADMAP_SPEC.md) | Phase F: Artefakte A→B→C, API, Workflow-lite |
|
||||
|
||||
## Tests (E2E / Refaktor-Budget)
|
||||
|
|
|
|||
|
|
@ -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: [
|
||||
|
|
|
|||
|
|
@ -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
516
frontend/src/components/ProgressionChainEditor.jsx
Normal file
516
frontend/src/components/ProgressionChainEditor.jsx
Normal 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
|
||||
259
frontend/src/components/ProgressionFindingsPanel.jsx
Normal file
259
frontend/src/components/ProgressionFindingsPanel.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
1027
frontend/src/components/ProgressionGraphEditor.jsx
Normal file
1027
frontend/src/components/ProgressionGraphEditor.jsx
Normal file
File diff suppressed because it is too large
Load Diff
126
frontend/src/components/ProgressionGraphListCard.jsx
Normal file
126
frontend/src/components/ProgressionGraphListCard.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
242
frontend/src/components/ProgressionSlotCard.jsx
Normal file
242
frontend/src/components/ProgressionSlotCard.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
186
frontend/src/components/exercises/ExerciseGapFillPrepModal.jsx
Normal file
186
frontend/src/components/exercises/ExerciseGapFillPrepModal.jsx
Normal 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, 10–12 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
) : (
|
||||
<>
|
||||
|
|
|
|||
15
frontend/src/pages/ProgressionGraphEditPage.jsx
Normal file
15
frontend/src/pages/ProgressionGraphEditPage.jsx
Normal 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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
1024
frontend/src/utils/progressionGraphDraft.js
Normal file
1024
frontend/src/utils/progressionGraphDraft.js
Normal file
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue
Block a user