Progressionsgraphen #52

Merged
Lars merged 5 commits from develop into main 2026-05-23 12:56:52 +02:00
19 changed files with 2793 additions and 16 deletions

View File

@ -2,7 +2,7 @@
**Version:** 0.2
**Datum:** 2026-05-23
**Status:** P0P2 ✅ · Phase A/B/B2 ✅ · **Phase C1 ✅** (Graph auto-match + variantenbewusste Nachfolger) · C2C3 geplant
**Status:** P0P2 ✅ · Phase A/B/B2 ✅ · **Phase C1C3 ✅** · **Phase E ✅** (Semantik + Pfad-QA)
**Bezüge:** `AI_PLANNING_KI_MULTISTAGE_FORECAST.md` · `AI_PROMPT_TARGET_ARCHITECTURE.md` · `SKILL_SCORING_SPEC.md` · `TRAINING_FRAMEWORK_SPEC.md` §3 (Progressionsgraph)
---
@ -190,7 +190,9 @@ Wenn `hits` leer oder Trainer wählt „Mit KI anlegen“:
| **B** | Text-Signale guidance/Rahmen-Ziele | ✅ **0.8.181** |
| **C1** | Graph auto-match + variantenbewusste Nachfolger | ✅ **0.8.183** |
| **C2** | Varianten in Trefferliste / Picker | ✅ **0.8.184** |
| **C3** | Graph-Builder (Ziel → Pfad → speichern) | 🔲 |
| **C3** | Graph-Builder (Ziel → Pfad → speichern) | ✅ **0.8.185** |
| **E** | Semantik-Schicht + Pfad-QA (Lücken/Brücken/LLM-QS) | ✅ **0.8.186** |
| **E2** | Pfad-Neuordnung + KI-Lückenfüller | ✅ **0.8.187** |
| **D** | Neu-Anlage: Pack an `suggestExerciseAi` | 🔲 |
---
@ -211,7 +213,7 @@ Wenn `hits` leer oder Trainer wählt „Mit KI anlegen“:
- **Ungespeicherte Plan-Änderungen:** ✅ Client übergibt `planned_exercise_ids[]` aus Formular (TrainingUnitEditPage).
- **Progressionsgraph-ID:** ✅ Auto-Match vom Anker (**C1**); manuelle Auswahl in UI noch offen.
- **Anker-Variante:** ✅ Client + DB (**C1**); Picker wählt Variante bei Treffer (**C2** — Dropdown + Graph-Vorschlag).
- **Graph-Builder:** Ziel eingeben → aufbauende Übungen → in Graph speichern (**C3**) — Compound-Nutzen über viele Pläne.
- **Graph-Builder (C3):** Ziel → Pfad vorschlagen → in Graph speichern — ✅ **0.8.185**
- **Varianten-Suche:** Library-Picker nutzt `include_variants`; Planungs-KI rankt primär **Übungsebene** — Varianten-Expansion nur gezielt (**C2**).
- **Enrichment:** Superadmin-Tool für Skills; Datenqualität der Bibliothek entscheidend für Profil-Score.
- **LLM-Intent:** ✅ P1 Szenario-Pipeline + `planning_exercise_search_intent` (Migration 073).
@ -427,6 +429,61 @@ Treffer: optional `hits[].suggested_variant_id`.
---
## 21. Phase C3 — Graph-Builder (Roadmap, offen)
## 21. Phase C3 — Graph-Builder (0.8.185) ✅
Ziel eingeben → aufbauende Übungen vorschlagen → nach Review in Graph speichern (`POST …/edges/sequence`). Nutzen über viele Pläne hinweg.
**API:** `POST /api/planning/progression-path-suggest`
| Feld | Bedeutung |
|------|-----------|
| `query` | Ziel / Entwicklungsrichtung (Freitext, min. 3 Zeichen) |
| `max_steps` | 210, Default 5 |
| `progression_graph_id` | optional — Graph-Kontext für Nachfolger ab Schritt 2 |
| `include_llm_intent` | LLM nur Schritt 1 (Budget) |
**Response:** `steps[]` mit `exercise_id`, `variant_id`, `title`, `reasons`, `variants`; `retrieval_phase: …+path_builder`.
**Algorithmus:** Iterativ Hybrid-Ranking — Schritt 1 aus Zielprofil, Folgeschritte mit Anker = letzte Übung, ohne Duplikate.
**UI:** `ExerciseProgressionPathBuilder` im Progressionsgraph-Panel — Review, Varianten, `POST …/edges/sequence`.
---
## 22. Phase E — Semantik-Schicht + Pfad-QA (0.8.186) ✅
### Semantic Brief (`planning_exercise_semantics.py`)
Parallel zum Katalog-Overlay — **nicht ersetzend**:
| Feld | Bedeutung |
|------|-----------|
| `primary_topic` | z. B. `mae geri` |
| `must_phrases` / `exclude_phrases` | Phrasen-Match in Titel/Ziel/Varianten |
| `development_arc` | einstieg → … → perfektion |
| `semantic_strength` | 01 — steuert dynamisches Blend im Hybrid-Score |
| `retrieval_query` | fokussierte Volltext-Query (nicht ganzer Satz) |
Optional LLM: Prompt `planning_exercise_query_semantics` (Migration **075**).
**Hybrid-Score:** neuer Term `w_semantic * semantic_score` — Profil/Volltext werden bei hoher `semantic_strength` relativ abgeschwächt.
### Pfad-QA (`planning_exercise_path_qa.py`)
Nach Pfad-Bildung:
1. **Lücken-Messung** zwischen benachbarten Schritten (Skill-Jaccard + Semantik zum erwarteten Phasen-Segment)
2. **Brücken-Übungen** bei großen Lücken (zusätzliche Schritte, markiert `is_bridge`)
3. **LLM-QS** (Prompt `planning_exercise_path_qa`): Reihenfolge, Themen-Abdeckung, Empfehlungen
**API-Erweiterung** `progression-path-suggest`: `include_path_qa`, `include_llm_path_qa` · Response: `semantic_brief_summary`, `path_qa`.
**Pfad-Schritte:** Semantic Brief + Entwicklungsphase in **allen** Schritten (nicht nur Schritt 1).
### Phase E2 (0.8.187)
- **LLM-QS → Neuordnung:** `ordered_step_indices` im Prompt `planning_exercise_path_qa` (Migration **076**)
- **KI-Lückenfüller:** `planning_exercise_path_ai_fill.py``is_ai_proposal` wenn Bibliothek keine Brücke liefert
- Request: `include_path_reorder`, `include_ai_gap_fill`
---
## 23. Backlog (offen)

View File

@ -0,0 +1,89 @@
-- Migration 075: Planungs-KI Phase E — Semantik-Enrichment + Pfad-QA Prompts
INSERT INTO ai_prompts (
slug, display_name, description, template,
category, output_format, output_schema, is_system_default, default_template, active, sort_order
)
SELECT
'planning_exercise_query_semantics',
'Planungs-Übungssuche Semantik',
'Erweitert deterministisches Semantic Brief um must/exclude phrases und Entwicklungsbogen.',
$t$Du bist Assistent für Kampfsport-Trainer bei der semantischen Analyse von Planungs-Anfragen.
Ziel: JSON für ein Semantic Brief präzise Kernbegriffe, Ausschlüsse, Entwicklungsbogen.
Nutze das bestehende Brief als Basis; ergänze/verfeinere, ersetze aber keine eindeutige Technik-Identität.
Anfrage: {{search_query}}
Bestehendes Brief (deterministisch): {{semantic_brief_json}}
Regeln:
- must_phrases: konkrete Technik-/Themen-Phrasen aus der Anfrage (z. B. "mae geri", nicht nur "geri")
- exclude_phrases: konkurrierende Techniken/Themen, die NICHT gemeint sind
- development_arc: geordnete Phasen aus: einstieg, grundlage, vertiefung, anwendung, perfektion
- semantic_strength: 0.01.0 (höher bei spezifischer Technik/Thema)
- primary_topic: Hauptthema in wenigen Worten
- topic_type: technique | focus | method | skill | general
Antworte NUR mit JSON:
{
"primary_topic": "Mae Geri",
"topic_type": "technique",
"must_phrases": ["mae geri"],
"exclude_phrases": ["mawashi geri", "sakuto geri"],
"development_arc": ["einstieg", "grundlage", "vertiefung", "perfektion"],
"semantic_strength": 0.9,
"rationale": "Kurz auf Deutsch"
}$t$,
'training',
'json',
'{"type":"object","properties":{"must_phrases":{"type":"array"},"exclude_phrases":{"type":"array"},"development_arc":{"type":"array"},"semantic_strength":{"type":"number"}}}'::jsonb,
true,
NULL,
true,
12
WHERE NOT EXISTS (SELECT 1 FROM ai_prompts WHERE slug = 'planning_exercise_query_semantics');
INSERT INTO ai_prompts (
slug, display_name, description, template,
category, output_format, output_schema, is_system_default, default_template, active, sort_order
)
SELECT
'planning_exercise_path_qa',
'Planungs-Pfad QA',
'Semantische Qualitätsprüfung eines vorgeschlagenen Übungspfads inkl. Lücken und Brücken.',
$t$Du bist Assistent für Kampfsport-Trainer und prüfst einen vorgeschlagenen Übungspfad.
Ziel-Anfrage: {{goal_query}}
Semantic Brief: {{semantic_brief_json}}
Schritte (JSON): {{steps_json}}
Erkannte Lücken: {{gaps_json}}
Eingefügte Brücken: {{bridge_inserts_json}}
Prüfe:
1. Deckt der Pfad das Hauptthema der Anfrage ab (nicht nur Oberbegriffe)?
2. Ist die Reihenfolge didaktisch sinnvoll (Einstieg Vertiefung Ziel)?
3. Sind Sprünge zwischen benachbarten Schritten zu groß?
4. Sind Brücken-Übungen sinnvoll oder überflüssig?
5. Fehlen wichtige Zwischenschritte?
Antworte NUR mit JSON:
{
"overall_ok": true,
"quality_score": 0.85,
"topic_coverage": "Kurz: wie gut das Hauptthema abgedeckt ist",
"issues": [""],
"sequence_notes": [""],
"recommendations": [""]
}$t$,
'training',
'json',
'{"type":"object","required":["overall_ok"],"properties":{"overall_ok":{"type":"boolean"},"quality_score":{"type":"number"},"issues":{"type":"array"},"sequence_notes":{"type":"array"},"recommendations":{"type":"array"}}}'::jsonb,
true,
NULL,
true,
13
WHERE NOT EXISTS (SELECT 1 FROM ai_prompts WHERE slug = 'planning_exercise_path_qa');
UPDATE ai_prompts SET default_template = template
WHERE slug IN ('planning_exercise_query_semantics', 'planning_exercise_path_qa')
AND (default_template IS NULL OR TRIM(default_template) = '');

View File

@ -0,0 +1,60 @@
-- Migration 076: Planungs-Pfad-QA — Neuordnung + KI-Lückenfüller (Phase E2)
UPDATE ai_prompts
SET template = $t$Du bist Assistent für Kampfsport-Trainer und prüfst einen vorgeschlagenen Übungspfad.
Ziel-Anfrage: {{goal_query}}
Semantic Brief: {{semantic_brief_json}}
Schritte (JSON): {{steps_json}}
Erkannte Lücken: {{gaps_json}}
Eingefügte Brücken: {{bridge_inserts_json}}
Prüfe:
1. Deckt der Pfad das Hauptthema der Anfrage ab (nicht nur Oberbegriffe)?
2. Ist die Reihenfolge didaktisch sinnvoll (Einstieg Vertiefung Ziel)?
3. Sind Sprünge zwischen benachbarten Schritten zu groß?
4. Sind Brücken-Übungen sinnvoll oder überflüssig?
5. Fehlen wichtige Zwischenschritte?
Wenn die Reihenfolge verbessert werden sollte: ordered_step_indices = Permutation der aktuellen 0-basierten Schritt-Indizes (beste didaktische Reihenfolge).
Nur Indizes aus dem steps_json verwenden Länge muss exakt der Schrittzahl entsprechen.
Antworte NUR mit JSON:
{
"overall_ok": true,
"quality_score": 0.85,
"topic_coverage": "Kurz: wie gut das Hauptthema abgedeckt ist",
"ordered_step_indices": [0, 1, 2, 3],
"issues": [""],
"sequence_notes": [""],
"recommendations": [""]
}$t$,
default_template = $t$Du bist Assistent für Kampfsport-Trainer und prüfst einen vorgeschlagenen Übungspfad.
Ziel-Anfrage: {{goal_query}}
Semantic Brief: {{semantic_brief_json}}
Schritte (JSON): {{steps_json}}
Erkannte Lücken: {{gaps_json}}
Eingefügte Brücken: {{bridge_inserts_json}}
Prüfe:
1. Deckt der Pfad das Hauptthema der Anfrage ab (nicht nur Oberbegriffe)?
2. Ist die Reihenfolge didaktisch sinnvoll (Einstieg Vertiefung Ziel)?
3. Sind Sprünge zwischen benachbarten Schritten zu groß?
4. Sind Brücken-Übungen sinnvoll oder überflüssig?
5. Fehlen wichtige Zwischenschritte?
Wenn die Reihenfolge verbessert werden sollte: ordered_step_indices = Permutation der aktuellen 0-basierten Schritt-Indizes (beste didaktische Reihenfolge).
Nur Indizes aus dem steps_json verwenden Länge muss exakt der Schrittzahl entsprechen.
Antworte NUR mit JSON:
{
"overall_ok": true,
"quality_score": 0.85,
"topic_coverage": "Kurz: wie gut das Hauptthema abgedeckt ist",
"ordered_step_indices": [0, 1, 2, 3],
"issues": [""],
"sequence_notes": [""],
"recommendations": [""]
}$t$
WHERE slug = 'planning_exercise_path_qa';

