filmatch-api / services /recommend.py
Gillenn's picture
Déploiement initial de Filmatch API
de3df6f
Raw
History Blame Contribute Delete
12.1 kB
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