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}