Implement Phase 1 of Planning Exercise Suggestion with Scenario Pipeline and LLM Intent Overlay
All checks were successful
Deploy Development / deploy (push) Successful in 43s
Test Suite / pytest-backend (push) Successful in 43s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 13s
Test Suite / k6 /health Baseline (push) Successful in 34s
Test Suite / playwright-tests (push) Successful in 1m14s
All checks were successful
Deploy Development / deploy (push) Successful in 43s
Test Suite / pytest-backend (push) Successful in 43s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 13s
Test Suite / k6 /health Baseline (push) Successful in 34s
Test Suite / playwright-tests (push) Successful in 1m14s
- 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.
This commit is contained in:
parent
207817376d
commit
45e3b5f4f6
|
|
@ -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`).
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ from prompt_resolver import MustacheRenderResult, render_mustache_template
|
|||
_PLANNING_AI_SLUGS = frozenset(
|
||||
{
|
||||
"planning_exercise_search_rank",
|
||||
"planning_exercise_search_intent",
|
||||
}
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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) = '');
|
||||
272
backend/planning_exercise_intent.py
Normal file
272
backend/planning_exercise_intent.py
Normal 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",
|
||||
]
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
284
backend/planning_exercise_target_pipeline.py
Normal file
284
backend/planning_exercise_target_pipeline.py
Normal 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",
|
||||
]
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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`**
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user