View File

@ -0,0 +1,196 @@
"""
Planungs-KI Phase E2: KI-Neuanlage-Vorschläge für unüberbrückbare Pfad-Lücken.
"""
from __future__ import annotations
import logging
from typing import Any, Dict, Mapping, Optional
import uuid
from ai_prompt_context import ExerciseFormAiPromptContext
from ai_prompt_job import run_exercise_form_ai_suggestion
from exercise_ai import strip_html_to_plain
from planning_exercise_semantics import PlanningSemanticBrief
_logger = logging.getLogger("shinkan.planning_exercise_path_ai_fill")
def _build_gap_ai_context(
*,
goal_query: str,
brief: PlanningSemanticBrief,
step_a: Mapping[str, Any],
step_b: Mapping[str, Any],
gap: Mapping[str, Any],
) -> ExerciseFormAiPromptContext:
topic = (brief.primary_topic or "Technik").strip()
phase = gap.get("expected_phase") or "vertiefung"
from_title = (step_a.get("title") or f"Übung #{step_a.get('exercise_id')}").strip()
to_title = (step_b.get("title") or f"Übung #{step_b.get('exercise_id')}").strip()
title = f"Brücke {topic} ({phase})"
goal = (
f"Planungsziel: {goal_query}\n\n"
f"Didaktische Brücken-Übung zwischen „{from_title}“ und „{to_title}“.\n"
f"Phase: {phase}. Thema: {topic}. "
f"Die Übung schließt die Lücke im Progressionspfad und bereitet sinnvoll auf den nächsten Schritt vor."
)
focus_hint = topic if brief.topic_type == "technique" else None
if brief.must_phrases:
focus_hint = ", ".join(brief.must_phrases[:2])
return ExerciseFormAiPromptContext(
title=title[:280],
goal=goal[:8000],
execution=None,
focus_hint=focus_hint,
)
def ai_proposal_to_path_step(
*,
ai_payload: Mapping[str, Any],
ctx_title: str,
gap: Mapping[str, Any],
step_a: Mapping[str, Any],
step_b: Mapping[str, Any],
) -> Dict[str, Any]:
summary_text = ""
summary_obj = ai_payload.get("summary")
if isinstance(summary_obj, dict):
summary_text = str(summary_obj.get("text") or "").strip()
elif isinstance(summary_obj, str):
summary_text = summary_obj.strip()
proposal_key = f"ai-{uuid.uuid4().hex[:10]}"
title = (ctx_title or "").strip() or "KI-Vorschlag (Brücke)"
reasons = ["KI-Neuanlage-Vorschlag — Lücke ohne passende Bibliotheks-Übung"]
return {
"exercise_id": None,
"proposal_key": proposal_key,
"variant_id": None,
"title": title,
"summary": summary_text or None,
"score": None,
"semantic_score": None,
"reasons": reasons,
"variants": [],
"is_bridge": True,
"is_ai_proposal": True,
"ai_suggestion": dict(ai_payload),
"bridge_for_gap": {
"from_exercise_id": int(step_a["exercise_id"]),
"to_exercise_id": int(step_b["exercise_id"]),
"gap_score": gap.get("gap_score"),
"expected_phase": gap.get("expected_phase"),
},
}
def try_suggest_ai_bridge_step(
cur,
*,
goal_query: str,
brief: PlanningSemanticBrief,
step_a: Mapping[str, Any],
step_b: Mapping[str, Any],
gap: Mapping[str, Any],
) -> Optional[Dict[str, Any]]:
"""Ruft exercise AI suggest auf — kein Speichern in DB."""
ctx = _build_gap_ai_context(
goal_query=goal_query,
brief=brief,
step_a=step_a,
step_b=step_b,
gap=gap,
)
g_plain = strip_html_to_plain(ctx.goal)
if not g_plain.strip() and not (ctx.title or "").strip():
return None
try:
payload = run_exercise_form_ai_suggestion(
cur,
ctx,
want_summary=True,
want_skills=True,
want_instructions=False,
)
except Exception as exc:
_logger.warning("KI-Lückenfüller fehlgeschlagen: %s", exc)
return None
if not payload:
return None
return ai_proposal_to_path_step(
ai_payload=payload,
ctx_title=ctx.title or "",
gap=gap,
step_a=step_a,
step_b=step_b,
)
def insert_ai_proposals_for_gaps(
cur,
steps: list,
unfilled_gaps: list,
*,
goal_query: str,
brief: PlanningSemanticBrief,
max_proposals: int = 2,
) -> tuple[list, list]:
"""Fügt KI-Vorschläge für Lücken ein, wenn Bibliotheks-Brücke fehlte."""
if not unfilled_gaps:
return steps, []
out = list(steps)
proposals: list = []
gap_by_pair = {
(int(g["from_exercise_id"]), int(g["to_exercise_id"])): g for g in unfilled_gaps
}
i = 0
while i < len(out) - 1 and len(proposals) < max_proposals:
a = out[i]
b = out[i + 1]
if a.get("is_ai_proposal") or b.get("is_ai_proposal"):
i += 1
continue
key = (int(a["exercise_id"]), int(b["exercise_id"]))
gap = gap_by_pair.get(key)
if not gap:
i += 1
continue
proposal = try_suggest_ai_bridge_step(
cur,
goal_query=goal_query,
brief=brief,
step_a=a,
step_b=b,
gap=gap,
)
if not proposal:
i += 1
continue
out.insert(i + 1, proposal)
proposals.append(
{
"inserted_after_index": i,
"proposal_key": proposal.get("proposal_key"),
"proposal_title": proposal.get("title"),
"gap": gap,
}
)
i += 2
return out, proposals
__all__ = [
"insert_ai_proposals_for_gaps",
"try_suggest_ai_bridge_step",
]

View File

