From a0a891e55030a4ba5f5f4c44dc8bd7c74a85c777 Mon Sep 17 00:00:00 2001 From: Lars Date: Sat, 23 May 2026 10:26:03 +0200 Subject: [PATCH 1/3] Implement Phase B Enhancements for Planning Exercise Profiles - Added support for section guidance notes and titles in the planning target profile, enabling richer context for exercise suggestions. - Introduced deterministic text-to-catalog signal mapping, allowing for improved integration of planning text signals into the exercise retrieval process. - Implemented a partner-related filter in exercise retrieval, enhancing the relevance of suggested exercises based on user intent. - Updated the retrieval phase to account for text signals, improving the accuracy of exercise recommendations. - Incremented version to 0.8.181 and updated changelog to reflect these significant enhancements in planning AI capabilities. --- backend/planning_exercise_profiles.py | 32 +++ backend/planning_exercise_retrieval.py | 18 +- backend/planning_exercise_suggest.py | 10 +- backend/planning_exercise_target_pipeline.py | 5 + backend/planning_exercise_text_signals.py | 201 ++++++++++++++++++ ...est_planning_exercise_retrieval_partner.py | 7 + .../test_planning_exercise_text_signals.py | 47 ++++ backend/version.py | 12 +- 8 files changed, 328 insertions(+), 4 deletions(-) create mode 100644 backend/planning_exercise_text_signals.py create mode 100644 backend/tests/test_planning_exercise_retrieval_partner.py create mode 100644 backend/tests/test_planning_exercise_text_signals.py diff --git a/backend/planning_exercise_profiles.py b/backend/planning_exercise_profiles.py index 26df100..0e6e985 100644 --- a/backend/planning_exercise_profiles.py +++ b/backend/planning_exercise_profiles.py @@ -8,6 +8,10 @@ from __future__ import annotations from dataclasses import dataclass, field from typing import Any, Dict, List, Optional, Sequence, Set, Tuple +from planning_exercise_text_signals import ( + load_framework_planning_text_parts, + resolve_planning_text_to_catalog_weights, +) from skill_scoring import ( ExerciseOccurrence, collect_unit_exercise_occurrences, @@ -339,6 +343,8 @@ def build_planning_target_profile( section_planned_exercise_ids: Optional[Sequence[int]] = None, anchor_exercise_id: Optional[int], intent: str, + section_guidance_notes: Optional[str] = None, + section_title: Optional[str] = None, ) -> PlanningTargetProfile: sources: List[str] = [] focus: Dict[int, float] = {} @@ -414,6 +420,30 @@ def build_planning_target_profile( tg = _merge_weight_maps(tg, ap.target_group_ids, scale=0.75) sources.append("anchor_exercise") + text_parts: List[str] = [] + if (section_title or "").strip(): + text_parts.append(str(section_title).strip()) + if (section_guidance_notes or "").strip(): + text_parts.append(str(section_guidance_notes).strip()) + if fw: + text_parts.extend( + load_framework_planning_text_parts( + cur, + int(fw["framework_program_id"]), + slot_id=int(fw["slot_id"]) if fw.get("slot_id") else None, + ) + ) + if text_parts: + blob = "\n".join(text_parts) + tf, ts, ttt, ttg, tsk = resolve_planning_text_to_catalog_weights(cur, blob) + if tf or ts or ttt or ttg or tsk: + focus = _merge_weight_maps(focus, tf, scale=0.88) + style = _merge_weight_maps(style, ts, scale=0.8) + tt = _merge_weight_maps(tt, ttt, scale=0.8) + tg = _merge_weight_maps(tg, ttg, scale=0.8) + skill_target = _merge_weight_maps(skill_target, tsk, scale=0.92) + sources.append("planning_text_signals") + skill_target = _normalize_weight_map(skill_target) skill_plan_norm = _normalize_weight_map(skill_plan) skill_gap: Dict[int, float] = {} @@ -470,6 +500,8 @@ def score_exercise_against_target( reasons.append("Deckt Skill-Lücke im bisherigen Plan") if "query_intent" in (target.sources or []): reasons.append("Passt zur KI-interpretierten Suchanfrage") + if "planning_text_signals" in (target.sources or []): + reasons.append("Passt zu Abschnitts- oder Rahmen-Zieltext") # Intent-gewichtete Dimensionen (Summe = 1.0) if intent == INTENT_FREE_SEARCH: diff --git a/backend/planning_exercise_retrieval.py b/backend/planning_exercise_retrieval.py index a15bd04..b1af575 100644 --- a/backend/planning_exercise_retrieval.py +++ b/backend/planning_exercise_retrieval.py @@ -17,6 +17,17 @@ from planning_exercise_profiles import ( _MAX_LIBRARY_ROWS = 8000 _PROFILE_LOAD_BATCH = 400 +_PARTNER_TEXT_MARKERS = ("partner", "paar", "paarweise", "zu zweit") + + +def _exercise_looks_partner_related(row: Mapping[str, Any]) -> bool: + parts = [ + str(row.get("method_archetype") or ""), + str(row.get("title") or ""), + str(row.get("summary") or ""), + ] + blob = " ".join(parts).lower() + return any(m in blob for m in _PARTNER_TEXT_MARKERS) def _skill_jaccard(a: Set[int], b: Set[int]) -> float: @@ -72,7 +83,7 @@ def fetch_all_visible_exercise_rows( params.extend(ek_filtered) sql = f""" - SELECT e.id, e.title, e.summary, + SELECT e.id, e.title, e.summary, e.method_archetype, ( SELECT fa.name FROM exercise_focus_areas efa JOIN focus_areas fa ON fa.id = efa.focus_area_id @@ -139,6 +150,7 @@ def rank_visible_library_hits( anchor_skills = set(pack.get("anchor_skill_ids") or []) anchor_id = pack.get("anchor_exercise_id") progression_notes = pack.get("progression_edge_notes") or {} + requires_partner = pack.get("requires_partner") last_planned_skills: Set[int] = set() planned_ids = pack.get("planned_exercise_ids") or [] @@ -154,6 +166,10 @@ def rank_visible_library_hits( eid = int(row["id"]) if anchor_id and eid == int(anchor_id): continue + if requires_partner is True and not _exercise_looks_partner_related(row): + continue + if requires_partner is False and _exercise_looks_partner_related(row): + continue cand_rows.append(row) cand_ids = [int(r["id"]) for r in cand_rows] diff --git a/backend/planning_exercise_suggest.py b/backend/planning_exercise_suggest.py index 8b6a6cc..dc5f148 100644 --- a/backend/planning_exercise_suggest.py +++ b/backend/planning_exercise_suggest.py @@ -638,14 +638,20 @@ def suggest_planning_exercises( target=target_profile, intent=intent, intent_weights=weights, - pack=pack, + pack={ + **pack, + "requires_partner": query_intent_summary.get("requires_partner"), + }, ) + text_signals_applied = "planning_text_signals" in (target_profile.sources or []) + planned_set = set(pack["planned_exercise_ids"]) llm_rank_applied = False retrieval_phase = compose_retrieval_phase( full_library=full_library_ranked, + text_signals=text_signals_applied, query_intent=query_intent_applied, llm_expectation=llm_expectation_applied, llm_rank=False, @@ -681,6 +687,7 @@ def suggest_planning_exercises( if llm_rank_applied: retrieval_phase = compose_retrieval_phase( full_library=full_library_ranked, + text_signals=text_signals_applied, query_intent=query_intent_applied, llm_expectation=llm_expectation_applied, llm_rank=True, @@ -719,6 +726,7 @@ def suggest_planning_exercises( "query_intent_summary": query_intent_summary, "retrieval_phase": retrieval_phase, "full_library_ranked": full_library_ranked, + "text_signals_applied": text_signals_applied, "profile_preselect_applied": False, "llm_rank_applied": llm_rank_applied, "llm_intent_applied": query_intent_applied, diff --git a/backend/planning_exercise_target_pipeline.py b/backend/planning_exercise_target_pipeline.py index 199a794..ac4b403 100644 --- a/backend/planning_exercise_target_pipeline.py +++ b/backend/planning_exercise_target_pipeline.py @@ -271,6 +271,8 @@ def build_planning_target_with_query_pipeline( section_planned_exercise_ids=section_planned_exercise_ids or [], anchor_exercise_id=anchor_exercise_id, intent=heuristic_intent, + section_guidance_notes=(context_summary.get("section_guidance_notes") or None), + section_title=(context_summary.get("section_title") or None), ) else: base = PlanningTargetProfile(sources=["query_only"]) @@ -387,6 +389,7 @@ def compose_retrieval_phase( *, full_library: bool = False, profile_preselect: bool = False, + text_signals: bool = False, query_intent: bool = False, llm_expectation: bool = False, llm_rank: bool = False, @@ -394,6 +397,8 @@ def compose_retrieval_phase( parts = ["profile_v1"] if full_library or profile_preselect: parts.append("full_library") + if text_signals: + parts.append("text_signals") if llm_expectation: parts.append("llm_expectation") elif query_intent: diff --git a/backend/planning_exercise_text_signals.py b/backend/planning_exercise_text_signals.py new file mode 100644 index 0000000..71fd442 --- /dev/null +++ b/backend/planning_exercise_text_signals.py @@ -0,0 +1,201 @@ +""" +Phase B: Deterministische Text→Katalog-Signale für PlanningTargetProfile. + +Mappt Abschnitts-guidance, Rahmen-Ziele/-Notizen und Programmbeschreibung +auf Skill-/Katalog-Gewichte (ohne LLM). +""" +from __future__ import annotations + +import re +from typing import Any, Dict, List, Mapping, Optional, Sequence, Tuple + +_MIN_SKILL_NAME_LEN = 3 +_MAX_SKILL_MATCHES = 12 +_MAX_CATALOG_MATCHES = 6 + + +def _normalize_text_blob(*parts: Optional[str]) -> str: + chunks: List[str] = [] + for p in parts: + s = (p or "").strip() + if s: + chunks.append(s) + return "\n".join(chunks).lower() + + +def _load_skills_for_text_match(cur) -> List[Tuple[int, str, int]]: + cur.execute( + """ + SELECT id, name FROM skills + WHERE (status IS NULL OR status = 'active') + AND name IS NOT NULL AND TRIM(name) <> '' + ORDER BY LENGTH(name) DESC, name ASC + """ + ) + out: List[Tuple[int, str, int]] = [] + for row in cur.fetchall(): + name = str(row.get("name") or "").strip() + if len(name) < _MIN_SKILL_NAME_LEN: + continue + out.append((int(row["id"]), name.lower(), len(name))) + return out + + +def _load_catalog_names(cur, table: str, id_col: str = "id", name_col: str = "name") -> List[Tuple[int, str, int]]: + cur.execute( + f""" + SELECT {id_col} AS id, {name_col} AS name + FROM {table} + WHERE {name_col} IS NOT NULL AND TRIM({name_col}) <> '' + ORDER BY LENGTH({name_col}) DESC, {name_col} ASC + """ + ) + out: List[Tuple[int, str, int]] = [] + for row in cur.fetchall(): + name = str(row.get("name") or "").strip() + if len(name) < 2: + continue + out.append((int(row["id"]), name.lower(), len(name))) + return out + + +def _match_catalog_names_in_text( + text: str, + catalog_rows: Sequence[Tuple[int, str, int]], + *, + weight: float = 0.85, + limit: int = _MAX_CATALOG_MATCHES, +) -> Dict[int, float]: + if not text or not catalog_rows: + return {} + out: Dict[int, float] = {} + for cid, name_lower, _ in catalog_rows: + if len(out) >= limit: + break + if len(name_lower) < 2: + continue + if name_lower in text: + out[cid] = max(out.get(cid, 0.0), weight) + return out + + +def _match_skills_in_text( + text: str, + skill_rows: Sequence[Tuple[int, str, int]], + *, + limit: int = _MAX_SKILL_MATCHES, +) -> Dict[int, float]: + if not text or not skill_rows: + return {} + out: Dict[int, float] = {} + for sid, name_lower, name_len in skill_rows: + if len(out) >= limit: + break + if name_len < _MIN_SKILL_NAME_LEN: + continue + if name_lower in text: + w = min(1.0, 0.72 + min(name_len, 20) * 0.012) + out[sid] = max(out.get(sid, 0.0), w) + return out + + +def load_framework_planning_text_parts( + cur, + framework_program_id: int, + *, + slot_id: Optional[int] = None, +) -> List[str]: + """Sammelt Rahmen-Texte für Text-Signal-Matching.""" + parts: List[str] = [] + cur.execute( + "SELECT description FROM training_framework_programs WHERE id = %s", + (int(framework_program_id),), + ) + row = cur.fetchone() + if row and (row.get("description") or "").strip(): + parts.append(str(row["description"]).strip()) + + cur.execute( + """ + SELECT title, notes FROM training_framework_goals + WHERE framework_program_id = %s + ORDER BY sort_order ASC + """, + (int(framework_program_id),), + ) + for g in cur.fetchall(): + t = (g.get("title") or "").strip() + n = (g.get("notes") or "").strip() + if t: + parts.append(t) + if n: + parts.append(n) + + if slot_id: + cur.execute( + "SELECT title, notes FROM training_framework_slots WHERE id = %s", + (int(slot_id),), + ) + srow = cur.fetchone() + if srow: + st = (srow.get("title") or "").strip() + sn = (srow.get("notes") or "").strip() + if st: + parts.append(st) + if sn: + parts.append(sn) + + return parts + + +def resolve_planning_text_to_catalog_weights( + cur, + text_blob: str, +) -> Tuple[Dict[int, float], Dict[int, float], Dict[int, float], Dict[int, float], Dict[int, float]]: + """ + Returns: focus, style, training_type, target_group, skill weight maps. + """ + text = _normalize_text_blob(text_blob) + if not text or len(text) < 3: + return {}, {}, {}, {}, {} + + skill_rows = _load_skills_for_text_match(cur) + focus_rows = _load_catalog_names(cur, "focus_areas") + style_rows = _load_catalog_names(cur, "style_directions") + tt_rows = _load_catalog_names(cur, "training_types") + tg_rows = _load_catalog_names(cur, "target_groups") + + skills = _match_skills_in_text(text, skill_rows) + focus = _match_catalog_names_in_text(text, focus_rows, weight=0.88) + style = _match_catalog_names_in_text(text, style_rows, weight=0.82) + tt = _match_catalog_names_in_text(text, tt_rows, weight=0.82) + tg = _match_catalog_names_in_text(text, tg_rows, weight=0.8) + + if re.search(r"\bpartner\b|\bpaar\b|\bpaarweise\b|\bzu zweit\b", text): + for gid, name_lower, _ in tg_rows: + if "partner" in name_lower or "paar" in name_lower: + tg[gid] = max(tg.get(gid, 0.0), 0.9) + break + + return focus, style, tt, tg, skills + + +def merge_text_signal_summary( + summary: Mapping[str, Any], + *, + text_sources: Sequence[str], + matched_skills: Sequence[Mapping[str, Any]], +) -> Dict[str, Any]: + out = dict(summary) + if text_sources: + out["text_signal_sources"] = list(text_sources) + if matched_skills: + out["text_signal_skills"] = list(matched_skills)[:8] + return out + + +__all__ = [ + "load_framework_planning_text_parts", + "merge_text_signal_summary", + "resolve_planning_text_to_catalog_weights", +] diff --git a/backend/tests/test_planning_exercise_retrieval_partner.py b/backend/tests/test_planning_exercise_retrieval_partner.py new file mode 100644 index 0000000..e49fed8 --- /dev/null +++ b/backend/tests/test_planning_exercise_retrieval_partner.py @@ -0,0 +1,7 @@ +"""Tests Partner-Filter im Planungs-Retrieval.""" +from planning_exercise_retrieval import _exercise_looks_partner_related + + +def test_exercise_partner_heuristic(): + assert _exercise_looks_partner_related({"title": "Partner-Fangspiel", "summary": ""}) + assert not _exercise_looks_partner_related({"title": "Kihon Solo", "summary": "Allein"}) diff --git a/backend/tests/test_planning_exercise_text_signals.py b/backend/tests/test_planning_exercise_text_signals.py new file mode 100644 index 0000000..ae3a558 --- /dev/null +++ b/backend/tests/test_planning_exercise_text_signals.py @@ -0,0 +1,47 @@ +"""Tests Phase B: Text-Signale für PlanningTargetProfile.""" +from planning_exercise_text_signals import resolve_planning_text_to_catalog_weights + + +def test_resolve_planning_text_matches_skill_and_partner_hint(): + class _Cur: + def execute(self, sql, params=None): + self._sql = sql + + def fetchall(self): + sql = getattr(self, "_sql", "") + if "FROM skills" in sql: + return [ + {"id": 5, "name": "Kime"}, + {"id": 8, "name": "Schnellkraft"}, + {"id": 2, "name": "Ab"}, + ] + if "FROM focus_areas" in sql: + return [{"id": 10, "name": "Karate Technik"}] + if "FROM style_directions" in sql: + return [] + if "FROM training_types" in sql: + return [] + if "FROM target_groups" in sql: + return [{"id": 3, "name": "Partnerübung"}] + return [] + + focus, style, tt, tg, skills = resolve_planning_text_to_catalog_weights( + _Cur(), + "Abschnitt: Kime vertiefen mit Partnerübung", + ) + assert skills.get(5, 0) > 0 + assert 8 not in skills + assert tg.get(3, 0) > 0 + assert not style and not tt + + +def test_resolve_planning_text_empty(): + class _Cur: + def execute(self, sql, params=None): + pass + + def fetchall(self): + return [] + + focus, style, tt, tg, skills = resolve_planning_text_to_catalog_weights(_Cur(), " ") + assert not focus and not skills diff --git a/backend/version.py b/backend/version.py index 5130641..62b9181 100644 --- a/backend/version.py +++ b/backend/version.py @@ -1,6 +1,6 @@ # Shinkan Jinkendo Version Information -APP_VERSION = "0.8.180" +APP_VERSION = "0.8.181" BUILD_DATE = "2026-05-23" DB_SCHEMA_VERSION = "20260531074" @@ -29,7 +29,7 @@ MODULE_VERSIONS = { "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.8.0", # Phase A: Voll-Library-Ranking gegen Erwartungsprofil + "planning_exercise_suggest": "0.9.0", # Phase B: Text-Signale guidance/Rahmen-Ziele; requires_partner-Filter "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 @@ -44,6 +44,14 @@ MODULE_VERSIONS = { } CHANGELOG = [ + { + "version": "0.8.181", + "date": "2026-05-23", + "changes": [ + "Planungs-KI Phase B: guidance_notes + Rahmen-Ziele/Notizen → Text-Signale im Erwartungsprofil (planning_text_signals).", + "requires_partner aus Intent filtert Übungen; retrieval_phase +text_signals; Grund „Passt zu Abschnitts-/Rahmen-Zieltext“.", + ], + }, { "version": "0.8.180", "date": "2026-05-23", From 50aff849d89bc007fb209e3d0bcfdcffd138986d Mon Sep 17 00:00:00 2001 From: Lars Date: Sat, 23 May 2026 10:28:03 +0200 Subject: [PATCH 2/3] Enhance Planning Exercise Suggestion and Ranking Logic - Introduced a new function `hybrid_ranking_ambiguous` to determine when to rerank candidates based on score proximity, improving the decision-making process for exercise suggestions. - Updated `should_run_llm_rank_pipeline` to incorporate the new ranking logic and handle scenarios with ambiguous rankings more effectively. - Adjusted the frontend to always include LLM ranking in requests, ensuring consistent behavior across different query lengths. - Incremented version to 0.8.182 and updated changelog to reflect these enhancements in planning AI capabilities. --- backend/planning_exercise_suggest.py | 1 + backend/planning_exercise_target_pipeline.py | 60 +++++++++++++++---- .../tests/test_planning_exercise_suggest.py | 38 ++++++++++-- backend/version.py | 12 +++- .../src/components/ExercisePickerModal.jsx | 3 +- 5 files changed, 95 insertions(+), 19 deletions(-) diff --git a/backend/planning_exercise_suggest.py b/backend/planning_exercise_suggest.py index dc5f148..4085b22 100644 --- a/backend/planning_exercise_suggest.py +++ b/backend/planning_exercise_suggest.py @@ -662,6 +662,7 @@ def suggest_planning_exercises( include_llm_rank=body.include_llm_rank, query_intent_applied=query_intent_applied, llm_expectation_applied=llm_expectation_applied, + has_planning_reference=has_plan_ref, hits=hits, ) if run_llm_rank: diff --git a/backend/planning_exercise_target_pipeline.py b/backend/planning_exercise_target_pipeline.py index ac4b403..3846c34 100644 --- a/backend/planning_exercise_target_pipeline.py +++ b/backend/planning_exercise_target_pipeline.py @@ -10,7 +10,7 @@ Ablauf: from __future__ import annotations import re -from typing import Any, Dict, List, Mapping, Optional, Tuple +from typing import Any, Dict, List, Mapping, Optional, Sequence, Tuple from planning_exercise_expectation import try_build_planning_expectation_from_context from planning_exercise_intent import ( @@ -135,6 +135,31 @@ def deterministic_rank_confident(hits: Sequence[Mapping[str, Any]], *, gap_thres return (top - fourth) >= gap_threshold +def hybrid_ranking_ambiguous( + hits: Sequence[Mapping[str, Any]], + *, + top_four_gap: float = 0.08, + top_ten_gap: float = 0.055, +) -> bool: + """True wenn Top-Kandidaten scores zu nah beieinander liegen — Rerank lohnt sich.""" + if len(hits) < 3: + return False + top = float(hits[0].get("score") or 0.0) + if len(hits) >= 4: + fourth = float(hits[3].get("score") or 0.0) + if (top - fourth) < top_four_gap: + return True + if len(hits) >= 10: + tenth = float(hits[9].get("score") or 0.0) + if (top - tenth) < top_ten_gap: + return True + elif len(hits) >= 2: + tail = float(hits[min(len(hits) - 1, 9)].get("score") or 0.0) + if (top - tail) < top_four_gap: + return True + return False + + def should_run_llm_rank_pipeline( query: Optional[str], scenario: str, @@ -142,26 +167,38 @@ def should_run_llm_rank_pipeline( include_llm_rank: bool, query_intent_applied: bool, llm_expectation_applied: bool = False, + has_planning_reference: bool = True, hits: Sequence[Mapping[str, Any]], ) -> bool: """ - 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. + Phase B2: Rerank bei unklarem Hybrid-Ranking — auch nach Erwartungs-/Intent-LLM. + + Budget: max. 2 LLM-Calls pro Suche (Profil-LLM + optional Rerank). """ if not include_llm_rank: return False - if query_intent_applied or llm_expectation_applied: + if len(hits) < 3: return False - if scenario == SCENARIO_PRESET_NEXT: + if not hybrid_ranking_ambiguous(hits): return False + q = _normalize_query(query) - if not q: - return False + profile_llm = query_intent_applied or llm_expectation_applied + + if scenario == SCENARIO_PRESET_NEXT: + return has_planning_reference + + if scenario == SCENARIO_FREE_SEARCH: + if len(q) < 10 and not profile_llm: + return False + return True + if scenario == SCENARIO_ADDITIVE: - return len(q) >= 12 and not deterministic_rank_confident(hits) - if len(q) < 22: - return False - return not deterministic_rank_confident(hits) + return len(q) >= 8 or profile_llm + + if profile_llm: + return True + return len(q) >= 14 def _recalculate_skill_gap(target: PlanningTargetProfile) -> PlanningTargetProfile: @@ -420,4 +457,5 @@ __all__ = [ "should_run_llm_intent_pipeline", "should_run_llm_rank_pipeline", "deterministic_rank_confident", + "hybrid_ranking_ambiguous", ] diff --git a/backend/tests/test_planning_exercise_suggest.py b/backend/tests/test_planning_exercise_suggest.py index 4f5a951..34d026e 100644 --- a/backend/tests/test_planning_exercise_suggest.py +++ b/backend/tests/test_planning_exercise_suggest.py @@ -56,11 +56,11 @@ def test_should_skip_llm_intent_short_free_search(): ) -def test_should_skip_llm_rank_when_intent_already_applied(): +def test_should_run_llm_rank_when_intent_applied_and_ambiguous(): from planning_exercise_target_pipeline import SCENARIO_ADDITIVE, 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( + assert should_run_llm_rank_pipeline( "Baut auf dem Plan auf und trainiert zusätzlich Schnellkraft mit Partner", SCENARIO_ADDITIVE, include_llm_rank=True, @@ -69,6 +69,36 @@ def test_should_skip_llm_rank_when_intent_already_applied(): ) +def test_should_skip_llm_rank_when_ranking_confident(): + from planning_exercise_target_pipeline import SCENARIO_PRESET_NEXT, should_run_llm_rank_pipeline + + hits = [{"score": 0.9}, {"score": 0.5}, {"score": 0.4}, {"score": 0.3}] + assert not should_run_llm_rank_pipeline( + "", + SCENARIO_PRESET_NEXT, + include_llm_rank=True, + query_intent_applied=False, + llm_expectation_applied=True, + has_planning_reference=True, + hits=hits, + ) + + +def test_should_run_llm_rank_for_preset_when_ambiguous(): + from planning_exercise_target_pipeline import SCENARIO_PRESET_NEXT, should_run_llm_rank_pipeline + + hits = [{"score": 0.42}, {"score": 0.41}, {"score": 0.4}, {"score": 0.39}] + assert should_run_llm_rank_pipeline( + "", + SCENARIO_PRESET_NEXT, + include_llm_rank=True, + query_intent_applied=False, + llm_expectation_applied=True, + has_planning_reference=True, + hits=hits, + ) + + 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" @@ -99,10 +129,10 @@ def test_should_run_llm_expectation_for_preset_with_planning_ref(): ) -def test_should_skip_llm_rank_when_expectation_applied(): +def test_should_skip_llm_rank_when_expectation_applied_but_confident(): 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}] + hits = [{"score": 0.85}, {"score": 0.4}, {"score": 0.35}, {"score": 0.3}] assert not should_run_llm_rank_pipeline( "", SCENARIO_PRESET_NEXT, diff --git a/backend/version.py b/backend/version.py index 62b9181..ae4b50f 100644 --- a/backend/version.py +++ b/backend/version.py @@ -1,6 +1,6 @@ # Shinkan Jinkendo Version Information -APP_VERSION = "0.8.181" +APP_VERSION = "0.8.182" BUILD_DATE = "2026-05-23" DB_SCHEMA_VERSION = "20260531074" @@ -29,7 +29,7 @@ MODULE_VERSIONS = { "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.9.0", # Phase B: Text-Signale guidance/Rahmen-Ziele; requires_partner-Filter + "planning_exercise_suggest": "0.10.0", # Phase B2: Rerank bei engem Top-Feld, auch nach Profil-LLM "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 @@ -44,6 +44,14 @@ MODULE_VERSIONS = { } CHANGELOG = [ + { + "version": "0.8.182", + "date": "2026-05-23", + "changes": [ + "Planungs-KI Phase B2: LLM-Rerank bei engem Top-Feld — auch nach Erwartungs-/Intent-LLM (max. 2 Calls).", + "Preset „Nächste aus Kontext“: Rerank wenn Ranking unklar; Frontend sendet include_llm_rank immer.", + ], + }, { "version": "0.8.181", "date": "2026-05-23", diff --git a/frontend/src/components/ExercisePickerModal.jsx b/frontend/src/components/ExercisePickerModal.jsx index 6269d0a..ad53453 100644 --- a/frontend/src/components/ExercisePickerModal.jsx +++ b/frontend/src/components/ExercisePickerModal.jsx @@ -31,7 +31,6 @@ const PAGE_SIZE = 100 const PLANNING_SUGGEST_LIMIT = 50 /** Client-Hinweis — Backend entscheidet final über LLM-Gates (max. 1 Call). */ const PLANNING_LLM_INTENT_MIN_CHARS = 10 -const PLANNING_LLM_RANK_MIN_CHARS = 24 const LEVEL_FILTER_OPTS = SKILL_LEVEL_OPTIONS.filter((o) => o.level != null) const INITIAL_FILTERS = { ...INITIAL_EXERCISE_LIST_FILTERS } @@ -460,7 +459,7 @@ export default function ExercisePickerModal({ : undefined, include_llm_intent: query.length >= PLANNING_LLM_INTENT_MIN_CHARS || !(query || '').trim(), - include_llm_rank: query.length >= PLANNING_LLM_RANK_MIN_CHARS, + include_llm_rank: true, query, intent_hint: activePlanningContext.intentHint || (useFreePlanningSearch && query ? 'free_search' : null), From b2157d8a4041ec0270e6262d178ec658232eae8f Mon Sep 17 00:00:00 2001 From: Lars Date: Sat, 23 May 2026 10:42:17 +0200 Subject: [PATCH 3/3] Update Planning Exercise Suggestion and Context Handling - Incremented version to 0.8.183, reflecting the implementation of Phase C1 enhancements. - Added support for progression graph auto-matching and variant-aware successors in exercise suggestions. - Updated request and response structures to include `anchor_exercise_variant_id`, `progression_graph_name`, and `suggested_variant_id`. - Enhanced frontend components to integrate planning AI search capabilities, including a new modal for exercise creation and improved context display in the exercise list. - Updated changelog to document these significant improvements in planning AI functionality. --- .../PLANNING_EXERCISE_SUGGEST_CONTEXT.md | 110 +++++++-- backend/planning_exercise_progression.py | 210 ++++++++++++++++++ backend/planning_exercise_retrieval.py | 4 + backend/planning_exercise_suggest.py | 102 ++++++--- .../test_planning_exercise_progression.py | 56 +++++ backend/version.py | 13 +- docs/HANDOVER.md | 69 ++++-- .../src/components/ExercisePickerModal.jsx | 12 + .../exercises/ExerciseAiQuickCreateModal.jsx | 90 ++++++++ .../components/exercises/ExerciseListCard.jsx | 16 ++ .../exercises/ExerciseListSearchBar.jsx | 103 +++++++++ .../exercises/ExercisesListPageRoot.jsx | 180 ++++++++++----- .../src/constants/planningExerciseSuggest.js | 5 + .../hooks/useExerciseListCatalogsAndQuery.js | 13 +- .../hooks/usePlanningExerciseSuggestSearch.js | 120 ++++++++++ frontend/src/pages/TrainingUnitEditPage.jsx | 18 +- 16 files changed, 985 insertions(+), 136 deletions(-) create mode 100644 backend/planning_exercise_progression.py create mode 100644 backend/tests/test_planning_exercise_progression.py create mode 100644 frontend/src/components/exercises/ExerciseAiQuickCreateModal.jsx create mode 100644 frontend/src/constants/planningExerciseSuggest.js create mode 100644 frontend/src/hooks/usePlanningExerciseSuggestSearch.js diff --git a/.claude/docs/working/PLANNING_EXERCISE_SUGGEST_CONTEXT.md b/.claude/docs/working/PLANNING_EXERCISE_SUGGEST_CONTEXT.md index f602dae..9b0f238 100644 --- a/.claude/docs/working/PLANNING_EXERCISE_SUGGEST_CONTEXT.md +++ b/.claude/docs/working/PLANNING_EXERCISE_SUGGEST_CONTEXT.md @@ -1,8 +1,8 @@ # Planungs-KI: Übungssuche & Kontext für Neu-Anlage -**Version:** 0.1 -**Datum:** 2026-05-22 -**Status:** P1 — Szenario-Pipeline + LLM Query-Intent-Overlay; P2 LLM-Rerank optional +**Version:** 0.2 +**Datum:** 2026-05-23 +**Status:** P0–P2 ✅ · Phase A/B/B2 ✅ · **Phase C1 ✅** (Graph auto-match + variantenbewusste Nachfolger) · C2–C3 geplant **Bezüge:** `AI_PLANNING_KI_MULTISTAGE_FORECAST.md` · `AI_PROMPT_TARGET_ARCHITECTURE.md` · `SKILL_SCORING_SPEC.md` · `TRAINING_FRAMEWORK_SPEC.md` §3 (Progressionsgraph) --- @@ -62,8 +62,11 @@ Serverseitig aus Request + DB (tokenbewusst für spätere LLM-Stufen): | `planned_exercise_ids[]` | Items der Einheit (Reihenfolge) | „N Übungen im Plan“ | | `anchor_exercise_id`, Titel | Request oder letzte Übung vor Einfügepunkt | Anker | | `anchor_skill_ids[]` | `exercise_skills` | (intern) | -| `progression_graph_id` | Request (optional) | Graph | -| `progression_successor_ids[]` | `exercise_progression_edges` ab Anker | (intern) | +| `progression_graph_id` | Request oder **Auto-Match** vom Anker (sichtbarer Graph mit passenden Ausgangskanten) | Graph | +| `progression_graph_name`, `progression_graph_auto_resolved` | Response `context_summary` | Graph (auto) | +| `anchor_exercise_variant_id` | Request / Abschnitt-Item / DB | (intern) | +| `progression_successor_ids[]` | `exercise_progression_edges` ab Anker (variantenbewusst, Migration **034**) | (intern) | +| `progression_successor_variants` | `to_exercise_variant_id` pro Nachfolger | (intern) | | `group_recent_exercise_ids[]` | Letzte Einheiten derselben Gruppe | Wiederholungsstrafe | | `framework_slot_notes` | Rahmen-Slot falls `framework_slot_id` | (später) | @@ -106,6 +109,7 @@ score = w_ft * fulltext_rank "phase_order_index": null, "parallel_stream_order_index": null, "anchor_exercise_id": 456, + "anchor_exercise_variant_id": 12, "progression_graph_id": 7, "query": "Schlage mir die nächste Übung vor", "intent_hint": "suggest_next", @@ -156,8 +160,9 @@ score = w_ft * fulltext_rank |-----|-----------| | `ExercisePickerModal` | Prop `planningContext` → Planungs-API statt reiner `listExercises`; Kontext-Chips; `reasons` unter Treffer | | `TrainingUnitEditPage` | `planningContext` aus Einheit + Picker-Ziel (Anker = letzte Übung im Abschnitt) | +| **`ExercisesListPageRoot`** | Schalter **„Neu mit KI-Assistent“**: Planungs-KI-Suche (frei, ohne `unit_id`) + Neuanlage im Modal; **„+ Neu“** ausgeblendet | | Rahmen / Kombi-Formular | analog, sobald `unit_id` / Slot-Blueprint bekannt | -| Übungsliste | weiter Volltext; Schalter „Neu mit KI-Assistent“ ohne Planungs-Pack | +| Übungsliste (ohne KI-Schalter) | weiter Volltext | **Zweites Suchfeld** im Picker: Query = Volltext + ergänzender Begriff (ODER in P0 als Konkatenation an Backend). @@ -174,18 +179,26 @@ Wenn `hits` leer oder Trainer wählt „Mit KI anlegen“: ## 9. Phasen-Roadmap -| Phase | Inhalt | -|-------|--------| -| **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** ✅ | Szenario-Pipeline + LLM Query-Intent → Erwartungsprofil-Overlay | -| **P3** | Skill-Discovery / Framework-Ziele im Pack | +| Phase | Inhalt | Status | +|-------|--------|--------| +| **P0** | Context-Pack, Hybrid-Score, API, Picker in Planung | ✅ | +| **P0.1** | `ExerciseMatchProfile` / `PlanningTargetProfile`, `profile_v1` | ✅ | +| **P1** | Szenario-Pipeline + LLM Query-Intent → Erwartungsprofil | ✅ | +| **P2 / B2** | LLM-Rerank bei engem Top-Feld (max. 2 Calls) | ✅ | +| **P3** | Skill-Discovery / Framework-Ziele im Pack | 🔲 | +| **A** | Voll-Library Hybrid-Ranking | ✅ **0.8.177** | +| **B** | Text-Signale guidance/Rahmen-Ziele | ✅ **0.8.181** | +| **C1** | Graph auto-match + variantenbewusste Nachfolger | ✅ **0.8.183** | +| **C2** | Varianten in Trefferliste / Picker | 🔲 | +| **C3** | Graph-Builder (Ziel → Pfad → speichern) | 🔲 | +| **D** | Neu-Anlage: Pack an `suggestExerciseAi` | 🔲 | --- ## 10. Changelog +- **2026-05-23:** Phase C1 — Graph auto-match, variantenbewusste Nachfolger (`planning_exercise_progression.py`). +- **2026-05-23:** Phase B2 — Rerank bei engem Top-Feld; Phase B — Text-Signale; Phase A — Voll-Library (siehe §17–§19). - **2026-05-22:** Erstfassung; P0 API + Planungs-Picker. - **2026-05-22:** P0 implementiert (`planning_exercise_suggest.py`, Router, Picker); unsaved Formular-Plan noch nicht an API (nur persistierte Einheit). - **2026-05-22:** P0.1 — `planning_exercise_profiles.py`, Profil-Score in Hybrid-Retrieval, `retrieval_phase: profile_v1`, `target_profile_summary`. @@ -193,11 +206,16 @@ Wenn `hits` leer oder Trainer wählt „Mit KI anlegen“: --- -## 11. Bekannte P0-Lücken +## 11. Bekannte Lücken & Backlog - **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). +- **Progressionsgraph-ID:** ✅ Auto-Match vom Anker (**C1**); manuelle Auswahl in UI noch offen. +- **Anker-Variante:** ✅ Client + DB (**C1**); Picker wählt Variante bei Treffer noch nicht (**C2**). +- **Graph-Builder:** Ziel eingeben → aufbauende Übungen → in Graph speichern (**C3**) — Compound-Nutzen über viele Pläne. +- **Varianten-Suche:** Library-Picker nutzt `include_variants`; Planungs-KI rankt primär **Übungsebene** — Varianten-Expansion nur gezielt (**C2**). +- **Enrichment:** Superadmin-Tool für Skills; Datenqualität der Bibliothek entscheidend für Profil-Score. - **LLM-Intent:** ✅ P1 Szenario-Pipeline + `planning_exercise_search_intent` (Migration 073). +- **Preset + LLM:** ✅ Erwartungs-LLM (074) bei Planungsbezug; Preset ohne Plan = kein Erwartungs-LLM. --- @@ -209,7 +227,7 @@ Komplexe Planungsanfragen brauchen **Schritte vor** dem Profil-Match — nicht j | `scenario_kind` | Typische Anfrage | LLM Intent? | |-----------------|------------------|-------------| -| `preset_next` | „Nächste Übung vorschlagen“ (Preset) | Nein — nur Basis-Profil | +| `preset_next` | „Nächste Übung vorschlagen“ (Preset) | Erwartungs-LLM (074) wenn Planungsbezug | | `progression` | Progressionsgraph / Pfad | Ja (wenn Freitext) | | `deepen` | Vertiefung Anker | Ja | | `continue_plan` | Auf bisherigen Plan aufbauen | Ja | @@ -256,7 +274,7 @@ Module: `planning_exercise_target_pipeline.py` · `planning_exercise_intent.py` | Feld | Typ | Default | Bedeutung | |------|-----|---------|-----------| | `planned_exercise_ids` | `int[]` | — | Optional: Reihenfolge aus Formular (überschreibt DB-Plan) | -| `include_llm_rank` | `bool` | `false` | Top-32 Hybrid-Kandidaten → OpenRouter Prompt `planning_exercise_search_rank` | +| `include_llm_rank` | `bool` | `true` (Client) | Backend gated (B2): Rerank nur bei engem Top-Feld, max. 2 LLM-Calls | **Response:** @@ -349,4 +367,60 @@ Im Hybrid-Score kommt **`w_profile * profile_score`** hinzu (Intent-abhängig ~0 | `retrieval_phase` | `"profile_v1"` — Phase-1 aktiv, kein LLM-Rerank | | `target_profile_summary` | Lesbare Kurzinfo für UI-Chips (Fokus, Top-Skills, Quellen) | -**Phase 2 (P2):** siehe §15 — optional per `include_llm_rank`. +**Phase 2 (P2 / B2):** siehe §15 und §18 — `include_llm_rank: true` vom Client, Backend entscheidet. + +--- + +## 17. Phase A — Voll-Library-Ranking (0.8.177) + +- Kein OR-Profil-Pool (~500 Übungen) mehr. +- Alle sichtbaren Übungen (bis 8000) werden hybrid gescored (`fetch_all_visible_exercise_rows` + `rank_visible_library_hits`). +- API: `full_library_ranked: true`, `retrieval_phase` enthält `+full_library+`. + +--- + +## 18. Phase B / B2 — Text-Signale & Rerank-Gates (0.8.181–0.8.182) + +**B — Text-Signale (`planning_exercise_text_signals.py`):** + +- `section_guidance_notes`, Rahmen-Ziele/Notizen → Skill-/Katalog-Gewichte ohne LLM. +- `requires_partner` aus Intent filtert Kandidaten. +- `retrieval_phase +text_signals`. + +**B2 — Rerank bei unklarem Ranking:** + +- `hybrid_ranking_ambiguous(hits)` (Top-4-/Top-10-Gap). +- Rerank auch nach Erwartungs-/Intent-LLM, wenn Scores eng beieinander. +- Budget: max. **2** LLM-Calls (Profil + optional Rerank). + +--- + +## 19. Phase C1 — Progressionsgraph im Planungskontext (0.8.183) + +**Modul:** `planning_exercise_progression.py` + +### Auto-Match Graph + +Wenn `progression_graph_id` fehlt und Anker-Übung gesetzt: sichtbarer Graph mit passender `next_exercise`-Kante vom Anker (variantenbewusst). Bevorzugung: variantenspezifische Kanten > Anzahl Kanten. + +### Variantenbewusste Nachfolger (Migration 034) + +Generische Kante (`from_exercise_variant_id IS NULL`) gilt für jeden Anker; variantenspezifische Kante nur bei passender Anker-Variante. + +Treffer: optional `hits[].suggested_variant_id`. + +### Request / Response + +| Feld | Bedeutung | +|------|-----------| +| `anchor_exercise_variant_id` | Request — Variante der Anker-Übung | +| `progression_graph_name` | Response — Name des (auto-)Graphs | +| `progression_graph_auto_resolved` | Response — Auto-Match aktiv | + +--- + +## 20. Phase C2 / C3 — Roadmap (offen) + +**C2:** Varianten in Trefferliste / Picker-Auswahl bei Graph-Treffern. + +**C3:** Graph-Builder — Ziel eingeben, aufbauende Übungen vorschlagen, nach Review in Graph speichern (`POST …/edges/sequence`). Nutzen über viele Pläne hinweg. diff --git a/backend/planning_exercise_progression.py b/backend/planning_exercise_progression.py new file mode 100644 index 0000000..355f7d1 --- /dev/null +++ b/backend/planning_exercise_progression.py @@ -0,0 +1,210 @@ +""" +Progressionsgraph-Auflösung für Planungs-KI (Phase C1). + +Variantenbewusste Nachfolger-Kanten (Migration 034) und Auto-Match eines sichtbaren Graphen +anhand der Anker-Übung, wenn der Client keine graph_id sendet. +""" +from __future__ import annotations + +from typing import Any, Dict, List, Mapping, Optional, Sequence, Set, Tuple + +from tenant_context import TenantContext, library_content_visibility_sql + +ProgressionSuccessorBundle = Tuple[Set[int], Dict[int, str], Dict[int, Optional[int]]] + + +def edge_matches_anchor_from( + edge: Mapping[str, Any], + from_variant_id: Optional[int], +) -> bool: + """Kante gilt als Ausgang vom Anker: generische Kante oder passende Varianten-Kante.""" + edge_var = edge.get("from_exercise_variant_id") + if edge_var is None: + return True + if from_variant_id is None: + return False + try: + return int(edge_var) == int(from_variant_id) + except (TypeError, ValueError): + return False + + +def filter_outgoing_progression_edges( + edges: Sequence[Mapping[str, Any]], + *, + from_variant_id: Optional[int], +) -> List[Mapping[str, Any]]: + return [e for e in edges if edge_matches_anchor_from(e, from_variant_id)] + + +def parse_successors_from_edges( + edges: Sequence[Mapping[str, Any]], +) -> ProgressionSuccessorBundle: + ids: Set[int] = set() + notes: Dict[int, str] = {} + variants: Dict[int, Optional[int]] = {} + for row in edges: + tid = int(row["to_exercise_id"]) + ids.add(tid) + n = (row.get("notes") or "").strip() + if n: + notes[tid] = n + raw_v = row.get("to_exercise_variant_id") + variants[tid] = int(raw_v) if raw_v is not None else None + return ids, notes, variants + + +def rank_progression_graph_rows(rows: Sequence[Mapping[str, Any]]) -> Optional[Mapping[str, Any]]: + if not rows: + return None + + def _key(row: Mapping[str, Any]) -> Tuple[int, int, int]: + var_match = int(row.get("variant_match_count") or 0) + out_count = int(row.get("outgoing_count") or 0) + gid = int(row.get("id") or 0) + return (var_match, out_count, gid) + + return max(rows, key=_key) + + +def resolve_progression_graph_for_planning( + cur, + tenant: TenantContext, + *, + from_exercise_id: Optional[int], + from_variant_id: Optional[int], + explicit_graph_id: Optional[int], +) -> Tuple[Optional[int], Optional[str], bool]: + """ + Liefert (graph_id, graph_name, auto_resolved). + + Bei explicit_graph_id: Sichtbarkeit prüfen, kein Auto-Match. + Sonst: sichtbarer Graph mit passenden Ausgangskanten vom Anker. + """ + profile_id = tenant.profile_id + role = tenant.global_role + vis_sql, vis_params = library_content_visibility_sql( + alias="g", + profile_id=profile_id, + role=role, + effective_club_id=tenant.effective_club_id, + ) + + if explicit_graph_id and int(explicit_graph_id) > 0: + gid = int(explicit_graph_id) + cur.execute( + f""" + SELECT g.id, g.name + FROM exercise_progression_graphs g + WHERE g.id = %s AND ({vis_sql}) + """, + [gid, *vis_params], + ) + row = cur.fetchone() + if not row: + return None, None, False + name = (row.get("name") or "").strip() or None + return gid, name, False + + if not from_exercise_id or int(from_exercise_id) < 1: + return None, None, False + + anchor_var = int(from_variant_id) if from_variant_id is not None else None + cur.execute( + f""" + SELECT g.id, g.name, + COUNT(*)::int AS outgoing_count, + COUNT(*) FILTER ( + WHERE e.from_exercise_variant_id IS NOT NULL + AND (%s IS NOT NULL) + AND e.from_exercise_variant_id = %s + )::int AS variant_match_count + FROM exercise_progression_edges e + INNER JOIN exercise_progression_graphs g ON g.id = e.graph_id + WHERE e.from_exercise_id = %s + AND LOWER(TRIM(e.edge_type)) = 'next_exercise' + AND ({vis_sql}) + AND ( + e.from_exercise_variant_id IS NULL + OR (%s IS NULL) + OR e.from_exercise_variant_id = %s + ) + GROUP BY g.id, g.name + """, + [anchor_var, anchor_var, int(from_exercise_id), *vis_params, anchor_var, anchor_var], + ) + picked = rank_progression_graph_rows(cur.fetchall()) + if not picked: + return None, None, False + gid = int(picked["id"]) + name = (picked.get("name") or "").strip() or None + return gid, name, True + + +def load_progression_successors_for_anchor( + cur, + *, + graph_id: Optional[int], + from_exercise_id: Optional[int], + from_variant_id: Optional[int], +) -> ProgressionSuccessorBundle: + if not graph_id or not from_exercise_id: + return set(), {}, {} + cur.execute( + """ + SELECT to_exercise_id, to_exercise_variant_id, notes, from_exercise_variant_id + FROM exercise_progression_edges + WHERE graph_id = %s AND from_exercise_id = %s + AND LOWER(TRIM(edge_type)) = 'next_exercise' + """, + (int(graph_id), int(from_exercise_id)), + ) + rows = [dict(r) for r in cur.fetchall()] + filtered = filter_outgoing_progression_edges(rows, from_variant_id=from_variant_id) + return parse_successors_from_edges(filtered) + + +def apply_progression_context_to_pack( + cur, + tenant: TenantContext, + pack: Dict[str, Any], + *, + explicit_graph_id: Optional[int], + anchor_variant_id: Optional[int], +) -> Dict[str, Any]: + """Pack um aufgelösten Graph und Nachfolger anreichern.""" + anchor_id = pack.get("anchor_exercise_id") + pack["anchor_exercise_variant_id"] = anchor_variant_id + + graph_id, graph_name, auto_resolved = resolve_progression_graph_for_planning( + cur, + tenant, + from_exercise_id=anchor_id, + from_variant_id=anchor_variant_id, + explicit_graph_id=explicit_graph_id, + ) + pack["progression_graph_id"] = graph_id + pack["progression_graph_name"] = graph_name + pack["progression_graph_auto_resolved"] = bool(auto_resolved) + + succ_ids, notes, succ_variants = load_progression_successors_for_anchor( + cur, + graph_id=graph_id, + from_exercise_id=anchor_id, + from_variant_id=anchor_variant_id, + ) + pack["progression_successor_ids"] = sorted(succ_ids) + pack["progression_edge_notes"] = notes + pack["progression_successor_variants"] = succ_variants + return pack + + +__all__ = [ + "apply_progression_context_to_pack", + "edge_matches_anchor_from", + "filter_outgoing_progression_edges", + "load_progression_successors_for_anchor", + "parse_successors_from_edges", + "rank_progression_graph_rows", + "resolve_progression_graph_for_planning", +] diff --git a/backend/planning_exercise_retrieval.py b/backend/planning_exercise_retrieval.py index b1af575..c027299 100644 --- a/backend/planning_exercise_retrieval.py +++ b/backend/planning_exercise_retrieval.py @@ -257,6 +257,10 @@ def rank_visible_library_hits( "reasons": reasons, } ) + succ_variants = pack.get("progression_successor_variants") or {} + suggested_vid = succ_variants.get(eid) + if suggested_vid: + hits[-1]["suggested_variant_id"] = int(suggested_vid) hits.sort(key=lambda h: (-h["score"], h.get("title") or "")) return hits, skills_by_ex diff --git a/backend/planning_exercise_suggest.py b/backend/planning_exercise_suggest.py index 4085b22..4b0b9ed 100644 --- a/backend/planning_exercise_suggest.py +++ b/backend/planning_exercise_suggest.py @@ -6,7 +6,7 @@ Siehe .claude/docs/working/PLANNING_EXERCISE_SUGGEST_CONTEXT.md from __future__ import annotations import re -from typing import Any, Dict, List, Optional, Sequence, Set, Tuple +from typing import Any, Dict, List, Mapping, Optional, Sequence, Set, Tuple from fastapi import HTTPException from pydantic import BaseModel, Field @@ -15,6 +15,7 @@ from tenant_context import TenantContext, library_content_visibility_sql from planning_exercise_profiles import skill_profile_summary_from_exercise_ids from planning_exercise_retrieval import run_multistage_planning_retrieval from planning_exercise_llm_rank import try_llm_rerank_planning_hits +from planning_exercise_progression import apply_progression_context_to_pack from planning_exercise_target_pipeline import ( build_planning_target_with_query_pipeline, compose_retrieval_phase, @@ -53,6 +54,7 @@ class PlanningExerciseSuggestRequest(BaseModel): phase_order_index: Optional[int] = Field(default=None, ge=0) parallel_stream_order_index: Optional[int] = Field(default=None, ge=0) anchor_exercise_id: Optional[int] = Field(default=None, ge=1) + anchor_exercise_variant_id: Optional[int] = Field(default=None, ge=1) progression_graph_id: Optional[int] = Field(default=None, ge=1) query: Optional[str] = "" intent_hint: Optional[str] = None @@ -169,31 +171,62 @@ def _load_skill_ids_for_exercise(cur, exercise_id: Optional[int]) -> Set[int]: return {int(r["skill_id"]) for r in cur.fetchall() if r.get("skill_id")} -def _load_progression_successors( +def _resolve_anchor_variant_id( + pack: Mapping[str, Any], + body: PlanningExerciseSuggestRequest, + sections: Optional[Sequence[Dict[str, Any]]] = None, +) -> Optional[int]: + raw = body.anchor_exercise_variant_id + if raw is not None: + try: + vid = int(raw) + except (TypeError, ValueError): + vid = 0 + if vid > 0: + return vid + anchor_id = pack.get("anchor_exercise_id") + if not anchor_id or not sections: + return None + sec = _section_for_context(sections, pack.get("section_order_index")) + if not sec: + return None + target = int(anchor_id) + for it in sorted(sec.get("items") or [], key=lambda x: int(x.get("order_index") or 0), reverse=True): + if str(it.get("item_type") or "").strip().lower() == "note": + continue + try: + eid = int(it.get("exercise_id")) + except (TypeError, ValueError): + continue + if eid != target: + continue + raw_v = it.get("exercise_variant_id") + if raw_v is None: + return None + try: + vid = int(raw_v) + except (TypeError, ValueError): + return None + return vid if vid > 0 else None + return None + + +def _finalize_progression_context( cur, - graph_id: Optional[int], - from_exercise_id: Optional[int], -) -> Tuple[Set[int], Dict[int, str]]: - if not graph_id or not from_exercise_id: - return set(), {} - cur.execute( - """ - SELECT to_exercise_id, notes - FROM exercise_progression_edges - WHERE graph_id = %s AND from_exercise_id = %s - AND LOWER(TRIM(edge_type)) = 'next_exercise' - """, - (int(graph_id), int(from_exercise_id)), + tenant: TenantContext, + pack: Dict[str, Any], + body: PlanningExerciseSuggestRequest, + *, + sections: Optional[Sequence[Dict[str, Any]]] = None, +) -> Dict[str, Any]: + anchor_variant = _resolve_anchor_variant_id(pack, body, sections) + return apply_progression_context_to_pack( + cur, + tenant, + pack, + explicit_graph_id=body.progression_graph_id, + anchor_variant_id=anchor_variant, ) - ids: Set[int] = set() - notes: Dict[int, str] = {} - for row in cur.fetchall(): - tid = int(row["to_exercise_id"]) - ids.add(tid) - n = (row.get("notes") or "").strip() - if n: - notes[tid] = n - return ids, notes def _load_group_recent_exercise_ids( @@ -469,9 +502,6 @@ def build_planning_exercise_context_pack( planned_ids = _collect_planned_exercise_ids(sections) anchor_id = _resolve_anchor_from_plan(planned_ids, body.anchor_exercise_id) anchor_skills = _load_skill_ids_for_exercise(cur, anchor_id) - progression_ids, progression_notes = _load_progression_successors( - cur, body.progression_graph_id, anchor_id - ) group_recent = _load_group_recent_exercise_ids(cur, unit.get("group_id"), int(body.unit_id)) titles = _load_exercise_titles(cur, [x for x in [anchor_id] if x]) @@ -493,9 +523,6 @@ def build_planning_exercise_context_pack( "anchor_exercise_id": anchor_id, "anchor_title": anchor_title, "anchor_skill_ids": sorted(anchor_skills), - "progression_graph_id": body.progression_graph_id, - "progression_successor_ids": sorted(progression_ids), - "progression_edge_notes": progression_notes, "group_recent_exercise_ids": sorted(group_recent), } return _attach_planning_context_details(cur, pack, sections=sections, body=body) @@ -527,9 +554,6 @@ def build_client_planning_context_pack( anchor_id = _resolve_anchor_from_plan(planned_ids, body.anchor_exercise_id) anchor_skills = _load_skill_ids_for_exercise(cur, anchor_id) - progression_ids, progression_notes = _load_progression_successors( - cur, body.progression_graph_id, anchor_id - ) group_id = body.group_id group_name = None @@ -559,9 +583,6 @@ def build_client_planning_context_pack( "anchor_exercise_id": anchor_id, "anchor_title": anchor_title, "anchor_skill_ids": sorted(anchor_skills), - "progression_graph_id": body.progression_graph_id, - "progression_successor_ids": sorted(progression_ids), - "progression_edge_notes": progression_notes, "group_recent_exercise_ids": sorted(group_recent), "context_mode": "client_free", } @@ -580,6 +601,12 @@ def suggest_planning_exercises( pack = build_client_planning_context_pack(cur, tenant=tenant, body=body) pack = _apply_client_planned_override(cur, pack, body) pack = _attach_planning_context_details(cur, pack, body=body) + sections_for_variant = None + if body.unit_id and not (body.anchor_exercise_variant_id and int(body.anchor_exercise_variant_id) > 0): + sections_for_variant = _fetch_sections(cur, int(body.unit_id)) + pack = _finalize_progression_context( + cur, tenant, pack, body, sections=sections_for_variant + ) query = _normalize_query(body.query) heuristic_intent = resolve_planning_exercise_intent(query, body.intent_hint) @@ -713,6 +740,9 @@ def suggest_planning_exercises( "anchor_exercise_id": pack.get("anchor_exercise_id"), "last_section_exercise_title": pack.get("last_section_exercise_title"), "progression_graph_id": pack.get("progression_graph_id"), + "progression_graph_name": pack.get("progression_graph_name"), + "progression_graph_auto_resolved": pack.get("progression_graph_auto_resolved"), + "anchor_exercise_variant_id": pack.get("anchor_exercise_variant_id"), "context_mode": pack.get("context_mode") or ("unit" if pack.get("unit_id") else "client_free"), "unit_skill_profile": pack.get("unit_skill_profile_summary"), "section_skill_profile": pack.get("section_skill_profile_summary"), diff --git a/backend/tests/test_planning_exercise_progression.py b/backend/tests/test_planning_exercise_progression.py new file mode 100644 index 0000000..2541d7e --- /dev/null +++ b/backend/tests/test_planning_exercise_progression.py @@ -0,0 +1,56 @@ +"""Tests Progressionsgraph-Auflösung für Planungs-KI (Phase C1).""" +from planning_exercise_progression import ( + edge_matches_anchor_from, + filter_outgoing_progression_edges, + parse_successors_from_edges, + rank_progression_graph_rows, +) + + +def test_edge_matches_anchor_from_generic_edge(): + assert edge_matches_anchor_from({"from_exercise_variant_id": None}, None) + assert edge_matches_anchor_from({"from_exercise_variant_id": None}, 5) + + +def test_edge_matches_anchor_from_variant_specific(): + assert edge_matches_anchor_from({"from_exercise_variant_id": 3}, 3) + assert not edge_matches_anchor_from({"from_exercise_variant_id": 3}, None) + assert not edge_matches_anchor_from({"from_exercise_variant_id": 3}, 4) + + +def test_filter_outgoing_progression_edges(): + edges = [ + {"from_exercise_variant_id": None, "to_exercise_id": 10}, + {"from_exercise_variant_id": 2, "to_exercise_id": 11}, + ] + filtered = filter_outgoing_progression_edges(edges, from_variant_id=2) + assert len(filtered) == 2 + only_generic = filter_outgoing_progression_edges(edges, from_variant_id=None) + assert len(only_generic) == 1 + assert only_generic[0]["to_exercise_id"] == 10 + + +def test_parse_successors_from_edges(): + ids, notes, variants = parse_successors_from_edges( + [ + { + "to_exercise_id": 20, + "to_exercise_variant_id": 7, + "notes": " leicht ", + }, + {"to_exercise_id": 21, "to_exercise_variant_id": None, "notes": ""}, + ] + ) + assert ids == {20, 21} + assert notes[20] == "leicht" + assert variants[20] == 7 + assert variants[21] is None + + +def test_rank_progression_graph_rows_prefers_variant_match(): + rows = [ + {"id": 1, "variant_match_count": 0, "outgoing_count": 5}, + {"id": 2, "variant_match_count": 2, "outgoing_count": 1}, + ] + best = rank_progression_graph_rows(rows) + assert best["id"] == 2 diff --git a/backend/version.py b/backend/version.py index ae4b50f..dfa3832 100644 --- a/backend/version.py +++ b/backend/version.py @@ -1,6 +1,6 @@ # Shinkan Jinkendo Version Information -APP_VERSION = "0.8.182" +APP_VERSION = "0.8.183" BUILD_DATE = "2026-05-23" DB_SCHEMA_VERSION = "20260531074" @@ -29,7 +29,7 @@ MODULE_VERSIONS = { "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.10.0", # Phase B2: Rerank bei engem Top-Feld, auch nach Profil-LLM + "planning_exercise_suggest": "0.11.0", # Phase C1: Graph auto-match + variantenbewusste Nachfolger "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 @@ -44,6 +44,15 @@ MODULE_VERSIONS = { } CHANGELOG = [ + { + "version": "0.8.183", + "date": "2026-05-23", + "changes": [ + "Planungs-KI Phase C1: Progressionsgraph auto-match vom Anker; variantenbewusste Nachfolger-Kanten (034).", + "Request/Response: anchor_exercise_variant_id, progression_graph_name, suggested_variant_id in Treffern.", + "Frontend Übungsliste: Planungs-KI-Suche im KI-Assistent-Modus; Neuanlage im Dialog statt „+ Neu“.", + ], + }, { "version": "0.8.182", "date": "2026-05-23", diff --git a/docs/HANDOVER.md b/docs/HANDOVER.md index 59a3904..931eb2b 100644 --- a/docs/HANDOVER.md +++ b/docs/HANDOVER.md @@ -1,7 +1,7 @@ # Shinkan Jinkendo – Entwicklungsstand & Handover -**Stand:** 2026-05-31 -**App-Version / DB-Schema:** App **`0.8.167`** (Planungs-KI Übungssuche P0); DB **`20260531071`** — maßgeblich **`backend/version.py`**. +**Stand:** 2026-05-23 +**App-Version / DB-Schema:** App **`0.8.183`** (Planungs-KI Phase C1); DB **`20260531074`** — maßgeblich **`backend/version.py`**. Diese Datei ist die **Einstiegs-Doku für neue Chat-Sessions**: Anforderungen im Detail stehen in `.claude/docs/` (siehe unten); hier der **implementierte Stand**, **Medien-Meilenstein** und **sinnvolle nächste Schritte**. @@ -89,9 +89,34 @@ 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.171**) +### 2.8 KI Assistenz Übungen & Planungs-KI Übungssuche (Stand **0.8.183**) -- **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. +**Spec / Pipeline:** `.claude/docs/working/PLANNING_EXERCISE_SUGGEST_CONTEXT.md` + +| Phase | Inhalt | Status | +|-------|--------|--------| +| **P0** | Kontext-Pack, Hybrid-Score, Planungs-Picker | ✅ | +| **P0.1** | `ExerciseMatchProfile` / `PlanningTargetProfile`, `profile_v1` | ✅ | +| **P1** | Szenario-Pipeline + LLM Intent (`073`) + Erwartungsprofil (`074`) | ✅ | +| **P2 / B2** | LLM-Rerank (`072`) bei engem Top-Feld, max. 2 LLM-Calls | ✅ **0.8.182** | +| **A** | Voll-Library deterministisch ranken (kein OR-Profil-Pool) | ✅ **0.8.177** | +| **B** | Text-Signale aus guidance/Rahmen-Zielen (`planning_text_signals`) | ✅ **0.8.181** | +| **C1** | Progressionsgraph auto-match + variantenbewusste Nachfolger | ✅ **0.8.183** | +| **C2** | Varianten in Trefferliste / Picker-Auswahl | 🔲 | +| **C3** | Graph-Builder: Ziel → Pfad vorschlagen → in Graph speichern | 🔲 | +| **D** | `planning_context` an `suggestExerciseAi` (Neu-Anlage) | 🔲 | + +**Backend:** `planning_exercise_suggest.py`, `planning_exercise_retrieval.py`, `planning_exercise_profiles.py`, `planning_exercise_target_pipeline.py`, `planning_exercise_progression.py` · Router `POST /api/planning/exercise-suggest` + +**Frontend:** `ExercisePickerModal` (Planung) · **`ExercisesListPageRoot`** — Schalter „Neu mit KI-Assistent“: Planungs-KI-Suche + Neuanlage-Modal (statt „+ Neu“) · `TrainingUnitEditPage` — `planningContext` + +**Superadmin:** Übungs-Anreicherung (Skills) — `exercise_enrichment_admin` (**0.8.178+**), separater Admin-Flow + +**Offen (Qualität):** Bibliothek durchgängig mit Skills (Enrichment-Datenarbeit); manuelle Graph-Auswahl in UI; Progressionsgraph-Builder; Skill-Discovery/Framework-Pfade im Pack (P3) + +#### Übungs-KI Formular / Schnellanlage (Stand **0.8.171**) + +- **Planungs-Übungssuche:** siehe Tabelle oben — getrennt von Formular-KI. - **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`** @@ -100,7 +125,7 @@ Das Schema ist gegenüber dem Code zurück: Migration **`022_skills_schema_compl - **Pflege:** Superadmin **`/admin/ai-prompts`**, **`/admin/ai-skill-retrieval`** - **Diagnose:** **`SHINKAN_AI_DEBUG=1`** — Logs `shinkan.exercise_ai`, `shinkan.openrouter` - **Frontend Formular:** Tab **Anleitung** — **„KI: Anleitung überarbeiten“**; Vorschau-Dialog pro Feld (**`ExerciseFormPageRoot.jsx`**) -- **Frontend Schnellanlage:** **`ExercisePickerModal`** (Planung/Rahmen) — Volltextsuche; bei keinem Treffer **„Mit KI anlegen“** (Suchstring → Titel/Skizze); Entwurf im **Rich-Text-Dialog** bearbeiten, dann speichern & übernehmen. **`ExercisesListPageRoot`** — gleiches Muster + Schalter **„KI-Anlage“** in der Suchleiste. +- **Frontend Schnellanlage:** **`ExercisePickerModal`** (Planung/Rahmen) — Volltext vs. Planungs-KI; KI-Neuanlage im Picker. **`ExercisesListPageRoot`** — Schalter **„Neu mit KI-Assistent“**: Planungs-KI-Suche (`usePlanningExerciseSuggestSearch`) + **`ExerciseAiQuickCreateModal`**; normales **„+ Neu“** ausgeblendet solange aktiv. --- @@ -220,18 +245,28 @@ Das Schema ist gegenüber dem Code zurück: Migration **`022_skills_schema_compl ## 7. Nächste Session — sinnvolle Arbeitspakete -1. **Coaching & Breakout (Regression):** Mehrphasen-Einheit mit zwei Splits und Ganzgruppen dazwischen — Rejoin-Karten, Nachbereitung speichern, Anzeige in Plan & Ablauf (`docs/HANDOVER.md` Arbeitspaket-Tabelle). -2. **P-13 Frontend-Verifikation:** Melde-Flow in Medienbibliothek, Inbox-Workflow (Status, Archiv, Wiedereröffnen), Club-Admin-Ansicht manuell auf Dev-System durchspielen. E-Mail-Benachrichtigungen verifizieren (SMTP-Log). -3. **Inline (Spec Abschnitt 11):** Basis umgesetzt — verbleibend: gezielte UX-Politik; optional Server-Normalisierung/Absicherung prüfen, falls Produkt es verlangt. -4. **Tests:** pytest für `media_assets`-Router (Leserechte, Lifecycle, `from-asset`); ggf. Snapshot der Pfad-Umzug-Logik. -5. **Retention:** Job-Dokumentation + Betrieb (ENV, Intervall); Dry-Run beschreiben. -6. **S3/Adapter:** Speicher-Abstraktion (Spec Abschnitt 7) — wenn Produkt es verlangt. -7. **Rahmen/UI:** Kalender „aus Rahmen übernehmen” weiter anbinden (parallel, unabhängig von Medien). -8. **Fachlicher Nutzerüberblick:** bei größeren UX-Änderungen **`docs/FACHLICHE_NUTZERFUNKTIONEN.md`** mitpflegen. -9. **Kombinations-Coach (Archetyp B/C):** Fachspez § 10.4 / **§ 10.6**; nach Implementierung **Anhang A** + `TRAINING_MODULES_IMPLEMENTATION_PLAN.md` aktualisieren (kein Doc-Drift). -10. **Archetyp-Administration:** Konfiguration oder DB statt nur `COMBINATION_ARCHETYPE_IDS` / `combinationArchetypes.js` (Paket **4e**). -11. **Kombi-Zeitfelder:** Massen-Vorbelegung aller Slots aus Archetyp/Global + optionales Modal beim Archetypwechsel (Paket **4f**, `COMBINATION_TIMING_PROFILE_PLAN.md`). -12. **Backend-Validierung** `method_profile` / `planning_method_profile` je Archetyp (Paket **4g**). +### Planungs-KI (priorisiert) + +1. **C2 — Varianten in Treffern:** Planungs-Picker: bei `suggested_variant_id` Variante vorauswählen; optional Varianten-Ranking bei `deepen`/`progression`. +2. **C3 — Graph-Builder:** Modus „Pfad zum Ziel“ → sequenzielle Vorschläge → `POST …/edges/sequence` nach Review. +3. **Graph-Auswahl UI:** Dropdown neben Auto-Match; Rahmen-Slot / Framework mit Default-Graph verknüpfen. +4. **Enrichment:** Skills für Kern-Übungen nachziehen (sonst schwaches Profil-Ranking). +5. **D — Neu-Anlage:** `planning_context_json` an `POST /api/exercises/ai/suggest`. + +### Allgemein + +6. **Coaching & Breakout (Regression):** Mehrphasen-Einheit mit zwei Splits und Ganzgruppen dazwischen — Rejoin-Karten, Nachbereitung speichern, Anzeige in Plan & Ablauf (`docs/HANDOVER.md` Arbeitspaket-Tabelle). +7. **P-13 Frontend-Verifikation:** Melde-Flow in Medienbibliothek, Inbox-Workflow (Status, Archiv, Wiedereröffnen), Club-Admin-Ansicht manuell auf Dev-System durchspielen. E-Mail-Benachrichtigungen verifizieren (SMTP-Log). +8. **Inline (Spec Abschnitt 11):** Basis umgesetzt — verbleibend: gezielte UX-Politik; optional Server-Normalisierung/Absicherung prüfen, falls Produkt es verlangt. +9. **Tests:** pytest für `media_assets`-Router (Leserechte, Lifecycle, `from-asset`); ggf. Snapshot der Pfad-Umzug-Logik. +10. **Retention:** Job-Dokumentation + Betrieb (ENV, Intervall); Dry-Run beschreiben. +11. **S3/Adapter:** Speicher-Abstraktion (Spec Abschnitt 7) — wenn Produkt es verlangt. +12. **Rahmen/UI:** Kalender „aus Rahmen übernehmen” weiter anbinden (parallel, unabhängig von Medien). +13. **Fachlicher Nutzerüberblick:** bei größeren UX-Änderungen **`docs/FACHLICHE_NUTZERFUNKTIONEN.md`** mitpflegen. +14. **Kombinations-Coach (Archetyp B/C):** Fachspez § 10.4 / **§ 10.6**; nach Implementierung **Anhang A** + `TRAINING_MODULES_IMPLEMENTATION_PLAN.md` aktualisieren (kein Doc-Drift). +15. **Archetyp-Administration:** Konfiguration oder DB statt nur `COMBINATION_ARCHETYPE_IDS` / `combinationArchetypes.js` (Paket **4e**). +16. **Kombi-Zeitfelder:** Massen-Vorbelegung aller Slots aus Archetyp/Global + optionales Modal beim Archetypwechsel (Paket **4f**, `COMBINATION_TIMING_PROFILE_PLAN.md`). +17. **Backend-Validierung** `method_profile` / `planning_method_profile` je Archetyp (Paket **4g**). --- diff --git a/frontend/src/components/ExercisePickerModal.jsx b/frontend/src/components/ExercisePickerModal.jsx index ad53453..40c0b85 100644 --- a/frontend/src/components/ExercisePickerModal.jsx +++ b/frontend/src/components/ExercisePickerModal.jsx @@ -114,6 +114,7 @@ export default function ExercisePickerModal({ phaseOrderIndex: planningContext?.phaseOrderIndex ?? null, parallelStreamOrderIndex: planningContext?.parallelStreamOrderIndex ?? null, anchorExerciseId: planningContext?.anchorExerciseId ?? null, + anchorExerciseVariantId: planningContext?.anchorExerciseVariantId ?? null, progressionGraphId: planningContext?.progressionGraphId ?? null, plannedExerciseIds: Array.isArray(planningContext?.plannedExerciseIds) ? planningContext.plannedExerciseIds @@ -446,6 +447,10 @@ export default function ExercisePickerModal({ activePlanningContext.anchorExerciseId != null ? Number(activePlanningContext.anchorExerciseId) : null, + anchor_exercise_variant_id: + activePlanningContext.anchorExerciseVariantId != null + ? Number(activePlanningContext.anchorExerciseVariantId) + : undefined, progression_graph_id: activePlanningContext.progressionGraphId != null ? Number(activePlanningContext.progressionGraphId) @@ -500,6 +505,7 @@ export default function ExercisePickerModal({ title: h.title, summary: h.summary, focus_area: h.focus_area, + suggested_variant_id: h.suggested_variant_id ?? null, _planningScore: h.score, _planningReasons: Array.isArray(h.reasons) ? h.reasons : [], updated_at: new Date().toISOString(), @@ -740,6 +746,12 @@ export default function ExercisePickerModal({ Anker: {planningContextSummary.anchor_title} ) : null} + {planningContextSummary.progression_graph_name ? ( + + Graph: {planningContextSummary.progression_graph_name} + {planningContextSummary.progression_graph_auto_resolved ? ' (auto)' : ''} + + ) : null} {Array.isArray(planningTargetProfileSummary?.focus_areas) && planningTargetProfileSummary.focus_areas.length > 0 ? planningTargetProfileSummary.focus_areas.map((fa) => ( diff --git a/frontend/src/components/exercises/ExerciseAiQuickCreateModal.jsx b/frontend/src/components/exercises/ExerciseAiQuickCreateModal.jsx new file mode 100644 index 0000000..adc995c --- /dev/null +++ b/frontend/src/components/exercises/ExerciseAiQuickCreateModal.jsx @@ -0,0 +1,90 @@ +import React, { useEffect } from 'react' +import ExerciseAiQuickCreateOffer from '../ExerciseAiQuickCreateOffer' + +/** + * KI-gestützte Neuanlage in einem Dialog — statt normalem „+ Neu“-Formular. + */ +export default function ExerciseAiQuickCreateModal({ + open, + onClose, + searchLabel, + title, + onTitleChange, + sketch, + onSketchChange, + focusAreaId, + onFocusAreaChange, + focusAreas = [], + catalogsReady = true, + busy = false, + error = '', + onRunAi, +}) { + useEffect(() => { + if (!open) return undefined + const onKey = (e) => { + if (e.key === 'Escape' && !busy) { + e.preventDefault() + onClose() + } + } + window.addEventListener('keydown', onKey) + return () => window.removeEventListener('keydown', onKey) + }, [open, busy, onClose]) + + if (!open) return null + + return ( +
{ + if (e.target === e.currentTarget && !busy) onClose() + }} + > +
e.stopPropagation()} + style={{ maxWidth: '640px' }} + > +
+

+ Neue Übung mit KI +

+ +
+
+

+ Die KI schlägt Titel, Ziel, Anleitung und Fähigkeiten vor — danach bearbeiten und als Entwurf speichern. + Für die normale manuelle Anlage den KI-Assistenten ausschalten. +

+ +
+
+
+ ) +} diff --git a/frontend/src/components/exercises/ExerciseListCard.jsx b/frontend/src/components/exercises/ExerciseListCard.jsx index 03dd1cb..2fd4a58 100644 --- a/frontend/src/components/exercises/ExerciseListCard.jsx +++ b/frontend/src/components/exercises/ExerciseListCard.jsx @@ -241,6 +241,22 @@ export default function ExerciseListCard({ /> ) : null} + {Array.isArray(exercise._planningReasons) && exercise._planningReasons.length > 0 ? ( +
    + {exercise._planningReasons.slice(0, 3).map((r) => ( +
  • {r}
  • + ))} +
+ ) : null}
diff --git a/frontend/src/components/exercises/ExerciseListSearchBar.jsx b/frontend/src/components/exercises/ExerciseListSearchBar.jsx index eaad9b9..54d5735 100644 --- a/frontend/src/components/exercises/ExerciseListSearchBar.jsx +++ b/frontend/src/components/exercises/ExerciseListSearchBar.jsx @@ -14,7 +14,110 @@ export default function ExerciseListSearchBar({ exerciseCount, allOnPageSelected, onToggleSelectAllPage, + kiSearchMode = false, + planningSearchInput = '', + onPlanningSearchInputChange, + onSubmitPlanningSearch, + planningSearchLoading = false, + planningHasSearched = false, + planningRetrievalPhase = '', + planningContextSummary = null, + planningTargetProfileSummary = null, + planningSearchError = '', }) { + if (kiSearchMode) { + return ( +
+ +
+ onPlanningSearchInputChange?.(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault() + onSubmitPlanningSearch?.() + } + }} + autoComplete="off" + enterKeyHint="search" + /> + +
+

+ Planungs-KI durchsucht die sichtbare Bibliothek mit Profil-Score und optional LLM — nicht + die einfache Volltextsuche. Filter und „Meine Übungen“ gelten hier nicht; KI-Assistent ausschalten für + klassische Liste. + {planningRetrievalPhase ? ( + <> + {' '} + · Phase: {planningRetrievalPhase} + + ) : null} +

+ {planningSearchError ? ( +

+ {planningSearchError} +

+ ) : null} + {planningHasSearched && planningContextSummary ? ( +
+ KI-Kontext +
+ {planningContextSummary.expectation_mode ? ( + + {planningContextSummary.expectation_mode === 'query_only' ? 'Freitext-Profil' : 'Hybrid-Profil'} + + ) : null} + {Array.isArray(planningTargetProfileSummary?.focus_areas) && + planningTargetProfileSummary.focus_areas.length > 0 + ? planningTargetProfileSummary.focus_areas.slice(0, 2).map((fa) => ( + + Fokus: {fa} + + )) + : null} + {Array.isArray(planningTargetProfileSummary?.top_skills) && + planningTargetProfileSummary.top_skills.length > 0 + ? planningTargetProfileSummary.top_skills.slice(0, 2).map((sk) => ( + + {sk.name} + + )) + : null} +
+
+ ) : null} + {!planningHasSearched ? ( +

+ Anfrage formulieren und „Vorschläge laden“ klicken. +

+ ) : null} +
+ ) + } + return (
diff --git a/frontend/src/components/exercises/ExercisesListPageRoot.jsx b/frontend/src/components/exercises/ExercisesListPageRoot.jsx index 0d7eab5..361a38a 100644 --- a/frontend/src/components/exercises/ExercisesListPageRoot.jsx +++ b/frontend/src/components/exercises/ExercisesListPageRoot.jsx @@ -12,7 +12,8 @@ import ExerciseListBulkToolbar from './ExerciseListBulkToolbar' import SaveSelectedExercisesAsModuleModal from './SaveSelectedExercisesAsModuleModal' import ExercisePeekModal from '../ExercisePeekModal' import NavStateLink from '../NavStateLink' -import ExerciseAiQuickCreateOffer from '../ExerciseAiQuickCreateOffer' +import { ExerciseAiQuickCreateTeaser } from '../ExerciseAiQuickCreateOffer' +import ExerciseAiQuickCreateModal from './ExerciseAiQuickCreateModal' import ExerciseAiSuggestPreviewModal from '../ExerciseAiSuggestPreviewModal' import { buildQuickCreateAiPreview, @@ -31,6 +32,7 @@ import { snapshotExerciseForSelection, } from '../../utils/exerciseListSelection' import { useExerciseListCatalogsAndQuery } from '../../hooks/useExerciseListCatalogsAndQuery' +import { usePlanningExerciseSuggestSearch } from '../../hooks/usePlanningExerciseSuggestSearch' import { INITIAL_EXERCISE_LIST_FILTERS, mergeExerciseListPrefsFromApi, @@ -89,10 +91,15 @@ function ExercisesListPageRoot() { const [peekExercise, setPeekExercise] = useState(null) const [saveModuleModalOpen, setSaveModuleModalOpen] = useState(false) const [aiQuickCreateEnabled, setAiQuickCreateEnabled] = useState(false) + const [aiQuickCreateModalOpen, setAiQuickCreateModalOpen] = useState(false) const [quickSaving, setQuickSaving] = useState(false) const [quickAiError, setQuickAiError] = useState('') const [quickCreateDraft, setQuickCreateDraft] = useState(null) + const planningKi = usePlanningExerciseSuggestSearch({ + enabled: pageTab === 'list' && aiQuickCreateEnabled, + }) + const { title: quickTitle, sketch: quickSketch, @@ -100,9 +107,14 @@ function ExercisesListPageRoot() { setTitle: setQuickTitle, setSketch: setQuickSketch, setFocusAreaId: setQuickFocusAreaId, - } = useExerciseAiQuickCreateFields(debouncedSearch, { - enabled: pageTab === 'list' && (aiQuickCreateEnabled || debouncedSearch.length >= 3), - }) + } = useExerciseAiQuickCreateFields( + aiQuickCreateModalOpen + ? planningKi.submittedQuery || planningKi.searchInput || debouncedSearch + : debouncedSearch, + { + enabled: pageTab === 'list' && aiQuickCreateModalOpen, + }, + ) useEffect(() => { if (!user?.id) return @@ -174,14 +186,18 @@ function ExercisesListPageRoot() { loadingMore, hasMore, loadMore, - } = useExerciseListCatalogsAndQuery({ queryBase, pageTab, tenantClubDepKey }) + } = useExerciseListCatalogsAndQuery({ + queryBase, + pageTab, + tenantClubDepKey, + skipListFetch: aiQuickCreateEnabled, + }) - const showQuickCreateOffer = - pageTab === 'list' && - catalogsReady && - !listFetching && - (aiQuickCreateEnabled || - (exercises.length === 0 && selectedEntries.length === 0 && debouncedSearch.length >= 3)) + const listExercises = exercises + const listFetchingResolved = listFetching + const exercisesForDisplay = aiQuickCreateEnabled ? planningKi.rows : listExercises + const listFetchingDisplay = aiQuickCreateEnabled ? planningKi.loading : listFetchingResolved + const hasMoreDisplay = aiQuickCreateEnabled ? false : hasMore const selectedIds = useMemo( () => new Set(selectedEntries.map((e) => Number(e.id)).filter((id) => Number.isFinite(id) && id > 0)), @@ -189,15 +205,22 @@ function ExercisesListPageRoot() { ) const selectedExercisesDisplay = useMemo( - () => mergeSelectedWithListEntries(selectedEntries, exercises), - [selectedEntries, exercises] + () => mergeSelectedWithListEntries(selectedEntries, exercisesForDisplay), + [selectedEntries, exercisesForDisplay], ) const filterResultExercises = useMemo( - () => exercises.filter((e) => !selectedIds.has(Number(e.id))), - [exercises, selectedIds] + () => exercisesForDisplay.filter((e) => !selectedIds.has(Number(e.id))), + [exercisesForDisplay, selectedIds], ) + const showKiCreateTeaser = + aiQuickCreateEnabled && + planningKi.hasSearched && + !planningKi.loading && + catalogsReady && + filterResultExercises.length > 0 + const focusOptions = useMemo( () => catalogs.focusAreas.map((fa) => ({ @@ -274,9 +297,9 @@ function ExercisesListPageRoot() { /** Für Browser-/datalist-Vorschläge (aktuelle Treffer-Titel, begrenzt) */ const searchTitleSuggestions = useMemo(() => { - const titles = exercises.map((e) => (e.title || '').trim()).filter(Boolean) + const titles = listExercises.map((e) => (e.title || '').trim()).filter(Boolean) return [...new Set(titles)].slice(0, 80) - }, [exercises]) + }, [listExercises]) const clubNameById = useMemo(() => { const m = {} @@ -377,6 +400,7 @@ function ExercisesListPageRoot() { setQuickCreateDraft( aiPreviewToQuickCreateDraft(preview, { title, focusAreaId: focusId, sketchPlain: sketch }), ) + setAiQuickCreateModalOpen(false) } catch (e) { console.error(e) const msg = e?.message || String(e) @@ -597,23 +621,36 @@ function ExercisesListPageRoot() {
- - + Neu - + {aiQuickCreateEnabled ? ( + + ) : ( + + + Neu + + )}
) : (