cinematch / scripts /build_corpus.py
Alluri Lakshman Narendra
Deploy CineMatch backend (FastAPI + hybrid retrieval)
22b7d63
Raw
History Blame Contribute Delete
11.3 kB
"""
build_corpus.py — Reproducible corpus builder for CineMatch.
Merges three public, no-auth sources into one clean corpus:
1. Pablinho "9000plus" TMDB dump -> base catalog. Every row has a REAL
TMDB poster URL, overview, genre, release date, rating, popularity.
2. TMDB-5000 (rashida048 mirror) -> enriches marquee films with structured
cast / director / keywords / tagline / runtime / tmdb id.
3. The repo's own movies_dataframe.json (45k pre-tokenized bags) -> adds
extra lexical tokens (plot/cast/keyword terms) for BM25 depth.
Output: backend/data/corpus.json — a list of movie records with BOTH the raw
display fields (preserved verbatim for the UI and for grounded explanations)
and two derived text fields used by the retrieval engine:
- semantic_text : a natural-language blob for the embedding model
- lexical_text : a keyword-dense blob for BM25 tokenization
Run: python scripts/build_corpus.py
It caches raw downloads under data/raw/ so re-runs are offline & deterministic.
"""
from __future__ import annotations
import ast
import json
import re
import sys
import unicodedata
import urllib.request
from pathlib import Path
import pandas as pd
ROOT = Path(__file__).resolve().parents[1]
RAW = ROOT / "data" / "raw"
OUT = ROOT / "data" / "corpus.json"
REPO_JSON = ROOT / "data" / "movies_dataframe.json"
SOURCES = {
"pablinho_movies.csv": "https://huggingface.co/datasets/Pablinho/movies-dataset/resolve/main/9000plus.csv",
"tmdb_5000.csv": "https://raw.githubusercontent.com/rashida048/Some-NLP-Projects/master/movie_dataset.csv",
}
def log(msg: str) -> None:
print(f"[build_corpus] {msg}", flush=True)
def ensure_raw() -> None:
RAW.mkdir(parents=True, exist_ok=True)
for name, url in SOURCES.items():
dst = RAW / name
if dst.exists() and dst.stat().st_size > 1000:
continue
log(f"downloading {name} ...")
req = urllib.request.Request(url, headers={"User-Agent": "cinematch/1.0"})
with urllib.request.urlopen(req, timeout=120) as r, open(dst, "wb") as f:
f.write(r.read())
log(f" saved {dst.stat().st_size // 1024} KB")
def norm_key(s: str) -> str:
"""Aggressive normalization for cross-source title joins (no separators)."""
s = unicodedata.normalize("NFKD", str(s))
s = "".join(c for c in s if not unicodedata.combining(c))
return re.sub(r"[^a-z0-9]", "", s.lower())
def slugify(s: str) -> str:
"""Human-readable, URL-safe slug used as the public movie id."""
s = unicodedata.normalize("NFKD", str(s))
s = "".join(c for c in s if not unicodedata.combining(c))
s = re.sub(r"[^a-z0-9]+", "-", s.lower()).strip("-")
return s or "untitled"
def parse_tmdb_json(raw, key: str = "name", limit: int | None = None) -> list[str]:
"""Parse a stringified list-of-dicts (raw TMDB JSON dump) into names."""
if not isinstance(raw, str) or not raw.strip().startswith("["):
return []
data = None
for loader in (json.loads, ast.literal_eval):
try:
data = loader(raw)
break
except Exception:
continue
if not isinstance(data, list):
return []
out: list[str] = []
for item in data:
if isinstance(item, dict) and item.get(key):
out.append(str(item[key]).strip())
if limit and len(out) >= limit:
break
return out
_KW_STOP = {
"of", "the", "a", "an", "and", "or", "to", "in", "on", "at", "by", "for",
"with", "is", "it", "as", "de", "la", "el", "los", "las",
}
def parse_keywords(raw, limit: int = 20) -> list[str]:
"""The rashida mirror pre-flattens keywords to a space-joined token string
(e.g. 'culture clash future space war'). Fall back to raw JSON if present."""
js = parse_tmdb_json(raw, limit=limit)
if js:
return js
if not isinstance(raw, str) or not raw.strip():
return []
seen, out = set(), []
for tok in raw.split():
t = tok.strip().lower()
if len(t) < 3 or t in _KW_STOP or t in seen:
continue
seen.add(t)
out.append(t)
if len(out) >= limit:
break
return out
def year_of(date_str) -> int | None:
m = re.search(r"(\d{4})", str(date_str))
if not m:
return None
y = int(m.group(1))
return y if 1878 <= y <= 2030 else None
def clean_ws(text) -> str:
return re.sub(r"\s+", " ", str(text or "")).strip()
def main() -> None:
ensure_raw()
# ---- 1. Base catalog: Pablinho -----------------------------------------
pab = pd.read_csv(RAW / "pablinho_movies.csv")
log(f"pablinho rows: {len(pab)}")
# ---- 2. TMDB-5000 enrichment table (keyed by normalized title) ---------
tm = pd.read_csv(RAW / "tmdb_5000.csv")
enrich: dict[str, dict] = {}
for _, r in tm.iterrows():
key = norm_key(r.get("title"))
if not key:
continue
# `cast` in this mirror is a space-joined string of top billed actors
# that cannot be reliably split into individual names, so it is kept as
# free text for retrieval only (never shown as structured facts).
cast_json = parse_tmdb_json(r.get("cast"), limit=6)
enrich[key] = {
"tmdb_id": int(r["id"]) if pd.notna(r.get("id")) else None,
"cast": cast_json, # populated only when clean JSON is available
"cast_text": "" if cast_json else clean_ws(r.get("cast")),
"director": clean_ws(r.get("director")) or None,
"keywords": parse_keywords(r.get("keywords"), limit=18),
"tagline": clean_ws(r.get("tagline")) or None,
"runtime": int(r["runtime"]) if pd.notna(r.get("runtime")) and r["runtime"] else None,
}
log(f"tmdb-5000 enrichment entries: {len(enrich)}")
# ---- 3. Repo JSON extra lexical tokens (keyed by normalized title) -----
repo_tokens: dict[str, list[str]] = {}
if REPO_JSON.exists():
repo = json.loads(REPO_JSON.read_text())
for m in repo:
key = norm_key(m.get("title"))
if key and isinstance(m.get("tags"), list):
repo_tokens[key] = m["tags"]
log(f"repo-json token bags: {len(repo_tokens)}")
# ---- Merge -------------------------------------------------------------
records: list[dict] = []
seen: dict[str, int] = {} # norm title -> index in records (dedupe)
used_slugs: dict[str, str] = {} # slug -> norm key that owns it
n_dir = n_kw = n_repo = 0
def unique_slug(title: str, year, key: str) -> str:
base = slugify(title)
cand = base
if cand in used_slugs and used_slugs[cand] != key:
cand = f"{base}-{year}" if year else f"{base}-x"
n = 2
while cand in used_slugs and used_slugs[cand] != key:
cand = f"{base}-{year}-{n}" if year else f"{base}-{n}"
n += 1
used_slugs[cand] = key
return cand
for _, row in pab.iterrows():
title = clean_ws(row.get("Title"))
if not title:
continue
key = norm_key(title)
overview = clean_ws(row.get("Overview"))
genres = [g.strip() for g in str(row.get("Genre") or "").split(",") if g.strip()]
year = year_of(row.get("Release_Date"))
try:
vote = round(float(row.get("Vote_Average")), 1)
except (TypeError, ValueError):
vote = None
try:
popularity = round(float(row.get("Popularity")), 3)
except (TypeError, ValueError):
popularity = 0.0
try:
vote_count = int(float(row.get("Vote_Count")))
except (TypeError, ValueError):
vote_count = 0
# De-dupe on normalized title, keeping the more-voted entry.
if key in seen:
prev = records[seen[key]]
if vote_count <= prev.get("vote_count", 0):
continue
records[seen[key]] = None # tombstone; rebuild below
enr = enrich.get(key, {})
if enr.get("director"):
n_dir += 1
if enr.get("keywords"):
n_kw += 1
extra = repo_tokens.get(key, [])
if extra:
n_repo += 1
cast = enr.get("cast", [])
cast_text = enr.get("cast_text", "")
director = enr.get("director")
keywords = enr.get("keywords", [])
tagline = enr.get("tagline")
rec = {
"id": unique_slug(title, year, key), # human-readable, URL-safe, unique
"tmdb_id": enr.get("tmdb_id"),
"title": title,
"year": year,
"genres": genres,
"overview": overview,
"tagline": tagline,
"poster_url": clean_ws(row.get("Poster_Url")) or None,
"vote_average": vote,
"vote_count": vote_count,
"popularity": popularity,
"language": clean_ws(row.get("Original_Language")) or None,
"cast": cast,
"director": director,
"keywords": keywords,
"runtime": enr.get("runtime"),
}
# ---- derived retrieval text ----
# semantic_text: natural language for the embedding model.
parts = [title]
if year:
parts.append(f"({year})")
if genres:
parts.append("Genres: " + ", ".join(genres) + ".")
if tagline:
parts.append(tagline)
if overview:
parts.append(overview)
if director:
parts.append(f"Directed by {director}.")
if cast:
parts.append("Starring " + ", ".join(cast) + ".")
elif cast_text:
parts.append("Starring " + cast_text + ".")
if keywords:
parts.append("Themes: " + ", ".join(keywords) + ".")
rec["semantic_text"] = clean_ws(" ".join(parts))
# lexical_text: keyword-dense bag for BM25. Genres/keywords/cast get
# repeated (they are high-signal facets) and the repo token bag is
# appended for extra plot vocabulary.
lex = [
title,
" ".join(genres * 2),
overview,
tagline or "",
" ".join(keywords),
" ".join(cast) or cast_text,
director or "",
" ".join(extra),
]
rec["lexical_text"] = clean_ws(" ".join(lex))
if key in seen:
records[seen[key]] = rec
else:
seen[key] = len(records)
records.append(rec)
records = [r for r in records if r is not None]
OUT.write_text(json.dumps(records, ensure_ascii=False))
log(f"WROTE {OUT} ({len(records)} movies, {OUT.stat().st_size // 1024} KB)")
log(f" with director:{n_dir} with keywords:{n_kw} with repo-tokens:{n_repo}")
with_year = sum(1 for r in records if r["year"])
with_poster = sum(1 for r in records if r["poster_url"])
log(f" with year:{with_year} with poster:{with_poster}")
decades = {}
for r in records:
if r["year"]:
decades[r["year"] // 10 * 10] = decades.get(r["year"] // 10 * 10, 0) + 1
log(" decades: " + ", ".join(f"{k}s:{v}" for k, v in sorted(decades.items())))
if __name__ == "__main__":
sys.exit(main())