import asyncio import unicodedata import re import numpy as np from datetime import datetime from typing import List, Dict, Optional, Set, Tuple from services.tmdb import get_genre_map, fetch_by_genres, fetch_popular, search_movie, TMDB_IMG from services.embedding import encode_movie, encode_mood from services.engine import EngineRecommandationGroupe MIN_POPULARITY = 8.0 MOOD_KEYWORDS: Dict[str, List[str]] = { "action": ["Action", "Thriller"], "comedie": ["Comedy"], "comédie": ["Comedy"], "drole": ["Comedy"], "drôle": ["Comedy"], "rire": ["Comedy"], "legere": ["Comedy"], "légère": ["Comedy"], "horreur": ["Horror"], "peur": ["Horror"], "drame": ["Drama"], "drama": ["Drama"], "emouvant": ["Drama", "Romance"], "poignant": ["Drama"], "intense": ["Thriller", "Drama"], "triste": ["Drama"], "melancolique": ["Drama"], "mélancolique": ["Drama"], "romantique": ["Romance"], "romance": ["Romance"], "amour": ["Romance"], "aventure": ["Adventure"], "science-fiction": ["Science Fiction"], "sf": ["Science Fiction"], "fantaisie": ["Fantasy"], "fantasy": ["Fantasy"], "animation": ["Animation"], "famille": ["Family"], "enfants": ["Family", "Animation"], "documentaire": ["Documentary"], "policier": ["Crime", "Mystery"], "crime": ["Crime"], "mystere": ["Mystery"], "mystère": ["Mystery"], "guerre": ["War"], "histoire": ["History"], "musical": ["Music"], "western": ["Western"], "thriller": ["Thriller"], "suspense": ["Thriller", "Mystery"], "feel-good": ["Comedy", "Romance"], "feel good": ["Comedy", "Romance"], "popcorn": ["Action", "Adventure"], "detente": ["Comedy", "Family"], "frissons": ["Horror", "Thriller"], } def _normalize(key: str) -> str: key = key.lower() key = unicodedata.normalize("NFD", key) key = "".join(c for c in key if unicodedata.category(c) != "Mn") key = re.sub(r"[^\w\s_]", "", key) return re.sub(r"\s+", " ", key).strip() def extract_genres_from_mood(mood_text: str) -> List[str]: mood_norm = _normalize(mood_text) genres: set = set() for keyword, genre_list in MOOD_KEYWORDS.items(): if _normalize(keyword) in mood_norm: genres.update(genre_list) return list(genres) async def _fetch_and_embed(film: Dict, genre_map: Dict) -> Optional[tuple]: try: result = await search_movie(film["name"], film["year"]) if result and result.get("overview"): genres = [genre_map.get(gid, "") for gid in result.get("genre_ids", [])] genres = [g for g in genres if g] emb = encode_movie(result["overview"], genres) return film["key"], emb except Exception: pass return None async def build_profile_embeddings( history: List[Dict], genre_map: Dict, top_k: int = 25 ) -> Dict[str, np.ndarray]: top_films = sorted(history, key=lambda x: x["rating"], reverse=True)[:top_k] results = await asyncio.gather( *[_fetch_and_embed(f, genre_map) for f in top_films], return_exceptions=True, ) out = {} for r in results: if r and not isinstance(r, Exception): key, emb = r out[key] = emb return out async def get_watchlist_films_for_mood( watchlist_sets: List[Set[str]], genres_mood: List[str], genre_map: Dict[int, str], max_guaranteed: int = 2, ) -> Tuple[List[Dict], List[Dict]]: """ Retourne (films_garantis, films_boostes). - Garantis : TOUS les utilisateurs ont le film ET il correspond au mood. - Boostes : CERTAINS utilisateurs ont le film ET il correspond au mood. """ all_keys: Set[str] = set() for ws in watchlist_sets: all_keys.update(ws) if not all_keys: return [], [] n_users = len(watchlist_sets) async def check(raw_key: str) -> Optional[Dict]: try: parts = raw_key.rsplit("_", 1) name = parts[0] year = int(parts[1]) if len(parts) > 1 and parts[1].isdigit() else None result = await search_movie(name, year) if not result or not result.get("overview"): return None title = result["title"].strip() raw_year = result.get("release_date", "")[:4] r_year = int(raw_year) if raw_year.isdigit() else None key = (title + "_" + str(r_year)) if r_year else title genres = [genre_map.get(gid, "") for gid in result.get("genre_ids", [])] genres = [g for g in genres if g] # Seuls les films dont un genre correspond au mood sont eligibles if genres_mood and not any(g in genres_mood for g in genres): return None return { "key": key, "tmdb_id": int(result["id"]), "title": title, "year": r_year, "overview": result.get("overview", ""), "genres": genres, "popularity": float(result.get("popularity", 0.0)), "poster_path": TMDB_IMG + result["poster_path"] if result.get("poster_path") else None, "on_watchlist": True, "score": 0.97, "_user_count": sum(1 for ws in watchlist_sets if raw_key in ws), "_all_users": sum(1 for ws in watchlist_sets if raw_key in ws) >= n_users, } except Exception: return None results = await asyncio.gather( *[check(k) for k in list(all_keys)[:50]], return_exceptions=True, ) matches = [r for r in results if r and not isinstance(r, Exception)] guaranteed = sorted([m for m in matches if m["_all_users"]], key=lambda x: x["popularity"], reverse=True) partial = sorted([m for m in matches if not m["_all_users"]], key=lambda x: x["popularity"], reverse=True) return guaranteed[:max_guaranteed], partial async def run_pipeline( histories: List[List[Dict]], mood_text: str, top_n: int = 5, watchlist_sets: Optional[List[Set[str]]] = None, ) -> List[Dict]: seen_norm = { _normalize(film["key"]) for history in histories for film in history } genre_map = await get_genre_map() genres_mood = extract_genres_from_mood(mood_text) # 1. Pool de candidats : genre-specifique ou populaire en fallback genre_pool, popular_pool = await asyncio.gather( fetch_by_genres(seen_norm, genre_map, genres_mood, max_pages=10), fetch_popular(seen_norm, genre_map, max_pages=3), ) all_candidates = genre_pool if genre_pool else popular_pool # Filtre popularite : retire les films trop obscurs candidates = [c for c in all_candidates.values() if c.get("popularity", 0) >= MIN_POPULARITY] if not candidates: return [] # 2. Embeddings des candidats candidate_embeddings: Dict[str, np.ndarray] = { c["key"]: encode_movie(c["overview"], c["genres"]) for c in candidates } # 3. Profils utilisateurs profile_embs_list = await asyncio.gather( *[build_profile_embeddings(h, genre_map) for h in histories] ) engine = EngineRecommandationGroupe( omega_hist=0.35, omega_mood=0.65, omega_pen=0.2, delta=0.35, gamma_pop=0.55, ) profils_groupe = [] for history, emb_catalogue in zip(histories, profile_embs_list): if emb_catalogue: profil = engine.calcul_profil_utilisateur(history, emb_catalogue) profils_groupe.append(profil) if not profils_groupe: return [] v_mood = encode_mood(mood_text) # 4. Scoring scored = [] for c in candidates: if c["key"] not in candidate_embeddings: continue score = engine.scoring_film_candidat( v_m=candidate_embeddings[c["key"]], genres_m=c["genres"], pop_m=c["popularity"], profils_groupe=profils_groupe, v_mood=v_mood, genres_mood=genres_mood, ) scored.append({**c, "score": round(score, 4), "on_watchlist": False}) scored.sort(key=lambda x: x["score"], reverse=True) results = scored[:top_n] # 5. Injection watchlist if watchlist_sets: wl_guaranteed, wl_partial = await get_watchlist_films_for_mood( watchlist_sets, genres_mood, genre_map ) if wl_guaranteed: wl_ids = {f["tmdb_id"] for f in wl_guaranteed} results = [r for r in results if r["tmdb_id"] not in wl_ids] results = (wl_guaranteed + results)[:top_n] if wl_partial: partial_ids = {f["tmdb_id"] for f in wl_partial} changed = False for r in results: if r["tmdb_id"] in partial_ids: r["score"] = round(r["score"] * 1.3, 4) r["on_watchlist"] = True changed = True if changed: results = sorted(results, key=lambda x: x["score"], reverse=True)[:top_n] return results async def run_pipeline_from_text( liked_film_names: List[str], mood_text: str, top_n: int = 5, ) -> Tuple[List[Dict], List[str]]: """ Pipeline pour la saisie manuelle. Meme algorithme que Letterboxd mais avec un historique synthetique. """ genre_map = await get_genre_map() search_results = await asyncio.gather( *[search_movie(name, None) for name in liked_film_names], return_exceptions=True, ) synthetic_history = [] profile_embeddings: Dict[str, np.ndarray] = {} films_found: List[str] = [] for result in search_results: if not result or isinstance(result, Exception) or not result.get("title"): continue title = result["title"].strip() raw_year = result.get("release_date", "")[:4] year = int(raw_year) if raw_year.isdigit() else None key = (title + "_" + str(year)) if year else title genres = [genre_map.get(gid, "") for gid in result.get("genre_ids", [])] genres = [g for g in genres if g] films_found.append(title) if result.get("overview"): profile_embeddings[key] = encode_movie(result["overview"], genres) synthetic_history.append({ "key": key, "name": title, "year": year, "rating": 4.5, "date_viewed": datetime.now(), }) if not synthetic_history or not profile_embeddings: raise ValueError("Aucun film trouve sur TMDB. Verifie les titres.") genres_mood = extract_genres_from_mood(mood_text) seen_norm: Set[str] = set() genre_pool, popular_pool = await asyncio.gather( fetch_by_genres(seen_norm, genre_map, genres_mood, max_pages=10), fetch_popular(seen_norm, genre_map, max_pages=3), ) all_candidates = genre_pool if genre_pool else popular_pool candidates = [c for c in all_candidates.values() if c.get("popularity", 0) >= MIN_POPULARITY] if not candidates: return [], films_found candidate_embeddings: Dict[str, np.ndarray] = { c["key"]: encode_movie(c["overview"], c["genres"]) for c in candidates } # Poids plus equilibres pour la saisie manuelle (peu de films = profil moins riche) engine = EngineRecommandationGroupe( omega_hist=0.4, omega_mood=0.6, omega_pen=0.1, delta=0.35, gamma_pop=0.55, ) profil = engine.calcul_profil_utilisateur(synthetic_history, profile_embeddings) v_mood = encode_mood(mood_text) scored = [] for c in candidates: if c["key"] not in candidate_embeddings: continue score = engine.scoring_film_candidat( v_m=candidate_embeddings[c["key"]], genres_m=c["genres"], pop_m=c["popularity"], profils_groupe=[profil], v_mood=v_mood, genres_mood=genres_mood, ) scored.append({**c, "score": round(score, 4), "on_watchlist": False}) scored.sort(key=lambda x: x["score"], reverse=True) return scored[:top_n], films_found