@ -0,0 +1,499 @@
"""
Planungs-KI Phase C3/E: Pfad-Vorschläge für Progressionsgraphen.
Ziel-Freitext semantisch gewichtete Schritte Lücken/Brücken optional LLM-QA.
"""
from __future__ import annotations
from typing import Any, Callable, Dict, List, Optional, Set, Tuple
from fastapi import HTTPException
from pydantic import BaseModel, Field
from tenant_context import TenantContext, library_content_visibility_sql
from planning_exercise_profiles import PlanningTargetProfile
from planning_exercise_path_qa import (
apply_llm_path_reorder,
build_path_qa_summary,
detect_path_gaps,
insert_bridge_exercises,
try_llm_qa_progression_path,
)
from planning_exercise_path_ai_fill import insert_ai_proposals_for_gaps
from planning_exercise_retrieval import run_multistage_planning_retrieval
from planning_exercise_semantics import (
PlanningSemanticBrief,
apply_path_retrieval_weights,
brief_to_summary_dict,
build_semantic_brief,
enrich_target_with_semantic_expectations,
exercise_passes_path_semantic_gate,
pick_best_path_hit,
resolve_semantic_skill_weights,
step_phase_for_index,
step_retrieval_query,
try_enrich_semantic_brief_with_llm,
)
from planning_exercise_target_pipeline import build_planning_target_with_query_pipeline
from planning_exercise_progression import apply_progression_context_to_pack
from planning_exercise_suggest import (
_enrich_planning_hits_with_variant_meta,
_load_skill_ids_for_exercise,
_normalize_query,
resolve_planning_exercise_intent,
)
from routers.training_planning import _has_planning_role
class ProgressionPathSuggestRequest(BaseModel):
query: str = Field(..., min_length=3, max_length=2000)
max_steps: int = Field(default=5, ge=2, le=10)
include_llm_intent: bool = True
include_path_qa: bool = True
include_llm_path_qa: bool = True
include_path_reorder: bool = True
include_ai_gap_fill: bool = True
progression_graph_id: Optional[int] = Field(default=None, ge=1)
exercise_kind_any: Optional[List[str]] = None
def _pick_best_path_hit(
hits: List[Dict[str, Any]],
used_exercise_ids: Set[int],
*,
semantic_brief: Optional[PlanningSemanticBrief] = None,
) -> Optional[Dict[str, Any]]:
return pick_best_path_hit(hits, used_exercise_ids, semantic_brief=semantic_brief)
def _build_path_target_profile(
cur,
*,
goal_query: str,
semantic_brief: PlanningSemanticBrief,
include_llm_intent: bool,
) -> Tuple[PlanningTargetProfile, Dict[str, Any], str]:
"""Einmaliges Erwartungsprofil für den gesamten Pfad (Query + Semantik + Skills)."""
empty_unit = {
"id": None,
"framework_slot_id": None,
"origin_framework_slot_id": None,
}
pipeline_context = {
"unit_title": None,
"group_name": None,
"section_title": None,
"section_guidance_notes": goal_query,
"section_exercise_count": 0,
"planned_count": 0,
"anchor_title": None,
"anchor_exercise_id": None,
"last_section_exercise_title": None,
"progression_graph_id": None,
"unit_skill_profile": None,
"section_skill_profile": None,
"has_planning_reference": False,
"expectation_mode": "query_only",
}
target, intent, _scenario, query_intent_summary = build_planning_target_with_query_pipeline(
cur,
unit=empty_unit,
planned_exercise_ids=[],
section_planned_exercise_ids=[],
anchor_exercise_id=None,
query=goal_query,
heuristic_intent=resolve_planning_exercise_intent(goal_query, "free_search"),
include_llm_intent=include_llm_intent,
context_summary=pipeline_context,
has_planning_reference=False,
)
skill_weights = resolve_semantic_skill_weights(cur, semantic_brief)
target = enrich_target_with_semantic_expectations(target, skill_weights=skill_weights)
return target, query_intent_summary, intent
def _hit_to_path_step(hit: Dict[str, Any], *, is_bridge: bool = False) -> Dict[str, Any]:
raw_vid = hit.get("suggested_variant_id")
variant_id: Optional[int] = None
if raw_vid is not None:
try:
vid = int(raw_vid)
if vid > 0:
variant_id = vid
except (TypeError, ValueError):
variant_id = None
step = {
"exercise_id": int(hit["id"]),
"variant_id": variant_id,
"title": hit.get("title"),
"summary": hit.get("summary"),
"score": hit.get("score"),
"semantic_score": hit.get("semantic_score"),
"reasons": list(hit.get("reasons") or []),
"variants": hit.get("variants") or [],
"suggested_variant_id": hit.get("suggested_variant_id"),
"suggested_variant_name": hit.get("suggested_variant_name"),
}
if is_bridge:
step["is_bridge"] = True
return step
def _run_path_step_retrieval(
cur,
*,
tenant: TenantContext,
goal_query: str,
step_index: int,
max_steps: int,
planned_ids: List[int],
anchor_id: Optional[int],
anchor_variant_id: Optional[int],
progression_graph_id: Optional[int],
include_llm_intent: bool,
exercise_kind_any: Optional[List[str]],
semantic_brief: PlanningSemanticBrief,
bridge_mode: bool = False,
step_a: Optional[Dict[str, Any]] = None,
step_b: Optional[Dict[str, Any]] = None,
path_target_profile: Optional[PlanningTargetProfile] = None,
path_intent: Optional[str] = None,
) -> Tuple[List[Dict[str, Any]], PlanningTargetProfile, Dict[str, Any], str]:
step_query = step_retrieval_query(semantic_brief, goal_query, step_index, max_steps)
if bridge_mode and step_a and step_b:
phase = step_phase_for_index(semantic_brief, step_index, max_steps)
parts = [semantic_brief.primary_topic or semantic_brief.retrieval_query or goal_query]
if phase:
parts.append(phase)
step_query = _normalize_query(" ".join(p for p in parts if p) + " brücke")
pack: Dict[str, Any] = {
"unit_id": None,
"unit": {
"id": None,
"framework_slot_id": None,
"origin_framework_slot_id": None,
},
"unit_title": None,
"group_id": None,
"group_name": None,
"section_order_index": None,
"section_title": None,
"section_guidance_notes": goal_query if step_index == 0 and not bridge_mode else step_query,
"planned_exercise_ids": list(planned_ids),
"anchor_exercise_id": anchor_id,
"anchor_title": None,
"anchor_skill_ids": sorted(_load_skill_ids_for_exercise(cur, anchor_id)),
"group_recent_exercise_ids": [],
"context_mode": "progression_path",
"has_planning_reference": bool(planned_ids or anchor_id or bridge_mode),
"semantic_brief": semantic_brief,
"retrieval_query": step_query,
"path_step_phase": step_phase_for_index(semantic_brief, step_index, max_steps),
}
pack = apply_progression_context_to_pack(
cur,
tenant,
pack,
explicit_graph_id=progression_graph_id,
anchor_variant_id=anchor_variant_id,
)
if step_index == 0 and not bridge_mode:
heuristic_intent = resolve_planning_exercise_intent(goal_query, "free_search")
else:
heuristic_intent = path_intent or resolve_planning_exercise_intent(goal_query, "free_search")
has_plan_ref = bool(pack.get("has_planning_reference"))
pipeline_context = {
"unit_title": None,
"group_name": None,
"section_title": pack.get("section_title"),
"section_guidance_notes": pack.get("section_guidance_notes"),
"section_exercise_count": len(planned_ids),
"planned_count": len(planned_ids),
"anchor_title": pack.get("anchor_title"),
"anchor_exercise_id": pack.get("anchor_exercise_id"),
"last_section_exercise_title": None,
"progression_graph_id": pack.get("progression_graph_id"),
"unit_skill_profile": None,
"section_skill_profile": None,
"has_planning_reference": has_plan_ref,
"expectation_mode": "query_only" if step_index == 0 and not planned_ids else "planning_hybrid",
}
if path_target_profile is not None:
target_profile = path_target_profile
intent = path_intent or resolve_planning_exercise_intent(goal_query, "free_search")
query_intent_summary = {}
else:
target_profile, intent, _scenario, query_intent_summary = build_planning_target_with_query_pipeline(
cur,
unit=pack["unit"],
planned_exercise_ids=pack["planned_exercise_ids"],
section_planned_exercise_ids=[],
anchor_exercise_id=pack.get("anchor_exercise_id"),
query=goal_query if step_index == 0 and not bridge_mode else step_query,
heuristic_intent=heuristic_intent,
include_llm_intent=include_llm_intent and step_index == 0 and not bridge_mode,
context_summary=pipeline_context,
has_planning_reference=has_plan_ref,
)
weights = apply_path_retrieval_weights(semantic_brief)
profile_id = tenant.profile_id
role = tenant.global_role
vis_sql, vis_params = library_content_visibility_sql(
alias="e",
profile_id=profile_id,
role=role,
effective_club_id=tenant.effective_club_id,
)
hits, _skills_by_ex, _full_lib = run_multistage_planning_retrieval(
cur,
vis_sql=vis_sql,
vis_params=vis_params,
query=step_query,
exercise_kind_any=exercise_kind_any,
target=target_profile,
intent=intent,
intent_weights=weights,
pack=pack,
)
hits = _enrich_planning_hits_with_variant_meta(cur, hits[:32])
return hits, target_profile, query_intent_summary, intent
def _make_bridge_search_fn(
cur,
*,
tenant: TenantContext,
goal_query: str,
max_steps: int,
progression_graph_id: Optional[int],
include_llm_intent: bool,
exercise_kind_any: Optional[List[str]],
semantic_brief: PlanningSemanticBrief,
planned_ids: List[int],
path_target_profile: PlanningTargetProfile,
path_intent: str,
) -> Callable[..., List[Dict[str, Any]]]:
def _bridge_search(
step_a: Dict[str, Any],
step_b: Dict[str, Any],
_gap: Dict[str, Any],
) -> List[Dict[str, Any]]:
hits, _, _, _ = _run_path_step_retrieval(
cur,
tenant=tenant,
goal_query=goal_query,
step_index=1,
max_steps=max_steps,
planned_ids=list(planned_ids) + [int(step_a["exercise_id"])],
anchor_id=int(step_a["exercise_id"]),
anchor_variant_id=step_a.get("variant_id"),
progression_graph_id=progression_graph_id,
include_llm_intent=include_llm_intent,
exercise_kind_any=exercise_kind_any,
semantic_brief=semantic_brief,
bridge_mode=True,
step_a=step_a,
step_b=step_b,
path_target_profile=path_target_profile,
path_intent=path_intent,
)
gated = [
h
for h in hits
if exercise_passes_path_semantic_gate(
semantic_score=float(h.get("semantic_score") or 0.0),
title=str(h.get("title") or ""),
summary=str(h.get("summary") or ""),
brief=semantic_brief,
strict=False,
)
]
return gated or hits[:12]
return _bridge_search
def suggest_progression_path(
cur,
*,
tenant: TenantContext,
body: ProgressionPathSuggestRequest,
) -> Dict[str, Any]:
role = tenant.global_role
if not _has_planning_role(role):
raise HTTPException(status_code=403, detail="Nur Trainer dürfen Pfad-Vorschläge abrufen")
goal_query = _normalize_query(body.query)
if len(goal_query) < 3:
raise HTTPException(status_code=400, detail="Ziel-Anfrage: mindestens 3 Zeichen")
max_steps = int(body.max_steps)
semantic_brief = build_semantic_brief(goal_query)
semantic_llm_applied = False
if body.include_llm_intent and semantic_brief.semantic_strength >= 0.35:
semantic_brief, semantic_llm_applied = try_enrich_semantic_brief_with_llm(
cur, goal_query, semantic_brief
)
path_target_profile, first_intent_summary, path_intent = _build_path_target_profile(
cur,
goal_query=goal_query,
semantic_brief=semantic_brief,
include_llm_intent=body.include_llm_intent,
)
used: Set[int] = set()
steps: List[Dict[str, Any]] = []
planned_ids: List[int] = []
anchor_id: Optional[int] = None
anchor_variant_id: Optional[int] = None
for step_index in range(max_steps):
hits, _tp, _qis, _intent = _run_path_step_retrieval(
cur,
tenant=tenant,
goal_query=goal_query,
step_index=step_index,
max_steps=max_steps,
planned_ids=planned_ids,
anchor_id=anchor_id,
anchor_variant_id=anchor_variant_id,
progression_graph_id=body.progression_graph_id,
include_llm_intent=body.include_llm_intent,
exercise_kind_any=body.exercise_kind_any,
semantic_brief=semantic_brief,
path_target_profile=path_target_profile,
path_intent=path_intent,
)
hit = _pick_best_path_hit(hits, used, semantic_brief=semantic_brief)
if not hit:
break
step = _hit_to_path_step(hit)
steps.append(step)
eid = int(step["exercise_id"])
used.add(eid)
planned_ids.append(eid)
anchor_id = eid
anchor_variant_id = step.get("variant_id")
if len(steps) < 2:
raise HTTPException(
status_code=422,
detail="Zu wenig passende Übungen für einen Pfad (mindestens 2 Schritte). Ziel präzisieren oder max_steps senken.",
)
gaps: List[Dict[str, Any]] = []
bridge_inserts: List[Dict[str, Any]] = []
ai_proposals: List[Dict[str, Any]] = []
llm_qa: Optional[Dict[str, Any]] = None
llm_qa_applied = False
reorder_applied = False
reorder_notes: List[str] = []
if body.include_path_qa:
gaps = detect_path_gaps(cur, steps, brief=semantic_brief)
unfilled_gaps: List[Dict[str, Any]] = []
if gaps:
bridge_fn = _make_bridge_search_fn(
cur,
tenant=tenant,
goal_query=goal_query,
max_steps=max_steps,
progression_graph_id=body.progression_graph_id,
include_llm_intent=body.include_llm_intent,
exercise_kind_any=body.exercise_kind_any,
semantic_brief=semantic_brief,
planned_ids=planned_ids,
path_target_profile=path_target_profile,
path_intent=path_intent,
)
steps, bridge_inserts, unfilled_gaps = insert_bridge_exercises(
cur,
steps,
gaps,
brief=semantic_brief,
bridge_search_fn=bridge_fn,
)
if body.include_ai_gap_fill and unfilled_gaps:
steps, ai_proposals = insert_ai_proposals_for_gaps(
cur,
steps,
unfilled_gaps,
goal_query=goal_query,
brief=semantic_brief,
)
if body.include_llm_path_qa:
llm_qa, llm_qa_applied = try_llm_qa_progression_path(
cur,
goal_query=goal_query,
brief=semantic_brief,
steps=steps,
gaps=gaps,
bridge_inserts=bridge_inserts,
)
if body.include_path_reorder and llm_qa_applied and llm_qa:
q_score = llm_qa.get("quality_score")
try:
q_val = float(q_score) if q_score is not None else None
except (TypeError, ValueError):
q_val = None
if llm_qa.get("overall_ok") or (q_val is not None and q_val >= 0.45):
steps, reorder_applied, reorder_notes = apply_llm_path_reorder(steps, llm_qa)
path_qa = build_path_qa_summary(
gaps=gaps,
bridge_inserts=bridge_inserts,
ai_proposals=ai_proposals,
llm_qa=llm_qa,
llm_applied=llm_qa_applied,
reorder_applied=reorder_applied,
reorder_notes=reorder_notes,
)
target_profile_summary = path_target_profile.to_summary_dict(cur)
retrieval_parts = ["profile_v1", "full_library", "path_builder", "semantics"]
if body.include_path_qa:
retrieval_parts.append("path_qa")
if llm_qa_applied:
retrieval_parts.append("llm_path_qa")
if reorder_applied:
retrieval_parts.append("path_reorder")
if ai_proposals:
retrieval_parts.append("ai_gap_fill")
return {
"goal_query": goal_query,
"max_steps_requested": max_steps,
"steps": steps,
"step_count": len(steps),
"target_profile_summary": target_profile_summary,
"semantic_brief_summary": brief_to_summary_dict(semantic_brief),
"semantic_llm_applied": semantic_llm_applied,
"query_intent_summary": first_intent_summary,
"progression_graph_id": body.progression_graph_id,
"path_qa": path_qa,
"retrieval_phase": "+".join(retrieval_parts),
}
__all__ = [
"ProgressionPathSuggestRequest",
"suggest_progression_path",
"_pick_best_path_hit",
"_pick_next_path_hit",
]
# Legacy-Alias für Tests
_pick_next_path_hit = _pick_best_path_hit

View File

