""" reranker.py ----------- Stage 2 of the pipeline: use a free HuggingFace-hosted LLM to re-rank the BM25 candidate set. Model: mistralai/Mistral-7B-Instruct-v0.2 Why re-rank with an LLM instead of just using BM25? - BM25 is purely lexical — it matches keywords but doesn't understand meaning. A query like "low-impact knee exercises" might not match "Single-Leg Box Squat" even though it's highly relevant. - The LLM understands intent — rehab vs performance, equipment constraints, difficulty level — and can weigh candidates against each other holistically. - Keeping retrieval and re-ranking separate means we only send ~15 candidates to the LLM rather than the full dataset, keeping latency low. The prompt asks the model to return a JSON list of IDs in ranked order with a short reason for each. We parse that and return the top 3–5 exercises. """ import json import os import re from huggingface_hub import InferenceClient MODEL = "mistralai/Mistral-7B-Instruct-v0.2" def _get_client() -> InferenceClient: token = os.environ.get("HF_TOKEN") return InferenceClient(model=MODEL, token=token) def rerank(query: str, candidates: list[dict], top_n: int = 5) -> list[dict]: """ Sends the query + candidate exercises to a free HuggingFace-hosted LLM and returns the top_n exercises re-ranked by relevance. Returns a list of dicts, each with all original exercise fields plus a 'reason' key explaining why it was ranked highly. """ if not candidates: return [] # Format candidates for the prompt candidates_text = "" for i, ex in enumerate(candidates, 1): candidates_text += ( f"{i}. ID: {ex['id']}\n" f" Title: {ex['title']}\n" f" Description: {ex['description']}\n" f" Tags: {ex['tags']}\n" f" Body Part: {ex['body_part']} | Difficulty: {ex['difficulty']}\n" f" Equipment: {ex['equipment']} | Injury Focus: {ex['injury_focus']}\n" f" Intensity: {ex['intensity']}\n\n" ) prompt = f"""[INST] You are a sports coaching assistant. A user has submitted this query: "{query}" Here are {len(candidates)} candidate exercises. Re-rank them from most to least relevant for the user's needs. Consider their goal, any constraints (equipment, injury, intensity), and appropriate difficulty. {candidates_text} Return ONLY a JSON array of the top {top_n} exercises in ranked order. Each item must have: - "id": the exercise ID string - "reason": one sentence explaining why it fits the query Example: [ {{"id": "EX_001", "reason": "Low-impact and targets knee stability, ideal for rehab."}}, {{"id": "EX_005", "reason": "Isometric hold builds quad strength without stressing the knee."}} ] Return only the JSON array, nothing else. [/INST]""" try: client = _get_client() response = client.text_generation( prompt, max_new_tokens=600, temperature=0.2, # low temperature for consistent structured output do_sample=True, ) raw = response.strip() # Extract JSON array from the response (model sometimes adds preamble) match = re.search(r'\[.*\]', raw, re.DOTALL) if match: raw = match.group(0) ranked_list = json.loads(raw) except Exception: # Graceful fallback: return BM25 order without reasons return [{**ex, "reason": ""} for ex in candidates[:top_n]] # Map IDs back to full exercise dicts and attach reason id_to_exercise = {ex["id"]: ex for ex in candidates} results = [] for item in ranked_list[:top_n]: ex_id = item.get("id") if ex_id in id_to_exercise: exercise = dict(id_to_exercise[ex_id]) exercise["reason"] = item.get("reason", "") results.append(exercise) # If parsing returned fewer than expected, pad with remaining BM25 candidates seen_ids = {ex["id"] for ex in results} for ex in candidates: if len(results) >= top_n: break if ex["id"] not in seen_ids: results.append({**ex, "reason": ""}) return results