Enhance Planning Exercise Functionality and LLM Integration
All checks were successful
Deploy Development / deploy (push) Successful in 41s
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 1m15s

- Added support for the new planning exercise expectation profile slug in the AI prompt runtime.
- Refactored SQL parameter handling in the planning exercise retrieval process to ensure correct binding for full-text search.
- Updated the planning exercise suggestion logic to incorporate LLM expectation handling, improving the accuracy of exercise recommendations.
- Introduced new functions to determine when to run the LLM expectation pipeline, enhancing the decision-making process for exercise suggestions.
- Incremented version to 0.8.176 and updated changelog to reflect these enhancements in planning AI capabilities.
This commit is contained in:
Lars 2026-05-22 23:08:53 +02:00
parent 04cc77d501
commit 5c882985e0
10 changed files with 332 additions and 15 deletions

View File

@ -15,6 +15,7 @@ _PLANNING_AI_SLUGS = frozenset(
{
"planning_exercise_search_rank",
"planning_exercise_search_intent",
"planning_exercise_expectation_profile",
}
)

View File

@ -0,0 +1,70 @@
-- Migration 074: KI-Prompt Planungs-Übungssuche — Erwartungsprofil aus Planungskontext (Preset)
-- 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_expectation_profile',
'Planungs-Übungssuche Erwartungsprofil',
'Leitet aus Einheit, Abschnitt, Anker und bisherigem Plan ein Erwartungsprofil für die nächste Übung ab (ohne Freitext-Anfrage).',
$t$Du bist Assistent für Kampfsport-Trainer in der Trainingsplanung.
Der Trainer wählt nächste Übung aus Kontext es gibt KEINE zusätzliche Freitext-Suchanfrage.
Deine Aufgabe: Aus dem Planungskontext und dem deterministischen Basis-Zielprofil ein präzises Erwartungsprofil ableiten:
- Was soll die nächste Übung fachlich leisten (Fortsetzen, Vertiefen, Lücke schließen, Abwechslung)?
- Welche Fähigkeiten, Fokus-Bereiche, Trainingsstile passen dazu?
- Berücksichtige: Rahmen/Einheit, Abschnittsziel (guidance_notes), letzte Übung im Abschnitt, Anker-Übung, Skill-Profile Einheit vs. Abschnitt, Skill-Lücken im Basisprofil.
Intent (intent): meist suggest_next oder continue_plan_goal; progression_next nur wenn Progressionsgraph/Anker klar nahelegt; deepen_exercise nur bei klarer Vertiefungslage.
continuation (optional, Kurzlabel):
- build_on_section: nahtlos an Abschnitt/letzte Übung anknüpfen
- close_skill_gap: fehlende Fähigkeiten aus Plan/Rahmen nachziehen
- deepen_anchor: Anker-Übung vertiefen
- variety: bewusst variieren nach bisherigem Block
- balance_load: Belastung ausgleichen / Tempo wechseln
Nutze skill_hints/focus_hints etc. mit Namen aus den Katalog-JSONs (beste Übereinstimmung).
emphasis: fast immer additive (baut auf Basisprofil auf), nur replace wenn Kontext eindeutig neuen Schwerpunkt verlangt.
Eingabe:
Heuristik-Intent: {{heuristic_intent}}
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": "suggest_next",
"scenario": "preset_next",
"continuation": "build_on_section",
"skill_hints": [{"name": "Kime", "weight": 0.9}],
"focus_hints": [],
"style_hints": [],
"training_type_hints": [],
"target_group_hints": [],
"requires_partner": null,
"emphasis": "additive",
"rationale": "Kurz auf Deutsch, 12 Sätze: warum diese nächste Übung sinnvoll ist"
}$t$,
'training',
'json',
'{"type":"object","required":["intent","scenario","rationale"],"properties":{"intent":{"type":"string"},"scenario":{"type":"string"},"continuation":{"type":"string"},"skill_hints":{"type":"array"},"emphasis":{"type":"string"},"rationale":{"type":"string"}}}'::jsonb,
true,
NULL,
true,
12
WHERE NOT EXISTS (SELECT 1 FROM ai_prompts WHERE slug = 'planning_exercise_expectation_profile');
UPDATE ai_prompts
SET default_template = template
WHERE slug = 'planning_exercise_expectation_profile'
AND (default_template IS NULL OR TRIM(default_template) = '');