@ -0,0 +1,398 @@
"""
Planungs-KI Phase E: Pfad-QA Lücken erkennen, Brücken vorschlagen, LLM-Prüfung.
"""
from __future__ import annotations
import json
import logging
import re
from typing import Any, Callable, Dict, List, Mapping, Optional, Sequence, Set, Tuple
from ai_prompt_runtime import AiPromptUnavailableError, load_and_render_ai_prompt
from exercise_ai import strip_html_to_plain
from openrouter_chat import (
effective_openrouter_model_for_prompt_row,
normalize_openrouter_env,
openrouter_chat_completion,
)
from planning_exercise_semantics import (
PlanningSemanticBrief,
brief_to_summary_dict,
score_exercise_semantic_relevance,
step_phase_for_index,
)
_logger = logging.getLogger("shinkan.planning_exercise_path_qa")
_GAP_SKILL_THRESHOLD = 0.10
_GAP_SEMANTIC_THRESHOLD = 0.28
_LARGE_GAP_SCORE = 0.52
_MAX_BRIDGE_INSERTS = 4
def _extract_json_object(text: str) -> Dict[str, Any]:
s = (text or "").strip()
if s.startswith("```"):
s = re.sub(r"^```[a-zA-Z0-9]*\s*", "", s)
if s.endswith("```"):
s = s[:-3].strip()
start = s.find("{")
end = s.rfind("}")
if start < 0 or end <= start:
raise ValueError("Kein JSON-Objekt in LLM-Antwort")
obj = json.loads(s[start : end + 1])
if not isinstance(obj, dict):
raise ValueError("LLM-Antwort ist kein JSON-Objekt")
return obj
def _skill_jaccard(a: Set[int], b: Set[int]) -> float:
if not a or not b:
return 0.0
inter = len(a & b)
union = len(a | b)
return inter / union if union else 0.0
def _load_exercise_skill_ids(cur, exercise_id: int) -> Set[int]:
cur.execute(
"SELECT skill_id FROM exercise_skills WHERE exercise_id = %s",
(int(exercise_id),),
)
return {int(r["skill_id"]) for r in cur.fetchall() if r.get("skill_id") is not None}
def _load_exercise_text_bundle(cur, exercise_id: int) -> Dict[str, Any]:
cur.execute(
"SELECT id, title, summary, goal FROM exercises WHERE id = %s",
(int(exercise_id),),
)
row = cur.fetchone()
if not row:
return {"title": "", "summary": "", "goal": "", "variant_names": []}
cur.execute(
"""
SELECT variant_name FROM exercise_variants
WHERE exercise_id = %s
ORDER BY sequence_order ASC NULLS LAST, id ASC
LIMIT 8
""",
(int(exercise_id),),
)
variants = [str(r.get("variant_name") or "") for r in cur.fetchall()]
return {
"title": str(row.get("title") or ""),
"summary": str(row.get("summary") or ""),
"goal": str(row.get("goal") or ""),
"variant_names": variants,
}
def measure_step_transition_gap(
cur,
step_a: Mapping[str, Any],
step_b: Mapping[str, Any],
*,
brief: PlanningSemanticBrief,
segment_index: int,
total_segments: int,
) -> Dict[str, Any]:
eid_a = int(step_a["exercise_id"])
eid_b = int(step_b["exercise_id"])
skills_a = _load_exercise_skill_ids(cur, eid_a)
skills_b = _load_exercise_skill_ids(cur, eid_b)
skill_sim = _skill_jaccard(skills_a, skills_b)
bundle_b = _load_exercise_text_bundle(cur, eid_b)
mid_phase = step_phase_for_index(brief, segment_index + 1, total_segments + 1)
sem_b, sem_reasons = score_exercise_semantic_relevance(
title=bundle_b["title"],
summary=bundle_b["summary"],
goal=bundle_b["goal"],
variant_names=bundle_b["variant_names"],
brief=brief,
step_phase=mid_phase,
)
gap_score = 0.0
if skill_sim < _GAP_SKILL_THRESHOLD:
gap_score += 0.45 * (1.0 - skill_sim / max(_GAP_SKILL_THRESHOLD, 0.01))
if sem_b < _GAP_SEMANTIC_THRESHOLD:
gap_score += 0.35 * (1.0 - sem_b / max(_GAP_SEMANTIC_THRESHOLD, 0.01))
if brief.semantic_strength >= 0.5 and sem_b < 0.15:
gap_score += 0.2
gap_score = min(1.0, round(gap_score, 4))
is_large = gap_score >= _LARGE_GAP_SCORE
return {
"from_exercise_id": eid_a,
"to_exercise_id": eid_b,
"from_title": step_a.get("title"),
"to_title": step_b.get("title"),
"skill_similarity": round(skill_sim, 4),
"semantic_score_to": sem_b,
"gap_score": gap_score,
"is_large_gap": is_large,
"expected_phase": mid_phase,
"reasons": sem_reasons,
}
def detect_path_gaps(
cur,
steps: Sequence[Mapping[str, Any]],
*,
brief: PlanningSemanticBrief,
) -> List[Dict[str, Any]]:
if len(steps) < 2:
return []
gaps: List[Dict[str, Any]] = []
total_segments = len(steps) - 1
for i in range(total_segments):
gap = measure_step_transition_gap(
cur,
steps[i],
steps[i + 1],
brief=brief,
segment_index=i,
total_segments=total_segments,
)
if gap.get("is_large_gap"):
gaps.append(gap)
return gaps
def _pick_bridge_hit(
hits: Sequence[Mapping[str, Any]],
*,
used_ids: Set[int],
step_a_id: int,
step_b_id: int,
) -> Optional[Dict[str, Any]]:
for hit in hits:
eid = int(hit["id"])
if eid in used_ids or eid in {step_a_id, step_b_id}:
continue
return dict(hit)
return None
def insert_bridge_exercises(
cur,
steps: List[Dict[str, Any]],
gaps: Sequence[Mapping[str, Any]],
*,
brief: PlanningSemanticBrief,
bridge_search_fn: Callable[..., List[Dict[str, Any]]],
max_inserts: int = _MAX_BRIDGE_INSERTS,
) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]], List[Dict[str, Any]]]:
"""
Fügt zwischen großen Lücken Brücken-Übungen ein.
bridge_search_fn(from_step, to_step, gap) -> hits
Returns: (steps, bridge_inserts, unfilled_gaps)
"""
if not gaps:
return steps, [], []
used_ids = {int(s["exercise_id"]) for s in steps if s.get("exercise_id") is not None}
inserts: List[Dict[str, Any]] = []
unfilled: List[Dict[str, Any]] = []
out = list(steps)
gap_by_pair = {
(int(g["from_exercise_id"]), int(g["to_exercise_id"])): g for g in gaps
}
i = 0
while i < len(out) - 1 and len(inserts) < max_inserts:
a = out[i]
b = out[i + 1]
if a.get("exercise_id") is None or b.get("exercise_id") is None:
i += 1
continue
key = (int(a["exercise_id"]), int(b["exercise_id"]))
gap = gap_by_pair.get(key)
if not gap:
i += 1
continue
hits = bridge_search_fn(a, b, gap)
bridge_hit = _pick_bridge_hit(
hits,
used_ids=used_ids,
step_a_id=int(a["exercise_id"]),
step_b_id=int(b["exercise_id"]),
)
if not bridge_hit:
unfilled.append(gap)
i += 1
continue
bridge_step = {
"exercise_id": int(bridge_hit["id"]),
"variant_id": bridge_hit.get("suggested_variant_id"),
"title": bridge_hit.get("title"),
"summary": bridge_hit.get("summary"),
"score": bridge_hit.get("score"),
"reasons": list(bridge_hit.get("reasons") or []) + ["Brücken-Übung (Lückenfüller)"],
"variants": bridge_hit.get("variants") or [],
"suggested_variant_id": bridge_hit.get("suggested_variant_id"),
"suggested_variant_name": bridge_hit.get("suggested_variant_name"),
"is_bridge": True,
"bridge_for_gap": {
"from_exercise_id": int(a["exercise_id"]),
"to_exercise_id": int(b["exercise_id"]),
"gap_score": gap.get("gap_score"),
},
}
out.insert(i + 1, bridge_step)
used_ids.add(int(bridge_step["exercise_id"]))
inserts.append(
{
"inserted_after_index": i,
"bridge_exercise_id": int(bridge_step["exercise_id"]),
"bridge_title": bridge_step.get("title"),
"gap": gap,
}
)
i += 2
return out, inserts, unfilled
def try_llm_qa_progression_path(
cur,
*,
goal_query: str,
brief: PlanningSemanticBrief,
steps: Sequence[Mapping[str, Any]],
gaps: Sequence[Mapping[str, Any]],
bridge_inserts: Sequence[Mapping[str, Any]],
) -> Tuple[Optional[Dict[str, Any]], bool]:
api_key, _ = normalize_openrouter_env()
if not api_key or len(steps) < 2:
return None, False
step_payload = []
for idx, step in enumerate(steps):
if step.get("is_ai_proposal") or step.get("exercise_id") is None:
step_payload.append(
{
"index": idx + 1,
"proposal_key": step.get("proposal_key"),
"title": step.get("title"),
"summary": strip_html_to_plain(step.get("summary"), max_len=400),
"is_bridge": bool(step.get("is_bridge")),
"is_ai_proposal": True,
"reasons": list(step.get("reasons") or [])[:3],
}
)
continue
bundle = _load_exercise_text_bundle(cur, int(step["exercise_id"]))
step_payload.append(
{
"index": idx + 1,
"exercise_id": int(step["exercise_id"]),
"proposal_key": step.get("proposal_key"),
"title": step.get("title") or bundle["title"],
"goal": strip_html_to_plain(bundle["goal"], max_len=400),
"is_bridge": bool(step.get("is_bridge")),
"is_ai_proposal": False,
"reasons": list(step.get("reasons") or [])[:3],
}
)
variables = {
"goal_query": goal_query or "",
"semantic_brief_json": json.dumps(brief_to_summary_dict(brief), ensure_ascii=False),
"steps_json": json.dumps(step_payload, ensure_ascii=False),
"gaps_json": json.dumps(list(gaps), ensure_ascii=False),
"bridge_inserts_json": json.dumps(list(bridge_inserts), ensure_ascii=False),
}
try:
prow, rendered = load_and_render_ai_prompt(cur, "planning_exercise_path_qa", variables)
model = effective_openrouter_model_for_prompt_row(prow)
raw = openrouter_chat_completion(api_key=api_key, model=model, user_content=rendered.text)
obj = _extract_json_object(raw)
return obj, True
except AiPromptUnavailableError:
return None, False
except Exception as exc:
_logger.warning("Pfad-QA-LLM fehlgeschlagen: %s", exc)
return None, False
def apply_llm_path_reorder(
steps: List[Dict[str, Any]],
llm_qa: Mapping[str, Any],
) -> Tuple[List[Dict[str, Any]], bool, List[str]]:
"""
Wendet LLM-Neuordnung an (ordered_step_indices = Permutation der aktuellen Indizes).
"""
raw = llm_qa.get("ordered_step_indices")
if not isinstance(raw, list) or len(raw) != len(steps):
return steps, False, []
try:
indices = [int(x) for x in raw]
except (TypeError, ValueError):
return steps, False, ["Neuordnung: ungültige Indizes"]
if sorted(indices) != list(range(len(steps))):
return steps, False, ["Neuordnung: keine gültige Permutation — ignoriert"]
if indices == list(range(len(steps))):
return steps, False, []
notes = [str(n) for n in (llm_qa.get("sequence_notes") or []) if str(n).strip()]
return [steps[i] for i in indices], True, notes
def build_path_qa_summary(
*,
gaps: Sequence[Mapping[str, Any]],
bridge_inserts: Sequence[Mapping[str, Any]],
ai_proposals: Sequence[Mapping[str, Any]],
llm_qa: Optional[Mapping[str, Any]],
llm_applied: bool,
reorder_applied: bool = False,
reorder_notes: Optional[Sequence[str]] = None,
) -> Dict[str, Any]:
summary: Dict[str, Any] = {
"gap_count": len(gaps),
"large_gaps": list(gaps),
"bridge_insert_count": len(bridge_inserts),
"bridge_inserts": list(bridge_inserts),
"ai_proposal_count": len(ai_proposals),
"ai_proposals": list(ai_proposals),
"llm_qa_applied": llm_applied,
"reorder_applied": reorder_applied,
"reorder_notes": list(reorder_notes or []),
}
if llm_qa:
summary["overall_ok"] = bool(llm_qa.get("overall_ok", True))
summary["quality_score"] = llm_qa.get("quality_score")
summary["issues"] = list(llm_qa.get("issues") or [])
summary["sequence_notes"] = list(llm_qa.get("sequence_notes") or [])
summary["topic_coverage"] = llm_qa.get("topic_coverage")
summary["recommendations"] = list(llm_qa.get("recommendations") or [])
else:
summary["overall_ok"] = len(gaps) == 0
summary["issues"] = [
f"Lücke zwischen „{g.get('from_title')}“ und „{g.get('to_title')}“ (Score {g.get('gap_score')})"
for g in gaps
] if gaps else []
return summary
__all__ = [
"apply_llm_path_reorder",
"build_path_qa_summary",
"detect_path_gaps",
"insert_bridge_exercises",
"measure_step_transition_gap",
"try_llm_qa_progression_path",
]

View File

