diff --git a/backend/planning_exercise_retrieval.py b/backend/planning_exercise_retrieval.py index 4800eae..a15bd04 100644 --- a/backend/planning_exercise_retrieval.py +++ b/backend/planning_exercise_retrieval.py @@ -1,10 +1,9 @@ """ -Mehrstufiges Retrieval für Planungs-Übungssuche (S1b). +Mehrstufiges Retrieval für Planungs-Übungssuche (Phase A). Stufen: - S1b-0 Kandidaten-Pool (Profil-Signale, Volltext, Progressions-Nachfolger) - S1b-1 Profil-Vorselektion → Top-K vor teurem Hybrid-Score - S1b-2 Hybrid-Score (Volltext, Graph, Skills, Plan, Profil, Wiederholung) + S1b-0 Gesamte sichtbare Bibliothek (Governance + Hard-Filter, kein Profil-OR-Pool) + S1b-1 Deterministischer Hybrid-Score auf allen Kandidaten → sortiert """ from __future__ import annotations @@ -16,8 +15,8 @@ from planning_exercise_profiles import ( score_exercise_against_target, ) -_RAW_POOL_LIMIT = 500 -_PROFILE_PRESELECT_LIMIT = 160 +_MAX_LIBRARY_ROWS = 8000 +_PROFILE_LOAD_BATCH = 400 def _skill_jaccard(a: Set[int], b: Set[int]) -> float: @@ -28,45 +27,37 @@ def _skill_jaccard(a: Set[int], b: Set[int]) -> float: return inter / union if union else 0.0 -def _top_weight_keys(weights: Mapping[int, float], limit: int) -> List[int]: - if not weights: - return [] - return [ - int(k) - for k, _ in sorted(weights.items(), key=lambda x: -float(x[1]))[:limit] - if int(k) > 0 - ] +def _normalize_exercise_kind_filter(exercise_kind_any: Optional[List[str]]) -> List[str]: + out: List[str] = [] + if not exercise_kind_any: + return out + for raw in exercise_kind_any: + s = str(raw or "").strip().lower() + if s in ("simple", "combination") and s not in out: + out.append(s) + return out -def _target_profile_signals(target: PlanningTargetProfile) -> Tuple[List[int], List[int], List[int]]: - skill_ids = _top_weight_keys(target.skill_weights, 8) - for sid in _top_weight_keys(target.skill_gap_weights, 6): - if sid not in skill_ids: - skill_ids.append(sid) - focus_ids = _top_weight_keys(target.focus_area_ids, 6) - style_ids = _top_weight_keys(target.style_direction_ids, 4) - return skill_ids[:12], focus_ids, style_ids - - -def fetch_retrieval_candidate_rows( +def fetch_all_visible_exercise_rows( cur, *, vis_sql: str, vis_params: Sequence[Any], query: str, exercise_kind_any: Optional[List[str]], - target: PlanningTargetProfile, - progression_successor_ids: Set[int], - anchor_skill_ids: Set[int], - raw_pool_limit: int = _RAW_POOL_LIMIT, + max_rows: int = _MAX_LIBRARY_ROWS, ) -> List[Dict[str, Any]]: - """S1b-0: Profil-geführter Kandidaten-Pool.""" + """ + S1b-0: Alle sichtbaren Übungen (ohne Profil-/Volltext-Pool-Vorselektion). + + Hard-Filter: Governance, nicht archiviert, optional exercise_kind. + Volltext-Rank nur als Score-Signal in SELECT, nicht als WHERE-Filter. + """ where = [vis_sql, "COALESCE(e.status, '') <> %s"] 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" @@ -74,56 +65,12 @@ def fetch_retrieval_candidate_rows( params.extend(vis_params) params.append("archived") - ek_filtered: List[str] = [] - if exercise_kind_any: - for raw in exercise_kind_any: - s = str(raw or "").strip().lower() - if s in ("simple", "combination") and s not in ek_filtered: - ek_filtered.append(s) + ek_filtered = _normalize_exercise_kind_filter(exercise_kind_any) if ek_filtered: ph = ",".join(["%s"] * len(ek_filtered)) where.append(f"(LOWER(TRIM(COALESCE(e.exercise_kind::text,''))) IN ({ph}))") params.extend(ek_filtered) - skill_ids, focus_ids, style_ids = _target_profile_signals(target) - if not skill_ids and anchor_skill_ids: - skill_ids = sorted(anchor_skill_ids)[:10] - - profile_clauses: List[str] = [] - if skill_ids: - ph = ",".join(["%s"] * len(skill_ids)) - profile_clauses.append( - f"EXISTS (SELECT 1 FROM exercise_skills es WHERE es.exercise_id = e.id AND es.skill_id IN ({ph}))" - ) - params.extend(skill_ids) - if focus_ids: - ph = ",".join(["%s"] * len(focus_ids)) - profile_clauses.append( - f"EXISTS (SELECT 1 FROM exercise_focus_areas efa WHERE efa.exercise_id = e.id AND efa.focus_area_id IN ({ph}))" - ) - params.extend(focus_ids) - if style_ids: - ph = ",".join(["%s"] * len(style_ids)) - profile_clauses.append( - f"EXISTS (SELECT 1 FROM exercise_style_directions esd WHERE esd.exercise_id = e.id AND esd.style_direction_id IN ({ph}))" - ) - params.extend(style_ids) - if progression_successor_ids: - ph = ",".join(["%s"] * len(progression_successor_ids)) - profile_clauses.append(f"e.id IN ({ph})") - params.extend(sorted(progression_successor_ids)) - if query: - profile_clauses.append("e.search_vector @@ plainto_tsquery('german', %s)") - params.append(query) - - use_profile_pool = bool(profile_clauses) - if use_profile_pool: - where.append(f"({' OR '.join(profile_clauses)})") - - order_by = "e.updated_at DESC, e.id DESC" - if query: - order_by = "ft_rank DESC NULLS LAST, e.updated_at DESC, e.id DESC" - sql = f""" SELECT e.id, e.title, e.summary, ( @@ -136,129 +83,46 @@ def fetch_retrieval_candidate_rows( {ft_select} FROM exercises e WHERE {' AND '.join(where)} - ORDER BY {order_by} + ORDER BY e.id ASC LIMIT %s """ - params.append(int(raw_pool_limit)) + params.append(int(max_rows)) cur.execute(sql, params) - rows = [dict(r) for r in cur.fetchall()] - - if rows or not use_profile_pool: - return rows - - return _fetch_broad_fallback_pool( - cur, - vis_sql=vis_sql, - vis_params=vis_params, - query=query, - ek_filtered=ek_filtered, - raw_pool_limit=raw_pool_limit, - ) - - -def _fetch_broad_fallback_pool( - cur, - *, - vis_sql: str, - vis_params: Sequence[Any], - query: str, - ek_filtered: List[str], - raw_pool_limit: int, -) -> List[Dict[str, Any]]: - fallback_where = [vis_sql, "COALESCE(e.status, '') <> %s"] - fallback_params: List[Any] = list(vis_params) - fallback_params.append("archived") - if ek_filtered: - ph = ",".join(["%s"] * len(ek_filtered)) - fallback_where.append(f"(LOWER(TRIM(COALESCE(e.exercise_kind::text,''))) IN ({ph}))") - fallback_params.extend(ek_filtered) - if query: - ft_fb = "ts_rank_cd(e.search_vector, plainto_tsquery('german', %s)) AS ft_rank" - fb_order = "ft_rank DESC NULLS LAST, e.updated_at DESC, e.id DESC" - fallback_params.insert(0, query) - else: - ft_fb = "0.0::float AS ft_rank" - fb_order = "e.updated_at DESC, e.id DESC" - - fb_sql = f""" - SELECT e.id, e.title, e.summary, - ( - SELECT fa.name FROM exercise_focus_areas efa - JOIN focus_areas fa ON fa.id = efa.focus_area_id - WHERE efa.exercise_id = e.id - ORDER BY efa.is_primary DESC NULLS LAST, fa.name ASC - LIMIT 1 - ) AS primary_focus_name, - {ft_fb} - FROM exercises e - WHERE {' AND '.join(fallback_where)} - ORDER BY {fb_order} - LIMIT %s - """ - fallback_params.append(int(raw_pool_limit)) - cur.execute(fb_sql, fallback_params) return [dict(r) for r in cur.fetchall()] -def profile_preselect_rows( - cur, - rows: Sequence[Dict[str, Any]], - *, - target: PlanningTargetProfile, - intent: str, - progression_successor_ids: Set[int], - query: str, - preselect_limit: int = _PROFILE_PRESELECT_LIMIT, -) -> Tuple[List[Dict[str, Any]], bool]: - """S1b-1: Profil-Score auf Pool, Top-K für Hybrid.""" - if len(rows) <= preselect_limit: - return list(rows), False - - cand_ids = [int(r["id"]) for r in rows] - match_profiles = load_exercise_match_profiles_bulk(cur, cand_ids) - - scored: List[Tuple[float, Dict[str, Any]]] = [] - row_by_id = {int(r["id"]): r for r in rows} - must_keep: Set[int] = set(int(x) for x in progression_successor_ids) - - if query: - max_ft = max(float(r.get("ft_rank") or 0.0) for r in rows) or 0.0 - if max_ft > 0: - for r in rows: - if float(r.get("ft_rank") or 0.0) / max_ft >= 0.5: - must_keep.add(int(r["id"])) - - for eid in cand_ids: - emp = match_profiles.get(eid) - profile_score = 0.0 - if emp: - profile_score, _ = score_exercise_against_target(emp, target, intent=intent) - scored.append((profile_score, row_by_id[eid])) - - scored.sort(key=lambda x: (-x[0], str(x[1].get("title") or ""))) - selected: List[Dict[str, Any]] = [] - seen: Set[int] = set() - for _, row in scored: - eid = int(row["id"]) - if eid in seen: - continue - seen.add(eid) - selected.append(row) - if len(selected) >= preselect_limit: - break - - for eid in must_keep: - if eid in seen: - continue - row = row_by_id.get(eid) - if row: - selected.append(row) - seen.add(eid) - - return selected, True +def _load_match_profiles_chunked(cur, exercise_ids: Sequence[int], *, batch: int = _PROFILE_LOAD_BATCH): + ids = sorted({int(x) for x in exercise_ids if int(x) > 0}) + if not ids: + return {} + out: Dict[int, Any] = {} + for i in range(0, len(ids), batch): + chunk = ids[i : i + batch] + out.update(load_exercise_match_profiles_bulk(cur, chunk)) + return out -def hybrid_score_planning_hits( +def _load_skill_sets_chunked(cur, exercise_ids: Sequence[int], *, batch: int = _PROFILE_LOAD_BATCH) -> Dict[int, Set[int]]: + ids = sorted({int(x) for x in exercise_ids if int(x) > 0}) + out: Dict[int, Set[int]] = {eid: set() for eid in ids} + if not ids: + return out + for i in range(0, len(ids), batch): + chunk = ids[i : i + batch] + ph = ",".join(["%s"] * len(chunk)) + cur.execute( + f"SELECT exercise_id, skill_id FROM exercise_skills WHERE exercise_id IN ({ph})", + chunk, + ) + for row in cur.fetchall(): + eid = int(row["exercise_id"]) + sid = row.get("skill_id") + if sid is not None: + out.setdefault(eid, set()).add(int(sid)) + return out + + +def rank_visible_library_hits( cur, rows: Sequence[Dict[str, Any]], *, @@ -268,7 +132,7 @@ def hybrid_score_planning_hits( target: PlanningTargetProfile, pack: Mapping[str, Any], ) -> Tuple[List[Dict[str, Any]], Dict[int, Set[int]]]: - """S1b-2: Hybrid-Score auf vorselektiertem Pool.""" + """S1b-1: Hybrid-Score auf der gesamten sichtbaren Bibliothek.""" planned_set = set(pack.get("planned_exercise_ids") or []) group_recent_set = set(pack.get("group_recent_exercise_ids") or []) progression_set = set(pack.get("progression_successor_ids") or []) @@ -285,24 +149,21 @@ def hybrid_score_planning_hits( ) last_planned_skills = {int(r["skill_id"]) for r in cur.fetchall() if r.get("skill_id")} - cand_ids = [int(r["id"]) for r in rows] - skills_by_ex: Dict[int, Set[int]] = {cid: set() for cid in cand_ids} - match_profiles = load_exercise_match_profiles_bulk(cur, cand_ids) - if cand_ids: - ph = ",".join(["%s"] * len(cand_ids)) - cur.execute( - f"SELECT exercise_id, skill_id FROM exercise_skills WHERE exercise_id IN ({ph})", - cand_ids, - ) - for r in cur.fetchall(): - skills_by_ex.setdefault(int(r["exercise_id"]), set()).add(int(r["skill_id"])) - - max_ft = 0.0 - scored_items: List[Dict[str, Any]] = [] + cand_rows: List[Dict[str, Any]] = [] for row in rows: eid = int(row["id"]) if anchor_id and eid == int(anchor_id): continue + cand_rows.append(row) + + cand_ids = [int(r["id"]) for r in cand_rows] + match_profiles = _load_match_profiles_chunked(cur, cand_ids) + skills_by_ex = _load_skill_sets_chunked(cur, cand_ids) + + max_ft = 0.0 + scored_items: List[Dict[str, Any]] = [] + for row in cand_rows: + eid = int(row["id"]) ft = float(row.get("ft_rank") or 0.0) if ft > max_ft: max_ft = ft @@ -397,29 +258,15 @@ def run_multistage_planning_retrieval( intent_weights: Mapping[str, float], pack: Mapping[str, Any], ) -> Tuple[List[Dict[str, Any]], Dict[int, Set[int]], bool]: - """Orchestriert S1b-0 → S1b-1 → S1b-2.""" - progression_set = set(pack.get("progression_successor_ids") or []) - anchor_skills = set(pack.get("anchor_skill_ids") or []) - - rows = fetch_retrieval_candidate_rows( + """Orchestriert S1b-0 → S1b-1 (Voll-Library-Ranking).""" + rows = fetch_all_visible_exercise_rows( cur, vis_sql=vis_sql, vis_params=vis_params, query=query, exercise_kind_any=exercise_kind_any, - target=target, - progression_successor_ids=progression_set, - anchor_skill_ids=anchor_skills, ) - rows, preselect_applied = profile_preselect_rows( - cur, - rows, - target=target, - intent=intent, - progression_successor_ids=progression_set, - query=query, - ) - hits, skills_by_ex = hybrid_score_planning_hits( + hits, skills_by_ex = rank_visible_library_hits( cur, rows, query=query, @@ -428,12 +275,35 @@ def run_multistage_planning_retrieval( target=target, pack=pack, ) - return hits, skills_by_ex, preselect_applied + full_library_ranked = len(rows) > 0 + return hits, skills_by_ex, full_library_ranked + + +# Legacy-Alias für Tests / externe Imports +fetch_retrieval_candidate_rows = fetch_all_visible_exercise_rows +hybrid_score_planning_hits = rank_visible_library_hits + + +def profile_preselect_rows( + cur, + rows: Sequence[Dict[str, Any]], + *, + target: PlanningTargetProfile, + intent: str, + progression_successor_ids: Set[int], + query: str, + preselect_limit: int = 160, +) -> Tuple[List[Dict[str, Any]], bool]: + """Deprecated: Phase A rankt die volle Library — keine separate Vorselektion.""" + _ = (cur, target, intent, progression_successor_ids, query, preselect_limit) + return list(rows), False __all__ = [ + "fetch_all_visible_exercise_rows", "fetch_retrieval_candidate_rows", "hybrid_score_planning_hits", "profile_preselect_rows", + "rank_visible_library_hits", "run_multistage_planning_retrieval", ] diff --git a/backend/planning_exercise_suggest.py b/backend/planning_exercise_suggest.py index 1b25080..8b6a6cc 100644 --- a/backend/planning_exercise_suggest.py +++ b/backend/planning_exercise_suggest.py @@ -629,7 +629,7 @@ def suggest_planning_exercises( effective_club_id=tenant.effective_club_id, ) - hits, skills_by_ex, profile_preselect_applied = run_multistage_planning_retrieval( + hits, skills_by_ex, full_library_ranked = run_multistage_planning_retrieval( cur, vis_sql=vis_sql, vis_params=vis_params, @@ -645,7 +645,7 @@ def suggest_planning_exercises( llm_rank_applied = False retrieval_phase = compose_retrieval_phase( - profile_preselect=profile_preselect_applied, + full_library=full_library_ranked, query_intent=query_intent_applied, llm_expectation=llm_expectation_applied, llm_rank=False, @@ -680,7 +680,7 @@ def suggest_planning_exercises( ) if llm_rank_applied: retrieval_phase = compose_retrieval_phase( - profile_preselect=profile_preselect_applied, + full_library=full_library_ranked, query_intent=query_intent_applied, llm_expectation=llm_expectation_applied, llm_rank=True, @@ -718,7 +718,8 @@ def suggest_planning_exercises( "scenario_kind": scenario_kind, "query_intent_summary": query_intent_summary, "retrieval_phase": retrieval_phase, - "profile_preselect_applied": profile_preselect_applied, + "full_library_ranked": full_library_ranked, + "profile_preselect_applied": False, "llm_rank_applied": llm_rank_applied, "llm_intent_applied": query_intent_applied, "llm_expectation_applied": llm_expectation_applied, diff --git a/backend/planning_exercise_target_pipeline.py b/backend/planning_exercise_target_pipeline.py index 0026d16..199a794 100644 --- a/backend/planning_exercise_target_pipeline.py +++ b/backend/planning_exercise_target_pipeline.py @@ -385,14 +385,15 @@ VALID_SCENARIOS_SET = { def compose_retrieval_phase( *, + full_library: bool = False, 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 full_library or profile_preselect: + parts.append("full_library") if llm_expectation: parts.append("llm_expectation") elif query_intent: diff --git a/backend/tests/test_planning_exercise_retrieval.py b/backend/tests/test_planning_exercise_retrieval.py index 8e4b834..e16972a 100644 --- a/backend/tests/test_planning_exercise_retrieval.py +++ b/backend/tests/test_planning_exercise_retrieval.py @@ -1,8 +1,12 @@ -"""Tests Planungs-Retrieval SQL-Parameter.""" -from planning_exercise_retrieval import fetch_retrieval_candidate_rows +"""Tests Planungs-Retrieval Phase A (Voll-Library-Ranking).""" +from planning_exercise_profiles import ExerciseMatchProfile, PlanningTargetProfile +from planning_exercise_retrieval import ( + fetch_all_visible_exercise_rows, + rank_visible_library_hits, +) -def test_fetch_retrieval_binds_query_before_visibility_params(): +def test_fetch_all_visible_has_no_profile_or_pool_filter(): captured = {} class _Cur: @@ -11,33 +15,95 @@ def test_fetch_retrieval_binds_query_before_visibility_params(): captured["params"] = list(params) def fetchall(self): - return [ - { - "id": 1, - "title": "Test", - "summary": "", - "primary_focus_name": None, - "ft_rank": 0.2, - } - ] + return [] - fetch_retrieval_candidate_rows( + fetch_all_visible_exercise_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, + query="Kime Partner", + exercise_kind_any=["simple"], ) + sql = captured["sql"].lower() + assert "exercise_skills" not in sql + assert "@@ plainto_tsquery" not in sql + assert " exists " not in sql.replace("primary_focus_name", "") params = captured["params"] - assert params[0] == "nächste Übung planen" + assert params[0] == "Kime Partner" assert params[1] == 42 assert params[2] == "archived" - assert params[-2] == "nächste Übung planen" - assert params[-1] == 10 + assert params[3] == "simple" + assert params[-1] == 8000 + + +def test_rank_visible_library_prefers_profile_over_untagged_pool_miss(): + """Übung ohne Pool-Tags, aber hoher Profil-Match, muss vor schwachem Treffer ranken.""" + + target = PlanningTargetProfile( + focus_area_ids={10: 1.0}, + skill_weights={5: 1.0}, + sources=["framework_catalog"], + ) + rows = [ + {"id": 1, "title": "Schwach", "summary": "", "primary_focus_name": "X", "ft_rank": 0.9}, + {"id": 2, "title": "Stark", "summary": "", "primary_focus_name": "Karate", "ft_rank": 0.0}, + ] + + profiles = { + 1: ExerciseMatchProfile(exercise_id=1, focus_area_ids={99: 1.0}), + 2: ExerciseMatchProfile(exercise_id=2, focus_area_ids={10: 1.0}, skill_weights={5: 1.0}), + } + + class _Cur: + def execute(self, sql, params=None): + return None + + def fetchall(self): + return [] + + def _fake_load(cur, exercise_ids, *, batch=400): + _ = (cur, batch) + return {eid: profiles[eid] for eid in exercise_ids if eid in profiles} + + def _fake_skills(cur, exercise_ids, *, batch=400): + _ = (cur, batch) + return {1: {99}, 2: {5, 10}} + + import planning_exercise_retrieval as mod + + orig_profiles = mod._load_match_profiles_chunked + orig_skills = mod._load_skill_sets_chunked + try: + mod._load_match_profiles_chunked = _fake_load + mod._load_skill_sets_chunked = _fake_skills + hits, _ = rank_visible_library_hits( + _Cur(), + rows, + query="", + intent="suggest_next", + intent_weights={ + "fulltext": 0.08, + "progression": 0.28, + "skill": 0.12, + "plan": 0.10, + "profile": 0.25, + "repeat_unit": -0.30, + "repeat_group": -0.15, + }, + target=target, + pack={ + "planned_exercise_ids": [], + "group_recent_exercise_ids": [], + "progression_successor_ids": [], + "anchor_skill_ids": [], + "anchor_exercise_id": None, + "progression_edge_notes": {}, + }, + ) + finally: + mod._load_match_profiles_chunked = orig_profiles + mod._load_skill_sets_chunked = orig_skills + + assert hits[0]["id"] == 2 + assert hits[0]["score"] > hits[1]["score"] diff --git a/backend/tests/test_planning_exercise_suggest.py b/backend/tests/test_planning_exercise_suggest.py index 5c6e194..4f5a951 100644 --- a/backend/tests/test_planning_exercise_suggest.py +++ b/backend/tests/test_planning_exercise_suggest.py @@ -73,10 +73,9 @@ 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" - assert ( - compose_retrieval_phase(profile_preselect=True, query_intent=True, llm_rank=False) - == "profile_v1+profile_preselect+query_intent" + compose_retrieval_phase(full_library=True, query_intent=True, llm_rank=False) + == "profile_v1+full_library+query_intent" ) @@ -119,6 +118,10 @@ def test_compose_retrieval_phase_llm_expectation(): compose_retrieval_phase(llm_expectation=True) == "profile_v1+llm_expectation" ) + assert ( + compose_retrieval_phase(full_library=True, llm_expectation=True) + == "profile_v1+full_library+llm_expectation" + ) def test_query_only_expectation_without_planning_reference(): diff --git a/backend/version.py b/backend/version.py index 3f6c0ae..64c517e 100644 --- a/backend/version.py +++ b/backend/version.py @@ -1,6 +1,6 @@ # Shinkan Jinkendo Version Information -APP_VERSION = "0.8.176" +APP_VERSION = "0.8.177" BUILD_DATE = "2026-05-22" DB_SCHEMA_VERSION = "20260531074" @@ -28,7 +28,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.7.0", # LLM-Erwartungsprofil aus Kontext (preset); Migration 074 + "planning_exercise_suggest": "0.8.0", # Phase A: Voll-Library-Ranking gegen Erwartungsprofil "training_units": "0.4.0", # POST .../publish-to-framework: Ablauf aus geplanter Einheit → Rahmen-Slot-Blueprint "training_programs": "0.1.0", "planning": "0.15.0", # Vorlagen: Strukturvorschau, Bearbeiten inkl. Split-Sessions + Beschreibung @@ -43,6 +43,14 @@ MODULE_VERSIONS = { } CHANGELOG = [ + { + "version": "0.8.177", + "date": "2026-05-22", + "changes": [ + "Planungs-Übungssuche Phase A: gesamte sichtbare Bibliothek deterministisch gegen Erwartungsprofil gerankt.", + "Kein OR-Profil-Pool mehr; Volltext nur noch als Score-Signal; retrieval_phase full_library.", + ], + }, { "version": "0.8.176", "date": "2026-05-22",