View File

@ -0,0 +1,69 @@
"""
Preset Nächste aus Kontext: LLM leitet Erwartungsprofil aus Planungskontext ab.
Prompt: planning_exercise_expectation_profile (Migration 074)
"""
from __future__ import annotations
import logging
from typing import Any, Dict, Mapping, Optional, Tuple
from planning_exercise_intent import (
PlanningQueryIntentParsed,
_compact_json,
_load_compact_catalog,
_load_skills_catalog_compact,
parse_planning_query_intent_response,
)
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_expectation")
def try_build_planning_expectation_from_context(
cur,
*,
heuristic_intent: str,
context_summary: Mapping[str, Any],
target_profile_summary: Mapping[str, Any],
) -> Tuple[Optional[PlanningQueryIntentParsed], bool]:
"""
LLM-Erwartungsprofil für preset_next / leere Anfrage mit Planungsbezug.
Returns (parsed overlay, applied).
"""
api_key, _ = normalize_openrouter_env()
if not api_key:
return None, False
variables = {
"heuristic_intent": heuristic_intent or "suggest_next",
"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_expectation_profile", 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)
if parsed.scenario not in ("preset_next", "continue_plan", "free_search"):
parsed = parsed.model_copy(update={"scenario": "preset_next"})
return parsed, True
except AiPromptUnavailableError:
return None, False
except Exception as exc:
_logger.warning("Planungs-Erwartungsprofil-LLM fehlgeschlagen: %s", exc)
return None, False
__all__ = ["try_build_planning_expectation_from_context"]

View File

@ -62,15 +62,18 @@ def fetch_retrieval_candidate_rows(
) -> List[Dict[str, Any]]:
"""S1b-0: Profil-geführter Kandidaten-Pool."""
where = [vis_sql, "COALESCE(e.status, '') <> %s"]
params: List[Any] = list(vis_params)
params.append("archived")
params: List[Any] = []
if query:
ft_select = "ts_rank_cd(e.search_vector, plainto_tsquery('german', %s)) AS ft_rank"
# SELECT-Platzhalter steht im SQL vor WHERE — Query zuerst binden.
params.append(query)
else:
ft_select = "0.0::float AS ft_rank"
params.extend(vis_params)
params.append("archived")
ek_filtered: List[str] = []
if exercise_kind_any:
for raw in exercise_kind_any:

View File

