From 45e3b5f4f66587c3664ddd5976a3ab13cad68d59 Mon Sep 17 00:00:00 2001
From: Lars
Date: Fri, 22 May 2026 22:15:19 +0200
Subject: [PATCH] Implement Phase 1 of Planning Exercise Suggestion with
Scenario Pipeline and LLM Intent Overlay
- Introduced the Scenario Pipeline for planning exercises, allowing for more nuanced query handling and exercise suggestions based on user intent.
- Enhanced the `suggestPlanningExercises` API to include `include_llm_intent`, `scenario_kind`, and `query_intent_summary`, improving the context provided to the frontend.
- Updated the `ExercisePickerModal` to display new information related to query intent and scenario classification, enhancing user experience during exercise selection.
- Incremented application version to 0.8.171 and updated changelog to document the new features and improvements in the planning AI capabilities.
---
.../PLANNING_EXERCISE_SUGGEST_CONTEXT.md | 56 +++-
backend/ai_prompt_runtime.py | 1 +
...prompt_planning_exercise_search_intent.sql | 74 +++++
backend/planning_exercise_intent.py | 272 +++++++++++++++++
backend/planning_exercise_profiles.py | 4 +
backend/planning_exercise_suggest.py | 38 ++-
backend/planning_exercise_target_pipeline.py | 284 ++++++++++++++++++
.../tests/test_planning_exercise_suggest.py | 57 +++-
backend/version.py | 16 +-
docs/HANDOVER.md | 5 +-
.../src/components/ExercisePickerModal.jsx | 15 +
11 files changed, 794 insertions(+), 28 deletions(-)
create mode 100644 backend/migrations/073_ai_prompt_planning_exercise_search_intent.sql
create mode 100644 backend/planning_exercise_intent.py
create mode 100644 backend/planning_exercise_target_pipeline.py
diff --git a/.claude/docs/working/PLANNING_EXERCISE_SUGGEST_CONTEXT.md b/.claude/docs/working/PLANNING_EXERCISE_SUGGEST_CONTEXT.md
index bdfd06e..f602dae 100644
--- a/.claude/docs/working/PLANNING_EXERCISE_SUGGEST_CONTEXT.md
+++ b/.claude/docs/working/PLANNING_EXERCISE_SUGGEST_CONTEXT.md
@@ -2,7 +2,7 @@
**Version:** 0.1
**Datum:** 2026-05-22
-**Status:** P0.1 — Hybrid-Retrieval + Phase-1-Profil-Score (`profile_v1`); LLM-Rerank P2
+**Status:** P1 — Szenario-Pipeline + LLM Query-Intent-Overlay; P2 LLM-Rerank optional
**Bezüge:** `AI_PLANNING_KI_MULTISTAGE_FORECAST.md` · `AI_PROMPT_TARGET_ARCHITECTURE.md` · `SKILL_SCORING_SPEC.md` · `TRAINING_FRAMEWORK_SPEC.md` §3 (Progressionsgraph)
---
@@ -25,7 +25,7 @@ Trainer in der **Trainingsplanung** sollen Übungen finden oder anlegen können
| Stufe | Name | Technik | P0 |
|-------|------|---------|-----|
| **S0** | Kontext-Pack | SQL/API, deterministisch | ✅ |
-| **S1a** | Intent strukturieren | Optional LLM `planning_exercise_search_intent` | Heuristik |
+| **S1a** | Intent strukturieren | LLM `planning_exercise_search_intent` (Szenario-Pipeline) | ✅ P1 |
| **S1b** | Hybrid-Retrieval | Score: Volltext + Graph + Skills + Plan + **Profil** | ✅ |
| **S1b+** | Profil-Vorselektion | `ExerciseMatchProfile` × `PlanningTargetProfile` | ✅ `profile_v1` |
| **S1c** | Rerank + Begründung | Optional LLM `planning_exercise_search_rank` | Regelbasierte `reasons[]` |
@@ -179,7 +179,7 @@ Wenn `hits` leer oder Trainer wählt „Mit KI anlegen“:
| **P0** ✅ | Context-Pack, Hybrid-Score, API, Picker in Planung |
| **P0.1** ✅ | `ExerciseMatchProfile` / `PlanningTargetProfile`, `profile_v1`, `target_profile_summary` |
| **P2** ✅ (optional) | LLM-Rerank `planning_exercise_search_rank`, `include_llm_rank`, `llm_rank_applied` |
-| **P1** | LLM Intent-JSON; Neu-Anlage mit Pack |
+| **P1** ✅ | Szenario-Pipeline + LLM Query-Intent → Erwartungsprofil-Overlay |
| **P3** | Skill-Discovery / Framework-Ziele im Pack |
---
@@ -197,7 +197,55 @@ Wenn `hits` leer oder Trainer wählt „Mit KI anlegen“:
- **Ungespeicherte Plan-Änderungen:** ✅ Client übergibt `planned_exercise_ids[]` aus Formular (TrainingUnitEditPage).
- **Progressionsgraph-ID:** noch nicht aus UI wählbar (`progression_graph_id` nur per API).
-- **LLM-Intent:** P1 laut Roadmap §9.
+- **LLM-Intent:** ✅ P1 Szenario-Pipeline + `planning_exercise_search_intent` (Migration 073).
+
+---
+
+## 16. Szenario-Pipeline & Query-Erwartungsprofil (P1)
+
+Komplexe Planungsanfragen brauchen **Schritte vor** dem Profil-Match — nicht jede Query ist gleich.
+
+### 16.1 Szenario-Klassen
+
+| `scenario_kind` | Typische Anfrage | LLM Intent? |
+|-----------------|------------------|-------------|
+| `preset_next` | „Nächste Übung vorschlagen“ (Preset) | Nein — nur Basis-Profil |
+| `progression` | Progressionsgraph / Pfad | Ja (wenn Freitext) |
+| `deepen` | Vertiefung Anker | Ja |
+| `continue_plan` | Auf bisherigen Plan aufbauen | Ja |
+| `additive_constraint` | Plan **+** Zusatz (z. B. Schnellkraft) | Ja |
+| `free_search` | Offene Stichwortsuche | Ja |
+
+**Routing:** `planning_exercise_target_pipeline.classify_planning_scenario()` → `should_run_llm_intent_pipeline()`.
+
+### 16.2 Pipeline (Reihenfolge)
+
+```
+S0 Kontext-Pack
+ → Heuristik-Intent + Szenario
+ → [optional] LLM planning_exercise_search_intent
+ → Basis PlanningTargetProfile (Rahmen, Plan, Anker, Gap)
+ → Merge Query-Overlay (Katalog-IDs aus Hints)
+ → Hybrid-Retrieval + Profil-Score
+ → [optional] LLM-Rerank
+```
+
+Module: `planning_exercise_target_pipeline.py` · `planning_exercise_intent.py`
+
+### 16.3 API (Erweiterung)
+
+| Request | Default | Bedeutung |
+|---------|---------|-----------|
+| `include_llm_intent` | `true` | LLM nur wenn Szenario ≠ preset_next und Query nicht leer |
+
+| Response | Bedeutung |
+|----------|-----------|
+| `scenario_kind` | Szenario-Klasse |
+| `query_intent_summary` | intent, llm_applied, rationale, skill_hints_resolved |
+| `intent_heuristic` | Heuristik vor LLM |
+| `retrieval_phase` | z. B. `profile_v1+query_intent+llm_rank` |
+
+**Prompt 073:** `planning_exercise_search_intent` — Ausgabe JSON mit `skill_hints`, `focus_hints`, `emphasis` (`additive`|`replace`).
---
diff --git a/backend/ai_prompt_runtime.py b/backend/ai_prompt_runtime.py
index a7c4bdb..f942ad1 100644
--- a/backend/ai_prompt_runtime.py
+++ b/backend/ai_prompt_runtime.py
@@ -14,6 +14,7 @@ from prompt_resolver import MustacheRenderResult, render_mustache_template
_PLANNING_AI_SLUGS = frozenset(
{
"planning_exercise_search_rank",
+ "planning_exercise_search_intent",
}
)
diff --git a/backend/migrations/073_ai_prompt_planning_exercise_search_intent.sql b/backend/migrations/073_ai_prompt_planning_exercise_search_intent.sql
new file mode 100644
index 0000000..7aa780e
--- /dev/null
+++ b/backend/migrations/073_ai_prompt_planning_exercise_search_intent.sql
@@ -0,0 +1,74 @@
+-- Migration 073: KI-Prompt Planungs-Übungssuche — Intent/Query-Overlay (P1)
+-- Spec: .claude/docs/working/PLANNING_EXERCISE_SUGGEST_CONTEXT.md §16
+
+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_search_intent',
+ 'Planungs-Übungssuche Intent',
+ 'Strukturiert Freitext-Anfrage in Intent, Szenario und Katalog-Hints für Erwartungsprofil-Overlay.',
+ $t$Du bist Assistent für Kampfsport-Trainer in der Trainingsplanung.
+Analysiere die Suchanfrage im Kontext der Einheit und des bisherigen Plans.
+
+Ziel: JSON für ein Erwartungsprofil-Overlay (Fähigkeiten, Fokus, Stil …) — NICHT Übungs-IDs erfinden.
+
+Szenario-Klassen (scenario):
+- preset_next: nur „nächste Übung“ ohne Zusatz — selten bei Freitext
+- progression: Progressionsgraph / Pfad / Folgeübung im Graph
+- deepen: Vertiefung zur Anker-Übung
+- continue_plan: baut auf bisherigem Plan der Einheit auf
+- additive_constraint: Plan beibehalten UND zusätzliche Anforderung (z. B. „außerdem Schnellkraft“)
+- free_search: offene Stichwortsuche / neues Thema
+
+Intent (intent): suggest_next | progression_next | deepen_exercise | continue_plan_goal | free_search
+
+emphasis:
+- additive: Zusatz zur bestehenden Planung (Default bei „zusätzlich/auch/dazu“)
+- replace: Suchanfrage soll Schwerpunkt eher ersetzen
+- neutral: nur leichte Gewichtung
+
+Nutze skill_hints/focus_hints etc. mit Namen aus den Katalog-JSONs (beste Übereinstimmung).
+Bei requires_partner: true/false/null wenn Partnerbezug erkennbar.
+
+Eingabe:
+Suchanfrage: {{search_query}}
+Heuristik-Intent: {{heuristic_intent}}
+Szenario-Hinweis (Server): {{scenario_hint}}
+Planungskontext: {{planning_context_json}}
+Basis-Zielprofil (deterministisch): {{target_profile_json}}
+
+Kataloge (Auszug — nur diese Namen/IDs verwenden):
+Skills: {{skills_catalog_json}}
+Fokus: {{focus_areas_catalog_json}}
+Trainingsstil: {{training_types_catalog_json}}
+Stilrichtung: {{style_directions_catalog_json}}
+Zielgruppe: {{target_groups_catalog_json}}
+
+Antworte NUR mit JSON:
+{
+ "intent": "continue_plan_goal",
+ "scenario": "additive_constraint",
+ "skill_hints": [{"name": "Schnellkraft", "weight": 1.0}],
+ "focus_hints": [],
+ "style_hints": [],
+ "training_type_hints": [],
+ "target_group_hints": [],
+ "requires_partner": null,
+ "emphasis": "additive",
+ "rationale": "Kurz auf Deutsch, 1 Satz"
+}$t$,
+ 'training',
+ 'json',
+ '{"type":"object","required":["intent","scenario"],"properties":{"intent":{"type":"string"},"scenario":{"type":"string"},"skill_hints":{"type":"array"},"emphasis":{"type":"string"},"rationale":{"type":"string"}}}'::jsonb,
+ true,
+ NULL,
+ true,
+ 11
+WHERE NOT EXISTS (SELECT 1 FROM ai_prompts WHERE slug = 'planning_exercise_search_intent');
+
+UPDATE ai_prompts
+SET default_template = template
+WHERE slug = 'planning_exercise_search_intent'
+ AND (default_template IS NULL OR TRIM(default_template) = '');
diff --git a/backend/planning_exercise_intent.py b/backend/planning_exercise_intent.py
new file mode 100644
index 0000000..43a64b7
--- /dev/null
+++ b/backend/planning_exercise_intent.py
@@ -0,0 +1,272 @@
+"""
+P1: LLM-Intent aus Planungs-Suchfrage → strukturiertes Query-Overlay für PlanningTargetProfile.
+
+Prompt: planning_exercise_search_intent (Migration 073)
+"""
+from __future__ import annotations
+
+import json
+import logging
+import re
+from typing import Any, Dict, List, Mapping, Optional, Sequence, Set, Tuple
+
+from pydantic import BaseModel, Field, field_validator
+
+from ai_prompt_runtime import AiPromptUnavailableError, load_and_render_ai_prompt
+from openrouter_chat import (
+ effective_openrouter_model_for_prompt_row,
+ normalize_openrouter_env,
+ openrouter_chat_completion,
+)
+
+_logger = logging.getLogger("shinkan.planning_exercise_intent")
+
+VALID_PARSED_INTENTS = {
+ "suggest_next",
+ "progression_next",
+ "deepen_exercise",
+ "continue_plan_goal",
+ "free_search",
+}
+
+VALID_SCENARIOS = {
+ "preset_next",
+ "progression",
+ "deepen",
+ "continue_plan",
+ "additive_constraint",
+ "free_search",
+}
+
+VALID_EMPHASIS = {"additive", "replace", "neutral"}
+
+
+class SkillHint(BaseModel):
+ name: str = Field(..., min_length=1, max_length=120)
+ weight: float = Field(default=1.0, ge=0.1, le=1.0)
+
+
+class PlanningQueryIntentParsed(BaseModel):
+ intent: str = "free_search"
+ scenario: str = "free_search"
+ skill_hints: List[SkillHint] = Field(default_factory=list)
+ focus_hints: List[str] = Field(default_factory=list)
+ style_hints: List[str] = Field(default_factory=list)
+ training_type_hints: List[str] = Field(default_factory=list)
+ target_group_hints: List[str] = Field(default_factory=list)
+ requires_partner: Optional[bool] = None
+ emphasis: str = "additive"
+ rationale: Optional[str] = Field(default=None, max_length=400)
+
+ @field_validator("intent")
+ @classmethod
+ def _intent(cls, v: str) -> str:
+ s = (v or "").strip().lower()
+ return s if s in VALID_PARSED_INTENTS else "free_search"
+
+ @field_validator("scenario")
+ @classmethod
+ def _scenario(cls, v: str) -> str:
+ s = (v or "").strip().lower()
+ return s if s in VALID_SCENARIOS else "free_search"
+
+ @field_validator("emphasis")
+ @classmethod
+ def _emphasis(cls, v: str) -> str:
+ s = (v or "").strip().lower()
+ return s if s in VALID_EMPHASIS else "additive"
+
+ @field_validator("focus_hints", "style_hints", "training_type_hints", "target_group_hints", mode="before")
+ @classmethod
+ def _str_list(cls, v: Any) -> List[str]:
+ if not v:
+ return []
+ if isinstance(v, str):
+ return [v.strip()] if v.strip() else []
+ out: List[str] = []
+ for item in v:
+ s = str(item or "").strip()
+ if s and s not in out:
+ out.append(s[:120])
+ return out[:8]
+
+
+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 parse_planning_query_intent_response(text: str) -> PlanningQueryIntentParsed:
+ obj = _extract_json_object(text)
+ return PlanningQueryIntentParsed.model_validate(obj)
+
+
+def _compact_json(obj: Any) -> str:
+ return json.dumps(obj, ensure_ascii=False, separators=(",", ":"))
+
+
+def _load_compact_catalog(cur, table: str, id_col: str, name_col: str = "name", limit: int = 80) -> List[Dict[str, Any]]:
+ cur.execute(
+ f"""
+ SELECT {id_col} AS id, {name_col} AS name
+ FROM {table}
+ ORDER BY {name_col} ASC NULLS LAST
+ LIMIT %s
+ """,
+ (limit,),
+ )
+ return [{"id": int(r["id"]), "name": str(r["name"] or "")[:80]} for r in cur.fetchall()]
+
+
+def _load_skills_catalog_compact(cur, limit: int = 120) -> List[Dict[str, Any]]:
+ cur.execute(
+ """
+ SELECT id, name, category
+ FROM skills
+ WHERE status IS NULL OR status = 'active'
+ ORDER BY name ASC
+ LIMIT %s
+ """,
+ (limit,),
+ )
+ return [
+ {
+ "id": int(r["id"]),
+ "name": str(r["name"] or "")[:80],
+ "category": str(r.get("category") or "")[:40],
+ }
+ for r in cur.fetchall()
+ ]
+
+
+def _resolve_name_hint(cur, table: str, hint: str, *, extra_where: str = "") -> Optional[int]:
+ h = (hint or "").strip()
+ if len(h) < 2:
+ return None
+ q = h.lower()
+ cur.execute(
+ f"""
+ SELECT id, name
+ FROM {table}
+ WHERE LOWER(name) LIKE %s {extra_where}
+ 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"%{q}%", q, f"{q}%"),
+ )
+ row = cur.fetchone()
+ return int(row["id"]) if row else None
+
+
+def resolve_query_intent_catalog_ids(
+ cur,
+ parsed: PlanningQueryIntentParsed,
+) -> Tuple[Dict[int, float], Dict[int, float], Dict[int, float], Dict[int, float], Dict[int, float], List[Dict[str, Any]]]:
+ """
+ Mappt Text-Hints auf Katalog-IDs. Returns (focus, style, tt, tg, skills, resolved_skills_meta).
+ """
+ focus: Dict[int, float] = {}
+ style: Dict[int, float] = {}
+ tt: Dict[int, float] = {}
+ tg: Dict[int, float] = {}
+ skills: Dict[int, float] = {}
+ resolved_skills: List[Dict[str, Any]] = []
+
+ for hint in parsed.focus_hints:
+ fid = _resolve_name_hint(cur, "focus_areas", hint)
+ if fid:
+ focus[fid] = max(focus.get(fid, 0.0), 0.9)
+
+ for hint in parsed.style_hints:
+ sid = _resolve_name_hint(cur, "style_directions", hint)
+ if sid:
+ style[sid] = max(style.get(sid, 0.0), 0.85)
+
+ for hint in parsed.training_type_hints:
+ tid = _resolve_name_hint(cur, "training_types", hint)
+ if tid:
+ tt[tid] = max(tt.get(tid, 0.0), 0.85)
+
+ for hint in parsed.target_group_hints:
+ gid = _resolve_name_hint(cur, "target_groups", hint)
+ if gid:
+ tg[gid] = max(tg.get(gid, 0.0), 0.85)
+
+ for sh in parsed.skill_hints[:8]:
+ 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"%{sh.name.lower()}%", sh.name.lower(), f"{sh.name.lower()}%"),
+ )
+ row = cur.fetchone()
+ if row:
+ sid = int(row["id"])
+ skills[sid] = max(skills.get(sid, 0.0), float(sh.weight))
+ resolved_skills.append({"skill_id": sid, "name": str(row["name"] or sh.name), "weight": skills[sid]})
+
+ return focus, style, tt, tg, skills, resolved_skills
+
+
+def try_parse_planning_query_intent(
+ cur,
+ *,
+ query: str,
+ heuristic_intent: str,
+ scenario_hint: str,
+ context_summary: Mapping[str, Any],
+ target_profile_summary: Mapping[str, Any],
+) -> Tuple[Optional[PlanningQueryIntentParsed], bool]:
+ api_key, _ = normalize_openrouter_env()
+ if not api_key or not (query or "").strip():
+ return None, False
+
+ variables = {
+ "search_query": (query or "").strip(),
+ "heuristic_intent": heuristic_intent or "",
+ "scenario_hint": scenario_hint or "",
+ "planning_context_json": _compact_json(dict(context_summary or {})),
+ "target_profile_json": _compact_json(dict(target_profile_summary or {})),
+ "skills_catalog_json": _compact_json(_load_skills_catalog_compact(cur)),
+ "focus_areas_catalog_json": _compact_json(_load_compact_catalog(cur, "focus_areas", "id")),
+ "training_types_catalog_json": _compact_json(_load_compact_catalog(cur, "training_types", "id")),
+ "style_directions_catalog_json": _compact_json(_load_compact_catalog(cur, "style_directions", "id")),
+ "target_groups_catalog_json": _compact_json(_load_compact_catalog(cur, "target_groups", "id")),
+ }
+
+ try:
+ prow, rendered = load_and_render_ai_prompt(cur, "planning_exercise_search_intent", variables)
+ model = effective_openrouter_model_for_prompt_row(prow)
+ raw = openrouter_chat_completion(api_key=api_key, model=model, user_content=rendered.text)
+ parsed = parse_planning_query_intent_response(raw)
+ return parsed, True
+ except AiPromptUnavailableError:
+ return None, False
+ except Exception as exc:
+ _logger.warning("Planungs-Intent-LLM fehlgeschlagen: %s", exc)
+ return None, False
+
+
+__all__ = [
+ "PlanningQueryIntentParsed",
+ "parse_planning_query_intent_response",
+ "resolve_query_intent_catalog_ids",
+ "try_parse_planning_query_intent",
+]
diff --git a/backend/planning_exercise_profiles.py b/backend/planning_exercise_profiles.py
index 9f503ed..b346cba 100644
--- a/backend/planning_exercise_profiles.py
+++ b/backend/planning_exercise_profiles.py
@@ -107,6 +107,7 @@ class PlanningTargetProfile:
target_group_ids: Dict[int, float] = field(default_factory=dict)
skill_weights: Dict[int, float] = field(default_factory=dict)
skill_gap_weights: Dict[int, float] = field(default_factory=dict)
+ skill_plan_weights: Dict[int, float] = field(default_factory=dict)
sources: List[str] = field(default_factory=list)
def to_summary_dict(self, cur, limit_skills: int = 5) -> Dict[str, Any]:
@@ -385,6 +386,7 @@ def build_planning_target_profile(
target_group_ids=_normalize_weight_map(tg) if tg else tg,
skill_weights=skill_target,
skill_gap_weights=_normalize_weight_map(skill_gap) if skill_gap else skill_gap,
+ skill_plan_weights=skill_plan_norm,
sources=sources,
)
@@ -420,6 +422,8 @@ def score_exercise_against_target(
reasons.append("Fähigkeiten-Schwerpunkt passend (Profilmetrik)")
if gap_sim >= 0.25 and target.skill_gap_weights:
reasons.append("Deckt Skill-Lücke im bisherigen Plan")
+ if "query_intent" in (target.sources or []):
+ reasons.append("Passt zur KI-interpretierten Suchanfrage")
# Intent-gewichtete Dimensionen (Summe = 1.0)
if intent == INTENT_FREE_SEARCH:
diff --git a/backend/planning_exercise_suggest.py b/backend/planning_exercise_suggest.py
index 83f31e4..47aef4a 100644
--- a/backend/planning_exercise_suggest.py
+++ b/backend/planning_exercise_suggest.py
@@ -13,11 +13,14 @@ from pydantic import BaseModel, Field
from tenant_context import TenantContext, library_content_visibility_sql
from planning_exercise_profiles import (
- build_planning_target_profile,
load_exercise_match_profiles_bulk,
score_exercise_against_target,
)
from planning_exercise_llm_rank import try_llm_rerank_planning_hits
+from planning_exercise_target_pipeline import (
+ build_planning_target_with_query_pipeline,
+ compose_retrieval_phase,
+)
# Planungs-Berechtigung + Sektionen (bestehende Implementierung)
from routers.training_planning import (
@@ -54,6 +57,7 @@ class PlanningExerciseSuggestRequest(BaseModel):
query: Optional[str] = ""
intent_hint: Optional[str] = None
planned_exercise_ids: Optional[List[int]] = None
+ include_llm_intent: bool = True
include_llm_rank: bool = False
limit: int = Field(default=20, ge=1, le=50)
exercise_kind_any: Optional[List[str]] = None
@@ -369,16 +373,30 @@ def suggest_planning_exercises(
pack = build_planning_exercise_context_pack(cur, tenant=tenant, body=body)
pack = _apply_client_planned_override(cur, pack, body)
query = _normalize_query(body.query)
- intent = resolve_planning_exercise_intent(query, body.intent_hint)
- weights = _intent_weights(intent)
- target_profile = build_planning_target_profile(
+ heuristic_intent = resolve_planning_exercise_intent(query, body.intent_hint)
+
+ pipeline_context = {
+ "unit_title": pack.get("unit_title"),
+ "group_name": pack.get("group_name"),
+ "section_title": pack.get("section_title"),
+ "planned_count": len(pack.get("planned_exercise_ids") or []),
+ "anchor_title": pack.get("anchor_title"),
+ "anchor_exercise_id": pack.get("anchor_exercise_id"),
+ "progression_graph_id": pack.get("progression_graph_id"),
+ }
+ target_profile, intent, scenario_kind, query_intent_summary = build_planning_target_with_query_pipeline(
cur,
unit=pack["unit"],
planned_exercise_ids=pack["planned_exercise_ids"],
anchor_exercise_id=pack.get("anchor_exercise_id"),
- intent=intent,
+ query=query,
+ heuristic_intent=heuristic_intent,
+ include_llm_intent=body.include_llm_intent,
+ context_summary=pipeline_context,
)
+ weights = _intent_weights(intent)
target_profile_summary = target_profile.to_summary_dict(cur)
+ query_intent_applied = bool(query_intent_summary.get("llm_applied"))
profile_id = tenant.profile_id
role = tenant.global_role
@@ -540,7 +558,7 @@ def suggest_planning_exercises(
hits.sort(key=lambda h: (-h["score"], h.get("title") or ""))
llm_applied = False
- retrieval_phase = "profile_v1"
+ retrieval_phase = compose_retrieval_phase(query_intent=query_intent_applied, llm_rank=False)
if body.include_llm_rank:
pre_limit = max(int(body.limit), _LLM_RERANK_PRE_LIMIT)
pool_hits = hits[:pre_limit]
@@ -562,7 +580,10 @@ def suggest_planning_exercises(
limit=int(body.limit),
)
if llm_applied:
- retrieval_phase = "profile_v1+llm_rank"
+ retrieval_phase = compose_retrieval_phase(
+ query_intent=query_intent_applied,
+ llm_rank=True,
+ )
tail = hits[pre_limit:]
hits = pool_hits + tail
else:
@@ -585,9 +606,12 @@ def suggest_planning_exercises(
return {
"context_summary": context_summary,
"target_profile_summary": target_profile_summary,
+ "scenario_kind": scenario_kind,
+ "query_intent_summary": query_intent_summary,
"retrieval_phase": retrieval_phase,
"llm_rank_applied": llm_applied,
"intent_resolved": intent,
+ "intent_heuristic": heuristic_intent,
"query_normalized": query or None,
"hits": hits,
}
diff --git a/backend/planning_exercise_target_pipeline.py b/backend/planning_exercise_target_pipeline.py
new file mode 100644
index 0000000..f76763b
--- /dev/null
+++ b/backend/planning_exercise_target_pipeline.py
@@ -0,0 +1,284 @@
+"""
+Szenario-Routing und Erwartungsprofil-Pipeline für Planungs-Übungssuche (P1).
+
+Ablauf:
+ 1. Heuristik: Intent + Szenario-Klasse aus Query/Kontext
+ 2. Optional LLM (planning_exercise_search_intent) bei komplexen Anfragen
+ 3. Deterministisches Basis-Profil (Rahmen, Plan, Anker)
+ 4. Query-Overlay mergen → PlanningTargetProfile für Vorselektion
+"""
+from __future__ import annotations
+
+import re
+from typing import Any, Dict, List, Mapping, Optional, Tuple
+
+from planning_exercise_intent import (
+ PlanningQueryIntentParsed,
+ resolve_query_intent_catalog_ids,
+ try_parse_planning_query_intent,
+)
+from planning_exercise_profiles import (
+ PlanningTargetProfile,
+ _merge_weight_maps,
+ _normalize_weight_map,
+ build_planning_target_profile,
+)
+
+SCENARIO_PRESET_NEXT = "preset_next"
+SCENARIO_PROGRESSION = "progression"
+SCENARIO_DEEPEN = "deepen"
+SCENARIO_CONTINUE_PLAN = "continue_plan"
+SCENARIO_ADDITIVE = "additive_constraint"
+SCENARIO_FREE_SEARCH = "free_search"
+
+_SIMPLE_PRESET_PATTERNS = (
+ r"^(schlage?\s+(mir\s+)?(die\s+)?(n[aä]chste|naechste)\s+(sinnvolle\s+)?(übung|uebung)\s*(vor)?\.?)$",
+ r"^(n[aä]chste|naechste)\s+(übung|uebung)\s*(vorschlag|vorschlagen|empfehl\w*)?\.?$",
+ r"^(vorschlag|vorschlagen|empfehl\w*)\s*(für|fuer)?\s*(die\s+)?(n[aä]chste|naechste)?\s*(übung|uebung)?\.?$",
+ r"^n[aä]chste\s+übung$",
+ r"^n[aä]chste\s+uebung$",
+)
+
+_ADDITIVE_MARKERS = (
+ "zusätzlich",
+ "zusaetzlich",
+ "auch ",
+ " außerdem",
+ " ausserdem",
+ " dazu",
+ " extra",
+ " mehr ",
+ " und dabei",
+ " sowie ",
+)
+
+
+def _normalize_query(q: Optional[str]) -> str:
+ return re.sub(r"\s+", " ", (q or "").strip())
+
+
+def is_simple_preset_query(query: Optional[str]) -> bool:
+ q = _normalize_query(query).lower()
+ if not q:
+ return True
+ for pat in _SIMPLE_PRESET_PATTERNS:
+ if re.match(pat, q, flags=re.IGNORECASE):
+ return True
+ return False
+
+
+def classify_planning_scenario(
+ query: Optional[str],
+ heuristic_intent: str,
+) -> str:
+ q = _normalize_query(query).lower()
+ if not q or is_simple_preset_query(q):
+ return SCENARIO_PRESET_NEXT
+ if heuristic_intent == "progression_next":
+ return SCENARIO_PROGRESSION
+ if heuristic_intent == "deepen_exercise":
+ return SCENARIO_DEEPEN
+ if any(m in f" {q} " for m in _ADDITIVE_MARKERS):
+ return SCENARIO_ADDITIVE
+ if heuristic_intent == "continue_plan_goal":
+ return SCENARIO_CONTINUE_PLAN
+ if heuristic_intent == "free_search":
+ return SCENARIO_FREE_SEARCH
+ if heuristic_intent == "suggest_next":
+ return SCENARIO_CONTINUE_PLAN
+ return SCENARIO_FREE_SEARCH
+
+
+def should_run_llm_intent_pipeline(
+ query: Optional[str],
+ scenario: str,
+ *,
+ include_llm_intent: bool,
+) -> bool:
+ if not include_llm_intent:
+ return False
+ if scenario == SCENARIO_PRESET_NEXT:
+ return False
+ return bool(_normalize_query(query))
+
+
+def _recalculate_skill_gap(target: PlanningTargetProfile) -> PlanningTargetProfile:
+ skill_target = _normalize_weight_map(dict(target.skill_weights))
+ skill_plan_norm = _normalize_weight_map(dict(target.skill_plan_weights))
+ skill_gap: Dict[int, float] = {}
+ for sid, tw in skill_target.items():
+ pw = skill_plan_norm.get(sid, 0.0)
+ gap = tw - pw * 0.85
+ if gap > 0.08:
+ skill_gap[sid] = gap
+ sources = list(target.sources)
+ if skill_gap and "skill_gap_vs_plan" not in sources:
+ sources.append("skill_gap_vs_plan")
+ elif not skill_gap:
+ sources = [s for s in sources if s != "skill_gap_vs_plan"]
+ return PlanningTargetProfile(
+ focus_area_ids=target.focus_area_ids,
+ style_direction_ids=target.style_direction_ids,
+ training_type_ids=target.training_type_ids,
+ target_group_ids=target.target_group_ids,
+ skill_weights=skill_target,
+ skill_gap_weights=_normalize_weight_map(skill_gap) if skill_gap else {},
+ skill_plan_weights=target.skill_plan_weights,
+ sources=sources,
+ )
+
+
+def merge_query_overlay_into_target(
+ base: PlanningTargetProfile,
+ *,
+ focus: Dict[int, float],
+ style: Dict[int, float],
+ tt: Dict[int, float],
+ tg: Dict[int, float],
+ skills: Dict[int, float],
+ emphasis: str = "additive",
+ scenario: str,
+) -> PlanningTargetProfile:
+ sources = list(base.sources)
+ if "query_intent" not in sources:
+ sources.append("query_intent")
+
+ if emphasis == "replace" or scenario == SCENARIO_FREE_SEARCH:
+ skill_w = _merge_weight_maps({}, skills, scale=1.0)
+ if skills:
+ skill_w = _normalize_weight_map(_merge_weight_maps(base.skill_weights, skills, scale=0.55))
+ if emphasis == "replace":
+ skill_w = _normalize_weight_map(skills)
+ focus_w = _merge_weight_maps(base.focus_area_ids, focus, scale=0.5 if emphasis == "replace" else 0.85)
+ style_w = _merge_weight_maps(base.style_direction_ids, style, scale=0.5)
+ tt_w = _merge_weight_maps(base.training_type_ids, tt, scale=0.5)
+ tg_w = _merge_weight_maps(base.target_group_ids, tg, scale=0.5)
+ else:
+ skill_scale = 1.0 if scenario == SCENARIO_ADDITIVE else 0.85
+ skill_w = _merge_weight_maps(base.skill_weights, skills, scale=skill_scale)
+ focus_w = _merge_weight_maps(base.focus_area_ids, focus, scale=0.9)
+ style_w = _merge_weight_maps(base.style_direction_ids, style, scale=0.75)
+ tt_w = _merge_weight_maps(base.training_type_ids, tt, scale=0.75)
+ tg_w = _merge_weight_maps(base.target_group_ids, tg, scale=0.75)
+
+ out = PlanningTargetProfile(
+ focus_area_ids=_normalize_weight_map(focus_w) if focus_w else focus_w,
+ style_direction_ids=_normalize_weight_map(style_w) if style_w else style_w,
+ training_type_ids=_normalize_weight_map(tt_w) if tt_w else tt_w,
+ target_group_ids=_normalize_weight_map(tg_w) if tg_w else tg_w,
+ skill_weights=_normalize_weight_map(skill_w) if skill_w else skill_w,
+ skill_gap_weights=dict(base.skill_gap_weights),
+ skill_plan_weights=dict(base.skill_plan_weights),
+ sources=sources,
+ )
+ return _recalculate_skill_gap(out)
+
+
+def build_planning_target_with_query_pipeline(
+ cur,
+ *,
+ unit: Dict[str, Any],
+ planned_exercise_ids: List[int],
+ anchor_exercise_id: Optional[int],
+ query: Optional[str],
+ heuristic_intent: str,
+ include_llm_intent: bool,
+ context_summary: Mapping[str, Any],
+) -> Tuple[PlanningTargetProfile, str, str, Dict[str, Any]]:
+ """
+ Returns: target_profile, resolved_intent, scenario_kind, query_intent_summary dict
+ """
+ scenario = classify_planning_scenario(query, heuristic_intent)
+ resolved_intent = heuristic_intent
+ llm_applied = False
+ parsed: Optional[PlanningQueryIntentParsed] = None
+ resolved_skills: List[Dict[str, Any]] = []
+
+ base = build_planning_target_profile(
+ cur,
+ unit=unit,
+ planned_exercise_ids=planned_exercise_ids,
+ anchor_exercise_id=anchor_exercise_id,
+ intent=heuristic_intent,
+ )
+ base_summary = base.to_summary_dict(cur)
+
+ if should_run_llm_intent_pipeline(query, scenario, include_llm_intent=include_llm_intent):
+ parsed, llm_applied = try_parse_planning_query_intent(
+ cur,
+ query=_normalize_query(query),
+ heuristic_intent=heuristic_intent,
+ scenario_hint=scenario,
+ context_summary=context_summary,
+ target_profile_summary=base_summary,
+ )
+
+ target = base
+ if parsed and llm_applied:
+ if parsed.intent in {
+ "suggest_next",
+ "progression_next",
+ "deepen_exercise",
+ "continue_plan_goal",
+ "free_search",
+ }:
+ resolved_intent = parsed.intent
+ if parsed.scenario in VALID_SCENARIOS_SET:
+ scenario = parsed.scenario
+
+ focus, style, tt, tg, skills, resolved_skills = resolve_query_intent_catalog_ids(cur, parsed)
+ if focus or style or tt or tg or skills:
+ target = merge_query_overlay_into_target(
+ base,
+ focus=focus,
+ style=style,
+ tt=tt,
+ tg=tg,
+ skills=skills,
+ emphasis=parsed.emphasis,
+ scenario=scenario,
+ )
+
+ query_intent_summary: Dict[str, Any] = {
+ "scenario": scenario,
+ "intent": resolved_intent,
+ "heuristic_intent": heuristic_intent,
+ "llm_applied": llm_applied,
+ "emphasis": parsed.emphasis if parsed else None,
+ "rationale": (parsed.rationale if parsed else None),
+ "skill_hints_resolved": resolved_skills,
+ "requires_partner": parsed.requires_partner if parsed else None,
+ }
+
+ return target, resolved_intent, scenario, query_intent_summary
+
+
+VALID_SCENARIOS_SET = {
+ SCENARIO_PRESET_NEXT,
+ SCENARIO_PROGRESSION,
+ SCENARIO_DEEPEN,
+ SCENARIO_CONTINUE_PLAN,
+ SCENARIO_ADDITIVE,
+ SCENARIO_FREE_SEARCH,
+}
+
+
+def compose_retrieval_phase(*, query_intent: bool, llm_rank: bool) -> str:
+ parts = ["profile_v1"]
+ if query_intent:
+ parts.append("query_intent")
+ if llm_rank:
+ parts.append("llm_rank")
+ return "+".join(parts)
+
+
+__all__ = [
+ "SCENARIO_ADDITIVE",
+ "SCENARIO_PRESET_NEXT",
+ "build_planning_target_with_query_pipeline",
+ "classify_planning_scenario",
+ "compose_retrieval_phase",
+ "is_simple_preset_query",
+ "merge_query_overlay_into_target",
+ "should_run_llm_intent_pipeline",
+]
diff --git a/backend/tests/test_planning_exercise_suggest.py b/backend/tests/test_planning_exercise_suggest.py
index 293f47c..bd0ee25 100644
--- a/backend/tests/test_planning_exercise_suggest.py
+++ b/backend/tests/test_planning_exercise_suggest.py
@@ -1,6 +1,15 @@
-"""Tests für Planungs-Übungssuche (Intent, LLM-Rerank-Parser)."""
+"""Tests Planungs-Übungssuche: Intent, Szenario-Pipeline, LLM-Parser."""
from planning_exercise_suggest import resolve_planning_exercise_intent
+from planning_exercise_intent import parse_planning_query_intent_response
from planning_exercise_llm_rank import parse_planning_exercise_rank_response
+from planning_exercise_target_pipeline import (
+ SCENARIO_ADDITIVE,
+ SCENARIO_PRESET_NEXT,
+ classify_planning_scenario,
+ compose_retrieval_phase,
+ is_simple_preset_query,
+ should_run_llm_intent_pipeline,
+)
def test_resolve_planning_exercise_intent_defaults():
@@ -14,6 +23,43 @@ def test_resolve_planning_exercise_intent_keywords():
assert resolve_planning_exercise_intent("progression graph", None) == "progression_next"
+def test_classify_planning_scenario_preset():
+ assert is_simple_preset_query("Schlage mir die nächste Übung vor")
+ assert classify_planning_scenario("", "suggest_next") == SCENARIO_PRESET_NEXT
+ assert classify_planning_scenario("nächste übung", "suggest_next") == SCENARIO_PRESET_NEXT
+
+
+def test_classify_planning_scenario_additive():
+ q = "Baut auf der Planung auf und trainiert zusätzlich Schnellkraft"
+ assert classify_planning_scenario(q, "continue_plan_goal") == SCENARIO_ADDITIVE
+ assert should_run_llm_intent_pipeline(q, SCENARIO_ADDITIVE, include_llm_intent=True)
+
+
+def test_should_skip_llm_for_preset():
+ assert not should_run_llm_intent_pipeline("", SCENARIO_PRESET_NEXT, include_llm_intent=True)
+ assert not should_run_llm_intent_pipeline(
+ "nächste übung",
+ SCENARIO_PRESET_NEXT,
+ include_llm_intent=True,
+ )
+
+
+def test_compose_retrieval_phase():
+ assert compose_retrieval_phase(query_intent=False, llm_rank=False) == "profile_v1"
+ assert compose_retrieval_phase(query_intent=True, llm_rank=True) == "profile_v1+query_intent+llm_rank"
+
+
+def test_parse_planning_query_intent_response():
+ parsed = parse_planning_query_intent_response(
+ '{"intent":"continue_plan_goal","scenario":"additive_constraint",'
+ '"skill_hints":[{"name":"Schnellkraft","weight":1}],"emphasis":"additive",'
+ '"rationale":"Zusatz Schnellkraft"}'
+ )
+ assert parsed.intent == "continue_plan_goal"
+ assert parsed.scenario == "additive_constraint"
+ assert parsed.skill_hints[0].name == "Schnellkraft"
+
+
def test_parse_planning_exercise_rank_response_filters_ids():
allowed = {10, 20, 30}
ranked, reasons = parse_planning_exercise_rank_response(
@@ -23,12 +69,3 @@ def test_parse_planning_exercise_rank_response_filters_ids():
assert ranked == [20, 10]
assert reasons[20] == "Passt gut"
assert 999 not in reasons
-
-
-def test_parse_planning_exercise_rank_response_reasons_by_id_alias():
- ranked, reasons = parse_planning_exercise_rank_response(
- '{"ranked_ids":[5],"reasons_by_id":{"5":"Skill-Lücke"}}',
- {5},
- )
- assert ranked == [5]
- assert reasons[5] == "Skill-Lücke"
diff --git a/backend/version.py b/backend/version.py
index c5ced1c..c0cb927 100644
--- a/backend/version.py
+++ b/backend/version.py
@@ -1,8 +1,8 @@
# Shinkan Jinkendo Version Information
-APP_VERSION = "0.8.170"
+APP_VERSION = "0.8.171"
BUILD_DATE = "2026-05-22"
-DB_SCHEMA_VERSION = "20260531072"
+DB_SCHEMA_VERSION = "20260531073"
MODULE_VERSIONS = {
"legal_documents": "1.4.0", # Admin: Live-Vorschau pro Abschnitt + modale Vollvorschau (Editor + Dokumentenliste)
@@ -27,8 +27,8 @@ MODULE_VERSIONS = {
"skills": "0.1.1", # DB 065 karate_relevance + relevance_level; CRUD unterstützt Felder
"skill_profiles": "1.0.0", # Phase 3: gewichtetes Fähigkeiten-Profil + skill-discovery/suggestions
"methods": "0.1.0",
- "exercises": "2.36.0", # Planungs-KI P2: LLM-Rerank + Client planned_exercise_ids
- "planning_exercise_suggest": "0.3.0", # include_llm_rank, planned_exercise_ids Override
+ "exercises": "2.37.0", # Planungs-KI P1: Szenario-Pipeline + Query-Intent-Overlay
+ "planning_exercise_suggest": "0.4.0", # include_llm_intent, scenario_kind, query_intent_summary
"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
@@ -43,6 +43,14 @@ MODULE_VERSIONS = {
}
CHANGELOG = [
+ {
+ "version": "0.8.171",
+ "date": "2026-05-22",
+ "changes": [
+ "Planungs-KI P1: Szenario-Pipeline (preset/progression/additive/…) + LLM Intent planning_exercise_search_intent → Erwartungsprofil-Overlay.",
+ "API: scenario_kind, query_intent_summary, include_llm_intent; Migration 073.",
+ ],
+ },
{
"version": "0.8.170",
"date": "2026-05-22",
diff --git a/docs/HANDOVER.md b/docs/HANDOVER.md
index bc852cf..59a3904 100644
--- a/docs/HANDOVER.md
+++ b/docs/HANDOVER.md
@@ -89,10 +89,9 @@ 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 & Skill-Katalog-Retrieval (Stand **0.8.170**)
+### 2.8 KI Assistenz Übungen & Skill-Katalog-Retrieval (Stand **0.8.171**)
-- **Zielarchitektur (Pflicht fuer Erweiterungen):** `.claude/docs/technical/AI_PROMPT_TARGET_ARCHITECTURE.md` — Kontext-Arten, Composition, Einbindung Planung/Rahmen; Phasenplan P0–P4.
-- **Planungs-Übungssuche (P2):** `.claude/docs/working/PLANNING_EXERCISE_SUGGEST_CONTEXT.md` — Hybrid + Profil-Score + optional **LLM-Rerank** (`include_llm_rank`, Prompt `planning_exercise_search_rank`); Client **`planned_exercise_ids`**; **`POST /api/planning/exercise-suggest`**; **`ExercisePickerModal`** + **`planningContext`** aus **`TrainingUnitEditPage`**.
+- **Planungs-Übungssuche (P1):** Szenario-Pipeline + **LLM Query-Intent** (`planning_exercise_search_intent`) → Erwartungsprofil-Overlay; danach Hybrid + optional LLM-Rerank — `.claude/docs/working/PLANNING_EXERCISE_SUGGEST_CONTEXT.md` §16.
- **Doku:** Umsetzung `.claude/docs/working/AI_EXERCISE_IMPLEMENTATION_PLAN.md`; Profil-/JSON-Konzept `.claude/docs/working/AI_SKILL_RETRIEVAL_PROFILES_SPEC.md`; Ist-Prompt/UI **`AI_PROMPT_SYSTEM_SPEC.md`**; API-Felder **`KI_FEATURES_SPEC.md`** §5.2
- **Kontext / Job:** **`ai_prompt_context`** (Titel, Ziel, Durchführung, Vorbereitung, Trainer-Hinweise, Fokus); **`ai_prompt_job`** — **`run_exercise_form_ai_suggestion`**; **`ai_prompt_runtime`**; **`exercise_ai`** — OpenRouter
- **DB:** **`067`** ai_prompts · **`069`** default_template · **`068`** ai_skill_retrieval_profiles · **`070`** openrouter_model · **`071`** **`exercise_instruction_rewrite`**
diff --git a/frontend/src/components/ExercisePickerModal.jsx b/frontend/src/components/ExercisePickerModal.jsx
index 133d802..5010408 100644
--- a/frontend/src/components/ExercisePickerModal.jsx
+++ b/frontend/src/components/ExercisePickerModal.jsx
@@ -69,6 +69,7 @@ export default function ExercisePickerModal({
const [planningContextSummary, setPlanningContextSummary] = useState(null)
const [planningTargetProfileSummary, setPlanningTargetProfileSummary] = useState(null)
const [planningLlmRankApplied, setPlanningLlmRankApplied] = useState(false)
+ const [planningQueryIntentSummary, setPlanningQueryIntentSummary] = useState(null)
const [planningIntentResolved, setPlanningIntentResolved] = useState(null)
const pickerScrollRef = useRef(null)
@@ -157,6 +158,7 @@ export default function ExercisePickerModal({
setPlanningContextSummary(null)
setPlanningTargetProfileSummary(null)
setPlanningLlmRankApplied(false)
+ setPlanningQueryIntentSummary(null)
setPlanningIntentResolved(null)
return
}
@@ -277,6 +279,7 @@ export default function ExercisePickerModal({
Array.isArray(planningContext.plannedExerciseIds) && planningContext.plannedExerciseIds.length > 0
? planningContext.plannedExerciseIds.map((x) => Number(x)).filter((x) => Number.isFinite(x) && x > 0)
: undefined,
+ include_llm_intent: Boolean(query),
include_llm_rank: true,
query,
intent_hint: planningContext.intentHint || null,
@@ -287,6 +290,7 @@ export default function ExercisePickerModal({
setPlanningContextSummary(res?.context_summary || null)
setPlanningTargetProfileSummary(res?.target_profile_summary || null)
setPlanningLlmRankApplied(Boolean(res?.llm_rank_applied))
+ setPlanningQueryIntentSummary(res?.query_intent_summary || null)
setPlanningIntentResolved(res?.intent_resolved || null)
const hits = (Array.isArray(res?.hits) ? res.hits : []).map((h) => ({
id: h.id,
@@ -303,6 +307,7 @@ export default function ExercisePickerModal({
setPlanningContextSummary(null)
setPlanningTargetProfileSummary(null)
setPlanningLlmRankApplied(false)
+ setPlanningQueryIntentSummary(null)
setPlanningIntentResolved(null)
const batch = await api.listExercises({
...queryBase,
@@ -322,6 +327,7 @@ export default function ExercisePickerModal({
setPlanningContextSummary(null)
setPlanningTargetProfileSummary(null)
setPlanningLlmRankApplied(false)
+ setPlanningQueryIntentSummary(null)
setPlanningIntentResolved(null)
} finally {
setLoading(false)
@@ -545,10 +551,19 @@ export default function ExercisePickerModal({
Skill-Lücke zum bisherigen Plan berücksichtigt
) : null}
+ {planningQueryIntentSummary?.rationale ? (
+
+ {planningQueryIntentSummary.rationale}
+
+ ) : null}
{planningIntentResolved ? (
Modus: {planningIntentResolved.replace(/_/g, ' ')}
+ {planningQueryIntentSummary?.scenario
+ ? ` · ${String(planningQueryIntentSummary.scenario).replace(/_/g, ' ')}`
+ : null}
{planningLlmRankApplied ? ' · KI-Ranking aktiv' : null}
+ {planningQueryIntentSummary?.llm_applied ? ' · KI-Intent aktiv' : null}
) : null}