Refactor Planning Exercise Retrieval and Suggestion Logic
All checks were successful
Deploy Development / deploy (push) Successful in 39s
Test Suite / pytest-backend (push) Successful in 39s
Test Suite / lint-backend (push) Successful in 1s
Test Suite / build-frontend (push) Successful in 12s
Test Suite / k6 /health Baseline (push) Successful in 33s
Test Suite / playwright-tests (push) Successful in 1m12s

- Updated the planning exercise retrieval process to implement a multistage approach, ranking the entire visible library deterministically against the expectation profile.
- Removed the previous profile OR pool mechanism, simplifying the retrieval logic and ensuring full-text search is only used as a scoring signal.
- Adjusted the `compose_retrieval_phase` function to accommodate the new full library ranking strategy.
- Incremented version to 0.8.177 and updated changelog to reflect these changes in planning exercise capabilities.
This commit is contained in:
Lars 2026-05-23 06:35:45 +02:00
parent a8633235f2
commit d1d8539b42
6 changed files with 208 additions and 259 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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