@ -617,6 +617,8 @@ def suggest_planning_exercises(
weights = _intent_weights(intent)
target_profile_summary = target_profile.to_summary_dict(cur)
query_intent_applied = bool(query_intent_summary.get("llm_applied"))
llm_expectation_applied = bool(query_intent_summary.get("llm_expectation_applied"))
profile_llm_applied = bool(query_intent_summary.get("profile_llm_applied"))
profile_id = tenant.profile_id
role = tenant.global_role
@ -645,6 +647,7 @@ def suggest_planning_exercises(
retrieval_phase = compose_retrieval_phase(
profile_preselect=profile_preselect_applied,
query_intent=query_intent_applied,
llm_expectation=llm_expectation_applied,
llm_rank=False,
)
run_llm_rank = should_run_llm_rank_pipeline(
@ -652,6 +655,7 @@ def suggest_planning_exercises(
scenario_kind,
include_llm_rank=body.include_llm_rank,
query_intent_applied=query_intent_applied,
llm_expectation_applied=llm_expectation_applied,
hits=hits,
)
if run_llm_rank:
@ -678,6 +682,7 @@ def suggest_planning_exercises(
retrieval_phase = compose_retrieval_phase(
profile_preselect=profile_preselect_applied,
query_intent=query_intent_applied,
llm_expectation=llm_expectation_applied,
llm_rank=True,
)
tail = hits[pre_limit:]
@ -716,6 +721,8 @@ def suggest_planning_exercises(
"profile_preselect_applied": profile_preselect_applied,
"llm_rank_applied": llm_rank_applied,
"llm_intent_applied": query_intent_applied,
"llm_expectation_applied": llm_expectation_applied,
"profile_llm_applied": profile_llm_applied,
"intent_resolved": intent,
"intent_heuristic": heuristic_intent,
"query_normalized": query or None,

View File

@ -12,6 +12,7 @@ from __future__ import annotations
import re
from typing import Any, Dict, List, Mapping, Optional, Tuple
from planning_exercise_expectation import try_build_planning_expectation_from_context
from planning_exercise_intent import (
PlanningQueryIntentParsed,
resolve_query_intent_catalog_ids,
@ -37,6 +38,7 @@ _SIMPLE_PRESET_PATTERNS = (
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$",
r"^(n[aä]chste|naechste)\s+(übung|uebung)\s+planen\.?$",
)
_ADDITIVE_MARKERS = (
@ -89,6 +91,20 @@ def classify_planning_scenario(
return SCENARIO_FREE_SEARCH
def should_run_llm_expectation_pipeline(
scenario: str,
*,
include_llm_intent: bool,
has_planning_reference: bool,
) -> bool:
"""Preset/leere Anfrage mit Planungsbezug → LLM-Erwartungsprofil statt Query-Intent."""
if not include_llm_intent:
return False
if not has_planning_reference:
return False
return scenario == SCENARIO_PRESET_NEXT
def should_run_llm_intent_pipeline(
query: Optional[str],
scenario: str,
@ -125,15 +141,16 @@ def should_run_llm_rank_pipeline(
*,
include_llm_rank: bool,
query_intent_applied: bool,
llm_expectation_applied: bool = False,
hits: Sequence[Mapping[str, Any]],
) -> bool:
"""
Maximal ein LLM-Call pro Request: wenn Intent-LLM lief, kein Rerank.
Maximal ein LLM-Call pro Request: wenn Intent- oder Erwartungs-LLM lief, kein Rerank.
Rerank nur bei längerer, komplexer Anfrage und unklarem Hybrid-Ranking.
"""
if not include_llm_rank:
return False
if query_intent_applied:
if query_intent_applied or llm_expectation_applied:
return False
if scenario == SCENARIO_PRESET_NEXT:
return False
@ -241,7 +258,9 @@ def build_planning_target_with_query_pipeline(
scenario = classify_planning_scenario(query, heuristic_intent)
resolved_intent = heuristic_intent
llm_applied = False
llm_expectation_applied = False
parsed: Optional[PlanningQueryIntentParsed] = None
expectation_parsed: Optional[PlanningQueryIntentParsed] = None
resolved_skills: List[Dict[str, Any]] = []
if has_planning_reference:
@ -257,8 +276,44 @@ def build_planning_target_with_query_pipeline(
base = PlanningTargetProfile(sources=["query_only"])
base_summary = base.to_summary_dict(cur)
target = base
if should_run_llm_intent_pipeline(query, scenario, include_llm_intent=include_llm_intent):
if should_run_llm_expectation_pipeline(
scenario,
include_llm_intent=include_llm_intent,
has_planning_reference=has_planning_reference,
):
expectation_parsed, llm_expectation_applied = try_build_planning_expectation_from_context(
cur,
heuristic_intent=heuristic_intent,
context_summary=context_summary,
target_profile_summary=base_summary,
)
parsed = expectation_parsed
if parsed and llm_expectation_applied:
if parsed.intent in {
"suggest_next",
"progression_next",
"deepen_exercise",
"continue_plan_goal",
"free_search",
}:
resolved_intent = parsed.intent
focus, style, tt, tg, skills, resolved_skills = resolve_query_intent_catalog_ids(cur, parsed)
if focus or style or tt or tg or skills or parsed.rationale:
target = merge_query_overlay_into_target(
base,
focus=focus,
style=style,
tt=tt,
tg=tg,
skills=skills,
emphasis=parsed.emphasis or "additive",
scenario=SCENARIO_PRESET_NEXT,
)
if "context_expectation" not in target.sources:
target.sources.append("context_expectation")
elif 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),
@ -268,8 +323,7 @@ def build_planning_target_with_query_pipeline(
target_profile_summary=base_summary,
)
target = base
if parsed and llm_applied:
if parsed and llm_applied and not llm_expectation_applied:
if parsed.intent in {
"suggest_next",
"progression_next",
@ -307,6 +361,8 @@ def build_planning_target_with_query_pipeline(
"intent": resolved_intent,
"heuristic_intent": heuristic_intent,
"llm_applied": llm_applied,
"llm_expectation_applied": llm_expectation_applied,
"profile_llm_applied": llm_applied or llm_expectation_applied,
"emphasis": parsed.emphasis if parsed else None,
"rationale": (parsed.rationale if parsed else None),
"skill_hints_resolved": resolved_skills,
@ -331,12 +387,15 @@ def compose_retrieval_phase(
*,
profile_preselect: bool = False,
query_intent: bool = False,
llm_expectation: bool = False,
llm_rank: bool = False,
) -> str:
parts = ["profile_v1"]
if profile_preselect:
parts.append("profile_preselect")
if query_intent:
if llm_expectation:
parts.append("llm_expectation")
elif query_intent:
parts.append("query_intent")
if llm_rank:
parts.append("llm_rank")
@ -351,6 +410,7 @@ __all__ = [
"compose_retrieval_phase",
"is_simple_preset_query",
"merge_query_overlay_into_target",
"should_run_llm_expectation_pipeline",
"should_run_llm_intent_pipeline",
"should_run_llm_rank_pipeline",
"deterministic_rank_confident",

View File

@ -0,0 +1,43 @@
"""Tests Planungs-Retrieval SQL-Parameter."""
from planning_exercise_retrieval import fetch_retrieval_candidate_rows
def test_fetch_retrieval_binds_query_before_visibility_params():
captured = {}
class _Cur:
def execute(self, sql, params):
captured["sql"] = sql
captured["params"] = list(params)
def fetchall(self):
return [
{
"id": 1,
"title": "Test",
"summary": "",
"primary_focus_name": None,
"ft_rank": 0.2,
}
]
fetch_retrieval_candidate_rows(
_Cur(),
vis_sql="(e.visibility = 'official' OR (e.visibility = 'private' AND e.created_by = %s))",
vis_params=[42],
query="nächste Übung planen",
exercise_kind_any=None,
target=__import__(
"planning_exercise_profiles", fromlist=["PlanningTargetProfile"]
).PlanningTargetProfile(),
progression_successor_ids=set(),
anchor_skill_ids={7},
raw_pool_limit=10,
)
params = captured["params"]
assert params[0] == "nächste Übung planen"
assert params[1] == 42
assert params[2] == "archived"
assert params[-2] == "nächste Übung planen"
assert params[-1] == 10

View File

@ -25,8 +25,10 @@ def test_resolve_planning_exercise_intent_keywords():
def test_classify_planning_scenario_preset():
assert is_simple_preset_query("Schlage mir die nächste Übung vor")
assert is_simple_preset_query("nächste Übung planen")
assert classify_planning_scenario("", "suggest_next") == SCENARIO_PRESET_NEXT
assert classify_planning_scenario("nächste übung", "suggest_next") == SCENARIO_PRESET_NEXT
assert classify_planning_scenario("nächste Übung planen", "suggest_next") == SCENARIO_PRESET_NEXT
def test_classify_planning_scenario_additive():
@ -78,6 +80,47 @@ def test_compose_retrieval_phase():
)
def test_should_run_llm_expectation_for_preset_with_planning_ref():
from planning_exercise_target_pipeline import should_run_llm_expectation_pipeline
assert should_run_llm_expectation_pipeline(
SCENARIO_PRESET_NEXT,
include_llm_intent=True,
has_planning_reference=True,
)
assert not should_run_llm_expectation_pipeline(
SCENARIO_PRESET_NEXT,
include_llm_intent=False,
has_planning_reference=True,
)
assert not should_run_llm_expectation_pipeline(
SCENARIO_ADDITIVE,
include_llm_intent=True,
has_planning_reference=True,
)
def test_should_skip_llm_rank_when_expectation_applied():
from planning_exercise_target_pipeline import SCENARIO_PRESET_NEXT, should_run_llm_rank_pipeline
hits = [{"score": 0.5}, {"score": 0.48}, {"score": 0.47}, {"score": 0.46}]
assert not should_run_llm_rank_pipeline(
"",
SCENARIO_PRESET_NEXT,
include_llm_rank=True,
query_intent_applied=False,
llm_expectation_applied=True,
hits=hits,
)
def test_compose_retrieval_phase_llm_expectation():
assert (
compose_retrieval_phase(llm_expectation=True)
== "profile_v1+llm_expectation"
)
def test_query_only_expectation_without_planning_reference():
from planning_exercise_profiles import PlanningTargetProfile
from planning_exercise_target_pipeline import build_planning_target_with_query_pipeline

View File

@ -1,8 +1,8 @@
# Shinkan Jinkendo Version Information
APP_VERSION = "0.8.174"
APP_VERSION = "0.8.176"
BUILD_DATE = "2026-05-22"
DB_SCHEMA_VERSION = "20260531073"
DB_SCHEMA_VERSION = "20260531074"
MODULE_VERSIONS = {
"legal_documents": "1.4.0", # Admin: Live-Vorschau pro Abschnitt + modale Vollvorschau (Editor + Dokumentenliste)
@ -22,13 +22,13 @@ MODULE_VERSIONS = {
"admin_ai_prompts": "1.0.3", # Migration 070: openrouter_model; PUT/Liste/Detail
"ai_prompt_job": "0.2.1", # want_instructions; run_exercise_form_ai_suggestion
"ai_prompt_context": "0.2.0", # preparation/trainer_notes; has_instruction_source_text
"ai_prompt_runtime": "0.2.1", # Kontext-Art planning_exercise_search; load_and_render_ai_prompt
"ai_prompt_runtime": "0.2.2", # Slug planning_exercise_expectation_profile
"groups": "0.1.0",
"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.37.0", # Planungs-KI P1: Szenario-Pipeline + Query-Intent-Overlay
"planning_exercise_suggest": "0.6.0", # Abschnitts-/Skill-Kontext; expectation_mode hybrid|query_only
"planning_exercise_suggest": "0.7.0", # LLM-Erwartungsprofil aus Kontext (preset); Migration 074
"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,22 @@ MODULE_VERSIONS = {
}
CHANGELOG = [
{
"version": "0.8.176",
"date": "2026-05-22",
"changes": [
"Fix: Planungs-Übungssuche mit Suchtext — SQL-Parameter für ts_rank/plainto_tsquery korrekt gebunden (500).",
"Preset-Erkennung: „nächste Übung planen“.",
],
},
{
"version": "0.8.175",
"date": "2026-05-22",
"changes": [
"Planungs-KI: „Nächste aus Kontext“ — LLM leitet Erwartungsprofil aus Planungskontext ab (Prompt 074).",
"API: llm_expectation_applied, profile_llm_applied; Retrieval-Phase llm_expectation; max. 1 LLM-Call.",
],
},
{
"version": "0.8.174",
"date": "2026-05-22",

View File

@ -419,7 +419,8 @@ export default function ExercisePickerModal({
.map((x) => Number(x))
.filter((x) => Number.isFinite(x) && x > 0)
: undefined,
include_llm_intent: query.length >= PLANNING_LLM_INTENT_MIN_CHARS,
include_llm_intent:
query.length >= PLANNING_LLM_INTENT_MIN_CHARS || !(query || '').trim(),
include_llm_rank: query.length >= PLANNING_LLM_RANK_MIN_CHARS,
query,
intent_hint:
@ -452,7 +453,7 @@ export default function ExercisePickerModal({
setPlanningContextSummary(res?.context_summary || null)
setPlanningTargetProfileSummary(res?.target_profile_summary || null)
setPlanningLlmRankApplied(Boolean(res?.llm_rank_applied))
setPlanningLlmIntentApplied(Boolean(res?.llm_intent_applied))
setPlanningLlmIntentApplied(Boolean(res?.profile_llm_applied ?? res?.llm_intent_applied))
setPlanningRetrievalPhase(res?.retrieval_phase || '')
setPlanningQueryIntentSummary(res?.query_intent_summary || null)
setPlanningIntentResolved(res?.intent_resolved || null)
@ -748,7 +749,11 @@ export default function ExercisePickerModal({
? ` · ${String(planningQueryIntentSummary.scenario).replace(/_/g, ' ')}`
: null}
{planningLlmRankApplied ? ' · KI-Ranking aktiv' : null}
{planningLlmIntentApplied ? ' · KI-Intent aktiv' : null}
{planningLlmIntentApplied
? planningQueryIntentSummary?.llm_expectation_applied
? ' · KI-Erwartungsprofil aktiv'
: ' · KI-Intent aktiv'
: null}
{!planningLlmRankApplied && !planningLlmIntentApplied && usePlanningSearch
? ' · ohne LLM (Profil/Hybrid)'
: null}