Spaces:
Runtime error
Runtime error
| 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 | |