@ -14,6 +14,11 @@ from planning_exercise_profiles import (
load_exercise_match_profiles_bulk,
score_exercise_against_target,
)
from planning_exercise_semantics import (
PlanningSemanticBrief,
exercise_passes_path_semantic_gate,
score_exercise_semantic_relevance,
)
_MAX_LIBRARY_ROWS = 8000
_PROFILE_LOAD_BATCH = 400
@ -133,6 +138,44 @@ def _load_skill_sets_chunked(cur, exercise_ids: Sequence[int], *, batch: int = _
return out
def _load_exercise_goals_chunked(cur, exercise_ids: Sequence[int], *, batch: int = _PROFILE_LOAD_BATCH) -> Dict[int, str]:
ids = sorted({int(x) for x in exercise_ids if int(x) > 0})
out: Dict[int, str] = {}
if not ids:
return out
for i in range(0, len(ids), batch):
chunk = ids[i : i + batch]
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 "")
return out
def _load_variant_names_chunked(cur, exercise_ids: Sequence[int], *, batch: int = _PROFILE_LOAD_BATCH) -> Dict[int, List[str]]:
ids = sorted({int(x) for x in exercise_ids if int(x) > 0})
out: Dict[int, List[str]] = {eid: [] for eid in ids}
if not ids:
return out
for i in range(0, len(ids), batch):
chunk = ids[i : i + batch]
ph = ",".join(["%s"] * len(chunk))
cur.execute(
f"""
SELECT exercise_id, variant_name FROM exercise_variants
WHERE exercise_id IN ({ph})
ORDER BY sequence_order ASC NULLS LAST, id ASC
""",
chunk,
)
for row in cur.fetchall():
eid = int(row["exercise_id"])
name = str(row.get("variant_name") or "").strip()
if name:
out.setdefault(eid, []).append(name[:80])
return out
def rank_visible_library_hits(
cur,
rows: Sequence[Dict[str, Any]],
@ -151,6 +194,12 @@ def rank_visible_library_hits(
anchor_id = pack.get("anchor_exercise_id")
progression_notes = pack.get("progression_edge_notes") or {}
requires_partner = pack.get("requires_partner")
semantic_brief_raw = pack.get("semantic_brief")
semantic_brief: Optional[PlanningSemanticBrief] = None
if isinstance(semantic_brief_raw, PlanningSemanticBrief):
semantic_brief = semantic_brief_raw
step_phase = pack.get("path_step_phase")
path_mode = pack.get("context_mode") == "progression_path"
last_planned_skills: Set[int] = set()
planned_ids = pack.get("planned_exercise_ids") or []
@ -175,6 +224,11 @@ def rank_visible_library_hits(
cand_ids = [int(r["id"]) for r in cand_rows]
match_profiles = _load_match_profiles_chunked(cur, cand_ids)
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:
goals_by_ex = _load_exercise_goals_chunked(cur, cand_ids)
variants_by_ex = _load_variant_names_chunked(cur, cand_ids)
max_ft = 0.0
scored_items: List[Dict[str, Any]] = []
@ -213,17 +267,52 @@ def rank_visible_library_hits(
emp, target, intent=intent
)
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, ""),
variant_names=variants_by_ex.get(eid, []),
brief=semantic_brief,
step_phase=step_phase,
)
if (
path_mode
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, ""),
brief=semantic_brief,
strict=True,
)
):
score_penalty = 0.42
else:
score_penalty = 0.0
score = (
weights["fulltext"] * ft_norm
weights.get("semantic", 0.0) * semantic_score
+ weights["fulltext"] * ft_norm
+ weights["progression"] * prog_hit
+ weights["skill"] * skill_sim
+ weights["plan"] * plan_aff
+ weights["profile"] * profile_score
+ weights["repeat_unit"] * repeat_unit
+ weights["repeat_group"] * repeat_group
- score_penalty
)
reasons: List[str] = []
if semantic_score >= 0.35 and semantic_reasons:
for sr in semantic_reasons:
if sr not in reasons:
reasons.append(sr)
if query and ft_norm >= 0.35:
reasons.append("Volltext-Treffer")
if prog_hit > 0:
@ -255,6 +344,7 @@ def rank_visible_library_hits(
"focus_area": row.get("primary_focus_name"),
"score": round(max(0.0, min(1.0, score)), 4),
"reasons": reasons,
"semantic_score": round(semantic_score, 4),
}
)
succ_variants = pack.get("progression_successor_variants") or {}
@ -283,7 +373,7 @@ def run_multistage_planning_retrieval(
cur,
vis_sql=vis_sql,
vis_params=vis_params,
query=query,
query=pack.get("retrieval_query") or query,
exercise_kind_any=exercise_kind_any,
)
hits, skills_by_ex = rank_visible_library_hits(

View File

@ -0,0 +1,717 @@
"""
Planungs-KI Phase E: Semantik-Schicht für Anfrage-Verständnis und Retrieval.
Trennt anfrage-spezifische Semantik (Technik, Phrasen, Entwicklungsbogen) vom
Katalog-Profil-Overlay (Fokus/Skills). Wird in Hybrid-Retrieval und Pfad-QA genutzt.
"""
from __future__ import annotations
import json
import logging
import re
from typing import Any, Dict, List, Mapping, Optional, Sequence, Tuple
from pydantic import BaseModel, Field, field_validator
from ai_prompt_runtime import AiPromptUnavailableError, load_and_render_ai_prompt
from exercise_ai import strip_html_to_plain
from openrouter_chat import (
effective_openrouter_model_for_prompt_row,
normalize_openrouter_env,
openrouter_chat_completion,
)
_logger = logging.getLogger("shinkan.planning_exercise_semantics")
_GERI_TECHNIQUES: Tuple[Tuple[str, Tuple[str, ...]], ...] = (
("mae geri", ("mawashi geri", "yoko geri", "ushiro geri", "sakuto geri", "mikazuki geri")),
("mawashi geri", ("mae geri", "yoko geri", "ushiro geri", "sakuto geri")),
("yoko geri", ("mae geri", "mawashi geri", "ushiro geri", "sakuto geri")),
("ushiro geri", ("mae geri", "mawashi geri", "yoko geri", "sakuto geri")),
("sakuto geri", ("mae geri", "mawashi geri", "yoko geri", "mikazuki geri")),
("mikazuki geri", ("mae geri", "mawashi geri", "sakuto geri")),
)
_OTHER_TECHNIQUE_PATTERNS: Tuple[Tuple[str, Tuple[str, ...]], ...] = (
("oi zuki", ("gyaku zuki", "age uke", "gedan barai")),
("gyaku zuki", ("oi zuki", "mae geri")),
("age uke", ("gedan barai", "soto uke")),
("gedan barai", ("age uke", "soto uke")),
)
_TECHNIQUE_EXPECTED_SKILLS: Dict[str, Tuple[str, ...]] = {
"mae geri": ("Geri Waza", "Koordination", "Gleichgewicht", "Kime"),
"mawashi geri": ("Geri Waza", "Koordination", "Gleichgewicht"),
"yoko geri": ("Geri Waza", "Koordination", "Gleichgewicht"),
"ushiro geri": ("Geri Waza", "Koordination", "Gleichgewicht"),
"sakuto geri": ("Geri Waza", "Koordination", "Gleichgewicht"),
"mikazuki geri": ("Geri Waza", "Koordination", "Gleichgewicht"),
}
_DEFAULT_TECHNIQUE_SKILLS: Tuple[str, ...] = ("Geri Waza", "Koordination", "Gleichgewicht")
_ARC_PHASES: Tuple[Tuple[str, Tuple[str, ...]], ...] = (
("einstieg", ("einstieg", "erlernen", "lernen", "anfänger", "anfaenger", "beginn", "grund")),
("grundlage", ("grundlage", "fundament", "basis", "basic")),
("vertiefung", ("vertief", "festigung", "übung", "uebung", "wiederhol")),
("anwendung", ("anwend", "partner", "kampf", "kumite", "reaktion")),
("perfektion", ("perfekt", "meisterschaft", "höchst", "hoechst", "kime", "sauber")),
)
_PHASE_QUERY_HINTS: Dict[str, str] = {
"einstieg": "einstieg grundübung einfach",
"grundlage": "grundtechnik festigung",
"vertiefung": "vertiefung technik übung",
"anwendung": "anwendung partner variante",
"perfektion": "perfektion kontrolle kime höchste stufe",
}
_QUERY_STOPWORDS = frozenset(
{
"von",
"bis",
"zur",
"zum",
"der",
"die",
"das",
"des",
"den",
"dem",
"ein",
"eine",
"einer",
"eines",
"und",
"oder",
"mit",
"für",
"fuer",
"im",
"in",
"am",
"an",
"auf",
"aus",
"beim",
"nach",
"vor",
"über",
"ueber",
"unter",
"wie",
"was",
"wo",
"wir",
"soll",
"sollen",
"bitte",
"schlage",
"vorschlag",
"übung",
"uebung",
"übungen",
"uebungen",
}
)
class PlanningSemanticBrief(BaseModel):
primary_topic: Optional[str] = Field(default=None, max_length=120)
topic_type: str = Field(default="general", max_length=40)
must_phrases: List[str] = Field(default_factory=list)
exclude_phrases: List[str] = Field(default_factory=list)
development_arc: List[str] = Field(default_factory=list)
retrieval_query: str = Field(default="", max_length=500)
semantic_strength: float = Field(default=0.0, ge=0.0, le=1.0)
rationale: Optional[str] = Field(default=None, max_length=400)
@field_validator("topic_type")
@classmethod
def _topic_type(cls, v: str) -> str:
s = (v or "general").strip().lower()
return s if s in {"general", "technique", "focus", "method", "skill"} else "general"
@field_validator("must_phrases", "exclude_phrases", "development_arc", mode="before")
@classmethod
def _norm_phrase_list(cls, v: Any) -> List[str]:
if not v:
return []
if isinstance(v, str):
s = _normalize_phrase(v)
return [s] if s else []
out: List[str] = []
for item in v:
s = _normalize_phrase(str(item or ""))
if s and s not in out:
out.append(s[:120])
return out[:12]
def _normalize_phrase(text: str) -> str:
return re.sub(r"\s+", " ", (text or "").strip().lower())
def _normalize_query(text: str) -> str:
return re.sub(r"\s+", " ", (text or "").strip())
def _extract_json_object(text: str) -> Dict[str, Any]:
s = (text or "").strip()
if s.startswith("```"):
s = re.sub(r"^```[a-zA-Z0-9]*\s*", "", s)
if s.endswith("```"):
s = s[:-3].strip()
start = s.find("{")
end = s.rfind("}")
if start < 0 or end <= start:
raise ValueError("Kein JSON-Objekt in LLM-Antwort")
obj = json.loads(s[start : end + 1])
if not isinstance(obj, dict):
raise ValueError("LLM-Antwort ist kein JSON-Objekt")
return obj
def _find_technique_in_text(q_lower: str) -> Optional[Tuple[str, Tuple[str, ...]]]:
for primary, excludes in _GERI_TECHNIQUES + _OTHER_TECHNIQUE_PATTERNS:
if primary in q_lower:
return primary, excludes
return None
def _detect_development_arc(q_lower: str) -> List[str]:
found: List[str] = []
for phase, markers in _ARC_PHASES:
if any(m in q_lower for m in markers):
if phase not in found:
found.append(phase)
if not found and ("von" in q_lower and "bis" in q_lower):
found = ["einstieg", "perfektion"]
return found
def _keyword_phrases_from_query(query: str) -> List[str]:
q = _normalize_query(query).lower()
tokens = re.findall(r"[a-zäöüß]{3,}", q, flags=re.IGNORECASE)
phrases: List[str] = []
for i, tok in enumerate(tokens):
low = tok.lower()
if low in _QUERY_STOPWORDS:
continue
if i + 1 < len(tokens):
nxt = tokens[i + 1].lower()
if nxt not in _QUERY_STOPWORDS:
pair = _normalize_phrase(f"{low} {nxt}")
if len(pair) >= 5 and pair not in phrases:
phrases.append(pair)
if len(low) >= 4 and low not in phrases:
phrases.append(low)
return phrases[:6]
def build_semantic_brief(query: Optional[str]) -> PlanningSemanticBrief:
"""Deterministisches Anfrage-Verständnis — ohne LLM."""
q = _normalize_query(query)
if not q:
return PlanningSemanticBrief(retrieval_query="", semantic_strength=0.0)
q_lower = q.lower()
must: List[str] = []
exclude: List[str] = []
topic_type = "general"
primary: Optional[str] = None
strength = 0.25
technique = _find_technique_in_text(q_lower)
if technique:
primary, ex = technique
must.append(primary)
exclude.extend(list(ex))
topic_type = "technique"
strength = max(strength, 0.82)
arc = _detect_development_arc(q_lower)
if arc:
strength = max(strength, 0.55 if technique else 0.45)
# Keine generischen Stichwörter in must_phrases — sonst verwässert das Scoring.
retrieval_parts = list(must)
if primary:
retrieval_parts.append(primary)
if arc:
retrieval_parts.extend(arc[:2])
retrieval = " ".join(dict.fromkeys(retrieval_parts))[:500] if retrieval_parts else q
if len(q) >= 24 and not technique:
strength = max(strength, 0.4)
return PlanningSemanticBrief(
primary_topic=primary,
topic_type=topic_type,
must_phrases=must[:8],
exclude_phrases=exclude[:10],
development_arc=arc[:5],
retrieval_query=retrieval[:500],
semantic_strength=min(1.0, round(strength, 3)),
rationale=None,
)
def merge_semantic_brief_llm(
base: PlanningSemanticBrief,
llm_obj: Mapping[str, Any],
) -> PlanningSemanticBrief:
"""LLM-Enrichment in deterministisches Brief mergen (LLM ergänzt, ersetzt nicht harte Technik-Regeln)."""
data = base.model_dump()
for key in ("primary_topic", "topic_type", "rationale"):
val = llm_obj.get(key)
if val:
data[key] = val
for key in ("must_phrases", "exclude_phrases", "development_arc"):
extra = llm_obj.get(key) or []
merged = list(data.get(key) or [])
for item in extra:
s = _normalize_phrase(str(item or ""))
if s and s not in merged:
merged.append(s)
data[key] = merged[:12]
llm_strength = llm_obj.get("semantic_strength")
if llm_strength is not None:
try:
data["semantic_strength"] = min(
1.0,
max(float(data["semantic_strength"]), float(llm_strength)),
)
except (TypeError, ValueError):
pass
if data.get("must_phrases"):
core = semantic_core_phrases(PlanningSemanticBrief.model_validate(data))
data["retrieval_query"] = " ".join(core[:4])[:500] if core else data.get("retrieval_query", "")
out = PlanningSemanticBrief.model_validate(data)
if out.primary_topic and out.topic_type == "general":
out = out.model_copy(update={"topic_type": "technique"})
return out
def try_enrich_semantic_brief_with_llm(
cur,
query: str,
base: PlanningSemanticBrief,
) -> Tuple[PlanningSemanticBrief, bool]:
api_key, _ = normalize_openrouter_env()
if not api_key or base.semantic_strength < 0.35:
return base, False
if not (query or "").strip():
return base, False
variables = {
"search_query": (query or "").strip(),
"semantic_brief_json": json.dumps(brief_to_summary_dict(base), ensure_ascii=False),
}
try:
prow, rendered = load_and_render_ai_prompt(cur, "planning_exercise_query_semantics", variables)
model = effective_openrouter_model_for_prompt_row(prow)
raw = openrouter_chat_completion(api_key=api_key, model=model, user_content=rendered.text)
obj = _extract_json_object(raw)
return merge_semantic_brief_llm(base, obj), True
except AiPromptUnavailableError:
return base, False
except Exception as exc:
_logger.warning("Semantik-LLM fehlgeschlagen: %s", exc)
return base, False
def brief_to_summary_dict(brief: PlanningSemanticBrief) -> Dict[str, Any]:
return {
"primary_topic": brief.primary_topic,
"topic_type": brief.topic_type,
"must_phrases": list(brief.must_phrases),
"exclude_phrases": list(brief.exclude_phrases),
"development_arc": list(brief.development_arc),
"retrieval_query": brief.retrieval_query,
"semantic_strength": brief.semantic_strength,
"rationale": brief.rationale,
}
def step_phase_for_index(brief: PlanningSemanticBrief, step_index: int, max_steps: int) -> Optional[str]:
arc = list(brief.development_arc or [])
if not arc:
if max_steps <= 1:
return None
default_arc = ["einstieg", "grundlage", "vertiefung", "anwendung", "perfektion"]
arc = default_arc[:max_steps] if brief.semantic_strength >= 0.5 else []
if not arc:
return None
if len(arc) == 1:
return arc[0]
pos = step_index / max(max_steps - 1, 1)
idx = min(len(arc) - 1, int(round(pos * (len(arc) - 1))))
return arc[idx]
def step_retrieval_query(
brief: PlanningSemanticBrief,
goal_query: str,
step_index: int,
max_steps: int,
) -> str:
phase = step_phase_for_index(brief, step_index, max_steps)
parts: List[str] = []
if brief.primary_topic:
parts.append(brief.primary_topic)
elif brief.retrieval_query:
parts.append(brief.retrieval_query.split()[0] if brief.retrieval_query else "")
if phase:
parts.append(phase)
if not parts and brief.retrieval_query:
parts.append(brief.retrieval_query)
elif not parts and goal_query:
parts.append(goal_query)
return _normalize_query(" ".join(p for p in parts if p)) or _normalize_query(goal_query)
def apply_dynamic_retrieval_weights(
base_weights: Mapping[str, float],
brief: PlanningSemanticBrief,
*,
scenario: str,
has_planning_reference: bool,
) -> Dict[str, float]:
"""Semantik-Kanal dynamisch gegen Profil/Plan abwägen."""
out = dict(base_weights)
sem = float(brief.semantic_strength or 0.0)
if sem <= 0.05:
out.setdefault("semantic", 0.0)
return out
query_driven = scenario == "free_search" or not has_planning_reference
sem_weight = 0.12 + sem * (0.38 if query_driven else 0.22)
out["semantic"] = round(sem_weight, 4)
if query_driven:
scale = 1.0 - sem * 0.35
out["fulltext"] = round(float(out.get("fulltext", 0.18)) * scale, 4)
out["profile"] = round(float(out.get("profile", 0.22)) * (1.0 - sem * 0.25), 4)
else:
out["fulltext"] = round(float(out.get("fulltext", 0.18)) * (1.0 - sem * 0.15), 4)
total = sum(v for k, v in out.items() if k not in {"repeat_unit", "repeat_group"} and v > 0)
if total > 0.92:
factor = 0.88 / total
for k in list(out.keys()):
if k in {"repeat_unit", "repeat_group"}:
continue
if out[k] > 0:
out[k] = round(out[k] * factor, 4)
return out
def _blob_from_fields(
title: str,
summary: str,
goal: str,
variant_names: Sequence[str],
) -> str:
parts = [title or "", strip_html_to_plain(summary, max_len=600), strip_html_to_plain(goal, max_len=800)]
parts.extend(variant_names or [])
return " ".join(p for p in parts if p).lower()
def _compact_alpha(text: str) -> str:
return re.sub(r"[^a-z0-9äöüß]+", "", (text or "").lower())
def _phrase_in_blob(phrase: str, blob: str) -> bool:
ph = _normalize_phrase(phrase)
if not ph or not blob:
return False
low = blob.lower()
if ph in low:
return True
if _compact_alpha(ph) and _compact_alpha(ph) in _compact_alpha(low):
return True
if " " not in ph:
return bool(re.search(rf"\b{re.escape(ph)}\b", low))
return ph in low
def score_exercise_semantic_relevance(
*,
title: str,
summary: str,
goal: str,
variant_names: Sequence[str],
brief: PlanningSemanticBrief,
step_phase: Optional[str] = None,
) -> Tuple[float, List[str]]:
if brief.semantic_strength <= 0.05:
return 0.0, []
blob = _blob_from_fields(title, summary, goal, variant_names)
if not blob.strip():
return 0.0, []
reasons: List[str] = []
must = list(brief.must_phrases or [])
exclude = list(brief.exclude_phrases or [])
core = semantic_core_phrases(brief)
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))
score = 0.0
if core:
core_ratio = core_hits / len(core)
score += 0.62 * core_ratio
if core_hits == len(core):
reasons.append("Kern-Thema der Anfrage im Übungstext")
elif core_hits > 0:
reasons.append("Teilweise passend zum Kern-Thema")
elif brief.primary_topic and _phrase_in_blob(brief.primary_topic, blob):
score += 0.55
reasons.append(f"Thema „{brief.primary_topic}“ im Übungstext")
if must and core != must:
extra_ratio = must_hits / len(must)
score += 0.12 * extra_ratio
primary_ok = bool(core_hits) or (
brief.primary_topic and _phrase_in_blob(brief.primary_topic, blob)
)
if exclude_hits > 0 and not primary_ok:
penalty = min(0.65, 0.22 * exclude_hits)
score -= penalty
reasons.append("Enthält ausgeschlossene Nebenthemen")
elif exclude_hits > 0 and primary_ok:
score -= min(0.12, 0.06 * exclude_hits)
if step_phase and step_phase in _PHASE_QUERY_HINTS:
phase_markers = next((markers for phase, markers in _ARC_PHASES if phase == step_phase), ())
if any(m in blob for m in phase_markers) or step_phase in blob:
score += 0.12
reasons.append(f"Passt zur Pfad-Phase „{step_phase}")
if brief.development_arc and not step_phase:
arc_hits = sum(1 for phase in brief.development_arc if phase in blob)
if arc_hits:
score += min(0.15, 0.05 * arc_hits)
return max(0.0, min(1.0, round(score, 4))), reasons[:4]
def semantic_core_phrases(brief: PlanningSemanticBrief) -> List[str]:
"""Harte Kernphrasen fürs Matching."""
if brief.primary_topic:
return [_normalize_phrase(brief.primary_topic)]
core = [_normalize_phrase(p) for p in (brief.must_phrases or [])[:2] if p]
return [p for p in core if p]
def resolve_semantic_skill_weights(cur, brief: PlanningSemanticBrief) -> Dict[int, float]:
"""Deterministisches Fähigkeitserwartungsprofil aus Technik-Thema."""
topic = _normalize_phrase(brief.primary_topic or "")
if topic in _TECHNIQUE_EXPECTED_SKILLS:
names = list(_TECHNIQUE_EXPECTED_SKILLS[topic])
elif brief.topic_type == "technique" or "geri" in topic:
names = list(_DEFAULT_TECHNIQUE_SKILLS)
else:
return {}
weights: Dict[int, float] = {}
for name in names[:6]:
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 row:
sid = int(row["id"])
weights[sid] = max(weights.get(sid, 0.0), 1.0)
return weights
def enrich_target_with_semantic_expectations(
target,
*,
skill_weights: Dict[int, float],
):
from planning_exercise_profiles import PlanningTargetProfile, _merge_weight_maps, _normalize_weight_map
if not skill_weights:
return target
merged = _normalize_weight_map(_merge_weight_maps(dict(target.skill_weights), skill_weights, scale=1.0))
sources = list(target.sources)
if "semantic_expectation" not in sources:
sources.append("semantic_expectation")
return PlanningTargetProfile(
focus_area_ids=dict(target.focus_area_ids),
style_direction_ids=dict(target.style_direction_ids),
training_type_ids=dict(target.training_type_ids),
target_group_ids=dict(target.target_group_ids),
skill_weights=merged,
skill_gap_weights=dict(target.skill_gap_weights),
skill_plan_weights=dict(target.skill_plan_weights),
sources=sources,
)
def apply_path_retrieval_weights(brief: PlanningSemanticBrief) -> Dict[str, float]:
"""Pfad-Builder: Semantik + Profil dominieren."""
sem = float(brief.semantic_strength or 0.0)
if sem >= 0.65:
return {
"semantic": 0.50,
"fulltext": 0.16,
"profile": 0.26,
"progression": 0.04,
"skill": 0.04,
"plan": 0.0,
"repeat_unit": -0.40,
"repeat_group": -0.15,
}
if sem >= 0.35:
return {
"semantic": 0.38,
"fulltext": 0.18,
"profile": 0.28,
"progression": 0.06,
"skill": 0.06,
"plan": 0.04,
"repeat_unit": -0.35,
"repeat_group": -0.15,
}
return {
"semantic": 0.22,
"fulltext": 0.22,
"profile": 0.28,
"progression": 0.10,
"skill": 0.10,
"plan": 0.08,
"repeat_unit": -0.30,
"repeat_group": -0.15,
}
def exercise_passes_path_semantic_gate(
*,
semantic_score: float,
title: str,
brief: PlanningSemanticBrief,
summary: str = "",
goal: str = "",
strict: bool = True,
) -> bool:
if brief.semantic_strength < 0.55:
return True
blob = _blob_from_fields(title, summary, goal, [])
min_score = 0.18 if strict else 0.06
if semantic_score >= min_score:
return True
topic = brief.primary_topic or ""
if topic and _phrase_in_blob(topic, blob):
return True
if not strict:
# Mae Geri oft im Fließtext, nicht im Titel
if semantic_score >= 0.04 and topic and _phrase_in_blob(topic, blob):
return True
parts = topic.split()
if len(parts) >= 2 and all(_phrase_in_blob(p, blob) for p in parts):
return True
return False
def pick_best_path_hit(
hits: List[Dict[str, Any]],
used_exercise_ids: Set[int],
*,
semantic_brief: Optional[PlanningSemanticBrief] = None,
) -> Optional[Dict[str, Any]]:
"""Gestufte Auswahl: strikt → relaxed → bester Semantik-Score."""
if not hits:
return None
def _scan(*, strict: bool) -> Optional[Dict[str, Any]]:
best: Optional[Dict[str, Any]] = None
best_key: Tuple[float, float] = (-1.0, -1.0)
for hit in hits:
eid = int(hit["id"])
if eid in used_exercise_ids:
continue
sem = float(hit.get("semantic_score") or 0.0)
if semantic_brief and not exercise_passes_path_semantic_gate(
semantic_score=sem,
title=str(hit.get("title") or ""),
summary=str(hit.get("summary") or ""),
goal="",
brief=semantic_brief,
strict=strict,
):
continue
score = float(hit.get("score") or 0.0)
key = (sem, score)
if key > best_key:
best_key = key
best = hit
return best
chosen = _scan(strict=True)
if chosen:
return chosen
chosen = _scan(strict=False)
if chosen:
return chosen
# Notfall: bester verbleibender Treffer mit Semantik > 0 (Thema trotzdem priorisieren)
fallback: Optional[Dict[str, Any]] = None
fallback_key: Tuple[float, float] = (-1.0, -1.0)
for hit in hits:
eid = int(hit["id"])
if eid in used_exercise_ids:
continue
sem = float(hit.get("semantic_score") or 0.0)
score = float(hit.get("score") or 0.0)
if sem <= 0 and semantic_brief and semantic_brief.primary_topic:
topic = semantic_brief.primary_topic
blob = (str(hit.get("title") or "") + " " + str(hit.get("summary") or "")).lower()
if not _phrase_in_blob(topic, blob):
continue
key = (sem, score)
if key > fallback_key:
fallback_key = key
fallback = hit
return fallback
__all__ = [
"PlanningSemanticBrief",
"apply_dynamic_retrieval_weights",
"apply_path_retrieval_weights",
"brief_to_summary_dict",
"build_semantic_brief",
"enrich_target_with_semantic_expectations",
"exercise_passes_path_semantic_gate",
"merge_semantic_brief_llm",
"pick_best_path_hit",
"resolve_semantic_skill_weights",
"score_exercise_semantic_relevance",
"semantic_core_phrases",
"step_phase_for_index",
"step_retrieval_query",
"try_enrich_semantic_brief_with_llm",
]

