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
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:
parent
a8633235f2
commit
d1d8539b42
|
|
@ -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",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user