Optimierung KI-Scuhe + Ki-Überarbeitungen der Übungen #49

Merged
Lars merged 16 commits from develop into main 2026-05-23 07:54:21 +02:00
11 changed files with 794 additions and 28 deletions
Showing only changes of commit 45e3b5f4f6 - Show all commits

View File

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

View File

@ -14,6 +14,7 @@ from prompt_resolver import MustacheRenderResult, render_mustache_template
_PLANNING_AI_SLUGS = frozenset(
{
"planning_exercise_search_rank",
"planning_exercise_search_intent",
}
)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 P0P4.
- **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`**

View File

@ -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
</p>
) : null}
{planningQueryIntentSummary?.rationale ? (
<p style={{ margin: '6px 0 0', fontSize: '11px', color: 'var(--text2)' }}>
{planningQueryIntentSummary.rationale}
</p>
) : null}
{planningIntentResolved ? (
<p style={{ margin: '6px 0 0', fontSize: '11px', color: 'var(--text3)' }}>
Modus: {planningIntentResolved.replace(/_/g, ' ')}
{planningQueryIntentSummary?.scenario
? ` · ${String(planningQueryIntentSummary.scenario).replace(/_/g, ' ')}`
: null}
{planningLlmRankApplied ? ' · KI-Ranking aktiv' : null}
{planningQueryIntentSummary?.llm_applied ? ' · KI-Intent aktiv' : null}
</p>
) : null}
</div>