View File

@ -21,6 +21,14 @@ from planning_exercise_target_pipeline import (
compose_retrieval_phase,
should_run_llm_rank_pipeline,
)
from planning_exercise_semantics import (
PlanningSemanticBrief,
apply_dynamic_retrieval_weights,
brief_to_summary_dict,
build_semantic_brief,
step_retrieval_query,
try_enrich_semantic_brief_with_llm,
)
# Planungs-Berechtigung + Sektionen (bestehende Implementierung)
from routers.training_planning import (
@ -89,6 +97,7 @@ def resolve_planning_exercise_intent(query: Optional[str], intent_hint: Optional
def _intent_weights(intent: str) -> Dict[str, float]:
base = {
"fulltext": 0.18,
"semantic": 0.0,
"progression": 0.18,
"skill": 0.12,
"plan": 0.08,
@ -714,12 +723,25 @@ def suggest_planning_exercises(
context_summary=pipeline_context,
has_planning_reference=has_plan_ref,
)
weights = _intent_weights(intent)
target_profile_summary = target_profile.to_summary_dict(cur)
query_intent_applied = bool(query_intent_summary.get("llm_applied"))
llm_expectation_applied = bool(query_intent_summary.get("llm_expectation_applied"))
profile_llm_applied = bool(query_intent_summary.get("profile_llm_applied"))
semantic_brief = build_semantic_brief(query)
semantic_llm_applied = False
if body.include_llm_intent and semantic_brief.semantic_strength >= 0.35:
semantic_brief, semantic_llm_applied = try_enrich_semantic_brief_with_llm(
cur, query, semantic_brief
)
weights = apply_dynamic_retrieval_weights(
_intent_weights(intent),
semantic_brief,
scenario=scenario_kind,
has_planning_reference=has_plan_ref,
)
profile_id = tenant.profile_id
role = tenant.global_role
vis_sql, vis_params = library_content_visibility_sql(
@ -741,6 +763,8 @@ def suggest_planning_exercises(
pack={
**pack,
"requires_partner": query_intent_summary.get("requires_partner"),
"semantic_brief": semantic_brief,
"retrieval_query": semantic_brief.retrieval_query or query,
},
)
@ -755,6 +779,7 @@ def suggest_planning_exercises(
query_intent=query_intent_applied,
llm_expectation=llm_expectation_applied,
llm_rank=False,
semantics=semantic_brief.semantic_strength >= 0.35,
)
run_llm_rank = should_run_llm_rank_pipeline(
query,
@ -792,6 +817,7 @@ def suggest_planning_exercises(
query_intent=query_intent_applied,
llm_expectation=llm_expectation_applied,
llm_rank=True,
semantics=semantic_brief.semantic_strength >= 0.35,
)
tail = hits[pre_limit:]
hits = pool_hits + tail
@ -837,6 +863,8 @@ def suggest_planning_exercises(
"llm_intent_applied": query_intent_applied,
"llm_expectation_applied": llm_expectation_applied,
"profile_llm_applied": profile_llm_applied,
"semantic_brief_summary": brief_to_summary_dict(semantic_brief),
"semantic_llm_applied": semantic_llm_applied,
"intent_resolved": intent,
"intent_heuristic": heuristic_intent,
"query_normalized": query or None,

View File

@ -430,12 +430,15 @@ def compose_retrieval_phase(
query_intent: bool = False,
llm_expectation: bool = False,
llm_rank: bool = False,
semantics: bool = False,
) -> str:
parts = ["profile_v1"]
if full_library or profile_preselect:
parts.append("full_library")
if text_signals:
parts.append("text_signals")
if semantics:
parts.append("semantics")
if llm_expectation:
parts.append("llm_expectation")
elif query_intent:

View File

@ -6,6 +6,7 @@ from fastapi import APIRouter, Depends
from db import get_db, get_cursor
from tenant_context import TenantContext, get_tenant_context
from planning_exercise_suggest import PlanningExerciseSuggestRequest, suggest_planning_exercises
from planning_exercise_path_builder import ProgressionPathSuggestRequest, suggest_progression_path
router = APIRouter(prefix="/api/planning", tags=["planning_exercise_suggest"])
@ -18,3 +19,13 @@ def post_planning_exercise_suggest(
with get_db() as conn:
cur = get_cursor(conn)
return suggest_planning_exercises(cur, tenant=tenant, body=body)
@router.post("/progression-path-suggest")
def post_progression_path_suggest(
body: ProgressionPathSuggestRequest,
tenant: TenantContext = Depends(get_tenant_context),
):
with get_db() as conn:
cur = get_cursor(conn)
return suggest_progression_path(cur, tenant=tenant, body=body)

View File

@ -0,0 +1,25 @@
"""Tests Planungs-KI Phase C3/E — Pfad-Vorschläge."""
from planning_exercise_path_builder import _pick_best_path_hit, _hit_to_path_step
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
assert _pick_best_path_hit(hits, {1, 2, 3}) is None
def test_hit_to_path_step_maps_variant():
step = _hit_to_path_step(
{
"id": 10,
"title": "Test",
"score": 0.8,
"reasons": ["Graph"],
"suggested_variant_id": 7,
"suggested_variant_name": "Leicht",
"variants": [{"id": 7, "variant_name": "Leicht"}],
}
)
assert step["exercise_id"] == 10
assert step["variant_id"] == 7
assert step["suggested_variant_name"] == "Leicht"

View File

@ -0,0 +1,69 @@
"""Tests Planungs-KI Phase E — Pfad-QA."""
from planning_exercise_path_builder import _pick_best_path_hit
from planning_exercise_semantics import build_semantic_brief
from planning_exercise_path_qa import apply_llm_path_reorder
def test_pick_best_path_hit_prefers_semantic_score():
brief = build_semantic_brief("Mae Geri Perfektion")
hits = [
{"id": 1, "title": "Mawashi", "score": 0.9, "semantic_score": 0.1},
{"id": 2, "title": "Mae Geri", "score": 0.75, "semantic_score": 0.85},
]
chosen = _pick_best_path_hit(hits, set(), semantic_brief=brief)
assert chosen["id"] == 2
def test_phrase_compact_match_maegeri():
from planning_exercise_semantics import _phrase_in_blob
assert _phrase_in_blob("mae geri", "Erlernen des Mae-Geri aus Einzelbewegungen")
assert _phrase_in_blob("mae geri", "Maegeri Kihon")
def test_pick_best_path_hit_fallback_title_only_in_summary():
from planning_exercise_semantics import pick_best_path_hit
brief = build_semantic_brief("Mae Geri Perfektion")
hits = [
{
"id": 1,
"title": "Kumite Stellungen",
"summary": "",
"score": 0.9,
"semantic_score": 0.02,
},
{
"id": 2,
"title": "Einzelbewegungen",
"summary": "Schrittweise Erlernen des Mae Geri",
"score": 0.5,
"semantic_score": 0.08,
},
]
chosen = pick_best_path_hit(hits, set(), semantic_brief=brief)
assert chosen is not None
assert int(chosen["id"]) == 2
def test_pick_best_path_hit_skips_used():
hits = [{"id": 1, "title": "A", "score": 0.5, "semantic_score": 0.5}]
assert _pick_best_path_hit(hits, {1}) is None
def test_apply_llm_path_reorder_permutation():
steps = [{"exercise_id": 1}, {"exercise_id": 2}, {"exercise_id": 3}]
reordered, applied, notes = apply_llm_path_reorder(
steps,
{"ordered_step_indices": [0, 2, 1], "sequence_notes": ["Vertiefung vor Anwendung"]},
)
assert applied is True
assert [s["exercise_id"] for s in reordered] == [1, 3, 2]
assert notes
def test_apply_llm_path_reorder_invalid_ignored():
steps = [{"exercise_id": 1}, {"exercise_id": 2}]
reordered, applied, _ = apply_llm_path_reorder(steps, {"ordered_step_indices": [0, 0]})
assert applied is False
assert reordered == steps

View File

@ -0,0 +1,68 @@
"""Tests Planungs-KI Phase E — Semantik-Schicht."""
from planning_exercise_semantics import (
apply_dynamic_retrieval_weights,
build_semantic_brief,
score_exercise_semantic_relevance,
step_retrieval_query,
)
def test_build_semantic_brief_mae_geri():
brief = build_semantic_brief(
"Von Erlernen bis zur Perfektion, des Fußtritts Mae Geri"
)
assert brief.primary_topic == "mae geri"
assert brief.must_phrases == ["mae geri"]
assert "mawashi geri" in brief.exclude_phrases
assert "perfektion" not in brief.must_phrases
assert brief.semantic_strength >= 0.8
def test_semantic_score_prefers_mae_over_mawashi():
brief = build_semantic_brief("Mae Geri Perfektion")
mae_score, _ = score_exercise_semantic_relevance(
title="Mae Geri — Frontkick Grundstellung",
summary="Frontkick von vorn",
goal="Sauberer Mae Geri",
variant_names=[],
brief=brief,
)
mawashi_score, _ = score_exercise_semantic_relevance(
title="Mawashi Geri — Rundkick",
summary="Rundkick Technik",
goal="Mawashi Geri Höhe",
variant_names=[],
brief=brief,
)
assert mae_score > mawashi_score
def test_dynamic_weights_boost_semantic_for_query_only():
brief = build_semantic_brief("Mae Geri bis Perfektion")
base = {
"fulltext": 0.45,
"semantic": 0.0,
"progression": 0.08,
"skill": 0.08,
"plan": 0.08,
"profile": 0.15,
"repeat_unit": -0.3,
"repeat_group": -0.15,
}
out = apply_dynamic_retrieval_weights(
base,
brief,
scenario="free_search",
has_planning_reference=False,
)
assert out["semantic"] > 0.25
assert out["fulltext"] < base["fulltext"]
def test_step_retrieval_query_carries_topic_and_phase():
brief = build_semantic_brief("Mae Geri von Einstieg bis Perfektion")
q0 = step_retrieval_query(brief, brief.retrieval_query, 0, 5)
q4 = step_retrieval_query(brief, brief.retrieval_query, 4, 5)
assert "mae geri" in q0.lower()
assert "mae geri" in q4.lower()
assert "einstieg grundübung" not in q0.lower()

View File

@ -1,6 +1,6 @@
# Shinkan Jinkendo Version Information
APP_VERSION = "0.8.184"
APP_VERSION = "0.8.189"
BUILD_DATE = "2026-05-23"
DB_SCHEMA_VERSION = "20260531074"
@ -29,7 +29,7 @@ MODULE_VERSIONS = {
"skill_profiles": "1.0.0", # Phase 3: gewichtetes Fähigkeiten-Profil + skill-discovery/suggestions
"methods": "0.1.0",
"exercises": "2.37.0", # Planungs-KI P1: Szenario-Pipeline + Query-Intent-Overlay
"planning_exercise_suggest": "0.12.0", # Phase C2: Varianten in Treffern + Übernahme
"planning_exercise_suggest": "0.15.2", # Pfad-Gate: soft penalty + gestufter Fallback
"training_units": "0.4.0", # POST .../publish-to-framework: Ablauf aus geplanter Einheit → Rahmen-Slot-Blueprint
"training_programs": "0.1.0",
"planning": "0.15.0", # Vorlagen: Strukturvorschau, Bearbeiten inkl. Split-Sessions + Beschreibung
@ -44,6 +44,46 @@ MODULE_VERSIONS = {
}
CHANGELOG = [
{
"version": "0.8.189",
"date": "2026-05-23",
"changes": [
"Fix Pfad-Builder: Semantik-Gate zu strikt — soft penalty statt Hard-Filter, gestufter Fallback.",
"Mae-Geri-Matching: Titel/Summary/Goal, Mae-Geri/Maegeri-Schreibweisen.",
],
},
{
"version": "0.8.188",
"date": "2026-05-23",
"changes": [
"Fix Pfad-Builder: Mae-Geri-Thema — kein Skill-Profil ab Schritt 2, verwässerte must_phrases.",
"Pfad-Retrieval: hartes Semantik-Gate, Geri-Waza/Koordination/Gleichgewicht-Erwartungsprofil, QS-Reorder nur bei ok.",
],
},
{
"version": "0.8.187",
"date": "2026-05-23",
"changes": [
"Planungs-KI Phase E2: LLM-Pfad-QS kann Reihenfolge per ordered_step_indices anpassen.",
"Unüberbrückbare Lücken: KI-Neuanlage-Vorschläge (is_ai_proposal) statt nur Bibliotheks-Brücken.",
],
},
{
"version": "0.8.186",
"date": "2026-05-23",
"changes": [
"Planungs-KI Phase E: Semantic Brief (Technik-Phrasen, Ausschlüsse, Entwicklungsbogen) im Hybrid-Retrieval.",
"Pfad-Builder: semantische Schritte, Lücken-Erkennung, Brücken-Übungen, optional LLM-Pfad-QS.",
],
},
{
"version": "0.8.185",
"date": "2026-05-23",
"changes": [
"Planungs-KI Phase C3: POST /api/planning/progression-path-suggest — Ziel → aufbauender Übungspfad.",
"Progressionsgraph-UI: KI-Pfad-Builder mit Review und Speichern via edges/sequence.",
],
},
{
"version": "0.8.184",
"date": "2026-05-23",

View File

@ -1,7 +1,7 @@
# Shinkan Jinkendo Entwicklungsstand & Handover
**Stand:** 2026-05-23
**App-Version / DB-Schema:** App **`0.8.184`** (Planungs-KI Phase C2); DB **`20260531074`** — maßgeblich **`backend/version.py`**.
**App-Version / DB-Schema:** App **`0.8.187`** (Planungs-KI Phase E2); DB **`20260531074`** — maßgeblich **`backend/version.py`**.
Diese Datei ist die **Einstiegs-Doku für neue Chat-Sessions**: Anforderungen im Detail stehen in `.claude/docs/` (siehe unten); hier der **implementierte Stand**, **Medien-Meilenstein** und **sinnvolle nächste Schritte**.
@ -103,7 +103,9 @@ Das Schema ist gegenüber dem Code zurück: Migration **`022_skills_schema_compl
| **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 | 🔲 |
| **C3** | Graph-Builder: Ziel → Pfad vorschlagen → in Graph speichern | ✅ **0.8.185** |
| **E** | Semantik-Schicht (Brief, Phrasen-Score) + Pfad-QA (Lücken, Brücken, LLM-QS) | ✅ **0.8.186** |
| **E2** | Pfad-Neuordnung (LLM) + KI-Neuanlage bei unüberbrückbaren Lücken | ✅ **0.8.187** |
| **D** | `planning_context` an `suggestExerciseAi` (Neu-Anlage) | 🔲 |
**Backend:** `planning_exercise_suggest.py`, `planning_exercise_retrieval.py`, `planning_exercise_profiles.py`, `planning_exercise_target_pipeline.py`, `planning_exercise_progression.py` · Router `POST /api/planning/exercise-suggest`
@ -247,10 +249,10 @@ Das Schema ist gegenüber dem Code zurück: Migration **`022_skills_schema_compl
### Planungs-KI (priorisiert)
1. **C3 — Graph-Builder:** Modus „Pfad zum Ziel“ → sequenzielle Vorschläge → `POST …/edges/sequence` nach Review.
2. **Graph-Auswahl UI:** Dropdown neben Auto-Match; Rahmen-Slot mit Default-Graph verknüpfen.
3. **Enrichment:** Skills für Kern-Übungen nachziehen (sonst schwaches Profil-Ranking).
4. **D — Neu-Anlage:** `planning_context_json` an `POST /api/exercises/ai/suggest`.
1. **Graph-Auswahl UI:** Dropdown neben Auto-Match; Rahmen-Slot mit Default-Graph verknüpfen.
2. **Enrichment:** Skills/Tags pro Technik (Feinauflösung statt nur Geri Waza).
3. **D — Neu-Anlage:** `planning_context_json` an `POST /api/exercises/ai/suggest`.
4. **E3:** KI-Vorschlag im UI direkt anlegen (Modal) · Embeddings für Freitext.
### Allgemein

View File

@ -84,6 +84,14 @@ export async function suggestPlanningExercises(body = {}) {
})
}
/** Planungs-KI Phase C3: aufbauender Übungspfad für Progressionsgraphen. */
export async function suggestProgressionPath(body = {}) {
return request('/api/planning/progression-path-suggest', {
method: 'POST',
body: JSON.stringify(body),
})
}
/** Rahmen-Slot → geplante Einheit (tiefe Kopie, origin_framework_slot_id). */
export async function createTrainingUnitFromFrameworkSlot(data) {
return request('/api/training-units/from-framework-slot', {

View File

@ -9,6 +9,7 @@ import SkillProfilePanel from './skills/SkillProfilePanel'
import { useAuth } from '../context/AuthContext'
import { getTenantClubDependencyKey } from '../utils/activeClub'
import ExercisePickerModal from './ExercisePickerModal'
import ExerciseProgressionPathBuilder from './ExerciseProgressionPathBuilder'
import { EXERCISE_VISIBILITY_FIELD_LABEL } from '../constants/exerciseGovernanceLabels'
const VIS_OPTIONS = [
@ -696,6 +697,13 @@ export default function ExerciseProgressionGraphPanel({
defaultExpanded
artifactType="progression_graph"
/>
<ExerciseProgressionPathBuilder
graphId={selectedGraphId}
disabled={busy}
onSaved={async () => {
await refreshEdges(selectedGraphId)
}}
/>
<div className="card" style={{ marginBottom: '12px' }}>
<h3 style={{ marginTop: 0, fontSize: '1rem' }}>Sequenz / Reihe anlegen</h3>
<p style={{ fontSize: '12px', color: 'var(--text3)', marginTop: 0 }}>

View File

@ -0,0 +1,409 @@
/**
* Planungs-KI Phase C3: Ziel Übungspfad vorschlagen in Progressionsgraph speichern.
*/
import React, { useCallback, useState } from 'react'
import api from '../utils/api'
function emptyPathStep() {
return { exerciseId: null, exerciseTitle: '', variantId: null, variants: [], reasons: [] }
}
function mapApiStepToRow(step) {
const variants = Array.isArray(step?.variants) ? step.variants : []
const rawVid = step?.variant_id ?? step?.suggested_variant_id ?? null
const variantId =
rawVid != null && Number.isFinite(Number(rawVid)) && Number(rawVid) > 0 ? Number(rawVid) : null
const isAiProposal = Boolean(step?.is_ai_proposal) || step?.exercise_id == null
return {
exerciseId: step?.exercise_id != null ? Number(step.exercise_id) : null,
proposalKey: step?.proposal_key || null,
exerciseTitle:
(step?.title || '').trim() ||
(step?.exercise_id ? `Übung #${step.exercise_id}` : 'KI-Vorschlag'),
variantId: isAiProposal ? null : variantId,
variants: isAiProposal ? [] : variants,
reasons: Array.isArray(step?.reasons) ? step.reasons : [],
isBridge: Boolean(step?.is_bridge),
isAiProposal,
aiSuggestion: step?.ai_suggestion || null,
semanticScore: step?.semantic_score,
}
}
export default function ExerciseProgressionPathBuilder({
graphId,
disabled = false,
onSaved,
}) {
const [goalQuery, setGoalQuery] = useState('')
const [maxSteps, setMaxSteps] = useState(5)
const [segmentNotes, setSegmentNotes] = useState('')
const [loading, setLoading] = useState(false)
const [saving, setSaving] = useState(false)
const [error, setError] = useState('')
const [targetSummary, setTargetSummary] = useState(null)
const [semanticBrief, setSemanticBrief] = useState(null)
const [pathQa, setPathQa] = useState(null)
const [pathSteps, setPathSteps] = useState([])
const patchStep = useCallback((idx, patch) => {
setPathSteps((prev) => prev.map((row, i) => (i === idx ? { ...row, ...patch } : row)))
}, [])
const removeStep = useCallback((idx) => {
setPathSteps((prev) => (prev.length <= 2 ? prev : prev.filter((_, i) => i !== idx)))
}, [])
const moveStep = useCallback((idx, dir) => {
setPathSteps((prev) => {
const j = idx + dir
if (j < 0 || j >= prev.length) return prev
const next = [...prev]
const t = next[idx]
next[idx] = next[j]
next[j] = t
return next
})
}, [])
const suggestPath = async () => {
const q = (goalQuery || '').trim()
if (q.length < 3) {
alert('Ziel-Anfrage: mindestens 3 Zeichen.')
return
}
if (!graphId) {
alert('Zuerst einen Graphen wählen.')
return
}
setLoading(true)
setError('')
try {
const res = await api.suggestProgressionPath({
query: q,
max_steps: Number(maxSteps),
include_llm_intent: true,
include_path_qa: true,
include_llm_path_qa: true,
progression_graph_id: Number(graphId),
})
const rows = (Array.isArray(res?.steps) ? res.steps : []).map(mapApiStepToRow)
if (rows.length < 2) {
throw new Error('Zu wenig Schritte im Vorschlag.')
}
setPathSteps(rows)
setTargetSummary(res?.target_profile_summary || null)
setSemanticBrief(res?.semantic_brief_summary || null)
setPathQa(res?.path_qa || null)
if (!segmentNotes.trim() && q) setSegmentNotes(q.slice(0, 400))
} catch (e) {
console.error(e)
setError(e.message || 'Pfad-Vorschlag fehlgeschlagen')
setPathSteps([])
setTargetSummary(null)
setSemanticBrief(null)
setPathQa(null)
} finally {
setLoading(false)
}
}
const savePathToGraph = async () => {
if (!graphId) {
alert('Zuerst einen Graphen wählen.')
return
}
const steps = pathSteps.filter((s) => s.exerciseId != null)
const skippedAi = pathSteps.filter((s) => s.isAiProposal).length
if (steps.length < 2) {
alert(
skippedAi > 0
? 'Mindestens zwei gespeicherte Übungen nötig. KI-Vorschläge zuerst als Übung anlegen.'
: 'Mindestens zwei Schritte mit Übung nötig.'
)
return
}
const n = steps.length - 1
const noteRaw = segmentNotes.trim()
const segment_notes = Array.from({ length: n }, (_, i) => {
const reasons = (steps[i + 1]?.reasons || []).slice(0, 2).join(' · ')
if (reasons) return reasons
return noteRaw || null
})
setSaving(true)
setError('')
try {
await api.createExerciseProgressionSequence(Number(graphId), {
steps: steps.map((s) => ({
exercise_id: s.exerciseId,
variant_id: s.variantId || null,
})),
segment_notes,
})
setPathSteps([])
setTargetSummary(null)
setSemanticBrief(null)
setPathQa(null)
if (typeof onSaved === 'function') await onSaved()
const msg =
skippedAi > 0
? `${n} Kante(n) gespeichert. ${skippedAi} KI-Vorschlag/Vorschläge nicht im Graph (noch nicht angelegt).`
: `${n} Nachfolger-Kante(n) aus KI-Pfad gespeichert.`
alert(msg)
} catch (e) {
console.error(e)
setError(e.message || 'Speichern fehlgeschlagen')
} finally {
setSaving(false)
}
}
return (
<div
className="card"
style={{
marginBottom: '12px',
borderColor: 'color-mix(in srgb, var(--accent) 35%, var(--border))',
}}
>
<h3 style={{ marginTop: 0, fontSize: '1rem' }}>KI: Pfad zum Ziel</h3>
<p style={{ fontSize: '12px', color: 'var(--text3)', marginTop: 0, lineHeight: 1.45 }}>
Ziel in Freitext formulieren die Planungs-KI schlägt eine semantisch passende, aufbauende Reihenfolge vor,
prüft Lücken (ggf. Brücken-Übungen) und optional per LLM-QS. Nach Review in den Graph speichern.
</p>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '10px', alignItems: 'flex-end' }}>
<div className="form-row" style={{ flex: '2 1 240px', marginBottom: 0 }}>
<label className="form-label">Ziel / Entwicklungsrichtung</label>
<input
className="form-input"
value={goalQuery}
onChange={(e) => setGoalQuery(e.target.value)}
placeholder="z. B. sichere Reaktion im Partnertraining aufbauen …"
disabled={disabled || loading || saving}
/>
</div>
<div className="form-row" style={{ flex: '0 1 120px', marginBottom: 0 }}>
<label className="form-label">Schritte</label>
<input
type="number"
min={2}
max={10}
className="form-input"
value={maxSteps}
onChange={(e) => setMaxSteps(Math.max(2, Math.min(10, Number(e.target.value) || 5)))}
disabled={disabled || loading || saving}
/>
</div>
<button
type="button"
className="btn btn-primary"
disabled={disabled || loading || saving || !graphId}
onClick={suggestPath}
>
{loading ? 'Vorschlag …' : 'Pfad vorschlagen'}
</button>
</div>
{error ? (
<p className="form-error" style={{ marginTop: '10px' }}>
{error}
</p>
) : null}
{(semanticBrief || targetSummary) && pathSteps.length > 0 ? (
<div style={{ marginTop: '10px', display: 'flex', flexWrap: 'wrap', gap: '6px' }}>
{semanticBrief?.primary_topic ? (
<span className="exercise-tag" style={{ borderColor: 'var(--accent)' }}>
Thema: {semanticBrief.primary_topic}
</span>
) : null}
{Array.isArray(semanticBrief?.development_arc) &&
semanticBrief.development_arc.slice(0, 3).map((phase) => (
<span key={phase} className="exercise-tag">
{phase}
</span>
))}
{Array.isArray(targetSummary?.focus_areas) &&
targetSummary.focus_areas.slice(0, 1).map((fa) => (
<span key={fa} className="exercise-tag">
Fokus: {fa}
</span>
))}
</div>
) : null}
{pathQa && pathSteps.length > 0 ? (
<div
style={{
marginTop: '10px',
padding: '10px 12px',
borderRadius: '8px',
background: pathQa.overall_ok ? 'color-mix(in srgb, var(--accent) 8%, var(--surface2))' : 'color-mix(in srgb, var(--danger) 8%, var(--surface2))',
fontSize: '12px',
lineHeight: 1.45,
}}
>
<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.slice(0, 4).map((issue) => (
<li key={issue}>{issue}</li>
))}
</ul>
) : null}
{Number(pathQa.bridge_insert_count) > 0 ? (
<p style={{ margin: '6px 0 0', color: 'var(--accent-dark)' }}>
{pathQa.bridge_insert_count} Brücken-Übung(en) aus der Bibliothek eingefügt.
</p>
) : null}
{Number(pathQa.ai_proposal_count) > 0 ? (
<p style={{ margin: '6px 0 0', color: 'var(--accent-dark)' }}>
{pathQa.ai_proposal_count} KI-Neuanlage-Vorschlag/Vorschläge vor dem Speichern als Übung anlegen.
</p>
) : null}
{pathQa.reorder_applied ? (
<p style={{ margin: '6px 0 0', color: 'var(--text2)' }}>
Reihenfolge nach QS angepasst.
{Array.isArray(pathQa.reorder_notes) && pathQa.reorder_notes[0]
? ` ${pathQa.reorder_notes[0]}`
: ''}
</p>
) : null}
{Array.isArray(targetSummary?.top_skills) &&
targetSummary.top_skills.slice(0, 2).map((sk) => (
<span key={sk.skill_id} className="exercise-tag">
{sk.name}
</span>
))}
</div>
) : null}
{pathSteps.length > 0 ? (
<>
<div style={{ marginTop: '14px' }}>
{pathSteps.map((step, idx) => (
<div
key={`${step.exerciseId}-${idx}`}
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))',
gap: '10px',
alignItems: 'end',
marginBottom: '12px',
paddingBottom: '12px',
borderBottom: idx < pathSteps.length - 1 ? '1px dashed var(--border)' : 'none',
}}
>
<div className="form-row" style={{ marginBottom: 0 }}>
<label className="form-label">
Schritt {idx + 1}
{step.isAiProposal ? ' (KI-Neu)' : step.isBridge ? ' (Brücke)' : ''}
{!step.isAiProposal && idx === 0 ? ' (Einstieg)' : ''}
{!step.isAiProposal && idx === pathSteps.length - 1 ? ' (Zielnähe)' : ''}
</label>
<div style={{ fontSize: '13px' }}>
<strong>{step.exerciseTitle}</strong>
{step.exerciseId ? (
<span style={{ color: 'var(--text3)' }}> (#{step.exerciseId})</span>
) : (
<span style={{ color: 'var(--text3)' }}> noch nicht in Bibliothek</span>
)}
</div>
{step.reasons?.length ? (
<ul
style={{
margin: '6px 0 0',
paddingLeft: '16px',
fontSize: '11px',
color: 'var(--accent-dark)',
}}
>
{step.reasons.slice(0, 2).map((r) => (
<li key={r}>{r}</li>
))}
</ul>
) : null}
</div>
<div className="form-row" style={{ marginBottom: 0 }}>
<label className="form-label">Variante</label>
{step.isAiProposal ? (
<p style={{ fontSize: '12px', color: 'var(--text3)', margin: 0 }}>
Nach Anlage der Übung im Graph wählbar.
</p>
) : (
<select
className="form-input"
value={step.variantId ?? ''}
onChange={(e) =>
patchStep(idx, {
variantId: e.target.value === '' ? null : parseInt(e.target.value, 10),
})
}
disabled={!step.exerciseId}
>
<option value="">Gesamte Übung</option>
{(step.variants || []).map((v) => (
<option key={v.id} value={v.id}>
{v.variant_name || `Variante #${v.id}`}
</option>
))}
</select>
)}
</div>
<div style={{ display: 'flex', gap: '6px', flexWrap: 'wrap' }}>
<button type="button" className="btn" style={{ fontSize: '12px', padding: '4px 8px' }} onClick={() => moveStep(idx, -1)}>
</button>
<button type="button" className="btn" style={{ fontSize: '12px', padding: '4px 8px' }} onClick={() => moveStep(idx, 1)}>
</button>
<button type="button" className="btn" style={{ fontSize: '12px', padding: '4px 8px' }} onClick={() => removeStep(idx)}>
Entfernen
</button>
</div>
</div>
))}
</div>
<div className="form-row">
<label className="form-label">Notiz für Kanten (Fallback, optional)</label>
<textarea
className="form-input"
rows={2}
value={segmentNotes}
onChange={(e) => setSegmentNotes(e.target.value)}
placeholder="Wird pro Kante genutzt, wenn keine KI-Begründung vorliegt."
/>
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px' }}>
<button
type="button"
className="btn btn-primary"
disabled={disabled || saving || pathSteps.filter((s) => s.exerciseId).length < 2}
onClick={savePathToGraph}
>
{saving ? 'Speichern …' : 'Pfad in Graph speichern'}
</button>
<button
type="button"
className="btn btn-secondary"
disabled={loading || saving}
onClick={() => {
setPathSteps([])
setTargetSummary(null)
}}
>
Vorschlag verwerfen
</button>
</div>
</>
) : null}
</div>
)
}