All checks were successful
Deploy Development / deploy (push) Successful in 43s
Test Suite / pytest-backend (push) Successful in 43s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 13s
Test Suite / k6 /health Baseline (push) Successful in 47s
Test Suite / playwright-tests (push) Successful in 1m14s
- Implemented optional LLM-Rerank functionality in the planning exercise suggestion process, allowing for improved exercise ranking based on user-defined criteria. - Updated the `suggestPlanningExercises` API to accept `planned_exercise_ids` for client-side overrides, enhancing flexibility in exercise selection. - Enhanced the `ExercisePickerModal` to reflect LLM ranking status and support new planning context features. - Incremented application version to 0.8.170 and updated changelog to document the new features and improvements in the planning AI capabilities.
224 lines
6.8 KiB
Python
224 lines
6.8 KiB
Python
"""
|
|
Phase 2 Planungs-Übungssuche: LLM-Rerank über Hybrid-Kandidaten.
|
|
|
|
Prompt-Slug: planning_exercise_search_rank (Migration 072)
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
import re
|
|
from typing import Any, Dict, List, Mapping, Optional, Sequence, Set, Tuple
|
|
|
|
from ai_prompt_runtime import AiPromptUnavailableError, load_and_render_ai_prompt
|
|
from exercise_ai import strip_html_to_plain
|
|
from openrouter_chat import (
|
|
effective_openrouter_model_for_prompt_row,
|
|
normalize_openrouter_env,
|
|
openrouter_chat_completion,
|
|
)
|
|
|
|
_logger = logging.getLogger("shinkan.planning_exercise_llm_rank")
|
|
|
|
_LLM_RERANK_POOL = 32
|
|
_MAX_GOAL_PLAIN = 480
|
|
_MAX_SUMMARY_PLAIN = 320
|
|
_MAX_REASON_LEN = 160
|
|
|
|
|
|
def _compact_json(obj: Any) -> str:
|
|
return json.dumps(obj, ensure_ascii=False, separators=(",", ":"))
|
|
|
|
|
|
def _extract_json_object(text: str) -> Dict[str, Any]:
|
|
s = (text or "").strip()
|
|
if s.startswith("```"):
|
|
s = re.sub(r"^```[a-zA-Z0-9]*\s*", "", s)
|
|
if s.endswith("```"):
|
|
s = s[:-3].strip()
|
|
start = s.find("{")
|
|
end = s.rfind("}")
|
|
if start < 0 or end <= start:
|
|
raise ValueError("Kein JSON-Objekt in LLM-Antwort")
|
|
obj = json.loads(s[start : end + 1])
|
|
if not isinstance(obj, dict):
|
|
raise ValueError("LLM-Antwort ist kein JSON-Objekt")
|
|
return obj
|
|
|
|
|
|
def parse_planning_exercise_rank_response(
|
|
text: str,
|
|
allowed_ids: Set[int],
|
|
) -> Tuple[List[int], Dict[int, str]]:
|
|
"""
|
|
Validiert LLM-Ranking: nur erlaubte exercise_id, dedupliziert, Reihenfolge beibehalten.
|
|
"""
|
|
obj = _extract_json_object(text)
|
|
ranked_raw = obj.get("ranked_ids") or obj.get("ranked") or obj.get("ids")
|
|
if not isinstance(ranked_raw, list):
|
|
raise ValueError("ranked_ids fehlt oder ist keine Liste")
|
|
|
|
ranked: List[int] = []
|
|
seen: Set[int] = set()
|
|
for raw in ranked_raw:
|
|
try:
|
|
eid = int(raw)
|
|
except (TypeError, ValueError):
|
|
continue
|
|
if eid < 1 or eid not in allowed_ids or eid in seen:
|
|
continue
|
|
seen.add(eid)
|
|
ranked.append(eid)
|
|
|
|
reasons_out: Dict[int, str] = {}
|
|
reasons_raw = obj.get("reasons") or obj.get("reasons_by_id") or {}
|
|
if isinstance(reasons_raw, dict):
|
|
for k, v in reasons_raw.items():
|
|
try:
|
|
eid = int(k)
|
|
except (TypeError, ValueError):
|
|
continue
|
|
if eid not in allowed_ids:
|
|
continue
|
|
txt = str(v or "").strip()
|
|
if txt:
|
|
reasons_out[eid] = txt[:_MAX_REASON_LEN]
|
|
|
|
return ranked, reasons_out
|
|
|
|
|
|
def _build_candidate_payload(
|
|
hit: Mapping[str, Any],
|
|
*,
|
|
goal_plain: str,
|
|
skill_names: Sequence[str],
|
|
) -> Dict[str, Any]:
|
|
return {
|
|
"id": int(hit["id"]),
|
|
"title": str(hit.get("title") or "").strip()[:200],
|
|
"summary": strip_html_to_plain(hit.get("summary"), max_len=_MAX_SUMMARY_PLAIN),
|
|
"goal": goal_plain,
|
|
"skills": list(skill_names)[:8],
|
|
"retrieval_score": float(hit.get("score") or 0.0),
|
|
}
|
|
|
|
|
|
def _load_exercise_goals(cur, exercise_ids: Sequence[int]) -> Dict[int, str]:
|
|
ids = [int(x) for x in exercise_ids if int(x) > 0]
|
|
if not ids:
|
|
return {}
|
|
ph = ",".join(["%s"] * len(ids))
|
|
cur.execute(
|
|
f"SELECT id, goal FROM exercises WHERE id IN ({ph})",
|
|
ids,
|
|
)
|
|
return {int(r["id"]): str(r.get("goal") or "") for r in cur.fetchall()}
|
|
|
|
|
|
def _load_skill_names(cur, skill_ids: Sequence[int]) -> Dict[int, str]:
|
|
ids = sorted({int(x) for x in skill_ids if int(x) > 0})
|
|
if not ids:
|
|
return {}
|
|
ph = ",".join(["%s"] * len(ids))
|
|
cur.execute(f"SELECT id, name FROM skills WHERE id IN ({ph})", ids)
|
|
return {int(r["id"]): str(r.get("name") or "") for r in cur.fetchall()}
|
|
|
|
|
|
def try_llm_rerank_planning_hits(
|
|
cur,
|
|
*,
|
|
hits: List[Dict[str, Any]],
|
|
skills_by_ex: Mapping[int, Set[int]],
|
|
query: str,
|
|
intent: str,
|
|
context_summary: Mapping[str, Any],
|
|
target_profile_summary: Mapping[str, Any],
|
|
limit: int,
|
|
) -> Tuple[List[Dict[str, Any]], bool]:
|
|
"""
|
|
Optionaler LLM-Rerank der Top-Kandidaten. Bei Fehler: Original-Reihenfolge, llm_applied=False.
|
|
"""
|
|
if not hits:
|
|
return hits, False
|
|
|
|
api_key, _ = normalize_openrouter_env()
|
|
if not api_key:
|
|
return hits, False
|
|
|
|
pool = hits[:_LLM_RERANK_POOL]
|
|
allowed_ids = {int(h["id"]) for h in pool}
|
|
goals = _load_exercise_goals(cur, list(allowed_ids))
|
|
|
|
all_skill_ids: Set[int] = set()
|
|
for eid in allowed_ids:
|
|
all_skill_ids.update(skills_by_ex.get(eid) or set())
|
|
skill_name_map = _load_skill_names(cur, list(all_skill_ids))
|
|
|
|
candidates: List[Dict[str, Any]] = []
|
|
for hit in pool:
|
|
eid = int(hit["id"])
|
|
sk_ids = sorted(skills_by_ex.get(eid) or set())
|
|
sk_names = [skill_name_map.get(sid, f"#{sid}") for sid in sk_ids[:8]]
|
|
goal_plain = strip_html_to_plain(goals.get(eid), max_len=_MAX_GOAL_PLAIN)
|
|
candidates.append(
|
|
_build_candidate_payload(hit, goal_plain=goal_plain, skill_names=sk_names)
|
|
)
|
|
|
|
variables = {
|
|
"search_query": query or "",
|
|
"intent": intent or "",
|
|
"planning_context_json": _compact_json(dict(context_summary or {})),
|
|
"target_profile_json": _compact_json(dict(target_profile_summary or {})),
|
|
"candidates_json": _compact_json(candidates),
|
|
"result_limit": str(max(1, min(int(limit), 50))),
|
|
}
|
|
|
|
try:
|
|
prow, rendered = load_and_render_ai_prompt(cur, "planning_exercise_search_rank", variables)
|
|
model = effective_openrouter_model_for_prompt_row(prow)
|
|
raw = openrouter_chat_completion(
|
|
api_key=api_key,
|
|
model=model,
|
|
user_content=rendered.text,
|
|
)
|
|
ranked_ids, llm_reasons = parse_planning_exercise_rank_response(raw, allowed_ids)
|
|
except AiPromptUnavailableError:
|
|
return hits, False
|
|
except Exception as exc:
|
|
_logger.warning("Planungs-LLM-Rerank fehlgeschlagen: %s", exc)
|
|
return hits, False
|
|
|
|
if not ranked_ids:
|
|
return hits, False
|
|
|
|
hit_by_id = {int(h["id"]): h for h in hits}
|
|
reranked: List[Dict[str, Any]] = []
|
|
used: Set[int] = set()
|
|
for eid in ranked_ids:
|
|
hit = hit_by_id.get(eid)
|
|
if not hit:
|
|
continue
|
|
used.add(eid)
|
|
new_hit = dict(hit)
|
|
reasons = list(hit.get("reasons") or [])
|
|
llm_reason = llm_reasons.get(eid)
|
|
if llm_reason and llm_reason not in reasons:
|
|
reasons.insert(0, llm_reason)
|
|
new_hit["reasons"] = reasons
|
|
new_hit["llm_rank"] = len(reranked) + 1
|
|
reranked.append(new_hit)
|
|
|
|
for hit in hits:
|
|
eid = int(hit["id"])
|
|
if eid in used:
|
|
continue
|
|
reranked.append(dict(hit))
|
|
|
|
return reranked[: max(int(limit), len(reranked))], True
|
|
|
|
|
|
__all__ = [
|
|
"parse_planning_exercise_rank_response",
|
|
"try_llm_rerank_planning_hits",
|
|
]
|