| """ |
| slm.py — the small-language-model layer: (a) query understanding and (b) a |
| grounded one-line "why recommended" per result. |
| |
| If a local Ollama instance is reachable it is used for both tasks with strict, |
| grounded prompts. Otherwise everything degrades to a genuinely capable |
| rule-based parser and a metadata-grounded template generator — so the feature |
| works on any machine, and explanations NEVER invent facts. |
| """ |
| from __future__ import annotations |
|
|
| import json |
| import re |
|
|
| import httpx |
|
|
| from app.schemas import ParsedQuery |
|
|
| |
| |
| |
| GENRE_CANON = { |
| "sci-fi": "Science Fiction", "scifi": "Science Fiction", |
| "science fiction": "Science Fiction", "sf": "Science Fiction", |
| "rom-com": "Romance", "romcom": "Romance", "romantic comedy": "Romance", |
| "romance": "Romance", "romantic": "Romance", "love story": "Romance", |
| "horror": "Horror", "scary": "Horror", "slasher": "Horror", |
| "thriller": "Thriller", "thrillers": "Thriller", "suspense": "Thriller", |
| "noir": "Crime", "neo-noir": "Crime", "crime": "Crime", "heist": "Crime", |
| "gangster": "Crime", "mystery": "Mystery", "whodunit": "Mystery", |
| "comedy": "Comedy", "comedies": "Comedy", "funny": "Comedy", |
| "drama": "Drama", "dramas": "Drama", "action": "Action", |
| "adventure": "Adventure", "fantasy": "Fantasy", "animated": "Animation", |
| "animation": "Animation", "anime": "Animation", "documentary": "Documentary", |
| "docs": "Documentary", "western": "Western", "war": "War", "musical": "Music", |
| "family": "Family", "kids": "Family", "history": "History", |
| "historical": "History", "biopic": "History", "superhero": "Action", |
| } |
|
|
| |
| MOODS = { |
| "cozy": ("cozy", "cozy warm comforting gentle"), |
| "comforting": ("cozy", "comforting warm feel-good"), |
| "feel-good": ("feel-good", "uplifting heartwarming joyful"), |
| "feelgood": ("feel-good", "uplifting heartwarming joyful"), |
| "heartwarming": ("heartwarming", "heartwarming tender wholesome"), |
| "wholesome": ("wholesome", "wholesome gentle warm"), |
| "dark": ("dark", "dark bleak grim shadowy"), |
| "gritty": ("gritty", "gritty raw visceral streetlevel"), |
| "bleak": ("bleak", "bleak nihilistic desolate"), |
| "mind-bending": ("cerebral", "mind-bending surreal reality-warping cerebral"), |
| "mindbending": ("cerebral", "mind-bending surreal reality-warping cerebral"), |
| "cerebral": ("cerebral", "cerebral intelligent philosophical"), |
| "trippy": ("cerebral", "trippy psychedelic surreal dreamlike"), |
| "surreal": ("cerebral", "surreal dreamlike absurd"), |
| "slow-burn": ("slow-burn", "slow-burn deliberate simmering patient"), |
| "slowburn": ("slow-burn", "slow-burn deliberate simmering patient"), |
| "fast-paced": ("kinetic", "fast-paced relentless breakneck"), |
| "emotional": ("emotional", "emotional heartfelt moving poignant"), |
| "heartfelt": ("emotional", "heartfelt sincere moving"), |
| "tearjerker": ("emotional", "tearjerker devastating moving"), |
| "heart": ("emotional", "emotional heartfelt warm humane"), |
| "epic": ("epic", "epic sweeping grand large-scale"), |
| "violent": ("violent", "violent brutal bloody"), |
| "creepy": ("creepy", "creepy unsettling eerie"), |
| "atmospheric": ("atmospheric", "atmospheric moody immersive"), |
| "quirky": ("quirky", "quirky offbeat whimsical"), |
| "stylish": ("stylish", "stylish sleek visually striking"), |
| } |
|
|
| _STOP_FROM_CLEAN = {"movie", "movies", "film", "films", "like", "something", |
| "want", "watch", "some", "give", "me", "find", "show", |
| "but", "that", "for", "the", "with"} |
|
|
|
|
| def _decade_bounds(text: str) -> tuple[int | None, int | None]: |
| t = text.lower() |
| m = re.search(r"between\s+(19|20)\d{2}\s+and\s+(19|20)\d{2}", t) |
| if m: |
| ys = [int(x) for x in re.findall(r"(?:19|20)\d{2}", m.group())] |
| return min(ys), max(ys) |
| m = re.search(r"\b(before)\s+((?:19|20)\d{2})", t) |
| if m: |
| return None, int(m.group(2)) |
| m = re.search(r"\b(after|since)\s+((?:19|20)\d{2})", t) |
| if m: |
| return int(m.group(2)), None |
| |
| for m in re.finditer(r"\b(?:(19|20)?(\d0))s\b", t): |
| dec = m.group(2) |
| cent = m.group(1) |
| if cent: |
| base = int(cent + dec) |
| else: |
| base = 1900 + int(dec) if int(dec) >= 30 else 2000 + int(dec) |
| return base, base + 9 |
| if re.search(r"\b(classic|old|vintage|golden age)\b", t): |
| return None, 1979 |
| if re.search(r"\b(recent|modern|new|latest|contemporary)\b", t): |
| return 2015, None |
| |
| m = re.search(r"\b((?:19|20)\d{2})\b", t) |
| if m: |
| y = int(m.group(1)) |
| return y, y |
| return None, None |
|
|
|
|
| def rule_parse(query: str) -> ParsedQuery: |
| q = query.strip() |
| low = q.lower() |
|
|
| |
| similar_to = None |
| sim = re.search( |
| r"(?:like|similar to|in the vein of|reminiscent of|reminds me of|à la|a la)\s+(.+?)" |
| r"(?:\s+but\b|\s+except\b|\s+with\b|\s+without\b|\s+that\b|[,.;]|$)", |
| low, |
| ) |
| if sim: |
| cand = sim.group(1).strip(" .,'\"") |
| |
| cand = re.sub(r"\b(movies?|films?)\b", "", cand).strip() |
| if 1 <= len(cand) <= 60: |
| similar_to = q[sim.start(1):sim.start(1) + len(sim.group(1))].strip(" .,'\"") |
| similar_to = re.sub(r"\s+but\b.*$", "", similar_to, flags=re.I).strip(" .,'\"") |
|
|
| |
| negations: list[str] = [] |
| for m in re.finditer( |
| r"(?:but\s+not|not\s+too|not|less|without|no|minus|avoid|except)\s+([a-z][a-z\- ]{2,30})", |
| low, |
| ): |
| phrase = m.group(1).strip() |
| for w in re.split(r"\s+and\s+|,|\s+", phrase): |
| w = w.strip("- ") |
| if w and w not in _STOP_FROM_CLEAN and len(w) > 2: |
| negations.append(w) |
| negations = list(dict.fromkeys(negations))[:4] |
|
|
| |
| genres: list[str] = [] |
| for phrase, canon in sorted(GENRE_CANON.items(), key=lambda kv: -len(kv[0])): |
| if re.search(rf"\b{re.escape(phrase)}\b", low): |
| neg_hit = any(phrase in n or n in phrase for n in negations) |
| if canon not in genres and not neg_hit: |
| genres.append(canon) |
| genres = genres[:4] |
|
|
| |
| moods: list[str] = [] |
| mood_expand: list[str] = [] |
| for phrase, (label, expand) in MOODS.items(): |
| if re.search(rf"(?<![a-z]){re.escape(phrase)}(?![a-z])", low): |
| if not any(phrase in n for n in negations): |
| if label not in moods: |
| moods.append(label) |
| mood_expand.append(expand) |
| moods = moods[:4] |
|
|
| era_from, era_to = _decade_bounds(low) |
|
|
| |
| min_rating = None |
| if re.search(r"\b(highly rated|top[- ]rated|acclaimed|critically acclaimed|best|masterpiece|great)\b", low): |
| min_rating = 7.5 |
|
|
| |
| |
| clean = low |
| clean = re.sub(r"(?:like|similar to|in the vein of|reminiscent of|reminds me of)\s+.+?" |
| r"(?=\s+but\b|\s+with\b|[,.;]|$)", " ", clean) |
| clean = re.sub( |
| r"(?:but\s+not|not\s+too|not|less|without|no|minus|avoid)\s+[a-z\-]+(?:\s+[a-z\-]+)?", |
| " ", clean, |
| ) |
| for w in _STOP_FROM_CLEAN: |
| clean = re.sub(rf"\b{w}\b", " ", clean) |
| clean = re.sub(r"[^a-z0-9\- ]", " ", clean) |
| clean = re.sub(r"\s+", " ", clean).strip() |
| expanded = " ".join([clean] + mood_expand).strip() |
|
|
| |
| if similar_to and len(clean.split()) <= 2: |
| intent = "similar" |
| elif len(q.split()) <= 4 and not moods and not genres and era_from is None: |
| intent = "find_title" |
| else: |
| intent = "recommend" |
|
|
| |
| kw = [w for w in clean.split() if len(w) > 3 and w not in {c.lower() for c in genres}] |
| keywords = list(dict.fromkeys(kw))[:6] |
|
|
| return ParsedQuery( |
| intent=intent, |
| clean_query=expanded or clean or q.lower(), |
| genres=genres, |
| moods=moods, |
| era_from=era_from, |
| era_to=era_to, |
| similar_to=similar_to, |
| keywords=keywords, |
| negations=negations, |
| min_rating=min_rating, |
| source="rules", |
| ) |
|
|
|
|
| |
| |
| |
| _GENRE_HUMAN = { |
| "Science Fiction": "sci-fi", "TV Movie": "TV movie", |
| } |
|
|
|
|
| def _genre_phrase(genres: list[str]) -> str: |
| if not genres: |
| return "film" |
| g = [_GENRE_HUMAN.get(x, x.lower()) for x in genres[:2]] |
| return " ".join(g) |
|
|
|
|
| def template_reason(query: str, parsed: ParsedQuery, rec: dict, match_terms: list[str]) -> str: |
| """A grounded one-liner built only from this movie's real metadata.""" |
| year = rec.get("year") |
| director = rec.get("director") |
| genres = rec.get("genres") or [] |
| rating = rec.get("vote_average") |
|
|
| subject = f"{director}'s" if director else "This" |
| desc = f"{year} " if year else "" |
| desc += _genre_phrase(genres) |
|
|
| |
| shared_kw = [t for t in match_terms if t][:3] |
| if shared_kw: |
| clause = f"its {', '.join(shared_kw)} threads line up with your search" |
| elif parsed.similar_to: |
| clause = f"a kindred watch to {parsed.similar_to}" |
| elif parsed.moods: |
| clause = f"it carries the {parsed.moods[0]} tone you asked for" |
| elif set(parsed.genres) & set(genres): |
| hit = next(iter(set(parsed.genres) & set(genres))) |
| clause = f"squarely in the {_GENRE_HUMAN.get(hit, hit.lower())} lane you want" |
| else: |
| clause = "a strong thematic match to your query" |
|
|
| tail = "" |
| if rating and rating >= 7.8: |
| tail = f", and it's well-rated at {rating}" |
|
|
| templates = [ |
| f"{subject} {desc} — {clause}{tail}.", |
| f"A {desc} from {subject.rstrip('s') if director else 'the catalog'} where {clause}{tail}.", |
| f"{subject} {desc}: {clause}{tail}.", |
| ] |
| |
| return templates[sum(map(ord, rec.get("id", ""))) % len(templates)].replace(" ", " ") |
|
|
|
|
| |
| |
| |
| class SLM: |
| def __init__(self, host: str, model: str, enabled: bool, timeout: float): |
| self.host = host.rstrip("/") |
| self.model = model |
| self.timeout = timeout |
| self.available = False |
| if enabled: |
| self.available = self._probe() |
|
|
| @property |
| def label(self) -> str: |
| return f"ollama:{self.model}" if self.available else "rules" |
|
|
| def _probe(self) -> bool: |
| try: |
| r = httpx.get(f"{self.host}/api/tags", timeout=2.0) |
| if r.status_code != 200: |
| return False |
| models = [m.get("name", "") for m in r.json().get("models", [])] |
| return any(self.model.split(":")[0] in m for m in models) |
| except Exception: |
| return False |
|
|
| def _generate(self, prompt: str, *, json_mode: bool = False) -> str | None: |
| try: |
| payload = { |
| "model": self.model, |
| "prompt": prompt, |
| "stream": False, |
| "options": {"temperature": 0.1, "num_predict": 220}, |
| } |
| if json_mode: |
| payload["format"] = "json" |
| r = httpx.post(f"{self.host}/api/generate", json=payload, timeout=self.timeout) |
| r.raise_for_status() |
| return r.json().get("response", "").strip() |
| except Exception: |
| return None |
|
|
| |
| def parse(self, query: str) -> ParsedQuery: |
| base = rule_parse(query) |
| if not self.available: |
| return base |
| prompt = ( |
| "You extract structured search intent from a movie query. " |
| "Return ONLY JSON with keys: genres (list of canonical TMDB genres), " |
| "moods (list of short adjectives), era_from (int|null), era_to (int|null), " |
| "similar_to (movie title string|null), negations (list of adjectives the " |
| "user does NOT want), min_rating (float|null). Do not invent titles.\n" |
| f"Query: {query!r}\nJSON:" |
| ) |
| raw = self._generate(prompt, json_mode=True) |
| if not raw: |
| return base |
| try: |
| data = json.loads(raw) |
| merged = base.model_copy(update={ |
| "genres": data.get("genres") or base.genres, |
| "moods": data.get("moods") or base.moods, |
| "era_from": data.get("era_from", base.era_from), |
| "era_to": data.get("era_to", base.era_to), |
| "similar_to": data.get("similar_to") or base.similar_to, |
| "negations": data.get("negations") or base.negations, |
| "min_rating": data.get("min_rating", base.min_rating), |
| "source": self.label, |
| }) |
| return merged |
| except Exception: |
| return base |
|
|
| |
| def explain(self, query: str, parsed: ParsedQuery, rec: dict, match_terms: list[str]) -> str: |
| if not self.available: |
| return template_reason(query, parsed, rec, match_terms) |
| facts = { |
| "title": rec.get("title"), "year": rec.get("year"), |
| "genres": rec.get("genres"), "director": rec.get("director"), |
| "keywords": rec.get("keywords", [])[:8], "rating": rec.get("vote_average"), |
| "overview": (rec.get("overview") or "")[:280], |
| } |
| prompt = ( |
| "Write ONE sentence (max 26 words) explaining why this movie fits the " |
| "user's search. Use ONLY the facts provided — never invent actors, plot " |
| "points, or awards. Be specific and confident, not generic.\n" |
| f"User search: {query!r}\n" |
| f"Movie facts (JSON): {json.dumps(facts, ensure_ascii=False)}\n" |
| "Sentence:" |
| ) |
| out = self._generate(prompt) |
| if not out: |
| return template_reason(query, parsed, rec, match_terms) |
| out = out.strip().strip('"').split("\n")[0] |
| return out if 8 <= len(out) <= 240 else template_reason(query, parsed, rec, match_terms) |
|
|